diff --git a/README.md b/README.md index f51285142..08b3f42bd 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,9 @@ The result is a local control layer for agentic coding: agents still do the codi Works with 23+ CLI-based coding agents including Claude Code, OpenAI Codex, Cursor, OpenCode, Aider, Amp, Goose, GitHub Copilot, Grok, Qwen Code, Kimi Code, Crush, Cline, Droid, Devin, Auggie, Continue, Kiro, and Kilo Code. +For direct CLI usage, including agent readiness checks and context-aware session +spawns, see the [CLI Guide](docs/cli/README.md). + **If it runs in a terminal, it runs on Agent Orchestrator.** --- @@ -172,6 +175,7 @@ For detailed architecture diagrams, data flows, and load-bearing rules, see [arc | -------------------------------------------------------- | ------------------------------------------------------- | | [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules | | [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules | +| [CLI Guide](docs/cli/README.md) | Direct `ao` CLI usage, command routes, and smoke tests | | [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract | --- diff --git a/backend/internal/adapters/agent/activitydispatch/dispatch.go b/backend/internal/adapters/agent/activitydispatch/dispatch.go index 812e15e29..9e849e0bd 100644 --- a/backend/internal/adapters/agent/activitydispatch/dispatch.go +++ b/backend/internal/adapters/agent/activitydispatch/dispatch.go @@ -9,19 +9,12 @@ package activitydispatch import ( + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitystate" "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" ) @@ -32,19 +25,21 @@ type DeriveFunc func(event string, payload []byte) (domain.ActivityState, bool) // Derivers maps the agent token in `ao hooks ` to its deriver. // Per-adapter PRs add their tokens here as they land. var Derivers = map[string]DeriveFunc{ + // Adapters that parse hook payloads for finer-grained state keep their own + // deriver; the rest share the name-only StandardDeriveActivityState. "claude-code": claudecode.DeriveActivityState, "codex": codex.DeriveActivityState, - "cursor": cursor.DeriveActivityState, - "opencode": opencode.DeriveActivityState, - "qwen": qwen.DeriveActivityState, - "copilot": copilot.DeriveActivityState, "droid": droid.DeriveActivityState, "agy": agy.DeriveActivityState, - "goose": goose.DeriveActivityState, - "cline": cline.DeriveActivityState, - "kiro": kiro.DeriveActivityState, - "kilocode": kilocode.DeriveActivityState, - "autohand": autohand.DeriveActivityState, + "opencode": opencode.DeriveActivityState, + "goose": activitystate.StandardDeriveActivityState, + "cursor": activitystate.StandardDeriveActivityState, + "qwen": activitystate.StandardDeriveActivityState, + "copilot": activitystate.StandardDeriveActivityState, + "cline": activitystate.StandardDeriveActivityState, + "kiro": activitystate.StandardDeriveActivityState, + "kilocode": activitystate.StandardDeriveActivityState, + "autohand": activitystate.StandardDeriveActivityState, } // Derive looks up the deriver for an agent token and applies it. ok=false when diff --git a/backend/internal/adapters/agent/activitystate/activitystate.go b/backend/internal/adapters/agent/activitystate/activitystate.go new file mode 100644 index 000000000..3c5375d9a --- /dev/null +++ b/backend/internal/adapters/agent/activitystate/activitystate.go @@ -0,0 +1,32 @@ +// Package activitystate holds the standard mapping from an AO hook sub-command +// name onto an activity state. Most adapters install the same +// session-start/user-prompt-submit/stop/permission-request callbacks and derive +// activity identically from the event name alone; they share this deriver rather +// than each carrying a copy. Adapters that inspect the hook payload for finer +// grained state (claude-code, codex, droid) keep their own deriver. +package activitystate + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// StandardDeriveActivityState maps a hook sub-command name onto an AO activity +// state. The bool is false when the event carries no activity signal. The +// payload is ignored: this is the name-only mapping shared by adapters whose +// hooks report activity purely through which callback fired. +// +// - session-start / user-prompt-submit → active +// - stop → idle +// - permission-request → waiting_input +func StandardDeriveActivityState(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/activitystate/activitystate_test.go b/backend/internal/adapters/agent/activitystate/activitystate_test.go new file mode 100644 index 000000000..3fe8f91b9 --- /dev/null +++ b/backend/internal/adapters/agent/activitystate/activitystate_test.go @@ -0,0 +1,28 @@ +package activitystate + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestStandardDeriveActivityState(t *testing.T) { + cases := []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 _, tc := range cases { + got, ok := StandardDeriveActivityState(tc.event, []byte("ignored")) + if got != tc.want || ok != tc.wantOK { + t.Errorf("event %q: got (%q, %v), want (%q, %v)", tc.event, got, ok, tc.want, tc.wantOK) + } + } +} diff --git a/backend/internal/adapters/agent/agentbase/agentbase.go b/backend/internal/adapters/agent/agentbase/agentbase.go new file mode 100644 index 000000000..7c1a302a7 --- /dev/null +++ b/backend/internal/adapters/agent/agentbase/agentbase.go @@ -0,0 +1,69 @@ +// Package agentbase supplies the defaults an agent adapter would otherwise +// hand-copy. Most adapters implement several ports.Agent methods identically: +// no config keys, prompt delivered in the launch command, and (for the simpler +// harnesses) no hooks, no resume, no session metadata. Embedding Base gives an +// adapter those defaults so it only writes the methods it actually customizes. +package agentbase + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Base provides no-op defaults for the optional ports.Agent methods. Embed it in +// a Plugin struct (`agentbase.Base`) and override only what the harness needs. +// Every method honors ctx cancellation and otherwise does nothing, matching what +// the adapters previously wrote by hand. +type Base struct{} + +// GetConfigSpec reports no agent-specific config keys. +func (Base) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + return ports.ConfigSpec{}, ctx.Err() +} + +// GetPromptDeliveryStrategy reports that the agent receives its prompt in the +// launch command itself, which is true for every shipped adapter. +func (Base) GetPromptDeliveryStrategy(ctx context.Context, _ ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetAgentHooks is a no-op for harnesses without a native hook surface. +func (Base) GetAgentHooks(ctx context.Context, _ ports.WorkspaceHookConfig) error { + return ctx.Err() +} + +// GetRestoreCommand reports that no existing native session can be continued. +func (Base) GetRestoreCommand(ctx context.Context, _ ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + return nil, false, nil +} + +// SessionInfo reports no agent-owned session metadata. +func (Base) SessionInfo(ctx context.Context, _ ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + return ports.SessionInfo{}, false, nil +} + +// StandardSessionInfo returns the normalized session metadata (native session +// id, title, summary) an adapter's hooks persisted under the shared +// ports.MetadataKey* keys. ok is false when none of the three is present. An +// adapter whose SessionInfo just reads those keys delegates here. +func StandardSessionInfo(session ports.SessionRef) (ports.SessionInfo, bool) { + 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 + } + return info, true +} diff --git a/backend/internal/adapters/agent/agy/agy.go b/backend/internal/adapters/agent/agy/agy.go index e29563bad..c527cc125 100644 --- a/backend/internal/adapters/agent/agy/agy.go +++ b/backend/internal/adapters/agent/agy/agy.go @@ -5,30 +5,34 @@ package agy 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/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - adapterID = "agy" +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" -) +var agyBinarySpec = binaryutil.BinarySpec{ + Label: "agy", + Names: []string{"agy"}, + WinNames: []string{"agy.cmd", "agy.exe", "agy"}, + UnixPaths: []string{"/usr/local/bin/agy", "/opt/homebrew/bin/agy"}, + UnixHomePaths: [][]string{{".local", "bin", "agy"}, {".cargo", "bin", "agy"}, {".npm", "bin", "agy"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "agy.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "agy.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".cargo", "bin", "agy.exe"}}, + }, +} // 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 { + agentbase.Base binaryMu sync.RWMutex resolvedBinary string } @@ -54,14 +58,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// 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: // @@ -89,15 +85,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( 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) { @@ -134,84 +121,15 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por 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 + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, 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. +// searching PATH then a handful of well-known install locations. It returns a +// wrapped ports.ErrAgentBinaryNotFound when agy is absent. 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 "", fmt.Errorf("agy: %w", ports.ErrAgentBinaryNotFound) - } - - 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 "", fmt.Errorf("agy: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, agyBinarySpec) } func (p *Plugin) agyBinary(ctx context.Context) (string, error) { @@ -238,8 +156,3 @@ func (p *Plugin) agyBinary(ctx context.Context) (string, error) { 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 index db6202c2e..eba2c0d29 100644 --- a/backend/internal/adapters/agent/agy/agy_test.go +++ b/backend/internal/adapters/agent/agy/agy_test.go @@ -43,7 +43,7 @@ func TestGetLaunchCommand(t *testing.T) { } func TestGetPromptDeliveryStrategy(t *testing.T) { - plugin := &Plugin{resolvedBinary: "agy"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { t.Fatal(err) diff --git a/backend/internal/adapters/agent/aider/aider.go b/backend/internal/adapters/agent/aider/aider.go index e4bd2f480..df014a3ab 100644 --- a/backend/internal/adapters/agent/aider/aider.go +++ b/backend/internal/adapters/agent/aider/aider.go @@ -18,6 +18,8 @@ import ( "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -26,6 +28,7 @@ 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 { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -51,14 +54,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// 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 ] @@ -92,40 +87,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( 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 { @@ -191,7 +152,7 @@ func ResolveAiderBinary(ctx context.Context) (string, error) { } for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { return candidate, nil } if err := ctx.Err(); err != nil { @@ -217,8 +178,3 @@ func (p *Plugin) aiderBinary(ctx context.Context) (string, error) { 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 index 5c891e581..40ad0201e 100644 --- a/backend/internal/adapters/agent/aider/aider_test.go +++ b/backend/internal/adapters/agent/aider/aider_test.go @@ -217,7 +217,7 @@ func TestGetLaunchCommandInlineSystemPromptIsDropped(t *testing.T) { } func TestGetRestoreCommandAlwaysFalse(t *testing.T) { - p := &Plugin{resolvedBinary: "aider"} + p := &Plugin{} cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ Session: ports.SessionRef{ Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abc123"}, diff --git a/backend/internal/adapters/agent/amp/amp.go b/backend/internal/adapters/agent/amp/amp.go index 42b0cf184..3f17e410f 100644 --- a/backend/internal/adapters/agent/amp/amp.go +++ b/backend/internal/adapters/agent/amp/amp.go @@ -8,15 +8,12 @@ package amp 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/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -25,6 +22,7 @@ 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 { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -50,15 +48,7 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// 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 Amp session: +// GetLaunchCommand builds the argv to start a new interactive Amp session: // // amp [-x ] // @@ -81,21 +71,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( 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. @@ -116,74 +91,22 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) 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 +var ampBinarySpec = binaryutil.BinarySpec{ + Label: "amp", + Names: []string{"amp"}, + WinNames: []string{"amp.cmd", "amp.exe", "amp"}, + UnixPaths: []string{"/usr/local/bin/amp", "/opt/homebrew/bin/amp"}, + UnixHomePaths: [][]string{{".local", "bin", "amp"}, {".npm", "bin", "amp"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "amp.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "amp.exe"}}, + }, } // 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. +// locations. It returns a wrapped ports.ErrAgentBinaryNotFound when 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 "", fmt.Errorf("amp: %w", ports.ErrAgentBinaryNotFound) - } - - 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 "", fmt.Errorf("amp: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, ampBinarySpec) } func (p *Plugin) ampBinary(ctx context.Context) (string, error) { @@ -201,8 +124,3 @@ func (p *Plugin) ampBinary(ctx context.Context) (string, error) { 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.go b/backend/internal/adapters/agent/auggie/auggie.go index 80be2b38e..87ca3b4d6 100644 --- a/backend/internal/adapters/agent/auggie/auggie.go +++ b/backend/internal/adapters/agent/auggie/auggie.go @@ -4,7 +4,7 @@ // // 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 +// `--print` (alias `-p`) which runs a single instruction and exits -- the mode AO // uses to drive it unattended. // // Launch shape: @@ -38,15 +38,12 @@ package auggie 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/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -55,6 +52,7 @@ 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 { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -80,14 +78,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// 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 [--rules ] [-- ] @@ -115,22 +105,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( 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: // @@ -159,81 +133,28 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) 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. +var auggieBinarySpec = binaryutil.BinarySpec{ + Label: "auggie", + Names: []string{"auggie"}, + WinNames: []string{"auggie.cmd", "auggie.exe", "auggie"}, + UnixPaths: []string{"/usr/local/bin/auggie", "/opt/homebrew/bin/auggie"}, + UnixHomePaths: [][]string{{".local", "bin", "auggie"}, {".npm", "bin", "auggie"}, {".npm-global", "bin", "auggie"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "auggie.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "auggie.exe"}}, + }, +} + // 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 "", fmt.Errorf("auggie: %w", ports.ErrAgentBinaryNotFound) - } - - 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 "", fmt.Errorf("auggie: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, auggieBinarySpec) } func (p *Plugin) auggieBinary(ctx context.Context) (string, error) { @@ -251,8 +172,3 @@ func (p *Plugin) auggieBinary(ctx context.Context) (string, error) { 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/activity.go b/backend/internal/adapters/agent/autohand/activity.go deleted file mode 100644 index e1280f371..000000000 --- a/backend/internal/adapters/agent/autohand/activity.go +++ /dev/null @@ -1,26 +0,0 @@ -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 index d7fca5a4d..1c64cd8e3 100644 --- a/backend/internal/adapters/agent/autohand/autohand.go +++ b/backend/internal/adapters/agent/autohand/autohand.go @@ -6,34 +6,27 @@ // 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. +// activity states. See hooks.go for hook installation. package autohand 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/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - adapterID = "autohand" - - autohandTitleMetadataKey = "title" - autohandSummaryMetadataKey = "summary" -) +const adapterID = "autohand" // 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 { + agentbase.Base + binaryMu sync.Mutex resolvedBinary string } @@ -59,14 +52,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// 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 @@ -98,15 +83,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( 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 @@ -140,15 +116,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por 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 + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // appendWorkspaceFlag scopes the run to the given workspace path via --path. @@ -161,10 +130,10 @@ func appendWorkspaceFlag(cmd *[]string, workspacePath string) { // 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. +// 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) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Autohand config/default behavior. case ports.PermissionModeAcceptEdits: @@ -176,85 +145,23 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { } } -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } +var autohandBinarySpec = binaryutil.BinarySpec{ + Label: "autohand", + Names: []string{"autohand"}, + WinNames: []string{"autohand.cmd", "autohand.exe", "autohand"}, + UnixPaths: []string{"/usr/local/bin/autohand", "/opt/homebrew/bin/autohand"}, + UnixHomePaths: [][]string{{".local", "bin", "autohand"}, {".npm", "bin", "autohand"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "autohand.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "autohand.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".local", "bin", "autohand.exe"}}, + }, } -// 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. +// ResolveAutohandBinary returns the path to the autohand binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. 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 "", fmt.Errorf("autohand: %w", ports.ErrAgentBinaryNotFound) - } - - 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 "", fmt.Errorf("autohand: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, autohandBinarySpec) } func (p *Plugin) autohandBinary(ctx context.Context) (string, error) { @@ -278,8 +185,3 @@ func (p *Plugin) autohandBinary(ctx context.Context) (string, error) { 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 index 04b039be0..8583b1821 100644 --- a/backend/internal/adapters/agent/autohand/autohand_test.go +++ b/backend/internal/adapters/agent/autohand/autohand_test.go @@ -8,6 +8,7 @@ import ( "reflect" "testing" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitystate" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -125,7 +126,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "autohand"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -137,7 +138,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "autohand"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -209,8 +210,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "sess-123", - autohandTitleMetadataKey: "Fix login redirect", - autohandSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -357,9 +358,9 @@ func TestDeriveActivityState(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, ok := DeriveActivityState(tt.event, []byte(`{}`)) + got, ok := activitystate.StandardDeriveActivityState(tt.event, []byte(`{}`)) if got != tt.want || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", + t.Fatalf("StandardDeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, got, ok, tt.want, tt.wantOK) } }) diff --git a/backend/internal/adapters/agent/autohand/hooks.go b/backend/internal/adapters/agent/autohand/hooks.go index 084515bba..1e579d423 100644 --- a/backend/internal/adapters/agent/autohand/hooks.go +++ b/backend/internal/adapters/agent/autohand/hooks.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -294,35 +295,12 @@ func writeAutohandHooks(configPath string, topLevel, hooksSection map[string]jso return fmt.Errorf("encode %s: %w", configPath, err) } data = append(data, '\n') - if err := atomicWriteFile(configPath, data, 0o600); err != nil { + if err := hookutil.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) } diff --git a/backend/internal/adapters/agent/binaryutil/binaryutil.go b/backend/internal/adapters/agent/binaryutil/binaryutil.go new file mode 100644 index 000000000..8016056e5 --- /dev/null +++ b/backend/internal/adapters/agent/binaryutil/binaryutil.go @@ -0,0 +1,134 @@ +// Package binaryutil centralizes the "find an agent's CLI binary" search that +// every adapter otherwise reimplements. Adapters differ only in the binary +// name(s) and the well-known install locations to probe, so they describe those +// with a BinarySpec and share the identical PATH-then-candidates iteration. +package binaryutil + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// BinarySpec describes where one agent's CLI binary can live. ResolveBinary +// searches PATH (via the platform's name list) first, then the platform's +// candidate install paths in order, returning the first hit. +// +// Path components are given as string slices joined onto their base directory, +// so a spec stays OS-agnostic and never hard-codes a separator. env-derived +// bases (APPDATA, LOCALAPPDATA, home) that are unset simply skip their +// candidates. +type BinarySpec struct { + // Label prefixes the ErrAgentBinaryNotFound error, e.g. "claude". + Label string + + // Names are the binary names looked up on PATH on non-Windows, in order. + Names []string + // WinNames are the binary names looked up on PATH on Windows, in order. + // Empty means the Windows branch does no PATH lookup. + WinNames []string + + // UnixPaths are absolute candidate paths probed on non-Windows, in order. + UnixPaths []string + // UnixHomePaths are candidate paths under the user's home dir on + // non-Windows; each entry is the components to join onto $HOME. + UnixHomePaths [][]string + + // WinPaths are candidate paths probed on Windows, in the exact order given. + // Each entry names the base directory (%APPDATA%, %LOCALAPPDATA%, or home) it + // is joined onto. Order is significant: a native installer location listed + // before an npm shim wins when both are present, so it is spelled out here + // rather than assumed. Entries whose base env is unset are skipped. + WinPaths []WinPath +} + +// WinBase names the base directory a Windows candidate path is joined onto. +type WinBase int + +// The base directories a Windows candidate path can resolve against. +const ( + WinAppData WinBase = iota // %APPDATA% + WinLocalAppData // %LOCALAPPDATA% + WinHome // the user's home directory +) + +// WinPath is one Windows candidate: Parts joined onto Base's directory. +type WinPath struct { + Base WinBase + Parts []string +} + +// ResolveBinary returns the path to spec's binary, searching PATH then the +// platform's candidate install locations. It returns a wrapped +// ports.ErrAgentBinaryNotFound when nothing matches, so callers surface a clear +// "command not found" rather than launching an empty argv. ctx cancellation is +// honored between probes. +func ResolveBinary(ctx context.Context, spec BinarySpec) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + names := spec.Names + var candidates []string + if runtime.GOOS == "windows" { + names = spec.WinNames + home, _ := os.UserHomeDir() + appData := os.Getenv("APPDATA") + localAppData := os.Getenv("LOCALAPPDATA") + for _, wp := range spec.WinPaths { + var base string + switch wp.Base { + case WinAppData: + base = appData + case WinLocalAppData: + base = localAppData + case WinHome: + base = home + } + if base == "" { + continue + } + candidates = append(candidates, filepath.Join(append([]string{base}, wp.Parts...)...)) + } + } else { + candidates = append(candidates, spec.UnixPaths...) + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, joinAll(home, spec.UnixHomePaths)...) + } + } + + for _, name := range names { + if err := ctx.Err(); err != nil { + return "", err + } + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + } + + for _, candidate := range candidates { + if err := ctx.Err(); err != nil { + return "", err + } + if hookutil.FileExists(candidate) { + return candidate, nil + } + } + + return "", fmt.Errorf("%s: %w", spec.Label, ports.ErrAgentBinaryNotFound) +} + +// joinAll joins each component slice onto base into an absolute candidate path. +func joinAll(base string, entries [][]string) []string { + out := make([]string, 0, len(entries)) + for _, parts := range entries { + out = append(out, filepath.Join(append([]string{base}, parts...)...)) + } + return out +} diff --git a/backend/internal/adapters/agent/binaryutil/binaryutil_test.go b/backend/internal/adapters/agent/binaryutil/binaryutil_test.go new file mode 100644 index 000000000..7cc3b24c7 --- /dev/null +++ b/backend/internal/adapters/agent/binaryutil/binaryutil_test.go @@ -0,0 +1,81 @@ +package binaryutil + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestResolveBinaryPrefersPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PATH lookup shape differs on windows") + } + dir := t.TempDir() + bin := filepath.Join(dir, "widget") + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + + got, err := ResolveBinary(context.Background(), BinarySpec{Label: "widget", Names: []string{"widget"}}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if got != bin { + t.Fatalf("got %q, want %q", got, bin) + } +} + +func TestResolveBinaryFallsBackToHomeCandidate(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix home candidate shape") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PATH", t.TempDir()) // empty of the binary + bin := filepath.Join(home, ".local", "bin", "widget") + if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + got, err := ResolveBinary(context.Background(), BinarySpec{ + Label: "widget", + Names: []string{"widget"}, + UnixHomePaths: [][]string{{".local", "bin", "widget"}}, + }) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if got != bin { + t.Fatalf("got %q, want %q", got, bin) + } +} + +func TestResolveBinaryNotFound(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + _, err := ResolveBinary(context.Background(), BinarySpec{ + Label: "widget", + Names: []string{"widget-does-not-exist"}, + WinNames: []string{"widget-does-not-exist.exe"}, + }) + if !errors.Is(err, ports.ErrAgentBinaryNotFound) { + t.Fatalf("want ErrAgentBinaryNotFound, got %v", err) + } +} + +func TestResolveBinaryHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := ResolveBinary(ctx, BinarySpec{Label: "widget", Names: []string{"widget"}}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("want context.Canceled, got %v", err) + } +} diff --git a/backend/internal/adapters/agent/claudecode/claudecode.go b/backend/internal/adapters/agent/claudecode/claudecode.go index ca94bb875..d54fd76d2 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode.go +++ b/backend/internal/adapters/agent/claudecode/claudecode.go @@ -24,7 +24,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "sync" "time" @@ -32,6 +31,8 @@ import ( "github.com/google/uuid" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -51,6 +52,7 @@ var claudeSessionNamespace = uuid.MustParse("a1f0c3d2-7b54-4e96-8a2b-0d9e1f2a3b4 // Plugin is the Claude Code agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -177,15 +179,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Claude Code 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 -} - // PreLaunch is an optional capability the spawn engine invokes (via type // assertion) immediately before creating the session. Claude Code shows a // blocking "do you trust this folder?" dialog the first time it runs in any @@ -265,15 +258,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por 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 + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // AuthStatus checks Claude Code's local authentication state without starting a @@ -430,7 +416,7 @@ func resolveRestoreSystemPrompt(cfg ports.RestoreConfig) (string, error) { // // Empty/unrecognized normalizes to default, so no flag is emitted. func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's settings.json defaultMode. case ports.PermissionModeAcceptEdits: @@ -456,74 +442,24 @@ func appendToolFlags(cmd *[]string, allowed, disallowed []string) { } } -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 settings.json (no flag). - return ports.PermissionModeDefault - } +// claudeBinarySpec locates the claude binary: PATH first, then the native +// installer's locations, npm global, Homebrew, and the claude-managed dir. +var claudeBinarySpec = binaryutil.BinarySpec{ + Label: "claude", + Names: []string{"claude"}, + WinNames: []string{"claude.cmd", "claude.exe", "claude"}, + UnixPaths: []string{"/usr/local/bin/claude", "/opt/homebrew/bin/claude"}, + UnixHomePaths: [][]string{{".local", "bin", "claude"}, {".npm", "bin", "claude"}, {".claude", "local", "claude"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "claude.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "claude.exe"}}, + }, } -// ResolveClaudeBinary finds the `claude` binary, searching PATH then a few -// well-known install locations (the native installer's ~/.local/bin, npm -// global, Homebrew). Returns "claude" as a last resort so callers get a -// clear "command not found" rather than an empty argv. +// ResolveClaudeBinary returns the path to the claude binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. func ResolveClaudeBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"claude.cmd", "claude.exe", "claude"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "claude.cmd"), - filepath.Join(appData, "npm", "claude.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - } - return "", fmt.Errorf("claude: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("claude"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/claude", - "/opt/homebrew/bin/claude", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "claude"), - filepath.Join(home, ".npm", "bin", "claude"), - filepath.Join(home, ".claude", "local", "claude"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("claude: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, claudeBinarySpec) } func (p *Plugin) claudeBinary(ctx context.Context) (string, error) { @@ -630,8 +566,3 @@ func ensureWorkspaceTrusted(configPath, workspacePath string) error { } return nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/claudecode/claudecode_test.go b/backend/internal/adapters/agent/claudecode/claudecode_test.go index 46a321d02..72b48f193 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode_test.go +++ b/backend/internal/adapters/agent/claudecode/claudecode_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -189,8 +190,8 @@ func TestGetAgentHooksInstallsClaudeHooks(t *testing.T) { t.Fatal(err) } var config struct { - Hooks map[string][]claudeMatcherGroup `json:"hooks"` - Permissions json.RawMessage `json:"permissions"` + Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"` + Permissions json.RawMessage `json:"permissions"` } if err := json.Unmarshal(data, &config); err != nil { t.Fatal(err) @@ -266,8 +267,8 @@ func TestUninstallHooksRemovesClaudeHooks(t *testing.T) { t.Fatal(err) } var config struct { - Hooks map[string][]claudeMatcherGroup `json:"hooks"` - Permissions json.RawMessage `json:"permissions"` + Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"` + Permissions json.RawMessage `json:"permissions"` } if err := json.Unmarshal(data, &config); err != nil { t.Fatal(err) @@ -349,7 +350,7 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { // countClaudeHookCommand counts how many hook entries under one event register // the given command — used to prove no duplicate AO hooks. -func countClaudeHookCommand(groups []claudeMatcherGroup, command string) int { +func countClaudeHookCommand(groups []hooksjson.MatcherGroup, command string) int { count := 0 for _, group := range groups { for _, hook := range group.Hooks { @@ -363,7 +364,7 @@ func countClaudeHookCommand(groups []claudeMatcherGroup, command string) int { // matcherForCommand returns the matcher on the group that registers the given // command (nil if the group has no matcher). -func matcherForCommand(groups []claudeMatcherGroup, command string) *string { +func matcherForCommand(groups []hooksjson.MatcherGroup, command string) *string { for _, group := range groups { for _, hook := range group.Hooks { if hook.Command == command { diff --git a/backend/internal/adapters/agent/claudecode/hooks.go b/backend/internal/adapters/agent/claudecode/hooks.go index da8fbb6a6..88019cdb3 100644 --- a/backend/internal/adapters/agent/claudecode/hooks.go +++ b/backend/internal/adapters/agent/claudecode/hooks.go @@ -2,64 +2,25 @@ package claudecode 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/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) const ( - claudeSettingsDirName = ".claude" - claudeSettingsFileName = "settings.local.json" - - // claudeHookCommandPrefix 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. + claudeSettingsDirName = ".claude" + claudeSettingsFileName = "settings.local.json" claudeHookCommandPrefix = "ao hooks claude-code " claudeHookTimeout = 30 ) -type claudeMatcherGroup struct { - // Matcher is a pointer so it round-trips exactly: SessionStart requires a - // real matcher ("startup"); UserPromptSubmit/Stop omit it (Claude ignores - // matcher for those events). omitempty drops a nil matcher on write. - Matcher *string `json:"matcher,omitempty"` - Hooks []claudeHookEntry `json:"hooks"` -} - -type claudeHookEntry struct { - Type string `json:"type"` - Command string `json:"command"` - Timeout int `json:"timeout,omitempty"` -} - -// claudeHookSpec describes one hook AO installs, defined in code rather -// than read from an embedded settings file. -type claudeHookSpec struct { - Event string - Matcher *string - Command string -} - // claudeStartupMatcher is referenced by pointer so SessionStart serializes with // its required "startup" matcher. var claudeStartupMatcher = "startup" -// claudeManagedHooks is the source of truth for the hooks AO installs: -// SessionStart (under the "startup" matcher), UserPromptSubmit, Stop, -// Notification, and SessionEnd. They report normalized session metadata and -// activity-state signals back into AO's store (see DeriveActivityState). -// Notification and SessionEnd carry no matcher: each installs once and fires -// for every sub-type, and the handler filters on the payload's -// notification_type / reason field — installing one command under multiple -// matchers would trip the per-command dedup in claudeHookCommandExists. -var claudeManagedHooks = []claudeHookSpec{ +// claudeManagedHooks is the source of truth for the hooks AO installs. +var claudeManagedHooks = []hooksjson.HookSpec{ {Event: "SessionStart", Matcher: &claudeStartupMatcher, Command: claudeHookCommandPrefix + "session-start"}, {Event: "UserPromptSubmit", Command: claudeHookCommandPrefix + "user-prompt-submit"}, {Event: "Stop", Command: claudeHookCommandPrefix + "stop"}, @@ -67,282 +28,31 @@ var claudeManagedHooks = []claudeHookSpec{ {Event: "SessionEnd", Command: claudeHookCommandPrefix + "session-end"}, } -// GetAgentHooks installs AO's Claude Code hooks into the worktree-local -// .claude/settings.local.json file (the per-session local settings, not the -// shared .claude/settings.json). The hooks (SessionStart, UserPromptSubmit, -// Stop, Notification, SessionEnd) report normalized session metadata and -// activity-state signals back into AO's store. Existing hooks and unrelated -// settings 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("claude-code.GetAgentHooks: WorkspacePath is required") - } - - settingsPath := claudeSettingsPath(cfg.WorkspacePath) - topLevel, rawHooks, err := readClaudeSettings(settingsPath) - if err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - - for event, specs := range groupClaudeHooksByEvent() { - var existingGroups []claudeMatcherGroup - if err := parseClaudeHookType(rawHooks, event, &existingGroups); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - for _, spec := range specs { - if !claudeHookCommandExists(existingGroups, spec.Command) { - entry := claudeHookEntry{Type: "command", Command: spec.Command, Timeout: claudeHookTimeout} - existingGroups = addClaudeHook(existingGroups, entry, spec.Matcher) - } - } - if err := marshalClaudeHookType(rawHooks, event, existingGroups); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - } - - if err := writeClaudeSettings(settingsPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(settingsPath), claudeSettingsFileName); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: gitignore: %w", err) - } - return nil -} - -// UninstallHooks removes AO's Claude Code hooks from the workspace-local -// .claude/settings.local.json file, leaving user-defined hooks and unrelated -// settings untouched. A missing settings 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("claude-code.UninstallHooks: workspacePath is required") - } - - settingsPath := claudeSettingsPath(workspacePath) - if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) { - return nil - } - topLevel, rawHooks, err := readClaudeSettings(settingsPath) - if err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - - for _, event := range claudeManagedEvents() { - var groups []claudeMatcherGroup - if err := parseClaudeHookType(rawHooks, event, &groups); err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - groups = removeClaudeManagedHooks(groups) - if err := marshalClaudeHookType(rawHooks, event, groups); err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - } - - if err := writeClaudeSettings(settingsPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - return nil -} - -// AreHooksInstalled reports whether any AO Claude Code hook is present in -// the workspace-local settings 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("claude-code.AreHooksInstalled: workspacePath is required") - } - - settingsPath := claudeSettingsPath(workspacePath) - if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) { - return false, nil - } - _, rawHooks, err := readClaudeSettings(settingsPath) - if err != nil { - return false, fmt.Errorf("claude-code.AreHooksInstalled: %w", err) - } - - for _, event := range claudeManagedEvents() { - var groups []claudeMatcherGroup - if err := parseClaudeHookType(rawHooks, event, &groups); err != nil { - return false, fmt.Errorf("claude-code.AreHooksInstalled: %w", err) - } - for _, group := range groups { - for _, hook := range group.Hooks { - if isClaudeManagedHook(hook.Command) { - return true, nil - } - } - } - } - return false, nil +// claudeHooks manages AO's hooks in the workspace-local +// .claude/settings.local.json file. +var claudeHooks = hooksjson.Manager{ + Label: "claude-code", + CommandPrefix: claudeHookCommandPrefix, + Timeout: claudeHookTimeout, + Path: claudeSettingsPath, + Managed: claudeManagedHooks, } func claudeSettingsPath(workspacePath string) string { return filepath.Join(workspacePath, claudeSettingsDirName, claudeSettingsFileName) } -// readClaudeSettings loads the settings 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 readClaudeSettings(settingsPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { - topLevel = map[string]json.RawMessage{} - rawHooks = map[string]json.RawMessage{} - - data, err := os.ReadFile(settingsPath) //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", settingsPath, 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", settingsPath, 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", settingsPath, err) - } - } - return topLevel, rawHooks, nil +// GetAgentHooks installs AO's Claude Code hooks, preserving user-defined hooks and unrelated settings. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return claudeHooks.Install(ctx, cfg.WorkspacePath) } -// writeClaudeSettings folds rawHooks back into topLevel and writes the file. An -// empty hooks map drops the "hooks" key entirely. -func writeClaudeSettings(settingsPath 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(settingsPath), 0o750); err != nil { - return fmt.Errorf("create settings dir: %w", err) - } - data, err := json.MarshalIndent(topLevel, "", " ") - if err != nil { - return fmt.Errorf("encode %s: %w", settingsPath, err) - } - data = append(data, '\n') - if err := hookutil.AtomicWriteFile(settingsPath, data, 0o600); err != nil { - return fmt.Errorf("write %s: %w", settingsPath, err) - } - return nil +// UninstallHooks removes AO's Claude Code hooks, leaving user-defined hooks untouched. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + return claudeHooks.Uninstall(ctx, workspacePath) } -// groupClaudeHooksByEvent groups the managed hook specs by their Claude event so -// each event's settings array is rewritten once. -func groupClaudeHooksByEvent() map[string][]claudeHookSpec { - byEvent := map[string][]claudeHookSpec{} - for _, spec := range claudeManagedHooks { - byEvent[spec.Event] = append(byEvent[spec.Event], spec) - } - return byEvent -} - -// claudeManagedEvents returns the distinct Claude events AO manages, in -// the order they first appear in claudeManagedHooks. -func claudeManagedEvents() []string { - seen := map[string]bool{} - events := make([]string, 0, len(claudeManagedHooks)) - for _, spec := range claudeManagedHooks { - if !seen[spec.Event] { - seen[spec.Event] = true - events = append(events, spec.Event) - } - } - return events -} - -func isClaudeManagedHook(command string) bool { - return strings.HasPrefix(command, claudeHookCommandPrefix) -} - -// removeClaudeManagedHooks strips AO hook entries from every group, -// dropping any group left without hooks so the event array doesn't accumulate -// empty matcher objects. -func removeClaudeManagedHooks(groups []claudeMatcherGroup) []claudeMatcherGroup { - result := make([]claudeMatcherGroup, 0, len(groups)) - for _, group := range groups { - kept := make([]claudeHookEntry, 0, len(group.Hooks)) - for _, hook := range group.Hooks { - if !isClaudeManagedHook(hook.Command) { - kept = append(kept, hook) - } - } - if len(kept) > 0 { - group.Hooks = kept - result = append(result, group) - } - } - return result -} - -func parseClaudeHookType(rawHooks map[string]json.RawMessage, event string, target *[]claudeMatcherGroup) 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 marshalClaudeHookType(rawHooks map[string]json.RawMessage, event string, groups []claudeMatcherGroup) 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 claudeHookCommandExists(groups []claudeMatcherGroup, command string) bool { - for _, group := range groups { - for _, hook := range group.Hooks { - if hook.Command == command { - return true - } - } - } - return false -} - -// addClaudeHook 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 addClaudeHook(groups []claudeMatcherGroup, hook claudeHookEntry, matcher *string) []claudeMatcherGroup { - for i, group := range groups { - if matchersEqual(group.Matcher, matcher) { - groups[i].Hooks = append(groups[i].Hooks, hook) - return groups - } - } - return append(groups, claudeMatcherGroup{Matcher: matcher, Hooks: []claudeHookEntry{hook}}) -} - -func matchersEqual(a, b *string) bool { - if a == nil || b == nil { - return a == nil && b == nil - } - return *a == *b +// AreHooksInstalled reports whether any AO Claude Code hook is present. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + return claudeHooks.AreInstalled(ctx, workspacePath) } diff --git a/backend/internal/adapters/agent/cline/activity.go b/backend/internal/adapters/agent/cline/activity.go deleted file mode 100644 index 5d51238f0..000000000 --- a/backend/internal/adapters/agent/cline/activity.go +++ /dev/null @@ -1,32 +0,0 @@ -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 index 799ec1b9c..f7666fd9b 100644 --- a/backend/internal/adapters/agent/cline/cline.go +++ b/backend/internal/adapters/agent/cline/cline.go @@ -16,26 +16,19 @@ package cline 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/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "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 { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -61,14 +54,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// 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 Cline session. Prompted // launches request machine-readable NDJSON output (`--json`). Promptless // launches stay interactive because Cline's JSON output mode requires a prompt @@ -97,15 +82,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( 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 [approval flags] --id `. Resumes are interactive // because no prompt is supplied here. ok is false when the hook-derived native @@ -141,82 +117,27 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por 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 + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil +} + +var clineBinarySpec = binaryutil.BinarySpec{ + Label: "cline", + Names: []string{"cline"}, + WinNames: []string{"cline.cmd", "cline.exe", "cline"}, + UnixPaths: []string{"/usr/local/bin/cline", "/opt/homebrew/bin/cline"}, + UnixHomePaths: [][]string{{".npm-global", "bin", "cline"}, {".npm", "bin", "cline"}, {".local", "bin", "cline"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "cline.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "cline.exe"}}, + }, } // 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. +// searching PATH then a handful of well-known install locations (Homebrew, npm +// global). It returns a wrapped ports.ErrAgentBinaryNotFound when cline is absent. 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 "", fmt.Errorf("cline: %w", ports.ErrAgentBinaryNotFound) - } - - 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 "", fmt.Errorf("cline: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, clineBinarySpec) } func (p *Plugin) clineBinary(ctx context.Context) (string, error) { @@ -236,7 +157,7 @@ func (p *Plugin) clineBinary(ctx context.Context) (string, error) { } func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Cline config/default behavior. case ports.PermissionModeAcceptEdits: @@ -252,20 +173,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *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 index b038fe834..9da5dc8ac 100644 --- a/backend/internal/adapters/agent/cline/cline_test.go +++ b/backend/internal/adapters/agent/cline/cline_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -114,7 +114,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "cline"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -126,7 +126,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "cline"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -245,11 +245,11 @@ func TestUninstallHooksRemovesClineHooks(t *testing.T) { } for _, spec := range clineManagedHooks { - if fileExists(filepath.Join(hooksDir, spec.Event)) { + if hookutil.FileExists(filepath.Join(hooksDir, spec.Event)) { t.Fatalf("%s still present after uninstall", spec.Event) } } - if !fileExists(userHook) { + if !hookutil.FileExists(userHook) { t.Fatal("user PostToolUse hook was removed by uninstall") } } @@ -326,8 +326,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "session-123", - clineTitleMetadataKey: "Fix login redirect", - clineSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -369,29 +369,6 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { } } -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()) diff --git a/backend/internal/adapters/agent/cline/hooks.go b/backend/internal/adapters/agent/cline/hooks.go index 53df0d0b7..184d4fe8c 100644 --- a/backend/internal/adapters/agent/cline/hooks.go +++ b/backend/internal/adapters/agent/cline/hooks.go @@ -83,11 +83,11 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi 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) { + if hookutil.FileExists(scriptPath) && !isManagedClineHook(scriptPath) { continue } script := renderClineHookScript(spec.Subcommand) - if err := atomicWriteFile(scriptPath, []byte(script), 0o700); err != nil { + if err := hookutil.AtomicWriteFile(scriptPath, []byte(script), 0o700); err != nil { return fmt.Errorf("cline.GetAgentHooks: write %s: %w", spec.Event, err) } written = append(written, spec.Event) @@ -116,7 +116,7 @@ func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error for _, spec := range clineManagedHooks { scriptPath := filepath.Join(hooksDir, spec.Event) - if !fileExists(scriptPath) || !isManagedClineHook(scriptPath) { + if !hookutil.FileExists(scriptPath) || !isManagedClineHook(scriptPath) { continue } if err := os.Remove(scriptPath); err != nil && !errors.Is(err, os.ErrNotExist) { @@ -143,7 +143,7 @@ func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (b for _, spec := range clineManagedHooks { scriptPath := filepath.Join(hooksDir, spec.Event) - if fileExists(scriptPath) && isManagedClineHook(scriptPath) { + if hookutil.FileExists(scriptPath) && isManagedClineHook(scriptPath) { return true, nil } } @@ -177,26 +177,3 @@ func isManagedClineHook(scriptPath string) bool { } 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/codex/codex.go b/backend/internal/adapters/agent/codex/codex.go index 95451c0bc..3e79a7006 100644 --- a/backend/internal/adapters/agent/codex/codex.go +++ b/backend/internal/adapters/agent/codex/codex.go @@ -19,12 +19,14 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) // Plugin is the Codex agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -51,14 +53,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Codex 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 Codex session, applying the // no-update-check, hook-trust bypass, and approval flags, AO's session-flag // activity hooks, the workspace trust override, optional system-prompt @@ -92,15 +86,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Codex 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 Codex // session: `codex resume `. ok is false when the hook-derived // native session id has not landed yet, so callers can fall back to fresh @@ -143,15 +128,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por 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 + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // AuthStatus checks Codex's local login state without making a model call. @@ -345,7 +323,7 @@ func appendTerminalCompatibilityFlags(cmd *[]string) { } func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // Codex sessions are AO-managed and run headlessly inside a terminal // mux pane; default to no approval prompts unless project settings @@ -360,18 +338,7 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { } } -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - +// fileExists is a package var so tests can stub it to scope candidate probing. var fileExists = func(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() diff --git a/backend/internal/adapters/agent/codex/codex_test.go b/backend/internal/adapters/agent/codex/codex_test.go index fbf46cd93..9ce5d3b21 100644 --- a/backend/internal/adapters/agent/codex/codex_test.go +++ b/backend/internal/adapters/agent/codex/codex_test.go @@ -233,7 +233,7 @@ func TestCodexTOMLBasicStringEscapes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "codex"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -245,7 +245,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "codex"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { diff --git a/backend/internal/adapters/agent/continueagent/continueagent.go b/backend/internal/adapters/agent/continueagent/continueagent.go index cc2d6ef11..672c6c6cd 100644 --- a/backend/internal/adapters/agent/continueagent/continueagent.go +++ b/backend/internal/adapters/agent/continueagent/continueagent.go @@ -23,15 +23,12 @@ package continueagent 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/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -40,9 +37,22 @@ import ( // (NOT the Go package name "continueagent"). const adapterID = "continue" +var continueBinarySpec = binaryutil.BinarySpec{ + Label: "cn", + Names: []string{"cn"}, + WinNames: []string{"cn.cmd", "cn.exe", "cn"}, + UnixPaths: []string{"/usr/local/bin/cn", "/opt/homebrew/bin/cn"}, + UnixHomePaths: [][]string{{".npm-global", "bin", "cn"}, {".local", "bin", "cn"}, {".npm", "bin", "cn"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "cn.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "cn.exe"}}, + }, +} + // 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 { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -68,14 +78,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// 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] [--rule ] `. // // `--print` runs Continue in non-interactive (headless) mode. The prompt is the @@ -100,14 +102,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( 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. // @@ -158,15 +152,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por 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 + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveContinueBinary finds the `cn` binary (Continue CLI), searching PATH then @@ -174,63 +161,7 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por // 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 "", fmt.Errorf("cn: %w", ports.ErrAgentBinaryNotFound) - } - - 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 "", fmt.Errorf("cn: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, continueBinarySpec) } func (p *Plugin) continueBinary(ctx context.Context) (string, error) { @@ -255,7 +186,7 @@ func (p *Plugin) continueBinary(ctx context.Context) (string, error) { // 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) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Continue config / default behavior. case ports.PermissionModeAcceptEdits: @@ -276,20 +207,3 @@ func appendSystemPromptRule(cmd *[]string, inline, file string) { *cmd = append(*cmd, "--rule", file) } } - -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/copilot/activity.go b/backend/internal/adapters/agent/copilot/activity.go deleted file mode 100644 index a32110547..000000000 --- a/backend/internal/adapters/agent/copilot/activity.go +++ /dev/null @@ -1,38 +0,0 @@ -package copilot - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Copilot CLI 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 copilotManagedHooks -// ("session-start", "user-prompt-submit", "permission-request", "stop"), NOT the -// native Copilot event name. Keeping this beside hooks.go means the events AO -// installs and what they mean live in one place. -// -// Copilot CLI documents that prompt-style hooks (userPromptSubmitted) do NOT -// fire in non-interactive `-p` mode, while preToolUse fires before every tool -// invocation (including ones that would prompt the user for approval) and is -// the most reliable signal in CLI pipe mode (-p). AO still installs every event -// so interactive resume and future modes report activity; the -// permission-request → waiting_input mapping (driven by preToolUse) is the one -// that always fires under AO's headless launch. -// -// TODO(copilot): ActivityExited is still runtime-observation-owned. If Copilot's -// sessionEnd/agentStop hook proves reliable in `-p` mode, map a real -// session-end here. Until then, the lifecycle reaper marks a dead Copilot -// runtime 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/copilot/copilot.go b/backend/internal/adapters/agent/copilot/copilot.go index fbb55f4be..647a293d0 100644 --- a/backend/internal/adapters/agent/copilot/copilot.go +++ b/backend/internal/adapters/agent/copilot/copilot.go @@ -28,20 +28,17 @@ import ( "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - adapterID = "copilot" - - copilotTitleMetadataKey = "title" - copilotSummaryMetadataKey = "summary" -) +const adapterID = "copilot" // Plugin is the GitHub Copilot CLI agent adapter. It is safe for concurrent use; // the binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -67,14 +64,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Copilot 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 interactive Copilot session: // // [env COPILOT_CUSTOM_INSTRUCTIONS_DIRS=[,]] copilot [permission flags] @@ -102,7 +91,9 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( } // GetPromptDeliveryStrategy reports that Copilot receives its prompt after the -// interactive process starts. +// interactive process starts. This overrides the agentbase.Base default +// (in-command) because Copilot's `-p` programmatic mode exits when done, which +// would leave AO's terminal pane dead. func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { if err := ctx.Err(); err != nil { return "", err @@ -149,21 +140,16 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[copilotTitleMetadataKey], - Summary: session.Metadata[copilotSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveCopilotBinary returns the path to the copilot binary on this machine, // searching PATH then a handful of well-known install locations (npm global, -// Homebrew). Returns "copilot" as a last-ditch fallback so callers see a clear -// "command not found" rather than an empty argv. +// Homebrew, the VS Code extension's bundled CLI). When the resolved path is the +// npm-loader shim, the platform-native binary is returned instead. This resolver +// stays hand-rolled (rather than binaryutil.ResolveBinary) because of that +// native-loader indirection. func ResolveCopilotBinary(ctx context.Context) (string, error) { if err := ctx.Err(); err != nil { return "", err @@ -190,7 +176,7 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { candidates = append(candidates, filepath.Join(home, ".copilot", "bin", "copilot.exe")) } for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { return candidate, nil } if err := ctx.Err(); err != nil { @@ -222,7 +208,7 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { } for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { if native := copilotNativeBinaryForLoader(candidate); native != "" { return native, nil } @@ -249,7 +235,7 @@ func copilotNativeBinaryForLoader(path string) string { platform = "darwin" } native := filepath.Join(filepath.Dir(resolved), "node_modules", ".bin", "copilot-"+platform+"-"+runtime.GOARCH) - if fileExists(native) { + if hookutil.FileExists(native) { return native } return "" @@ -305,12 +291,12 @@ func copilotInstructionsEnvPrefix(inlinePrompt, promptFile string) ([]string, er // appendApprovalFlags maps AO's 4 permission modes onto Copilot CLI approval // flags (https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-programmatic-reference): // -// default → no flag (defer to ~/.copilot config / per-tool prompts) -// accept-edits → --allow-tool 'write' (auto-approve file edits only) -// auto → --allow-all-tools (auto-approve every tool, still scoped paths/urls) -// bypass-permissions → --allow-all (full bypass: tools, paths, urls) +// default -> no flag (defer to ~/.copilot config / per-tool prompts) +// accept-edits -> --allow-tool 'write' (auto-approve file edits only) +// auto -> --allow-all-tools (auto-approve every tool, still scoped paths/urls) +// bypass-permissions -> --allow-all (full bypass: tools, paths, urls) func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's ~/.copilot config / interactive prompts. case ports.PermissionModeAcceptEdits: @@ -321,20 +307,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--allow-all") } } - -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/copilot/copilot_test.go b/backend/internal/adapters/agent/copilot/copilot_test.go index 2eb51fadc..bb495c3c3 100644 --- a/backend/internal/adapters/agent/copilot/copilot_test.go +++ b/backend/internal/adapters/agent/copilot/copilot_test.go @@ -9,7 +9,6 @@ import ( "runtime" "testing" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -147,7 +146,7 @@ func TestGetLaunchCommandRespectsCanceledContext(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "copilot"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -159,7 +158,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "copilot"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -371,8 +370,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "uuid-123", - copilotTitleMetadataKey: "Fix login redirect", - copilotSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -569,29 +568,6 @@ func TestCopilotManagedHooksUseDocumentedEventNames(t *testing.T) { } } -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 contains(values []string, needle string) bool { for _, value := range values { if value == needle { diff --git a/backend/internal/adapters/agent/copilot/hooks.go b/backend/internal/adapters/agent/copilot/hooks.go index 65e2e280d..39f02ae61 100644 --- a/backend/internal/adapters/agent/copilot/hooks.go +++ b/backend/internal/adapters/agent/copilot/hooks.go @@ -238,40 +238,12 @@ func writeCopilotHooks(hooksPath string, file copilotHookFile) error { return fmt.Errorf("encode %s: %w", hooksPath, err) } data = append(data, '\n') - if err := atomicWriteFile(hooksPath, data, 0o600); err != nil { + if err := hookutil.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 in the same directory -// followed by a rename, so a crash or signal mid-write can't leave a truncated -// or empty file that Copilot then fails to parse (silently disabling hooks). -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) -} - // isCopilotManagedHook reports whether an entry is one AO owns, recognized by the // command prefix on either the bash or powershell command. func isCopilotManagedHook(entry copilotHookEntry) bool { diff --git a/backend/internal/adapters/agent/crush/crush.go b/backend/internal/adapters/agent/crush/crush.go index 6f192c8a3..3971e573c 100644 --- a/backend/internal/adapters/agent/crush/crush.go +++ b/backend/internal/adapters/agent/crush/crush.go @@ -8,15 +8,12 @@ package crush 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/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -29,6 +26,7 @@ const ( // 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 { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -54,14 +52,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// 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: // @@ -102,15 +92,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( 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 @@ -143,83 +124,24 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) 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 +var crushBinarySpec = binaryutil.BinarySpec{ + Label: "crush", + Names: []string{"crush"}, + WinNames: []string{"crush.cmd", "crush.exe", "crush"}, + UnixPaths: []string{"/usr/local/bin/crush", "/opt/homebrew/bin/crush"}, + UnixHomePaths: [][]string{{".local", "bin", "crush"}, {".cargo", "bin", "crush"}, {".npm", "bin", "crush"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "crush.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "crush.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".cargo", "bin", "crush.exe"}}, + }, } // 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. +// searching PATH then a handful of well-known install locations. It returns a +// wrapped ports.ErrAgentBinaryNotFound when crush is absent. 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 "", fmt.Errorf("crush: %w", ports.ErrAgentBinaryNotFound) - } - - 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 "", fmt.Errorf("crush: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, crushBinarySpec) } func (p *Plugin) crushBinary(ctx context.Context) (string, error) { @@ -237,8 +159,3 @@ func (p *Plugin) crushBinary(ctx context.Context) (string, error) { 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 index 45756dc14..b7cb1b26e 100644 --- a/backend/internal/adapters/agent/crush/crush_test.go +++ b/backend/internal/adapters/agent/crush/crush_test.go @@ -90,7 +90,7 @@ func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "crush"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { diff --git a/backend/internal/adapters/agent/cursor/activity.go b/backend/internal/adapters/agent/cursor/activity.go deleted file mode 100644 index 068ce985a..000000000 --- a/backend/internal/adapters/agent/cursor/activity.go +++ /dev/null @@ -1,30 +0,0 @@ -package cursor - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Cursor 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 cursorManagedHooks -// ("session-start", "user-prompt-submit", "stop", "permission-request"), not -// the native Cursor event name. Cursor currently has no SessionEnd/Notification -// equivalent in the adapter, so runtime exit still falls back to the reaper. -// -// TODO(cursor): ActivityExited is still runtime-observation-owned. If Cursor -// 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 Cursor -// 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/cursor/activity_test.go b/backend/internal/adapters/agent/cursor/activity_test.go deleted file mode 100644 index 9b9e13227..000000000 --- a/backend/internal/adapters/agent/cursor/activity_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package cursor - -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}, - {"native event name -> no signal", "beforeShellExecution", "", 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/cursor/cursor.go b/backend/internal/adapters/agent/cursor/cursor.go index ecc883556..9b9dc9dfd 100644 --- a/backend/internal/adapters/agent/cursor/cursor.go +++ b/backend/internal/adapters/agent/cursor/cursor.go @@ -18,17 +18,15 @@ import ( "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - cursorTitleMetadataKey = "title" - cursorSummaryMetadataKey = "summary" -) - // Plugin is the Cursor agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -54,14 +52,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Cursor 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 Cursor CLI session: // // cursor-agent -p --output-format stream-json --trust [permission flags] @@ -92,15 +82,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Cursor 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 Cursor CLI // session: // @@ -136,15 +117,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[cursorTitleMetadataKey], - Summary: session.Metadata[cursorSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveCursorBinary returns the path to the cursor-agent binary on this @@ -183,7 +157,7 @@ func ResolveCursorBinary(ctx context.Context) (string, error) { ) for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { return candidate, nil } if err := ctx.Err(); err != nil { @@ -211,7 +185,7 @@ func (p *Plugin) cursorBinary(ctx context.Context) (string, error) { } func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Cursor config approvalMode. case ports.PermissionModeAcceptEdits: @@ -223,20 +197,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *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/cursor/cursor_test.go b/backend/internal/adapters/agent/cursor/cursor_test.go index 6600a9cd4..3e20d24a9 100644 --- a/backend/internal/adapters/agent/cursor/cursor_test.go +++ b/backend/internal/adapters/agent/cursor/cursor_test.go @@ -113,7 +113,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "cursor-agent"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -125,7 +125,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "cursor-agent"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -200,8 +200,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "chat-123", - cursorTitleMetadataKey: "Fix login redirect", - cursorSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) diff --git a/backend/internal/adapters/agent/devin/devin.go b/backend/internal/adapters/agent/devin/devin.go index dade0428f..55097c34a 100644 --- a/backend/internal/adapters/agent/devin/devin.go +++ b/backend/internal/adapters/agent/devin/devin.go @@ -24,26 +24,30 @@ package devin 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/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - devinTitleMetadataKey = "title" - devinSummaryMetadataKey = "summary" -) +var devinBinarySpec = binaryutil.BinarySpec{ + Label: "devin", + Names: []string{"devin"}, + WinNames: []string{"devin.cmd", "devin.exe", "devin"}, + UnixPaths: []string{"/usr/local/bin/devin", "/opt/homebrew/bin/devin"}, + UnixHomePaths: [][]string{{".devin", "bin", "devin"}, {".local", "bin", "devin"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinHome, Parts: []string{".devin", "bin", "devin.exe"}}, + }, +} // Plugin is the Devin for Terminal agent adapter. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -69,14 +73,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// 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). // @@ -99,14 +95,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( 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. // @@ -161,74 +149,13 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por 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 + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, 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 "", fmt.Errorf("devin: %w", ports.ErrAgentBinaryNotFound) - } - - 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 "", fmt.Errorf("devin: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, devinBinarySpec) } func (p *Plugin) devinBinary(ctx context.Context) (string, error) { @@ -251,7 +178,7 @@ func (p *Plugin) devinBinary(ctx context.Context) (string, error) { // permission values (`auto`/normal and `dangerous`/bypass), per // `devin --permission-mode -h`. func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to ~/.config/devin/config.json (default mode is auto). case ports.PermissionModeAcceptEdits: @@ -264,20 +191,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *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 index 26159b811..9fe36cf07 100644 --- a/backend/internal/adapters/agent/devin/devin_test.go +++ b/backend/internal/adapters/agent/devin/devin_test.go @@ -195,8 +195,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { 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.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", }, }) if err != nil { diff --git a/backend/internal/adapters/agent/droid/droid.go b/backend/internal/adapters/agent/droid/droid.go index b306fb94e..f37feb9ff 100644 --- a/backend/internal/adapters/agent/droid/droid.go +++ b/backend/internal/adapters/agent/droid/droid.go @@ -23,28 +23,21 @@ import ( "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/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "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 { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -70,14 +63,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// 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] @@ -115,15 +100,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( 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 @@ -166,15 +142,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por 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 + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // droidAutonomyLevel maps an AO permission mode onto Droid's @@ -187,7 +156,7 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por // 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) { + switch ports.NormalizePermissionMode(mode) { case ports.PermissionModeAcceptEdits: return "low" case ports.PermissionModeAuto: @@ -254,73 +223,24 @@ func sanitizeSessionID(id string) string { 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. +var droidBinarySpec = binaryutil.BinarySpec{ + Label: "droid", + Names: []string{"droid"}, + WinNames: []string{"droid.cmd", "droid.exe", "droid"}, + UnixPaths: []string{"/usr/local/bin/droid", "/opt/homebrew/bin/droid"}, + UnixHomePaths: [][]string{{".local", "bin", "droid"}, {".factory", "bin", "droid"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "droid.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "droid.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".local", "bin", "droid.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".factory", "bin", "droid.exe"}}, + }, +} + +// ResolveDroidBinary returns the path to the droid binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. 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 "", fmt.Errorf("droid: %w", ports.ErrAgentBinaryNotFound) - } - - 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 "", fmt.Errorf("droid: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, droidBinarySpec) } func (p *Plugin) droidBinary(ctx context.Context) (string, error) { @@ -338,21 +258,3 @@ func (p *Plugin) droidBinary(ctx context.Context) (string, error) { 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 index ce3ed3607..ea3018f22 100644 --- a/backend/internal/adapters/agent/droid/droid_test.go +++ b/backend/internal/adapters/agent/droid/droid_test.go @@ -180,8 +180,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { 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.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", }, }) if err != nil { diff --git a/backend/internal/adapters/agent/droid/hooks.go b/backend/internal/adapters/agent/droid/hooks.go index 55c4f561c..4b55ebf4a 100644 --- a/backend/internal/adapters/agent/droid/hooks.go +++ b/backend/internal/adapters/agent/droid/hooks.go @@ -2,15 +2,9 @@ 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/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -18,49 +12,20 @@ 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 identifies the hook commands AO owns, so install + // skips duplicates and uninstall recognizes AO entries by prefix. 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{ +// Notification, and SessionEnd. +var droidManagedHooks = []hooksjson.HookSpec{ {Event: "SessionStart", Matcher: &droidStartupMatcher, Command: droidHookCommandPrefix + "session-start"}, {Event: "UserPromptSubmit", Command: droidHookCommandPrefix + "user-prompt-submit"}, {Event: "Stop", Command: droidHookCommandPrefix + "stop"}, @@ -68,287 +33,30 @@ var droidManagedHooks = []droidHookSpec{ {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) - } - if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), droidHooksFileName); err != nil { - return fmt.Errorf("droid.GetAgentHooks: gitignore: %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 +// droidHooks manages AO's hooks in the workspace-local .factory/hooks.json file. +var droidHooks = hooksjson.Manager{ + Label: "droid", + CommandPrefix: droidHookCommandPrefix, + Timeout: droidHookTimeout, + Path: droidHooksPath, + Managed: droidManagedHooks, } 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 +// GetAgentHooks installs AO's Droid hooks, preserving user-defined hooks. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return droidHooks.Install(ctx, cfg.WorkspacePath) } -// 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 +// UninstallHooks removes AO's Droid hooks, leaving user-defined hooks untouched. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + return droidHooks.Uninstall(ctx, workspacePath) } -// 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 +// AreHooksInstalled reports whether any AO Droid hook is present. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + return droidHooks.AreInstalled(ctx, workspacePath) } diff --git a/backend/internal/adapters/agent/goose/activity.go b/backend/internal/adapters/agent/goose/activity.go deleted file mode 100644 index 183568925..000000000 --- a/backend/internal/adapters/agent/goose/activity.go +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 224ac8a4e..000000000 --- a/backend/internal/adapters/agent/goose/activity_test.go +++ /dev/null @@ -1,32 +0,0 @@ -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 index f238651fb..23eb66476 100644 --- a/backend/internal/adapters/agent/goose/goose.go +++ b/backend/internal/adapters/agent/goose/goose.go @@ -25,22 +25,18 @@ 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/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "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" @@ -49,6 +45,7 @@ const ( // 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 { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -74,14 +71,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// 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 @@ -117,15 +106,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( 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 @@ -164,15 +144,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por 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 + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // systemPromptText returns the system instructions to inject. Goose's `--system` @@ -228,7 +201,7 @@ func gooseModeEnvPrefix(mode ports.PermissionMode) []string { // - bypass-permissions → auto: Goose's fully-autonomous mode is the nearest // equivalent to bypass. func gooseMode(mode ports.PermissionMode) string { - switch normalizePermissionMode(mode) { + switch ports.NormalizePermissionMode(mode) { case ports.PermissionModeAcceptEdits: return "smart_approve" case ports.PermissionModeAuto: @@ -240,90 +213,26 @@ func gooseMode(mode ports.PermissionMode) string { } } -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 - } +// gooseBinarySpec locates the goose binary: PATH first, then the install +// script's ~/.local/bin, Homebrew, Cargo, and npm global locations. +var gooseBinarySpec = binaryutil.BinarySpec{ + Label: "goose", + Names: []string{"goose"}, + WinNames: []string{"goose.cmd", "goose.exe", "goose"}, + UnixPaths: []string{"/usr/local/bin/goose", "/opt/homebrew/bin/goose"}, + UnixHomePaths: [][]string{{".local", "bin", "goose"}, {".cargo", "bin", "goose"}, {".npm", "bin", "goose"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "goose.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "goose.exe"}}, + {Base: binaryutil.WinLocalAppData, Parts: []string{"Programs", "goose", "goose.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".cargo", "bin", "goose.exe"}}, + }, } -// 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. +// ResolveGooseBinary returns the path to the goose binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. 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 "", fmt.Errorf("goose: %w", ports.ErrAgentBinaryNotFound) - } - - 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 "", fmt.Errorf("goose: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, gooseBinarySpec) } func (p *Plugin) gooseBinary(ctx context.Context) (string, error) { @@ -341,8 +250,3 @@ func (p *Plugin) gooseBinary(ctx context.Context) (string, error) { 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 index 4cbbf9807..d83deb207 100644 --- a/backend/internal/adapters/agent/goose/goose_test.go +++ b/backend/internal/adapters/agent/goose/goose_test.go @@ -9,6 +9,7 @@ import ( "reflect" "testing" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -141,7 +142,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "goose"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -153,7 +154,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "goose"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -421,8 +422,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "thread-123", - gooseTitleMetadataKey: "Fix login redirect", - gooseSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -513,7 +514,13 @@ func containsSubsequence(values []string, needle []string) bool { return false } -func countGooseHookCommand(entries []gooseMatcherGroup, command string) int { +// gooseHookFile is the on-disk shape of the hooks file, used to decode and +// assert on what GetAgentHooks wrote. +type gooseHookFile struct { + Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"` +} + +func countGooseHookCommand(entries []hooksjson.MatcherGroup, command string) int { count := 0 for _, entry := range entries { for _, hook := range entry.Hooks { diff --git a/backend/internal/adapters/agent/goose/hooks.go b/backend/internal/adapters/agent/goose/hooks.go index f631659e5..da935f14b 100644 --- a/backend/internal/adapters/agent/goose/hooks.go +++ b/backend/internal/adapters/agent/goose/hooks.go @@ -2,22 +2,16 @@ package goose 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/adapters/agent/hooksjson" "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. + // 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" @@ -25,332 +19,45 @@ const ( 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. + // skips duplicates and uninstall recognizes AO entries by prefix. 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{ +var gooseManagedHooks = []hooksjson.HookSpec{ {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) - } - if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), gooseHooksFileName); err != nil { - return fmt.Errorf("goose.GetAgentHooks: gitignore: %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 +// gooseHooks manages AO's hooks in the workspace-local +// .agents/plugins/ao/hooks/hooks.json file. +var gooseHooks = hooksjson.Manager{ + Label: "goose", + CommandPrefix: gooseHookCommandPrefix, + Timeout: gooseHookTimeout, + Path: gooseHooksPath, + Managed: gooseManagedHooks, } 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 +// GetAgentHooks installs AO's Goose hooks, preserving user-defined hooks. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return gooseHooks.Install(ctx, cfg.WorkspacePath) } -// 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 +// UninstallHooks removes AO's Goose hooks, leaving user-defined hooks untouched. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + return gooseHooks.Uninstall(ctx, workspacePath) } -// 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}, - }) +// AreHooksInstalled reports whether any AO Goose hook is present. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + return gooseHooks.AreInstalled(ctx, workspacePath) } diff --git a/backend/internal/adapters/agent/grok/grok.go b/backend/internal/adapters/agent/grok/grok.go index ddebb78a0..0f5ca4cfb 100644 --- a/backend/internal/adapters/agent/grok/grok.go +++ b/backend/internal/adapters/agent/grok/grok.go @@ -16,21 +16,32 @@ package grok 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/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) +var grokBinarySpec = binaryutil.BinarySpec{ + Label: "grok", + Names: []string{"grok"}, + WinNames: []string{"grok.cmd", "grok.exe", "grok"}, + UnixPaths: []string{"/usr/local/bin/grok", "/opt/homebrew/bin/grok"}, + UnixHomePaths: [][]string{{".grok", "bin", "grok"}, {".local", "bin", "grok"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "grok.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "grok.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".grok", "bin", "grok.exe"}}, + }, +} + // Plugin is the Grok Build agent adapter. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -56,14 +67,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// 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 `grok --no-auto-update [--permission-mode ] -p `. // Prompt is delivered via -p (in command). // @@ -85,14 +88,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( 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 Grok Build // has a full Claude Code compatibility layer. // @@ -166,81 +161,13 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - // The keys written by claude hooks (which we install for grok too). - 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 + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveGrokBinary finds the `grok` binary (xAI Grok Build CLI). func ResolveGrokBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"grok.cmd", "grok.exe", "grok"} { - 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", "grok.cmd"), - filepath.Join(appData, "npm", "grok.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".grok", "bin", "grok.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("grok: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("grok"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/grok", - "/opt/homebrew/bin/grok", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".grok", "bin", "grok"), - filepath.Join(home, ".local", "bin", "grok"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("grok: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, grokBinarySpec) } func (p *Plugin) grokBinary(ctx context.Context) (string, error) { @@ -260,7 +187,7 @@ func (p *Plugin) grokBinary(ctx context.Context) (string, error) { } func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's ~/.grok/config.toml (or default behavior). case ports.PermissionModeAcceptEdits: @@ -271,20 +198,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--permission-mode", "bypassPermissions") } } - -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/hooksjson/hooksjson.go b/backend/internal/adapters/agent/hooksjson/hooksjson.go new file mode 100644 index 000000000..21d497b54 --- /dev/null +++ b/backend/internal/adapters/agent/hooksjson/hooksjson.go @@ -0,0 +1,332 @@ +// Package hooksjson implements the matcher-group hooks file that several agents +// (claude-code, goose, qwen, agy, droid) share byte-for-byte in shape. Each such +// file is a JSON object with a "hooks" sub-map keyed by native event name, whose +// values are matcher groups ({matcher?, hooks:[{type,command,timeout}]}). The +// adapters differed only in the file path, the AO command prefix, the per-hook +// timeout, and which events they install, so they describe those with a Manager +// and share the install/uninstall/detect logic here. +// +// The read/write path preserves every top-level key and every user-defined hook +// AO does not own, and writes atomically, so installing AO's hooks never clobbers +// unrelated settings. +package hooksjson + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" +) + +// HookEntry is one command hook inside a matcher group. +type HookEntry struct { + Type string `json:"type"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// MatcherGroup is a set of hooks sharing one matcher. Matcher is a pointer so it +// round-trips exactly: events that require a matcher (e.g. claude SessionStart's +// "startup") carry one; events that omit it serialize without the key. +type MatcherGroup struct { + Matcher *string `json:"matcher,omitempty"` + Hooks []HookEntry `json:"hooks"` +} + +// HookSpec describes one hook AO installs: the native event it attaches to, its +// optional matcher, and the command to run. Adapters define these in code rather +// than reading an embedded template. +type HookSpec struct { + Event string + Matcher *string + Command string +} + +// Manager installs, removes, and detects AO's hooks in one agent's matcher-group +// hooks file. Construct one per adapter with its file path, command prefix, +// per-hook timeout, and managed hook set. +type Manager struct { + // Label prefixes error messages, e.g. "claude-code" or "goose", so the + // wrapped error reads "