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 @@
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)