From 9ba5e162a5b47ca1ef1dc2c4da31843a1bae82fb Mon Sep 17 00:00:00 2001 From: yyovil Date: Sat, 6 Jun 2026 05:23:10 +0530 Subject: [PATCH 1/5] feat(agents): add droid adapter Registers the droid harness, stacked on the agent platform. Includes its own activity deriver. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agent/activitydispatch/dispatch.go | 2 + .../internal/adapters/agent/droid/activity.go | 60 +++ .../adapters/agent/droid/activity_test.go | 42 +++ .../internal/adapters/agent/droid/droid.go | 353 ++++++++++++++++++ .../adapters/agent/droid/droid_test.go | 320 ++++++++++++++++ .../internal/adapters/agent/droid/hooks.go | 351 +++++++++++++++++ .../adapters/agent/registry/registry.go | 2 + backend/internal/daemon/wiring_test.go | 1 + 8 files changed, 1131 insertions(+) create mode 100644 backend/internal/adapters/agent/droid/activity.go create mode 100644 backend/internal/adapters/agent/droid/activity_test.go create mode 100644 backend/internal/adapters/agent/droid/droid.go create mode 100644 backend/internal/adapters/agent/droid/droid_test.go create mode 100644 backend/internal/adapters/agent/droid/hooks.go diff --git a/backend/internal/adapters/agent/activitydispatch/dispatch.go b/backend/internal/adapters/agent/activitydispatch/dispatch.go index f02d0d5..11b7f4d 100644 --- a/backend/internal/adapters/agent/activitydispatch/dispatch.go +++ b/backend/internal/adapters/agent/activitydispatch/dispatch.go @@ -13,6 +13,7 @@ import ( "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/opencode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/qwen" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -31,6 +32,7 @@ var Derivers = map[string]DeriveFunc{ "opencode": opencode.DeriveActivityState, "qwen": qwen.DeriveActivityState, "copilot": copilot.DeriveActivityState, + "droid": droid.DeriveActivityState, } // Derive looks up the deriver for an agent token and applies it. ok=false when 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/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index fdf3a47..08738bc 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -11,6 +11,7 @@ import ( "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/grok" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/kimi" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" @@ -33,6 +34,7 @@ func Constructors() []adapters.Adapter { qwen.New(), copilot.New(), kimi.New(), + droid.New(), } } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 9552e1f..9864c5e 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -98,6 +98,7 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { {domain.HarnessQwen, "qwen"}, {domain.HarnessCopilot, "copilot"}, {domain.HarnessKimi, "kimi"}, + {domain.HarnessDroid, "droid"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness) From a063bd12f543db53e4d43ca9b35c9a1e88c10f11 Mon Sep 17 00:00:00 2001 From: yyovil Date: Sat, 6 Jun 2026 05:23:19 +0530 Subject: [PATCH 2/5] feat(agents): add amp adapter Registers the amp harness, stacked on the agent platform. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/internal/adapters/agent/amp/amp.go | 228 ++++++++++++++++++ .../internal/adapters/agent/amp/amp_test.go | 212 ++++++++++++++++ .../adapters/agent/registry/registry.go | 2 + backend/internal/daemon/wiring_test.go | 1 + 4 files changed, 443 insertions(+) create mode 100644 backend/internal/adapters/agent/amp/amp.go create mode 100644 backend/internal/adapters/agent/amp/amp_test.go 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/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index 08738bc..3d5f9a1 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/amp" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/copilot" @@ -35,6 +36,7 @@ func Constructors() []adapters.Adapter { copilot.New(), kimi.New(), droid.New(), + amp.New(), } } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 9864c5e..de77c58 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -99,6 +99,7 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { {domain.HarnessCopilot, "copilot"}, {domain.HarnessKimi, "kimi"}, {domain.HarnessDroid, "droid"}, + {domain.HarnessAmp, "amp"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness) From f343e1f2477028dae6155e86464907772b11dfec Mon Sep 17 00:00:00 2001 From: yyovil Date: Sat, 6 Jun 2026 05:23:27 +0530 Subject: [PATCH 3/5] feat(agents): add agy adapter Registers the agy harness, stacked on the agent platform. Includes its own activity deriver. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agent/activitydispatch/dispatch.go | 2 + .../internal/adapters/agent/agy/activity.go | 27 ++ .../adapters/agent/agy/activity_test.go | 32 ++ backend/internal/adapters/agent/agy/agy.go | 244 ++++++++++++++ .../internal/adapters/agent/agy/agy_test.go | 202 ++++++++++++ backend/internal/adapters/agent/agy/hooks.go | 305 ++++++++++++++++++ .../adapters/agent/registry/registry.go | 2 + backend/internal/daemon/wiring_test.go | 1 + 8 files changed, 815 insertions(+) create mode 100644 backend/internal/adapters/agent/agy/activity.go create mode 100644 backend/internal/adapters/agent/agy/activity_test.go create mode 100644 backend/internal/adapters/agent/agy/agy.go create mode 100644 backend/internal/adapters/agent/agy/agy_test.go create mode 100644 backend/internal/adapters/agent/agy/hooks.go diff --git a/backend/internal/adapters/agent/activitydispatch/dispatch.go b/backend/internal/adapters/agent/activitydispatch/dispatch.go index 11b7f4d..2d68c50 100644 --- a/backend/internal/adapters/agent/activitydispatch/dispatch.go +++ b/backend/internal/adapters/agent/activitydispatch/dispatch.go @@ -9,6 +9,7 @@ package activitydispatch import ( + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agy" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/copilot" @@ -33,6 +34,7 @@ var Derivers = map[string]DeriveFunc{ "qwen": qwen.DeriveActivityState, "copilot": copilot.DeriveActivityState, "droid": droid.DeriveActivityState, + "agy": agy.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/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index 3d5f9a1..6fd8876 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -7,6 +7,7 @@ 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/amp" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" @@ -37,6 +38,7 @@ func Constructors() []adapters.Adapter { kimi.New(), droid.New(), amp.New(), + agy.New(), } } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index de77c58..e726cff 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -100,6 +100,7 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { {domain.HarnessKimi, "kimi"}, {domain.HarnessDroid, "droid"}, {domain.HarnessAmp, "amp"}, + {domain.HarnessAgy, "agy"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness) From 3afef376ef6cf7672d3f43cf9d8aceb41f3212c1 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 7 Jun 2026 03:34:58 +0530 Subject: [PATCH 4/5] feat(agents): add crush, aider, goose, auggie, continue, devin, cline, kiro, kilocode, vibe, pi, autohand adapters Cherry-pick batch landing the remaining 12 yyovil adapter directories per Discussion #148 recipe, on top of #145 (grok/cursor/qwen/copilot/kimi) and the droid/amp/agy commits earlier on this branch. Each adapter is a self-contained package under backend/internal/adapters/agent//; registry.Constructors(), activitydispatch.Derivers (for adapters with activity.go), and wiring_test.go are unified to register all 23 shipped adapters in one place. No new migration: 0007_allow_implemented_harnesses already widens the sessions.harness CHECK to cover every adapter. --- .../agent/activitydispatch/dispatch.go | 10 + .../internal/adapters/agent/aider/aider.go | 222 ++++++++ .../adapters/agent/aider/aider_test.go | 291 ++++++++++ .../internal/adapters/agent/auggie/auggie.go | 254 +++++++++ .../adapters/agent/auggie/auggie_test.go | 220 +++++++ .../adapters/agent/autohand/activity.go | 26 + .../adapters/agent/autohand/autohand.go | 283 +++++++++ .../adapters/agent/autohand/autohand_test.go | 539 ++++++++++++++++++ .../internal/adapters/agent/autohand/hooks.go | 337 +++++++++++ .../internal/adapters/agent/cline/activity.go | 32 ++ .../internal/adapters/agent/cline/cline.go | 261 +++++++++ .../adapters/agent/cline/cline_test.go | 432 ++++++++++++++ .../internal/adapters/agent/cline/hooks.go | 193 +++++++ .../agent/continueagent/continueagent.go | 280 +++++++++ .../agent/continueagent/continueagent_test.go | 269 +++++++++ .../internal/adapters/agent/crush/activity.go | 14 + .../adapters/agent/crush/activity_test.go | 15 + .../internal/adapters/agent/crush/crush.go | 243 ++++++++ .../adapters/agent/crush/crush_test.go | 263 +++++++++ .../internal/adapters/agent/crush/hooks.go | 39 ++ .../internal/adapters/agent/devin/devin.go | 282 +++++++++ .../adapters/agent/devin/devin_test.go | 274 +++++++++ .../internal/adapters/agent/goose/activity.go | 35 ++ .../adapters/agent/goose/activity_test.go | 32 ++ .../internal/adapters/agent/goose/goose.go | 326 +++++++++++ .../adapters/agent/goose/goose_test.go | 440 ++++++++++++++ .../internal/adapters/agent/goose/hooks.go | 352 ++++++++++++ .../adapters/agent/kilocode/activity.go | 31 + .../agent/kilocode/assets/ao-activity.ts | 203 +++++++ .../internal/adapters/agent/kilocode/hooks.go | 186 ++++++ .../adapters/agent/kilocode/kilocode.go | 315 ++++++++++ .../adapters/agent/kilocode/kilocode_test.go | 449 +++++++++++++++ .../internal/adapters/agent/kiro/activity.go | 31 + backend/internal/adapters/agent/kiro/hooks.go | 327 +++++++++++ backend/internal/adapters/agent/kiro/kiro.go | 270 +++++++++ .../internal/adapters/agent/kiro/kiro_test.go | 445 +++++++++++++++ backend/internal/adapters/agent/pi/pi.go | 243 ++++++++ backend/internal/adapters/agent/pi/pi_test.go | 231 ++++++++ .../adapters/agent/registry/registry.go | 24 + backend/internal/adapters/agent/vibe/vibe.go | 249 ++++++++ .../internal/adapters/agent/vibe/vibe_test.go | 206 +++++++ backend/internal/daemon/wiring_test.go | 12 + 42 files changed, 9186 insertions(+) create mode 100644 backend/internal/adapters/agent/aider/aider.go create mode 100644 backend/internal/adapters/agent/aider/aider_test.go create mode 100644 backend/internal/adapters/agent/auggie/auggie.go create mode 100644 backend/internal/adapters/agent/auggie/auggie_test.go create mode 100644 backend/internal/adapters/agent/autohand/activity.go create mode 100644 backend/internal/adapters/agent/autohand/autohand.go create mode 100644 backend/internal/adapters/agent/autohand/autohand_test.go create mode 100644 backend/internal/adapters/agent/autohand/hooks.go create mode 100644 backend/internal/adapters/agent/cline/activity.go create mode 100644 backend/internal/adapters/agent/cline/cline.go create mode 100644 backend/internal/adapters/agent/cline/cline_test.go create mode 100644 backend/internal/adapters/agent/cline/hooks.go create mode 100644 backend/internal/adapters/agent/continueagent/continueagent.go create mode 100644 backend/internal/adapters/agent/continueagent/continueagent_test.go create mode 100644 backend/internal/adapters/agent/crush/activity.go create mode 100644 backend/internal/adapters/agent/crush/activity_test.go create mode 100644 backend/internal/adapters/agent/crush/crush.go create mode 100644 backend/internal/adapters/agent/crush/crush_test.go create mode 100644 backend/internal/adapters/agent/crush/hooks.go create mode 100644 backend/internal/adapters/agent/devin/devin.go create mode 100644 backend/internal/adapters/agent/devin/devin_test.go create mode 100644 backend/internal/adapters/agent/goose/activity.go create mode 100644 backend/internal/adapters/agent/goose/activity_test.go create mode 100644 backend/internal/adapters/agent/goose/goose.go create mode 100644 backend/internal/adapters/agent/goose/goose_test.go create mode 100644 backend/internal/adapters/agent/goose/hooks.go create mode 100644 backend/internal/adapters/agent/kilocode/activity.go create mode 100644 backend/internal/adapters/agent/kilocode/assets/ao-activity.ts create mode 100644 backend/internal/adapters/agent/kilocode/hooks.go create mode 100644 backend/internal/adapters/agent/kilocode/kilocode.go create mode 100644 backend/internal/adapters/agent/kilocode/kilocode_test.go create mode 100644 backend/internal/adapters/agent/kiro/activity.go create mode 100644 backend/internal/adapters/agent/kiro/hooks.go create mode 100644 backend/internal/adapters/agent/kiro/kiro.go create mode 100644 backend/internal/adapters/agent/kiro/kiro_test.go create mode 100644 backend/internal/adapters/agent/pi/pi.go create mode 100644 backend/internal/adapters/agent/pi/pi_test.go create mode 100644 backend/internal/adapters/agent/vibe/vibe.go create mode 100644 backend/internal/adapters/agent/vibe/vibe_test.go diff --git a/backend/internal/adapters/agent/activitydispatch/dispatch.go b/backend/internal/adapters/agent/activitydispatch/dispatch.go index 2d68c50..a386668 100644 --- a/backend/internal/adapters/agent/activitydispatch/dispatch.go +++ b/backend/internal/adapters/agent/activitydispatch/dispatch.go @@ -10,11 +10,16 @@ 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" @@ -35,6 +40,11 @@ var Derivers = map[string]DeriveFunc{ "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/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/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/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..00062a4 --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/kilocode.go @@ -0,0 +1,315 @@ +// 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, _ := json.Marshal(map[string]map[string]string{"permission": config}) + 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 6fd8876..77f9b52 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -8,16 +8,28 @@ import ( "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" ) @@ -39,6 +51,18 @@ func Constructors() []adapters.Adapter { 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 e726cff..0e4815d 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -101,6 +101,18 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { {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) From 52c663c13a2eff4a90fe96f31db09befa503971b Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 7 Jun 2026 03:43:01 +0530 Subject: [PATCH 5/5] fix(agents/kilocode): return error from json.Marshal of permission config Previously the marshal error was discarded and the function returned a prefix carrying an empty KILO_CONFIG_CONTENT. An unrecoverable marshal failure for the typed map should never happen in practice, but if it ever did, Kilo would silently launch with default permissions regardless of the requested mode. Surface it as "no prefix" so the caller's mode choice can't be misrepresented. --- backend/internal/adapters/agent/kilocode/kilocode.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/internal/adapters/agent/kilocode/kilocode.go b/backend/internal/adapters/agent/kilocode/kilocode.go index 00062a4..1d9286e 100644 --- a/backend/internal/adapters/agent/kilocode/kilocode.go +++ b/backend/internal/adapters/agent/kilocode/kilocode.go @@ -212,7 +212,14 @@ func kilocodePermissionEnvPrefix(mode ports.PermissionMode) []string { // 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, _ := json.Marshal(map[string]map[string]string{"permission": config}) + 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)} }