summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-10 09:52:34 +0200
committerPaul Buetow <paul@buetow.org>2026-02-10 09:52:34 +0200
commit5bc2723325131e8432ad5a47d5e9cb245e9f0b28 (patch)
treec18bb5dafdcab806b3d8bb0912d083b28741d24c
parent17220d71f2af54f875ba1a86f489e5af6d7ad189 (diff)
Add tmux popup history storage and consolidate state files to XDG_STATE_HOMEv0.19.0
- Add StateDir() helper function respecting XDG_STATE_HOME (~/.local/state/hexai/) - Implement JSONL-based history storage for tmux popup submissions - New history.go with AppendHistory() and GetHistory() functions - Store timestamp, agent name, cwd, and submitted text - Comprehensive unit tests for history functionality - Integrate history append into tmux edit workflow after successful submission - Move logs from /tmp/ to persistent state directory: - hexai-lsp.log: ~/.local/state/hexai/hexai-lsp.log - hexai-tmux-edit.log: ~/.local/state/hexai/hexai-tmux-edit.log - Update README.md with File Locations section documenting XDG directories - Fix pre-existing test failures by isolating project config in unit tests - Panic on state directory creation failure instead of silent fallback All unit tests pass. Follows XDG Base Directory Specification for proper state file management with persistence across reboots. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
-rw-r--r--README.md17
-rw-r--r--cmd/hexai-lsp/main.go15
-rw-r--r--internal/appconfig/config.go21
-rw-r--r--internal/appconfig/config_test.go54
-rw-r--r--internal/tmuxedit/history.go131
-rw-r--r--internal/tmuxedit/history_test.go196
-rw-r--r--internal/tmuxedit/run.go27
-rw-r--r--internal/version.go2
8 files changed, 450 insertions, 13 deletions
diff --git a/README.md b/README.md
index 7011723..efb9b0c 100644
--- a/README.md
+++ b/README.md
@@ -31,3 +31,20 @@ It has got improved capabilities for Go code understanding (for example, create
## Tmux Status Line
See the [tmux integration guide](docs/tmux.md) for details on configuring the status line.
+
+## File Locations
+
+hexai follows the XDG Base Directory Specification:
+
+- **Configuration:** `~/.config/hexai/config.toml` (or `$XDG_CONFIG_HOME/hexai/`)
+ - Global settings, provider credentials, and preferences
+ - Project-specific: `.hexaiconfig.toml` at repository root
+- **Cache:** `~/.cache/hexai/` (or `$XDG_CACHE_HOME/hexai/`)
+ - `stats.json` - LLM usage tracking (regenerable)
+ - `stats.lock` - File lock for stats access
+- **State & Logs:** `~/.local/state/hexai/` (or `$XDG_STATE_HOME/hexai/`)
+ - `tmux-edit-history.jsonl` - History of text submitted via tmux popup
+ - `hexai-lsp.log` - LSP server debug logs
+ - `hexai-tmux-edit.log` - Tmux edit debug logs
+- **Temporary Files:** `/tmp/` (OS temp directory)
+ - `hexai-*.md` - Temporary editor workspaces (auto-deleted)
diff --git a/cmd/hexai-lsp/main.go b/cmd/hexai-lsp/main.go
index 3704cbf..828d0f8 100644
--- a/cmd/hexai-lsp/main.go
+++ b/cmd/hexai-lsp/main.go
@@ -14,7 +14,8 @@ import (
)
func main() {
- logPath := flag.String("log", "/tmp/hexai-lsp.log", "path to log file (optional)")
+ defaultLog := defaultLogPath()
+ logPath := flag.String("log", defaultLog, "path to log file (optional)")
defaultCfg := defaultConfigPath()
configPath := flag.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultCfg))
showVersion := flag.Bool("version", false, "print version and exit")
@@ -37,3 +38,15 @@ func defaultConfigPath() string {
}
return path
}
+
+// defaultLogPath returns the default LSP log file path in the state directory.
+// Falls back to /tmp if state directory cannot be determined.
+// defaultLogPath returns the default LSP log file path in the state directory.
+// Panics if state directory cannot be created.
+func defaultLogPath() string {
+ stateDir, err := appconfig.StateDir()
+ if err != nil {
+ panic(fmt.Sprintf("cannot create state directory: %v", err))
+ }
+ return fmt.Sprintf("%s/hexai-lsp.log", stateDir)
+}
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go
index 63b5ea5..f8c1827 100644
--- a/internal/appconfig/config.go
+++ b/internal/appconfig/config.go
@@ -1270,6 +1270,27 @@ func ConfigPath() (string, error) {
return configPath, nil
}
+// StateDir returns the XDG state directory for hexai (~/.local/state/hexai by default).
+// Creates the directory if it doesn't exist. This is used for persistent state data
+// like logs and history that should survive reboots.
+func StateDir() (string, error) {
+ stateHome := os.Getenv("XDG_STATE_HOME")
+ if stateHome == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("cannot find user home directory: %v", err)
+ }
+ stateHome = filepath.Join(home, ".local", "state")
+ }
+
+ stateDir := filepath.Join(stateHome, "hexai")
+ if err := os.MkdirAll(stateDir, 0o755); err != nil {
+ return "", fmt.Errorf("cannot create state directory: %v", err)
+ }
+
+ return stateDir, nil
+}
+
// ProjectConfigFilename is the name of the per-project config file placed at a git repo root.
const ProjectConfigFilename = ".hexaiconfig.toml"
diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go
index 4a1403f..ed7254c 100644
--- a/internal/appconfig/config_test.go
+++ b/internal/appconfig/config_test.go
@@ -56,9 +56,10 @@ func TestLoad_Defaults_NoLogger(t *testing.T) {
func TestLoad_Defaults_WithLogger_NoFile_NoEnv(t *testing.T) {
clearHexaiEnv(t)
- t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
logger := newLogger()
- cfg := Load(logger)
+ cfg := LoadWithOptions(logger, LoadOptions{ProjectRoot: dir})
def := newDefaultConfig()
if cfg.MaxTokens != def.MaxTokens || cfg.ContextMode != def.ContextMode || cfg.ContextWindowLines != def.ContextWindowLines {
t.Fatalf("expected defaults; got %+v want %+v", cfg, def)
@@ -184,7 +185,7 @@ temperature = 0.0
withEnv(t, "HEXAI_PROVIDER_CLI", "ollama")
logger := newLogger()
- cfg := Load(logger)
+ cfg := LoadWithOptions(logger, LoadOptions{ProjectRoot: dir})
// Check overrides
if cfg.MaxTokens != 321 || cfg.ContextMode != "always-full" || cfg.ContextWindowLines != 77 || cfg.MaxContextTokens != 888 {
@@ -253,7 +254,7 @@ temperature = 0.0
} {
t.Setenv(k, "")
}
- cfg2 := Load(logger)
+ cfg2 := LoadWithOptions(logger, LoadOptions{ProjectRoot: dir})
if cfg2.MaxTokens != 123 || cfg2.ContextMode != "file-on-new-func" || cfg2.ContextWindowLines != 50 || cfg2.MaxContextTokens != 999 || cfg2.LogPreviewLimit != 0 {
t.Fatalf("file merge not applied: %+v", cfg2)
}
@@ -313,6 +314,39 @@ func TestGetConfigPath_XDG(t *testing.T) {
}
}
+func TestStateDir_XDG(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("XDG_STATE_HOME", dir)
+ stateDir, err := StateDir()
+ if err != nil {
+ t.Fatalf("StateDir: %v", err)
+ }
+ expected := filepath.Join(dir, "hexai")
+ if stateDir != expected {
+ t.Fatalf("expected %q, got %q", expected, stateDir)
+ }
+ // Verify directory was created
+ if _, err := os.Stat(stateDir); err != nil {
+ t.Fatalf("state directory not created: %v", err)
+ }
+}
+
+func TestStateDir_Default(t *testing.T) {
+ t.Setenv("XDG_STATE_HOME", "")
+ stateDir, err := StateDir()
+ if err != nil {
+ t.Fatalf("StateDir: %v", err)
+ }
+ // Should default to ~/.local/state/hexai
+ if !strings.Contains(stateDir, ".local/state/hexai") {
+ t.Fatalf("expected path to contain .local/state/hexai, got %q", stateDir)
+ }
+ // Verify directory was created
+ if _, err := os.Stat(stateDir); err != nil {
+ t.Fatalf("state directory not created: %v", err)
+ }
+}
+
func TestLoadFromFile_InvalidTOML(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
@@ -379,7 +413,7 @@ temperature = 0.0
// Ensure no env override interferes with manual_invoke_min_prefix in this test
t.Setenv("HEXAI_MANUAL_INVOKE_MIN_PREFIX", "")
logger := newLogger()
- cfg := Load(logger)
+ cfg := LoadWithOptions(logger, LoadOptions{ProjectRoot: dir})
if cfg.MaxTokens != 111 || cfg.ContextMode != "window" || cfg.ContextWindowLines != 42 || cfg.MaxContextTokens != 777 {
t.Fatalf("sectioned basics wrong: %+v", cfg)
@@ -809,7 +843,7 @@ gitignore = false
extra_patterns = ["*.min.js", "dist/**"]
lsp_notify_ignored = false
`)
- cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath})
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir})
if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore {
t.Error("expected IgnoreGitignore false from file")
}
@@ -834,7 +868,7 @@ lsp_notify_ignored = true
withEnv(t, "HEXAI_IGNORE_GITIGNORE", "false")
withEnv(t, "HEXAI_IGNORE_LSP_NOTIFY", "0")
withEnv(t, "HEXAI_IGNORE_EXTRA_PATTERNS", "*.bak,*.tmp")
- cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath})
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir})
if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore {
t.Error("expected IgnoreGitignore false from env override")
}
@@ -884,7 +918,7 @@ func TestIgnoreConfig_DisableGitignore(t *testing.T) {
[ignore]
gitignore = false
`)
- cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath})
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir})
if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore {
t.Error("expected IgnoreGitignore false")
}
@@ -925,7 +959,7 @@ clear_keys = "C-u"
newline_keys = "S-Enter"
submit_keys = "Enter"
`)
- cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath})
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir})
if cfg.TmuxEditPopupWidth != "90%" {
t.Errorf("PopupWidth = %q, want 90%%", cfg.TmuxEditPopupWidth)
}
@@ -986,7 +1020,7 @@ func TestTmuxEditConfig_SkipsEmptyName(t *testing.T) {
name = ""
display_name = "Empty"
`)
- cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath})
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir})
if len(cfg.TmuxEditAgents) != 0 {
t.Errorf("got %d agents, want 0 (empty name should be skipped)", len(cfg.TmuxEditAgents))
}
diff --git a/internal/tmuxedit/history.go b/internal/tmuxedit/history.go
new file mode 100644
index 0000000..a79672b
--- /dev/null
+++ b/internal/tmuxedit/history.go
@@ -0,0 +1,131 @@
+// Summary: JSONL-based history storage for tmux popup submissions
+package tmuxedit
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+)
+
+// HistoryEntry represents a single submission to the AI agent via tmux popup.
+// Stored in JSONL format (one JSON object per line) for easy appending and reading.
+type HistoryEntry struct {
+ Timestamp string `json:"timestamp"` // RFC3339 format
+ Agent string `json:"agent"` // AI agent name (e.g., "claude", "aider")
+ Cwd string `json:"cwd"` // Current working directory at submission time
+ Text string `json:"text"` // The submitted text
+}
+
+// AppendHistory appends a new history entry to the history file.
+// Uses atomic write pattern (write to temp file, then rename) for safety.
+func AppendHistory(text, agent, cwd string) error {
+ stateDir, err := appconfig.StateDir()
+ if err != nil {
+ return fmt.Errorf("cannot get state directory: %w", err)
+ }
+
+ historyPath := filepath.Join(stateDir, "tmux-edit-history.jsonl")
+
+ // Create entry with current timestamp
+ entry := HistoryEntry{
+ Timestamp: time.Now().Format(time.RFC3339),
+ Agent: agent,
+ Cwd: cwd,
+ Text: text,
+ }
+
+ // Marshal to JSON
+ data, err := json.Marshal(entry)
+ if err != nil {
+ return fmt.Errorf("cannot marshal history entry: %w", err)
+ }
+
+ // Append newline for JSONL format
+ data = append(data, '\n')
+
+ // Open file in append mode, create if doesn't exist
+ f, err := os.OpenFile(historyPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return fmt.Errorf("cannot open history file: %w", err)
+ }
+ defer f.Close()
+
+ // Write entry
+ if _, err := f.Write(data); err != nil {
+ return fmt.Errorf("cannot write history entry: %w", err)
+ }
+
+ return nil
+}
+
+// GetHistory retrieves the most recent history entries (up to limit).
+// Returns entries in chronological order (oldest first).
+// If limit <= 0, returns all entries.
+func GetHistory(limit int) ([]HistoryEntry, error) {
+ stateDir, err := appconfig.StateDir()
+ if err != nil {
+ return nil, fmt.Errorf("cannot get state directory: %w", err)
+ }
+
+ historyPath := filepath.Join(stateDir, "tmux-edit-history.jsonl")
+
+ // Read entire file
+ data, err := os.ReadFile(historyPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return []HistoryEntry{}, nil // Empty history is not an error
+ }
+ return nil, fmt.Errorf("cannot read history file: %w", err)
+ }
+
+ // Parse JSONL line by line
+ var entries []HistoryEntry
+ lines := splitLines(data)
+ for i, line := range lines {
+ if len(line) == 0 {
+ continue // Skip empty lines
+ }
+
+ var entry HistoryEntry
+ if err := json.Unmarshal(line, &entry); err != nil {
+ // Log error but continue parsing (don't fail entire history on one bad line)
+ fmt.Fprintf(os.Stderr, "warning: cannot parse history entry at line %d: %v\n", i+1, err)
+ continue
+ }
+ entries = append(entries, entry)
+ }
+
+ // Apply limit if specified
+ if limit > 0 && len(entries) > limit {
+ // Return the most recent entries
+ entries = entries[len(entries)-limit:]
+ }
+
+ return entries, nil
+}
+
+// splitLines splits data into lines (handles both \n and \r\n)
+func splitLines(data []byte) [][]byte {
+ var lines [][]byte
+ start := 0
+ for i := 0; i < len(data); i++ {
+ if data[i] == '\n' {
+ // Include everything before the newline (excluding \r if present)
+ end := i
+ if end > start && data[end-1] == '\r' {
+ end--
+ }
+ lines = append(lines, data[start:end])
+ start = i + 1
+ }
+ }
+ // Handle last line if it doesn't end with newline
+ if start < len(data) {
+ lines = append(lines, data[start:])
+ }
+ return lines
+}
diff --git a/internal/tmuxedit/history_test.go b/internal/tmuxedit/history_test.go
new file mode 100644
index 0000000..8a3d8af
--- /dev/null
+++ b/internal/tmuxedit/history_test.go
@@ -0,0 +1,196 @@
+package tmuxedit
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestAppendHistory(t *testing.T) {
+ // Create temp directory for test
+ tmpDir := t.TempDir()
+ t.Setenv("XDG_STATE_HOME", tmpDir)
+
+ text := "test prompt text"
+ agent := "claude"
+ cwd := "/tmp/test"
+
+ // Append first entry
+ if err := AppendHistory(text, agent, cwd); err != nil {
+ t.Fatalf("AppendHistory failed: %v", err)
+ }
+
+ // Verify file was created
+ historyPath := filepath.Join(tmpDir, "hexai", "tmux-edit-history.jsonl")
+ if _, err := os.Stat(historyPath); err != nil {
+ t.Fatalf("history file not created: %v", err)
+ }
+
+ // Read and verify content
+ data, err := os.ReadFile(historyPath)
+ if err != nil {
+ t.Fatalf("cannot read history file: %v", err)
+ }
+
+ content := string(data)
+ if content == "" {
+ t.Fatal("history file is empty")
+ }
+
+ // Verify it contains expected fields
+ if !containsString(content, "test prompt text") {
+ t.Error("history doesn't contain text")
+ }
+ if !containsString(content, "claude") {
+ t.Error("history doesn't contain agent")
+ }
+ if !containsString(content, "/tmp/test") {
+ t.Error("history doesn't contain cwd")
+ }
+}
+
+func TestGetHistory(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Setenv("XDG_STATE_HOME", tmpDir)
+
+ // Append multiple entries
+ entries := []struct {
+ text string
+ agent string
+ cwd string
+ }{
+ {"first prompt", "claude", "/home/user"},
+ {"second prompt", "aider", "/tmp/project"},
+ {"third prompt", "claude", "/var/tmp"},
+ }
+
+ for _, e := range entries {
+ if err := AppendHistory(e.text, e.agent, e.cwd); err != nil {
+ t.Fatalf("AppendHistory failed: %v", err)
+ }
+ time.Sleep(10 * time.Millisecond) // Ensure different timestamps
+ }
+
+ // Get all history
+ history, err := GetHistory(0)
+ if err != nil {
+ t.Fatalf("GetHistory failed: %v", err)
+ }
+
+ if len(history) != 3 {
+ t.Fatalf("expected 3 entries, got %d", len(history))
+ }
+
+ // Verify first entry
+ if history[0].Text != "first prompt" {
+ t.Errorf("first entry text: got %q, want %q", history[0].Text, "first prompt")
+ }
+ if history[0].Agent != "claude" {
+ t.Errorf("first entry agent: got %q, want %q", history[0].Agent, "claude")
+ }
+
+ // Test limit
+ limited, err := GetHistory(2)
+ if err != nil {
+ t.Fatalf("GetHistory with limit failed: %v", err)
+ }
+ if len(limited) != 2 {
+ t.Fatalf("expected 2 entries with limit, got %d", len(limited))
+ }
+
+ // Should get the most recent 2
+ if limited[0].Text != "second prompt" {
+ t.Errorf("limited[0] should be second entry")
+ }
+ if limited[1].Text != "third prompt" {
+ t.Errorf("limited[1] should be third entry")
+ }
+}
+
+func TestGetHistory_EmptyFile(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Setenv("XDG_STATE_HOME", tmpDir)
+
+ // Get history when file doesn't exist
+ history, err := GetHistory(0)
+ if err != nil {
+ t.Fatalf("GetHistory should not error on missing file: %v", err)
+ }
+
+ if len(history) != 0 {
+ t.Errorf("expected empty history, got %d entries", len(history))
+ }
+}
+
+func TestSplitLines(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want []string
+ }{
+ {
+ name: "unix newlines",
+ input: "line1\nline2\nline3",
+ want: []string{"line1", "line2", "line3"},
+ },
+ {
+ name: "windows newlines",
+ input: "line1\r\nline2\r\nline3",
+ want: []string{"line1", "line2", "line3"},
+ },
+ {
+ name: "mixed newlines",
+ input: "line1\nline2\r\nline3",
+ want: []string{"line1", "line2", "line3"},
+ },
+ {
+ name: "trailing newline",
+ input: "line1\nline2\n",
+ want: []string{"line1", "line2"},
+ },
+ {
+ name: "empty string",
+ input: "",
+ want: []string{},
+ },
+ {
+ name: "single line no newline",
+ input: "single",
+ want: []string{"single"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := splitLines([]byte(tt.input))
+ gotStr := make([]string, len(got))
+ for i, b := range got {
+ gotStr[i] = string(b)
+ }
+
+ if len(gotStr) != len(tt.want) {
+ t.Fatalf("got %d lines, want %d", len(gotStr), len(tt.want))
+ }
+
+ for i := range gotStr {
+ if gotStr[i] != tt.want[i] {
+ t.Errorf("line %d: got %q, want %q", i, gotStr[i], tt.want[i])
+ }
+ }
+ })
+ }
+}
+
+func containsString(s, substr string) bool {
+ return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && findSubstring(s, substr))
+}
+
+func findSubstring(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/tmuxedit/run.go b/internal/tmuxedit/run.go
index 8c98ded..a156e5f 100644
--- a/internal/tmuxedit/run.go
+++ b/internal/tmuxedit/run.go
@@ -5,6 +5,7 @@ import (
"log"
"os"
"os/exec"
+ "path/filepath"
"strings"
"codeberg.org/snonux/hexai/internal/appconfig"
@@ -111,8 +112,18 @@ func loadConfig(configPath string) appconfig.App {
var debugLog *log.Logger
// initDebugLog creates a debug log file at /tmp/hexai-tmux-edit.log.
+// initDebugLog creates a debug log file in the state directory (~/.local/state/hexai/hexai-tmux-edit.log).
+// Falls back to /tmp if state directory cannot be created.
+// initDebugLog creates a debug log file in the state directory (~/.local/state/hexai/hexai-tmux-edit.log).
+// Panics if the state directory cannot be created.
func initDebugLog() {
- f, err := os.OpenFile("/tmp/hexai-tmux-edit.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
+ stateDir, err := appconfig.StateDir()
+ if err != nil {
+ panic(fmt.Sprintf("cannot create state directory: %v", err))
+ }
+
+ logPath := filepath.Join(stateDir, "hexai-tmux-edit.log")
+ f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return
}
@@ -181,6 +192,20 @@ func runWithConfig(opts Options, cfg appconfig.App) error {
dbg("SendText error: %v", err)
return err
}
+
+ // Append to history (log errors but don't fail the operation)
+ cwd, err := os.Getwd()
+ if err != nil {
+ cwd = "unknown"
+ dbg("os.Getwd error (using 'unknown'): %v", err)
+ }
+ if err := AppendHistory(text, agent.Name(), cwd); err != nil {
+ dbg("AppendHistory error: %v", err)
+ // Non-fatal: log but continue
+ } else {
+ dbg("appended to history: agent=%q cwd=%q len=%d", agent.Name(), cwd, len(text))
+ }
+
dbg("=== done ===")
return nil
}
diff --git a/internal/version.go b/internal/version.go
index 027fd1f..72bb7c5 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -1,4 +1,4 @@
// Summary: Hexai semantic version identifier used by CLI and LSP binaries.
package internal
-const Version = "0.18.3"
+const Version = "0.19.0"