diff --git a/frontend/src/components/AgentMonitor.svelte b/frontend/src/components/AgentMonitor.svelte index 7966cb3..fe38671 100644 --- a/frontend/src/components/AgentMonitor.svelte +++ b/frontend/src/components/AgentMonitor.svelte @@ -23,9 +23,17 @@ let knownProjects = $state([]); let modelsByProvider = $state({}); let allModels = $state([]); + let runnableTools = $state([]); let pollTimer; let logTimer; + async function loadTools() { + try { + const r = await fetch("/api/tools"); + if (r.ok) runnableTools = await r.json(); + } catch { /* ignored */ } + } + async function loadSessionData() { try { const sessions = await fetchSessions(); @@ -221,6 +229,7 @@ onMount(() => { loadAgents(); loadSessionData(); + loadTools(); pollTimer = setInterval(() => { loadAgents(); if (selectedAgent?.status === "running") { @@ -535,10 +544,12 @@
diff --git a/frontend/src/components/ChatView.svelte b/frontend/src/components/ChatView.svelte index 0542a7b..53b226e 100644 --- a/frontend/src/components/ChatView.svelte +++ b/frontend/src/components/ChatView.svelte @@ -15,8 +15,16 @@ let knownProjects = $state([]); let modelsByProvider = $state({}); let allModels = $state([]); + let runnableTools = $state([]); let messagesEnd = $state(); + async function loadTools() { + try { + const r = await fetch("/api/tools"); + if (r.ok) runnableTools = await r.json(); + } catch { /* ignored */ } + } + async function loadSessionData() { try { const r = await fetch("/api/sessions"); @@ -180,6 +188,7 @@ onMount(() => { loadChats(); loadSessionData(); + loadTools(); }); @@ -215,10 +224,12 @@
@@ -335,10 +346,9 @@
{ form.model = ''; }}> - - - - + {#each runnableTools.filter(t => t.installed) as t (t.id)} + + {/each} + {#if !runnableTools.length} + + {/if}
diff --git a/frontend/src/components/ToolInventory.svelte b/frontend/src/components/ToolInventory.svelte index a736ce8..4bbe0af 100644 --- a/frontend/src/components/ToolInventory.svelte +++ b/frontend/src/components/ToolInventory.svelte @@ -367,6 +367,11 @@ {tool.name} Not found
+ {#if tool.installHint} +
+
Install{tool.installHint}
+
+ {/if}
{/each}
diff --git a/internal/inventory/inventory.go b/internal/inventory/inventory.go index 3d7dd42..f55aaff 100644 --- a/internal/inventory/inventory.go +++ b/internal/inventory/inventory.go @@ -3,13 +3,14 @@ package inventory import "time" type ToolInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Installed bool `json:"installed"` - Version string `json:"version,omitempty"` - BinaryPath string `json:"binaryPath,omitempty"` - ConfigDir string `json:"configDir,omitempty"` - DataDir string `json:"dataDir,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Installed bool `json:"installed"` + Version string `json:"version,omitempty"` + BinaryPath string `json:"binaryPath,omitempty"` + ConfigDir string `json:"configDir,omitempty"` + DataDir string `json:"dataDir,omitempty"` + InstallHint string `json:"installHint,omitempty"` } type ModelUsage struct { diff --git a/internal/inventory/tools.go b/internal/inventory/tools.go index 3a62c9c..2bc0505 100644 --- a/internal/inventory/tools.go +++ b/internal/inventory/tools.go @@ -11,12 +11,13 @@ import ( ) type toolDef struct { - ID string - Name string - Binary string - ConfigDir string // relative to home - DataDir string // relative to home + ID string + Name string + Binary string + ConfigDir string // relative to home + DataDir string // relative to home VersionFlag string + InstallHint string } var toolDefs = []toolDef{ @@ -24,26 +25,31 @@ var toolDefs = []toolDef{ ID: "claude", Name: "Claude Code", Binary: "claude", ConfigDir: ".claude", DataDir: ".claude/projects", VersionFlag: "--version", + InstallHint: "npm install -g @anthropic-ai/claude-code", }, { ID: "codex", Name: "Codex CLI", Binary: "codex", ConfigDir: ".codex", DataDir: ".codex", VersionFlag: "--version", + InstallHint: "npm install -g @openai/codex", }, { ID: "copilot", Name: "Copilot CLI", Binary: "github-copilot", ConfigDir: ".copilot", DataDir: ".copilot/session-state", VersionFlag: "--version", + InstallHint: "npm install -g @github/copilot", }, { ID: "gemini", Name: "Gemini CLI", Binary: "gemini", ConfigDir: ".gemini", DataDir: ".gemini/tmp", VersionFlag: "--version", + InstallHint: "npm install -g @google/gemini-cli", }, { ID: "opencode", Name: "OpenCode", Binary: "opencode", ConfigDir: ".config/opencode", DataDir: ".local/share/opencode", VersionFlag: "--version", + InstallHint: "go install github.com/opencode-ai/opencode@latest", }, { ID: "cursor-cli", Name: "Cursor Agent (CLI)", Binary: "agent", @@ -54,35 +60,48 @@ var toolDefs = []toolDef{ ID: "cursor-ide", Name: "Cursor (IDE)", Binary: "cursor", ConfigDir: ".config/Cursor", DataDir: ".config/Cursor/User/globalStorage", VersionFlag: "--version", + InstallHint: "Download from cursor.com", }, { ID: "antigravity", Name: "Antigravity", Binary: "antigravity", ConfigDir: ".gemini/antigravity", DataDir: ".gemini/antigravity/conversations", VersionFlag: "--version", + InstallHint: "Download from developers.google.com/gemini", + }, + { + ID: "agy", Name: "Agy CLI", Binary: "agy", + ConfigDir: ".gemini/antigravity-cli", DataDir: ".gemini/antigravity-cli/conversations", + VersionFlag: "--version", + InstallHint: "npm install -g agy", }, { ID: "aider", Name: "Aider", Binary: "aider", VersionFlag: "--version", + InstallHint: "pip install aider-chat", }, { ID: "goose", Name: "Goose", Binary: "goose", ConfigDir: ".config/goose", VersionFlag: "--version", + InstallHint: "Download from github.com/block/goose/releases", }, { ID: "amp", Name: "Amp", Binary: "amp", ConfigDir: ".config/amp", VersionFlag: "--version", + InstallHint: "npm install -g @sourcegraph/amp", }, { ID: "devin", Name: "Devin", Binary: "devin", ConfigDir: ".config/devin", VersionFlag: "--version", + InstallHint: "Download from devin.ai", }, { ID: "amazonq", Name: "Amazon Q Developer", Binary: "q", ConfigDir: ".aws/amazonq", VersionFlag: "--version", + InstallHint: "Download from aws.amazon.com/q/developer", }, { ID: "continue", Name: "Continue.dev", Binary: "continue", @@ -91,9 +110,11 @@ var toolDefs = []toolDef{ { ID: "windsurf", Name: "Windsurf", Binary: "windsurf", ConfigDir: ".codeium/windsurf", + InstallHint: "Download from windsurf.com", }, { ID: "trae", Name: "Trae", Binary: "trae", + InstallHint: "Download from trae.ai", }, { ID: "kilo", Name: "Kilo Code", Binary: "kilo", @@ -105,6 +126,15 @@ var toolDefs = []toolDef{ }, } +func InstallHint(binary string) string { + for _, def := range toolDefs { + if def.Binary == binary { + return def.InstallHint + } + } + return "" +} + func init() { if runtime.GOOS == "darwin" { for i := range toolDefs { @@ -122,8 +152,9 @@ func scanTools() []ToolInfo { for _, def := range toolDefs { t := ToolInfo{ - ID: def.ID, - Name: def.Name, + ID: def.ID, + Name: def.Name, + InstallHint: def.InstallHint, } if bin, err := exec.LookPath(def.Binary); err == nil { diff --git a/internal/launcher/launcher.go b/internal/launcher/launcher.go index 4936bcb..92e787e 100644 --- a/internal/launcher/launcher.go +++ b/internal/launcher/launcher.go @@ -9,6 +9,7 @@ import ( "syscall" "vibecockpit/internal/config" + "vibecockpit/internal/inventory" "vibecockpit/internal/provider" ) @@ -25,6 +26,13 @@ func LaunchNew(cfg *config.Config, prov provider.Provider, dir string) error { func launch(cfg *config.Config, provName, bin string, args []string, dir string) error { binPath := resolveBinary(cfg, provName, bin) if binPath == "" { + hint := inventory.InstallHint(bin) + if hint != "" { + return fmt.Errorf( + "could not find %q in PATH.\n\n"+ + "Install it with:\n\n %s\n\n"+ + "Or set a custom path in Settings > Provider binary paths", bin, hint) + } configPath := config.Path() return fmt.Errorf( "could not find %q in PATH. Configure its location in settings:\n\n"+ @@ -104,10 +112,20 @@ func envForLaunch(cfg *config.Config, binPath string) []string { return env } +func shellQuote(s string) string { + if s == "" { + return "''" + } + if !strings.ContainsAny(s, " \t\n\"'\\$`!#&|;(){}[]<>?*~") { + return s + } + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} + func launchTerminal(cfg *config.Config, terminal, binPath string, args []string, dir string) error { - fullCmd := binPath + fullCmd := shellQuote(binPath) for _, a := range args { - fullCmd += " " + a + fullCmd += " " + shellQuote(a) } var cmd *exec.Cmd @@ -128,17 +146,17 @@ func launchTerminal(cfg *config.Config, terminal, binPath string, args []string, case "konsole": cmd = exec.Command("konsole", append([]string{"--workdir", dir, "-e"}, append([]string{binPath}, args...)...)...) case "xterm": - cmd = exec.Command("xterm", "-e", "cd "+dir+" && "+fullCmd) + cmd = exec.Command("xterm", "-e", "cd "+shellQuote(dir)+" && "+fullCmd) case "iterm2": script := fmt.Sprintf(`tell application "iTerm2" create window with default profile command "cd %s && %s" - end tell`, dir, fullCmd) + end tell`, shellQuote(dir), fullCmd) cmd = exec.Command("osascript", "-e", script) case "terminal.app": script := fmt.Sprintf(`tell application "Terminal" do script "cd %s && %s" activate - end tell`, dir, fullCmd) + end tell`, shellQuote(dir), fullCmd) cmd = exec.Command("osascript", "-e", script) default: return fmt.Errorf("unsupported terminal: %s", terminal) @@ -151,21 +169,16 @@ func launchTerminal(cfg *config.Config, terminal, binPath string, args []string, } func launchCustom(_ *config.Config, tmpl, binPath string, args []string, dir string) error { - fullCmd := binPath + fullCmd := shellQuote(binPath) for _, a := range args { - fullCmd += " " + a + fullCmd += " " + shellQuote(a) } - expanded := strings.ReplaceAll(tmpl, "{dir}", dir) + expanded := strings.ReplaceAll(tmpl, "{dir}", shellQuote(dir)) expanded = strings.ReplaceAll(expanded, "{cmd}", fullCmd) - expanded = strings.ReplaceAll(expanded, "{bin}", binPath) - - parts := strings.Fields(expanded) - if len(parts) == 0 { - return fmt.Errorf("empty custom terminal command") - } + expanded = strings.ReplaceAll(expanded, "{bin}", shellQuote(binPath)) - cmd := exec.Command(parts[0], parts[1:]...) + cmd := exec.Command("sh", "-c", expanded) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Start() diff --git a/internal/launcher/shellquote_test.go b/internal/launcher/shellquote_test.go new file mode 100644 index 0000000..0772a88 --- /dev/null +++ b/internal/launcher/shellquote_test.go @@ -0,0 +1,56 @@ +package launcher + +import "testing" + +func TestShellQuote_NoSpecialChars(t *testing.T) { + got := shellQuote("hello") + if got != "hello" { + t.Errorf("got %q, want %q", got, "hello") + } +} + +func TestShellQuote_Empty(t *testing.T) { + got := shellQuote("") + if got != "''" { + t.Errorf("got %q, want %q", got, "''") + } +} + +func TestShellQuote_WithSpaces(t *testing.T) { + got := shellQuote("/home/user/my project") + want := "'/home/user/my project'" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestShellQuote_WithSingleQuotes(t *testing.T) { + got := shellQuote("it's here") + want := "'it'\"'\"'s here'" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestShellQuote_PathWithParens(t *testing.T) { + got := shellQuote("/home/user/project (copy)") + want := "'/home/user/project (copy)'" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestShellQuote_SimplePath(t *testing.T) { + got := shellQuote("/home/user/project") + if got != "/home/user/project" { + t.Errorf("simple path should not be quoted, got %q", got) + } +} + +func TestShellQuote_Ampersand(t *testing.T) { + got := shellQuote("foo&bar") + want := "'foo&bar'" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/internal/memory/extract.go b/internal/memory/extract.go index 45ffc47..cbd26a3 100644 --- a/internal/memory/extract.go +++ b/internal/memory/extract.go @@ -60,7 +60,8 @@ func ExtractMessages(dataPath string) ([]Message, error) { // extractJSONLMessages handles the Claude Code transcript format. Each // line is a JSON object with `type` and `message.content` (string or -// content blocks). +// content blocks). It also auto-detects the agy/Antigravity transcript +// format (keyed on `"source"` + `"step_index"`) and delegates accordingly. func extractJSONLMessages(path string) ([]Message, error) { f, err := os.Open(path) if err != nil { @@ -68,18 +69,31 @@ func extractJSONLMessages(path string) ([]Message, error) { } defer f.Close() - var msgs []Message - idx := 0 scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024) - for scanner.Scan() { + if !scanner.Scan() { + return nil, nil + } + first := scanner.Bytes() + + if bytes.Contains(first, []byte(`"source"`)) && bytes.Contains(first, []byte(`"step_index"`)) { + return extractAgyMessages(first, scanner), nil + } + + return extractClaudeMessages(first, scanner), nil +} + +func extractClaudeMessages(firstLine []byte, scanner *bufio.Scanner) []Message { + var msgs []Message + idx := 0 + + process := func(line []byte) { if len(msgs) >= MaxMessagesPerSession { - break + return } - line := scanner.Bytes() if !bytes.Contains(line, []byte(`"message"`)) { - continue + return } var entry struct { Type string `json:"type"` @@ -90,14 +104,14 @@ func extractJSONLMessages(path string) ([]Message, error) { } `json:"message"` } if err := json.Unmarshal(line, &entry); err != nil { - continue + return } if entry.Type != "user" && entry.Type != "assistant" { - continue + return } text := decodeContent(entry.Message.Content) if text == "" { - continue + return } if len(text) > MaxMessageBytes { text = text[:MaxMessageBytes] @@ -112,7 +126,77 @@ func extractJSONLMessages(path string) ([]Message, error) { }) idx++ } - return msgs, nil + + process(firstLine) + for scanner.Scan() { + process(scanner.Bytes()) + } + return msgs +} + +// extractAgyMessages handles the agy/Antigravity CLI transcript format. +// Each line has source (USER_EXPLICIT, MODEL, SYSTEM), type, and content. +func extractAgyMessages(firstLine []byte, scanner *bufio.Scanner) []Message { + var msgs []Message + idx := 0 + + process := func(line []byte) { + if len(msgs) >= MaxMessagesPerSession { + return + } + var entry struct { + Source string `json:"source"` + Type string `json:"type"` + Content string `json:"content"` + Thinking string `json:"thinking"` + CreatedAt string `json:"created_at"` + } + if err := json.Unmarshal(line, &entry); err != nil { + return + } + var role, text string + switch { + case entry.Source == "USER_EXPLICIT" && entry.Type == "USER_INPUT": + role = "user" + text = entry.Content + if i := strings.Index(text, ""); i >= 0 { + text = text[i+len(""):] + } + if i := strings.Index(text, ""); i >= 0 { + text = text[:i] + } + case entry.Source == "MODEL" && entry.Type == "PLANNER_RESPONSE": + role = "assistant" + text = entry.Content + if text == "" { + text = entry.Thinking + } + default: + return + } + text = strings.TrimSpace(text) + if text == "" { + return + } + if len(text) > MaxMessageBytes { + text = text[:MaxMessageBytes] + } + text = sanitize.Text(text) + ts, _ := time.Parse(time.RFC3339, entry.CreatedAt) + msgs = append(msgs, Message{ + Idx: idx, + Role: role, + Timestamp: ts, + Content: text, + }) + idx++ + } + + process(firstLine) + for scanner.Scan() { + process(scanner.Bytes()) + } + return msgs } // decodeContent handles both shapes Claude uses for message.content: diff --git a/internal/memory/extract_agy_test.go b/internal/memory/extract_agy_test.go new file mode 100644 index 0000000..1528f7f --- /dev/null +++ b/internal/memory/extract_agy_test.go @@ -0,0 +1,138 @@ +package memory + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestExtractMessages_AgyFormat(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "transcript.jsonl") + + lines := []map[string]interface{}{ + {"source": "USER_EXPLICIT", "type": "USER_INPUT", "content": "hello world", "step_index": 0, "created_at": "2026-05-01T12:00:00Z"}, + {"source": "MODEL", "type": "PLANNER_RESPONSE", "content": "Hi there!", "step_index": 1, "created_at": "2026-05-01T12:00:01Z"}, + {"source": "SYSTEM", "type": "TOOL_RESULT", "content": "some tool output", "step_index": 2}, + {"source": "USER_EXPLICIT", "type": "USER_INPUT", "content": "follow up", "step_index": 3, "created_at": "2026-05-01T12:00:02Z"}, + {"source": "MODEL", "type": "PLANNER_RESPONSE", "content": "", "thinking": "let me think about this", "step_index": 4}, + } + + var content []byte + for _, l := range lines { + b, _ := json.Marshal(l) + content = append(content, b...) + content = append(content, '\n') + } + os.WriteFile(path, content, 0644) + + msgs, err := ExtractMessages(path) + if err != nil { + t.Fatalf("ExtractMessages: %v", err) + } + + if len(msgs) != 4 { + t.Fatalf("expected 4 messages, got %d", len(msgs)) + } + + if msgs[0].Role != "user" || msgs[0].Content != "hello world" { + t.Errorf("msg[0]: role=%q content=%q", msgs[0].Role, msgs[0].Content) + } + + if msgs[1].Role != "assistant" || msgs[1].Content != "Hi there!" { + t.Errorf("msg[1]: role=%q content=%q", msgs[1].Role, msgs[1].Content) + } + + if msgs[2].Role != "user" || msgs[2].Content != "follow up" { + t.Errorf("msg[2]: role=%q content=%q", msgs[2].Role, msgs[2].Content) + } + + if msgs[3].Role != "assistant" || msgs[3].Content != "let me think about this" { + t.Errorf("msg[3]: role=%q content=%q (should fallback to thinking)", msgs[3].Role, msgs[3].Content) + } +} + +func TestExtractMessages_AgyStripsXMLTags(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "transcript.jsonl") + + line := map[string]interface{}{ + "source": "USER_EXPLICIT", "type": "USER_INPUT", + "content": "prefixactual promptsuffix", + "step_index": 0, + } + b, _ := json.Marshal(line) + b = append(b, '\n') + os.WriteFile(path, b, 0644) + + msgs, err := ExtractMessages(path) + if err != nil { + t.Fatalf("ExtractMessages: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + if msgs[0].Content != "actual prompt" { + t.Errorf("expected XML tags stripped, got %q", msgs[0].Content) + } +} + +func TestExtractMessages_AgySkipsSystemEntries(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "transcript.jsonl") + + lines := []map[string]interface{}{ + {"source": "SYSTEM", "type": "TOOL_RESULT", "content": "tool output", "step_index": 0}, + {"source": "MODEL", "type": "TOOL_CALL", "content": "calling tool", "step_index": 1}, + } + + var content []byte + for _, l := range lines { + b, _ := json.Marshal(l) + content = append(content, b...) + content = append(content, '\n') + } + os.WriteFile(path, content, 0644) + + msgs, err := ExtractMessages(path) + if err != nil { + t.Fatalf("ExtractMessages: %v", err) + } + if len(msgs) != 0 { + t.Errorf("expected 0 messages (only system/tool entries), got %d", len(msgs)) + } +} + +func TestExtractMessages_AutoDetectsClaude(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "transcript.jsonl") + + line := `{"type":"user","timestamp":"2026-05-01T12:00:00Z","message":{"role":"user","content":"hello from claude"}}` + "\n" + os.WriteFile(path, []byte(line), 0644) + + msgs, err := ExtractMessages(path) + if err != nil { + t.Fatalf("ExtractMessages: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + if msgs[0].Role != "user" || msgs[0].Content != "hello from claude" { + t.Errorf("msg[0]: role=%q content=%q", msgs[0].Role, msgs[0].Content) + } +} + +func TestExtractMessages_EmptyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "transcript.jsonl") + os.WriteFile(path, []byte{}, 0644) + + msgs, err := ExtractMessages(path) + if err != nil { + t.Fatalf("ExtractMessages: %v", err) + } + if msgs != nil { + t.Errorf("expected nil for empty file, got %v", msgs) + } +} diff --git a/internal/provider/agy/agy.go b/internal/provider/agy/agy.go new file mode 100644 index 0000000..1d1f4bb --- /dev/null +++ b/internal/provider/agy/agy.go @@ -0,0 +1,539 @@ +package agy + +import ( + "bufio" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" + + "vibecockpit/internal/provider" +) + +type Agy struct { + baseDir string + workspaceDir string +} + +func New(workspaceDir string) *Agy { + home, _ := os.UserHomeDir() + return &Agy{ + baseDir: filepath.Join(home, ".gemini", "antigravity-cli"), + workspaceDir: workspaceDir, + } +} + +func Available() bool { + home, _ := os.UserHomeDir() + _, err := os.Stat(filepath.Join(home, ".gemini", "antigravity-cli", "conversations")) + return err == nil +} + +func (a *Agy) Name() string { return "agy" } +func (a *Agy) Icon() string { return "◈" } + +func (a *Agy) ResumeCommand(s provider.Session) (string, []string) { + return "agy", []string{"--conversation", s.ID} +} + +func (a *Agy) NewCommand(_ string) (string, []string) { + return "agy", nil +} + +func (a *Agy) DeleteSession(id string) error { + _ = os.Remove(filepath.Join(a.baseDir, "conversations", id+".pb")) + _ = os.RemoveAll(filepath.Join(a.baseDir, "brain", id)) + return nil +} + +func (a *Agy) ScanSessions(_ context.Context) ([]provider.Session, error) { + model := a.loadModel() + history := a.loadHistory() + active := scanActivePIDs() + + convDir := filepath.Join(a.baseDir, "conversations") + entries, err := os.ReadDir(convDir) + if err != nil { + return nil, err + } + + var sessions []provider.Session + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".pb") { + continue + } + id := strings.TrimSuffix(entry.Name(), ".pb") + info, err := entry.Info() + if err != nil { + continue + } + sessions = append(sessions, a.buildSession(id, info, model, history, active)) + } + + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].Modified.After(sessions[j].Modified) + }) + return sessions, nil +} + +type convMeta struct { + workspace string + firstPrompt string + created time.Time +} + +type historyEntry struct { + Display string `json:"display"` + Timestamp int64 `json:"timestamp"` + Workspace string `json:"workspace"` + ConversationID string `json:"conversationId"` +} + +type transcriptEntry struct { + Source string `json:"source"` + Type string `json:"type"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` +} + +type brainMeta struct { + Summary string `json:"summary"` + UpdatedAt string `json:"updatedAt"` +} + +func (a *Agy) loadModel() string { + data, err := os.ReadFile(filepath.Join(a.baseDir, "settings.json")) + if err != nil { + return "gemini" + } + var s struct { + Model string `json:"model"` + } + if json.Unmarshal(data, &s) == nil && s.Model != "" { + return s.Model + } + return "gemini" +} + +func (a *Agy) loadHistory() map[string]*convMeta { + result := make(map[string]*convMeta) + + f, err := os.Open(filepath.Join(a.baseDir, "history.jsonl")) + if err == nil { + defer f.Close() + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 256*1024), 256*1024) + + var pending *historyEntry + for scanner.Scan() { + var entry historyEntry + if json.Unmarshal(scanner.Bytes(), &entry) != nil { + continue + } + if entry.ConversationID == "" { + pending = &entry + continue + } + if _, ok := result[entry.ConversationID]; !ok { + meta := &convMeta{workspace: entry.Workspace} + if pending != nil && pending.Workspace == entry.Workspace { + meta.firstPrompt = truncate(pending.Display, 200) + meta.created = time.UnixMilli(pending.Timestamp) + } else { + meta.firstPrompt = truncate(entry.Display, 200) + meta.created = time.UnixMilli(entry.Timestamp) + } + result[entry.ConversationID] = meta + } + pending = nil + } + } + + if data, err := os.ReadFile(filepath.Join(a.baseDir, "cache", "last_conversations.json")); err == nil { + var lastConvs map[string]string + if json.Unmarshal(data, &lastConvs) == nil { + for workspace, convID := range lastConvs { + if _, ok := result[convID]; !ok { + result[convID] = &convMeta{workspace: workspace} + } + } + } + } + + return result +} + +func (a *Agy) buildSession(id string, convInfo os.FileInfo, model string, history map[string]*convMeta, active map[string]int) provider.Session { + modified := convInfo.ModTime() + created := modified + + var projectPath, firstPrompt, summary string + var messageCount int + + if meta, ok := history[id]; ok { + projectPath = meta.workspace + firstPrompt = meta.firstPrompt + if !meta.created.IsZero() { + created = meta.created + } + } + + transcriptPath := filepath.Join(a.baseDir, "brain", id, ".system_generated", "logs", "transcript.jsonl") + if tc := parseTranscript(transcriptPath); tc != nil { + messageCount = tc.messageCount + if firstPrompt == "" && tc.firstPrompt != "" { + firstPrompt = tc.firstPrompt + } + if !tc.created.IsZero() && tc.created.Before(created) { + created = tc.created + } + } + + brainDir := filepath.Join(a.baseDir, "brain", id) + summary = loadBrainSummary(brainDir) + + if messageCount == 0 { + if entries, err := os.ReadDir(brainDir); err == nil { + for _, e := range entries { + if strings.HasPrefix(e.Name(), "task.md.resolved.") { + messageCount++ + } + } + } + } + + if projectPath == "" { + projectPath = extractPathFromBrain(brainDir) + } + if projectPath == "" { + projectPath = a.fuzzyMatchWorkspace(loadProjectName(brainDir)) + } + + projectName := filepath.Base(projectPath) + if projectPath == "" { + projectName = "agy-session" + } + + var gitBranch string + var isActive bool + var activePID int + + if projectPath != "" { + gitBranch = detectGitBranch(projectPath) + } + + if pid, ok := active[id]; ok { + isActive = true + activePID = pid + } + + dataPath := transcriptPath + if _, err := os.Stat(dataPath); err != nil { + dataPath = filepath.Join(a.baseDir, "conversations", id+".pb") + } + + return provider.Session{ + ID: id, + Provider: "agy", + ProjectName: projectName, + ProjectPath: projectPath, + Summary: truncate(summary, 200), + FirstPrompt: firstPrompt, + Model: model, + GitBranch: gitBranch, + MessageCount: messageCount, + Created: created, + Modified: modified, + IsActive: isActive, + ActivePID: activePID, + DataPath: dataPath, + } +} + +type transcriptInfo struct { + messageCount int + firstPrompt string + created time.Time +} + +func parseTranscript(path string) *transcriptInfo { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + info := &transcriptInfo{} + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 256*1024), 256*1024) + + for scanner.Scan() { + var entry transcriptEntry + if json.Unmarshal(scanner.Bytes(), &entry) != nil { + continue + } + if entry.Source == "USER_EXPLICIT" && entry.Type == "USER_INPUT" { + info.messageCount++ + if info.firstPrompt == "" { + content := entry.Content + if idx := strings.Index(content, ""); idx >= 0 { + content = content[idx+len(""):] + } + if idx := strings.Index(content, ""); idx >= 0 { + content = content[:idx] + } + info.firstPrompt = truncate(strings.TrimSpace(content), 200) + } + if info.created.IsZero() && entry.CreatedAt != "" { + if t, err := time.Parse(time.RFC3339, entry.CreatedAt); err == nil { + info.created = t + } + } + } + } + return info +} + +func loadBrainSummary(brainDir string) string { + for _, name := range []string{"walkthrough.md.metadata.json", "task.md.metadata.json", "implementation_plan.md.metadata.json"} { + data, err := os.ReadFile(filepath.Join(brainDir, name)) + if err != nil { + continue + } + var meta brainMeta + if json.Unmarshal(data, &meta) == nil && meta.Summary != "" { + return meta.Summary + } + } + return "" +} + +func loadProjectName(brainDir string) string { + for _, name := range []string{"walkthrough.md", "task.md"} { + data, err := os.ReadFile(filepath.Join(brainDir, name)) + if err != nil { + continue + } + if title := extractTitle(string(data)); title != "" { + return title + } + } + return "" +} + +//nolint:gosec // paths are extracted from local brain files, not external input +func extractPathFromBrain(brainDir string) string { + files := []string{"walkthrough.md", "implementation_plan.md", "task.md"} + if entries, err := os.ReadDir(brainDir); err == nil { + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".resolved") || strings.Contains(e.Name(), ".resolved.") { + files = append(files, e.Name()) + } + } + } + + for _, name := range files { + data, err := os.ReadFile(filepath.Join(brainDir, name)) + if err != nil { + continue + } + for _, line := range strings.Split(string(data), "\n") { + idx := strings.Index(line, "/home/") + if idx < 0 { + idx = strings.Index(line, "/Users/") + } + if idx < 0 { + continue + } + path := line[idx:] + for _, suffix := range []string{")", "]", "`", "\"", "'"} { + if i := strings.Index(path, suffix); i > 0 { + path = path[:i] + } + } + if strings.Contains(path, "/.gemini/") { + continue + } + dir := filepath.Dir(path) + candidate := dir + for candidate != "/" && candidate != "." { + if _, err := os.Stat(filepath.Join(candidate, ".git")); err == nil { + return candidate + } + candidate = filepath.Dir(candidate) + } + candidate = dir + var found string + for candidate != "/" && candidate != "." { + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + for _, marker := range []string{"package.json", "go.mod", "Cargo.toml", "pyproject.toml", "Makefile"} { + if _, err := os.Stat(filepath.Join(candidate, marker)); err == nil { + found = candidate + } + } + } + candidate = filepath.Dir(candidate) + } + if found != "" { + return found + } + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return dir + } + } + } + return "" +} + +func (a *Agy) fuzzyMatchWorkspace(projectName string) string { + if projectName == "" || projectName == "agy-session" || a.workspaceDir == "" { + return "" + } + + keywords := tokenize(projectName) + if len(keywords) == 0 { + return "" + } + + var bestMatch string + bestScore := 0 + + entries, err := os.ReadDir(a.workspaceDir) + if err != nil { + return "" + } + for _, e := range entries { + if !e.IsDir() { + continue + } + score := matchScore(keywords, tokenize(e.Name())) + if score > bestScore { + bestScore = score + bestMatch = filepath.Join(a.workspaceDir, e.Name()) + } + } + + if bestScore >= 2 || (bestScore == 1 && len(keywords) == 1) { + return bestMatch + } + return "" +} + +func tokenize(s string) []string { + s = strings.ToLower(s) + for _, c := range []string{"-", "_", ".", "(", ")", ",", ":", "!", "/"} { + s = strings.ReplaceAll(s, c, " ") + } + var tokens []string + for _, w := range strings.Fields(s) { + if len(w) >= 3 { + tokens = append(tokens, w) + } + } + return tokens +} + +func matchScore(projectKeywords, dirTokens []string) int { + score := 0 + for _, kw := range projectKeywords { + for _, dt := range dirTokens { + if kw == dt { + score += 3 + } else if strings.Contains(dt, kw) || strings.Contains(kw, dt) { + score += 2 + } + } + } + unmatched := len(dirTokens) - len(projectKeywords) + if unmatched > 0 && score > 0 { + score -= unmatched + } + return score +} + +func extractTitle(md string) string { + for _, line := range strings.SplitN(md, "\n", 5) { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "# ") { + title := strings.TrimPrefix(line, "# ") + for _, suffix := range []string{" - Task Breakdown", " - Task List", " - Implementation Complete", " - Walkthrough", " Walkthrough"} { + title = strings.TrimSuffix(title, suffix) + } + for _, prefix := range []string{"Walkthrough - ", "Walkthrough: "} { + title = strings.TrimPrefix(title, prefix) + } + if len(title) > 100 { + title = title[:100] + } + return title + } + } + return "" +} + +func truncate(s string, max int) string { + if idx := strings.IndexByte(s, '\n'); idx >= 0 { + s = s[:idx] + } + if len(s) > max { + s = s[:max-3] + "..." + } + return s +} + +func detectGitBranch(dir string) string { + cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// scanActivePIDs finds running agy processes and maps conversation IDs to PIDs. +func scanActivePIDs() map[string]int { + result := make(map[string]int) + entries, err := os.ReadDir("/proc") + if err != nil { + return result + } + for _, e := range entries { + name := e.Name() + if len(name) == 0 || name[0] < '0' || name[0] > '9' { + continue + } + pid, err := strconv.Atoi(name) + if err != nil { + continue + } + if err := syscall.Kill(pid, 0); err != nil { + continue + } + data, err := os.ReadFile("/proc/" + name + "/cmdline") + if err != nil { + continue + } + args := strings.Split(string(data), "\x00") + if len(args) == 0 { + continue + } + base := filepath.Base(args[0]) + if base != "agy" { + continue + } + for i, arg := range args { + if arg == "--conversation" && i+1 < len(args) { + result[args[i+1]] = pid + break + } + } + } + return result +} diff --git a/internal/provider/agy/agy_test.go b/internal/provider/agy/agy_test.go new file mode 100644 index 0000000..b694d34 --- /dev/null +++ b/internal/provider/agy/agy_test.go @@ -0,0 +1,250 @@ +package agy + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "vibecockpit/internal/provider" +) + +func TestResumeCommand(t *testing.T) { + a := &Agy{} + cmd, args := a.ResumeCommand(provider.Session{ID: "abc-123"}) + if cmd != "agy" { + t.Errorf("cmd: got %q, want agy", cmd) + } + if len(args) != 2 || args[0] != "--conversation" || args[1] != "abc-123" { + t.Errorf("args: got %v, want [--conversation abc-123]", args) + } +} + +func TestNewCommand(t *testing.T) { + a := &Agy{} + cmd, args := a.NewCommand("/some/dir") + if cmd != "agy" { + t.Errorf("cmd: got %q, want agy", cmd) + } + if args != nil { + t.Errorf("args: got %v, want nil", args) + } +} + +func TestNameIcon(t *testing.T) { + a := &Agy{} + if a.Name() != "agy" { + t.Errorf("Name: got %q, want agy", a.Name()) + } + if a.Icon() != "◈" { + t.Errorf("Icon: got %q, want ◈", a.Icon()) + } +} + +func TestLoadModel(t *testing.T) { + dir := t.TempDir() + a := &Agy{baseDir: dir} + + if m := a.loadModel(); m != "gemini" { + t.Errorf("missing settings.json: got %q, want gemini", m) + } + + os.WriteFile(filepath.Join(dir, "settings.json"), []byte(`{"model":"gemini-2.5-pro"}`), 0644) + if m := a.loadModel(); m != "gemini-2.5-pro" { + t.Errorf("with settings: got %q, want gemini-2.5-pro", m) + } +} + +func TestLoadHistory(t *testing.T) { + dir := t.TempDir() + a := &Agy{baseDir: dir} + + lines := []string{ + `{"display":"first prompt","timestamp":1700000000000,"workspace":"/home/user/project"}`, + `{"display":"first prompt","timestamp":1700000001000,"workspace":"/home/user/project","conversationId":"conv-1"}`, + `{"display":"second prompt","timestamp":1700000002000,"workspace":"/home/user/other","conversationId":"conv-2"}`, + } + var content []byte + for _, l := range lines { + content = append(content, []byte(l+"\n")...) + } + os.WriteFile(filepath.Join(dir, "history.jsonl"), content, 0644) + + history := a.loadHistory() + if len(history) < 2 { + t.Fatalf("expected at least 2 entries, got %d", len(history)) + } + + conv1 := history["conv-1"] + if conv1 == nil { + t.Fatal("conv-1 not found") + } + if conv1.workspace != "/home/user/project" { + t.Errorf("conv-1 workspace: got %q", conv1.workspace) + } + if conv1.firstPrompt != "first prompt" { + t.Errorf("conv-1 firstPrompt: got %q", conv1.firstPrompt) + } + + conv2 := history["conv-2"] + if conv2 == nil { + t.Fatal("conv-2 not found") + } + if conv2.workspace != "/home/user/other" { + t.Errorf("conv-2 workspace: got %q", conv2.workspace) + } +} + +func TestParseTranscript(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "transcript.jsonl") + + now := time.Now().UTC().Truncate(time.Second) + lines := []map[string]interface{}{ + {"source": "USER_EXPLICIT", "type": "USER_INPUT", "content": "hello world", "step_index": 0, "created_at": now.Format(time.RFC3339)}, + {"source": "MODEL", "type": "PLANNER_RESPONSE", "content": "Hi there!", "step_index": 1}, + {"source": "USER_EXPLICIT", "type": "USER_INPUT", "content": "follow up question", "step_index": 2}, + } + + var content []byte + for _, l := range lines { + b, _ := json.Marshal(l) + content = append(content, b...) + content = append(content, '\n') + } + os.WriteFile(path, content, 0644) + + info := parseTranscript(path) + if info == nil { + t.Fatal("expected non-nil info") + } + if info.messageCount != 2 { + t.Errorf("messageCount: got %d, want 2", info.messageCount) + } + if info.firstPrompt != "hello world" { + t.Errorf("firstPrompt: got %q, want 'hello world'", info.firstPrompt) + } + if !info.created.Equal(now) { + t.Errorf("created: got %v, want %v", info.created, now) + } +} + +func TestParseTranscript_Missing(t *testing.T) { + info := parseTranscript("/nonexistent/path/transcript.jsonl") + if info != nil { + t.Error("expected nil for missing file") + } +} + +func TestExtractTitle(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"# My Project - Walkthrough\nsome text", "My Project"}, + {"# Build System - Task Breakdown\nmore text", "Build System"}, + {"# Simple Title\ncontent", "Simple Title"}, + {"no title here", ""}, + } + for _, tt := range tests { + got := extractTitle(tt.input) + if got != tt.want { + t.Errorf("extractTitle(%q) = %q, want %q", tt.input[:20], got, tt.want) + } + } +} + +func TestTruncate(t *testing.T) { + if got := truncate("short", 100); got != "short" { + t.Errorf("got %q, want 'short'", got) + } + if got := truncate("line1\nline2", 100); got != "line1" { + t.Errorf("got %q, want 'line1'", got) + } + long := "abcdefghijklmnopqrstuvwxyz" + if got := truncate(long, 10); got != "abcdefg..." { + t.Errorf("got %q, want 'abcdefg...'", got) + } +} + +func TestTokenize(t *testing.T) { + tokens := tokenize("My-Cool_Project.Name") + expected := []string{"cool", "project", "name"} + if len(tokens) != len(expected) { + t.Fatalf("got %v, want %v", tokens, expected) + } + for i, tok := range tokens { + if tok != expected[i] { + t.Errorf("token[%d]: got %q, want %q", i, tok, expected[i]) + } + } +} + +func TestMatchScore(t *testing.T) { + score := matchScore([]string{"vibecockpit"}, []string{"vibecockpit", "dev"}) + if score < 2 { + t.Errorf("exact match should score >= 2, got %d", score) + } + + zero := matchScore([]string{"unrelated"}, []string{"something", "else"}) + if zero != 0 { + t.Errorf("no match should score 0, got %d", zero) + } +} + +func TestDeleteSession(t *testing.T) { + dir := t.TempDir() + a := &Agy{baseDir: dir} + + convDir := filepath.Join(dir, "conversations") + os.MkdirAll(convDir, 0755) + os.WriteFile(filepath.Join(convDir, "sess-1.pb"), []byte("data"), 0644) + + brainDir := filepath.Join(dir, "brain", "sess-1") + os.MkdirAll(brainDir, 0755) + os.WriteFile(filepath.Join(brainDir, "task.md"), []byte("# Task"), 0644) + + if err := a.DeleteSession("sess-1"); err != nil { + t.Fatalf("DeleteSession: %v", err) + } + + if _, err := os.Stat(filepath.Join(convDir, "sess-1.pb")); !os.IsNotExist(err) { + t.Error("pb file should be deleted") + } + if _, err := os.Stat(brainDir); !os.IsNotExist(err) { + t.Error("brain dir should be deleted") + } +} + +func TestFuzzyMatchWorkspace(t *testing.T) { + wsDir := t.TempDir() + os.MkdirAll(filepath.Join(wsDir, "vibecockpit-dev"), 0755) + os.MkdirAll(filepath.Join(wsDir, "other-project"), 0755) + + a := &Agy{workspaceDir: wsDir} + + got := a.fuzzyMatchWorkspace("vibecockpit") + want := filepath.Join(wsDir, "vibecockpit-dev") + if got != want { + t.Errorf("got %q, want %q", got, want) + } + + got = a.fuzzyMatchWorkspace("") + if got != "" { + t.Errorf("empty name should return empty, got %q", got) + } +} + +func TestLoadBrainSummary(t *testing.T) { + dir := t.TempDir() + + meta := map[string]string{"summary": "This is a test summary", "updatedAt": "2026-05-01"} + data, _ := json.Marshal(meta) + os.WriteFile(filepath.Join(dir, "task.md.metadata.json"), data, 0644) + + got := loadBrainSummary(dir) + if got != "This is a test summary" { + t.Errorf("got %q, want 'This is a test summary'", got) + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 2c3c196..a5c4da0 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -345,6 +345,12 @@ func toolConfigFor(tool string) toolConfig { mcpFile: ".gemini/settings.json", mcpKey: "mcpServers", } + case "agy": + return toolConfig{ + bin: "agy", + mcpFile: ".gemini/settings.json", + mcpKey: "mcpServers", + } case "opencode": return toolConfig{ bin: "opencode", @@ -378,6 +384,8 @@ func buildArgs(tc toolConfig, model, prompt string) []string { args = append(args, "--model", model) } return args + case "agy": + return []string{"--dangerously-skip-permissions", "-p", prompt} case "opencode": args := []string{"run"} if model != "" { @@ -554,6 +562,7 @@ var toolConfigMap = map[string][]string{ "claude": {".mcp.json"}, "codex": {"codex.json", ".codex/config.toml"}, "gemini": {".gemini/settings.json"}, + "agy": {".gemini/settings.json"}, "opencode": {"opencode.json"}, "cursor": {".cursor/mcp.json"}, } @@ -647,6 +656,11 @@ func defaultAllowedTools() []string { } } +// SupportedTools returns tool IDs that have explicit non-interactive support. +func SupportedTools() []string { + return []string{"claude", "codex", "gemini", "agy", "opencode"} +} + // ToolConfigFor exposes toolConfigFor for use by the chat package. func ToolConfigFor(tool string) ToolConfig { tc := toolConfigFor(tool) diff --git a/internal/web/server.go b/internal/web/server.go index 94d3f31..ee0d65d 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -204,6 +204,7 @@ func Start(cfg *config.Config, providers []provider.Provider, opts StartOpts) er mux.HandleFunc("PUT /api/config", s.handlePutConfig) mux.HandleFunc("GET /api/costs", s.handleCosts) mux.HandleFunc("GET /api/inventory", s.handleInventory) + mux.HandleFunc("GET /api/tools", s.handleTools) mux.HandleFunc("GET /api/stats", s.handleStats) mux.HandleFunc("GET /api/inventory/file", s.handleInventoryFile) mux.HandleFunc("GET /api/version", s.handleVersion) @@ -848,6 +849,45 @@ func (s *server) handleInventory(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(inv) } +func (s *server) handleTools(w http.ResponseWriter, _ *http.Request) { + supported := make(map[string]bool) + for _, id := range runner.SupportedTools() { + supported[id] = true + } + + var inv *inventory.Inventory + if s.inventoryCache != nil && time.Since(s.inventoryCachedAt) < s.inventoryTTL { + inv = s.inventoryCache + } else { + all := s.sessionCache.getSessions(false) + inv = inventory.Scan(all, s.cfg.NewProjectDir) + s.inventoryCache = inv + s.inventoryCachedAt = time.Now() + } + + type toolEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Installed bool `json:"installed"` + Version string `json:"version,omitempty"` + } + var tools []toolEntry + for _, t := range inv.Tools { + if !supported[t.ID] { + continue + } + tools = append(tools, toolEntry{ + ID: t.ID, + Name: t.Name, + Installed: t.Installed, + Version: t.Version, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tools) +} + func (s *server) handleInventoryFile(w http.ResponseWriter, r *http.Request) { path := r.URL.Query().Get("path") if path == "" { diff --git a/main.go b/main.go index b1af112..6251b51 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,7 @@ import ( "vibecockpit/internal/plugin/builtin" "vibecockpit/internal/plugin/remote" "vibecockpit/internal/provider" + "vibecockpit/internal/provider/agy" "vibecockpit/internal/provider/antigravity" "vibecockpit/internal/provider/claude" "vibecockpit/internal/provider/claudedesktop" @@ -228,6 +229,7 @@ func buildRegistry(cfg *config.Config) *plugin.Registry { reg.Register(builtin.New("gemini", "Gemini CLI", "✦", gemini.New(), gemini.New().Available)) reg.Register(builtin.New("cursor", "Cursor Agent", "●", cursoragent.New(), cursoragent.Available)) reg.Register(builtin.New("antigravity", "Antigravity", "▲", antigravity.New(cfg.NewProjectDir), antigravity.Available)) + reg.Register(builtin.New("agy", "Agy CLI", "◈", agy.New(cfg.NewProjectDir), agy.Available)) for i, src := range cfg.RemoteSources { id := fmt.Sprintf("remote-%d", i)