refactor(adapters): cut ~3,100 LOC of redundancy in the agent adapter layer (#2349) (#2355)

* refactor(adapters): add shared helpers for adapter dedup (#2349)

Introduce the shared building blocks the per-adapter cleanup will use:
- ports.NormalizePermissionMode (finding 3)
- hookutil.FileExists (finding 10)
- binaryutil.ResolveBinary + BinarySpec (finding 2)
- activitystate.StandardDeriveActivityState (finding 4)
- agentbase.Base embed + StandardSessionInfo (findings 6-9)

No adapters wired up yet; behavior unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(goose): convert to shared adapter helpers (reference) (#2349)

Reference conversion proving the shared packages against real tests:
- hooks.go collapses onto hooksjson.Manager (finding 1)
- ResolveGooseBinary via binaryutil.BinarySpec (finding 2)
- gooseMode uses ports.NormalizePermissionMode (finding 3)
- activity.go deleted; dispatch points at activitystate (findings 4, 5)
- SessionInfo via agentbase.StandardSessionInfo; Base embed drops the
  GetConfigSpec/GetPromptDeliveryStrategy no-ops (findings 6-8)
- fileExists/atomicWriteFile copies removed (findings 5, 10)

Also adds hooksjson package + activitystate test. goose: 327->~90 LOC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(adapters): dedup remaining 22 agent adapters onto shared helpers (#2349)

Applies the shared helpers across every remaining adapter:
- hooksjson.Manager for the matcher-group cohort (claudecode, qwen, droid)
- binaryutil.BinarySpec for 19 binary resolvers (aider/cursor/opencode/codex
  keep their special resolvers; all now use hookutil.FileExists)
- ports.NormalizePermissionMode replaces 15 private copies
- agentbase.Base embed drops the GetConfigSpec/GetPromptDeliveryStrategy no-ops
  (and GetAgentHooks/GetRestoreCommand/SessionInfo no-ops on hookless adapters)
- agentbase.StandardSessionInfo replaces the per-adapter metadata readers
- activitystate.StandardDeriveActivityState via dispatch for the 8 name-only
  derivers; their activity.go files removed (claudecode/codex/droid/agy/opencode
  keep payload-parsing derivers)
- 4 private atomicWriteFile copies missing fsync now use hookutil.AtomicWriteFile
- 23 private fileExists copies removed

Full backend build + vet + test suite green (1647 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

* fix(binaryutil): preserve per-adapter Windows candidate order (#2349)

Review feedback: the shared resolver hardcoded Windows candidate order as
APPDATA then LOCALAPPDATA, which flipped Kiro's lookup so the npm shim
(%APPDATA%\npm\kiro-cli.*) was probed before the native install
(%LOCALAPPDATA%\Programs\kiro\kiro-cli.exe). A fixed order can't preserve every
adapter's original order (goose/vibe want APPDATA first, kiro wants LOCALAPPDATA
first), so BinarySpec now takes an ordered WinPaths []WinPath list where each
entry names its base (WinAppData/WinLocalAppData/WinHome). Every adapter's list
reproduces its pre-refactor order exactly; kiro's native path is restored to
first.

go build / vet / test all green (1647 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(adapters): drop unused resolvedBinary writes flagged by govet (#2349)

Embedding agentbase.Base (value receiver) lets govet's unusedwrite analyzer
prove that setting resolvedBinary in tests that only call Base-promoted methods
(GetConfigSpec/GetPromptDeliveryStrategy/SessionInfo/cancellation) is a dead
write. Drop the field from those 23 constructions; GetLaunchCommand/GetRestore
tests that actually read it keep it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: gofmt manager_test.go struct alignment (#2349)

Inherited via the merge of main: a SessionRecord literal aligned its Metadata
field across a multi-key line, which gofmt/goimports rejects. One-line reformat
to unblock CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(adapters): fix stale resolver fallback comments (#2349)

Review nit: 6 Resolve*Binary functions now delegate to binaryutil.ResolveBinary,
which returns a wrapped ports.ErrAgentBinaryNotFound rather than the bare binary
name. Update their doc comments (kiro, vibe, amp, agy, crush, cline) to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Harshit Singh Bhandari 2026-07-04 20:49:15 +05:30 committed by GitHub
parent 5d7ad6137d
commit 2c08597ee6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 1300 additions and 4123 deletions

View File

@ -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 <agent> <event>` 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

View File

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

View File

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

View File

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

View File

@ -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 <WorkspacePath> [--dangerously-skip-permissions] --conversation <agentSessionId>`.
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()
}

View File

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

View File

@ -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 <prompt> [permission flags] --no-check-update --no-stream --no-pretty [--read <system prompt file>]
@ -91,40 +86,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 {
@ -190,7 +151,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 {
@ -216,8 +177,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()
}

View File

@ -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"},

View File

@ -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,14 +48,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 Amp session:
//
// amp [--permission-mode <mode>] [--append-system-prompt <system prompt>] [-- <prompt>]
@ -87,21 +77,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.
@ -126,14 +101,6 @@ 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
}
func appendPermissionFlags(cmd *[]string, mode ports.PermissionMode) {
switch mode {
case ports.PermissionModeAcceptEdits:
@ -145,66 +112,22 @@ func appendPermissionFlags(cmd *[]string, mode ports.PermissionMode) {
}
}
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) {
@ -222,8 +145,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()
}

View File

@ -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 [--instruction-file <f> | --instruction <s>] [-- <prompt>]
@ -116,22 +106,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:
//
@ -156,81 +130,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 <tool>:<allow|deny>` 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) {
@ -248,8 +169,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()
}

View File

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

View File

@ -6,34 +6,27 @@
// command mode (`autohand -p <prompt>` / positional prompt), native session
// resume (`autohand resume <sessionId>`), 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 <workspace>] <sessionId>`. ok is false when
// the hook-derived native session id has not landed yet, so callers can fall
@ -139,15 +115,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.
@ -160,10 +129,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:
@ -175,85 +144,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) {
@ -277,8 +184,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()
}

View File

@ -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 {
@ -208,8 +209,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",
},
})
@ -356,9 +357,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)
}
})

View File

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

View File

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

View File

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

View File

@ -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
@ -261,15 +254,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
@ -409,7 +395,7 @@ func resolveSystemPrompt(cfg ports.LaunchConfig) (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:
@ -435,74 +421,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) {
@ -609,8 +545,3 @@ func ensureWorkspaceTrusted(configPath, workspacePath string) error {
}
return nil
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}

View File

@ -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"
)
@ -183,8 +184,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)
@ -260,8 +261,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)
@ -343,7 +344,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 {
@ -357,7 +358,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 {

View File

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

View File

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

View File

@ -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 <agentSessionId>`. Resumes are interactive
// because no prompt is supplied here. ok is false when the hook-derived native
@ -138,82 +114,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) {
@ -233,7 +154,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:
@ -249,20 +170,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()
}

View File

@ -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")
}
}
@ -324,8 +324,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",
},
})
@ -367,29 +367,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())

View File

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

View File

@ -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 <agentSessionId>`. ok is false when the hook-derived
// native session id has not landed yet, so callers can fall back to fresh
@ -138,15 +123,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.
@ -340,7 +318,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
@ -355,18 +333,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()

View File

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

View File

@ -22,15 +22,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"
)
@ -39,9 +36,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
}
@ -67,14 +77,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] <prompt>`.
//
// `--print` runs Continue in non-interactive (headless) mode. The prompt is the
@ -97,14 +99,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.
//
@ -154,15 +148,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
@ -170,63 +157,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) {
@ -251,7 +182,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:
@ -262,20 +193,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
*cmd = append(*cmd, "--auto")
}
}
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
switch mode {
case ports.PermissionModeDefault,
ports.PermissionModeAcceptEdits,
ports.PermissionModeAuto,
ports.PermissionModeBypassPermissions:
return mode
default:
return ports.PermissionModeDefault
}
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}

View File

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

View File

@ -28,19 +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
}
@ -66,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:
//
// copilot [permission flags]
@ -95,7 +85,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
@ -138,21 +130,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
@ -179,7 +166,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 {
@ -211,7 +198,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
}
@ -238,7 +225,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 ""
@ -263,12 +250,12 @@ func (p *Plugin) copilotBinary(ctx context.Context) (string, error) {
// 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:
@ -279,20 +266,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()
}

View File

@ -9,7 +9,6 @@ import (
"runtime"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
@ -118,7 +117,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 {
@ -130,7 +129,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 {
@ -309,8 +308,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",
},
})
@ -507,29 +506,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 {

View File

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

View File

@ -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 <WorkspacePath>] [--yolo] --session <agentSessionId>`.
// 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()
}

View File

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

View File

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

View File

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

View File

@ -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] <prompt>
@ -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()
}

View File

@ -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",
},
})

View File

@ -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 <mode>] -p <prompt>`.
// 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()
}

View File

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

View File

@ -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 <path>] [--append-system-prompt[-file] <x>] [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 <path>] -r <agentSessionId>`. It re-applies the permission
// autonomy (resume otherwise reverts to the configured default) but not the
@ -161,15 +137,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
@ -182,7 +151,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:
@ -249,73 +218,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) {
@ -333,21 +253,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()
}

View File

@ -178,8 +178,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 {

View File

@ -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 <event>` 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)
}

View File

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

View File

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

View File

@ -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=<mode>] goose run [--system <text>] -t <prompt>
@ -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=<mode>] goose run --resume --session-id <agentSessionId>
@ -156,15 +136,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`
@ -210,7 +183,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:
@ -222,90 +195,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) {
@ -323,8 +232,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()
}

View File

@ -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 {
@ -419,8 +420,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",
},
})
@ -511,7 +512,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 {

View File

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

View File

@ -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 <mode>] -p <prompt>`.
// 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()
}

View File

@ -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 "<label>.GetAgentHooks: ...".
Label string
// CommandPrefix identifies AO-owned hook commands, e.g. "ao hooks goose ".
// Install skips commands already present and uninstall/detect match on it.
CommandPrefix string
// Timeout is written into each installed hook entry.
Timeout int
// Path returns the hooks file path for a workspace.
Path func(workspacePath string) string
// Managed is the set of hooks AO installs.
Managed []HookSpec
}
// Install merges AO's managed hooks into the workspace's hooks file, preserving
// user-defined hooks and unrelated settings, and is idempotent (a command
// already present is not appended). It also writes a self-ignoring .gitignore
// covering the hooks file so it does not block worktree teardown.
func (m Manager) Install(ctx context.Context, workspacePath string) error {
if err := ctx.Err(); err != nil {
return err
}
if strings.TrimSpace(workspacePath) == "" {
return fmt.Errorf("%s.GetAgentHooks: WorkspacePath is required", m.Label)
}
hooksPath := m.Path(workspacePath)
topLevel, rawHooks, err := readHooksFile(hooksPath)
if err != nil {
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
}
for event, specs := range m.groupByEvent() {
var groups []MatcherGroup
if err := parseEvent(rawHooks, event, &groups); err != nil {
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
}
for _, spec := range specs {
if !commandExists(groups, spec.Command) {
entry := HookEntry{Type: "command", Command: spec.Command, Timeout: m.Timeout}
groups = addHook(groups, entry, spec.Matcher)
}
}
if err := marshalEvent(rawHooks, event, groups); err != nil {
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
}
}
if err := writeHooksFile(hooksPath, topLevel, rawHooks); err != nil {
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
}
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), filepath.Base(hooksPath)); err != nil {
return fmt.Errorf("%s.GetAgentHooks: gitignore: %w", m.Label, err)
}
return nil
}
// Uninstall removes AO's hooks from the workspace's hooks file, leaving
// user-defined hooks and unrelated settings untouched. A missing file is a no-op.
func (m Manager) Uninstall(ctx context.Context, workspacePath string) error {
if err := ctx.Err(); err != nil {
return err
}
if strings.TrimSpace(workspacePath) == "" {
return fmt.Errorf("%s.UninstallHooks: workspacePath is required", m.Label)
}
hooksPath := m.Path(workspacePath)
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
return nil
}
topLevel, rawHooks, err := readHooksFile(hooksPath)
if err != nil {
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
}
for _, event := range m.managedEvents() {
var groups []MatcherGroup
if err := parseEvent(rawHooks, event, &groups); err != nil {
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
}
groups = removeManaged(groups, m.CommandPrefix)
if err := marshalEvent(rawHooks, event, groups); err != nil {
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
}
}
if err := writeHooksFile(hooksPath, topLevel, rawHooks); err != nil {
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
}
return nil
}
// AreInstalled reports whether any AO hook is present in the workspace's hooks
// file. A missing file means none are installed.
func (m Manager) AreInstalled(ctx context.Context, workspacePath string) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
if strings.TrimSpace(workspacePath) == "" {
return false, fmt.Errorf("%s.AreHooksInstalled: workspacePath is required", m.Label)
}
hooksPath := m.Path(workspacePath)
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
return false, nil
}
_, rawHooks, err := readHooksFile(hooksPath)
if err != nil {
return false, fmt.Errorf("%s.AreHooksInstalled: %w", m.Label, err)
}
for _, event := range m.managedEvents() {
var groups []MatcherGroup
if err := parseEvent(rawHooks, event, &groups); err != nil {
return false, fmt.Errorf("%s.AreHooksInstalled: %w", m.Label, err)
}
for _, group := range groups {
for _, hook := range group.Hooks {
if strings.HasPrefix(hook.Command, m.CommandPrefix) {
return true, nil
}
}
}
}
return false, nil
}
// groupByEvent groups the managed specs by event so each event array is
// rewritten once.
func (m Manager) groupByEvent() map[string][]HookSpec {
byEvent := map[string][]HookSpec{}
for _, spec := range m.Managed {
byEvent[spec.Event] = append(byEvent[spec.Event], spec)
}
return byEvent
}
// managedEvents returns the distinct managed events, in first-seen order.
func (m Manager) managedEvents() []string {
seen := map[string]bool{}
events := make([]string, 0, len(m.Managed))
for _, spec := range m.Managed {
if !seen[spec.Event] {
seen[spec.Event] = true
events = append(events, spec.Event)
}
}
return events
}
// readHooksFile loads the 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 readHooksFile(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
}
// writeHooksFile folds rawHooks back into topLevel and writes the file
// atomically. An empty hooks map drops the "hooks" key entirely.
func writeHooksFile(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error {
if len(rawHooks) == 0 {
delete(topLevel, "hooks")
} else {
hooksJSON, err := json.Marshal(rawHooks)
if err != nil {
return fmt.Errorf("encode hooks: %w", err)
}
topLevel["hooks"] = hooksJSON
}
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil {
return fmt.Errorf("create hook dir: %w", err)
}
data, err := json.MarshalIndent(topLevel, "", " ")
if err != nil {
return fmt.Errorf("encode %s: %w", hooksPath, err)
}
data = append(data, '\n')
if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil {
return fmt.Errorf("write %s: %w", hooksPath, err)
}
return nil
}
func parseEvent(rawHooks map[string]json.RawMessage, event string, target *[]MatcherGroup) 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 marshalEvent(rawHooks map[string]json.RawMessage, event string, groups []MatcherGroup) 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 commandExists(groups []MatcherGroup, command string) bool {
for _, group := range groups {
for _, hook := range group.Hooks {
if hook.Command == command {
return true
}
}
}
return false
}
// addHook appends hook to the group with a matching matcher, creating that group
// if none matches.
func addHook(groups []MatcherGroup, hook HookEntry, matcher *string) []MatcherGroup {
for i, group := range groups {
if matchersEqual(group.Matcher, matcher) {
groups[i].Hooks = append(groups[i].Hooks, hook)
return groups
}
}
return append(groups, MatcherGroup{Matcher: matcher, Hooks: []HookEntry{hook}})
}
// removeManaged strips AO hook entries (matched by command prefix) from every
// group, dropping any group left without hooks so the event array doesn't
// accumulate empty matcher objects.
func removeManaged(groups []MatcherGroup, prefix string) []MatcherGroup {
result := make([]MatcherGroup, 0, len(groups))
for _, group := range groups {
kept := make([]HookEntry, 0, len(group.Hooks))
for _, hook := range group.Hooks {
if !strings.HasPrefix(hook.Command, prefix) {
kept = append(kept, hook)
}
}
if len(kept) > 0 {
group.Hooks = kept
result = append(result, group)
}
}
return result
}
func matchersEqual(a, b *string) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}

View File

@ -53,6 +53,14 @@ func EnsureWorkspaceGitignore(dir string, names ...string) error {
return nil
}
// FileExists reports whether path names an existing regular file (not a
// directory). Adapters use it when probing well-known install locations for an
// agent binary.
func FileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// 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 the agent then fails to parse (silently disabling hooks).

View File

@ -1,31 +0,0 @@
package kilocode
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
// DeriveActivityState maps a Kilo Code plugin hook event onto an AO activity
// state. The bool is false when the event carries no activity signal.
//
// event is the AO hook sub-command name the installed plugin shells via
// `ao hooks kilocode <event>` (see kilocodeManagedEvents in hooks.go), not a
// native Kilo event name. The plugin reports:
// - "session-start" → a Kilo session was created (turn begins).
// - "user-prompt-submit" → the user submitted a prompt (turn begins).
// - "permission-request" → Kilo is asking the user to approve a tool call.
// - "stop" → the current turn went idle/finished.
//
// Kilo has no native session/process-end plugin event the adapter maps to
// ActivityExited, so runtime exit still falls back to the lifecycle reaper.
func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
switch event {
case "session-start":
return domain.ActivityActive, true
case "user-prompt-submit":
return domain.ActivityActive, true
case "stop":
return domain.ActivityIdle, true
case "permission-request":
return domain.ActivityWaitingInput, true
default:
return "", false
}
}

View File

@ -92,7 +92,7 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi
if err := os.MkdirAll(filepath.Dir(pluginPath), 0o750); err != nil {
return fmt.Errorf("kilocode.GetAgentHooks: create plugin dir: %w", err)
}
if err := atomicWriteFile(pluginPath, []byte(kilocodePluginSource), 0o600); err != nil {
if err := hookutil.AtomicWriteFile(pluginPath, []byte(kilocodePluginSource), 0o600); err != nil {
return fmt.Errorf("kilocode.GetAgentHooks: write plugin: %w", err)
}
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(pluginPath), kilocodePluginFileName); err != nil {
@ -160,31 +160,3 @@ func isAOManagedPlugin(path string) (bool, error) {
}
return strings.Contains(string(data), kilocodePluginSentinel), nil
}
// atomicWriteFile writes data to path via a temp file + rename, so a crash mid-
// write can't leave a truncated plugin file that Kilo then fails to import
// (silently disabling activity reporting).
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}

View File

@ -24,15 +24,12 @@ package kilocode
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
@ -40,18 +37,12 @@ const (
// adapterID is the registry id and the value users pass to
// `ao spawn --agent`. It matches domain.HarnessKilocode.
adapterID = "kilocode"
// Normalized session-metadata keys the Kilo plugin persists into the AO
// session store and SessionInfo reads back. Shared vocabulary with the Codex
// and opencode adapters so the dashboard treats every agent uniformly. The
// agent-session-id key is the shared ports.MetadataKeyAgentSessionID.
kilocodeTitleMetadataKey = "title"
kilocodeSummaryMetadataKey = "summary"
)
// Plugin is the Kilo Code agent adapter. It is safe for concurrent use; the
// binary path is resolved once and cached under binaryMu.
type Plugin struct {
agentbase.Base
binaryMu sync.Mutex
resolvedBinary string
}
@ -77,16 +68,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
}
}
// GetConfigSpec reports the agent-specific config keys. Kilo Code exposes none
// yet: model and agent selection are read from Kilo's own config
// (kilo.json / ~/.config/kilo), exactly as a normal launch.
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
if err := ctx.Err(); err != nil {
return ports.ConfigSpec{}, err
}
return ports.ConfigSpec{}, nil
}
// GetLaunchCommand builds the argv to start a new interactive Kilo Code session.
// Shape:
//
@ -112,15 +93,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
return cmd, nil
}
// GetPromptDeliveryStrategy reports that Kilo Code receives its prompt in the
// launch command itself (via --prompt).
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
if err := ctx.Err(); err != nil {
return "", err
}
return ports.PromptDeliveryInCommand, nil
}
// GetRestoreCommand rebuilds the argv that continues an existing Kilo Code
// session: `[env KILO_CONFIG_CONTENT=<json>] kilocode --session <agentSessionId>`.
// It re-applies the permission env (resume otherwise reverts to the configured
@ -152,15 +124,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[kilocodeTitleMetadataKey],
Summary: session.Metadata[kilocodeSummaryMetadataKey],
}
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
}
// kilocodePermissionEnvVar is the env var Kilo deep-merges as the
@ -176,15 +141,15 @@ const kilocodePermissionEnvVar = "KILO_CONFIG_CONTENT"
// config (tool -> action, values "ask"/"allow"/"deny", verified via
// `kilocode config check`). Tools left unset fall back to Kilo's own default
// action ("ask"), so each mode only names the tools it relaxes:
// - default nil: no env; Kilo's config decides every prompt.
// - accept-edits edits ("write"/"edit"/"patch" gate on the "edit"
// - default -> nil: no env; Kilo's config decides every prompt.
// - accept-edits -> edits ("write"/"edit"/"patch" gate on the "edit"
// key) auto-approved; bash and everything else still prompt.
// - auto edits + bash auto-approved; network/other still prompt.
// - auto -> edits + bash auto-approved; network/other still prompt.
// Kilo has no classifier/reviewer gate (unlike Claude Code's "auto"), so
// this is the closest analog its flat allow/ask/deny config can express.
// - bypass-permissions "*" wildcard-allows every tool: nothing prompts.
// - bypass-permissions -> "*" wildcard-allows every tool: nothing prompts.
func kilocodePermissionConfig(mode ports.PermissionMode) map[string]string {
switch normalizePermissionMode(mode) {
switch ports.NormalizePermissionMode(mode) {
case ports.PermissionModeAcceptEdits:
return map[string]string{"edit": "allow"}
case ports.PermissionModeAuto:
@ -224,81 +189,22 @@ func kilocodePermissionEnvPrefix(mode ports.PermissionMode) []string {
return []string{"env", kilocodePermissionEnvVar + "=" + string(blob)}
}
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
switch mode {
case ports.PermissionModeDefault,
ports.PermissionModeAcceptEdits,
ports.PermissionModeAuto,
ports.PermissionModeBypassPermissions:
return mode
default:
// Empty or unrecognized: defer to Kilo's own config (no flag).
return ports.PermissionModeDefault
}
var kilocodeBinarySpec = binaryutil.BinarySpec{
Label: "kilocode",
Names: []string{"kilocode"},
WinNames: []string{"kilocode.cmd", "kilocode.exe", "kilocode"},
UnixPaths: []string{"/usr/local/bin/kilocode", "/opt/homebrew/bin/kilocode"},
UnixHomePaths: [][]string{{".npm-global", "bin", "kilocode"}, {".npm", "bin", "kilocode"}, {".local", "bin", "kilocode"}},
WinPaths: []binaryutil.WinPath{
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kilocode.cmd"}},
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kilocode.exe"}},
},
}
// ResolveKilocodeBinary returns the path to the kilocode binary on this machine,
// searching PATH then a handful of well-known install locations (npm global
// bin, Homebrew). Returns "kilocode" as a last-ditch fallback so callers see a
// clear "command not found" rather than an empty argv.
// ResolveKilocodeBinary returns the path to the kilocode binary, or a wrapped
// ports.ErrAgentBinaryNotFound when it is absent.
func ResolveKilocodeBinary(ctx context.Context) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if runtime.GOOS == "windows" {
for _, name := range []string{"kilocode.cmd", "kilocode.exe", "kilocode"} {
if path, err := exec.LookPath(name); err == nil && path != "" {
return path, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
candidates := []string{}
if appData := os.Getenv("APPDATA"); appData != "" {
candidates = append(candidates,
filepath.Join(appData, "npm", "kilocode.cmd"),
filepath.Join(appData, "npm", "kilocode.exe"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "", fmt.Errorf("kilocode: %w", ports.ErrAgentBinaryNotFound)
}
if path, err := exec.LookPath("kilocode"); err == nil && path != "" {
return path, nil
}
candidates := []string{
"/usr/local/bin/kilocode",
"/opt/homebrew/bin/kilocode",
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates,
filepath.Join(home, ".npm-global", "bin", "kilocode"),
filepath.Join(home, ".npm", "bin", "kilocode"),
filepath.Join(home, ".local", "bin", "kilocode"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "", fmt.Errorf("kilocode: %w", ports.ErrAgentBinaryNotFound)
return binaryutil.ResolveBinary(ctx, kilocodeBinarySpec)
}
func (p *Plugin) kilocodeBinary(ctx context.Context) (string, error) {
@ -316,8 +222,3 @@ func (p *Plugin) kilocodeBinary(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()
}

View File

@ -8,6 +8,7 @@ import (
"strings"
"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"
)
@ -91,7 +92,7 @@ func TestGetLaunchCommandMapsPermissionModes(t *testing.T) {
}
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kilocode"}
plugin := &Plugin{}
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
if err != nil {
@ -103,7 +104,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
}
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kilocode"}
plugin := &Plugin{}
spec, err := plugin.GetConfigSpec(context.Background())
if err != nil {
@ -347,8 +348,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
WorkspacePath: "/some/path",
Metadata: map[string]string{
ports.MetadataKeyAgentSessionID: "ses_abc123",
kilocodeTitleMetadataKey: "Fix login redirect",
kilocodeSummaryMetadataKey: "Updated the auth callback and tests.",
ports.MetadataKeyTitle: "Fix login redirect",
ports.MetadataKeySummary: "Updated the auth callback and tests.",
"ignored": "not returned",
},
})
@ -405,9 +406,9 @@ func TestDeriveActivityState(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.event, func(t *testing.T) {
state, ok := DeriveActivityState(tc.event, nil)
state, ok := activitystate.StandardDeriveActivityState(tc.event, nil)
if state != tc.wantState || ok != tc.wantOK {
t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tc.event, state, ok, tc.wantState, tc.wantOK)
t.Fatalf("StandardDeriveActivityState(%q) = (%q, %v), want (%q, %v)", tc.event, state, ok, tc.wantState, tc.wantOK)
}
})
}

View File

@ -17,15 +17,12 @@ package kimi
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"
)
@ -34,6 +31,7 @@ const adapterID = "kimi"
// Plugin is the Kimi 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
}
@ -59,14 +57,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 Kimi session:
//
// kimi -p <prompt> (non-interactive, default)
@ -74,7 +64,7 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
//
// When a prompt is supplied, it is delivered via `-p` (in command), which runs
// a single prompt without opening the TUI. Per Kimi docs, `--prompt` cannot be
// combined with `--yolo`, `--auto`, or `--plan` non-interactive mode already
// combined with `--yolo`, `--auto`, or `--plan` -- non-interactive mode already
// uses the `auto` permission policy by default, so approval flags would be
// rejected at startup. They are only emitted on the (interactive) path with no
// prompt. Kimi has no documented system-prompt flag, so cfg.SystemPrompt /
@ -96,21 +86,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
return cmd, nil
}
// GetPromptDeliveryStrategy reports that Kimi 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: Kimi CLI exposes no native hook system
// and is not documented as Claude Code hook-compatible.
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
return ctx.Err()
}
// GetRestoreCommand rebuilds the argv that continues an existing Kimi session
// when a native Kimi session id is known:
//
@ -118,8 +93,8 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi
//
// ok is false when no native session id has been captured, so callers fall back
// to fresh launch behavior. Per Kimi docs, `--yolo` and `--auto` cannot be
// combined with `--session` (or `--continue`) resumed sessions inherit the
// approval settings of the original session so cfg.Permissions is
// combined with `--session` (or `--continue`) -- resumed sessions inherit the
// approval settings of the original session -- so cfg.Permissions is
// intentionally ignored here. Kimi has no lifecycle hook for AO to capture the
// native session id from yet, so in practice this returns ok=false today.
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
@ -139,15 +114,6 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
return cmd, true, nil
}
// SessionInfo is intentionally a no-op until Kimi exposes a way to capture its
// native session id and display 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
}
// appendApprovalFlags maps AO's permission modes onto Kimi's approval flags
// for interactive launches. Per Kimi docs these flags cannot be combined with
// `--prompt`, `--session`, or `--continue`, so callers on those paths must
@ -181,72 +147,25 @@ func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
}
}
var kimiBinarySpec = binaryutil.BinarySpec{
Label: "kimi",
Names: []string{"kimi"},
WinNames: []string{"kimi.cmd", "kimi.exe", "kimi"},
UnixPaths: []string{"/usr/local/bin/kimi", "/opt/homebrew/bin/kimi"},
UnixHomePaths: [][]string{{".local", "bin", "kimi"}, {".cargo", "bin", "kimi"}},
WinPaths: []binaryutil.WinPath{
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kimi.cmd"}},
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kimi.exe"}},
{Base: binaryutil.WinHome, Parts: []string{".local", "bin", "kimi.exe"}},
},
}
// ResolveKimiBinary finds the `kimi` binary, searching PATH then common install
// locations (the uv tool/curl installer drops it in ~/.local/bin, plus Homebrew
// and ~/.cargo/bin). It returns "kimi" as a last resort so callers get the
// shell's normal command-not-found behavior if Kimi is absent.
func ResolveKimiBinary(ctx context.Context) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if runtime.GOOS == "windows" {
for _, name := range []string{"kimi.cmd", "kimi.exe", "kimi"} {
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", "kimi.cmd"),
filepath.Join(appData, "npm", "kimi.exe"),
)
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates,
filepath.Join(home, ".local", "bin", "kimi.exe"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "", fmt.Errorf("kimi: %w", ports.ErrAgentBinaryNotFound)
}
if path, err := exec.LookPath("kimi"); err == nil && path != "" {
return path, nil
}
candidates := []string{
"/usr/local/bin/kimi",
"/opt/homebrew/bin/kimi",
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates,
filepath.Join(home, ".local", "bin", "kimi"),
filepath.Join(home, ".cargo", "bin", "kimi"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "", fmt.Errorf("kimi: %w", ports.ErrAgentBinaryNotFound)
return binaryutil.ResolveBinary(ctx, kimiBinarySpec)
}
func (p *Plugin) kimiBinary(ctx context.Context) (string, error) {
@ -264,8 +183,3 @@ func (p *Plugin) kimiBinary(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()
}

View File

@ -1,31 +0,0 @@
package kiro
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
// DeriveActivityState maps a Kiro hook event onto an AO activity state. The
// bool is false when the event carries no activity signal.
//
// event is the AO hook sub-command name installed in kiroManagedHooks
// ("session-start", "user-prompt-submit", "permission-request", "stop"), not
// the native Kiro event name (agentSpawn/userPromptSubmit/preToolUse/stop).
// Kiro currently has no session/process-end hook in the adapter, so runtime
// exit still falls back to the lifecycle reaper.
//
// TODO(kiro): ActivityExited is still runtime-observation-owned. If Kiro adds a
// native session/process-end hook, map that hook to ActivityExited here. Until
// then, make sure the lifecycle reaper can still mark a dead Kiro runtime as
// exited even when the last hook signal was sticky waiting_input.
func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
switch event {
case "session-start":
return domain.ActivityActive, true
case "user-prompt-submit":
return domain.ActivityActive, true
case "stop":
return domain.ActivityIdle, true
case "permission-request":
return domain.ActivityWaitingInput, true
default:
return "", false
}
}

View File

@ -229,35 +229,12 @@ func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMess
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 + rename, so a crash mid-
// write can't leave a truncated/empty file that Kiro then fails to parse.
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
// groupKiroHooksByEvent groups the managed hook specs by their Kiro event so
// each event's array is rewritten once.
func groupKiroHooksByEvent() map[string][]kiroHookSpec {

View File

@ -15,31 +15,24 @@
// captured from a Kiro hook payload.
//
// AO-managed sessions derive native session identity and display metadata from
// Kiro's native hooks (see hooks.go / activity.go) rather than transcript scans.
// Kiro's native hooks (see hooks.go) rather than transcript scans.
package kiro
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 (
kiroTitleMetadataKey = "title"
kiroSummaryMetadataKey = "summary"
)
// Plugin is the Kiro agent adapter. It is safe for concurrent use; the binary
// path is resolved once and cached under binaryMu.
type Plugin struct {
agentbase.Base
binaryMu sync.Mutex
resolvedBinary string
}
@ -65,14 +58,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
}
}
// GetConfigSpec reports the agent-specific config keys. Kiro exposes none yet.
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
if err := ctx.Err(); err != nil {
return ports.ConfigSpec{}, err
}
return ports.ConfigSpec{}, nil
}
// GetLaunchCommand builds the argv to start a new headless Kiro session:
// `kiro-cli chat --no-interactive [trust flags] -- <prompt>`.
//
@ -94,15 +79,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
return cmd, nil
}
// GetPromptDeliveryStrategy reports that Kiro receives its prompt in the launch
// command itself.
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
if err := ctx.Err(); err != nil {
return "", err
}
return ports.PromptDeliveryInCommand, nil
}
// GetRestoreCommand rebuilds the argv that continues an existing Kiro session:
// `kiro-cli chat --no-interactive --resume-id <agentSessionId> [trust flags]`.
// ok is false when the hook-derived native session id has not landed yet, so
@ -133,91 +109,31 @@ 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[kiroTitleMetadataKey],
Summary: session.Metadata[kiroSummaryMetadataKey],
}
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 kiroBinarySpec = binaryutil.BinarySpec{
Label: "kiro",
Names: []string{"kiro-cli"},
WinNames: []string{"kiro-cli.cmd", "kiro-cli.exe", "kiro-cli"},
UnixPaths: []string{"/usr/local/bin/kiro-cli", "/opt/homebrew/bin/kiro-cli"},
UnixHomePaths: [][]string{{".kiro", "bin", "kiro-cli"}, {".local", "bin", "kiro-cli"}},
// The native Kiro installer location is probed before the npm shim, matching
// the pre-refactor order so a native install still wins when both are present.
WinPaths: []binaryutil.WinPath{
{Base: binaryutil.WinLocalAppData, Parts: []string{"Programs", "kiro", "kiro-cli.exe"}},
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kiro-cli.cmd"}},
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kiro-cli.exe"}},
{Base: binaryutil.WinHome, Parts: []string{".kiro", "bin", "kiro-cli.exe"}},
},
}
// ResolveKiroBinary returns the path to the kiro-cli binary on this machine,
// searching PATH then a handful of well-known install locations. Returns
// "kiro-cli" as a last-ditch fallback so callers see a clear "command not
// found" rather than an empty argv.
// searching PATH then a handful of well-known install locations. It returns a
// wrapped ports.ErrAgentBinaryNotFound when kiro-cli is absent.
func ResolveKiroBinary(ctx context.Context) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if runtime.GOOS == "windows" {
for _, name := range []string{"kiro-cli.cmd", "kiro-cli.exe", "kiro-cli"} {
path, err := exec.LookPath(name)
if err == nil && path != "" {
return path, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
candidates := []string{}
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
candidates = append(candidates,
filepath.Join(localAppData, "Programs", "kiro", "kiro-cli.exe"),
)
}
if appData := os.Getenv("APPDATA"); appData != "" {
candidates = append(candidates,
filepath.Join(appData, "npm", "kiro-cli.cmd"),
filepath.Join(appData, "npm", "kiro-cli.exe"),
)
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates,
filepath.Join(home, ".kiro", "bin", "kiro-cli.exe"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "", fmt.Errorf("kiro: %w", ports.ErrAgentBinaryNotFound)
}
if path, err := exec.LookPath("kiro-cli"); err == nil && path != "" {
return path, nil
}
candidates := []string{
"/usr/local/bin/kiro-cli",
"/opt/homebrew/bin/kiro-cli",
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates,
filepath.Join(home, ".kiro", "bin", "kiro-cli"),
filepath.Join(home, ".local", "bin", "kiro-cli"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "", fmt.Errorf("kiro: %w", ports.ErrAgentBinaryNotFound)
return binaryutil.ResolveBinary(ctx, kiroBinarySpec)
}
func (p *Plugin) kiroBinary(ctx context.Context) (string, error) {
@ -241,7 +157,7 @@ func (p *Plugin) kiroBinary(ctx context.Context) (string, error) {
// (the interactive per-tool prompt). accept-edits grants the write-capable
// built-in tools; auto/bypass grant all tools.
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
switch normalizePermissionMode(permissions) {
switch ports.NormalizePermissionMode(permissions) {
case ports.PermissionModeDefault:
// No flag: defer to the user's Kiro config / per-tool prompting.
case ports.PermissionModeAcceptEdits:
@ -252,20 +168,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
*cmd = append(*cmd, "--trust-all-tools")
}
}
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
switch mode {
case ports.PermissionModeDefault,
ports.PermissionModeAcceptEdits,
ports.PermissionModeAuto,
ports.PermissionModeBypassPermissions:
return mode
default:
return ports.PermissionModeDefault
}
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}

View File

@ -11,7 +11,6 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
@ -114,7 +113,7 @@ func TestGetLaunchCommandCtxCancelled(t *testing.T) {
}
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
plugin := &Plugin{}
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
if err != nil {
@ -126,7 +125,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
}
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
plugin := &Plugin{}
spec, err := plugin.GetConfigSpec(context.Background())
if err != nil {
@ -357,8 +356,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
WorkspacePath: "/some/path",
Metadata: map[string]string{
ports.MetadataKeyAgentSessionID: "uuid-123",
kiroTitleMetadataKey: "Fix login redirect",
kiroSummaryMetadataKey: "Updated the auth callback and tests.",
ports.MetadataKeyTitle: "Fix login redirect",
ports.MetadataKeySummary: "Updated the auth callback and tests.",
"ignored": "not returned",
},
})
@ -400,29 +399,6 @@ func TestSessionInfoFalseWhenNoHookMetadata(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 TestResolveKiroBinaryFallback(t *testing.T) {
// When the binary is not on PATH or any well-known location, the resolver
// MUST surface ports.ErrAgentBinaryNotFound rather than a silent string

View File

@ -29,6 +29,8 @@ 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/adapters/agent/hookutil"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
_ "modernc.org/sqlite" // register sqlite driver for opencode session metadata probes
@ -39,17 +41,18 @@ const (
// `ao spawn --agent`. It matches domain.HarnessOpenCode.
adapterID = "opencode"
// Normalized session-metadata keys the opencode plugin persists into the AO
// session store and SessionInfo reads back. Shared vocabulary with the Codex
// and Claude Code adapters so the dashboard treats every agent uniformly.
// opencodeAgentSessionIDMetadataKey is the session-metadata key the opencode
// plugin persists the native session id under. GetRestoreCommand reads it back
// to resume an existing session. SessionInfo delegates to
// agentbase.StandardSessionInfo which reads ports.MetadataKeyAgentSessionID
// (same value), but GetRestoreCommand reads it directly, so the const stays.
opencodeAgentSessionIDMetadataKey = "agentSessionId"
opencodeTitleMetadataKey = "title"
opencodeSummaryMetadataKey = "summary"
)
// Plugin is the opencode 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
}
@ -76,16 +79,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
}
}
// GetConfigSpec reports the agent-specific config keys. opencode exposes none
// yet: model and agent selection are read from opencode's own config
// (opencode.json / ~/.config/opencode), exactly as a normal launch.
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
if err := ctx.Err(); err != nil {
return ports.ConfigSpec{}, err
}
return ports.ConfigSpec{}, nil
}
// GetLaunchCommand builds the argv to start a new interactive opencode session.
// Shape:
//
@ -111,15 +104,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
return cmd, nil
}
// GetPromptDeliveryStrategy reports that opencode receives its prompt in the
// launch command itself (via --prompt).
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
if err := ctx.Err(); err != nil {
return "", err
}
return ports.PromptDeliveryInCommand, nil
}
// GetRestoreCommand rebuilds the argv that continues an existing opencode
// session: `opencode [--dangerously-skip-permissions] --session <agentSessionId>`.
// It re-applies the permission flag (resume otherwise reverts to the configured
@ -154,15 +138,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[opencodeAgentSessionIDMetadataKey],
Title: session.Metadata[opencodeTitleMetadataKey],
Summary: session.Metadata[opencodeSummaryMetadataKey],
}
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 whether opencode has at least one configured provider
@ -353,24 +330,11 @@ func opencodeDBCount(ctx context.Context, db *sql.DB, query string) (int, error)
// - default / accept-edits / auto → no flag. opencode resolves approvals from
// its own `permission` config exactly as a normal launch.
func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) {
if normalizePermissionMode(permissions) == ports.PermissionModeBypassPermissions {
if ports.NormalizePermissionMode(permissions) == ports.PermissionModeBypassPermissions {
*cmd = append(*cmd, "--dangerously-skip-permissions")
}
}
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 opencode's own config (no flag).
return ports.PermissionModeDefault
}
}
// ResolveOpenCodeBinary returns the path to the opencode binary on this machine,
// searching PATH then a handful of well-known install locations (the install
// script's ~/.opencode/bin, Homebrew, npm global).
@ -393,7 +357,7 @@ func ResolveOpenCodeBinary(ctx context.Context) (string, error) {
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
if hookutil.FileExists(candidate) {
return candidate, nil
}
}
@ -416,7 +380,7 @@ func ResolveOpenCodeBinary(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 {
@ -442,8 +406,3 @@ func (p *Plugin) opencodeBinary(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()
}

View File

@ -344,7 +344,7 @@ func TestGetLaunchCommandMapsPermissionModes(t *testing.T) {
}
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
plugin := &Plugin{resolvedBinary: "opencode"}
plugin := &Plugin{}
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
if err != nil {
@ -356,7 +356,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
}
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
plugin := &Plugin{resolvedBinary: "opencode"}
plugin := &Plugin{}
spec, err := plugin.GetConfigSpec(context.Background())
if err != nil {
@ -599,8 +599,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
WorkspacePath: "/some/path",
Metadata: map[string]string{
opencodeAgentSessionIDMetadataKey: "ses_abc123",
opencodeTitleMetadataKey: "Fix login redirect",
opencodeSummaryMetadataKey: "Updated the auth callback and tests.",
ports.MetadataKeyTitle: "Fix login redirect",
ports.MetadataKeySummary: "Updated the auth callback and tests.",
"ignored": "not returned",
},
})

View File

@ -12,7 +12,7 @@
// variant), so a system-prompt file is read from disk and its contents are
// inlined into the flag; a read failure aborts the launch.
//
// Permissions: Pi has no permission/approval CLI flags ("No permission popups"
// Permissions: Pi has no permission/approval CLI flags ("No permission popups" --
// confirmation flows are built via TypeScript extensions), so AO emits no
// permission flag and defers to Pi's own behavior.
//
@ -33,15 +33,13 @@ package pi
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"
)
@ -50,6 +48,7 @@ const adapterID = "pi"
// Plugin is the Pi agent adapter. It is safe for concurrent use; the binary
// path is resolved once and cached under binaryMu.
type Plugin struct {
agentbase.Base
binaryMu sync.Mutex
resolvedBinary string
}
@ -75,14 +74,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 Pi session:
//
// pi --print [--append-system-prompt <system prompt>] [<prompt>]
@ -112,22 +103,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
return cmd, nil
}
// GetPromptDeliveryStrategy reports that Pi receives its prompt in the launch
// command itself.
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
if err := ctx.Err(); err != nil {
return "", err
}
return ports.PromptDeliveryInCommand, nil
}
// GetAgentHooks is intentionally a no-op: Pi's lifecycle hooks are only
// reachable through in-process TypeScript extensions, not a config file AO can
// install, and Pi has no Claude Code hook compatibility.
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
return ctx.Err()
}
// GetRestoreCommand rebuilds the argv that continues an existing Pi session when
// a native session id is available in metadata. Pi resumes by id with
// `--session <id>` (partial UUIDs accepted). Until that id exists, ok is false
@ -149,77 +124,23 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
return cmd, true, nil
}
// SessionInfo is intentionally a no-op until a Pi-specific extension persists
// session metadata (title/summary). The native session id, when known, is read
// directly from metadata by GetRestoreCommand.
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
if err := ctx.Err(); err != nil {
return ports.SessionInfo{}, false, err
}
return ports.SessionInfo{}, false, nil
var piBinarySpec = binaryutil.BinarySpec{
Label: "pi",
Names: []string{"pi"},
WinNames: []string{"pi.cmd", "pi.exe", "pi"},
UnixPaths: []string{"/usr/local/bin/pi", "/opt/homebrew/bin/pi"},
UnixHomePaths: [][]string{{".npm-global", "bin", "pi"}, {".local", "bin", "pi"}, {".pi", "bin", "pi"}},
WinPaths: []binaryutil.WinPath{
{Base: binaryutil.WinAppData, Parts: []string{"npm", "pi.cmd"}},
{Base: binaryutil.WinAppData, Parts: []string{"npm", "pi.exe"}},
},
}
// ResolvePiBinary finds the `pi` binary, searching PATH then common install
// locations. It returns "pi" as a last resort so callers get the shell's normal
// command-not-found behavior if Pi is absent.
func ResolvePiBinary(ctx context.Context) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if runtime.GOOS == "windows" {
for _, name := range []string{"pi.cmd", "pi.exe", "pi"} {
if path, err := exec.LookPath(name); err == nil && path != "" {
return path, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
candidates := []string{}
if appData := os.Getenv("APPDATA"); appData != "" {
candidates = append(candidates,
filepath.Join(appData, "npm", "pi.cmd"),
filepath.Join(appData, "npm", "pi.exe"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "", fmt.Errorf("pi: %w", ports.ErrAgentBinaryNotFound)
}
if path, err := exec.LookPath("pi"); err == nil && path != "" {
return path, nil
}
candidates := []string{
"/usr/local/bin/pi",
"/opt/homebrew/bin/pi",
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates,
filepath.Join(home, ".npm-global", "bin", "pi"),
filepath.Join(home, ".local", "bin", "pi"),
filepath.Join(home, ".pi", "bin", "pi"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "", fmt.Errorf("pi: %w", ports.ErrAgentBinaryNotFound)
return binaryutil.ResolveBinary(ctx, piBinarySpec)
}
func (p *Plugin) piBinary(ctx context.Context) (string, error) {
@ -237,8 +158,3 @@ func (p *Plugin) piBinary(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()
}

View File

@ -1,31 +0,0 @@
package qwen
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
// DeriveActivityState maps a Qwen Code 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 qwenManagedHooks
// ("session-start", "user-prompt-submit", "permission-request", "stop"), not
// the native Qwen event name. Qwen Code has no SessionEnd equivalent in the
// adapter yet, so runtime exit still falls back to the reaper.
//
// TODO(qwen): ActivityExited is still runtime-observation-owned. Qwen Code has a
// native SessionEnd hook; if AO installs it, map "session-end" to
// ActivityExited here. Until then, make sure the lifecycle reaper can still mark
// a dead Qwen 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
}
}

View File

@ -2,14 +2,9 @@ package qwen
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"
)
@ -17,10 +12,8 @@ const (
qwenSettingsDirName = ".qwen"
qwenSettingsFileName = "settings.json"
// qwenHookCommandPrefix 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.
// qwenHookCommandPrefix identifies the hook commands AO owns, so install
// skips duplicates and uninstall recognizes AO entries by prefix.
qwenHookCommandPrefix = "ao hooks qwen "
// qwenHookTimeout is in milliseconds: Qwen Code (a gemini-cli fork) measures
@ -28,355 +21,44 @@ const (
qwenHookTimeout = 30000
)
// qwenHookFile is the on-disk shape of the "hooks" sub-object of
// .qwen/settings.json. It is used by tests to decode the written file.
type qwenHookFile struct {
Hooks map[string][]qwenMatcherGroup `json:"hooks"`
}
type qwenMatcherGroup struct {
// Matcher is a pointer so it round-trips exactly: SessionStart targets the
// payload "source" field with a "startup" matcher; UserPromptSubmit/Stop/
// PermissionRequest omit it (Qwen ignores the matcher for those events).
// omitempty drops a nil matcher on write.
Matcher *string `json:"matcher,omitempty"`
Hooks []qwenHookEntry `json:"hooks"`
}
type qwenHookEntry struct {
Type string `json:"type"`
Command string `json:"command"`
Timeout int `json:"timeout,omitempty"`
}
// qwenHookSpec describes one hook AO installs, defined in code rather than read
// from an embedded settings file.
type qwenHookSpec struct {
Event string
Matcher *string
Command string
}
// qwenStartupMatcher is referenced by pointer so SessionStart serializes with
// its "startup" source matcher.
var qwenStartupMatcher = "startup"
// qwenManagedHooks is the source of truth for the hooks AO installs:
// SessionStart (under the "startup" source matcher), UserPromptSubmit,
// PermissionRequest, and Stop. They report normalized session metadata and
// activity-state signals back into AO's store (see DeriveActivityState). The
// AO sub-command names are FIXED and must match the cases in
// DeriveActivityState.
var qwenManagedHooks = []qwenHookSpec{
// PermissionRequest, and Stop.
var qwenManagedHooks = []hooksjson.HookSpec{
{Event: "SessionStart", Matcher: &qwenStartupMatcher, Command: qwenHookCommandPrefix + "session-start"},
{Event: "UserPromptSubmit", Command: qwenHookCommandPrefix + "user-prompt-submit"},
{Event: "PermissionRequest", Command: qwenHookCommandPrefix + "permission-request"},
{Event: "Stop", Command: qwenHookCommandPrefix + "stop"},
}
// GetAgentHooks installs AO's Qwen Code hooks into the worktree-local
// .qwen/settings.json file (the project-level settings). The hooks
// (SessionStart, UserPromptSubmit, PermissionRequest, Stop) report normalized
// session metadata and activity 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("qwen.GetAgentHooks: WorkspacePath is required")
}
settingsPath := qwenSettingsPath(cfg.WorkspacePath)
topLevel, rawHooks, err := readQwenSettings(settingsPath)
if err != nil {
return fmt.Errorf("qwen.GetAgentHooks: %w", err)
}
for event, specs := range groupQwenHooksByEvent() {
var existingGroups []qwenMatcherGroup
if err := parseQwenHookType(rawHooks, event, &existingGroups); err != nil {
return fmt.Errorf("qwen.GetAgentHooks: %w", err)
}
for _, spec := range specs {
if !qwenHookCommandExists(existingGroups, spec.Command) {
entry := qwenHookEntry{Type: "command", Command: spec.Command, Timeout: qwenHookTimeout}
existingGroups = addQwenHook(existingGroups, entry, spec.Matcher)
}
}
if err := marshalQwenHookType(rawHooks, event, existingGroups); err != nil {
return fmt.Errorf("qwen.GetAgentHooks: %w", err)
}
}
if err := writeQwenSettings(settingsPath, topLevel, rawHooks); err != nil {
return fmt.Errorf("qwen.GetAgentHooks: %w", err)
}
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(settingsPath), qwenSettingsFileName); err != nil {
return fmt.Errorf("qwen.GetAgentHooks: gitignore: %w", err)
}
return nil
}
// UninstallHooks removes AO's Qwen Code hooks from the workspace-local
// .qwen/settings.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("qwen.UninstallHooks: workspacePath is required")
}
settingsPath := qwenSettingsPath(workspacePath)
if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) {
return nil
}
topLevel, rawHooks, err := readQwenSettings(settingsPath)
if err != nil {
return fmt.Errorf("qwen.UninstallHooks: %w", err)
}
for _, event := range qwenManagedEvents() {
var groups []qwenMatcherGroup
if err := parseQwenHookType(rawHooks, event, &groups); err != nil {
return fmt.Errorf("qwen.UninstallHooks: %w", err)
}
groups = removeQwenManagedHooks(groups)
if err := marshalQwenHookType(rawHooks, event, groups); err != nil {
return fmt.Errorf("qwen.UninstallHooks: %w", err)
}
}
if err := writeQwenSettings(settingsPath, topLevel, rawHooks); err != nil {
return fmt.Errorf("qwen.UninstallHooks: %w", err)
}
return nil
}
// AreHooksInstalled reports whether any AO Qwen 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("qwen.AreHooksInstalled: workspacePath is required")
}
settingsPath := qwenSettingsPath(workspacePath)
if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) {
return false, nil
}
_, rawHooks, err := readQwenSettings(settingsPath)
if err != nil {
return false, fmt.Errorf("qwen.AreHooksInstalled: %w", err)
}
for _, event := range qwenManagedEvents() {
var groups []qwenMatcherGroup
if err := parseQwenHookType(rawHooks, event, &groups); err != nil {
return false, fmt.Errorf("qwen.AreHooksInstalled: %w", err)
}
for _, group := range groups {
for _, hook := range group.Hooks {
if isQwenManagedHook(hook.Command) {
return true, nil
}
}
}
}
return false, nil
// qwenHooks manages AO's hooks in the workspace-local .qwen/settings.json file.
var qwenHooks = hooksjson.Manager{
Label: "qwen",
CommandPrefix: qwenHookCommandPrefix,
Timeout: qwenHookTimeout,
Path: qwenSettingsPath,
Managed: qwenManagedHooks,
}
func qwenSettingsPath(workspacePath string) string {
return filepath.Join(workspacePath, qwenSettingsDirName, qwenSettingsFileName)
}
// readQwenSettings 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 readQwenSettings(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 Qwen Code hooks, preserving user-defined hooks and unrelated settings.
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
return qwenHooks.Install(ctx, cfg.WorkspacePath)
}
// writeQwenSettings folds rawHooks back into topLevel and writes the file. An
// empty hooks map drops the "hooks" key entirely.
func writeQwenSettings(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 := atomicWriteFile(settingsPath, data, 0o600); err != nil {
return fmt.Errorf("write %s: %w", settingsPath, err)
}
return nil
// UninstallHooks removes AO's Qwen Code hooks, leaving user-defined hooks untouched.
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
return qwenHooks.Uninstall(ctx, workspacePath)
}
// 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 Qwen Code 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)
}
// groupQwenHooksByEvent groups the managed hook specs by their Qwen event so
// each event's settings array is rewritten once.
func groupQwenHooksByEvent() map[string][]qwenHookSpec {
byEvent := map[string][]qwenHookSpec{}
for _, spec := range qwenManagedHooks {
byEvent[spec.Event] = append(byEvent[spec.Event], spec)
}
return byEvent
}
// qwenManagedEvents returns the distinct Qwen events AO manages, in the order
// they first appear in qwenManagedHooks.
func qwenManagedEvents() []string {
seen := map[string]bool{}
events := make([]string, 0, len(qwenManagedHooks))
for _, spec := range qwenManagedHooks {
if !seen[spec.Event] {
seen[spec.Event] = true
events = append(events, spec.Event)
}
}
return events
}
func isQwenManagedHook(command string) bool {
return strings.HasPrefix(command, qwenHookCommandPrefix)
}
// removeQwenManagedHooks strips AO hook entries from every group, dropping any
// group left without hooks so the event array doesn't accumulate empty matcher
// objects.
func removeQwenManagedHooks(groups []qwenMatcherGroup) []qwenMatcherGroup {
result := make([]qwenMatcherGroup, 0, len(groups))
for _, group := range groups {
kept := make([]qwenHookEntry, 0, len(group.Hooks))
for _, hook := range group.Hooks {
if !isQwenManagedHook(hook.Command) {
kept = append(kept, hook)
}
}
if len(kept) > 0 {
group.Hooks = kept
result = append(result, group)
}
}
return result
}
func parseQwenHookType(rawHooks map[string]json.RawMessage, event string, target *[]qwenMatcherGroup) 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 marshalQwenHookType(rawHooks map[string]json.RawMessage, event string, groups []qwenMatcherGroup) 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 qwenHookCommandExists(groups []qwenMatcherGroup, command string) bool {
for _, group := range groups {
for _, hook := range group.Hooks {
if hook.Command == command {
return true
}
}
}
return false
}
// addQwenHook 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 addQwenHook(groups []qwenMatcherGroup, hook qwenHookEntry, matcher *string) []qwenMatcherGroup {
for i, group := range groups {
if matchersEqual(group.Matcher, matcher) {
groups[i].Hooks = append(groups[i].Hooks, hook)
return groups
}
}
return append(groups, qwenMatcherGroup{Matcher: matcher, Hooks: []qwenHookEntry{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 Qwen Code hook is present.
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
return qwenHooks.AreInstalled(ctx, workspacePath)
}

View File

@ -15,26 +15,19 @@ package qwen
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 (
qwenTitleMetadataKey = "title"
qwenSummaryMetadataKey = "summary"
)
// Plugin is the Qwen 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
}
@ -60,14 +53,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
}
}
// GetConfigSpec reports the agent-specific config keys. Qwen Code 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 Qwen Code session: the
// approval-mode flag, optional system-prompt instructions, and the initial
// prompt (passed via `-p` so a leading "-" is not read as a flag). Prompt is
@ -92,15 +77,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
return cmd, nil
}
// GetPromptDeliveryStrategy reports that Qwen Code receives its prompt in the
// launch command itself (via -p).
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 Qwen Code
// session: `qwen [--approval-mode <mode>] -r <agentSessionId>`. ok is false when
// the hook-derived native session id has not landed yet, so callers can fall
@ -132,83 +108,26 @@ 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[qwenTitleMetadataKey],
Summary: session.Metadata[qwenSummaryMetadataKey],
}
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
}
// ResolveQwenBinary returns the path to the qwen binary on this machine,
// searching PATH then a handful of well-known install locations (Homebrew, npm
// global). Returns ports.ErrAgentBinaryNotFound when none of those find the
// binary — better than the previous silent `"qwen"` fallback, which let an
// empty tmux pane masquerade as a live session.
var qwenBinarySpec = binaryutil.BinarySpec{
Label: "qwen",
Names: []string{"qwen"},
WinNames: []string{"qwen.cmd", "qwen.exe", "qwen"},
UnixPaths: []string{"/usr/local/bin/qwen", "/opt/homebrew/bin/qwen"},
UnixHomePaths: [][]string{{".npm-global", "bin", "qwen"}, {".npm", "bin", "qwen"}, {".local", "bin", "qwen"}},
WinPaths: []binaryutil.WinPath{
{Base: binaryutil.WinAppData, Parts: []string{"npm", "qwen.cmd"}},
{Base: binaryutil.WinAppData, Parts: []string{"npm", "qwen.exe"}},
},
}
// ResolveQwenBinary returns the path to the qwen binary, or a wrapped
// ports.ErrAgentBinaryNotFound when it is absent.
func ResolveQwenBinary(ctx context.Context) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if runtime.GOOS == "windows" {
for _, name := range []string{"qwen.cmd", "qwen.exe", "qwen"} {
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", "qwen.cmd"),
filepath.Join(appData, "npm", "qwen.exe"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "", fmt.Errorf("qwen: %w", ports.ErrAgentBinaryNotFound)
}
if path, err := exec.LookPath("qwen"); err == nil && path != "" {
return path, nil
}
candidates := []string{
"/usr/local/bin/qwen",
"/opt/homebrew/bin/qwen",
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates,
filepath.Join(home, ".npm-global", "bin", "qwen"),
filepath.Join(home, ".npm", "bin", "qwen"),
filepath.Join(home, ".local", "bin", "qwen"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "", fmt.Errorf("qwen: %w", ports.ErrAgentBinaryNotFound)
return binaryutil.ResolveBinary(ctx, qwenBinarySpec)
}
func (p *Plugin) qwenBinary(ctx context.Context) (string, error) {
@ -231,7 +150,7 @@ func (p *Plugin) qwenBinary(ctx context.Context) (string, error) {
// `--approval-mode` choices (plan|default|auto-edit|auto|yolo). Default emits no
// flag so Qwen resolves its starting mode from the user's own config.
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
switch normalizePermissionMode(permissions) {
switch ports.NormalizePermissionMode(permissions) {
case ports.PermissionModeDefault:
// No flag: defer to the user's Qwen Code config/default behavior.
case ports.PermissionModeAcceptEdits:
@ -242,20 +161,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
*cmd = append(*cmd, "--approval-mode", "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()
}

View File

@ -8,7 +8,7 @@ import (
"reflect"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
@ -89,7 +89,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
}
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
plugin := &Plugin{resolvedBinary: "qwen"}
plugin := &Plugin{}
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
if err != nil {
@ -101,7 +101,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
}
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
plugin := &Plugin{resolvedBinary: "qwen"}
plugin := &Plugin{}
spec, err := plugin.GetConfigSpec(context.Background())
if err != nil {
@ -140,6 +140,10 @@ func TestContextCancellationIsHonored(t *testing.T) {
}
}
type qwenHookFile struct {
Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"`
}
func TestGetAgentHooksInstallsQwenHooks(t *testing.T) {
plugin := &Plugin{resolvedBinary: "qwen"}
workspace := t.TempDir()
@ -203,7 +207,7 @@ func TestGetAgentHooksInstallsQwenHooks(t *testing.T) {
assertStartupMatcher(t, config.Hooks["SessionStart"])
}
func assertStartupMatcher(t *testing.T, groups []qwenMatcherGroup) {
func assertStartupMatcher(t *testing.T, groups []hooksjson.MatcherGroup) {
t.Helper()
for _, group := range groups {
for _, hook := range group.Hooks {
@ -330,8 +334,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
WorkspacePath: "/some/path",
Metadata: map[string]string{
ports.MetadataKeyAgentSessionID: "sess-123",
qwenTitleMetadataKey: "Fix login redirect",
qwenSummaryMetadataKey: "Updated the auth callback and tests.",
ports.MetadataKeyTitle: "Fix login redirect",
ports.MetadataKeySummary: "Updated the auth callback and tests.",
"ignored": "not returned",
},
})
@ -373,29 +377,6 @@ func TestSessionInfoFalseWhenNoHookMetadata(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 {
@ -429,7 +410,7 @@ func containsSubsequence(values []string, needle []string) bool {
return false
}
func countQwenHookCommand(entries []qwenMatcherGroup, command string) int {
func countQwenHookCommand(entries []hooksjson.MatcherGroup, command string) int {
count := 0
for _, entry := range entries {
for _, hook := range entry.Hooks {

View File

@ -26,15 +26,12 @@ package vibe
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"
)
@ -43,6 +40,7 @@ const adapterID = "vibe"
// Plugin is the Mistral Vibe agent adapter. It is safe for concurrent use; the
// binary path is resolved once and cached under binaryMu.
type Plugin struct {
agentbase.Base
binaryMu sync.Mutex
resolvedBinary string
}
@ -68,14 +66,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 non-interactive Vibe session:
//
// vibe --trust --output text [--workdir <path>] [--agent <profile>] -p <prompt>
@ -104,21 +94,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
return cmd, nil
}
// GetPromptDeliveryStrategy reports that Vibe receives its prompt in the launch
// command itself.
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
if err := ctx.Err(); err != nil {
return "", err
}
return ports.PromptDeliveryInCommand, nil
}
// GetAgentHooks is intentionally a no-op: Vibe has no usable lifecycle-hook
// surface for AO activity reporting (Tier C).
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
return ctx.Err()
}
// GetRestoreCommand rebuilds the argv that continues an existing Vibe session
// when a native session id is available in metadata. Without it, ok is false
// and callers fall back to fresh launch behavior.
@ -143,15 +118,8 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
return cmd, true, nil
}
// SessionInfo is intentionally a no-op until Vibe can surface native session
// metadata to AO.
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
if err := ctx.Err(); err != nil {
return ports.SessionInfo{}, false, err
}
return ports.SessionInfo{}, false, nil
}
// appendWorkdirFlag adds Vibe's explicit `--workdir` flag. Vibe validates its
// own working directory in addition to the process cwd AO sets.
func appendWorkdirFlag(cmd *[]string, workspacePath string) {
if workspacePath != "" {
*cmd = append(*cmd, "--workdir", workspacePath)
@ -172,70 +140,22 @@ func appendAgentFlags(cmd *[]string, mode ports.PermissionMode) {
}
}
var vibeBinarySpec = binaryutil.BinarySpec{
Label: "vibe",
Names: []string{"vibe"},
WinNames: []string{"vibe.exe", "vibe.cmd", "vibe"},
UnixPaths: []string{"/usr/local/bin/vibe", "/opt/homebrew/bin/vibe"},
UnixHomePaths: [][]string{{".local", "bin", "vibe"}, {".local", "share", "uv", "tools", "mistral-vibe", "bin", "vibe"}},
WinPaths: []binaryutil.WinPath{
{Base: binaryutil.WinAppData, Parts: []string{"Python", "Scripts", "vibe.exe"}},
{Base: binaryutil.WinLocalAppData, Parts: []string{"uv", "tools", "mistral-vibe", "Scripts", "vibe.exe"}},
},
}
// ResolveVibeBinary finds the `vibe` binary, searching PATH then common install
// locations. It returns "vibe" as a last resort so callers get the shell's
// normal command-not-found behavior if Vibe is absent.
// locations. It returns a wrapped ports.ErrAgentBinaryNotFound when Vibe is absent.
func ResolveVibeBinary(ctx context.Context) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if runtime.GOOS == "windows" {
for _, name := range []string{"vibe.exe", "vibe.cmd", "vibe"} {
if path, err := exec.LookPath(name); err == nil && path != "" {
return path, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
candidates := []string{}
if appData := os.Getenv("APPDATA"); appData != "" {
candidates = append(candidates,
filepath.Join(appData, "Python", "Scripts", "vibe.exe"),
)
}
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
candidates = append(candidates,
filepath.Join(localAppData, "uv", "tools", "mistral-vibe", "Scripts", "vibe.exe"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "", fmt.Errorf("vibe: %w", ports.ErrAgentBinaryNotFound)
}
if path, err := exec.LookPath("vibe"); err == nil && path != "" {
return path, nil
}
candidates := []string{
"/usr/local/bin/vibe",
"/opt/homebrew/bin/vibe",
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates,
filepath.Join(home, ".local", "bin", "vibe"),
filepath.Join(home, ".local", "share", "uv", "tools", "mistral-vibe", "bin", "vibe"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "", fmt.Errorf("vibe: %w", ports.ErrAgentBinaryNotFound)
return binaryutil.ResolveBinary(ctx, vibeBinarySpec)
}
func (p *Plugin) vibeBinary(ctx context.Context) (string, error) {
@ -253,8 +173,3 @@ func (p *Plugin) vibeBinary(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()
}

View File

@ -196,6 +196,22 @@ const (
PermissionModeBypassPermissions = domain.PermissionModeBypassPermissions
)
// NormalizePermissionMode collapses an empty or unrecognized mode to
// PermissionModeDefault, leaving the four known modes unchanged. Adapters call
// it so a stored value they don't recognize defers to the agent's own config
// (usually by emitting no flag) rather than mapping onto a bogus one.
func NormalizePermissionMode(mode PermissionMode) PermissionMode {
switch mode {
case PermissionModeDefault,
PermissionModeAcceptEdits,
PermissionModeAuto,
PermissionModeBypassPermissions:
return mode
default:
return PermissionModeDefault
}
}
// PromptDeliveryStrategy describes how AO should deliver the initial prompt.
type PromptDeliveryStrategy string