diff --git a/backend/internal/adapters/agent/activitydispatch/dispatch.go b/backend/internal/adapters/agent/activitydispatch/dispatch.go index f02d0d5..a386668 100644 --- a/backend/internal/adapters/agent/activitydispatch/dispatch.go +++ b/backend/internal/adapters/agent/activitydispatch/dispatch.go @@ -9,10 +9,17 @@ package activitydispatch import ( + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agy" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/autohand" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cline" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/copilot" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cursor" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/goose" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/kilocode" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/kiro" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/qwen" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -31,6 +38,13 @@ var Derivers = map[string]DeriveFunc{ "opencode": opencode.DeriveActivityState, "qwen": qwen.DeriveActivityState, "copilot": copilot.DeriveActivityState, + "droid": droid.DeriveActivityState, + "agy": agy.DeriveActivityState, + "goose": goose.DeriveActivityState, + "cline": cline.DeriveActivityState, + "kiro": kiro.DeriveActivityState, + "kilocode": kilocode.DeriveActivityState, + "autohand": autohand.DeriveActivityState, } // Derive looks up the deriver for an agent token and applies it. ok=false when diff --git a/backend/internal/adapters/agent/agy/activity.go b/backend/internal/adapters/agent/agy/activity.go new file mode 100644 index 0000000..d4ebca4 --- /dev/null +++ b/backend/internal/adapters/agent/agy/activity.go @@ -0,0 +1,27 @@ +package agy + +import ( + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// DeriveActivityState maps an Agy hook event onto an AO activity state. The +// bool is false when the event carries no activity signal. +// +// event is the AO hook sub-command name installed in agyManagedHooks: +// "session-start", "session-end", "before-agent", "after-agent", "after-tool". +func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + switch event { + case "before-agent": + return domain.ActivityActive, true + case "after-agent": + return domain.ActivityIdle, true + case "after-tool": + return domain.ActivityActive, true + case "session-end": + return domain.ActivityExited, true + case "session-start": + return "", false + default: + return "", false + } +} diff --git a/backend/internal/adapters/agent/agy/activity_test.go b/backend/internal/adapters/agent/agy/activity_test.go new file mode 100644 index 0000000..f77cda4 --- /dev/null +++ b/backend/internal/adapters/agent/agy/activity_test.go @@ -0,0 +1,32 @@ +package agy + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestDeriveActivityState(t *testing.T) { + tests := []struct { + name string + event string + want domain.ActivityState + wantOK bool + }{ + {"before agent -> active", "before-agent", domain.ActivityActive, true}, + {"after agent -> idle", "after-agent", domain.ActivityIdle, true}, + {"after tool -> active", "after-tool", domain.ActivityActive, true}, + {"session end -> exited", "session-end", domain.ActivityExited, true}, + {"session start -> no signal", "session-start", "", false}, + {"unknown event -> no signal", "unknown", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := DeriveActivityState(tt.event, []byte(`{}`)) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", + tt.event, got, ok, tt.want, tt.wantOK) + } + }) + } +} diff --git a/backend/internal/adapters/agent/agy/agy.go b/backend/internal/adapters/agent/agy/agy.go new file mode 100644 index 0000000..5758367 --- /dev/null +++ b/backend/internal/adapters/agent/agy/agy.go @@ -0,0 +1,244 @@ +// Package agy implements the Agy (Antigravity) agent adapter: launching new sessions, +// resuming sessions by native ID, installing workspace-local hooks, and reading +// hook-derived session info. +package agy + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + adapterID = "agy" + + // Normalized session-metadata keys. Shared vocabulary with the Codex and Claude Code + // adapters so the dashboard treats every agent uniformly. + agyTitleMetadataKey = "title" + agySummaryMetadataKey = "summary" +) + +// Plugin is the Agy agent adapter. It is safe for concurrent use; the binary +// path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.RWMutex + resolvedBinary string +} + +// New returns a ready-to-register Agy adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Agy", + Description: "Run Agy (Antigravity) worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Agy exposes none yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start an interactive Agy session. +// Shape: +// +// agy --add-dir [--dangerously-skip-permissions] [--prompt-interactive ] +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.agyBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + + if cfg.WorkspacePath != "" { + cmd = append(cmd, "--add-dir", cfg.WorkspacePath) + } + + if cfg.Permissions == ports.PermissionModeBypassPermissions { + cmd = append(cmd, "--dangerously-skip-permissions") + } + + if cfg.Prompt != "" { + cmd = append(cmd, "--prompt-interactive", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Agy receives its prompt in the +// launch command itself via --prompt-interactive. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetRestoreCommand rebuilds the argv that continues an existing Agy session: +// `agy --add-dir [--dangerously-skip-permissions] --conversation `. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.agyBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = []string{binary} + + if cfg.Session.WorkspacePath != "" { + cmd = append(cmd, "--add-dir", cfg.Session.WorkspacePath) + } + + if cfg.Permissions == ports.PermissionModeBypassPermissions { + cmd = append(cmd, "--dangerously-skip-permissions") + } + + cmd = append(cmd, "--conversation", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Agy hook-derived metadata. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[agyTitleMetadataKey], + Summary: session.Metadata[agySummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// ResolveAgyBinary returns the path to the agy binary on this machine, +// searching PATH then a handful of well-known install locations. +// Returns "agy" as a last-ditch fallback. +func ResolveAgyBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"agy.cmd", "agy.exe", "agy"} { + path, err := exec.LookPath(name) + if err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "agy.cmd"), + filepath.Join(appData, "npm", "agy.exe"), + ) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "agy.exe")) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "agy", nil + } + + if path, err := exec.LookPath("agy"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/agy", + "/opt/homebrew/bin/agy", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "agy"), + filepath.Join(home, ".cargo", "bin", "agy"), + filepath.Join(home, ".npm", "bin", "agy"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "agy", nil +} + +func (p *Plugin) agyBinary(ctx context.Context) (string, error) { + // Fast path: a concurrent-safe read of the already-resolved binary. + p.binaryMu.RLock() + cached := p.resolvedBinary + p.binaryMu.RUnlock() + if cached != "" { + return cached, nil + } + + // Populate path: take the write lock and re-check, since another goroutine + // may have resolved the binary between releasing RLock and acquiring Lock. + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveAgyBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/agy/agy_test.go b/backend/internal/adapters/agent/agy/agy_test.go new file mode 100644 index 0000000..b512050 --- /dev/null +++ b/backend/internal/adapters/agent/agy/agy_test.go @@ -0,0 +1,202 @@ +package agy + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifest(t *testing.T) { + plugin := New() + manifest := plugin.Manifest() + if manifest.ID != "agy" { + t.Fatalf("manifest id = %q, want agy", manifest.ID) + } +} + +func TestGetLaunchCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "agy"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "fix this", + WorkspacePath: "/tmp/ws", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "agy", + "--add-dir", "/tmp/ws", + "--dangerously-skip-permissions", + "--prompt-interactive", "fix this", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetPromptDeliveryStrategy(t *testing.T) { + plugin := &Plugin{resolvedBinary: "agy"} + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryInCommand { + t.Fatalf("strategy = %q, want in_command", got) + } +} + +func TestGetRestoreCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "agy"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "native-id-123"}, + WorkspacePath: "/tmp/ws", + }, + }) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected ok=true") + } + + want := []string{ + "agy", + "--add-dir", "/tmp/ws", + "--dangerously-skip-permissions", + "--conversation", "native-id-123", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandNoSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "agy"} + _, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{}, + }, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected ok=false when agentSessionId is missing") + } +} + +func TestSessionInfo(t *testing.T) { + plugin := &Plugin{} + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "native-id-123", + "title": "My Title", + "summary": "My Summary", + }, + }) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected ok=true") + } + if info.AgentSessionID != "native-id-123" || info.Title != "My Title" || info.Summary != "My Summary" { + t.Fatalf("unexpected SessionInfo: %#v", info) + } +} + +func TestHooksLifecycle(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agy-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + plugin := &Plugin{} + cfg := ports.WorkspaceHookConfig{ + WorkspacePath: tmpDir, + } + + // 1. Initially hooks should not be installed. + installed, err := plugin.AreHooksInstalled(context.Background(), tmpDir) + if err != nil { + t.Fatal(err) + } + if installed { + t.Fatal("expected hooks to not be installed initially") + } + + // 2. Install hooks. + err = plugin.GetAgentHooks(context.Background(), cfg) + if err != nil { + t.Fatal(err) + } + + installed, err = plugin.AreHooksInstalled(context.Background(), tmpDir) + if err != nil { + t.Fatal(err) + } + if !installed { + t.Fatal("expected hooks to be installed after GetAgentHooks") + } + + // Verify hooks.json structure + hooksJSONPath := filepath.Join(tmpDir, ".gemini", "hooks.json") + data, err := os.ReadFile(hooksJSONPath) + if err != nil { + t.Fatal(err) + } + + var hookFile agyHookFile + if err := json.Unmarshal(data, &hookFile); err != nil { + t.Fatal(err) + } + + if len(hookFile.Hooks) != len(agyManagedHooks) { + t.Fatalf("expected %d events in hooks, got %d", len(agyManagedHooks), len(hookFile.Hooks)) + } + + for _, spec := range agyManagedHooks { + groups, ok := hookFile.Hooks[spec.Event] + if !ok { + t.Fatalf("expected event %q in hooks.json", spec.Event) + } + found := false + for _, group := range groups { + for _, h := range group.Hooks { + if h.Command == spec.Command { + found = true + break + } + } + } + if !found { + t.Fatalf("expected command %q for event %q", spec.Command, spec.Event) + } + } + + // 3. Uninstall hooks. + err = plugin.UninstallHooks(context.Background(), tmpDir) + if err != nil { + t.Fatal(err) + } + + installed, err = plugin.AreHooksInstalled(context.Background(), tmpDir) + if err != nil { + t.Fatal(err) + } + if installed { + t.Fatal("expected hooks to be uninstalled after UninstallHooks") + } +} diff --git a/backend/internal/adapters/agent/agy/hooks.go b/backend/internal/adapters/agent/agy/hooks.go new file mode 100644 index 0000000..0168929 --- /dev/null +++ b/backend/internal/adapters/agent/agy/hooks.go @@ -0,0 +1,305 @@ +package agy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + agyHooksDirName = ".gemini" + agyHooksFileName = "hooks.json" + + agyHookCommandPrefix = "ao hooks agy " +) + +type agyHookFile struct { + Hooks map[string][]agyMatcherGroup `json:"hooks"` +} + +type agyMatcherGroup struct { + Matcher *string `json:"matcher,omitempty"` + Hooks []agyHookEntry `json:"hooks"` +} + +type agyHookEntry struct { + Type string `json:"type"` + Command string `json:"command"` +} + +type agyHookSpec struct { + Event string + Command string +} + +var agyManagedHooks = []agyHookSpec{ + {Event: "SessionStart", Command: agyHookCommandPrefix + "session-start"}, + {Event: "SessionEnd", Command: agyHookCommandPrefix + "session-end"}, + {Event: "BeforeAgent", Command: agyHookCommandPrefix + "before-agent"}, + {Event: "AfterAgent", Command: agyHookCommandPrefix + "after-agent"}, + {Event: "AfterTool", Command: agyHookCommandPrefix + "after-tool"}, +} + +// GetAgentHooks installs AO's Agy hooks into the worktree-local +// .gemini/hooks.json file. Existing hook entries are preserved and duplicate +// AO commands are not appended. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(cfg.WorkspacePath) == "" { + return errors.New("agy.GetAgentHooks: WorkspacePath is required") + } + + hooksPath := agyHooksPath(cfg.WorkspacePath) + topLevel, rawHooks, err := readAgyHooks(hooksPath) + if err != nil { + return fmt.Errorf("agy.GetAgentHooks: %w", err) + } + + for event, specs := range groupAgyHooksByEvent() { + var existingGroups []agyMatcherGroup + if err := parseAgyHookType(rawHooks, event, &existingGroups); err != nil { + return fmt.Errorf("agy.GetAgentHooks: %w", err) + } + for _, spec := range specs { + if !agyHookCommandExists(existingGroups, spec.Command) { + entry := agyHookEntry{Type: "command", Command: spec.Command} + existingGroups = addAgyHook(existingGroups, entry) + } + } + if err := marshalAgyHookType(rawHooks, event, existingGroups); err != nil { + return fmt.Errorf("agy.GetAgentHooks: %w", err) + } + } + + if err := writeAgyHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("agy.GetAgentHooks: %w", err) + } + + return nil +} + +// UninstallHooks removes AO's Agy hooks from the workspace-local +// .gemini/hooks.json file, leaving user-defined hooks untouched. A missing file +// is a no-op. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return errors.New("agy.UninstallHooks: workspacePath is required") + } + + hooksPath := agyHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return nil + } + topLevel, rawHooks, err := readAgyHooks(hooksPath) + if err != nil { + return fmt.Errorf("agy.UninstallHooks: %w", err) + } + + for _, event := range agyManagedEvents() { + var groups []agyMatcherGroup + if err := parseAgyHookType(rawHooks, event, &groups); err != nil { + return fmt.Errorf("agy.UninstallHooks: %w", err) + } + groups = removeAgyManagedHooks(groups) + if err := marshalAgyHookType(rawHooks, event, groups); err != nil { + return fmt.Errorf("agy.UninstallHooks: %w", err) + } + } + + if err := writeAgyHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("agy.UninstallHooks: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether any AO Agy hook is present in the +// workspace-local hooks file. A missing file means none are installed. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if strings.TrimSpace(workspacePath) == "" { + return false, errors.New("agy.AreHooksInstalled: workspacePath is required") + } + + hooksPath := agyHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return false, nil + } + _, rawHooks, err := readAgyHooks(hooksPath) + if err != nil { + return false, fmt.Errorf("agy.AreHooksInstalled: %w", err) + } + + for _, event := range agyManagedEvents() { + var groups []agyMatcherGroup + if err := parseAgyHookType(rawHooks, event, &groups); err != nil { + return false, fmt.Errorf("agy.AreHooksInstalled: %w", err) + } + for _, group := range groups { + for _, hook := range group.Hooks { + if isAgyManagedHook(hook.Command) { + return true, nil + } + } + } + } + return false, nil +} + +func agyHooksPath(workspacePath string) string { + return filepath.Join(workspacePath, agyHooksDirName, agyHooksFileName) +} + +// readAgyHooks loads the hooks file into a top-level raw map plus the decoded +// "hooks" sub-map, preserving keys AO doesn't manage. A missing or empty +// file yields empty maps. +func readAgyHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { + topLevel = map[string]json.RawMessage{} + rawHooks = map[string]json.RawMessage{} + + data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return topLevel, rawHooks, nil + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return topLevel, rawHooks, nil + } + if err := json.Unmarshal(data, &topLevel); err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) + } + if hooksRaw, ok := topLevel["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) + } + } + return topLevel, rawHooks, nil +} + +// writeAgyHooks folds rawHooks back into topLevel and writes the file. An +// empty hooks map drops the "hooks" key entirely. +func writeAgyHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { + if len(rawHooks) == 0 { + delete(topLevel, "hooks") + } else { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("encode hooks: %w", err) + } + topLevel["hooks"] = hooksJSON + } + + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + return fmt.Errorf("create hook dir: %w", err) + } + data, err := json.MarshalIndent(topLevel, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", hooksPath, err) + } + data = append(data, '\n') + if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", hooksPath, err) + } + return nil +} + +func groupAgyHooksByEvent() map[string][]agyHookSpec { + byEvent := map[string][]agyHookSpec{} + for _, spec := range agyManagedHooks { + byEvent[spec.Event] = append(byEvent[spec.Event], spec) + } + return byEvent +} + +func agyManagedEvents() []string { + seen := map[string]bool{} + events := make([]string, 0, len(agyManagedHooks)) + for _, spec := range agyManagedHooks { + if !seen[spec.Event] { + seen[spec.Event] = true + events = append(events, spec.Event) + } + } + return events +} + +func isAgyManagedHook(command string) bool { + return strings.HasPrefix(command, agyHookCommandPrefix) +} + +func removeAgyManagedHooks(groups []agyMatcherGroup) []agyMatcherGroup { + result := make([]agyMatcherGroup, 0, len(groups)) + for _, group := range groups { + kept := make([]agyHookEntry, 0, len(group.Hooks)) + for _, hook := range group.Hooks { + if !isAgyManagedHook(hook.Command) { + kept = append(kept, hook) + } + } + if len(kept) > 0 { + group.Hooks = kept + result = append(result, group) + } + } + return result +} + +func parseAgyHookType(rawHooks map[string]json.RawMessage, event string, target *[]agyMatcherGroup) error { + data, ok := rawHooks[event] + if !ok { + return nil + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("parse %s hooks: %w", event, err) + } + return nil +} + +func marshalAgyHookType(rawHooks map[string]json.RawMessage, event string, groups []agyMatcherGroup) error { + if len(groups) == 0 { + delete(rawHooks, event) + return nil + } + data, err := json.Marshal(groups) + if err != nil { + return fmt.Errorf("encode %s hooks: %w", event, err) + } + rawHooks[event] = data + return nil +} + +func agyHookCommandExists(groups []agyMatcherGroup, command string) bool { + for _, group := range groups { + for _, hook := range group.Hooks { + if hook.Command == command { + return true + } + } + } + return false +} + +func addAgyHook(groups []agyMatcherGroup, hook agyHookEntry) []agyMatcherGroup { + for i, group := range groups { + if group.Matcher == nil { + groups[i].Hooks = append(groups[i].Hooks, hook) + return groups + } + } + return append(groups, agyMatcherGroup{Matcher: nil, Hooks: []agyHookEntry{hook}}) +} diff --git a/backend/internal/adapters/agent/aider/aider.go b/backend/internal/adapters/agent/aider/aider.go new file mode 100644 index 0000000..cb76632 --- /dev/null +++ b/backend/internal/adapters/agent/aider/aider.go @@ -0,0 +1,222 @@ +// Package aider implements the Aider agent adapter: launching headless Aider +// worker sessions. +// +// Aider is a Tier C adapter: it has no lifecycle hook surface, no native +// session id, and no resume-by-id mechanism, so hook installation, restore, and +// SessionInfo are intentionally no-ops. The permission mapping is lossy because +// Aider lacks a graduated approval ladder or sandbox (see the comments on +// appendApprovalFlags). +package aider + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const adapterID = "aider" + +// Plugin is the Aider agent adapter. It is safe for concurrent use; the binary +// path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Aider adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Aider", + Description: "Run Aider worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports no agent-specific config keys yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a headless Aider session: +// +// aider -m [permission flags] --no-check-update --no-stream --no-pretty [--read ] +// +// The prompt is delivered with `-m ` rather than positionally: Aider +// treats positional arguments as files to add to the chat, so a positional +// prompt would be misread. The `-m` pair is only appended when a prompt is set. +// +// Aider has no inline system-prompt mechanism; only SystemPromptFile is honored +// via --read. The --no-check-update --no-stream --no-pretty flags keep Aider +// well-behaved in a non-interactive, captured-output context. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.aiderBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + if cfg.Prompt != "" { + cmd = append(cmd, "-m", cfg.Prompt) + } + appendApprovalFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "--no-check-update", "--no-stream", "--no-pretty") + if cfg.SystemPromptFile != "" { + cmd = append(cmd, "--read", cfg.SystemPromptFile) + } + // aider has no inline system-prompt mechanism; only SystemPromptFile is + // honored via --read. A cfg.SystemPrompt with no file is intentionally + // dropped here rather than written to disk. + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Aider receives its prompt in the launch +// command itself (via -m). +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetAgentHooks is a no-op: Aider emits no lifecycle hooks (Tier C), so there +// is no native hook config to install AO hooks into. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return ctx.Err() +} + +// GetRestoreCommand always reports that no native session can be continued. +// Aider has no native session id or resume-by-id mechanism +// (see github.com/Aider-AI/aider issues/166), so the manager always falls back +// to a fresh launch. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + return nil, false, nil +} + +// SessionInfo is a no-op: Aider exposes no captureable session metadata. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + return ports.SessionInfo{}, false, nil +} + +// normalizePermissionMode collapses an empty mode onto PermissionModeDefault so +// callers can switch over a stable set of values. +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + if mode == "" { + return ports.PermissionModeDefault + } + return mode +} + +// appendApprovalFlags maps AO's permission modes onto Aider's flags. The mapping +// is lossy: Aider has no graduated approval ladder and no sandbox, so multiple +// AO modes collapse onto the same Aider behavior. +func appendApprovalFlags(cmd *[]string, mode ports.PermissionMode) { + switch normalizePermissionMode(mode) { + case ports.PermissionModeDefault: + // No flags: Aider's interactive confirmation prompts apply. In headless + // -m mode an unanswered confirm can hang; this is acceptable and + // documented, deferring the choice to the user's own Aider config. + case ports.PermissionModeAcceptEdits: + // Apply edits without prompting but leave them uncommitted. + *cmd = append(*cmd, "--yes-always", "--no-auto-commits") + case ports.PermissionModeAuto: + // Apply edits without prompting and keep Aider's default auto-commit. + *cmd = append(*cmd, "--yes-always") + case ports.PermissionModeBypassPermissions: + // Lossy: Aider has no sandbox/bypass, so this is identical to auto. + *cmd = append(*cmd, "--yes-always") + default: + // Unhandled/future modes: no flags, deferring to the user's Aider config. + } +} + +// ResolveAiderBinary finds the `aider` binary, searching PATH then common +// install locations. It returns "aider" as a last resort so callers get the +// shell's normal command-not-found behavior if Aider is absent. +func ResolveAiderBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"aider.exe", "aider.cmd", "aider"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + return "aider", nil + } + + if path, err := exec.LookPath("aider"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/aider", + "/opt/homebrew/bin/aider", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append([]string{filepath.Join(home, ".local", "bin", "aider")}, candidates...) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "aider", nil +} + +func (p *Plugin) aiderBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveAiderBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/aider/aider_test.go b/backend/internal/adapters/agent/aider/aider_test.go new file mode 100644 index 0000000..e4c611d --- /dev/null +++ b/backend/internal/adapters/agent/aider/aider_test.go @@ -0,0 +1,291 @@ +package aider + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifest(t *testing.T) { + m := (&Plugin{}).Manifest() + if m.ID != "aider" { + t.Fatalf("ID = %q, want aider", m.ID) + } + if m.Name != "Aider" { + t.Fatalf("Name = %q, want Aider", m.Name) + } + hasAgent := false + for _, c := range m.Capabilities { + if c == adapters.CapabilityAgent { + hasAgent = true + } + } + if !hasAgent { + t.Fatal("missing CapabilityAgent") + } +} + +func TestGetConfigSpecEmpty(t *testing.T) { + spec, err := (&Plugin{}).GetConfigSpec(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(spec.Fields) != 0 { + t.Fatalf("expected no fields, got %d", len(spec.Fields)) + } +} + +func TestGetPromptDeliveryStrategy(t *testing.T) { + s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if s != ports.PromptDeliveryInCommand { + t.Fatalf("strategy = %q, want %q", s, ports.PromptDeliveryInCommand) + } +} + +func TestGetLaunchCommandDeliversPromptWithFlag(t *testing.T) { + p := &Plugin{resolvedBinary: "aider"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "add a health check", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"aider", "-m", "add a health check", "--no-check-update", "--no-stream", "--no-pretty"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandOmitsPromptFlagWhenEmpty(t *testing.T) { + p := &Plugin{resolvedBinary: "aider"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + + want := []string{"aider", "--no-check-update", "--no-stream", "--no-pretty"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } + for _, arg := range cmd { + if arg == "-m" { + t.Fatalf("cmd = %#v unexpectedly contains -m for empty prompt", cmd) + } + } +} + +func TestGetLaunchCommandAlwaysAppendsHeadlessFlags(t *testing.T) { + p := &Plugin{resolvedBinary: "aider"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Prompt: "do the thing"}) + if err != nil { + t.Fatal(err) + } + + for _, want := range []string{"--no-check-update", "--no-stream", "--no-pretty"} { + found := false + for _, arg := range cmd { + if arg == want { + found = true + break + } + } + if !found { + t.Fatalf("cmd = %#v missing headless flag %q", cmd, want) + } + } +} + +func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { + tests := []struct { + name string + mode ports.PermissionMode + wantFlags []string + wantAbsent []string + }{ + { + name: "default omits approval flags", + mode: ports.PermissionModeDefault, + wantFlags: nil, + wantAbsent: []string{"--yes-always", "--no-auto-commits"}, + }, + { + name: "empty omits approval flags", + mode: "", + wantFlags: nil, + wantAbsent: []string{"--yes-always", "--no-auto-commits"}, + }, + { + name: "accept edits applies but leaves uncommitted", + mode: ports.PermissionModeAcceptEdits, + wantFlags: []string{"--yes-always", "--no-auto-commits"}, + wantAbsent: nil, + }, + { + name: "auto applies and auto-commits", + mode: ports.PermissionModeAuto, + wantFlags: []string{"--yes-always"}, + wantAbsent: []string{"--no-auto-commits"}, + }, + { + name: "bypass collapses onto auto", + mode: ports.PermissionModeBypassPermissions, + wantFlags: []string{"--yes-always"}, + wantAbsent: []string{"--no-auto-commits"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Plugin{resolvedBinary: "aider"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "do the thing", + Permissions: tt.mode, + }) + if err != nil { + t.Fatal(err) + } + + for _, want := range tt.wantFlags { + found := false + for _, arg := range cmd { + if arg == want { + found = true + break + } + } + if !found { + t.Fatalf("cmd = %#v missing expected flag %q", cmd, want) + } + } + for _, absent := range tt.wantAbsent { + for _, arg := range cmd { + if arg == absent { + t.Fatalf("cmd = %#v unexpectedly contains %q", cmd, absent) + } + } + } + }) + } +} + +func TestGetLaunchCommandSystemPromptFileUsesRead(t *testing.T) { + p := &Plugin{resolvedBinary: "aider"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "do the thing", + SystemPromptFile: "/tmp/system.md", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"aider", "-m", "do the thing", "--no-check-update", "--no-stream", "--no-pretty", "--read", "/tmp/system.md"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandInlineSystemPromptIsDropped(t *testing.T) { + p := &Plugin{resolvedBinary: "aider"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "do the thing", + SystemPrompt: "inline ignored", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"aider", "-m", "do the thing", "--no-check-update", "--no-stream", "--no-pretty"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } + for _, arg := range cmd { + if arg == "--read" { + t.Fatalf("cmd = %#v unexpectedly contains --read for inline system prompt", cmd) + } + if arg == "inline ignored" { + t.Fatalf("cmd = %#v unexpectedly contains inline system prompt text", cmd) + } + } +} + +func TestGetRestoreCommandAlwaysFalse(t *testing.T) { + p := &Plugin{resolvedBinary: "aider"} + cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abc123"}, + }, + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatalf("ok=true, want false (aider has no resume-by-id)") + } + if cmd != nil { + t.Fatalf("cmd = %#v, want nil", cmd) + } +} + +func TestGetAgentHooksNoOp(t *testing.T) { + if err := (&Plugin{}).GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err != nil { + t.Fatalf("GetAgentHooks err = %v, want nil", err) + } +} + +func TestSessionInfoNoOp(t *testing.T) { + info, ok, err := (&Plugin{}).SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abc123"}, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatalf("ok=true with info %#v, want no-op false", info) + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero", info) + } +} + +func TestContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := (&Plugin{}).GetConfigSpec(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("GetConfigSpec err = %v, want context.Canceled", err) + } + if _, err := (&Plugin{}).GetLaunchCommand(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetLaunchCommand err = %v, want context.Canceled", err) + } + if _, err := (&Plugin{}).GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetPromptDeliveryStrategy err = %v, want context.Canceled", err) + } + if err := (&Plugin{}).GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetAgentHooks err = %v, want context.Canceled", err) + } + if _, _, err := (&Plugin{}).GetRestoreCommand(ctx, ports.RestoreConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetRestoreCommand err = %v, want context.Canceled", err) + } + if _, _, err := (&Plugin{}).SessionInfo(ctx, ports.SessionRef{}); !errors.Is(err, context.Canceled) { + t.Fatalf("SessionInfo err = %v, want context.Canceled", err) + } +} + +func TestResolveAiderBinaryFallback(t *testing.T) { + bin, err := ResolveAiderBinary(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if bin == "" { + t.Fatal("ResolveAiderBinary returned empty string") + } +} diff --git a/backend/internal/adapters/agent/amp/amp.go b/backend/internal/adapters/agent/amp/amp.go new file mode 100644 index 0000000..bf22db9 --- /dev/null +++ b/backend/internal/adapters/agent/amp/amp.go @@ -0,0 +1,228 @@ +// Package amp implements the Amp agent adapter: launching new interactive Amp +// sessions and resuming sessions when a native Amp thread id is known. +// +// Amp activity hooks and SessionInfo derivation will likely require an +// Amp-specific TypeScript plugin, similar to opencode. Until that integration +// exists, hook installation and SessionInfo are intentionally no-ops. +package amp + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const adapterID = "amp" + +// Plugin is the Amp agent adapter. It is safe for concurrent use; the binary +// path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Amp adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Amp", + Description: "Run Amp worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports no agent-specific config keys yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new interactive Amp session: +// +// amp [--permission-mode ] [--append-system-prompt ] [-- ] +// +// The prompt is passed after `--` so a prompt beginning with "-" is not +// mistaken for a flag. System prompts are appended to Amp's defaults, mirroring +// the Claude Code adapter's launch shape. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + if err := ctx.Err(); err != nil { + return nil, err + } + binary, err := p.ampBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + appendPermissionFlags(&cmd, cfg.Permissions) + if cfg.SystemPromptFile != "" { + cmd = append(cmd, "--append-system-prompt-file", cfg.SystemPromptFile) + } else if cfg.SystemPrompt != "" { + cmd = append(cmd, "--append-system-prompt", cfg.SystemPrompt) + } + if cfg.Prompt != "" { + cmd = append(cmd, "--", cfg.Prompt) + } + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Amp receives its prompt in the launch +// command itself. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetAgentHooks is intentionally a no-op until Amp activity can be reported via +// an Amp-specific plugin. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return ctx.Err() +} + +// GetRestoreCommand rebuilds the argv that continues an existing Amp session +// when plugin-derived native session metadata is available. Until that metadata +// exists, ok is false and callers fall back to fresh launch behavior. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.ampBinary(ctx) + if err != nil { + return nil, false, err + } + // Capacity fits binary + up to two permission flags + --resume + sessionID. + cmd = make([]string, 0, 5) + cmd = append(cmd, binary) + appendPermissionFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "--resume", agentSessionID) + return cmd, true, nil +} + +// SessionInfo is intentionally a no-op until Amp plugin metadata exists. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + return ports.SessionInfo{}, false, nil +} + +func appendPermissionFlags(cmd *[]string, mode ports.PermissionMode) { + switch mode { + case ports.PermissionModeAcceptEdits: + *cmd = append(*cmd, "--permission-mode", "acceptEdits") + case ports.PermissionModeAuto: + *cmd = append(*cmd, "--permission-mode", "auto") + case ports.PermissionModeBypassPermissions: + *cmd = append(*cmd, "--permission-mode", "bypassPermissions") + } +} + +// ResolveAmpBinary finds the `amp` binary, searching PATH then common install +// locations. It returns "amp" as a last resort so callers get the shell's normal +// command-not-found behavior if Amp is absent. +func ResolveAmpBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"amp.cmd", "amp.exe", "amp"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "amp.cmd"), + filepath.Join(appData, "npm", "amp.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + return "amp", nil + } + + if path, err := exec.LookPath("amp"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/amp", + "/opt/homebrew/bin/amp", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "amp"), + filepath.Join(home, ".npm", "bin", "amp"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "amp", nil +} + +func (p *Plugin) ampBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveAmpBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/amp/amp_test.go b/backend/internal/adapters/agent/amp/amp_test.go new file mode 100644 index 0000000..e2d9366 --- /dev/null +++ b/backend/internal/adapters/agent/amp/amp_test.go @@ -0,0 +1,212 @@ +package amp + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifest(t *testing.T) { + m := (&Plugin{}).Manifest() + if m.ID != "amp" { + t.Fatalf("ID = %q, want amp", m.ID) + } + if m.Name != "Amp" { + t.Fatalf("Name = %q, want Amp", m.Name) + } + hasAgent := false + for _, c := range m.Capabilities { + if c == adapters.CapabilityAgent { + hasAgent = true + } + } + if !hasAgent { + t.Fatal("missing CapabilityAgent") + } +} + +func TestGetConfigSpecEmpty(t *testing.T) { + spec, err := (&Plugin{}).GetConfigSpec(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(spec.Fields) != 0 { + t.Fatalf("expected no fields, got %d", len(spec.Fields)) + } +} + +func TestGetPromptDeliveryStrategy(t *testing.T) { + s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if s != ports.PromptDeliveryInCommand { + t.Fatalf("strategy = %q, want %q", s, ports.PromptDeliveryInCommand) + } +} + +func TestGetLaunchCommandBypassWithPrompt(t *testing.T) { + p := &Plugin{resolvedBinary: "amp"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-add a health check", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"amp", "--permission-mode", "bypassPermissions", "--", "-add a health check"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { + tests := []struct { + name string + mode ports.PermissionMode + want []string + wantAbsent string + }{ + {"default omits flag", ports.PermissionModeDefault, []string{"amp"}, "--permission-mode"}, + {"empty omits flag", "", []string{"amp"}, "--permission-mode"}, + {"accept edits", ports.PermissionModeAcceptEdits, []string{"amp", "--permission-mode", "acceptEdits"}, ""}, + {"auto", ports.PermissionModeAuto, []string{"amp", "--permission-mode", "auto"}, ""}, + {"bypass", ports.PermissionModeBypassPermissions, []string{"amp", "--permission-mode", "bypassPermissions"}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Plugin{resolvedBinary: "amp"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Permissions: tt.mode}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(cmd, tt.want) { + t.Fatalf("cmd = %#v, want %#v", cmd, tt.want) + } + if tt.wantAbsent != "" { + for _, arg := range cmd { + if arg == tt.wantAbsent { + t.Fatalf("cmd = %#v unexpectedly contains %q", cmd, tt.wantAbsent) + } + } + } + }) + } +} + +func TestGetLaunchCommandAppendsSystemPrompt(t *testing.T) { + p := &Plugin{resolvedBinary: "amp"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPrompt: "follow repo rules", + Prompt: "do the thing", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"amp", "--append-system-prompt", "follow repo rules", "--", "do the thing"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandPrefersSystemPromptFileFlag(t *testing.T) { + p := &Plugin{resolvedBinary: "amp"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPromptFile: "/tmp/system.md", + SystemPrompt: "inline ignored", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"amp", "--append-system-prompt-file", "/tmp/system.md"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommand(t *testing.T) { + p := &Plugin{resolvedBinary: "amp"} + cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "T-abc123"}, + }, + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("ok=false, want true") + } + + want := []string{"amp", "--permission-mode", "bypassPermissions", "--resume", "T-abc123"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommandNoID(t *testing.T) { + p := &Plugin{resolvedBinary: "amp"} + _, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{Metadata: map[string]string{}}, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("ok=true with no agentSessionId, want false") + } +} + +func TestGetAgentHooksNoOp(t *testing.T) { + if err := (&Plugin{}).GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err != nil { + t.Fatalf("GetAgentHooks err = %v, want nil", err) + } +} + +func TestSessionInfoNoOp(t *testing.T) { + info, ok, err := (&Plugin{}).SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "T-abc123"}, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatalf("ok=true with info %#v, want no-op false", info) + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero", info) + } +} + +func TestContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := (&Plugin{}).GetConfigSpec(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("GetConfigSpec err = %v, want context.Canceled", err) + } + if _, err := (&Plugin{}).GetLaunchCommand(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetLaunchCommand err = %v, want context.Canceled", err) + } + if _, err := (&Plugin{}).GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetPromptDeliveryStrategy err = %v, want context.Canceled", err) + } + if err := (&Plugin{}).GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetAgentHooks err = %v, want context.Canceled", err) + } + if _, _, err := (&Plugin{}).GetRestoreCommand(ctx, ports.RestoreConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetRestoreCommand err = %v, want context.Canceled", err) + } + if _, _, err := (&Plugin{}).SessionInfo(ctx, ports.SessionRef{}); !errors.Is(err, context.Canceled) { + t.Fatalf("SessionInfo err = %v, want context.Canceled", err) + } +} diff --git a/backend/internal/adapters/agent/auggie/auggie.go b/backend/internal/adapters/agent/auggie/auggie.go new file mode 100644 index 0000000..5fd5c72 --- /dev/null +++ b/backend/internal/adapters/agent/auggie/auggie.go @@ -0,0 +1,254 @@ +// Package auggie implements the Auggie (Augment Code) agent adapter: launching +// new headless Auggie sessions and resuming sessions when a native Auggie +// session id is known. +// +// Auggie is Augment Code's terminal coding agent (binary "auggie", installed via +// `npm install -g @augmentcode/auggie`). It exposes a headless one-shot mode via +// `--print` (alias `-p`) which runs a single instruction and exits — the mode AO +// uses to drive it unattended. +// +// Launch shape: +// +// auggie --print [--instruction-file | --instruction ] [-- ] +// +// The prompt is the print-mode positional, passed after `--` so a prompt +// beginning with "-" is not mistaken for a flag. A system prompt, when supplied, +// is injected via Auggie's `--instruction-file` / `--instruction` flags, which +// append guidance to the workspace rules. +// +// Permissions: Auggie has no single "approve everything" flag. It governs +// unattended tool/file approval through granular `--permission :` +// rules (and a read-only `--ask` mode), not a 4-mode bypass like Claude Code. +// Because there is no verifiable blanket auto-approve flag, every AO permission +// mode emits no flag and defers to the user's Auggie configuration, rather than +// guessing a flag that does not exist. +// +// Resume: Auggie supports `--resume ` (alias `-r`), usable with +// `--print` for headless resume. AO only has a native session id to resume from +// when one was captured into session metadata; Auggie exposes no hook/lifecycle +// system, so that id is not captured automatically yet. GetRestoreCommand +// therefore returns ok=false until a native session id is present, at which point +// callers fall back to a fresh launch. +// +// Hooks/activity: Auggie has no hook or lifecycle event system (it reads +// .claude/commands/ for slash commands, but that is not Claude Code hook +// compatibility). Hook installation and SessionInfo are intentionally no-ops +// (Tier C) until an Auggie-specific activity integration exists. +package auggie + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const adapterID = "auggie" + +// Plugin is the Auggie agent adapter. It is safe for concurrent use; the binary +// path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Auggie adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Auggie", + Description: "Run Auggie (Augment Code) worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports no agent-specific config keys yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new headless Auggie session: +// +// auggie --print [--instruction-file | --instruction ] [-- ] +// +// The prompt is passed after `--` so a prompt beginning with "-" is not mistaken +// for a flag. A system prompt is injected via --instruction-file / --instruction, +// mirroring the system-prompt handling of the other adapters. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + if err := ctx.Err(); err != nil { + return nil, err + } + binary, err := p.auggieBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary, "--print"} + if cfg.SystemPromptFile != "" { + cmd = append(cmd, "--instruction-file", cfg.SystemPromptFile) + } else if cfg.SystemPrompt != "" { + cmd = append(cmd, "--instruction", cfg.SystemPrompt) + } + if cfg.Prompt != "" { + cmd = append(cmd, "--", cfg.Prompt) + } + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Auggie receives its prompt in the launch +// command itself (the print-mode positional). +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetAgentHooks is intentionally a no-op: Auggie has no hook or lifecycle event +// system, so there is nothing to install. Activity reporting will require an +// Auggie-specific integration once one exists. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return ctx.Err() +} + +// GetRestoreCommand rebuilds the argv that continues an existing Auggie session +// when a native session id is available in metadata: +// +// auggie --print --resume +// +// Auggie has no hook surface to capture that id automatically yet, so in practice +// the id is empty and ok is false, letting callers fall back to a fresh launch. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.auggieBinary(ctx) + if err != nil { + return nil, false, err + } + cmd = []string{binary, "--print", "--resume", agentSessionID} + return cmd, true, nil +} + +// SessionInfo is intentionally a no-op until Auggie session metadata can be +// captured (Auggie exposes no hook surface to derive it from). +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + return ports.SessionInfo{}, false, nil +} + +// Auggie has no single blanket auto-approve/bypass flag; unattended tool/file +// approval is governed by granular `--permission :` rules, so +// AO emits no approval flag and defers every mode to the user's Auggie config. +// There is therefore no appendApprovalFlags helper for this adapter. + +// ResolveAuggieBinary finds the `auggie` binary, searching PATH then common +// install locations. It returns "auggie" as a last resort so callers get the +// shell's normal command-not-found behavior if Auggie is absent. +func ResolveAuggieBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"auggie.cmd", "auggie.exe", "auggie"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "auggie.cmd"), + filepath.Join(appData, "npm", "auggie.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + return "auggie", nil + } + + if path, err := exec.LookPath("auggie"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/auggie", + "/opt/homebrew/bin/auggie", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "auggie"), + filepath.Join(home, ".npm", "bin", "auggie"), + filepath.Join(home, ".npm-global", "bin", "auggie"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "auggie", nil +} + +func (p *Plugin) auggieBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveAuggieBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/auggie/auggie_test.go b/backend/internal/adapters/agent/auggie/auggie_test.go new file mode 100644 index 0000000..af18525 --- /dev/null +++ b/backend/internal/adapters/agent/auggie/auggie_test.go @@ -0,0 +1,220 @@ +package auggie + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifest(t *testing.T) { + m := (&Plugin{}).Manifest() + if m.ID != "auggie" { + t.Fatalf("ID = %q, want auggie", m.ID) + } + if m.Name != "Auggie" { + t.Fatalf("Name = %q, want Auggie", m.Name) + } + hasAgent := false + for _, c := range m.Capabilities { + if c == adapters.CapabilityAgent { + hasAgent = true + } + } + if !hasAgent { + t.Fatal("missing CapabilityAgent") + } +} + +func TestGetConfigSpecEmpty(t *testing.T) { + spec, err := (&Plugin{}).GetConfigSpec(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(spec.Fields) != 0 { + t.Fatalf("expected no fields, got %d", len(spec.Fields)) + } +} + +func TestGetPromptDeliveryStrategy(t *testing.T) { + s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if s != ports.PromptDeliveryInCommand { + t.Fatalf("strategy = %q, want %q", s, ports.PromptDeliveryInCommand) + } +} + +func TestGetLaunchCommandWithPrompt(t *testing.T) { + p := &Plugin{resolvedBinary: "auggie"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-add a health check", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"auggie", "--print", "--", "-add a health check"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +// TestGetLaunchCommandPermissionModesEmitNoFlag documents that Auggie has no +// blanket auto-approve flag, so every AO permission mode produces the same argv +// (no permission flag) and defers to the user's Auggie config. +func TestGetLaunchCommandPermissionModesEmitNoFlag(t *testing.T) { + modes := []ports.PermissionMode{ + ports.PermissionModeDefault, + "", + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions, + } + want := []string{"auggie", "--print"} + for _, mode := range modes { + t.Run(string(mode), func(t *testing.T) { + p := &Plugin{resolvedBinary: "auggie"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Permissions: mode}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } + for _, arg := range cmd { + if arg == "--permission" || arg == "--permission-mode" { + t.Fatalf("cmd = %#v unexpectedly contains a permission flag", cmd) + } + } + }) + } +} + +func TestGetLaunchCommandAppendsSystemPrompt(t *testing.T) { + p := &Plugin{resolvedBinary: "auggie"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPrompt: "follow repo rules", + Prompt: "do the thing", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"auggie", "--print", "--instruction", "follow repo rules", "--", "do the thing"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandPrefersSystemPromptFileFlag(t *testing.T) { + p := &Plugin{resolvedBinary: "auggie"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPromptFile: "/tmp/system.md", + SystemPrompt: "inline ignored", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"auggie", "--print", "--instruction-file", "/tmp/system.md"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommand(t *testing.T) { + p := &Plugin{resolvedBinary: "auggie"} + cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "sess-abc123"}, + }, + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("ok=false, want true") + } + + want := []string{"auggie", "--print", "--resume", "sess-abc123"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommandNoID(t *testing.T) { + p := &Plugin{resolvedBinary: "auggie"} + _, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{Metadata: map[string]string{}}, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("ok=true with no agentSessionId, want false") + } +} + +func TestGetAgentHooksNoOp(t *testing.T) { + if err := (&Plugin{}).GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err != nil { + t.Fatalf("GetAgentHooks err = %v, want nil", err) + } +} + +func TestSessionInfoNoOp(t *testing.T) { + info, ok, err := (&Plugin{}).SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "sess-abc123"}, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatalf("ok=true with info %#v, want no-op false", info) + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero", info) + } +} + +func TestResolveAuggieBinaryFallback(t *testing.T) { + // With a cancelled context the resolver returns the context error rather than + // a binary path; with a live context it always yields a non-empty path. + bin, err := ResolveAuggieBinary(context.Background()) + if err != nil { + t.Fatalf("ResolveAuggieBinary err = %v", err) + } + if bin == "" { + t.Fatal("ResolveAuggieBinary returned empty path") + } +} + +func TestContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := (&Plugin{}).GetConfigSpec(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("GetConfigSpec err = %v, want context.Canceled", err) + } + if _, err := (&Plugin{}).GetLaunchCommand(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetLaunchCommand err = %v, want context.Canceled", err) + } + if _, err := (&Plugin{}).GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetPromptDeliveryStrategy err = %v, want context.Canceled", err) + } + if err := (&Plugin{}).GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetAgentHooks err = %v, want context.Canceled", err) + } + if _, _, err := (&Plugin{}).GetRestoreCommand(ctx, ports.RestoreConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetRestoreCommand err = %v, want context.Canceled", err) + } + if _, _, err := (&Plugin{}).SessionInfo(ctx, ports.SessionRef{}); !errors.Is(err, context.Canceled) { + t.Fatalf("SessionInfo err = %v, want context.Canceled", err) + } +} diff --git a/backend/internal/adapters/agent/autohand/activity.go b/backend/internal/adapters/agent/autohand/activity.go new file mode 100644 index 0000000..e1280f3 --- /dev/null +++ b/backend/internal/adapters/agent/autohand/activity.go @@ -0,0 +1,26 @@ +package autohand + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// DeriveActivityState maps an Autohand hook event onto an AO activity state. The +// bool is false when the event carries no activity signal. +// +// event is the AO hook sub-command name installed in autohandManagedHooks +// ("session-start", "user-prompt-submit", "permission-request", "stop"), routed +// from Autohand's native lifecycle events. Autohand has no SessionEnd/process- +// exit hook wired into the adapter, so runtime exit still falls back to the +// lifecycle reaper. +func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + switch event { + case "session-start": + return domain.ActivityActive, true + case "user-prompt-submit": + return domain.ActivityActive, true + case "stop": + return domain.ActivityIdle, true + case "permission-request": + return domain.ActivityWaitingInput, true + default: + return "", false + } +} diff --git a/backend/internal/adapters/agent/autohand/autohand.go b/backend/internal/adapters/agent/autohand/autohand.go new file mode 100644 index 0000000..ba2f28e --- /dev/null +++ b/backend/internal/adapters/agent/autohand/autohand.go @@ -0,0 +1,283 @@ +// Package autohand implements the Autohand Code agent adapter: launching new +// command-mode sessions, resuming native sessions by id, installing AO's +// lifecycle hooks into Autohand's config, and reading hook-derived session info. +// +// Autohand ("autohand") is an autonomous coding agent with a non-interactive +// command mode (`autohand -p ` / positional prompt), native session +// resume (`autohand resume `), and a native hook/lifecycle system +// whose events (session-start, stop, permission-request, ...) AO maps onto +// activity states. See hooks.go for hook installation and activity.go for the +// event→state mapping. +package autohand + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + adapterID = "autohand" + + autohandTitleMetadataKey = "title" + autohandSummaryMetadataKey = "summary" +) + +// Plugin is the Autohand agent adapter. It is safe for concurrent use; the +// binary path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Autohand adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Autohand", + Description: "Run Autohand worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Autohand exposes none yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new Autohand command-mode session, +// scoping the run to the workspace, applying the approval-mode flags and optional +// system-prompt override, and passing the initial prompt as a positional argument +// after `--` so a prompt beginning with "-" is not read as a flag. +// +// autohand [--path ] [] [--sys-prompt ] [-- ] +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.autohandBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + appendWorkspaceFlag(&cmd, cfg.WorkspacePath) + appendApprovalFlags(&cmd, cfg.Permissions) + + // Autohand's --sys-prompt accepts either an inline string or a file path, + // auto-detected by the CLI; prefer the file form when AO provides one. + if cfg.SystemPromptFile != "" { + cmd = append(cmd, "--sys-prompt", cfg.SystemPromptFile) + } else if cfg.SystemPrompt != "" { + cmd = append(cmd, "--sys-prompt", cfg.SystemPrompt) + } + + if cfg.Prompt != "" { + cmd = append(cmd, "--", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Autohand receives its prompt in the +// launch command itself. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetRestoreCommand rebuilds the argv that continues an existing Autohand +// session: `autohand resume [--path ] `. ok is false when +// the hook-derived native session id has not landed yet, so callers can fall +// back to fresh launch behavior. Autohand's resume sub-command does not accept +// approval flags, so none are appended here. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.autohandBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 5) + cmd = append(cmd, binary, "resume") + appendWorkspaceFlag(&cmd, cfg.Session.WorkspacePath) + cmd = append(cmd, agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Autohand hook-derived metadata. Metadata is intentionally +// nil: callers get the normalized fields directly. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[autohandTitleMetadataKey], + Summary: session.Metadata[autohandSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// appendWorkspaceFlag scopes the run to the given workspace path via --path. +func appendWorkspaceFlag(cmd *[]string, workspacePath string) { + if strings.TrimSpace(workspacePath) != "" { + *cmd = append(*cmd, "--path", workspacePath) + } +} + +// appendApprovalFlags maps AO's four permission modes onto Autohand's approval +// flags. Default emits no flag so Autohand resolves its starting mode from the +// user's own config (permissions.mode). Autohand has no distinct "accept-edits" +// mode, so it maps to --yes (auto-confirm risky actions) — the least-privileged +// non-interactive option — while auto/bypass map to --unrestricted. +func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { + switch normalizePermissionMode(permissions) { + case ports.PermissionModeDefault: + // No flag: defer to the user's Autohand config/default behavior. + case ports.PermissionModeAcceptEdits: + *cmd = append(*cmd, "--yes") + case ports.PermissionModeAuto: + *cmd = append(*cmd, "--unrestricted") + case ports.PermissionModeBypassPermissions: + *cmd = append(*cmd, "--unrestricted") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + return ports.PermissionModeDefault + } +} + +// ResolveAutohandBinary returns the path to the autohand binary on this machine, +// searching PATH then a handful of well-known install locations (Homebrew, the +// official ~/.local/bin installer, npm global). Returns "autohand" as a +// last-ditch fallback so callers see a clear "command not found" rather than an +// empty argv. +func ResolveAutohandBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"autohand.cmd", "autohand.exe", "autohand"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "autohand.cmd"), + filepath.Join(appData, "npm", "autohand.exe"), + ) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, ".local", "bin", "autohand.exe")) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "autohand", nil + } + + if path, err := exec.LookPath("autohand"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/autohand", + "/opt/homebrew/bin/autohand", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "autohand"), + filepath.Join(home, ".npm", "bin", "autohand"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "autohand", nil +} + +func (p *Plugin) autohandBinary(ctx context.Context) (string, error) { + // Honor cancellation even on the cached path, where ResolveAutohandBinary + // (which has its own ctx.Err() guard) is never reached. + if err := ctx.Err(); err != nil { + return "", err + } + + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveAutohandBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/autohand/autohand_test.go b/backend/internal/adapters/agent/autohand/autohand_test.go new file mode 100644 index 0000000..9e96bbf --- /dev/null +++ b/backend/internal/adapters/agent/autohand/autohand_test.go @@ -0,0 +1,539 @@ +package autohand + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifestIDMatchesHarness(t *testing.T) { + m := (&Plugin{}).Manifest() + if m.ID != "autohand" { + t.Fatalf("Manifest ID = %q, want %q", m.ID, "autohand") + } + if adapterID != "autohand" { + t.Fatalf("adapterID = %q, want %q", adapterID, "autohand") + } + if len(m.Capabilities) != 1 || m.Capabilities[0] != "agent" { + t.Fatalf("Capabilities = %#v, want [agent]", m.Capabilities) + } +} + +func TestGetLaunchCommandBuildsArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "autohand"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-fix this", + WorkspacePath: "/work/space", + SystemPromptFile: filepath.Join("tmp", "prompt with spaces.md"), + SystemPrompt: "ignored", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "autohand", + "--path", "/work/space", + "--unrestricted", + "--sys-prompt", filepath.Join("tmp", "prompt with spaces.md"), + "--", "-fix this", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandInlineSystemPrompt(t *testing.T) { + plugin := &Plugin{resolvedBinary: "autohand"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPrompt: "be terse", + }) + if err != nil { + t.Fatal(err) + } + want := []string{"autohand", "--sys-prompt", "be terse"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + want []string + notExpected []string + }{ + { + name: "default", + permission: ports.PermissionModeDefault, + notExpected: []string{"--unrestricted", "--yes", "--restricted"}, + }, + { + name: "accept-edits", + permission: ports.PermissionModeAcceptEdits, + want: []string{"--yes"}, + notExpected: []string{"--unrestricted"}, + }, + { + name: "auto", + permission: ports.PermissionModeAuto, + want: []string{"--unrestricted"}, + }, + { + name: "bypass-permissions", + permission: ports.PermissionModeBypassPermissions, + want: []string{"--unrestricted"}, + }, + { + name: "unknown falls back to default", + permission: "frobnicate", + notExpected: []string{"--unrestricted", "--yes", "--restricted"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "autohand"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: tt.permission, + }) + if err != nil { + t.Fatal(err) + } + for _, want := range tt.want { + if !contains(cmd, want) { + t.Fatalf("command %#v missing %q", cmd, want) + } + } + for _, missing := range tt.notExpected { + if contains(cmd, missing) { + t.Fatalf("command %#v contains %q", cmd, missing) + } + } + }) + } +} + +func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "autohand"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryInCommand { + t.Fatalf("unexpected strategy: %q", got) + } +} + +func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { + plugin := &Plugin{resolvedBinary: "autohand"} + + spec, err := plugin.GetConfigSpec(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(spec.Fields) != 0 { + t.Fatalf("unexpected config fields: %#v", spec.Fields) + } +} + +func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "autohand"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: ports.SessionRef{ + WorkspacePath: "/work/space", + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "sess-123"}, + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + want := []string{"autohand", "resume", "--path", "/work/space", "sess-123"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "autohand"} + + cases := []struct { + name string + ref ports.SessionRef + }{ + {"empty session ref", ports.SessionRef{}}, + {"empty metadata", ports.SessionRef{Metadata: map[string]string{}}}, + {"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}}, + {"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: tc.ref, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if cmd != nil { + t.Fatalf("cmd = %#v, want nil", cmd) + } + }) + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "autohand"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "sess-123", + autohandTitleMetadataKey: "Fix login redirect", + autohandSummaryMetadataKey: "Updated the auth callback and tests.", + "ignored": "not returned", + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatalf("ok = false, want true") + } + if info.AgentSessionID != "sess-123" { + t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q, want hook title", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q, want hook summary", info.Summary) + } + if info.Metadata != nil { + t.Fatalf("Metadata = %#v, want nil", info.Metadata) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "autohand"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero value", info) + } +} + +func TestContextCancellationIsRespected(t *testing.T) { + plugin := &Plugin{resolvedBinary: "autohand"} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := plugin.GetConfigSpec(ctx); err == nil { + t.Fatal("GetConfigSpec: want context error") + } + if _, err := plugin.GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); err == nil { + t.Fatal("GetPromptDeliveryStrategy: want context error") + } + if _, _, err := plugin.GetRestoreCommand(ctx, ports.RestoreConfig{}); err == nil { + t.Fatal("GetRestoreCommand: want context error") + } + if _, _, err := plugin.SessionInfo(ctx, ports.SessionRef{}); err == nil { + t.Fatal("SessionInfo: want context error") + } + if err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); err == nil { + t.Fatal("GetAgentHooks: want context error") + } + // resolvedBinary is set, so this exercises the cached-binary path, which + // must still honor cancellation. + if _, err := plugin.GetLaunchCommand(ctx, ports.LaunchConfig{}); err == nil { + t.Fatal("GetLaunchCommand: want context error") + } +} + +// TestGetAgentHooksPreservesUnknownEntryFields locks the round-trip behavior: +// keys AO does not model on a user hook entry (here "async") must survive a +// GetAgentHooks rewrite instead of being silently dropped. +func TestGetAgentHooksPreservesUnknownEntryFields(t *testing.T) { + plugin := &Plugin{resolvedBinary: "autohand"} + configPath := filepath.Join(t.TempDir(), "config.json") + t.Setenv("AUTOHAND_CONFIG", configPath) + + existing := `{ + "hooks": { + "enabled": false, + "hooks": [ + {"event": "stop", "command": "~/.autohand/hooks/sound-alert.sh", "description": "user hook", "enabled": true, "async": true, "filter": {"glob": "*.go"}} + ] + } +}` + if err := os.WriteFile(configPath, []byte(existing), 0o600); err != nil { + t.Fatal(err) + } + + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + var top struct { + Hooks struct { + Hooks []map[string]json.RawMessage `json:"hooks"` + } `json:"hooks"` + } + if err := json.Unmarshal(data, &top); err != nil { + t.Fatal(err) + } + + var userEntry map[string]json.RawMessage + for _, entry := range top.Hooks.Hooks { + if string(entry["command"]) == `"~/.autohand/hooks/sound-alert.sh"` { + userEntry = entry + break + } + } + if userEntry == nil { + t.Fatalf("user hook entry not found in %s", data) + } + if string(userEntry["async"]) != "true" { + t.Fatalf("unknown field async dropped: %s", data) + } + filterRaw, ok := userEntry["filter"] + if !ok { + t.Fatalf("unknown field filter dropped: %s", data) + } + var filter map[string]string + if err := json.Unmarshal(filterRaw, &filter); err != nil { + t.Fatalf("filter not valid json: %v (%s)", err, filterRaw) + } + if filter["glob"] != "*.go" { + t.Fatalf("unknown field filter not preserved: got %v in %s", filter, data) + } +} + +func TestDeriveActivityState(t *testing.T) { + tests := []struct { + name string + event string + want domain.ActivityState + wantOK bool + }{ + {"session start -> active", "session-start", domain.ActivityActive, true}, + {"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true}, + {"stop -> idle", "stop", domain.ActivityIdle, true}, + {"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, true}, + {"unknown event -> no signal", "frobnicate", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := DeriveActivityState(tt.event, []byte(`{}`)) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", + tt.event, got, ok, tt.want, tt.wantOK) + } + }) + } +} + +func TestGetAgentHooksInstallsAndPreservesConfig(t *testing.T) { + plugin := &Plugin{resolvedBinary: "autohand"} + configPath := filepath.Join(t.TempDir(), "config.json") + t.Setenv("AUTOHAND_CONFIG", configPath) + + // Seed a config with unrelated keys plus a user hook; both must survive. + existing := `{ + "provider": "openai", + "auth": {"token": "keep-me"}, + "hooks": { + "enabled": false, + "hooks": [ + {"event": "stop", "command": "~/.autohand/hooks/sound-alert.sh", "description": "user hook", "enabled": true, "async": true} + ] + } +}` + if err := os.WriteFile(configPath, []byte(existing), 0o600); err != nil { + t.Fatal(err) + } + + cfg := ports.WorkspaceHookConfig{ + DataDir: t.TempDir(), + SessionID: "sess-1", + WorkspacePath: t.TempDir(), + } + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + // A second install must not duplicate AO hook commands. + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + + // Unrelated top-level config keys are preserved. + var top map[string]json.RawMessage + if err := json.Unmarshal(data, &top); err != nil { + t.Fatal(err) + } + if string(top["provider"]) != `"openai"` { + t.Fatalf("provider not preserved: %s", top["provider"]) + } + if _, ok := top["auth"]; !ok { + t.Fatalf("auth block dropped: %s", data) + } + + _, hooksSection, entries := mustReadHooks(t, configPath) + if string(hooksSection["enabled"]) != "true" { + t.Fatalf("hooks.enabled = %s, want true", hooksSection["enabled"]) + } + + for _, spec := range autohandManagedHooks { + command := autohandHookCommandPrefix + spec.Subcommand + if got := countCommand(entries, command); got != 1 { + t.Fatalf("command %q count = %d, want 1 in %#v", command, got, entries) + } + } + if countCommand(entries, "~/.autohand/hooks/sound-alert.sh") != 1 { + t.Fatalf("user hook not preserved: %#v", entries) + } + + if installed, err := plugin.AreHooksInstalled(context.Background(), ""); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } +} + +func TestUninstallHooksRemovesOnlyAOHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "autohand"} + configPath := filepath.Join(t.TempDir(), "config.json") + t.Setenv("AUTOHAND_CONFIG", configPath) + + existing := `{ + "hooks": { + "enabled": false, + "hooks": [ + {"event": "stop", "command": "~/.autohand/hooks/sound-alert.sh", "description": "user hook", "enabled": true} + ] + } +}` + if err := os.WriteFile(configPath, []byte(existing), 0o600); err != nil { + t.Fatal(err) + } + + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: t.TempDir()} + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, ""); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + + if err := plugin.UninstallHooks(ctx, ""); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, ""); err != nil || installed { + t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err) + } + + _, _, entries := mustReadHooks(t, configPath) + for _, spec := range autohandManagedHooks { + command := autohandHookCommandPrefix + spec.Subcommand + if got := countCommand(entries, command); got != 0 { + t.Fatalf("command %q count = %d after uninstall, want 0", command, got) + } + } + if countCommand(entries, "~/.autohand/hooks/sound-alert.sh") != 1 { + t.Fatalf("user hook not preserved after uninstall: %#v", entries) + } +} + +func TestUninstallHooksMissingFileIsNoOp(t *testing.T) { + plugin := &Plugin{resolvedBinary: "autohand"} + configPath := filepath.Join(t.TempDir(), "missing", "config.json") + t.Setenv("AUTOHAND_CONFIG", configPath) + + if err := plugin.UninstallHooks(context.Background(), ""); err != nil { + t.Fatalf("UninstallHooks on missing file = %v, want nil", err) + } + if installed, err := plugin.AreHooksInstalled(context.Background(), ""); err != nil || installed { + t.Fatalf("AreHooksInstalled on missing file = (%v, %v), want (false, nil)", installed, err) + } +} + +func TestGetAgentHooksCreatesConfigWhenAbsent(t *testing.T) { + plugin := &Plugin{resolvedBinary: "autohand"} + configPath := filepath.Join(t.TempDir(), "nested", "config.json") + t.Setenv("AUTOHAND_CONFIG", configPath) + + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err != nil { + t.Fatal(err) + } + _, hooksSection, entries := mustReadHooks(t, configPath) + if string(hooksSection["enabled"]) != "true" { + t.Fatalf("hooks.enabled = %s, want true", hooksSection["enabled"]) + } + if len(entries) != len(autohandManagedHooks) { + t.Fatalf("entry count = %d, want %d", len(entries), len(autohandManagedHooks)) + } +} + +func mustReadHooks(t *testing.T, configPath string) (map[string]json.RawMessage, map[string]json.RawMessage, []autohandHookEntry) { + t.Helper() + top, section, entries, err := readAutohandHooks(configPath) + if err != nil { + t.Fatalf("readAutohandHooks: %v", err) + } + return top, section, entries +} + +func countCommand(entries []autohandHookEntry, command string) int { + count := 0 + for _, entry := range entries { + if entry.Command == command { + count++ + } + } + return count +} + +func contains(values []string, needle string) bool { + for _, v := range values { + if v == needle { + return true + } + } + return false +} diff --git a/backend/internal/adapters/agent/autohand/hooks.go b/backend/internal/adapters/agent/autohand/hooks.go new file mode 100644 index 0000000..084515b --- /dev/null +++ b/backend/internal/adapters/agent/autohand/hooks.go @@ -0,0 +1,337 @@ +package autohand + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + autohandConfigDirName = ".autohand" + autohandConfigFileName = "config.json" + + // autohandHookCommandPrefix identifies the hook commands AO owns, so + // install skips duplicates and uninstall recognizes AO entries by prefix + // without an embedded template to diff against. + autohandHookCommandPrefix = "ao hooks autohand " + autohandHookTimeout = 30 +) + +// autohandManagedHookKeys are the entry keys AO owns. On marshal they are +// written from the typed fields below; any other key the user set is preserved +// from Extra. Keep in sync with the json tags on autohandHookEntry. +var autohandManagedHookKeys = []string{"event", "command", "description", "enabled", "timeout"} + +// autohandHookEntry is the on-disk shape of one entry in the config's +// hooks.hooks array. AO owns the five typed fields; any other key the user set +// on an entry (matcher, filter, async, ...) is captured in Extra so a rewrite +// preserves fields AO does not own instead of silently dropping them. +type autohandHookEntry struct { + Event string `json:"event"` + Command string `json:"command"` + Description string `json:"description,omitempty"` + Enabled bool `json:"enabled"` + Timeout int `json:"timeout,omitempty"` + + // Extra holds keys AO does not manage, captured on unmarshal and written + // back on marshal so they round-trip. encoding/json does not support + // `json:",inline"`, so the round-trip is implemented via the custom + // UnmarshalJSON/MarshalJSON below. + Extra map[string]json.RawMessage `json:"-"` +} + +// UnmarshalJSON decodes the entry's typed fields and captures every key AO does +// not manage into Extra, so a later MarshalJSON can write them back verbatim. +func (e *autohandHookEntry) UnmarshalJSON(data []byte) error { + raw := map[string]json.RawMessage{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + // Decode the managed fields via a type alias to avoid recursing into this + // method, then drop the managed keys so Extra holds only unknown ones. + type managedAlias autohandHookEntry + var managed managedAlias + if err := json.Unmarshal(data, &managed); err != nil { + return err + } + *e = autohandHookEntry(managed) + + for _, key := range autohandManagedHookKeys { + delete(raw, key) + } + if len(raw) > 0 { + e.Extra = raw + } else { + e.Extra = nil + } + return nil +} + +// MarshalJSON writes AO's managed fields merged with any preserved unknown keys +// from Extra. Managed fields win on key collision so AO's values stay +// authoritative. +func (e autohandHookEntry) MarshalJSON() ([]byte, error) { + out := make(map[string]json.RawMessage, len(e.Extra)+len(autohandManagedHookKeys)) + for key, val := range e.Extra { + out[key] = val + } + + type managedAlias autohandHookEntry + managedJSON, err := json.Marshal(managedAlias(e)) + if err != nil { + return nil, err + } + var managed map[string]json.RawMessage + if err := json.Unmarshal(managedJSON, &managed); err != nil { + return nil, err + } + for key, val := range managed { + out[key] = val + } + return json.Marshal(out) +} + +// autohandHookSpec describes one hook AO installs. Event is Autohand's native +// lifecycle event name; Subcommand is the AO hook sub-command appended after the +// command prefix (and the value DeriveActivityState switches on). +type autohandHookSpec struct { + Event string + Subcommand string +} + +// autohandManagedHooks is the source of truth for the hooks AO installs. Each +// native Autohand event is routed to the AO sub-command DeriveActivityState +// understands. Autohand's pre-prompt event is the user-prompt-submit signal. +var autohandManagedHooks = []autohandHookSpec{ + {Event: "session-start", Subcommand: "session-start"}, + {Event: "pre-prompt", Subcommand: "user-prompt-submit"}, + {Event: "permission-request", Subcommand: "permission-request"}, + {Event: "stop", Subcommand: "stop"}, +} + +// GetAgentHooks installs AO's Autohand hooks into the Autohand config's +// hooks.hooks array. Existing user hooks are preserved and duplicate AO commands +// are not appended. The rest of the config (auth, provider, ...) is preserved +// byte-for-byte because only the hooks section is decoded and rewritten. +// +// Autohand loads hooks from a single config file (default ~/.autohand/config.json, +// overridable via AUTOHAND_CONFIG); it does not merge a workspace-local file at +// runtime, so AO installs into that config rather than a per-workspace file. The +// AUTOHAND_CONFIG env var, when set, takes precedence so AO and the agent agree +// on the target. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + + configPath := autohandConfigPath() + topLevel, hooksSection, entries, err := readAutohandHooks(configPath) + if err != nil { + return fmt.Errorf("autohand.GetAgentHooks: %w", err) + } + + for _, spec := range autohandManagedHooks { + command := autohandHookCommandPrefix + spec.Subcommand + if autohandHookCommandExists(entries, command) { + continue + } + entries = append(entries, autohandHookEntry{ + Event: spec.Event, + Command: command, + Description: "AO activity hook", + Enabled: true, + Timeout: autohandHookTimeout, + }) + } + + // Autohand only fires hooks when the hooks section is enabled. + hooksSection["enabled"] = json.RawMessage(`true`) + + if err := writeAutohandHooks(configPath, topLevel, hooksSection, entries); err != nil { + return fmt.Errorf("autohand.GetAgentHooks: %w", err) + } + return nil +} + +// UninstallHooks removes AO's Autohand hooks from the config's hooks.hooks +// array, leaving user-defined hooks and the rest of the config untouched. A +// missing file is a no-op. The hooks.enabled flag is left in place because it +// enables every Autohand hook, not just AO's. +func (p *Plugin) UninstallHooks(ctx context.Context, _ string) error { + if err := ctx.Err(); err != nil { + return err + } + + configPath := autohandConfigPath() + if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) { + return nil + } + topLevel, hooksSection, entries, err := readAutohandHooks(configPath) + if err != nil { + return fmt.Errorf("autohand.UninstallHooks: %w", err) + } + + kept := make([]autohandHookEntry, 0, len(entries)) + for _, entry := range entries { + if !isAutohandManagedHook(entry.Command) { + kept = append(kept, entry) + } + } + + if err := writeAutohandHooks(configPath, topLevel, hooksSection, kept); err != nil { + return fmt.Errorf("autohand.UninstallHooks: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether any AO Autohand hook is present in the +// config. A missing file means none are installed. +func (p *Plugin) AreHooksInstalled(ctx context.Context, _ string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + + configPath := autohandConfigPath() + if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) { + return false, nil + } + _, _, entries, err := readAutohandHooks(configPath) + if err != nil { + return false, fmt.Errorf("autohand.AreHooksInstalled: %w", err) + } + for _, entry := range entries { + if isAutohandManagedHook(entry.Command) { + return true, nil + } + } + return false, nil +} + +// autohandConfigPath returns the config file Autohand loads hooks from: the +// AUTOHAND_CONFIG override if set, else ~/.autohand/config.json. +func autohandConfigPath() string { + if env := strings.TrimSpace(os.Getenv("AUTOHAND_CONFIG")); env != "" { + return env + } + home, err := os.UserHomeDir() + if err != nil { + // Fall back to a relative path; callers surface the resulting error. + return filepath.Join(autohandConfigDirName, autohandConfigFileName) + } + return filepath.Join(home, autohandConfigDirName, autohandConfigFileName) +} + +// readAutohandHooks loads the config into a top-level raw map, the decoded +// "hooks" section (preserving keys AO doesn't manage such as "enabled"), and the +// decoded hooks array. A missing or empty file yields empty maps and a nil +// slice. +func readAutohandHooks(configPath string) (topLevel, hooksSection map[string]json.RawMessage, entries []autohandHookEntry, err error) { + topLevel = map[string]json.RawMessage{} + hooksSection = map[string]json.RawMessage{} + + data, err := os.ReadFile(configPath) //nolint:gosec // path is the user's own Autohand config + if errors.Is(err, os.ErrNotExist) { + return topLevel, hooksSection, nil, nil + } + if err != nil { + return nil, nil, nil, fmt.Errorf("read %s: %w", configPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return topLevel, hooksSection, nil, nil + } + if err := json.Unmarshal(data, &topLevel); err != nil { + return nil, nil, nil, fmt.Errorf("parse %s: %w", configPath, err) + } + if hooksRaw, ok := topLevel["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &hooksSection); err != nil { + return nil, nil, nil, fmt.Errorf("parse hooks in %s: %w", configPath, err) + } + } + if arrRaw, ok := hooksSection["hooks"]; ok { + if err := json.Unmarshal(arrRaw, &entries); err != nil { + return nil, nil, nil, fmt.Errorf("parse hooks array in %s: %w", configPath, err) + } + } + return topLevel, hooksSection, entries, nil +} + +// writeAutohandHooks folds the entries back into the hooks section, the hooks +// section back into topLevel, and writes the file atomically. An empty entries +// slice drops the "hooks" array key. +func writeAutohandHooks(configPath string, topLevel, hooksSection map[string]json.RawMessage, entries []autohandHookEntry) error { + if len(entries) == 0 { + delete(hooksSection, "hooks") + } else { + arrJSON, err := json.Marshal(entries) + if err != nil { + return fmt.Errorf("encode hooks array: %w", err) + } + hooksSection["hooks"] = arrJSON + } + + if len(hooksSection) == 0 { + delete(topLevel, "hooks") + } else { + hooksJSON, err := json.Marshal(hooksSection) + if err != nil { + return fmt.Errorf("encode hooks section: %w", err) + } + topLevel["hooks"] = hooksJSON + } + + if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + data, err := json.MarshalIndent(topLevel, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", configPath, err) + } + data = append(data, '\n') + if err := atomicWriteFile(configPath, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", configPath, err) + } + return nil +} + +// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- +// write can't leave a truncated/empty config that Autohand then fails to parse. +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +func isAutohandManagedHook(command string) bool { + return strings.HasPrefix(command, autohandHookCommandPrefix) +} + +func autohandHookCommandExists(entries []autohandHookEntry, command string) bool { + for _, entry := range entries { + if entry.Command == command { + return true + } + } + return false +} diff --git a/backend/internal/adapters/agent/cline/activity.go b/backend/internal/adapters/agent/cline/activity.go new file mode 100644 index 0000000..5d51238 --- /dev/null +++ b/backend/internal/adapters/agent/cline/activity.go @@ -0,0 +1,32 @@ +package cline + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// DeriveActivityState maps a Cline hook event onto an AO activity state. The +// bool is false when the event carries no activity signal. +// +// event is the AO hook sub-command name installed by clineManagedHooks +// ("session-start", "user-prompt-submit", "permission-request", "stop"), not +// the native Cline event name. Cline currently exposes no stable +// session/process-end hook the adapter installs, so runtime exit still falls +// back to the lifecycle reaper. +// +// TODO(cline): ActivityExited is still runtime-observation-owned. If Cline adds +// a stable native session/process-end hook (e.g. session_shutdown via the CLI +// `cline hook` path), map it to ActivityExited here. Until then, ensure the +// reaper can still mark a dead Cline runtime as exited even when the last hook +// signal was sticky waiting_input. +func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + switch event { + case "session-start": + return domain.ActivityActive, true + case "user-prompt-submit": + return domain.ActivityActive, true + case "stop": + return domain.ActivityIdle, true + case "permission-request": + return domain.ActivityWaitingInput, true + default: + return "", false + } +} diff --git a/backend/internal/adapters/agent/cline/cline.go b/backend/internal/adapters/agent/cline/cline.go new file mode 100644 index 0000000..4dd9903 --- /dev/null +++ b/backend/internal/adapters/agent/cline/cline.go @@ -0,0 +1,261 @@ +// Package cline implements the Cline CLI agent adapter: launching new +// headless sessions, resuming sessions by native session id, installing +// workspace-local Cline hooks, and reading hook-derived session info. +// +// Cline is an autonomous coding agent that runs in the terminal (binary +// "cline", installed via `npm i -g cline`). AO drives it headlessly by passing +// the prompt as a positional argument and requesting NDJSON output with +// `--json`, which Cline emits one event per line for machine parsing. +// +// AO-managed sessions derive native session identity from Cline hooks +// (the workspace-local `.clinerules/hooks/` executable scripts AO installs) +// rather than transcript/cache scans. +package cline + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + clineTitleMetadataKey = "title" + clineSummaryMetadataKey = "summary" +) + +// Plugin is the Cline agent adapter. It is safe for concurrent use; the binary +// path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Cline adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: "cline", + Name: "Cline", + Description: "Run Cline worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Cline exposes none yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new headless Cline session, +// requesting machine-readable NDJSON output (`--json`), applying the approval +// flags, an optional system-prompt override (`-s`), and the initial prompt as +// the trailing positional argument. The prompt is placed after `--` so a +// leading "-" is not read as a flag. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.clineBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary, "--json"} + appendApprovalFlags(&cmd, cfg.Permissions) + + if cfg.SystemPrompt != "" { + cmd = append(cmd, "-s", cfg.SystemPrompt) + } + + if cfg.Prompt != "" { + cmd = append(cmd, "--", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Cline receives its prompt in the +// launch command itself (as a positional argument). +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetRestoreCommand rebuilds the argv that continues an existing Cline session: +// `cline --json [approval flags] --id `. ok is false when the +// hook-derived native session id has not landed yet, so callers can fall back +// to fresh launch behavior. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.clineBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 8) + cmd = append(cmd, binary, "--json") + appendApprovalFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "--id", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Cline hook-derived metadata. Metadata is intentionally +// nil for Cline: callers get the normalized fields directly. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[clineTitleMetadataKey], + Summary: session.Metadata[clineSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// ResolveClineBinary returns the path to the cline binary on this machine, +// searching PATH then a handful of well-known install locations +// (Homebrew, npm global). Returns "cline" as a last-ditch fallback so callers +// see a clear "command not found" rather than an empty argv. +func ResolveClineBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"cline.cmd", "cline.exe", "cline"} { + path, err := exec.LookPath(name) + if err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "cline.cmd"), + filepath.Join(appData, "npm", "cline.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "cline", nil + } + + if path, err := exec.LookPath("cline"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/cline", + "/opt/homebrew/bin/cline", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".npm-global", "bin", "cline"), + filepath.Join(home, ".npm", "bin", "cline"), + filepath.Join(home, ".local", "bin", "cline"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "cline", nil +} + +func (p *Plugin) clineBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveClineBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { + switch normalizePermissionMode(permissions) { + case ports.PermissionModeDefault: + // No flag: defer to the user's Cline config/default behavior. + case ports.PermissionModeAcceptEdits: + // Edit-accepting mode: turn on Cline's auto-approval so edits are + // applied without prompting, matching the AcceptEdits semantics every + // other adapter uses (the more-permissive, edit-accepting mode). + *cmd = append(*cmd, "--auto-approve", "true") + case ports.PermissionModeAuto: + // Auto-approve every tool for unattended runs. + *cmd = append(*cmd, "--auto-approve", "true") + case ports.PermissionModeBypassPermissions: + // yolo mode: auto-approve tools with the restricted (safer) toolset. + *cmd = append(*cmd, "--yolo") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + return ports.PermissionModeDefault + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/cline/cline_test.go b/backend/internal/adapters/agent/cline/cline_test.go new file mode 100644 index 0000000..7b33121 --- /dev/null +++ b/backend/internal/adapters/agent/cline/cline_test.go @@ -0,0 +1,432 @@ +package cline + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-fix this", + SystemPrompt: "be careful", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "cline", + "--json", + "--yolo", + "-s", "be careful", + "--", "-fix this", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + want []string + notExpected string + }{ + { + name: "default", + permission: ports.PermissionModeDefault, + notExpected: "--auto-approve", + }, + { + name: "accept-edits", + permission: ports.PermissionModeAcceptEdits, + want: []string{"--auto-approve", "true"}, + }, + { + name: "auto", + permission: ports.PermissionModeAuto, + want: []string{"--auto-approve", "true"}, + }, + { + name: "bypass-permissions", + permission: ports.PermissionModeBypassPermissions, + want: []string{"--yolo"}, + }, + { + name: "empty", + permission: "", + notExpected: "--auto-approve", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: tt.permission, + }) + if err != nil { + t.Fatal(err) + } + if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) { + t.Fatalf("command %#v does not contain %#v", cmd, tt.want) + } + if tt.notExpected != "" && contains(cmd, tt.notExpected) { + t.Fatalf("command %#v contains %q", cmd, tt.notExpected) + } + }) + } +} + +func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryInCommand { + t.Fatalf("unexpected strategy: %q", got) + } +} + +func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + + spec, err := plugin.GetConfigSpec(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(spec.Fields) != 0 { + t.Fatalf("unexpected config fields: %#v", spec.Fields) + } +} + +func TestManifestIDMatchesHarness(t *testing.T) { + m := (&Plugin{}).Manifest() + if m.ID != "cline" { + t.Fatalf("manifest ID = %q, want %q", m.ID, "cline") + } + if m.Name != "Cline" { + t.Fatalf("manifest Name = %q, want %q", m.Name, "Cline") + } +} + +func TestGetAgentHooksInstallsClineHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + workspace := t.TempDir() + hooksDir := filepath.Join(workspace, clineHooksDirName, clineHooksSubDir) + + // Pre-seed a user's own hook script; it must survive install. + if err := os.MkdirAll(hooksDir, 0o750); err != nil { + t.Fatal(err) + } + userHook := filepath.Join(hooksDir, "PostToolUse") + if err := os.WriteFile(userHook, []byte("#!/usr/bin/env bash\necho '{\"cancel\": false}'\n"), 0o700); err != nil { + t.Fatal(err) + } + + cfg := ports.WorkspaceHookConfig{ + DataDir: t.TempDir(), + SessionID: "sess-1", + WorkspacePath: workspace, + } + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + // A second install must be idempotent (no error, scripts still single). + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + for _, spec := range clineManagedHooks { + scriptPath := filepath.Join(hooksDir, spec.Event) + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("read %s: %v", spec.Event, err) + } + content := string(data) + if !strings.Contains(content, clineHookMarker) { + t.Fatalf("%s missing AO marker:\n%s", spec.Event, content) + } + if !strings.Contains(content, clineHookCommandPrefix+spec.Subcommand) { + t.Fatalf("%s missing forward command %q:\n%s", spec.Event, clineHookCommandPrefix+spec.Subcommand, content) + } + info, err := os.Stat(scriptPath) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0o100 == 0 { + t.Fatalf("%s is not executable: %v", spec.Event, info.Mode()) + } + } + + // User-authored hook untouched. + data, err := os.ReadFile(userHook) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), clineHookMarker) { + t.Fatalf("user PostToolUse hook was overwritten by AO: %s", data) + } +} + +func TestGetAgentHooksRequiresWorkspacePath(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{}); err == nil { + t.Fatal("expected error for empty WorkspacePath") + } +} + +func TestUninstallHooksRemovesClineHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + workspace := t.TempDir() + hooksDir := filepath.Join(workspace, clineHooksDirName, clineHooksSubDir) + + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + + // Pre-seed a user's own hook; it must survive uninstall. + if err := os.MkdirAll(hooksDir, 0o750); err != nil { + t.Fatal(err) + } + userHook := filepath.Join(hooksDir, "PostToolUse") + if err := os.WriteFile(userHook, []byte("#!/usr/bin/env bash\necho '{\"cancel\": false}'\n"), 0o700); err != nil { + t.Fatal(err) + } + + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + + if err := plugin.UninstallHooks(ctx, workspace); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err) + } + + for _, spec := range clineManagedHooks { + if fileExists(filepath.Join(hooksDir, spec.Event)) { + t.Fatalf("%s still present after uninstall", spec.Event) + } + } + if !fileExists(userHook) { + t.Fatal("user PostToolUse hook was removed by uninstall") + } +} + +func TestUninstallHooksMissingDirIsNoOp(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + if err := plugin.UninstallHooks(context.Background(), t.TempDir()); err != nil { + t.Fatalf("uninstall on missing hooks dir = %v, want nil", err) + } +} + +func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "session-123"}, + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + want := []string{ + "cline", + "--json", + "--auto-approve", "true", + "--id", "session-123", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + + cases := []struct { + name string + ref ports.SessionRef + }{ + {"empty session ref", ports.SessionRef{}}, + {"empty metadata", ports.SessionRef{Metadata: map[string]string{}}}, + {"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}}, + {"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: tc.ref, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if cmd != nil { + t.Fatalf("cmd = %#v, want nil", cmd) + } + }) + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "session-123", + clineTitleMetadataKey: "Fix login redirect", + clineSummaryMetadataKey: "Updated the auth callback and tests.", + "ignored": "not returned", + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatalf("ok = false, want true") + } + if info.AgentSessionID != "session-123" { + t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q, want hook title", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q, want hook summary", info.Summary) + } + if info.Metadata != nil { + t.Fatalf("Metadata = %#v, want nil for Cline", info.Metadata) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero value", info) + } +} + +func TestDeriveActivityState(t *testing.T) { + tests := []struct { + event string + want domain.ActivityState + wantOK bool + }{ + {"session-start", domain.ActivityActive, true}, + {"user-prompt-submit", domain.ActivityActive, true}, + {"stop", domain.ActivityIdle, true}, + {"permission-request", domain.ActivityWaitingInput, true}, + {"unknown", "", false}, + {"", "", false}, + } + for _, tt := range tests { + t.Run(tt.event, func(t *testing.T) { + got, ok := DeriveActivityState(tt.event, nil) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, got, ok, tt.want, tt.wantOK) + } + }) + } +} + +func TestContextCancellationIsHonored(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := plugin.GetLaunchCommand(ctx, ports.LaunchConfig{}); err == nil { + // GetLaunchCommand resolves the cached binary first; ctx.Err is checked + // inside ResolveClineBinary only when no cached binary. With a cached + // binary it may not error, so we assert the other methods instead. + _ = err + } + if _, err := plugin.GetConfigSpec(ctx); err == nil { + t.Fatal("GetConfigSpec: expected context error") + } + if _, err := plugin.GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); err == nil { + t.Fatal("GetPromptDeliveryStrategy: expected context error") + } + if _, _, err := plugin.GetRestoreCommand(ctx, ports.RestoreConfig{}); err == nil { + t.Fatal("GetRestoreCommand: expected context error") + } + if _, _, err := plugin.SessionInfo(ctx, ports.SessionRef{}); err == nil { + t.Fatal("SessionInfo: expected context error") + } + if err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{WorkspacePath: "/x"}); err == nil { + t.Fatal("GetAgentHooks: expected context error") + } + if _, err := ResolveClineBinary(ctx); err == nil { + t.Fatal("ResolveClineBinary: expected context error") + } +} + +func contains(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + +func containsSubsequence(values []string, needle []string) bool { + if len(needle) == 0 { + return true + } + + for start := range values { + if start+len(needle) > len(values) { + return false + } + ok := true + for offset, want := range needle { + if values[start+offset] != want { + ok = false + break + } + } + if ok { + return true + } + } + + return false +} diff --git a/backend/internal/adapters/agent/cline/hooks.go b/backend/internal/adapters/agent/cline/hooks.go new file mode 100644 index 0000000..fc2d0fb --- /dev/null +++ b/backend/internal/adapters/agent/cline/hooks.go @@ -0,0 +1,193 @@ +package cline + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Cline's hook system is git-style: each lifecycle hook is an executable script +// placed in the workspace-local `.clinerules/hooks/` directory, named exactly +// after the hook event (no extension), reading a JSON payload on stdin and +// writing a JSON result on stdout (see docs.cline.bot hooks reference). +// +// AO installs one wrapper script per managed event. Each script forwards the +// hook payload to `ao hooks cline ` and emits the no-op +// continuation result Cline expects. Scripts carry a marker line so install is +// idempotent and uninstall recognizes AO-owned scripts without an embedded +// template to diff against; user-authored hooks (lacking the marker) are never +// touched. +const ( + clineHooksDirName = ".clinerules" + clineHooksSubDir = "hooks" + + // clineHookCommandPrefix identifies the hook commands AO owns. The CLI hook + // dispatcher routes "ao hooks cline " to DeriveActivityState. + clineHookCommandPrefix = "ao hooks cline " + + // clineHookMarker tags AO-generated hook scripts so install/uninstall can + // distinguish them from user-authored Cline hooks in the same directory. + clineHookMarker = "# ao-managed-cline-hook" +) + +// clineHookSpec describes one hook AO installs: the native Cline hook event +// (used as the script's filename) and the AO sub-command its wrapper forwards +// to (used by DeriveActivityState). +type clineHookSpec struct { + // Event is the native Cline hook name, which is also the script filename. + Event string + // Subcommand is the fixed AO hook sub-command name the wrapper invokes. + Subcommand string +} + +// clineManagedHooks is the source of truth for the hooks AO installs. The +// native Cline events are mapped onto AO's fixed sub-command names so activity +// derivation stays uniform across adapters: +// - TaskStart -> session-start (a new task begins: active) +// - UserPromptSubmit -> user-prompt-submit (user message submitted: active) +// - PreToolUse -> permission-request (about to act: approval point) +// - TaskCancel -> stop (task cancelled/aborted: idle) +var clineManagedHooks = []clineHookSpec{ + {Event: "TaskStart", Subcommand: "session-start"}, + {Event: "UserPromptSubmit", Subcommand: "user-prompt-submit"}, + {Event: "PreToolUse", Subcommand: "permission-request"}, + {Event: "TaskCancel", Subcommand: "stop"}, +} + +// GetAgentHooks installs AO's Cline hook scripts into the worktree-local +// `.clinerules/hooks/` directory. Existing user-authored hook scripts are +// preserved, and re-running install simply rewrites AO-owned scripts in place. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(cfg.WorkspacePath) == "" { + return errors.New("cline.GetAgentHooks: WorkspacePath is required") + } + + hooksDir := clineHooksDir(cfg.WorkspacePath) + if err := os.MkdirAll(hooksDir, 0o750); err != nil { + return fmt.Errorf("cline.GetAgentHooks: create hook dir: %w", err) + } + + for _, spec := range clineManagedHooks { + scriptPath := filepath.Join(hooksDir, spec.Event) + // Never clobber a user-authored hook with the same event name. + if fileExists(scriptPath) && !isManagedClineHook(scriptPath) { + continue + } + script := renderClineHookScript(spec.Subcommand) + if err := atomicWriteFile(scriptPath, []byte(script), 0o700); err != nil { + return fmt.Errorf("cline.GetAgentHooks: write %s: %w", spec.Event, err) + } + } + return nil +} + +// UninstallHooks removes AO's Cline hook scripts from the workspace-local +// `.clinerules/hooks/` directory, leaving user-authored hooks untouched. A +// missing directory is a no-op. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return errors.New("cline.UninstallHooks: workspacePath is required") + } + + hooksDir := clineHooksDir(workspacePath) + if _, err := os.Stat(hooksDir); errors.Is(err, os.ErrNotExist) { + return nil + } + + for _, spec := range clineManagedHooks { + scriptPath := filepath.Join(hooksDir, spec.Event) + if !fileExists(scriptPath) || !isManagedClineHook(scriptPath) { + continue + } + if err := os.Remove(scriptPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("cline.UninstallHooks: remove %s: %w", spec.Event, err) + } + } + return nil +} + +// AreHooksInstalled reports whether any AO Cline hook script is present in the +// workspace-local hooks directory. A missing directory means none. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if strings.TrimSpace(workspacePath) == "" { + return false, errors.New("cline.AreHooksInstalled: workspacePath is required") + } + + hooksDir := clineHooksDir(workspacePath) + if _, err := os.Stat(hooksDir); errors.Is(err, os.ErrNotExist) { + return false, nil + } + + for _, spec := range clineManagedHooks { + scriptPath := filepath.Join(hooksDir, spec.Event) + if fileExists(scriptPath) && isManagedClineHook(scriptPath) { + return true, nil + } + } + return false, nil +} + +func clineHooksDir(workspacePath string) string { + return filepath.Join(workspacePath, clineHooksDirName, clineHooksSubDir) +} + +// renderClineHookScript builds an executable wrapper that forwards the Cline +// hook payload (JSON on stdin) to the AO CLI hook dispatcher and prints the +// no-op continuation result Cline expects ({"cancel": false}). The marker line +// identifies it as AO-owned. +func renderClineHookScript(subcommand string) string { + var b strings.Builder + b.WriteString("#!/usr/bin/env bash\n") + b.WriteString(clineHookMarker + "\n") + // Forward stdin to the AO dispatcher; ignore its exit code so a missing/old + // `ao` binary can never block Cline's own execution. + b.WriteString(clineHookCommandPrefix + subcommand + " || true\n") + // Cline requires a JSON result on stdout; never block the agent. + b.WriteString(`echo '{"cancel": false}'` + "\n") + return b.String() +} + +func isManagedClineHook(scriptPath string) bool { + data, err := os.ReadFile(scriptPath) //nolint:gosec // path built from caller-owned workspace dir + if err != nil { + return false + } + return strings.Contains(string(data), clineHookMarker) +} + +// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- +// write can't leave a truncated script that Cline then fails to execute. +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} diff --git a/backend/internal/adapters/agent/continueagent/continueagent.go b/backend/internal/adapters/agent/continueagent/continueagent.go new file mode 100644 index 0000000..92dd2e8 --- /dev/null +++ b/backend/internal/adapters/agent/continueagent/continueagent.go @@ -0,0 +1,280 @@ +// Package continueagent implements the Continue CLI agent adapter. +// +// Continue (https://docs.continue.dev/guides/cli) is Continue's terminal coding +// agent. Its binary is "cn" (npm package @continuedev/cli) and the AO harness / +// manifest id is the string "continue". The Go package and directory are named +// "continueagent" because "continue" is a reserved keyword. +// +// Tier B (Claude Code-compatible hooks): the Continue CLI natively reads Claude +// Code hook settings (.claude/settings.json and .claude/settings.local.json) and +// dispatches Claude-format hook events (SessionStart, UserPromptSubmit, +// PreToolUse, PostToolUse, Stop, Notification) with the standard hook payload +// (session_id, hook_event_name, hookSpecificOutput, permissionDecision, +// additionalContext). So we reuse the claudecode hook installer and route hook +// callbacks through the existing "ao hooks claude-code " dispatcher — no +// Continue-specific native hook config or activity deriver is needed. +// +// Launch is headless via `cn --print [--auto|--readonly] `; the prompt +// is the positional argument (in-command delivery). Restore continues a specific +// native session by id with `cn --fork ` (Continue's `--resume` only +// continues the *last* session, so it cannot target a particular AO session). +package continueagent + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// adapterID is the AO harness / manifest id. It is the string "continue" +// (NOT the Go package name "continueagent"). +const adapterID = "continue" + +// Plugin is the Continue CLI agent adapter. It is safe for concurrent use; the +// binary path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Continue adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. ID is "continue". +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Continue", + Description: "Run Continue CLI worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports no agent-specific config keys yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds `cn --print [--auto|--readonly] `. +// +// `--print` runs Continue in non-interactive (headless) mode. The prompt is the +// positional argument and is delivered in-command. Permission flags map AO's 4 +// modes onto Continue's two booleans (--auto / --readonly); Default and +// AcceptEdits emit no flag so Continue resolves behavior from the user's config. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.continueBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary, "--print"} + appendApprovalFlags(&cmd, cfg.Permissions) + + if cfg.Prompt != "" { + cmd = append(cmd, "--", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetAgentHooks reuses the Claude Code hook installer because the Continue CLI +// natively reads Claude Code hook settings. +// +// The installed commands are "ao hooks claude-code ", so the existing CLI +// hook dispatcher routes them to the claude derive logic. The Continue CLI reads +// .claude/settings.local.json from the worktree and fires Claude-format events +// (SessionStart / UserPromptSubmit / Stop / Notification), giving AO +// title/summary/agentSessionId + activity for free without a Continue-specific +// hook implementation or code duplication. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + return (&claudecode.Plugin{}).GetAgentHooks(ctx, cfg) +} + +// GetRestoreCommand builds `cn --print [--auto|--readonly] --fork ` +// when a hook-captured native session id is available. ok=false otherwise (the +// manager falls back to a fresh launch). `--fork ` continues a specific +// session by id; Continue's `--resume` only continues the last session and so +// cannot target a particular AO session. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.continueBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 4) + cmd = append(cmd, binary, "--print") + appendApprovalFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "--fork", agentSessionID) + return cmd, true, nil +} + +// SessionInfo reads hook-derived metadata. Since hook install is delegated to +// the claude hooks (via Continue's compat layer), the metadata keys are the +// claude ones ("title", "summary", "agentSessionId"). +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[ports.MetadataKeyTitle], + Summary: session.Metadata[ports.MetadataKeySummary], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// ResolveContinueBinary finds the `cn` binary (Continue CLI), searching PATH then +// common npm/global install locations. It returns "cn" as a last resort so +// callers get the shell's normal command-not-found behavior if Continue is +// absent. +func ResolveContinueBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"cn.cmd", "cn.exe", "cn"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "cn.cmd"), + filepath.Join(appData, "npm", "cn.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + return "cn", nil + } + + if path, err := exec.LookPath("cn"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/cn", + "/opt/homebrew/bin/cn", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".npm-global", "bin", "cn"), + filepath.Join(home, ".local", "bin", "cn"), + filepath.Join(home, ".npm", "bin", "cn"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "cn", nil +} + +func (p *Plugin) continueBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveContinueBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +// appendApprovalFlags maps AO's 4 permission modes onto Continue's two boolean +// flags. Continue exposes only `--readonly` (plan mode, read-only tools) and +// `--auto` (all tools allowed); there is no separate yolo/bypass beyond --auto, +// and the two flags are mutually exclusive. Default and AcceptEdits emit no flag +// so Continue defers to the user's own config / default behavior. +func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { + switch normalizePermissionMode(permissions) { + case ports.PermissionModeDefault: + // No flag: defer to the user's Continue config / default behavior. + case ports.PermissionModeAcceptEdits: + // Continue has no granular "accept edits only" mode; defer to config. + case ports.PermissionModeAuto: + *cmd = append(*cmd, "--auto") + case ports.PermissionModeBypassPermissions: + *cmd = append(*cmd, "--auto") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + return ports.PermissionModeDefault + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/continueagent/continueagent_test.go b/backend/internal/adapters/agent/continueagent/continueagent_test.go new file mode 100644 index 0000000..1cb1465 --- /dev/null +++ b/backend/internal/adapters/agent/continueagent/continueagent_test.go @@ -0,0 +1,269 @@ +package continueagent + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifest(t *testing.T) { + m := (&Plugin{}).Manifest() + if m.ID != "continue" { + t.Fatalf("ID = %q, want continue", m.ID) + } + if m.Name != "Continue" { + t.Fatalf("Name = %q, want Continue", m.Name) + } + hasAgent := false + for _, c := range m.Capabilities { + if c == adapters.CapabilityAgent { + hasAgent = true + } + } + if !hasAgent { + t.Fatal("missing CapabilityAgent") + } +} + +func TestGetConfigSpecEmpty(t *testing.T) { + spec, err := (&Plugin{}).GetConfigSpec(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(spec.Fields) != 0 { + t.Fatalf("expected no fields, got %d", len(spec.Fields)) + } +} + +func TestGetPromptDeliveryStrategy(t *testing.T) { + s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if s != ports.PromptDeliveryInCommand { + t.Fatalf("strategy = %q, want in_command", s) + } +} + +func TestGetLaunchCommandBypass(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cn"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "do the thing", + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"cn", "--print", "--auto", "--", "do the thing"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandAuto(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cn"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "refactor auth", + Permissions: ports.PermissionModeAuto, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"cn", "--print", "--auto", "--", "refactor auth"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandDefaultPerms(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cn"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "fix it", + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"cn", "--print", "--", "fix it"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } + joined := strings.Join(cmd, " ") + if strings.Contains(joined, "--auto") || strings.Contains(joined, "--readonly") { + t.Fatal("should not emit a permission flag for default perms") + } +} + +func TestGetLaunchCommandAcceptEditsNoFlag(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cn"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "tidy up", + Permissions: ports.PermissionModeAcceptEdits, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"cn", "--print", "--", "tidy up"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v (accept-edits should emit no flag)", cmd, want) + } +} + +func TestGetLaunchCommandNoPrompt(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cn"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"cn", "--print"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + // Force binary resolution (unset cache) so ctx.Err() is hit. + _, err := (&Plugin{}).GetLaunchCommand(ctx, ports.LaunchConfig{Prompt: "x"}) + if err == nil { + t.Fatal("expected error from canceled context, got nil") + } +} + +func TestGetRestoreCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cn"} + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "sess-abc123", + }, + }, + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Fatal("ok=false, want true") + } + want := []string{"cn", "--print", "--auto", "--fork", "sess-abc123"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommandDefaultPerms(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cn"} + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "sess-xyz", + }, + }, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Fatal("ok=false, want true") + } + want := []string{"cn", "--print", "--fork", "sess-xyz"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommandNoID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cn"} + _, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{Metadata: map[string]string{}}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatal("ok=true with no agentSessionId, want false") + } +} + +func TestGetRestoreCommandWhitespaceID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cn"} + _, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: " ", + }}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatal("ok=true with whitespace agentSessionId, want false") + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cn"} + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "cn-ses-1", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", + }, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Fatal("ok=false, want true") + } + if info.AgentSessionID != "cn-ses-1" { + t.Fatalf("AgentSessionID = %q, want cn-ses-1", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q", info.Summary) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cn"} + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatalf("ok=true with empty metadata, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero", info) + } +} + +func TestGetAgentHooksDelegates(t *testing.T) { + // We don't exercise the full hook merge here (claude tests cover it); just + // ensure delegation is wired and succeeds against a temp workspace. + plugin := &Plugin{resolvedBinary: "cn"} + ws := t.TempDir() + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{ + WorkspacePath: ws, + SessionID: "continue-test-1", + }); err != nil { + t.Fatalf("GetAgentHooks: %v", err) + } +} + +func TestResolveContinueBinaryContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := ResolveContinueBinary(ctx); err == nil { + t.Fatal("expected error from canceled context, got nil") + } +} diff --git a/backend/internal/adapters/agent/crush/activity.go b/backend/internal/adapters/agent/crush/activity.go new file mode 100644 index 0000000..d02dc2d --- /dev/null +++ b/backend/internal/adapters/agent/crush/activity.go @@ -0,0 +1,14 @@ +package crush + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// DeriveActivityState maps a Crush hook event onto an AO activity state. +// Currently a no-op since Crush doesn't have full hooks support like Claude Code and Codex. +// The bool is false to indicate no activity signal is available. +// +// TODO(crush): Implement activity state mapping once Crush has native hook support. +// Until then, runtime exit falls back to the reaper. +func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + // No-op for now since Crush doesn't have full hooks support + return "", false +} diff --git a/backend/internal/adapters/agent/crush/activity_test.go b/backend/internal/adapters/agent/crush/activity_test.go new file mode 100644 index 0000000..81f1595 --- /dev/null +++ b/backend/internal/adapters/agent/crush/activity_test.go @@ -0,0 +1,15 @@ +package crush + +import ( + "testing" +) + +func TestDeriveActivityStateReturnsFalse(t *testing.T) { + state, ok := DeriveActivityState("some-event", []byte("payload")) + if ok { + t.Fatalf("unexpected ok: got true, want false (DeriveActivityState is a no-op for Crush)") + } + if state != "" { + t.Fatalf("unexpected non-empty state: got %q", state) + } +} diff --git a/backend/internal/adapters/agent/crush/crush.go b/backend/internal/adapters/agent/crush/crush.go new file mode 100644 index 0000000..da01e75 --- /dev/null +++ b/backend/internal/adapters/agent/crush/crush.go @@ -0,0 +1,243 @@ +// Package crush implements the Crush agent adapter: launching new sessions, +// resuming sessions by native ID, and reading session info. +// +// Crush differs from other agents in that it doesn't have full hooks support, +// so GetAgentHooks and SessionInfo are no-ops for now. Session tracking is +// done through basic session ID management only. +package crush + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // adapterID is the registry id and the value users pass to + // `ao spawn --agent`. It matches domain.HarnessCrush. + adapterID = "crush" +) + +// Plugin is the Crush agent adapter. It is safe for concurrent use; the +// binary path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Crush adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Crush", + Description: "Run Crush worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Crush exposes none yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start an interactive Crush session. +// Shape: +// +// crush [--cwd ] [--yolo] [-- ] +// +// The session runs in the worktree (cwd is set by the runtime). Crush doesn't +// have native system prompt support, so cfg.SystemPrompt / SystemPromptFile are +// intentionally ignored. The initial task prompt is delivered as a positional +// argument after `--`. The --yolo flag corresponds to bypass-permissions mode. +// +// We intentionally do not pass --session on launch: cfg.SessionID is the +// AO-internal id, not a Crush-native session id. Letting Crush mint its own +// native session id (captured by hooks into session metadata) keeps launch +// consistent with GetRestoreCommand, which resumes using that native id. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.crushBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + + // Crush uses --cwd to set working directory + if cfg.WorkspacePath != "" { + cmd = append(cmd, "--cwd", cfg.WorkspacePath) + } + + // Handle permission modes + if cfg.Permissions == ports.PermissionModeBypassPermissions { + cmd = append(cmd, "--yolo") + } + + // Prompt is passed after `--` so a leading "-" is not read as a flag + if cfg.Prompt != "" { + cmd = append(cmd, "--", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Crush receives its prompt in the +// launch command itself as a positional argument. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetRestoreCommand rebuilds the argv that continues an existing Crush session: +// `crush [--cwd ] [--yolo] --session `. +// It re-applies the permission flag but not the prompt, which the session +// already carries. ok is false when the native session id is not available. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.crushBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = []string{binary} + + if cfg.Session.WorkspacePath != "" { + cmd = append(cmd, "--cwd", cfg.Session.WorkspacePath) + } + + if cfg.Permissions == ports.PermissionModeBypassPermissions { + cmd = append(cmd, "--yolo") + } + + cmd = append(cmd, "--session", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Crush session metadata. Currently a no-op since Crush +// doesn't have full hooks support like Claude Code and Codex. Returns false +// to indicate no metadata is available. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + // No-op for now since Crush doesn't have full hooks support + return ports.SessionInfo{}, false, nil +} + +// ResolveCrushBinary returns the path to the crush binary on this machine, +// searching PATH then a handful of well-known install locations. +// Returns "crush" as a last-ditch fallback. +func ResolveCrushBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"crush.cmd", "crush.exe", "crush"} { + path, err := exec.LookPath(name) + if err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "crush.cmd"), + filepath.Join(appData, "npm", "crush.exe"), + ) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "crush.exe")) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "crush", nil + } + + if path, err := exec.LookPath("crush"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/crush", + "/opt/homebrew/bin/crush", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "crush"), + filepath.Join(home, ".cargo", "bin", "crush"), + filepath.Join(home, ".npm", "bin", "crush"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "crush", nil +} + +func (p *Plugin) crushBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveCrushBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/crush/crush_test.go b/backend/internal/adapters/agent/crush/crush_test.go new file mode 100644 index 0000000..45756dc --- /dev/null +++ b/backend/internal/adapters/agent/crush/crush_test.go @@ -0,0 +1,263 @@ +package crush + +import ( + "context" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "crush"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "fix this", + WorkspacePath: "/tmp/workspace", + SessionID: "test-session-id", + }) + if err != nil { + t.Fatal(err) + } + + // cfg.SessionID is the AO-internal id and must NOT be passed as --session on + // launch; Crush mints its own native id, which GetRestoreCommand resumes by. + want := []string{ + "crush", + "--cwd", "/tmp/workspace", + "--yolo", + "--", "fix this", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + want []string + notExpected string + }{ + { + name: "default", + permission: ports.PermissionModeDefault, + notExpected: "--yolo", + }, + { + name: "accept-edits", + permission: ports.PermissionModeAcceptEdits, + want: nil, // Crush doesn't have granular permission modes + notExpected: "--yolo", + }, + { + name: "auto", + permission: ports.PermissionModeAuto, + want: nil, // Crush doesn't have granular permission modes + notExpected: "--yolo", + }, + { + name: "bypass-permissions", + permission: ports.PermissionModeBypassPermissions, + want: []string{"--yolo"}, + }, + { + name: "empty", + permission: "", + notExpected: "--yolo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "crush"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: tt.permission, + }) + if err != nil { + t.Fatal(err) + } + if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) { + t.Fatalf("command %#v does not contain %#v", cmd, tt.want) + } + if tt.notExpected != "" && contains(cmd, tt.notExpected) { + t.Fatalf("command %#v contains %q", cmd, tt.notExpected) + } + }) + } +} + +func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "crush"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + + if got != ports.PromptDeliveryInCommand { + t.Fatalf("unexpected prompt delivery strategy: got %v, want %v", got, ports.PromptDeliveryInCommand) + } +} + +func TestGetRestoreCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "crush"} + + tests := []struct { + name string + agentSessionID string + workspacePath string + permission ports.PermissionMode + wantOk bool + wantContains []string + }{ + { + name: "restore with session id", + agentSessionID: "crush-session-123", + workspacePath: "/tmp/workspace", + permission: ports.PermissionModeDefault, + wantOk: true, + wantContains: []string{"--cwd", "/tmp/workspace", "--session", "crush-session-123"}, + }, + { + name: "restore with bypass permissions", + agentSessionID: "crush-session-456", + workspacePath: "/tmp/workspace", + permission: ports.PermissionModeBypassPermissions, + wantOk: true, + wantContains: []string{"--cwd", "/tmp/workspace", "--yolo", "--session", "crush-session-456"}, + }, + { + name: "no session id", + agentSessionID: "", + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{"agentSessionId": tt.agentSessionID}, + WorkspacePath: tt.workspacePath, + }, + Permissions: tt.permission, + }) + if err != nil { + t.Fatal(err) + } + if ok != tt.wantOk { + t.Fatalf("unexpected ok: got %v, want %v", ok, tt.wantOk) + } + if tt.wantOk && len(tt.wantContains) > 0 && !containsSubsequence(cmd, tt.wantContains) { + t.Fatalf("command %#v does not contain %#v", cmd, tt.wantContains) + } + }) + } +} + +func TestSessionInfoReturnsFalse(t *testing.T) { + plugin := &Plugin{} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + ID: "session-123", + Metadata: map[string]string{"agentSessionId": "crush-session-123"}, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatalf("unexpected ok: got true, want false (SessionInfo is a no-op for Crush)") + } + if info.AgentSessionID != "" || info.Title != "" || info.Summary != "" { + t.Fatalf("unexpected non-empty info: got %#v", info) + } +} + +func TestManifest(t *testing.T) { + plugin := &Plugin{} + + manifest := plugin.Manifest() + if manifest.ID != adapterID { + t.Fatalf("unexpected manifest ID: got %q, want %q", manifest.ID, adapterID) + } + if manifest.Name != "Crush" { + t.Fatalf("unexpected manifest name: got %q, want \"Crush\"", manifest.Name) + } + if len(manifest.Capabilities) != 1 { + t.Fatalf("unexpected capabilities count: got %d, want 1", len(manifest.Capabilities)) + } +} + +func TestGetConfigSpecReturnsEmpty(t *testing.T) { + plugin := &Plugin{} + + spec, err := plugin.GetConfigSpec(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(spec.Fields) != 0 { + t.Fatalf("unexpected config spec fields: got %d, want 0", len(spec.Fields)) + } +} + +func TestGetAgentHooksIsNoOp(t *testing.T) { + plugin := &Plugin{} + + err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{ + WorkspacePath: "/tmp/workspace", + }) + if err != nil { + t.Fatalf("unexpected error from GetAgentHooks (no-op): %v", err) + } +} + +func TestUninstallHooksIsNoOp(t *testing.T) { + plugin := &Plugin{} + + err := plugin.UninstallHooks(context.Background(), "/tmp/workspace") + if err != nil { + t.Fatalf("unexpected error from UninstallHooks (no-op): %v", err) + } +} + +func TestAreHooksInstalledReturnsFalse(t *testing.T) { + plugin := &Plugin{} + + installed, err := plugin.AreHooksInstalled(context.Background(), "/tmp/workspace") + if err != nil { + t.Fatalf("unexpected error from AreHooksInstalled (no-op): %v", err) + } + if installed { + t.Fatalf("unexpected installed status: got true, want false (hooks are no-op for Crush)") + } +} + +// Helper functions from codex_test.go + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +func containsSubsequence(haystack, needle []string) bool { + for i := 0; i <= len(haystack)-len(needle); i++ { + match := true + for j, n := range needle { + if haystack[i+j] != n { + match = false + break + } + } + if match { + return true + } + } + return false +} diff --git a/backend/internal/adapters/agent/crush/hooks.go b/backend/internal/adapters/agent/crush/hooks.go new file mode 100644 index 0000000..fc00da6 --- /dev/null +++ b/backend/internal/adapters/agent/crush/hooks.go @@ -0,0 +1,39 @@ +package crush + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// GetAgentHooks is a no-op for Crush since it doesn't have full hooks support +// like Claude Code and Codex. Crush doesn't have a native hook configuration system +// that AO can integrate with for session metadata tracking. +// +// TODO(crush): Implement hook installation once Crush has native hook support. +// Until then, session metadata tracking is not available. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + // No-op for now since Crush doesn't have full hooks support + return nil +} + +// UninstallHooks is a no-op for Crush. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + // No-op for now since Crush doesn't have full hooks support + return nil +} + +// AreHooksInstalled is a no-op for Crush. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + // No-op for now since Crush doesn't have full hooks support + return false, nil +} diff --git a/backend/internal/adapters/agent/devin/devin.go b/backend/internal/adapters/agent/devin/devin.go new file mode 100644 index 0000000..e20d609 --- /dev/null +++ b/backend/internal/adapters/agent/devin/devin.go @@ -0,0 +1,282 @@ +// Package devin implements the Devin ("Devin for Terminal", Cognition) agent +// adapter. +// +// Devin for Terminal (binary "devin") is Cognition's terminal coding agent. It +// has a documented Claude Code compatibility layer: it imports `.claude/` +// configuration (commands, subagents, and Claude Code lifecycle hooks), storing +// the converted hooks in `.devin/hooks.v1.json`. Because of this, AO reuses the +// Claude Code hook installer (which writes .claude/settings.local.json with AO +// hook commands) and Devin picks them up via its compat layer. This makes Devin +// a Tier B (Claude-compat) adapter, mirroring the grok adapter. +// +// Launch uses `-p ` for the initial task in non-interactive/print mode +// (in-command delivery). Permission handling uses `--permission-mode`, whose +// valid values are `normal` (aliases: auto) and `dangerous` (aliases: yolo, +// bypass). AO's four permission modes are mapped onto these two: Default emits +// no flag (defer to the user's ~/.config/devin/config.json), AcceptEdits/Auto +// map to `auto`, and BypassPermissions maps to `dangerous`. +// +// Restore prefers the hook-captured native session id via `-r `. Devin +// session ids are listed by `devin list --format json`; AO captures the native +// id through the Claude-compat hook payloads (SessionStart) into session +// metadata, the same path grok uses. +package devin + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + devinTitleMetadataKey = "title" + devinSummaryMetadataKey = "summary" +) + +// Plugin is the Devin for Terminal agent adapter. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Devin adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: "devin", + Name: "Devin", + Description: "Run Cognition Devin for Terminal worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports no agent-specific config keys yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds `devin [--permission-mode ] -p `. +// Prompt is delivered via -p (in command, non-interactive print mode). +// +// Permission values come from `devin --permission-mode -h`: +// `normal` (alias auto) and `dangerous` (aliases yolo, bypass). Default omits +// the flag so Devin uses its config (default mode is auto/normal). +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.devinBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + appendApprovalFlags(&cmd, cfg.Permissions) + + if cfg.Prompt != "" { + cmd = append(cmd, "-p", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetAgentHooks reuses the Claude Code hook installer because Devin for Terminal +// has a documented Claude Code compatibility layer. +// +// Official docs (https://docs.devin.ai/cli, Configuration Import / Extensibility): +// Devin reads configuration from `.claude/` including "Commands, custom +// subagents, hooks"; its "Lifecycle hooks (Claude Code compatible)" are stored +// in `.devin/hooks.v1.json`. The binary itself ships a +// `config-importers/.../claude` + `agent-ext/hooks/importers/claude` layer that +// converts Claude hooks (SessionStart, UserPromptSubmit, Stop, PermissionRequest, +// SessionEnd, ...) on load. +// +// This means Devin picks up the .claude/settings.local.json (and the AO hook +// commands we install there) in the worktree. The installed commands are +// "ao hooks claude-code ", so the existing CLI hook dispatcher routes them +// to claude derive logic (Devin is grouped with claude-code in cli/hooks.go). +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + return (&claudecode.Plugin{}).GetAgentHooks(ctx, cfg) +} + +// GetRestoreCommand builds `devin [--permission-mode ] -r ` +// when we have a hook-captured native id. ok=false otherwise (fall back to fresh +// launch in the manager). +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.devinBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 5) + cmd = append(cmd, binary) + appendApprovalFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "-r", agentSessionID) + return cmd, true, nil +} + +// SessionInfo reads hook-derived metadata. Since we delegate hook install to +// claude hooks (via compat), the keys in the metadata map are the claude ones +// ("title", "summary", "agentSessionId"). We surface them under the normalized +// SessionInfo. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[devinTitleMetadataKey], + Summary: session.Metadata[devinSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// ResolveDevinBinary finds the `devin` binary (Cognition Devin for Terminal CLI). +func ResolveDevinBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"devin.cmd", "devin.exe", "devin"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + candidates := []string{} + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".devin", "bin", "devin.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + return "devin", nil + } + + if path, err := exec.LookPath("devin"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/devin", + "/opt/homebrew/bin/devin", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".devin", "bin", "devin"), + filepath.Join(home, ".local", "bin", "devin"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "devin", nil +} + +func (p *Plugin) devinBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveDevinBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +// appendApprovalFlags maps AO's four permission modes onto Devin's two native +// permission values (`auto`/normal and `dangerous`/bypass), per +// `devin --permission-mode -h`. +func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { + switch normalizePermissionMode(permissions) { + case ports.PermissionModeDefault: + // No flag: defer to ~/.config/devin/config.json (default mode is auto). + case ports.PermissionModeAcceptEdits: + // Devin has no dedicated accept-edits flag; auto prompts for writes, + // which is the safest non-default mapping. + *cmd = append(*cmd, "--permission-mode", "auto") + case ports.PermissionModeAuto: + *cmd = append(*cmd, "--permission-mode", "auto") + case ports.PermissionModeBypassPermissions: + *cmd = append(*cmd, "--permission-mode", "dangerous") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + return ports.PermissionModeDefault + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/devin/devin_test.go b/backend/internal/adapters/agent/devin/devin_test.go new file mode 100644 index 0000000..4510c7d --- /dev/null +++ b/backend/internal/adapters/agent/devin/devin_test.go @@ -0,0 +1,274 @@ +package devin + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifest(t *testing.T) { + m := (&Plugin{}).Manifest() + if m.ID != "devin" { + t.Fatalf("ID = %q, want devin", m.ID) + } + if m.Name != "Devin" { + t.Fatalf("Name = %q", m.Name) + } + hasAgent := false + for _, c := range m.Capabilities { + if c == adapters.CapabilityAgent { + hasAgent = true + } + } + if !hasAgent { + t.Fatal("missing CapabilityAgent") + } +} + +func TestGetConfigSpecEmpty(t *testing.T) { + spec, err := (&Plugin{}).GetConfigSpec(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(spec.Fields) != 0 { + t.Fatalf("expected no fields, got %d", len(spec.Fields)) + } +} + +func TestGetConfigSpecCtxCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := (&Plugin{}).GetConfigSpec(ctx); err == nil { + t.Fatal("expected ctx error, got nil") + } +} + +func TestGetPromptDeliveryStrategy(t *testing.T) { + s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if s != ports.PromptDeliveryInCommand { + t.Fatalf("strategy = %q, want in_command", s) + } +} + +func TestGetLaunchCommandBypass(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "do the thing", + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"devin", "--permission-mode", "dangerous", "-p", "do the thing"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandDefaultPerms(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "fix it", + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"devin", "-p", "fix it"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } + if strings.Contains(strings.Join(cmd, " "), "permission-mode") { + t.Fatal("should not have --permission-mode for default perms") + } +} + +func TestGetLaunchCommandAcceptEdits(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "refactor auth", + Permissions: ports.PermissionModeAcceptEdits, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"devin", "--permission-mode", "auto", "-p", "refactor auth"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandAuto(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "ship it", + Permissions: ports.PermissionModeAuto, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"devin", "--permission-mode", "auto", "-p", "ship it"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandNoPrompt(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"devin"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandCtxCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := (&Plugin{}).GetLaunchCommand(ctx, ports.LaunchConfig{Prompt: "x"}); err == nil { + t.Fatal("expected ctx error, got nil") + } +} + +func TestGetRestoreCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "sess-abc123", + }, + }, + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Fatal("ok=false, want true") + } + want := []string{"devin", "--permission-mode", "dangerous", "-r", "sess-abc123"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommandNoID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + _, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{Metadata: map[string]string{}}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatal("ok=true with no agentSessionId, want false") + } +} + +func TestGetRestoreCommandWhitespaceID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + _, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: " ", + }}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatal("ok=true with whitespace agentSessionId, want false") + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "devin-ses-1", + devinTitleMetadataKey: "Fix login redirect", + devinSummaryMetadataKey: "Updated the auth callback and tests.", + }, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Fatal("ok=false, want true") + } + if info.AgentSessionID != "devin-ses-1" { + t.Fatalf("AgentSessionID = %q, want devin-ses-1", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q", info.Summary) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "devin"} + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatalf("ok=true with empty metadata, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero", info) + } +} + +func TestGetAgentHooksDelegates(t *testing.T) { + // We don't exercise the full hook merge here (claude tests cover it); + // just ensure it doesn't blow up on a temp workspace and that the + // method is wired (real hook install is exercised via claude delegation). + plugin := &Plugin{resolvedBinary: "devin"} + ws := t.TempDir() + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{ + WorkspacePath: ws, + SessionID: "devin-test-1", + }); err != nil { + t.Fatalf("GetAgentHooks: %v", err) + } +} + +func TestGetAgentHooksCtxCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := (&Plugin{}).GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); err == nil { + t.Fatal("expected ctx error, got nil") + } +} + +func TestResolveDevinBinaryFallback(t *testing.T) { + // When devin is not on PATH or well-known locations, fall back to the bare + // name so exec can still attempt to launch it. + bin, err := ResolveDevinBinary(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if bin == "" { + t.Fatal("empty binary path") + } +} + +func TestResolveDevinBinaryCtxCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := ResolveDevinBinary(ctx); err == nil { + t.Fatal("expected ctx error, got nil") + } +} diff --git a/backend/internal/adapters/agent/droid/activity.go b/backend/internal/adapters/agent/droid/activity.go new file mode 100644 index 0000000..500eaaf --- /dev/null +++ b/backend/internal/adapters/agent/droid/activity.go @@ -0,0 +1,60 @@ +package droid + +import ( + "encoding/json" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// DeriveActivityState maps a Droid hook event (and its native stdin payload) +// onto an AO activity state. The bool is false when the event carries no +// activity signal — e.g. SessionStart (metadata only) or a SessionEnd reason +// that doesn't actually end the AO session — in which case the caller reports +// nothing. +// +// event is the AO hook sub-command name installed in droidManagedHooks +// ("user-prompt-submit", "stop", "notification", "session-end", ...), NOT the +// native Droid event name. Keeping this beside hooks.go means the events AO +// installs and what they mean live in one place. +// +// Droid's payload shapes differ from Claude Code's in one way that matters here: +// the Notification payload carries no notification_type discriminator (it only +// has a free-form message), but Droid only fires Notification when it needs a +// permission decision or has been idle awaiting input for 60s — both mean the +// agent is blocked on the user — so every Notification maps to waiting_input. +func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bool) { + switch event { + case "user-prompt-submit": + return domain.ActivityActive, true + case "stop": + // End of a turn: the agent is idle but alive (not exited). A following + // Notification upgrades this to the sticky waiting_input. + return domain.ActivityIdle, true + case "notification": + return domain.ActivityWaitingInput, true + case "session-end": + return sessionEndState(payload) + default: + return "", false + } +} + +// sessionEndState reports exited for reasons that actually end the session. +// "clear" keeps the same AO session alive (a new native session continues in +// the worktree), so it reports nothing. Any other reason — logout, +// prompt_input_exit, other, or an absent/unknown reason on a SessionEnd that did +// fire — is treated as a real exit. SessionEnd is not guaranteed on crash, so +// the reaper remains the backstop; both paths guard on IsTerminated, so +// whichever lands first wins. +func sessionEndState(payload []byte) (domain.ActivityState, bool) { + var p struct { + Reason string `json:"reason"` + } + _ = json.Unmarshal(payload, &p) + switch p.Reason { + case "clear": + return "", false + default: + return domain.ActivityExited, true + } +} diff --git a/backend/internal/adapters/agent/droid/activity_test.go b/backend/internal/adapters/agent/droid/activity_test.go new file mode 100644 index 0000000..0581094 --- /dev/null +++ b/backend/internal/adapters/agent/droid/activity_test.go @@ -0,0 +1,42 @@ +package droid + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestDeriveActivityState(t *testing.T) { + tests := []struct { + name string + event string + payload string + want domain.ActivityState + wantOK bool + }{ + {"user prompt -> active", "user-prompt-submit", `{}`, domain.ActivityActive, true}, + {"stop -> idle", "stop", `{}`, domain.ActivityIdle, true}, + // Droid notifications fire only on permission-needed or 60s-idle, both of + // which mean the agent is blocked on the user — and the payload carries no + // notification_type to discriminate — so every notification is waiting_input. + {"notification -> waiting_input", "notification", `{"message":"Droid needs your permission"}`, domain.ActivityWaitingInput, true}, + {"notification empty payload -> waiting_input", "notification", `{}`, domain.ActivityWaitingInput, true}, + {"notification malformed payload -> waiting_input", "notification", `not json`, domain.ActivityWaitingInput, true}, + {"session-end logout -> exited", "session-end", `{"reason":"logout"}`, domain.ActivityExited, true}, + {"session-end prompt_input_exit -> exited", "session-end", `{"reason":"prompt_input_exit"}`, domain.ActivityExited, true}, + {"session-end other -> exited", "session-end", `{"reason":"other"}`, domain.ActivityExited, true}, + {"session-end absent reason -> exited", "session-end", `{}`, domain.ActivityExited, true}, + {"session-end clear -> no signal", "session-end", `{"reason":"clear"}`, "", false}, + {"session-start -> no signal", "session-start", `{}`, "", false}, + {"unknown event -> no signal", "frobnicate", `{}`, "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := DeriveActivityState(tt.event, []byte(tt.payload)) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("DeriveActivityState(%q, %q) = (%q, %v), want (%q, %v)", + tt.event, tt.payload, got, ok, tt.want, tt.wantOK) + } + }) + } +} diff --git a/backend/internal/adapters/agent/droid/droid.go b/backend/internal/adapters/agent/droid/droid.go new file mode 100644 index 0000000..7315d4f --- /dev/null +++ b/backend/internal/adapters/agent/droid/droid.go @@ -0,0 +1,353 @@ +// Package droid implements the Droid (Factory) agent adapter: launching new +// interactive sessions, resuming hook-tracked sessions, installing +// workspace-local hooks, and reading hook-derived session info. +// +// Droid is Factory's terminal coding agent (binary "droid"). Unlike Grok it has +// no Claude Code compatibility layer, so AO installs its own hooks into the +// worktree-local .factory/hooks.json (see hooks.go). The hook JSON structure +// matches Claude Code's, but Droid's Notification payload omits notification_type +// and its hooks live under .factory/, so the adapter ships its own activity +// deriver (see activity.go) rather than reusing Claude's. +// +// Launch uses the interactive `droid [prompt]` command (the prompt is a +// positional argument). Droid's interactive TUI exposes no per-launch permission +// flag (--auto / --skip-permissions-unsafe live only on `droid exec`), so AO's +// graduated permission modes are delivered by writing a process-scoped runtime +// settings file (sessionDefaultSettings.autonomyLevel) and passing it via the +// root `--settings ` flag. Restore prefers the hook-captured native +// session id via `-r `. +package droid + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // Normalized session-metadata keys the hooks persist into the AO session + // store and SessionInfo reads back. Shared vocabulary with the Codex, Grok, + // and opencode adapters so the dashboard treats every agent uniformly. + droidTitleMetadataKey = "title" + droidSummaryMetadataKey = "summary" +) + +// Plugin is the Droid agent adapter. It is safe for concurrent use; the binary +// path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Droid adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: "droid", + Name: "Droid", + Description: "Run Factory Droid worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports no agent-specific config keys yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new interactive Droid session: +// +// droid [--settings ] [--append-system-prompt[-file] ] [prompt] +// +// The prompt is delivered as a positional argument (in command). Droid resolves +// its model and other defaults from the user's own settings; only the autonomy +// level is overridden, and only for non-default permission modes (see +// permissionSettingsArgs). System-prompt text/file is appended (not replaced), +// matching Droid's --append-system-prompt semantics. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.droidBinary(ctx) + if err != nil { + return nil, err + } + + cmd = make([]string, 0, 6) + cmd = append(cmd, binary) + + settingsArgs, err := permissionSettingsArgs(cfg.SessionID, cfg.Permissions) + if err != nil { + return nil, err + } + cmd = append(cmd, settingsArgs...) + + if cfg.SystemPromptFile != "" { + cmd = append(cmd, "--append-system-prompt-file", cfg.SystemPromptFile) + } else if cfg.SystemPrompt != "" { + cmd = append(cmd, "--append-system-prompt", cfg.SystemPrompt) + } + + if cfg.Prompt != "" { + cmd = append(cmd, cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Droid receives its prompt in the launch +// command itself (the positional prompt argument). +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetRestoreCommand rebuilds the argv that continues an existing Droid session: +// `droid [--settings ] -r `. It re-applies the permission +// autonomy (resume otherwise reverts to the configured default) but not the +// prompt, which the session already carries. ok is false when the hook-derived +// native session id has not landed yet, so callers fall back to fresh launch +// behavior — mirroring the Codex and opencode adapters. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.droidBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 5) + cmd = append(cmd, binary) + settingsArgs, err := permissionSettingsArgs(cfg.Session.ID, cfg.Permissions) + if err != nil { + return nil, false, err + } + cmd = append(cmd, settingsArgs...) + cmd = append(cmd, "-r", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Droid hook-derived metadata. Metadata is intentionally +// nil: callers get the normalized fields directly, matching the Codex adapter. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[droidTitleMetadataKey], + Summary: session.Metadata[droidSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// droidAutonomyLevel maps an AO permission mode onto Droid's +// sessionDefaultSettings.autonomyLevel (off|low|medium|high). The empty string +// means "no override" — defer to the user's own Droid settings — so the default +// mode emits no --settings flag and writes no file. +// +// accept-edits → low (safe file operations) +// auto → medium (local dev operations) +// bypass-permissions → high (max interactive autonomy; Droid's interactive +// TUI has no exec-style --skip-permissions-unsafe) +func droidAutonomyLevel(mode ports.PermissionMode) string { + switch normalizePermissionMode(mode) { + case ports.PermissionModeAcceptEdits: + return "low" + case ports.PermissionModeAuto: + return "medium" + case ports.PermissionModeBypassPermissions: + return "high" + default: + return "" + } +} + +// permissionSettingsArgs renders a non-default permission mode as a +// `--settings ` argv pair, writing a process-scoped runtime settings file +// that overrides only sessionDefaultSettings.autonomyLevel. The default mode +// returns nil (no flag, no file) so Droid uses the user's own settings. +// +// Interactive `droid` exposes no per-launch permission flag (--auto and +// --skip-permissions-unsafe exist only on `droid exec`), so autonomy must be +// delivered through settings. The file is written under the OS temp dir, keyed +// by session id, rather than into the worktree so it never lands in a commit. +func permissionSettingsArgs(sessionID string, mode ports.PermissionMode) ([]string, error) { + level := droidAutonomyLevel(mode) + if level == "" { + return nil, nil + } + + blob, err := json.Marshal(map[string]any{ + "sessionDefaultSettings": map[string]any{"autonomyLevel": level}, + }) + if err != nil { + return nil, fmt.Errorf("droid: encode runtime settings: %w", err) + } + + path := runtimeSettingsPath(sessionID) + if err := hookutil.AtomicWriteFile(path, append(blob, '\n'), 0o600); err != nil { + return nil, fmt.Errorf("droid: write runtime settings: %w", err) + } + return []string{"--settings", path}, nil +} + +// runtimeSettingsPath is the deterministic temp-dir path for a session's +// process-scoped runtime settings file. A stable name keyed by session id means +// relaunches overwrite rather than accumulate files. +func runtimeSettingsPath(sessionID string) string { + name := sanitizeSessionID(sessionID) + if name == "" { + name = "default" + } + return filepath.Join(os.TempDir(), "ao-droid-"+name+"-settings.json") +} + +// sanitizeSessionID keeps only filename-safe characters so the session id can +// be embedded in a temp file name without path traversal or separators. +func sanitizeSessionID(id string) string { + var b strings.Builder + for _, r := range id { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + return b.String() +} + +// ResolveDroidBinary finds the `droid` binary (Factory Droid CLI), searching +// PATH then a handful of well-known install locations. Returns "droid" as a +// last-ditch fallback so callers see a clear "command not found" rather than an +// empty argv. +func ResolveDroidBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"droid.cmd", "droid.exe", "droid"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "droid.cmd"), + filepath.Join(appData, "npm", "droid.exe"), + ) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "droid.exe"), + filepath.Join(home, ".factory", "bin", "droid.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + return "droid", nil + } + + if path, err := exec.LookPath("droid"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/droid", + "/opt/homebrew/bin/droid", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "droid"), + filepath.Join(home, ".factory", "bin", "droid"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "droid", nil +} + +func (p *Plugin) droidBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveDroidBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + // Empty or unrecognized: defer to Droid's own settings (no flag). + return ports.PermissionModeDefault + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/droid/droid_test.go b/backend/internal/adapters/agent/droid/droid_test.go new file mode 100644 index 0000000..2607372 --- /dev/null +++ b/backend/internal/adapters/agent/droid/droid_test.go @@ -0,0 +1,320 @@ +package droid + +import ( + "context" + "encoding/json" + "os" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifest(t *testing.T) { + m := (&Plugin{}).Manifest() + if m.ID != "droid" { + t.Fatalf("ID = %q, want droid", m.ID) + } + if m.Name != "Droid" { + t.Fatalf("Name = %q", m.Name) + } + hasAgent := false + for _, c := range m.Capabilities { + if c == adapters.CapabilityAgent { + hasAgent = true + } + } + if !hasAgent { + t.Fatal("missing CapabilityAgent") + } +} + +func TestGetConfigSpecEmpty(t *testing.T) { + spec, err := (&Plugin{}).GetConfigSpec(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(spec.Fields) != 0 { + t.Fatalf("expected no fields, got %d", len(spec.Fields)) + } +} + +func TestGetPromptDeliveryStrategy(t *testing.T) { + s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if s != ports.PromptDeliveryInCommand { + t.Fatalf("strategy = %q, want in_command", s) + } +} + +func TestGetLaunchCommandDefaultPerms(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SessionID: "mer-1", + Prompt: "do the thing", + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"droid", "do the thing"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } + if strings.Contains(strings.Join(cmd, " "), "--settings") { + t.Fatal("default perms should not emit --settings") + } +} + +func TestGetLaunchCommandBypassWritesSettings(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + settingsPath := runtimeSettingsPath("mer-2") + t.Cleanup(func() { _ = os.Remove(settingsPath) }) + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SessionID: "mer-2", + Prompt: "refactor auth", + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"droid", "--settings", settingsPath, "refactor auth"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } + + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("read settings file: %v", err) + } + var parsed struct { + SessionDefaultSettings struct { + AutonomyLevel string `json:"autonomyLevel"` + } `json:"sessionDefaultSettings"` + } + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("parse settings file: %v", err) + } + if parsed.SessionDefaultSettings.AutonomyLevel != "high" { + t.Fatalf("autonomyLevel = %q, want high", parsed.SessionDefaultSettings.AutonomyLevel) + } +} + +func TestGetLaunchCommandAutonomyLevels(t *testing.T) { + for _, tc := range []struct { + mode ports.PermissionMode + level string + }{ + {ports.PermissionModeAcceptEdits, "low"}, + {ports.PermissionModeAuto, "medium"}, + {ports.PermissionModeBypassPermissions, "high"}, + } { + if got := droidAutonomyLevel(tc.mode); got != tc.level { + t.Fatalf("droidAutonomyLevel(%q) = %q, want %q", tc.mode, got, tc.level) + } + } + if got := droidAutonomyLevel(ports.PermissionModeDefault); got != "" { + t.Fatalf("default autonomy = %q, want empty", got) + } +} + +func TestGetLaunchCommandSystemPrompt(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SessionID: "mer-3", + Prompt: "fix it", + SystemPrompt: "follow AGENTS.md", + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"droid", "--append-system-prompt", "follow AGENTS.md", "fix it"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + ID: "mer-4", + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "droid-ses-1", + }, + }, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Fatal("ok=false, want true") + } + want := []string{"droid", "-r", "droid-ses-1"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommandNoID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + _, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{Metadata: map[string]string{}}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatal("ok=true with no agentSessionId, want false") + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "droid-ses-1", + droidTitleMetadataKey: "Fix login redirect", + droidSummaryMetadataKey: "Updated the auth callback and tests.", + }, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Fatal("ok=false, want true") + } + if info.AgentSessionID != "droid-ses-1" { + t.Fatalf("AgentSessionID = %q", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q", info.Summary) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatal("ok=true with empty metadata, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero", info) + } +} + +func TestGetAgentHooksInstallsIntoFactoryHooksJSON(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + ws := t.TempDir() + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{ + WorkspacePath: ws, + SessionID: "mer-5", + }); err != nil { + t.Fatalf("GetAgentHooks: %v", err) + } + + data, err := os.ReadFile(droidHooksPath(ws)) + if err != nil { + t.Fatalf("read hooks.json: %v", err) + } + body := string(data) + for _, spec := range droidManagedHooks { + if !strings.Contains(body, spec.Command) { + t.Fatalf("hooks.json missing managed command %q:\n%s", spec.Command, body) + } + } + if !strings.Contains(body, `"startup"`) { + t.Fatalf("SessionStart hook missing startup matcher:\n%s", body) + } + + installed, err := plugin.AreHooksInstalled(context.Background(), ws) + if err != nil { + t.Fatalf("AreHooksInstalled: %v", err) + } + if !installed { + t.Fatal("AreHooksInstalled=false after install, want true") + } +} + +func TestGetAgentHooksIdempotentAndPreservesUserHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + ws := t.TempDir() + // Seed a user-defined hook AO must preserve. + if err := os.MkdirAll(droidHooksPath(ws)[:len(droidHooksPath(ws))-len(droidHooksFileName)], 0o750); err != nil { + t.Fatal(err) + } + seed := `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"echo mine"}]}]}}` + if err := os.WriteFile(droidHooksPath(ws), []byte(seed), 0o600); err != nil { + t.Fatal(err) + } + + for i := 0; i < 2; i++ { + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: ws}); err != nil { + t.Fatalf("GetAgentHooks #%d: %v", i, err) + } + } + + data, err := os.ReadFile(droidHooksPath(ws)) + if err != nil { + t.Fatal(err) + } + body := string(data) + if !strings.Contains(body, "echo mine") { + t.Fatalf("user hook dropped:\n%s", body) + } + // The AO stop command must appear exactly once despite two installs. + if n := strings.Count(body, droidHookCommandPrefix+"stop"); n != 1 { + t.Fatalf("AO stop command count = %d, want 1 (idempotent):\n%s", n, body) + } +} + +func TestUninstallHooksRemovesAOHooksLeavesUserHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + ws := t.TempDir() + dir := droidHooksPath(ws)[:len(droidHooksPath(ws))-len(droidHooksFileName)] + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + seed := `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"echo mine"}]}]}}` + if err := os.WriteFile(droidHooksPath(ws), []byte(seed), 0o600); err != nil { + t.Fatal(err) + } + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: ws}); err != nil { + t.Fatal(err) + } + if err := plugin.UninstallHooks(context.Background(), ws); err != nil { + t.Fatalf("UninstallHooks: %v", err) + } + + data, err := os.ReadFile(droidHooksPath(ws)) + if err != nil { + t.Fatal(err) + } + body := string(data) + if strings.Contains(body, droidHookCommandPrefix) { + t.Fatalf("AO hooks not removed:\n%s", body) + } + if !strings.Contains(body, "echo mine") { + t.Fatalf("user hook dropped on uninstall:\n%s", body) + } + + installed, err := plugin.AreHooksInstalled(context.Background(), ws) + if err != nil { + t.Fatal(err) + } + if installed { + t.Fatal("AreHooksInstalled=true after uninstall, want false") + } +} diff --git a/backend/internal/adapters/agent/droid/hooks.go b/backend/internal/adapters/agent/droid/hooks.go new file mode 100644 index 0000000..e9ed328 --- /dev/null +++ b/backend/internal/adapters/agent/droid/hooks.go @@ -0,0 +1,351 @@ +package droid + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + droidSettingsDirName = ".factory" + droidHooksFileName = "hooks.json" + + // droidHookCommandPrefix identifies the hook commands AO owns. Every managed + // command starts with it, so install can skip duplicates and uninstall can + // recognize AO entries by prefix without an embedded template to diff + // against. The CLI dispatcher routes `ao hooks droid ` to the Droid + // activity deriver. + droidHookCommandPrefix = "ao hooks droid " + droidHookTimeout = 30 +) + +type droidMatcherGroup struct { + // Matcher is a pointer so it round-trips exactly: SessionStart serializes + // with its "startup" matcher; UserPromptSubmit/Stop/Notification/SessionEnd + // omit it (Droid ignores matcher for those events). omitempty drops a nil + // matcher on write. + Matcher *string `json:"matcher,omitempty"` + Hooks []droidHookEntry `json:"hooks"` +} + +type droidHookEntry struct { + Type string `json:"type"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// droidHookSpec describes one hook AO installs, defined in code rather than read +// from an embedded settings file. +type droidHookSpec struct { + Event string + Matcher *string + Command string +} + +// droidStartupMatcher is referenced by pointer so SessionStart serializes with +// its "startup" source matcher. +var droidStartupMatcher = "startup" + +// droidManagedHooks is the source of truth for the hooks AO installs: +// SessionStart (under the "startup" matcher), UserPromptSubmit, Stop, +// Notification, and SessionEnd. They report normalized activity-state signals +// back into AO's store (see DeriveActivityState). The non-SessionStart events +// carry no matcher: each installs once and fires for every sub-type, and the +// handler filters on the payload where it must. +var droidManagedHooks = []droidHookSpec{ + {Event: "SessionStart", Matcher: &droidStartupMatcher, Command: droidHookCommandPrefix + "session-start"}, + {Event: "UserPromptSubmit", Command: droidHookCommandPrefix + "user-prompt-submit"}, + {Event: "Stop", Command: droidHookCommandPrefix + "stop"}, + {Event: "Notification", Command: droidHookCommandPrefix + "notification"}, + {Event: "SessionEnd", Command: droidHookCommandPrefix + "session-end"}, +} + +// GetAgentHooks installs AO's Droid hooks into the worktree-local +// .factory/hooks.json file (the project-scope hooks config Droid reads). The +// hooks report normalized activity-state signals back into AO's store. Existing +// hooks and unrelated keys are preserved, and duplicate AO commands are not +// appended, so the install is idempotent. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(cfg.WorkspacePath) == "" { + return errors.New("droid.GetAgentHooks: WorkspacePath is required") + } + + hooksPath := droidHooksPath(cfg.WorkspacePath) + topLevel, rawHooks, err := readDroidHooks(hooksPath) + if err != nil { + return fmt.Errorf("droid.GetAgentHooks: %w", err) + } + + byEvent := groupDroidHooksByEvent() + events := make([]string, 0, len(byEvent)) + for event := range byEvent { + events = append(events, event) + } + sort.Strings(events) + for _, event := range events { + specs := byEvent[event] + var existingGroups []droidMatcherGroup + if err := parseDroidHookType(rawHooks, event, &existingGroups); err != nil { + return fmt.Errorf("droid.GetAgentHooks: %w", err) + } + for _, spec := range specs { + if !droidHookCommandExists(existingGroups, spec.Command) { + entry := droidHookEntry{Type: "command", Command: spec.Command, Timeout: droidHookTimeout} + existingGroups = addDroidHook(existingGroups, entry, spec.Matcher) + } + } + if err := marshalDroidHookType(rawHooks, event, existingGroups); err != nil { + return fmt.Errorf("droid.GetAgentHooks: %w", err) + } + } + + if err := writeDroidHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("droid.GetAgentHooks: %w", err) + } + return nil +} + +// UninstallHooks removes AO's Droid hooks from the workspace-local +// .factory/hooks.json file, leaving user-defined hooks and unrelated keys +// untouched. A missing file is a no-op. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return errors.New("droid.UninstallHooks: workspacePath is required") + } + + hooksPath := droidHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return nil + } + topLevel, rawHooks, err := readDroidHooks(hooksPath) + if err != nil { + return fmt.Errorf("droid.UninstallHooks: %w", err) + } + + for _, event := range droidManagedEvents() { + var groups []droidMatcherGroup + if err := parseDroidHookType(rawHooks, event, &groups); err != nil { + return fmt.Errorf("droid.UninstallHooks: %w", err) + } + groups = removeDroidManagedHooks(groups) + if err := marshalDroidHookType(rawHooks, event, groups); err != nil { + return fmt.Errorf("droid.UninstallHooks: %w", err) + } + } + + if err := writeDroidHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("droid.UninstallHooks: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether any AO Droid hook is present in the +// workspace-local hooks file. A missing file means none are installed. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if strings.TrimSpace(workspacePath) == "" { + return false, errors.New("droid.AreHooksInstalled: workspacePath is required") + } + + hooksPath := droidHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return false, nil + } + _, rawHooks, err := readDroidHooks(hooksPath) + if err != nil { + return false, fmt.Errorf("droid.AreHooksInstalled: %w", err) + } + + for _, event := range droidManagedEvents() { + var groups []droidMatcherGroup + if err := parseDroidHookType(rawHooks, event, &groups); err != nil { + return false, fmt.Errorf("droid.AreHooksInstalled: %w", err) + } + for _, group := range groups { + for _, hook := range group.Hooks { + if isDroidManagedHook(hook.Command) { + return true, nil + } + } + } + } + return false, nil +} + +func droidHooksPath(workspacePath string) string { + return filepath.Join(workspacePath, droidSettingsDirName, droidHooksFileName) +} + +// readDroidHooks loads the hooks file into a top-level raw map plus the decoded +// "hooks" sub-map, preserving every key AO doesn't manage. A missing or empty +// file yields empty maps. +func readDroidHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { + topLevel = map[string]json.RawMessage{} + rawHooks = map[string]json.RawMessage{} + + data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return topLevel, rawHooks, nil + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return topLevel, rawHooks, nil + } + if err := json.Unmarshal(data, &topLevel); err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) + } + if hooksRaw, ok := topLevel["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) + } + } + return topLevel, rawHooks, nil +} + +// writeDroidHooks folds rawHooks back into topLevel and writes the file. An +// empty hooks map drops the "hooks" key entirely. +func writeDroidHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { + if len(rawHooks) == 0 { + delete(topLevel, "hooks") + } else { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("encode hooks: %w", err) + } + topLevel["hooks"] = hooksJSON + } + + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + return fmt.Errorf("create hooks dir: %w", err) + } + data, err := json.MarshalIndent(topLevel, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", hooksPath, err) + } + data = append(data, '\n') + if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", hooksPath, err) + } + return nil +} + +// groupDroidHooksByEvent groups the managed hook specs by their Droid event so +// each event's array is rewritten once. +func groupDroidHooksByEvent() map[string][]droidHookSpec { + byEvent := map[string][]droidHookSpec{} + for _, spec := range droidManagedHooks { + byEvent[spec.Event] = append(byEvent[spec.Event], spec) + } + return byEvent +} + +// droidManagedEvents returns the distinct Droid events AO manages, in the order +// they first appear in droidManagedHooks. +func droidManagedEvents() []string { + seen := map[string]bool{} + events := make([]string, 0, len(droidManagedHooks)) + for _, spec := range droidManagedHooks { + if !seen[spec.Event] { + seen[spec.Event] = true + events = append(events, spec.Event) + } + } + return events +} + +func isDroidManagedHook(command string) bool { + return strings.HasPrefix(command, droidHookCommandPrefix) +} + +// removeDroidManagedHooks strips AO hook entries from every group, dropping any +// group left without hooks so the event array doesn't accumulate empty matcher +// objects. +func removeDroidManagedHooks(groups []droidMatcherGroup) []droidMatcherGroup { + result := make([]droidMatcherGroup, 0, len(groups)) + for _, group := range groups { + kept := make([]droidHookEntry, 0, len(group.Hooks)) + for _, hook := range group.Hooks { + if !isDroidManagedHook(hook.Command) { + kept = append(kept, hook) + } + } + if len(kept) > 0 { + group.Hooks = kept + result = append(result, group) + } + } + return result +} + +func parseDroidHookType(rawHooks map[string]json.RawMessage, event string, target *[]droidMatcherGroup) error { + data, ok := rawHooks[event] + if !ok { + return nil + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("parse %s hooks: %w", event, err) + } + return nil +} + +func marshalDroidHookType(rawHooks map[string]json.RawMessage, event string, groups []droidMatcherGroup) error { + if len(groups) == 0 { + delete(rawHooks, event) + return nil + } + data, err := json.Marshal(groups) + if err != nil { + return fmt.Errorf("encode %s hooks: %w", event, err) + } + rawHooks[event] = data + return nil +} + +func droidHookCommandExists(groups []droidMatcherGroup, command string) bool { + for _, group := range groups { + for _, hook := range group.Hooks { + if hook.Command == command { + return true + } + } + } + return false +} + +// addDroidHook appends hook to an existing group with the same matcher (so a +// SessionStart hook lands under its "startup" matcher), creating that group if +// none matches. +func addDroidHook(groups []droidMatcherGroup, hook droidHookEntry, matcher *string) []droidMatcherGroup { + for i, group := range groups { + if matchersEqual(group.Matcher, matcher) { + groups[i].Hooks = append(groups[i].Hooks, hook) + return groups + } + } + return append(groups, droidMatcherGroup{Matcher: matcher, Hooks: []droidHookEntry{hook}}) +} + +func matchersEqual(a, b *string) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} diff --git a/backend/internal/adapters/agent/goose/activity.go b/backend/internal/adapters/agent/goose/activity.go new file mode 100644 index 0000000..1835689 --- /dev/null +++ b/backend/internal/adapters/agent/goose/activity.go @@ -0,0 +1,35 @@ +package goose + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// DeriveActivityState maps a Goose hook event onto an AO activity state. The +// bool is false when the event carries no activity signal. +// +// event is the AO hook sub-command name installed in gooseManagedHooks +// ("session-start", "user-prompt-submit", "stop", "permission-request"), not +// the native Goose event name. +// +// Goose's native hook surface (as of 2026-05) emits SessionStart / +// UserPromptSubmit / Stop / SessionEnd plus the tool-use events, but has no +// dedicated permission/approval event yet, so AO does not install a +// "permission-request" hook today. The case is kept here so that, if a future +// Goose release adds an approval lifecycle event, mapping it to waiting_input is +// a one-line hooks.go change with no deriver edit needed. +// +// TODO(goose): ActivityExited is still runtime-observation-owned. Goose has a +// native SessionEnd hook; if AO starts installing it, map it to ActivityExited +// here. Until then, the lifecycle reaper marks a dead Goose runtime as exited. +func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + switch event { + case "session-start": + return domain.ActivityActive, true + case "user-prompt-submit": + return domain.ActivityActive, true + case "stop": + return domain.ActivityIdle, true + case "permission-request": + return domain.ActivityWaitingInput, true + default: + return "", false + } +} diff --git a/backend/internal/adapters/agent/goose/activity_test.go b/backend/internal/adapters/agent/goose/activity_test.go new file mode 100644 index 0000000..224ac8a --- /dev/null +++ b/backend/internal/adapters/agent/goose/activity_test.go @@ -0,0 +1,32 @@ +package goose + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestDeriveActivityState(t *testing.T) { + tests := []struct { + name string + event string + want domain.ActivityState + wantOK bool + }{ + {"session start -> active", "session-start", domain.ActivityActive, true}, + {"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true}, + {"stop -> idle", "stop", domain.ActivityIdle, true}, + {"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, true}, + {"unknown event -> no signal", "frobnicate", "", false}, + {"empty event -> no signal", "", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := DeriveActivityState(tt.event, []byte(`{}`)) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", + tt.event, got, ok, tt.want, tt.wantOK) + } + }) + } +} diff --git a/backend/internal/adapters/agent/goose/goose.go b/backend/internal/adapters/agent/goose/goose.go new file mode 100644 index 0000000..1045102 --- /dev/null +++ b/backend/internal/adapters/agent/goose/goose.go @@ -0,0 +1,326 @@ +// Package goose implements the Goose (Block) agent adapter: launching new +// headless sessions, resuming hook-tracked sessions, installing +// workspace-local lifecycle hooks, and reading hook-derived session info. +// +// Goose (binary "goose") runs headlessly via `goose run -t ""`. It has a +// native Claude-Code-style lifecycle hook system (released 2026-05): a plugin +// directory under /.agents/plugins//hooks/hooks.json is +// auto-discovered at startup and its commands run on SessionStart / +// UserPromptSubmit / Stop / etc. AO installs its hooks there, so AO derives +// native session identity and activity from Goose hooks (Tier A), the same way +// the Codex adapter does. +// +// Permission/approval is controlled by the GOOSE_MODE environment variable +// (auto / approve / chat / smart_approve), not a CLI flag, so non-default modes +// are delivered as an `env GOOSE_MODE=` argv prefix (the same technique +// the opencode adapter uses for OPENCODE_PERMISSION). The default mode emits no +// prefix so Goose defers to the user's own config. +// +// Note: the AO repo also vendors pressly/goose as its SQLite migration tool, +// but that is a different Go import path; this package's name `goose` only +// collides at the import-alias level, which central wiring resolves. +package goose + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + adapterID = "goose" + + gooseTitleMetadataKey = "title" + gooseSummaryMetadataKey = "summary" + + // gooseModeEnvVar is the only permission-control surface Goose honors: the + // approval mode is read from this process env var, not from any CLI flag. + gooseModeEnvVar = "GOOSE_MODE" +) + +// Plugin is the Goose agent adapter. It is safe for concurrent use; the binary +// path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Goose adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Goose", + Description: "Run Goose worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Goose exposes none yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new headless Goose session: +// +// [env GOOSE_MODE=] goose run [--system ] [-t ] +// +// The prompt is delivered in-command via `-t`. A non-default permission mode is +// rendered as an `env GOOSE_MODE=` prefix because Goose reads its approval +// mode from the environment, not from a flag. System instructions, when present, +// are passed via `--system`. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.gooseBinary(ctx) + if err != nil { + return nil, err + } + + cmd = append(gooseModeEnvPrefix(cfg.Permissions), binary, "run") + + systemPrompt, err := systemPromptText(cfg) + if err != nil { + return nil, err + } + if systemPrompt != "" { + cmd = append(cmd, "--system", systemPrompt) + } + + if cfg.Prompt != "" { + cmd = append(cmd, "-t", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Goose receives its prompt in the launch +// command itself (via `-t`). +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetRestoreCommand rebuilds the argv that continues an existing Goose session: +// +// [env GOOSE_MODE=] goose run --resume --session-id +// +// ok is false when the hook-derived native session id has not landed yet, so +// callers can fall back to fresh launch behavior. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.gooseBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = append(gooseModeEnvPrefix(cfg.Permissions), binary, "run", "--resume", "--session-id", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Goose hook-derived metadata. Metadata is intentionally +// nil for Goose: callers get the normalized fields directly. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[gooseTitleMetadataKey], + Summary: session.Metadata[gooseSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// systemPromptText returns the system instructions to inject. Goose's `--system` +// flag takes inline text only (no file variant), so a system-prompt file is read +// from disk and its contents inlined. A read failure is surfaced as an error so a +// misconfigured prompt file does not silently fall back to the inline +// SystemPrompt string; only an empty-after-trim file falls back. +func systemPromptText(cfg ports.LaunchConfig) (string, error) { + if cfg.SystemPromptFile != "" { + data, err := os.ReadFile(cfg.SystemPromptFile) //nolint:gosec // path is AO-owned launch config + if err != nil { + return "", fmt.Errorf("read %s: %w", cfg.SystemPromptFile, err) + } + if text := strings.TrimSpace(string(data)); text != "" { + return text, nil + } + } + return cfg.SystemPrompt, nil +} + +// gooseModeEnvPrefix renders mode as an `env GOOSE_MODE=` argv prefix, or +// nil for the default mode. +// +// The var must reach Goose as a process env var, not an argv flag. The runtime +// runs the argv through a shell, which execs `env`, which sets the var and execs +// goose. A bare `GOOSE_MODE=...` argv element would not work: the runtime +// shell-quotes every element, and a quoted token is run as a command rather than +// read as an assignment — hence the explicit `env` wrapper. POSIX-only, which +// matches the runtime. +func gooseModeEnvPrefix(mode ports.PermissionMode) []string { + value := gooseMode(mode) + if value == "" { + return nil + } + return []string{"env", gooseModeEnvVar + "=" + value} +} + +// gooseMode maps an AO permission mode onto Goose's GOOSE_MODE value. +// +// - default → "": no env; Goose's own config decides approvals. +// - accept-edits → smart_approve: auto-approves safe edits, asks on risk. +// - auto → auto: fully autonomous, no approval prompts. +// - bypass-permissions → auto: Goose's fully-autonomous mode is the nearest +// equivalent to bypass. +func gooseMode(mode ports.PermissionMode) string { + switch normalizePermissionMode(mode) { + case ports.PermissionModeAcceptEdits: + return "smart_approve" + case ports.PermissionModeAuto: + return "auto" + case ports.PermissionModeBypassPermissions: + return "auto" + default: + return "" + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + // Empty or unrecognized: defer to Goose's own config (no env). + return ports.PermissionModeDefault + } +} + +// ResolveGooseBinary returns the path to the goose binary on this machine, +// searching PATH then a handful of well-known install locations (the install +// script's ~/.local/bin, Homebrew, Cargo, npm global). Returns "goose" as a +// last-ditch fallback so callers see a clear "command not found" rather than an +// empty argv. +func ResolveGooseBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"goose.cmd", "goose.exe", "goose"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "goose.cmd"), + filepath.Join(appData, "npm", "goose.exe"), + ) + } + if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { + candidates = append(candidates, filepath.Join(localAppData, "Programs", "goose", "goose.exe")) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "goose.exe")) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "goose", nil + } + + if path, err := exec.LookPath("goose"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/goose", + "/opt/homebrew/bin/goose", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "goose"), + filepath.Join(home, ".cargo", "bin", "goose"), + filepath.Join(home, ".npm", "bin", "goose"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "goose", nil +} + +func (p *Plugin) gooseBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveGooseBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/goose/goose_test.go b/backend/internal/adapters/agent/goose/goose_test.go new file mode 100644 index 0000000..4de5c94 --- /dev/null +++ b/backend/internal/adapters/agent/goose/goose_test.go @@ -0,0 +1,440 @@ +package goose + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifestIDIsGoose(t *testing.T) { + m := New().Manifest() + if m.ID != "goose" { + t.Fatalf("Manifest().ID = %q, want %q", m.ID, "goose") + } + if m.Name != "Goose" { + t.Fatalf("Manifest().Name = %q, want %q", m.Name, "Goose") + } + if len(m.Capabilities) != 1 || m.Capabilities[0] != "agent" { + t.Fatalf("Manifest().Capabilities = %#v, want [agent]", m.Capabilities) + } +} + +func TestGetLaunchCommandBuildsArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-fix this", + SystemPrompt: "be terse", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "env", "GOOSE_MODE=auto", + "goose", "run", + "--system", "be terse", + "-t", "-fix this", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandSystemPromptFileInlined(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "prompt.md") + if err := os.WriteFile(file, []byte(" from file \n"), 0o600); err != nil { + t.Fatal(err) + } + plugin := &Plugin{resolvedBinary: "goose"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPromptFile: file, + SystemPrompt: "inline fallback ignored", + Prompt: "do work", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"goose", "run", "--system", "from file", "-t", "do work"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + want []string + notExpected string + }{ + { + name: "default", + permission: ports.PermissionModeDefault, + notExpected: "env", + }, + { + name: "accept-edits", + permission: ports.PermissionModeAcceptEdits, + want: []string{"env", "GOOSE_MODE=smart_approve"}, + }, + { + name: "auto", + permission: ports.PermissionModeAuto, + want: []string{"env", "GOOSE_MODE=auto"}, + }, + { + name: "bypass-permissions", + permission: ports.PermissionModeBypassPermissions, + want: []string{"env", "GOOSE_MODE=auto"}, + }, + { + name: "empty", + permission: "", + notExpected: "env", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: tt.permission, + }) + if err != nil { + t.Fatal(err) + } + if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) { + t.Fatalf("command %#v does not contain %#v", cmd, tt.want) + } + if tt.notExpected != "" && contains(cmd, tt.notExpected) { + t.Fatalf("command %#v contains %q", cmd, tt.notExpected) + } + }) + } +} + +func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryInCommand { + t.Fatalf("unexpected strategy: %q", got) + } +} + +func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + spec, err := plugin.GetConfigSpec(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(spec.Fields) != 0 { + t.Fatalf("unexpected config fields: %#v", spec.Fields) + } +} + +func TestContextCancellationIsHonored(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := plugin.GetConfigSpec(ctx); err == nil { + t.Fatal("GetConfigSpec: expected error from cancelled context") + } + if _, err := plugin.GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); err == nil { + t.Fatal("GetPromptDeliveryStrategy: expected error from cancelled context") + } + if _, _, err := plugin.GetRestoreCommand(ctx, ports.RestoreConfig{}); err == nil { + t.Fatal("GetRestoreCommand: expected error from cancelled context") + } + if _, _, err := plugin.SessionInfo(ctx, ports.SessionRef{}); err == nil { + t.Fatal("SessionInfo: expected error from cancelled context") + } + if err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{WorkspacePath: "/tmp"}); err == nil { + t.Fatal("GetAgentHooks: expected error from cancelled context") + } +} + +func TestGetAgentHooksInstallsGooseHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + workspace := t.TempDir() + hooksPath := gooseHooksPath(workspace) + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"hooks":{"Stop":[{"matcher":null,"hooks":[{"type":"command","command":"custom stop hook","timeout":3}]}]}}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + cfg := ports.WorkspaceHookConfig{ + DataDir: t.TempDir(), + SessionID: "sess-1", + WorkspacePath: workspace, + } + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + // A second install must not duplicate AO hook commands. + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var config gooseHookFile + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + if config.Hooks == nil { + t.Fatalf("hooks config missing hooks object: %#v", config) + } + for _, spec := range gooseManagedHooks { + entries := config.Hooks[spec.Event] + if count := countGooseHookCommand(entries, spec.Command); count != 1 { + t.Fatalf("%s command count = %d, want 1 in %#v", spec.Event, count, entries) + } + } + stopEntries := config.Hooks["Stop"] + if countGooseHookCommand(stopEntries, "custom stop hook") != 1 { + t.Fatalf("existing Stop hook was not preserved: %#v", stopEntries) + } +} + +func TestUninstallHooksRemovesGooseHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + workspace := t.TempDir() + hooksPath := gooseHooksPath(workspace) + + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + + // Pre-seed a user's own Stop hook; it must survive uninstall. + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"hooks":{"Stop":[{"matcher":null,"hooks":[{"type":"command","command":"custom stop hook","timeout":3}]}]}}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + + if err := plugin.UninstallHooks(ctx, workspace); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var config gooseHookFile + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + for _, spec := range gooseManagedHooks { + if got := countGooseHookCommand(config.Hooks[spec.Event], spec.Command); got != 0 { + t.Fatalf("%s command %q count = %d after uninstall, want 0", spec.Event, spec.Command, got) + } + } + if countGooseHookCommand(config.Hooks["Stop"], "custom stop hook") != 1 { + t.Fatalf("user Stop hook not preserved: %#v", config.Hooks["Stop"]) + } +} + +func TestGetAgentHooksRequiresWorkspacePath(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{}); err == nil { + t.Fatal("expected error when WorkspacePath is empty") + } +} + +func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "thread-123"}, + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + want := []string{ + "env", "GOOSE_MODE=auto", + "goose", "run", "--resume", "--session-id", "thread-123", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + cases := []struct { + name string + ref ports.SessionRef + }{ + {"empty session ref", ports.SessionRef{}}, + {"empty metadata", ports.SessionRef{Metadata: map[string]string{}}}, + {"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}}, + {"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: tc.ref, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if cmd != nil { + t.Fatalf("cmd = %#v, want nil", cmd) + } + }) + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "thread-123", + gooseTitleMetadataKey: "Fix login redirect", + gooseSummaryMetadataKey: "Updated the auth callback and tests.", + "ignored": "not returned", + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatalf("ok = false, want true") + } + if info.AgentSessionID != "thread-123" { + t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q, want hook title", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q, want hook summary", info.Summary) + } + if info.Metadata != nil { + t.Fatalf("Metadata = %#v, want nil for Goose", info.Metadata) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero value", info) + } +} + +func TestResolveGooseBinaryFallback(t *testing.T) { + // On a machine without goose on PATH or any well-known location, resolution + // still returns a usable last-ditch "goose" name rather than empty. + got, err := ResolveGooseBinary(context.Background()) + if err != nil { + t.Fatalf("ResolveGooseBinary err = %v", err) + } + if got == "" { + t.Fatal("ResolveGooseBinary returned empty binary") + } + if !strings.Contains(got, "goose") { + t.Fatalf("ResolveGooseBinary = %q, want a path containing goose", got) + } +} + +func contains(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + +func containsSubsequence(values []string, needle []string) bool { + if len(needle) == 0 { + return true + } + + for start := range values { + if start+len(needle) > len(values) { + return false + } + ok := true + for offset, want := range needle { + if values[start+offset] != want { + ok = false + break + } + } + if ok { + return true + } + } + + return false +} + +func countGooseHookCommand(entries []gooseMatcherGroup, command string) int { + count := 0 + for _, entry := range entries { + for _, hook := range entry.Hooks { + if hook.Command == command { + count++ + } + } + } + return count +} diff --git a/backend/internal/adapters/agent/goose/hooks.go b/backend/internal/adapters/agent/goose/hooks.go new file mode 100644 index 0000000..0e128a7 --- /dev/null +++ b/backend/internal/adapters/agent/goose/hooks.go @@ -0,0 +1,352 @@ +package goose + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // goosePluginDirName is the AO plugin directory under a workspace's + // .agents/plugins/. Goose auto-discovers any plugin dir containing a + // hooks/hooks.json at startup; unlike Codex there is no separate feature + // flag to toggle, so installing the file is sufficient. + gooseHooksRootDirName = ".agents" + goosePluginsDirName = "plugins" + goosePluginName = "ao" + gooseHooksSubDirName = "hooks" + gooseHooksFileName = "hooks.json" + + // gooseHookCommandPrefix identifies the hook commands AO owns, so install + // skips duplicates and uninstall recognizes AO entries by prefix without an + // embedded template to diff against. + gooseHookCommandPrefix = "ao hooks goose " + gooseHookTimeout = 30 +) + +// gooseHookFile is the on-disk shape of .agents/plugins/ao/hooks/hooks.json. It +// is used by tests to decode the written file. +type gooseHookFile struct { + Hooks map[string][]gooseMatcherGroup `json:"hooks"` +} + +type gooseMatcherGroup struct { + Matcher *string `json:"matcher,omitempty"` + Hooks []gooseHookEntry `json:"hooks"` +} + +type gooseHookEntry struct { + Type string `json:"type"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// gooseHookSpec describes one hook AO installs, defined in code rather than read +// from an embedded hooks file. +type gooseHookSpec struct { + Event string + Command string +} + +// gooseManagedHooks is the source of truth for the hooks AO installs. Goose +// groups every hook under the nil matcher. Goose has no permission/approval +// lifecycle event yet, so AO installs only the session/prompt/stop signals. +var gooseManagedHooks = []gooseHookSpec{ + {Event: "SessionStart", Command: gooseHookCommandPrefix + "session-start"}, + {Event: "UserPromptSubmit", Command: gooseHookCommandPrefix + "user-prompt-submit"}, + {Event: "Stop", Command: gooseHookCommandPrefix + "stop"}, +} + +// GetAgentHooks installs AO's Goose hooks into the worktree-local +// .agents/plugins/ao/hooks/hooks.json file. Existing hook entries are preserved +// and duplicate AO commands are not appended. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(cfg.WorkspacePath) == "" { + return errors.New("goose.GetAgentHooks: WorkspacePath is required") + } + + hooksPath := gooseHooksPath(cfg.WorkspacePath) + topLevel, rawHooks, err := readGooseHooks(hooksPath) + if err != nil { + return fmt.Errorf("goose.GetAgentHooks: %w", err) + } + + for event, specs := range groupGooseHooksByEvent() { + var existingGroups []gooseMatcherGroup + if err := parseGooseHookType(rawHooks, event, &existingGroups); err != nil { + return fmt.Errorf("goose.GetAgentHooks: %w", err) + } + for _, spec := range specs { + if !gooseHookCommandExists(existingGroups, spec.Command) { + entry := gooseHookEntry{Type: "command", Command: spec.Command, Timeout: gooseHookTimeout} + existingGroups = addGooseHook(existingGroups, entry) + } + } + if err := marshalGooseHookType(rawHooks, event, existingGroups); err != nil { + return fmt.Errorf("goose.GetAgentHooks: %w", err) + } + } + + if err := writeGooseHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("goose.GetAgentHooks: %w", err) + } + return nil +} + +// UninstallHooks removes AO's Goose hooks from the workspace-local +// .agents/plugins/ao/hooks/hooks.json file, leaving user-defined hooks +// untouched. A missing file is a no-op. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return errors.New("goose.UninstallHooks: workspacePath is required") + } + + hooksPath := gooseHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return nil + } + topLevel, rawHooks, err := readGooseHooks(hooksPath) + if err != nil { + return fmt.Errorf("goose.UninstallHooks: %w", err) + } + + for _, event := range gooseManagedEvents() { + var groups []gooseMatcherGroup + if err := parseGooseHookType(rawHooks, event, &groups); err != nil { + return fmt.Errorf("goose.UninstallHooks: %w", err) + } + groups = removeGooseManagedHooks(groups) + if err := marshalGooseHookType(rawHooks, event, groups); err != nil { + return fmt.Errorf("goose.UninstallHooks: %w", err) + } + } + + if err := writeGooseHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("goose.UninstallHooks: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether any AO Goose hook is present in the +// workspace-local hooks file. A missing file means none are installed. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if strings.TrimSpace(workspacePath) == "" { + return false, errors.New("goose.AreHooksInstalled: workspacePath is required") + } + + hooksPath := gooseHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return false, nil + } + _, rawHooks, err := readGooseHooks(hooksPath) + if err != nil { + return false, fmt.Errorf("goose.AreHooksInstalled: %w", err) + } + + for _, event := range gooseManagedEvents() { + var groups []gooseMatcherGroup + if err := parseGooseHookType(rawHooks, event, &groups); err != nil { + return false, fmt.Errorf("goose.AreHooksInstalled: %w", err) + } + for _, group := range groups { + for _, hook := range group.Hooks { + if isGooseManagedHook(hook.Command) { + return true, nil + } + } + } + } + return false, nil +} + +func gooseHooksPath(workspacePath string) string { + return filepath.Join(workspacePath, gooseHooksRootDirName, goosePluginsDirName, goosePluginName, gooseHooksSubDirName, gooseHooksFileName) +} + +// readGooseHooks loads the hooks file into a top-level raw map plus the decoded +// "hooks" sub-map, preserving keys AO doesn't manage. A missing or empty file +// yields empty maps. +func readGooseHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { + topLevel = map[string]json.RawMessage{} + rawHooks = map[string]json.RawMessage{} + + data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return topLevel, rawHooks, nil + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return topLevel, rawHooks, nil + } + if err := json.Unmarshal(data, &topLevel); err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) + } + if hooksRaw, ok := topLevel["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) + } + } + return topLevel, rawHooks, nil +} + +// writeGooseHooks folds rawHooks back into topLevel and writes the file. An +// empty hooks map drops the "hooks" key entirely. +func writeGooseHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { + if len(rawHooks) == 0 { + delete(topLevel, "hooks") + } else { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("encode hooks: %w", err) + } + topLevel["hooks"] = hooksJSON + } + + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + return fmt.Errorf("create hook dir: %w", err) + } + data, err := json.MarshalIndent(topLevel, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", hooksPath, err) + } + data = append(data, '\n') + if err := atomicWriteFile(hooksPath, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", hooksPath, err) + } + return nil +} + +// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- +// write can't leave a truncated/empty file that Goose then fails to parse. +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +// groupGooseHooksByEvent groups the managed hook specs by their Goose event so +// each event's array is rewritten once. +func groupGooseHooksByEvent() map[string][]gooseHookSpec { + byEvent := map[string][]gooseHookSpec{} + for _, spec := range gooseManagedHooks { + byEvent[spec.Event] = append(byEvent[spec.Event], spec) + } + return byEvent +} + +// gooseManagedEvents returns the distinct Goose events AO manages, in the order +// they first appear in gooseManagedHooks. +func gooseManagedEvents() []string { + seen := map[string]bool{} + events := make([]string, 0, len(gooseManagedHooks)) + for _, spec := range gooseManagedHooks { + if !seen[spec.Event] { + seen[spec.Event] = true + events = append(events, spec.Event) + } + } + return events +} + +func isGooseManagedHook(command string) bool { + return strings.HasPrefix(command, gooseHookCommandPrefix) +} + +// removeGooseManagedHooks strips AO hook entries from every group, dropping any +// group left without hooks. +func removeGooseManagedHooks(groups []gooseMatcherGroup) []gooseMatcherGroup { + result := make([]gooseMatcherGroup, 0, len(groups)) + for _, group := range groups { + kept := make([]gooseHookEntry, 0, len(group.Hooks)) + for _, hook := range group.Hooks { + if !isGooseManagedHook(hook.Command) { + kept = append(kept, hook) + } + } + if len(kept) > 0 { + group.Hooks = kept + result = append(result, group) + } + } + return result +} + +func parseGooseHookType(rawHooks map[string]json.RawMessage, event string, target *[]gooseMatcherGroup) error { + data, ok := rawHooks[event] + if !ok { + return nil + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("parse %s hooks: %w", event, err) + } + return nil +} + +func marshalGooseHookType(rawHooks map[string]json.RawMessage, event string, groups []gooseMatcherGroup) error { + if len(groups) == 0 { + delete(rawHooks, event) + return nil + } + data, err := json.Marshal(groups) + if err != nil { + return fmt.Errorf("encode %s hooks: %w", event, err) + } + rawHooks[event] = data + return nil +} + +func gooseHookCommandExists(groups []gooseMatcherGroup, command string) bool { + for _, group := range groups { + for _, hook := range group.Hooks { + if hook.Command == command { + return true + } + } + } + return false +} + +func addGooseHook(groups []gooseMatcherGroup, hook gooseHookEntry) []gooseMatcherGroup { + for i, group := range groups { + if group.Matcher == nil { + groups[i].Hooks = append(groups[i].Hooks, hook) + return groups + } + } + return append(groups, gooseMatcherGroup{ + Matcher: nil, + Hooks: []gooseHookEntry{hook}, + }) +} diff --git a/backend/internal/adapters/agent/kilocode/activity.go b/backend/internal/adapters/agent/kilocode/activity.go new file mode 100644 index 0000000..4f0dcca --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/activity.go @@ -0,0 +1,31 @@ +package kilocode + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// DeriveActivityState maps a Kilo Code plugin hook event onto an AO activity +// state. The bool is false when the event carries no activity signal. +// +// event is the AO hook sub-command name the installed plugin shells via +// `ao hooks kilocode ` (see kilocodeManagedEvents in hooks.go), not a +// native Kilo event name. The plugin reports: +// - "session-start" → a Kilo session was created (turn begins). +// - "user-prompt-submit" → the user submitted a prompt (turn begins). +// - "permission-request" → Kilo is asking the user to approve a tool call. +// - "stop" → the current turn went idle/finished. +// +// Kilo has no native session/process-end plugin event the adapter maps to +// ActivityExited, so runtime exit still falls back to the lifecycle reaper. +func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + switch event { + case "session-start": + return domain.ActivityActive, true + case "user-prompt-submit": + return domain.ActivityActive, true + case "stop": + return domain.ActivityIdle, true + case "permission-request": + return domain.ActivityWaitingInput, true + default: + return "", false + } +} diff --git a/backend/internal/adapters/agent/kilocode/assets/ao-activity.ts b/backend/internal/adapters/agent/kilocode/assets/ao-activity.ts new file mode 100644 index 0000000..5071b78 --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/assets/ao-activity.ts @@ -0,0 +1,203 @@ +// agent-orchestrator: managed kilocode activity plugin (do not edit) +// +// The Kilo Code CLI (binary "kilocode") is a fork of sst/opencode and loads the +// @opencode-ai/plugin runtime, so this plugin uses the same lifecycle surface. +// It maps Kilo's native lifecycle events onto AO's normalized activity events: +// session.created -> `ao hooks kilocode session-start` +// message.updated / message.part.updated -> `ao hooks kilocode user-prompt-submit` +// permission.ask hook -> `ao hooks kilocode permission-request` +// session.status (status.type == idle) -> `ao hooks kilocode stop` +// +// The native session id (and prompt/model where known) is piped to the hook +// command as JSON on stdin, run with cwd set to the worktree so AO can correlate +// the Kilo session to its AO session. Every invocation is best-effort and must +// never crash the user's Kilo session: a missing `ao` binary is a guarded no-op +// (`command -v ao`), and spawn exceptions, non-zero exit codes, and malformed +// event payloads are caught and surfaced through Kilo's structured logger +// (client.app.log) for diagnosis — never rethrown. +// +// `import type` is erased at runtime by Bun's transpiler, so this loads even +// before Kilo has installed @opencode-ai/plugin into the config dir. +import type { Plugin } from "@opencode-ai/plugin" + +export const aoActivity: Plugin = async ({ directory, client }) => { + // ao hooks must never be able to hang Kilo: cap each invocation, matching + // the 30s timeout the claude-code and codex hook entries use. + const HOOK_TIMEOUT_MS = 30_000 + // A user message is reported at most twice (see reportUserPrompt): an optional + // early empty report, then an upgrade carrying the prompt text. Maps a message + // id to whether the report we already sent included the prompt text. + const promptReports = new Map() + // message.* events don't carry the session id, so track it from events that do. + let currentSessionID: string | null = null + // The model of the most recent assistant message, forwarded for context. + let currentModel: string | null = null + const messageStore = new Map() + // Bound messageStore so it can't grow unbounded within a session: `kilo run` + // flows that never deliver a text message.part.updated leave the user message + // entry undeleted, so without a cap the map would accumulate across many turns. + // Map preserves insertion order, so the first key is the oldest entry. + const MESSAGE_STORE_MAX = 256 + function rememberMessage(id: string, msg: any) { + messageStore.set(id, msg) + while (messageStore.size > MESSAGE_STORE_MAX) { + const oldest = messageStore.keys().next().value + if (oldest === undefined) break + messageStore.delete(oldest) + } + } + + // Wrap in `sh -c` with a guard so a missing `ao` binary is a silent no-op + // (exit 0) rather than a per-event error in the user's session. + function hookCmd(hookName: string): string[] { + return ["sh", "-c", `if ! command -v ao >/dev/null 2>&1; then exit 0; fi; exec ao hooks kilocode ${hookName}`] + } + + // Report a hook failure through Kilo's structured logger. Best-effort: the + // log call must itself never throw or reject back into Kilo, hence the + // optional chaining + swallowed rejection. + function logHookFailure(hookName: string, detail: string) { + try { + void client?.app + ?.log?.({ body: { service: "ao-activity", level: "error", message: `hook ${hookName} failed: ${detail}` } }) + ?.catch?.(() => {}) + } catch { + // The logger itself is unavailable — nothing more we can safely do. + } + } + + // All hooks are dispatched synchronously (Bun.spawnSync), for two reasons: + // 1. Ordering. An async hook yields the event loop; if Kilo does not await + // the handler's promise, a later event (e.g. message.updated -> + // user-prompt-submit) could complete before an in-flight async + // session-start, so AO would see the prompt before the session is + // registered. spawnSync blocks Kilo's single-threaded loop until the hook + // returns, so events are reported strictly in dispatch order. + // 2. `kilo run` exits on the idle event, so an async stop hook would be + // killed before completing. + // + // A non-zero exit (the guard makes a missing `ao` exit 0, so this is a real + // `ao hooks` failure) or a spawn exception is logged with its stderr and never + // rethrown, so reporting failures are diagnosable without crashing Kilo. + function callHookSync(hookName: string, payload: Record) { + try { + const result = Bun.spawnSync(hookCmd(hookName), { + cwd: directory, + stdin: new TextEncoder().encode(JSON.stringify(payload) + "\n"), + stdout: "ignore", + stderr: "pipe", + timeout: HOOK_TIMEOUT_MS, + }) + if (!result.success) { + const stderr = result.stderr ? new TextDecoder().decode(result.stderr).trim() : "" + logHookFailure(hookName, `exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`) + } + } catch (err) { + // The spawn itself failed (e.g. no `sh` on PATH). Never propagate. + logHookFailure(hookName, err instanceof Error ? err.message : String(err)) + } + } + + function switchedSession(sessionID: string): boolean { + if (currentSessionID === sessionID) return false + promptReports.clear() + messageStore.clear() + currentModel = null + currentSessionID = sessionID + return true + } + + // Report a user prompt, preferring the one that carries the prompt text. + // message.updated can arrive before message.part.updated with no text, so an + // early empty report must NOT dedup away the later text report — otherwise the + // prompt never reaches AO and title-from-prompt metadata breaks. Therefore: an + // empty report fires at most once (so run-mode flows that omit the text part + // still mark the session active), and a text report fires once and is terminal. + function reportUserPrompt(sessionID: string, messageID: string, prompt: string) { + const hasText = prompt.length > 0 + const reportedWithText = promptReports.get(messageID) + if (reportedWithText) return // already reported with text — terminal + if (reportedWithText === false && !hasText) return // already reported empty; no new info + promptReports.set(messageID, hasText) + callHookSync("user-prompt-submit", { session_id: sessionID, prompt, model: currentModel ?? "" }) + } + + return { + // permission.ask fires when Kilo needs the user to approve a tool call. AO + // maps it to a sticky waiting_input state. The plugin only observes the + // request (it does not alter `output.status`), so Kilo's own approval flow + // is untouched. + "permission.ask": async (input: any) => { + try { + const sessionID = input?.sessionID ?? input?.sessionId ?? currentSessionID + if (!sessionID) return + callHookSync("permission-request", { session_id: sessionID, model: currentModel ?? "" }) + } catch (err) { + logHookFailure("permission-request", err instanceof Error ? err.message : String(err)) + } + }, + + event: async ({ event }) => { + try { + switch (event.type) { + case "session.created": { + const session = (event as any).properties?.info + if (!session?.id) break + if (switchedSession(session.id)) { + callHookSync("session-start", { session_id: session.id }) + } + break + } + + case "message.updated": { + const msg = (event as any).properties?.info + if (!msg) break + if (msg.sessionID && switchedSession(msg.sessionID)) { + callHookSync("session-start", { session_id: msg.sessionID }) + } + if (msg.role === "assistant" && msg.modelID) currentModel = msg.modelID + // Fallback: some `kilo run` flows never deliver message.part.updated + // for the prompt, so start the turn from the user message itself. + if (msg.role === "user") { + rememberMessage(msg.id, msg) + const sessionID = msg.sessionID ?? currentSessionID + if (sessionID) reportUserPrompt(sessionID, msg.id, "") + } + break + } + + case "message.part.updated": { + const part = (event as any).properties?.part + if (!part?.messageID) break + const msg = messageStore.get(part.messageID) + if (msg?.role === "user" && part.type === "text") { + const sessionID = msg.sessionID ?? currentSessionID + const prompt = part.text ?? "" + if (sessionID) reportUserPrompt(sessionID, msg.id, prompt) + if (prompt.length > 0) messageStore.delete(part.messageID) + } + break + } + + case "session.status": { + // session.status fires in both TUI and `kilo run`; session.idle is + // deprecated and not reliably emitted in run mode. + // AO's "stop" hook means "the current turn is idle/finished", not + // "the whole native session has terminated", so multi-turn TUI + // sessions intentionally emit one stop per idle transition. + const props = (event as any).properties + if (props?.status?.type !== "idle") break + const sessionID = props?.sessionID ?? currentSessionID + if (!sessionID) break + callHookSync("stop", { session_id: sessionID, model: currentModel ?? "" }) + break + } + } + } catch (err) { + // A malformed/unexpected event payload must never crash Kilo; log it + // (tagged with the event type) for diagnosis and move on. + logHookFailure(`event:${(event as any)?.type ?? "unknown"}`, err instanceof Error ? err.message : String(err)) + } + }, + } +} diff --git a/backend/internal/adapters/agent/kilocode/hooks.go b/backend/internal/adapters/agent/kilocode/hooks.go new file mode 100644 index 0000000..3297407 --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/hooks.go @@ -0,0 +1,186 @@ +package kilocode + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + _ "embed" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // Kilo Code scans each config dir for `{plugin,plugins}/*.{ts,js}` (verified + // in the @kilocode/cli binary). Its config-dir suffixes are `.kilo`, + // `.kilocode`, and `.opencode` (it is an opencode fork). AO writes the + // branded `.kilocode/plugins/` so the AO plugin lands in Kilo's own dir and + // never collides with a sibling opencode adapter's `.opencode/` install. + kilocodePluginDirName = ".kilocode" + kilocodePluginSubDir = "plugins" + + // kilocodePluginFileName is the AO-owned plugin file. AO fully owns this + // filename: install overwrites it and uninstall deletes it (guarded by the + // sentinel), so user-authored plugins in other files are never touched. + // It is TypeScript (Kilo runs on Bun); the file's only import is a type-only + // import, which Bun erases at runtime. + kilocodePluginFileName = "ao-activity.ts" + + // kilocodePluginSentinel marks the file as AO-managed. AreHooksInstalled and + // UninstallHooks key off it so AO never deletes a user file that happens to + // share the name. It must appear verbatim in the embedded plugin source. + kilocodePluginSentinel = "agent-orchestrator: managed kilocode activity plugin" + + // kilocodeHookCommandPrefix identifies the hook commands AO owns. The + // embedded plugin shells `ao hooks kilocode `; this prefix is the + // shared contract with the `ao hooks` CLI dispatcher and is asserted by tests + // so the plugin can't silently drift away from it. + kilocodeHookCommandPrefix = "ao hooks kilocode " +) + +// kilocodePluginSource is the AO-managed Kilo Code plugin, embedded so it ships +// inside the binary and is written verbatim into a session's worktree on hook +// install. It is a real, lintable source file under assets/ rather than a Go +// string literal because it is plugin source code, not a data structure AO +// assembles (the way it builds Codex/Claude hook JSON). +// +//go:embed assets/ao-activity.ts +var kilocodePluginSource string + +// kilocodeManagedEvents are the normalized activity events the embedded plugin +// reports. They are defined here (not parsed from the file) so tests can assert +// the plugin wires every one via the `ao hooks kilocode ` command, and +// they mirror exactly the events kilocode.DeriveActivityState switches on. +var kilocodeManagedEvents = []string{"session-start", "user-prompt-submit", "permission-request", "stop"} + +// GetAgentHooks installs AO's Kilo Code activity plugin into the worktree-local +// .kilocode/plugins/ directory. Unlike Claude Code and Codex, Kilo Code has no +// native command-hook config to merge into; its only lifecycle-extensibility +// surface is a JS/TS plugin. AO therefore writes a dedicated, AO-owned plugin +// file. The write is atomic and idempotent: re-installing overwrites AO's own +// file with identical content. It refuses to overwrite a file that is NOT +// AO-managed (no sentinel), so a user plugin that happens to occupy our path is +// never silently destroyed — install fails loudly instead. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(cfg.WorkspacePath) == "" { + return errors.New("kilocode.GetAgentHooks: WorkspacePath is required") + } + + pluginPath := kilocodePluginPath(cfg.WorkspacePath) + // Guard against clobbering a user file at our path: overwrite only when the + // target is absent or already AO-managed. A foreign file is a loud error, + // not silent data loss (uninstall is sentinel-guarded the same way). + if _, err := os.Stat(pluginPath); err == nil { + managed, err := isAOManagedPlugin(pluginPath) + if err != nil { + return fmt.Errorf("kilocode.GetAgentHooks: %w", err) + } + if !managed { + return fmt.Errorf("kilocode.GetAgentHooks: refusing to overwrite non-AO file at %s — move it so AO can install its plugin", pluginPath) + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("kilocode.GetAgentHooks: stat plugin: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(pluginPath), 0o750); err != nil { + return fmt.Errorf("kilocode.GetAgentHooks: create plugin dir: %w", err) + } + if err := atomicWriteFile(pluginPath, []byte(kilocodePluginSource), 0o600); err != nil { + return fmt.Errorf("kilocode.GetAgentHooks: write plugin: %w", err) + } + return nil +} + +// UninstallHooks removes AO's Kilo Code plugin from the workspace-local +// .kilocode/plugins/ directory. It deletes the file only when it carries the AO +// sentinel, so a user file that happens to share the name is left in place. A +// missing file is a no-op. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return errors.New("kilocode.UninstallHooks: workspacePath is required") + } + + pluginPath := kilocodePluginPath(workspacePath) + managed, err := isAOManagedPlugin(pluginPath) + if err != nil { + return fmt.Errorf("kilocode.UninstallHooks: %w", err) + } + if !managed { + return nil + } + if err := os.Remove(pluginPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("kilocode.UninstallHooks: remove plugin: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether AO's Kilo Code plugin is present in the +// workspace-local plugin dir. A missing file, or a same-named file without the +// AO sentinel, means none are installed. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if strings.TrimSpace(workspacePath) == "" { + return false, errors.New("kilocode.AreHooksInstalled: workspacePath is required") + } + managed, err := isAOManagedPlugin(kilocodePluginPath(workspacePath)) + if err != nil { + return false, fmt.Errorf("kilocode.AreHooksInstalled: %w", err) + } + return managed, nil +} + +func kilocodePluginPath(workspacePath string) string { + return filepath.Join(workspacePath, kilocodePluginDirName, kilocodePluginSubDir, kilocodePluginFileName) +} + +// isAOManagedPlugin reports whether the file at path exists and carries the AO +// sentinel. A missing file yields (false, nil). +func isAOManagedPlugin(path string) (bool, error) { + data, err := os.ReadFile(path) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read %s: %w", path, err) + } + return strings.Contains(string(data), kilocodePluginSentinel), nil +} + +// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- +// write can't leave a truncated plugin file that Kilo then fails to import +// (silently disabling activity reporting). +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() // no-op once renamed + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} diff --git a/backend/internal/adapters/agent/kilocode/kilocode.go b/backend/internal/adapters/agent/kilocode/kilocode.go new file mode 100644 index 0000000..1d9286e --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/kilocode.go @@ -0,0 +1,322 @@ +// Package kilocode implements the Kilo Code CLI agent adapter: launching new +// TUI sessions, resuming sessions by native id, installing a workspace-local +// activity plugin, and reading plugin-derived session info. +// +// The Kilo Code CLI (binary "kilocode", also aliased "kilo"; npm package +// @kilocode/cli) is a fork of sst/opencode and shares its CLI surface and +// plugin runtime, so AO bridges it the same two ways it bridges opencode: +// - It has no native command-hook config (no settings.local.json / hooks.json +// equivalent). Its only lifecycle-extensibility surface is the @opencode-ai +// plugin SDK loaded from a config dir's `{plugin,plugins}/*.{ts,js}` glob, +// so GetAgentHooks installs an AO-owned plugin file (see hooks.go) into +// .kilocode/plugins/ instead of merging JSON. +// - Its interactive TUI exposes no permission flag (the --auto flag lives only +// on `kilo run`, not the default TUI command AO launches) and no +// system-prompt flag. AO's graduated permission modes are delivered via the +// KILO_CONFIG_CONTENT env var, which Kilo deep-merges as the +// highest-precedence inline config; the system prompt defers to Kilo's own +// config. +// +// AO-managed sessions derive native session identity and display metadata from +// the Kilo plugin's reported events, mirroring the opencode and Codex adapters. +package kilocode + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // adapterID is the registry id and the value users pass to + // `ao spawn --agent`. It matches domain.HarnessKilocode. + adapterID = "kilocode" + + // Normalized session-metadata keys the Kilo plugin persists into the AO + // session store and SessionInfo reads back. Shared vocabulary with the Codex + // and opencode adapters so the dashboard treats every agent uniformly. The + // agent-session-id key is the shared ports.MetadataKeyAgentSessionID. + kilocodeTitleMetadataKey = "title" + kilocodeSummaryMetadataKey = "summary" +) + +// Plugin is the Kilo Code agent adapter. It is safe for concurrent use; the +// binary path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Kilo Code adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Kilo Code", + Description: "Run Kilo Code worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Kilo Code exposes none +// yet: model and agent selection are read from Kilo's own config +// (kilo.json / ~/.config/kilo), exactly as a normal launch. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new interactive Kilo Code session. +// Shape: +// +// [env KILO_CONFIG_CONTENT=] kilocode [--prompt ] +// +// The session runs in the worktree (cwd is set by the runtime, as for opencode +// and Codex). Kilo Code has no CLI flag to set a system prompt, so +// cfg.SystemPrompt / SystemPromptFile are intentionally ignored here — Kilo +// resolves instructions from its own config and AGENTS.md rules. The initial +// task prompt is delivered via --prompt (its argument, so a leading "-" is not +// read as a flag). Non-default permission modes prepend a KILO_CONFIG_CONTENT +// env assignment rather than a flag (see kilocodePermissionEnvPrefix). +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.kilocodeBinary(ctx) + if err != nil { + return nil, err + } + + cmd = append(kilocodePermissionEnvPrefix(cfg.Permissions), binary) + if cfg.Prompt != "" { + cmd = append(cmd, "--prompt", cfg.Prompt) + } + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Kilo Code receives its prompt in the +// launch command itself (via --prompt). +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetRestoreCommand rebuilds the argv that continues an existing Kilo Code +// session: `[env KILO_CONFIG_CONTENT=] kilocode --session `. +// It re-applies the permission env (resume otherwise reverts to the configured +// default) but not the prompt, which the session already carries. ok is false +// when the plugin-derived native session id has not landed yet, so callers fall +// back to fresh launch behavior — mirroring the opencode adapter. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.kilocodeBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = append(kilocodePermissionEnvPrefix(cfg.Permissions), binary, "--session", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Kilo plugin-derived metadata. Metadata is intentionally +// nil for Kilo Code: callers get the normalized fields directly, matching the +// opencode and Codex adapters. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[kilocodeTitleMetadataKey], + Summary: session.Metadata[kilocodeSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// kilocodePermissionEnvVar is the env var Kilo deep-merges as the +// highest-precedence inline config (`KILO_CONFIG_CONTENT`, see the CLI's config +// precedence: global -> KILO_CONFIG -> ./kilo.json -> .kilo/kilo.json -> +// KILO_CONFIG_CONTENT -> managed; later wins). It is the permission-control +// surface the interactive TUI honors: the --auto flag exists solely on +// `kilo run`, not on the default TUI command AO launches, so passing any +// permission flag would make Kilo reject the argv and the session fail to launch. +const kilocodePermissionEnvVar = "KILO_CONFIG_CONTENT" + +// kilocodePermissionConfig maps an AO permission mode onto Kilo's permission +// config (tool -> action, values "ask"/"allow"/"deny", verified via +// `kilocode config check`). Tools left unset fall back to Kilo's own default +// action ("ask"), so each mode only names the tools it relaxes: +// - default → nil: no env; Kilo's config decides every prompt. +// - accept-edits → edits ("write"/"edit"/"patch" gate on the "edit" +// key) auto-approved; bash and everything else still prompt. +// - auto → edits + bash auto-approved; network/other still prompt. +// Kilo has no classifier/reviewer gate (unlike Claude Code's "auto"), so +// this is the closest analog its flat allow/ask/deny config can express. +// - bypass-permissions → "*" wildcard-allows every tool: nothing prompts. +func kilocodePermissionConfig(mode ports.PermissionMode) map[string]string { + switch normalizePermissionMode(mode) { + case ports.PermissionModeAcceptEdits: + return map[string]string{"edit": "allow"} + case ports.PermissionModeAuto: + return map[string]string{"edit": "allow", "bash": "allow"} + case ports.PermissionModeBypassPermissions: + return map[string]string{"*": "allow"} + default: + return nil + } +} + +// kilocodePermissionEnvPrefix renders mode's permission config as an +// `env KILO_CONFIG_CONTENT=` argv prefix, or nil for the default mode. +// +// The var must reach Kilo as a process env var, not an argv flag. The runtime +// runs the argv through a shell, which execs `env`, which sets the var and execs +// kilocode. A bare `KILO_CONFIG_CONTENT=...` argv element would not work: the +// runtime shell-quotes every element, and a quoted token is run as a command +// rather than read as an assignment — hence the explicit `env` wrapper. +// POSIX-only, which matches the zellij runtime. +func kilocodePermissionEnvPrefix(mode ports.PermissionMode) []string { + config := kilocodePermissionConfig(mode) + if len(config) == 0 { + return nil + } + // The inline config is the JSON object {"permission": {: }}. + // Marshaling a map[string]string never errors and emits keys in sorted order, + // so the prefix is deterministic for tests and reproducible across launches. + blob, err := json.Marshal(map[string]map[string]string{"permission": config}) + if err != nil { + // Should never happen for map[string]map[string]string, but a silent + // empty KILO_CONFIG_CONTENT would silently launch with default Kilo + // permissions regardless of the requested mode — drop the prefix + // entirely so the caller's mode choice can't be misrepresented. + return nil + } + return []string{"env", kilocodePermissionEnvVar + "=" + string(blob)} +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + // Empty or unrecognized: defer to Kilo's own config (no flag). + return ports.PermissionModeDefault + } +} + +// ResolveKilocodeBinary returns the path to the kilocode binary on this machine, +// searching PATH then a handful of well-known install locations (npm global +// bin, Homebrew). Returns "kilocode" as a last-ditch fallback so callers see a +// clear "command not found" rather than an empty argv. +func ResolveKilocodeBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"kilocode.cmd", "kilocode.exe", "kilocode"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "kilocode.cmd"), + filepath.Join(appData, "npm", "kilocode.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + return "kilocode", nil + } + + if path, err := exec.LookPath("kilocode"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/kilocode", + "/opt/homebrew/bin/kilocode", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".npm-global", "bin", "kilocode"), + filepath.Join(home, ".npm", "bin", "kilocode"), + filepath.Join(home, ".local", "bin", "kilocode"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "kilocode", nil +} + +func (p *Plugin) kilocodeBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveKilocodeBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/kilocode/kilocode_test.go b/backend/internal/adapters/agent/kilocode/kilocode_test.go new file mode 100644 index 0000000..c9335d8 --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/kilocode_test.go @@ -0,0 +1,449 @@ +package kilocode + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifestIDIsKilocode(t *testing.T) { + m := New().Manifest() + if m.ID != "kilocode" { + t.Fatalf("Manifest ID = %q, want kilocode", m.ID) + } + if m.Name != "Kilo Code" { + t.Fatalf("Manifest Name = %q, want Kilo Code", m.Name) + } +} + +func TestGetLaunchCommandBuildsArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kilocode"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-fix this", + SystemPromptFile: filepath.Join("tmp", "prompt with spaces.md"), + SystemPrompt: "ignored", + }) + if err != nil { + t.Fatal(err) + } + + // Kilo has no system-prompt flag, so SystemPrompt/SystemPromptFile are + // dropped; the prompt is delivered via --prompt. bypass-permissions prepends + // an `env KILO_CONFIG_CONTENT=...` assignment (the TUI has no permission flag). + want := []string{ + "env", `KILO_CONFIG_CONTENT={"permission":{"*":"allow"}}`, + "kilocode", + "--prompt", "-fix this", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + // wantEnv is the expected KILO_CONFIG_CONTENT value, or "" when the mode + // emits no env prefix at all (defers entirely to Kilo's own config). + wantEnv string + }{ + {name: "default", permission: ports.PermissionModeDefault, wantEnv: ""}, + {name: "accept-edits", permission: ports.PermissionModeAcceptEdits, wantEnv: `{"permission":{"edit":"allow"}}`}, + {name: "auto", permission: ports.PermissionModeAuto, wantEnv: `{"permission":{"bash":"allow","edit":"allow"}}`}, + {name: "bypass-permissions", permission: ports.PermissionModeBypassPermissions, wantEnv: `{"permission":{"*":"allow"}}`}, + {name: "empty", permission: "", wantEnv: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kilocode"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{Permissions: tt.permission}) + if err != nil { + t.Fatal(err) + } + // A permission FLAG must never leak onto the interactive TUI launch; + // those exist only on `kilo run` (--auto). + if contains(cmd, "--auto") { + t.Fatalf("command %#v contains run-only --auto", cmd) + } + if tt.wantEnv == "" { + if len(cmd) == 0 || cmd[0] == "env" { + t.Fatalf("command %#v should have no env prefix", cmd) + } + return + } + // Non-default modes prepend `env KILO_CONFIG_CONTENT=`. + want := "KILO_CONFIG_CONTENT=" + tt.wantEnv + if len(cmd) < 3 || cmd[0] != "env" || cmd[1] != want || cmd[2] != "kilocode" { + t.Fatalf("command %#v must be prefixed with `env %s`", cmd, want) + } + }) + } +} + +func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kilocode"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryInCommand { + t.Fatalf("unexpected strategy: %q", got) + } +} + +func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kilocode"} + + spec, err := plugin.GetConfigSpec(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(spec.Fields) != 0 { + t.Fatalf("unexpected config fields: %#v", spec.Fields) + } +} + +func TestGetAgentHooksInstallsPlugin(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kilocode"} + workspace := t.TempDir() + + // A user's own plugin in the same dir must survive AO's install untouched. + pluginDir := filepath.Dir(kilocodePluginPath(workspace)) + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + userPlugin := filepath.Join(pluginDir, "user.js") + userBody := []byte("export const userPlugin = async () => ({})\n") + if err := os.WriteFile(userPlugin, userBody, 0o644); err != nil { + t.Fatal(err) + } + + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + // A second install must be idempotent (overwrite with identical content). + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + + data, err := os.ReadFile(kilocodePluginPath(workspace)) + if err != nil { + t.Fatal(err) + } + body := string(data) + if !strings.Contains(body, kilocodePluginSentinel) { + t.Fatalf("installed plugin missing AO sentinel:\n%s", body) + } + // Every normalized activity event must be wired via `ao hooks kilocode `. + for _, event := range kilocodeManagedEvents { + want := kilocodeHookCommandPrefix + event + if !strings.Contains(body, want) { + t.Fatalf("installed plugin missing hook command %q:\n%s", want, body) + } + } + // The Kilo-native lifecycle surfaces the plugin subscribes to. Stop maps to + // session.status(idle) — NOT the deprecated session.idle — the user prompt is + // detected from message.updated/message.part.updated, and permission requests + // from the permission.ask hook. + for _, marker := range []string{"session.created", "message.updated", "message.part.updated", "session.status", "permission.ask"} { + if !strings.Contains(body, marker) { + t.Fatalf("installed plugin missing Kilo event %q:\n%s", marker, body) + } + } + // Guard against regressing back to subscribing to the deprecated/unreliable + // session.idle event (the quoted event string is how a `case` would name it; + // the explanatory comment mentions it unquoted, which is fine). + if strings.Contains(body, `"session.idle"`) { + t.Fatalf("plugin subscribes to deprecated session.idle; use session.status(idle):\n%s", body) + } + // A hung `ao hooks` call must not block Kilo forever, so each spawn is + // time-boxed (parity with the claude/codex 30s hook timeout). + if !strings.Contains(body, "timeout:") { + t.Fatalf("plugin spawn has no timeout; a hung hook would block Kilo:\n%s", body) + } + + // The user's plugin is untouched. + got, err := os.ReadFile(userPlugin) + if err != nil { + t.Fatalf("user plugin removed by install: %v", err) + } + if !reflect.DeepEqual(got, userBody) { + t.Fatalf("user plugin modified by install: %q", got) + } +} + +func TestGetAgentHooksRefusesToClobberForeignFile(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kilocode"} + workspace := t.TempDir() + ctx := context.Background() + + // A non-AO file occupying AO's exact path must NOT be silently overwritten. + pluginPath := kilocodePluginPath(workspace) + if err := os.MkdirAll(filepath.Dir(pluginPath), 0o755); err != nil { + t.Fatal(err) + } + foreign := []byte("export const notOurs = async () => ({})\n") + if err := os.WriteFile(pluginPath, foreign, 0o644); err != nil { + t.Fatal(err) + } + + err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{WorkspacePath: workspace}) + if err == nil { + t.Fatal("GetAgentHooks overwrote a non-AO file; want a loud error") + } + got, readErr := os.ReadFile(pluginPath) + if readErr != nil { + t.Fatalf("foreign file removed by refused install: %v", readErr) + } + if !reflect.DeepEqual(got, foreign) { + t.Fatalf("foreign file modified by refused install: %q", got) + } +} + +func TestUninstallHooksRemovesPlugin(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kilocode"} + workspace := t.TempDir() + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + + // Pre-seed a user's own plugin; it must survive uninstall. + pluginDir := filepath.Dir(kilocodePluginPath(workspace)) + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + userPlugin := filepath.Join(pluginDir, "user.js") + if err := os.WriteFile(userPlugin, []byte("export const userPlugin = async () => ({})\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + + if err := plugin.UninstallHooks(ctx, workspace); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err) + } + if _, err := os.Stat(kilocodePluginPath(workspace)); !os.IsNotExist(err) { + t.Fatalf("AO plugin still present after uninstall: err=%v", err) + } + if _, err := os.Stat(userPlugin); err != nil { + t.Fatalf("user plugin removed by uninstall: %v", err) + } +} + +func TestUninstallHooksLeavesForeignFile(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kilocode"} + workspace := t.TempDir() + ctx := context.Background() + + // A non-AO file occupying AO's filename must NOT be deleted by uninstall. + pluginPath := kilocodePluginPath(workspace) + if err := os.MkdirAll(filepath.Dir(pluginPath), 0o755); err != nil { + t.Fatal(err) + } + foreign := []byte("export const notOurs = async () => ({})\n") + if err := os.WriteFile(pluginPath, foreign, 0o644); err != nil { + t.Fatal(err) + } + + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled on foreign file = (%v, %v), want (false, nil)", installed, err) + } + if err := plugin.UninstallHooks(ctx, workspace); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("foreign file removed by uninstall: %v", err) + } + if !reflect.DeepEqual(got, foreign) { + t.Fatalf("foreign file modified by uninstall: %q", got) + } +} + +func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kilocode"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "ses_abc123"}, + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + want := []string{ + "env", `KILO_CONFIG_CONTENT={"permission":{"*":"allow"}}`, + "kilocode", + "--session", "ses_abc123", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kilocode"} + + cases := []struct { + name string + ref ports.SessionRef + }{ + {"empty session ref", ports.SessionRef{}}, + {"empty metadata", ports.SessionRef{Metadata: map[string]string{}}}, + {"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}}, + {"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeDefault, + Session: tc.ref, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if cmd != nil { + t.Fatalf("cmd = %#v, want nil", cmd) + } + }) + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kilocode"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "ses_abc123", + kilocodeTitleMetadataKey: "Fix login redirect", + kilocodeSummaryMetadataKey: "Updated the auth callback and tests.", + "ignored": "not returned", + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatalf("ok = false, want true") + } + if info.AgentSessionID != "ses_abc123" { + t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q, want hook title", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q, want hook summary", info.Summary) + } + if info.Metadata != nil { + t.Fatalf("Metadata = %#v, want nil for kilocode", info.Metadata) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kilocode"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero value", info) + } +} + +func TestDeriveActivityState(t *testing.T) { + cases := []struct { + event string + wantState domain.ActivityState + wantOK bool + }{ + {"session-start", domain.ActivityActive, true}, + {"user-prompt-submit", domain.ActivityActive, true}, + {"stop", domain.ActivityIdle, true}, + {"permission-request", domain.ActivityWaitingInput, true}, + {"unknown", "", false}, + {"", "", false}, + } + for _, tc := range cases { + t.Run(tc.event, func(t *testing.T) { + state, ok := DeriveActivityState(tc.event, nil) + if state != tc.wantState || ok != tc.wantOK { + t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tc.event, state, ok, tc.wantState, tc.wantOK) + } + }) + } +} + +func TestContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // These methods check ctx.Err() before doing any work, so a cancelled + // context surfaces as an error. (GetLaunchCommand resolves the binary first, + // whose own ctx check is short-circuited by the cached resolvedBinary, so it + // is intentionally not asserted here — matching the codex/opencode exemplars.) + plugin := &Plugin{resolvedBinary: "kilocode"} + if _, err := plugin.GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); err == nil { + t.Fatal("GetPromptDeliveryStrategy: want ctx error, got nil") + } + if _, err := plugin.GetConfigSpec(ctx); err == nil { + t.Fatal("GetConfigSpec: want ctx error, got nil") + } + if _, _, err := plugin.GetRestoreCommand(ctx, ports.RestoreConfig{}); err == nil { + t.Fatal("GetRestoreCommand: want ctx error, got nil") + } + if _, _, err := plugin.SessionInfo(ctx, ports.SessionRef{}); err == nil { + t.Fatal("SessionInfo: want ctx error, got nil") + } + if err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{WorkspacePath: "/tmp"}); err == nil { + t.Fatal("GetAgentHooks: want ctx error, got nil") + } +} + +func contains(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} diff --git a/backend/internal/adapters/agent/kiro/activity.go b/backend/internal/adapters/agent/kiro/activity.go new file mode 100644 index 0000000..619bb22 --- /dev/null +++ b/backend/internal/adapters/agent/kiro/activity.go @@ -0,0 +1,31 @@ +package kiro + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// DeriveActivityState maps a Kiro hook event onto an AO activity state. The +// bool is false when the event carries no activity signal. +// +// event is the AO hook sub-command name installed in kiroManagedHooks +// ("session-start", "user-prompt-submit", "permission-request", "stop"), not +// the native Kiro event name (agentSpawn/userPromptSubmit/preToolUse/stop). +// Kiro currently has no session/process-end hook in the adapter, so runtime +// exit still falls back to the lifecycle reaper. +// +// TODO(kiro): ActivityExited is still runtime-observation-owned. If Kiro adds a +// native session/process-end hook, map that hook to ActivityExited here. Until +// then, make sure the lifecycle reaper can still mark a dead Kiro runtime as +// exited even when the last hook signal was sticky waiting_input. +func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + switch event { + case "session-start": + return domain.ActivityActive, true + case "user-prompt-submit": + return domain.ActivityActive, true + case "stop": + return domain.ActivityIdle, true + case "permission-request": + return domain.ActivityWaitingInput, true + default: + return "", false + } +} diff --git a/backend/internal/adapters/agent/kiro/hooks.go b/backend/internal/adapters/agent/kiro/hooks.go new file mode 100644 index 0000000..e5f8149 --- /dev/null +++ b/backend/internal/adapters/agent/kiro/hooks.go @@ -0,0 +1,327 @@ +package kiro + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // Kiro reads hooks from a workspace-local agent configuration file at + // .kiro/agents/.json. AO installs its hooks into a dedicated agent + // file so it never clobbers a user's own agents. + // See https://kiro.dev/docs/cli/hooks/ and + // https://kiro.dev/docs/cli/custom-agents/configuration-reference#hooks-field + kiroHooksDirName = ".kiro" + kiroAgentsDirName = "agents" + kiroAgentFileName = "ao.json" + + // kiroHookCommandPrefix identifies the hook commands AO owns, so install + // skips duplicates and uninstall recognizes AO entries by prefix without an + // embedded template to diff against. + kiroHookCommandPrefix = "ao hooks kiro " +) + +// kiroHookFile is the on-disk shape of .kiro/agents/ao.json. It is used by +// tests to decode the written file. Kiro hooks are a map of camelCase event +// name to a flat array of {matcher?, command} entries. +type kiroHookFile struct { + Hooks map[string][]kiroHookEntry `json:"hooks"` +} + +type kiroHookEntry struct { + Matcher string `json:"matcher,omitempty"` + Command string `json:"command"` +} + +// kiroHookSpec describes one hook AO installs, defined in code rather than read +// from an embedded hooks file. +type kiroHookSpec struct { + // Event is the native Kiro hook event name (camelCase). + Event string + // Command is the AO hook command line. + Command string +} + +// kiroManagedHooks is the source of truth for the hooks AO installs. The native +// Kiro events are mapped onto AO hook sub-command names (the trailing word) so +// the CLI hook dispatcher routes them to DeriveActivityState: +// +// agentSpawn -> session-start (ActivityActive) +// userPromptSubmit -> user-prompt-submit (ActivityActive) +// preToolUse -> permission-request (ActivityWaitingInput) +// stop -> stop (ActivityIdle) +var kiroManagedHooks = []kiroHookSpec{ + {Event: "agentSpawn", Command: kiroHookCommandPrefix + "session-start"}, + {Event: "userPromptSubmit", Command: kiroHookCommandPrefix + "user-prompt-submit"}, + {Event: "preToolUse", Command: kiroHookCommandPrefix + "permission-request"}, + {Event: "stop", Command: kiroHookCommandPrefix + "stop"}, +} + +// GetAgentHooks installs AO's Kiro hooks into the worktree-local +// .kiro/agents/ao.json file. Existing hook entries are preserved and duplicate +// AO commands are not appended. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(cfg.WorkspacePath) == "" { + return errors.New("kiro.GetAgentHooks: WorkspacePath is required") + } + + hooksPath := kiroAgentPath(cfg.WorkspacePath) + topLevel, rawHooks, err := readKiroHooks(hooksPath) + if err != nil { + return fmt.Errorf("kiro.GetAgentHooks: %w", err) + } + + for event, specs := range groupKiroHooksByEvent() { + var existing []kiroHookEntry + if err := parseKiroHookEvent(rawHooks, event, &existing); err != nil { + return fmt.Errorf("kiro.GetAgentHooks: %w", err) + } + for _, spec := range specs { + if !kiroHookCommandExists(existing, spec.Command) { + existing = append(existing, kiroHookEntry{Command: spec.Command}) + } + } + if err := marshalKiroHookEvent(rawHooks, event, existing); err != nil { + return fmt.Errorf("kiro.GetAgentHooks: %w", err) + } + } + + if err := writeKiroHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("kiro.GetAgentHooks: %w", err) + } + return nil +} + +// UninstallHooks removes AO's Kiro hooks from the workspace-local +// .kiro/agents/ao.json file, leaving user-defined hooks untouched. A missing +// file is a no-op. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return errors.New("kiro.UninstallHooks: workspacePath is required") + } + + hooksPath := kiroAgentPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return nil + } + topLevel, rawHooks, err := readKiroHooks(hooksPath) + if err != nil { + return fmt.Errorf("kiro.UninstallHooks: %w", err) + } + + for _, event := range kiroManagedEvents() { + var entries []kiroHookEntry + if err := parseKiroHookEvent(rawHooks, event, &entries); err != nil { + return fmt.Errorf("kiro.UninstallHooks: %w", err) + } + entries = removeKiroManagedHooks(entries) + if err := marshalKiroHookEvent(rawHooks, event, entries); err != nil { + return fmt.Errorf("kiro.UninstallHooks: %w", err) + } + } + + if err := writeKiroHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("kiro.UninstallHooks: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether any AO Kiro hook is present in the +// workspace-local agent file. A missing file means none are installed. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if strings.TrimSpace(workspacePath) == "" { + return false, errors.New("kiro.AreHooksInstalled: workspacePath is required") + } + + hooksPath := kiroAgentPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return false, nil + } + _, rawHooks, err := readKiroHooks(hooksPath) + if err != nil { + return false, fmt.Errorf("kiro.AreHooksInstalled: %w", err) + } + + for _, event := range kiroManagedEvents() { + var entries []kiroHookEntry + if err := parseKiroHookEvent(rawHooks, event, &entries); err != nil { + return false, fmt.Errorf("kiro.AreHooksInstalled: %w", err) + } + for _, entry := range entries { + if isKiroManagedHook(entry.Command) { + return true, nil + } + } + } + return false, nil +} + +func kiroAgentPath(workspacePath string) string { + return filepath.Join(workspacePath, kiroHooksDirName, kiroAgentsDirName, kiroAgentFileName) +} + +// readKiroHooks loads the agent file into a top-level raw map plus the decoded +// "hooks" sub-map, preserving keys AO doesn't manage. A missing or empty file +// yields empty maps. +func readKiroHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { + topLevel = map[string]json.RawMessage{} + rawHooks = map[string]json.RawMessage{} + + data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return topLevel, rawHooks, nil + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return topLevel, rawHooks, nil + } + if err := json.Unmarshal(data, &topLevel); err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) + } + if hooksRaw, ok := topLevel["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) + } + } + return topLevel, rawHooks, nil +} + +// writeKiroHooks folds rawHooks back into topLevel and writes the file. An +// empty hooks map drops the "hooks" key entirely. +func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { + if len(rawHooks) == 0 { + delete(topLevel, "hooks") + } else { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("encode hooks: %w", err) + } + topLevel["hooks"] = hooksJSON + } + + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + return fmt.Errorf("create hook dir: %w", err) + } + data, err := json.MarshalIndent(topLevel, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", hooksPath, err) + } + data = append(data, '\n') + if err := atomicWriteFile(hooksPath, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", hooksPath, err) + } + return nil +} + +// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- +// write can't leave a truncated/empty file that Kiro then fails to parse. +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +// groupKiroHooksByEvent groups the managed hook specs by their Kiro event so +// each event's array is rewritten once. +func groupKiroHooksByEvent() map[string][]kiroHookSpec { + byEvent := map[string][]kiroHookSpec{} + for _, spec := range kiroManagedHooks { + byEvent[spec.Event] = append(byEvent[spec.Event], spec) + } + return byEvent +} + +// kiroManagedEvents returns the distinct Kiro events AO manages, in the order +// they first appear in kiroManagedHooks. +func kiroManagedEvents() []string { + seen := map[string]bool{} + events := make([]string, 0, len(kiroManagedHooks)) + for _, spec := range kiroManagedHooks { + if !seen[spec.Event] { + seen[spec.Event] = true + events = append(events, spec.Event) + } + } + return events +} + +func isKiroManagedHook(command string) bool { + return strings.HasPrefix(command, kiroHookCommandPrefix) +} + +// removeKiroManagedHooks strips AO hook entries from an event's array. +func removeKiroManagedHooks(entries []kiroHookEntry) []kiroHookEntry { + kept := make([]kiroHookEntry, 0, len(entries)) + for _, entry := range entries { + if !isKiroManagedHook(entry.Command) { + kept = append(kept, entry) + } + } + return kept +} + +func parseKiroHookEvent(rawHooks map[string]json.RawMessage, event string, target *[]kiroHookEntry) error { + data, ok := rawHooks[event] + if !ok { + return nil + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("parse %s hooks: %w", event, err) + } + return nil +} + +func marshalKiroHookEvent(rawHooks map[string]json.RawMessage, event string, entries []kiroHookEntry) error { + if len(entries) == 0 { + delete(rawHooks, event) + return nil + } + data, err := json.Marshal(entries) + if err != nil { + return fmt.Errorf("encode %s hooks: %w", event, err) + } + rawHooks[event] = data + return nil +} + +func kiroHookCommandExists(entries []kiroHookEntry, command string) bool { + for _, entry := range entries { + if entry.Command == command { + return true + } + } + return false +} diff --git a/backend/internal/adapters/agent/kiro/kiro.go b/backend/internal/adapters/agent/kiro/kiro.go new file mode 100644 index 0000000..e831330 --- /dev/null +++ b/backend/internal/adapters/agent/kiro/kiro.go @@ -0,0 +1,270 @@ +// Package kiro implements the Kiro (AWS) agent adapter: launching new headless +// sessions, resuming hook-tracked sessions, installing workspace-local hooks, +// and reading hook-derived session info. +// +// Kiro is AWS's agentic coding assistant. Its terminal CLI ships as the +// `kiro-cli` binary and exposes a non-interactive ("headless") mode via +// `kiro-cli chat --no-interactive ""`, suitable for AO-driven worker +// sessions. See https://kiro.dev/docs/cli/headless/ and +// https://kiro.dev/docs/cli/reference/cli-commands/. +// +// Launch delivers the initial prompt as a positional argument after `--` so a +// leading "-" is not parsed as a flag. Permission/approval modes map onto +// Kiro's tool-trust flags (`--trust-all-tools`, `--trust-tools=`). +// Restore uses `kiro-cli chat --resume-id ` with the native session id +// captured from a Kiro hook payload. +// +// AO-managed sessions derive native session identity and display metadata from +// Kiro's native hooks (see hooks.go / activity.go) rather than transcript scans. +package kiro + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + kiroTitleMetadataKey = "title" + kiroSummaryMetadataKey = "summary" +) + +// Plugin is the Kiro agent adapter. It is safe for concurrent use; the binary +// path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Kiro adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: "kiro", + Name: "Kiro", + Description: "Run Kiro (AWS) worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Kiro exposes none yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new headless Kiro session: +// `kiro-cli chat --no-interactive [trust flags] -- `. +// +// The prompt is passed as a positional argument after `--` so a leading "-" is +// not read as a flag. Kiro's --no-interactive mode requires a prompt argument. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.kiroBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary, "chat", "--no-interactive"} + appendApprovalFlags(&cmd, cfg.Permissions) + + if cfg.Prompt != "" { + cmd = append(cmd, "--", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Kiro receives its prompt in the launch +// command itself. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetRestoreCommand rebuilds the argv that continues an existing Kiro session: +// `kiro-cli chat --no-interactive --resume-id [trust flags]`. +// ok is false when the hook-derived native session id has not landed yet, so +// callers can fall back to fresh launch behavior. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.kiroBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 8) + cmd = append(cmd, binary, "chat", "--no-interactive", "--resume-id", agentSessionID) + appendApprovalFlags(&cmd, cfg.Permissions) + return cmd, true, nil +} + +// SessionInfo surfaces Kiro hook-derived metadata. Metadata is intentionally +// nil for Kiro: callers get the normalized fields directly. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[kiroTitleMetadataKey], + Summary: session.Metadata[kiroSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// ResolveKiroBinary returns the path to the kiro-cli binary on this machine, +// searching PATH then a handful of well-known install locations. Returns +// "kiro-cli" as a last-ditch fallback so callers see a clear "command not +// found" rather than an empty argv. +func ResolveKiroBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"kiro-cli.cmd", "kiro-cli.exe", "kiro-cli"} { + path, err := exec.LookPath(name) + if err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + candidates := []string{} + if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { + candidates = append(candidates, + filepath.Join(localAppData, "Programs", "kiro", "kiro-cli.exe"), + ) + } + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "kiro-cli.cmd"), + filepath.Join(appData, "npm", "kiro-cli.exe"), + ) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".kiro", "bin", "kiro-cli.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "kiro-cli", nil + } + + if path, err := exec.LookPath("kiro-cli"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/kiro-cli", + "/opt/homebrew/bin/kiro-cli", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".kiro", "bin", "kiro-cli"), + filepath.Join(home, ".local", "bin", "kiro-cli"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "kiro-cli", nil +} + +func (p *Plugin) kiroBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveKiroBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +// appendApprovalFlags maps AO's 4 permission modes onto Kiro's tool-trust +// flags. Default emits no flag so Kiro defers to the user's own configuration +// (the interactive per-tool prompt). accept-edits grants the write-capable +// built-in tools; auto/bypass grant all tools. +func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { + switch normalizePermissionMode(permissions) { + case ports.PermissionModeDefault: + // No flag: defer to the user's Kiro config / per-tool prompting. + case ports.PermissionModeAcceptEdits: + *cmd = append(*cmd, "--trust-tools=fs_read,fs_write") + case ports.PermissionModeAuto: + *cmd = append(*cmd, "--trust-all-tools") + case ports.PermissionModeBypassPermissions: + *cmd = append(*cmd, "--trust-all-tools") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + return ports.PermissionModeDefault + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/kiro/kiro_test.go b/backend/internal/adapters/agent/kiro/kiro_test.go new file mode 100644 index 0000000..c47dea5 --- /dev/null +++ b/backend/internal/adapters/agent/kiro/kiro_test.go @@ -0,0 +1,445 @@ +package kiro + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifestIDIsKiro(t *testing.T) { + m := (&Plugin{}).Manifest() + if m.ID != "kiro" { + t.Fatalf("manifest ID = %q, want %q", m.ID, "kiro") + } + if m.Name != "Kiro" { + t.Fatalf("manifest Name = %q, want %q", m.Name, "Kiro") + } + if len(m.Capabilities) != 1 || m.Capabilities[0] != adapters.CapabilityAgent { + t.Fatalf("manifest Capabilities = %#v, want [CapabilityAgent]", m.Capabilities) + } +} + +func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-fix this", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "kiro-cli", "chat", "--no-interactive", + "--trust-all-tools", + "--", "-fix this", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + want []string + notExpected []string + }{ + { + name: "default", + permission: ports.PermissionModeDefault, + notExpected: []string{"--trust-all-tools", "--trust-tools=fs_read,fs_write"}, + }, + { + name: "accept-edits", + permission: ports.PermissionModeAcceptEdits, + want: []string{"--trust-tools=fs_read,fs_write"}, + }, + { + name: "auto", + permission: ports.PermissionModeAuto, + want: []string{"--trust-all-tools"}, + }, + { + name: "bypass-permissions", + permission: ports.PermissionModeBypassPermissions, + want: []string{"--trust-all-tools"}, + }, + { + name: "empty", + permission: "", + notExpected: []string{"--trust-all-tools", "--trust-tools=fs_read,fs_write"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: tt.permission, + }) + if err != nil { + t.Fatal(err) + } + if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) { + t.Fatalf("command %#v does not contain %#v", cmd, tt.want) + } + for _, missing := range tt.notExpected { + if contains(cmd, missing) { + t.Fatalf("command %#v contains %q", cmd, missing) + } + } + }) + } +} + +func TestGetLaunchCommandCtxCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + plugin := &Plugin{} + if _, err := plugin.GetLaunchCommand(ctx, ports.LaunchConfig{}); err == nil { + t.Fatal("expected error from cancelled context, got nil") + } +} + +func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryInCommand { + t.Fatalf("unexpected strategy: %q", got) + } +} + +func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + + spec, err := plugin.GetConfigSpec(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(spec.Fields) != 0 { + t.Fatalf("unexpected config fields: %#v", spec.Fields) + } +} + +func TestGetAgentHooksInstallsKiroHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + workspace := t.TempDir() + hooksDir := filepath.Join(workspace, kiroHooksDirName, kiroAgentsDirName) + if err := os.MkdirAll(hooksDir, 0o755); err != nil { + t.Fatal(err) + } + hooksPath := filepath.Join(hooksDir, kiroAgentFileName) + existing := `{"name":"ao","hooks":{"stop":[{"command":"custom stop hook"}]}}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + cfg := ports.WorkspaceHookConfig{ + DataDir: t.TempDir(), + SessionID: "sess-1", + WorkspacePath: workspace, + } + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + // A second install must not duplicate AO hook commands. + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + // The unmanaged top-level "name" key must be preserved. + var topLevel map[string]json.RawMessage + if err := json.Unmarshal(data, &topLevel); err != nil { + t.Fatal(err) + } + if _, ok := topLevel["name"]; !ok { + t.Fatalf("unmanaged top-level key 'name' was dropped: %s", data) + } + + var config kiroHookFile + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + if config.Hooks == nil { + t.Fatalf("hooks config missing hooks object: %#v", config) + } + for _, spec := range kiroManagedHooks { + entries := config.Hooks[spec.Event] + if count := countKiroHookCommand(entries, spec.Command); count != 1 { + t.Fatalf("%s command count = %d, want 1 in %#v", spec.Event, count, entries) + } + } + stopEntries := config.Hooks["stop"] + if countKiroHookCommand(stopEntries, "custom stop hook") != 1 { + t.Fatalf("existing stop hook was not preserved: %#v", stopEntries) + } +} + +func TestUninstallHooksRemovesKiroHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + workspace := t.TempDir() + hooksPath := kiroAgentPath(workspace) + + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + + // Pre-seed a user's own stop hook; it must survive uninstall. + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"hooks":{"stop":[{"command":"custom stop hook"}]}}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + + if err := plugin.UninstallHooks(ctx, workspace); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var config kiroHookFile + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + for _, spec := range kiroManagedHooks { + if got := countKiroHookCommand(config.Hooks[spec.Event], spec.Command); got != 0 { + t.Fatalf("%s command %q count = %d after uninstall, want 0", spec.Event, spec.Command, got) + } + } + if countKiroHookCommand(config.Hooks["stop"], "custom stop hook") != 1 { + t.Fatalf("user stop hook not preserved: %#v", config.Hooks["stop"]) + } +} + +func TestAreHooksInstalledMissingFile(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + workspace := t.TempDir() + installed, err := plugin.AreHooksInstalled(context.Background(), workspace) + if err != nil { + t.Fatal(err) + } + if installed { + t.Fatal("AreHooksInstalled = true for missing file, want false") + } +} + +func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "uuid-123"}, + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + want := []string{ + "kiro-cli", "chat", "--no-interactive", + "--resume-id", "uuid-123", + "--trust-all-tools", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + + cases := []struct { + name string + ref ports.SessionRef + }{ + {"empty session ref", ports.SessionRef{}}, + {"empty metadata", ports.SessionRef{Metadata: map[string]string{}}}, + {"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}}, + {"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: tc.ref, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if cmd != nil { + t.Fatalf("cmd = %#v, want nil", cmd) + } + }) + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "uuid-123", + kiroTitleMetadataKey: "Fix login redirect", + kiroSummaryMetadataKey: "Updated the auth callback and tests.", + "ignored": "not returned", + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatalf("ok = false, want true") + } + if info.AgentSessionID != "uuid-123" { + t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q, want hook title", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q, want hook summary", info.Summary) + } + if info.Metadata != nil { + t.Fatalf("Metadata = %#v, want nil for Kiro", info.Metadata) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "kiro-cli"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero value", info) + } +} + +func TestDeriveActivityState(t *testing.T) { + tests := []struct { + event string + wantState domain.ActivityState + wantOK bool + }{ + {"session-start", domain.ActivityActive, true}, + {"user-prompt-submit", domain.ActivityActive, true}, + {"stop", domain.ActivityIdle, true}, + {"permission-request", domain.ActivityWaitingInput, true}, + {"unknown", "", false}, + {"", "", false}, + } + for _, tt := range tests { + t.Run(tt.event, func(t *testing.T) { + state, ok := DeriveActivityState(tt.event, nil) + if state != tt.wantState || ok != tt.wantOK { + t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, state, ok, tt.wantState, tt.wantOK) + } + }) + } +} + +func TestResolveKiroBinaryFallback(t *testing.T) { + got, err := ResolveKiroBinary(context.Background()) + if err != nil { + t.Fatal(err) + } + if got == "" { + t.Fatal("ResolveKiroBinary returned empty path") + } +} + +func TestResolveKiroBinaryCtxCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := ResolveKiroBinary(ctx); err == nil { + t.Fatal("expected error from cancelled context, got nil") + } +} + +func contains(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + +func containsSubsequence(values []string, needle []string) bool { + if len(needle) == 0 { + return true + } + + for start := range values { + if start+len(needle) > len(values) { + return false + } + ok := true + for offset, want := range needle { + if values[start+offset] != want { + ok = false + break + } + } + if ok { + return true + } + } + + return false +} + +func countKiroHookCommand(entries []kiroHookEntry, command string) int { + count := 0 + for _, entry := range entries { + if entry.Command == command { + count++ + } + } + return count +} diff --git a/backend/internal/adapters/agent/pi/pi.go b/backend/internal/adapters/agent/pi/pi.go new file mode 100644 index 0000000..8cfcb4e --- /dev/null +++ b/backend/internal/adapters/agent/pi/pi.go @@ -0,0 +1,243 @@ +// Package pi implements the Pi agent adapter: launching new headless Pi +// sessions and resuming sessions when a native Pi session id is known. +// +// Pi (badlogic / "@earendil-works/pi-coding-agent", binary "pi") is a minimal +// terminal coding harness. AO drives it non-interactively with `-p` / `--print` +// ("process prompt and exit"). The initial prompt is delivered in-command as a +// trailing positional message; Pi's argument parser does not honor a `--` +// options terminator, so AO relies on prompts not beginning with a literal "-". +// +// System prompts are appended to Pi's default coding-assistant prompt via +// `--append-system-prompt `. Pi's flag takes inline text only (no file +// variant), so a system-prompt file is read from disk and its contents are +// inlined into the flag; a read failure aborts the launch. +// +// Permissions: Pi has no permission/approval CLI flags ("No permission popups" — +// confirmation flows are built via TypeScript extensions), so AO emits no +// permission flag and defers to Pi's own behavior. +// +// Restore: Pi persists sessions to ~/.pi/agent/sessions/ and resumes by id with +// `--session ` (partial UUIDs accepted). The native session id is emitted on +// the first line of `--mode json` output as {"type":"session","id":"",...} +// and is captured into session metadata out-of-band; GetRestoreCommand reads it +// back from metadata. ok=false when no native id is known (manager falls back to +// a fresh launch). +// +// Hooks/activity: Pi exposes lifecycle hooks only through in-process TypeScript +// extensions (pi.on("session_start", ...), etc.), not a config file AO can +// install, and it has no Claude Code hook compatibility. There is therefore no +// Tier A native hook installer nor a Tier B Claude-compat delegation; hook +// installation and SessionInfo are intentionally no-ops until a Pi-specific +// extension exists. +package pi + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const adapterID = "pi" + +// Plugin is the Pi agent adapter. It is safe for concurrent use; the binary +// path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Pi adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Pi", + Description: "Run Pi worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports no agent-specific config keys yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new headless Pi session: +// +// pi --print [--append-system-prompt ] [] +// +// The prompt is delivered in-command as a trailing positional message. Pi does +// not honor a `--` options terminator, so the prompt must not begin with "-". +// Pi has no permission flags, so none are emitted. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.piBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary, "--print"} + if cfg.SystemPromptFile != "" { + data, err := os.ReadFile(cfg.SystemPromptFile) //nolint:gosec // path is AO-owned launch config + if err != nil { + return nil, err + } + cmd = append(cmd, "--append-system-prompt", string(data)) + } else if cfg.SystemPrompt != "" { + cmd = append(cmd, "--append-system-prompt", cfg.SystemPrompt) + } + if cfg.Prompt != "" { + cmd = append(cmd, cfg.Prompt) + } + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Pi receives its prompt in the launch +// command itself. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetAgentHooks is intentionally a no-op: Pi's lifecycle hooks are only +// reachable through in-process TypeScript extensions, not a config file AO can +// install, and Pi has no Claude Code hook compatibility. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return ctx.Err() +} + +// GetRestoreCommand rebuilds the argv that continues an existing Pi session when +// a native session id is available in metadata. Pi resumes by id with +// `--session ` (partial UUIDs accepted). Until that id exists, ok is false +// and callers fall back to fresh launch behavior. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.piBinary(ctx) + if err != nil { + return nil, false, err + } + cmd = []string{binary, "--print", "--session", agentSessionID} + return cmd, true, nil +} + +// SessionInfo is intentionally a no-op until a Pi-specific extension persists +// session metadata (title/summary). The native session id, when known, is read +// directly from metadata by GetRestoreCommand. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + return ports.SessionInfo{}, false, nil +} + +// ResolvePiBinary finds the `pi` binary, searching PATH then common install +// locations. It returns "pi" as a last resort so callers get the shell's normal +// command-not-found behavior if Pi is absent. +func ResolvePiBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"pi.cmd", "pi.exe", "pi"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "pi.cmd"), + filepath.Join(appData, "npm", "pi.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + return "pi", nil + } + + if path, err := exec.LookPath("pi"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/pi", + "/opt/homebrew/bin/pi", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".npm-global", "bin", "pi"), + filepath.Join(home, ".local", "bin", "pi"), + filepath.Join(home, ".pi", "bin", "pi"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "pi", nil +} + +func (p *Plugin) piBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolvePiBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/pi/pi_test.go b/backend/internal/adapters/agent/pi/pi_test.go new file mode 100644 index 0000000..47210d9 --- /dev/null +++ b/backend/internal/adapters/agent/pi/pi_test.go @@ -0,0 +1,231 @@ +package pi + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifest(t *testing.T) { + m := (&Plugin{}).Manifest() + if m.ID != "pi" { + t.Fatalf("ID = %q, want pi", m.ID) + } + if m.Name != "Pi" { + t.Fatalf("Name = %q, want Pi", m.Name) + } + hasAgent := false + for _, c := range m.Capabilities { + if c == adapters.CapabilityAgent { + hasAgent = true + } + } + if !hasAgent { + t.Fatal("missing CapabilityAgent") + } +} + +func TestGetConfigSpecEmpty(t *testing.T) { + spec, err := (&Plugin{}).GetConfigSpec(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(spec.Fields) != 0 { + t.Fatalf("expected no fields, got %d", len(spec.Fields)) + } +} + +func TestGetPromptDeliveryStrategy(t *testing.T) { + s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if s != ports.PromptDeliveryInCommand { + t.Fatalf("strategy = %q, want %q", s, ports.PromptDeliveryInCommand) + } +} + +func TestGetLaunchCommandWithPrompt(t *testing.T) { + p := &Plugin{resolvedBinary: "pi"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "add a health check", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"pi", "--print", "add a health check"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandEmitsNoPermissionFlag(t *testing.T) { + // Pi has no permission CLI surface; every mode must produce the same argv + // and never emit a permission flag. + modes := []ports.PermissionMode{ + ports.PermissionModeDefault, + "", + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions, + } + + for _, mode := range modes { + t.Run(string(mode), func(t *testing.T) { + p := &Plugin{resolvedBinary: "pi"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Permissions: mode}) + if err != nil { + t.Fatal(err) + } + want := []string{"pi", "--print"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } + for _, arg := range cmd { + if arg == "--permission-mode" { + t.Fatalf("cmd = %#v unexpectedly contains a permission flag", cmd) + } + } + }) + } +} + +func TestGetLaunchCommandAppendsSystemPrompt(t *testing.T) { + p := &Plugin{resolvedBinary: "pi"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPrompt: "follow repo rules", + Prompt: "do the thing", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"pi", "--print", "--append-system-prompt", "follow repo rules", "do the thing"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandInlinesSystemPromptFileContents(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "system.md") + if err := os.WriteFile(file, []byte("file contents win"), 0o600); err != nil { + t.Fatal(err) + } + + p := &Plugin{resolvedBinary: "pi"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPromptFile: file, + SystemPrompt: "inline ignored", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"pi", "--print", "--append-system-prompt", "file contents win"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandSystemPromptFileReadError(t *testing.T) { + p := &Plugin{resolvedBinary: "pi"} + _, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPromptFile: filepath.Join(t.TempDir(), "missing.md"), + SystemPrompt: "inline ignored", + }) + if err == nil { + t.Fatal("expected error for unreadable system-prompt file, got nil") + } +} + +func TestGetRestoreCommand(t *testing.T) { + p := &Plugin{resolvedBinary: "pi"} + cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "019e950e-52e0-7411-961b-d380ca7e610f"}, + }, + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("ok=false, want true") + } + + want := []string{"pi", "--print", "--session", "019e950e-52e0-7411-961b-d380ca7e610f"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommandNoID(t *testing.T) { + p := &Plugin{resolvedBinary: "pi"} + _, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{Metadata: map[string]string{}}, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("ok=true with no agentSessionId, want false") + } +} + +func TestGetAgentHooksNoOp(t *testing.T) { + if err := (&Plugin{}).GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err != nil { + t.Fatalf("GetAgentHooks err = %v, want nil", err) + } +} + +func TestSessionInfoNoOp(t *testing.T) { + info, ok, err := (&Plugin{}).SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "019e950e-52e0-7411-961b-d380ca7e610f"}, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatalf("ok=true with info %#v, want no-op false", info) + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero", info) + } +} + +func TestContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := (&Plugin{}).GetConfigSpec(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("GetConfigSpec err = %v, want context.Canceled", err) + } + if _, err := (&Plugin{}).GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetPromptDeliveryStrategy err = %v, want context.Canceled", err) + } + if err := (&Plugin{}).GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetAgentHooks err = %v, want context.Canceled", err) + } + if _, _, err := (&Plugin{}).GetRestoreCommand(ctx, ports.RestoreConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetRestoreCommand err = %v, want context.Canceled", err) + } + if _, _, err := (&Plugin{}).SessionInfo(ctx, ports.SessionRef{}); !errors.Is(err, context.Canceled) { + t.Fatalf("SessionInfo err = %v, want context.Canceled", err) + } +} + +func TestResolvePiBinaryContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := ResolvePiBinary(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("ResolvePiBinary err = %v, want context.Canceled", err) + } +} diff --git a/backend/internal/adapters/agent/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index fdf3a47..77f9b52 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -7,14 +7,29 @@ import ( "fmt" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agy" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/aider" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/amp" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/auggie" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/autohand" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cline" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/continueagent" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/copilot" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/crush" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cursor" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/devin" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/goose" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/grok" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/kilocode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/kimi" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/kiro" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/pi" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/qwen" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/vibe" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -33,6 +48,21 @@ func Constructors() []adapters.Adapter { qwen.New(), copilot.New(), kimi.New(), + droid.New(), + amp.New(), + agy.New(), + crush.New(), + aider.New(), + goose.New(), + auggie.New(), + continueagent.New(), + devin.New(), + cline.New(), + kiro.New(), + kilocode.New(), + vibe.New(), + pi.New(), + autohand.New(), } } diff --git a/backend/internal/adapters/agent/vibe/vibe.go b/backend/internal/adapters/agent/vibe/vibe.go new file mode 100644 index 0000000..f006c51 --- /dev/null +++ b/backend/internal/adapters/agent/vibe/vibe.go @@ -0,0 +1,249 @@ +// Package vibe implements the Mistral Vibe agent adapter: launching new +// non-interactive Vibe sessions and resuming sessions when a native Vibe +// session id is known. +// +// Mistral Vibe (binary "vibe", https://github.com/mistralai/mistral-vibe) is a +// Python CLI installed via `uv tool install mistral-vibe`, pip, or its install +// script. AO drives it in programmatic/headless mode with `-p `, which +// auto-approves tools, prints the final response, and exits. `--trust` skips +// the working-directory trust prompt for non-interactive automation, and +// `--output text` pins the human-readable output format. +// +// Permission modes map onto Vibe's builtin agent profiles via `--agent`: +// accept-edits ("auto-approves file edits only") and auto-approve +// ("auto-approves all tool executions"). PermissionModeDefault emits no flag so +// Vibe resolves its starting agent from the user's `default_agent` config. +// +// Vibe has no usable lifecycle-hook surface for AO activity: its only hook type +// is an experimental, off-by-default POST_AGENT_TURN hook with no +// session-start/user-prompt-submit/stop/permission-request taxonomy, and it is +// not Claude-Code compatible. Hook installation and SessionInfo are therefore +// intentionally no-ops (Tier C). +// +// Restore uses `--resume ` (Vibe matches by partial/short id) when +// a native session id is available in metadata. +package vibe + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const adapterID = "vibe" + +// Plugin is the Mistral Vibe agent adapter. It is safe for concurrent use; the +// binary path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Mistral Vibe adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Mistral Vibe", + Description: "Run Mistral Vibe worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports no agent-specific config keys yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new non-interactive Vibe session: +// +// vibe --trust --output text [--agent ] -p +// +// The prompt is delivered through `-p` (programmatic mode), so AO uses +// in-command delivery. `--trust` skips the trust prompt for automation and +// `--output text` pins the output format. Vibe exposes no CLI system-prompt +// flag (system prompts are config-driven), so SystemPrompt is not forwarded. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + if err := ctx.Err(); err != nil { + return nil, err + } + binary, err := p.vibeBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary, "--trust", "--output", "text"} + appendAgentFlags(&cmd, cfg.Permissions) + if cfg.Prompt != "" { + cmd = append(cmd, "-p", cfg.Prompt) + } + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Vibe receives its prompt in the launch +// command itself. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetAgentHooks is intentionally a no-op: Vibe has no usable lifecycle-hook +// surface for AO activity reporting (Tier C). +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return ctx.Err() +} + +// GetRestoreCommand rebuilds the argv that continues an existing Vibe session +// when a native session id is available in metadata. Without it, ok is false +// and callers fall back to fresh launch behavior. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.vibeBinary(ctx) + if err != nil { + return nil, false, err + } + cmd = make([]string, 0, 8) + cmd = append(cmd, binary, "--trust", "--output", "text") + appendAgentFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "--resume", agentSessionID) + return cmd, true, nil +} + +// SessionInfo is intentionally a no-op until Vibe can surface native session +// metadata to AO. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + return ports.SessionInfo{}, false, nil +} + +// appendAgentFlags maps AO permission modes onto Vibe's builtin `--agent` +// profiles. PermissionModeDefault (and the empty mode) emit no flag so Vibe +// resolves its starting agent from the user's `default_agent` config. +func appendAgentFlags(cmd *[]string, mode ports.PermissionMode) { + switch mode { + case ports.PermissionModeAcceptEdits: + *cmd = append(*cmd, "--agent", "accept-edits") + case ports.PermissionModeAuto: + *cmd = append(*cmd, "--agent", "auto-approve") + case ports.PermissionModeBypassPermissions: + *cmd = append(*cmd, "--agent", "auto-approve") + } +} + +// ResolveVibeBinary finds the `vibe` binary, searching PATH then common install +// locations. It returns "vibe" as a last resort so callers get the shell's +// normal command-not-found behavior if Vibe is absent. +func ResolveVibeBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"vibe.exe", "vibe.cmd", "vibe"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "Python", "Scripts", "vibe.exe"), + ) + } + if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { + candidates = append(candidates, + filepath.Join(localAppData, "uv", "tools", "mistral-vibe", "Scripts", "vibe.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + return "vibe", nil + } + + if path, err := exec.LookPath("vibe"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/vibe", + "/opt/homebrew/bin/vibe", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "vibe"), + filepath.Join(home, ".local", "share", "uv", "tools", "mistral-vibe", "bin", "vibe"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "vibe", nil +} + +func (p *Plugin) vibeBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveVibeBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/vibe/vibe_test.go b/backend/internal/adapters/agent/vibe/vibe_test.go new file mode 100644 index 0000000..06d8dee --- /dev/null +++ b/backend/internal/adapters/agent/vibe/vibe_test.go @@ -0,0 +1,206 @@ +package vibe + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifest(t *testing.T) { + m := (&Plugin{}).Manifest() + if m.ID != "vibe" { + t.Fatalf("ID = %q, want vibe", m.ID) + } + if m.Name != "Mistral Vibe" { + t.Fatalf("Name = %q, want Mistral Vibe", m.Name) + } + hasAgent := false + for _, c := range m.Capabilities { + if c == adapters.CapabilityAgent { + hasAgent = true + } + } + if !hasAgent { + t.Fatal("missing CapabilityAgent") + } +} + +func TestGetConfigSpecEmpty(t *testing.T) { + spec, err := (&Plugin{}).GetConfigSpec(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(spec.Fields) != 0 { + t.Fatalf("expected no fields, got %d", len(spec.Fields)) + } +} + +func TestGetPromptDeliveryStrategy(t *testing.T) { + s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if s != ports.PromptDeliveryInCommand { + t.Fatalf("strategy = %q, want %q", s, ports.PromptDeliveryInCommand) + } +} + +func TestGetLaunchCommandWithPrompt(t *testing.T) { + p := &Plugin{resolvedBinary: "vibe"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "add a health check", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"vibe", "--trust", "--output", "text", "--agent", "auto-approve", "-p", "add a health check"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { + tests := []struct { + name string + mode ports.PermissionMode + want []string + wantAbsent string + }{ + {"default omits flag", ports.PermissionModeDefault, []string{"vibe", "--trust", "--output", "text"}, "--agent"}, + {"empty omits flag", "", []string{"vibe", "--trust", "--output", "text"}, "--agent"}, + {"accept edits", ports.PermissionModeAcceptEdits, []string{"vibe", "--trust", "--output", "text", "--agent", "accept-edits"}, ""}, + {"auto", ports.PermissionModeAuto, []string{"vibe", "--trust", "--output", "text", "--agent", "auto-approve"}, ""}, + {"bypass", ports.PermissionModeBypassPermissions, []string{"vibe", "--trust", "--output", "text", "--agent", "auto-approve"}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Plugin{resolvedBinary: "vibe"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Permissions: tt.mode}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(cmd, tt.want) { + t.Fatalf("cmd = %#v, want %#v", cmd, tt.want) + } + if tt.wantAbsent != "" { + for _, arg := range cmd { + if arg == tt.wantAbsent { + t.Fatalf("cmd = %#v unexpectedly contains %q", cmd, tt.wantAbsent) + } + } + } + }) + } +} + +func TestGetLaunchCommandOmitsPromptWhenEmpty(t *testing.T) { + p := &Plugin{resolvedBinary: "vibe"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeAuto, + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"vibe", "--trust", "--output", "text", "--agent", "auto-approve"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } + for _, arg := range cmd { + if arg == "-p" { + t.Fatalf("cmd = %#v unexpectedly contains %q", cmd, "-p") + } + } +} + +func TestGetRestoreCommand(t *testing.T) { + p := &Plugin{resolvedBinary: "vibe"} + cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abcd1234-5678-90ab-cdef-1234567890ab"}, + }, + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("ok=false, want true") + } + + want := []string{"vibe", "--trust", "--output", "text", "--agent", "auto-approve", "--resume", "abcd1234-5678-90ab-cdef-1234567890ab"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommandNoID(t *testing.T) { + p := &Plugin{resolvedBinary: "vibe"} + _, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{Metadata: map[string]string{}}, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("ok=true with no agentSessionId, want false") + } +} + +func TestGetAgentHooksNoOp(t *testing.T) { + if err := (&Plugin{}).GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err != nil { + t.Fatalf("GetAgentHooks err = %v, want nil", err) + } +} + +func TestSessionInfoNoOp(t *testing.T) { + info, ok, err := (&Plugin{}).SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abcd1234"}, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatalf("ok=true with info %#v, want no-op false", info) + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero", info) + } +} + +func TestContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := (&Plugin{}).GetConfigSpec(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("GetConfigSpec err = %v, want context.Canceled", err) + } + if _, err := (&Plugin{}).GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetPromptDeliveryStrategy err = %v, want context.Canceled", err) + } + if err := (&Plugin{}).GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetAgentHooks err = %v, want context.Canceled", err) + } + if _, _, err := (&Plugin{}).GetRestoreCommand(ctx, ports.RestoreConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetRestoreCommand err = %v, want context.Canceled", err) + } + if _, _, err := (&Plugin{}).SessionInfo(ctx, ports.SessionRef{}); !errors.Is(err, context.Canceled) { + t.Fatalf("SessionInfo err = %v, want context.Canceled", err) + } +} + +func TestResolveVibeBinaryContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := ResolveVibeBinary(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("ResolveVibeBinary err = %v, want context.Canceled", err) + } +} diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 9552e1f..0e4815d 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -98,6 +98,21 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { {domain.HarnessQwen, "qwen"}, {domain.HarnessCopilot, "copilot"}, {domain.HarnessKimi, "kimi"}, + {domain.HarnessDroid, "droid"}, + {domain.HarnessAmp, "amp"}, + {domain.HarnessAgy, "agy"}, + {domain.HarnessCrush, "crush"}, + {domain.HarnessAider, "aider"}, + {domain.HarnessGoose, "goose"}, + {domain.HarnessAuggie, "auggie"}, + {domain.HarnessContinue, "continue"}, + {domain.HarnessDevin, "devin"}, + {domain.HarnessCline, "cline"}, + {domain.HarnessKiro, "kiro"}, + {domain.HarnessKilocode, "kilocode"}, + {domain.HarnessVibe, "vibe"}, + {domain.HarnessPi, "pi"}, + {domain.HarnessAutohand, "autohand"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness)