Merge remote-tracking branch 'origin/main' into ao/reverbcode-2/issue-2272-prompts
# Conflicts: # backend/internal/adapters/agent/amp/amp.go # backend/internal/adapters/agent/continueagent/continueagent.go # backend/internal/adapters/agent/copilot/copilot.go # backend/internal/adapters/agent/kiro/kiro.go # backend/internal/adapters/agent/opencode/opencode.go # backend/internal/adapters/agent/vibe/vibe.go
This commit is contained in:
commit
8449f5b856
|
|
@ -108,6 +108,9 @@ The result is a local control layer for agentic coding: agents still do the codi
|
|||
|
||||
Works with 23+ CLI-based coding agents including Claude Code, OpenAI Codex, Cursor, OpenCode, Aider, Amp, Goose, GitHub Copilot, Grok, Qwen Code, Kimi Code, Crush, Cline, Droid, Devin, Auggie, Continue, Kiro, and Kilo Code.
|
||||
|
||||
For direct CLI usage, including agent readiness checks and context-aware session
|
||||
spawns, see the [CLI Guide](docs/cli/README.md).
|
||||
|
||||
**If it runs in a terminal, it runs on Agent Orchestrator.**
|
||||
|
||||
---
|
||||
|
|
@ -172,6 +175,7 @@ For detailed architecture diagrams, data flows, and load-bearing rules, see [arc
|
|||
| -------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules |
|
||||
| [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules |
|
||||
| [CLI Guide](docs/cli/README.md) | Direct `ao` CLI usage, command routes, and smoke tests |
|
||||
| [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 <context file>]
|
||||
|
|
@ -92,40 +87,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Aider receives its prompt in the launch
|
||||
// command itself (via -m).
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetAgentHooks is a no-op: Aider emits no lifecycle hooks (Tier C), so there
|
||||
// is no native hook config to install AO hooks into.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// GetRestoreCommand always reports that no native session can be continued.
|
||||
// Aider has no native session id or resume-by-id mechanism
|
||||
// (see github.com/Aider-AI/aider issues/166), so the manager always falls back
|
||||
// to a fresh launch.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// SessionInfo is a no-op: Aider exposes no captureable session metadata.
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
|
||||
// normalizePermissionMode collapses an empty mode onto PermissionModeDefault so
|
||||
// callers can switch over a stable set of values.
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
|
|
@ -191,7 +152,7 @@ func ResolveAiderBinary(ctx context.Context) (string, error) {
|
|||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
if hookutil.FileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
|
|
@ -217,8 +178,3 @@ func (p *Plugin) aiderBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -8,15 +8,12 @@ package amp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -25,6 +22,7 @@ const adapterID = "amp"
|
|||
// Plugin is the Amp agent adapter. It is safe for concurrent use; the binary
|
||||
// path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -50,15 +48,7 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports no agent-specific config keys yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds the argv to start a new Amp session:
|
||||
// GetLaunchCommand builds the argv to start a new interactive Amp session:
|
||||
//
|
||||
// amp [-x <prompt>]
|
||||
//
|
||||
|
|
@ -81,21 +71,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Amp receives its prompt in the launch
|
||||
// command itself.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetAgentHooks is intentionally a no-op until Amp activity can be reported via
|
||||
// an Amp-specific plugin.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Amp session
|
||||
// when plugin-derived native session metadata is available. Until that metadata
|
||||
// exists, ok is false and callers fall back to fresh launch behavior.
|
||||
|
|
@ -116,74 +91,22 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
|
|||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo is intentionally a no-op until Amp plugin metadata exists.
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
return ports.SessionInfo{}, false, nil
|
||||
var ampBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "amp",
|
||||
Names: []string{"amp"},
|
||||
WinNames: []string{"amp.cmd", "amp.exe", "amp"},
|
||||
UnixPaths: []string{"/usr/local/bin/amp", "/opt/homebrew/bin/amp"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "amp"}, {".npm", "bin", "amp"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "amp.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "amp.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// ResolveAmpBinary finds the `amp` binary, searching PATH then common install
|
||||
// locations. It returns "amp" as a last resort so callers get the shell's normal
|
||||
// command-not-found behavior if Amp is absent.
|
||||
// locations. It returns a wrapped ports.ErrAgentBinaryNotFound when Amp is absent.
|
||||
func ResolveAmpBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"amp.cmd", "amp.exe", "amp"} {
|
||||
if path, err := exec.LookPath(name); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "amp.cmd"),
|
||||
filepath.Join(appData, "npm", "amp.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("amp: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("amp"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/amp",
|
||||
"/opt/homebrew/bin/amp",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "amp"),
|
||||
filepath.Join(home, ".npm", "bin", "amp"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("amp: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, ampBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) ampBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -201,8 +124,3 @@ func (p *Plugin) ampBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
//
|
||||
// Auggie is Augment Code's terminal coding agent (binary "auggie", installed via
|
||||
// `npm install -g @augmentcode/auggie`). It exposes a headless one-shot mode via
|
||||
// `--print` (alias `-p`) which runs a single instruction and exits — the mode AO
|
||||
// `--print` (alias `-p`) which runs a single instruction and exits -- the mode AO
|
||||
// uses to drive it unattended.
|
||||
//
|
||||
// Launch shape:
|
||||
|
|
@ -38,15 +38,12 @@ package auggie
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -55,6 +52,7 @@ const adapterID = "auggie"
|
|||
// Plugin is the Auggie agent adapter. It is safe for concurrent use; the binary
|
||||
// path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -80,14 +78,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports no agent-specific config keys yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds the argv to start a new headless Auggie session:
|
||||
//
|
||||
// auggie --print [--rules <f>] [-- <prompt>]
|
||||
|
|
@ -115,22 +105,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Auggie receives its prompt in the launch
|
||||
// command itself (the print-mode positional).
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetAgentHooks is intentionally a no-op: Auggie has no hook or lifecycle event
|
||||
// system, so there is nothing to install. Activity reporting will require an
|
||||
// Auggie-specific integration once one exists.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Auggie session
|
||||
// when a native session id is available in metadata:
|
||||
//
|
||||
|
|
@ -159,81 +133,28 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
|
|||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo is intentionally a no-op until Auggie session metadata can be
|
||||
// captured (Auggie exposes no hook surface to derive it from).
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
|
||||
// Auggie has no single blanket auto-approve/bypass flag; unattended tool/file
|
||||
// approval is governed by granular `--permission <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) {
|
||||
|
|
@ -251,8 +172,3 @@ func (p *Plugin) auggieBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -140,15 +116,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[autohandTitleMetadataKey],
|
||||
Summary: session.Metadata[autohandSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// appendWorkspaceFlag scopes the run to the given workspace path via --path.
|
||||
|
|
@ -161,10 +130,10 @@ func appendWorkspaceFlag(cmd *[]string, workspacePath string) {
|
|||
// appendApprovalFlags maps AO's four permission modes onto Autohand's approval
|
||||
// flags. Default emits no flag so Autohand resolves its starting mode from the
|
||||
// user's own config (permissions.mode). Autohand has no distinct "accept-edits"
|
||||
// mode, so it maps to --yes (auto-confirm risky actions) — the least-privileged
|
||||
// non-interactive option — while auto/bypass map to --unrestricted.
|
||||
// mode, so it maps to --yes (auto-confirm risky actions) -- the least-privileged
|
||||
// non-interactive option -- while auto/bypass map to --unrestricted.
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's Autohand config/default behavior.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
|
|
@ -176,85 +145,23 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
|||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
var autohandBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "autohand",
|
||||
Names: []string{"autohand"},
|
||||
WinNames: []string{"autohand.cmd", "autohand.exe", "autohand"},
|
||||
UnixPaths: []string{"/usr/local/bin/autohand", "/opt/homebrew/bin/autohand"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "autohand"}, {".npm", "bin", "autohand"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "autohand.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "autohand.exe"}},
|
||||
{Base: binaryutil.WinHome, Parts: []string{".local", "bin", "autohand.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// ResolveAutohandBinary returns the path to the autohand binary on this machine,
|
||||
// searching PATH then a handful of well-known install locations (Homebrew, the
|
||||
// official ~/.local/bin installer, npm global). Returns "autohand" as a
|
||||
// last-ditch fallback so callers see a clear "command not found" rather than an
|
||||
// empty argv.
|
||||
// ResolveAutohandBinary returns the path to the autohand binary, or a wrapped
|
||||
// ports.ErrAgentBinaryNotFound when it is absent.
|
||||
func ResolveAutohandBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"autohand.cmd", "autohand.exe", "autohand"} {
|
||||
if path, err := exec.LookPath(name); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "autohand.cmd"),
|
||||
filepath.Join(appData, "npm", "autohand.exe"),
|
||||
)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(home, ".local", "bin", "autohand.exe"))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("autohand: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("autohand"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/autohand",
|
||||
"/opt/homebrew/bin/autohand",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "autohand"),
|
||||
filepath.Join(home, ".npm", "bin", "autohand"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("autohand: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, autohandBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) autohandBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -278,8 +185,3 @@ func (p *Plugin) autohandBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitystate"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
|
@ -125,7 +126,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
|
|
@ -137,7 +138,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
|
|
@ -209,8 +210,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "sess-123",
|
||||
autohandTitleMetadataKey: "Fix login redirect",
|
||||
autohandSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
|
|
@ -357,9 +358,9 @@ func TestDeriveActivityState(t *testing.T) {
|
|||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := DeriveActivityState(tt.event, []byte(`{}`))
|
||||
got, ok := activitystate.StandardDeriveActivityState(tt.event, []byte(`{}`))
|
||||
if got != tt.want || ok != tt.wantOK {
|
||||
t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)",
|
||||
t.Fatalf("StandardDeriveActivityState(%q) = (%q, %v), want (%q, %v)",
|
||||
tt.event, got, ok, tt.want, tt.wantOK)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,6 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -32,6 +31,8 @@ import (
|
|||
"github.com/google/uuid"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -51,6 +52,7 @@ var claudeSessionNamespace = uuid.MustParse("a1f0c3d2-7b54-4e96-8a2b-0d9e1f2a3b4
|
|||
// Plugin is the Claude Code agent adapter. It is safe for concurrent use; the
|
||||
// binary path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -177,15 +179,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Claude Code receives its prompt in the
|
||||
// launch command itself.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// PreLaunch is an optional capability the spawn engine invokes (via type
|
||||
// assertion) immediately before creating the session. Claude Code shows a
|
||||
// blocking "do you trust this folder?" dialog the first time it runs in any
|
||||
|
|
@ -265,15 +258,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[ports.MetadataKeyTitle],
|
||||
Summary: session.Metadata[ports.MetadataKeySummary],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// AuthStatus checks Claude Code's local authentication state without starting a
|
||||
|
|
@ -430,7 +416,7 @@ func resolveRestoreSystemPrompt(cfg ports.RestoreConfig) (string, error) {
|
|||
//
|
||||
// Empty/unrecognized normalizes to default, so no flag is emitted.
|
||||
func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's settings.json defaultMode.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
|
|
@ -456,74 +442,24 @@ func appendToolFlags(cmd *[]string, allowed, disallowed []string) {
|
|||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
// Empty or unrecognized: defer to settings.json (no flag).
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
// claudeBinarySpec locates the claude binary: PATH first, then the native
|
||||
// installer's locations, npm global, Homebrew, and the claude-managed dir.
|
||||
var claudeBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "claude",
|
||||
Names: []string{"claude"},
|
||||
WinNames: []string{"claude.cmd", "claude.exe", "claude"},
|
||||
UnixPaths: []string{"/usr/local/bin/claude", "/opt/homebrew/bin/claude"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "claude"}, {".npm", "bin", "claude"}, {".claude", "local", "claude"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "claude.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "claude.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// ResolveClaudeBinary finds the `claude` binary, searching PATH then a few
|
||||
// well-known install locations (the native installer's ~/.local/bin, npm
|
||||
// global, Homebrew). Returns "claude" as a last resort so callers get a
|
||||
// clear "command not found" rather than an empty argv.
|
||||
// ResolveClaudeBinary returns the path to the claude binary, or a wrapped
|
||||
// ports.ErrAgentBinaryNotFound when it is absent.
|
||||
func ResolveClaudeBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"claude.cmd", "claude.exe", "claude"} {
|
||||
if path, err := exec.LookPath(name); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "claude.cmd"),
|
||||
filepath.Join(appData, "npm", "claude.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("claude: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("claude"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/claude",
|
||||
"/opt/homebrew/bin/claude",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "claude"),
|
||||
filepath.Join(home, ".npm", "bin", "claude"),
|
||||
filepath.Join(home, ".claude", "local", "claude"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("claude: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, claudeBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) claudeBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -630,8 +566,3 @@ func ensureWorkspaceTrusted(configPath, workspacePath string) error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -189,8 +190,8 @@ func TestGetAgentHooksInstallsClaudeHooks(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
var config struct {
|
||||
Hooks map[string][]claudeMatcherGroup `json:"hooks"`
|
||||
Permissions json.RawMessage `json:"permissions"`
|
||||
Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"`
|
||||
Permissions json.RawMessage `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -266,8 +267,8 @@ func TestUninstallHooksRemovesClaudeHooks(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
var config struct {
|
||||
Hooks map[string][]claudeMatcherGroup `json:"hooks"`
|
||||
Permissions json.RawMessage `json:"permissions"`
|
||||
Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"`
|
||||
Permissions json.RawMessage `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -349,7 +350,7 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
|
|||
|
||||
// countClaudeHookCommand counts how many hook entries under one event register
|
||||
// the given command — used to prove no duplicate AO hooks.
|
||||
func countClaudeHookCommand(groups []claudeMatcherGroup, command string) int {
|
||||
func countClaudeHookCommand(groups []hooksjson.MatcherGroup, command string) int {
|
||||
count := 0
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
|
|
@ -363,7 +364,7 @@ func countClaudeHookCommand(groups []claudeMatcherGroup, command string) int {
|
|||
|
||||
// matcherForCommand returns the matcher on the group that registers the given
|
||||
// command (nil if the group has no matcher).
|
||||
func matcherForCommand(groups []claudeMatcherGroup, command string) *string {
|
||||
func matcherForCommand(groups []hooksjson.MatcherGroup, command string) *string {
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if hook.Command == command {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -141,82 +117,27 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[clineTitleMetadataKey],
|
||||
Summary: session.Metadata[clineSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
var clineBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "cline",
|
||||
Names: []string{"cline"},
|
||||
WinNames: []string{"cline.cmd", "cline.exe", "cline"},
|
||||
UnixPaths: []string{"/usr/local/bin/cline", "/opt/homebrew/bin/cline"},
|
||||
UnixHomePaths: [][]string{{".npm-global", "bin", "cline"}, {".npm", "bin", "cline"}, {".local", "bin", "cline"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "cline.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "cline.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// ResolveClineBinary returns the path to the cline binary on this machine,
|
||||
// searching PATH then a handful of well-known install locations
|
||||
// (Homebrew, npm global). Returns "cline" as a last-ditch fallback so callers
|
||||
// see a clear "command not found" rather than an empty argv.
|
||||
// searching PATH then a handful of well-known install locations (Homebrew, npm
|
||||
// global). It returns a wrapped ports.ErrAgentBinaryNotFound when cline is absent.
|
||||
func ResolveClineBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"cline.cmd", "cline.exe", "cline"} {
|
||||
path, err := exec.LookPath(name)
|
||||
if err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "cline.cmd"),
|
||||
filepath.Join(appData, "npm", "cline.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("cline: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("cline"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/cline",
|
||||
"/opt/homebrew/bin/cline",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".npm-global", "bin", "cline"),
|
||||
filepath.Join(home, ".npm", "bin", "cline"),
|
||||
filepath.Join(home, ".local", "bin", "cline"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("cline: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, clineBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) clineBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -236,7 +157,7 @@ func (p *Plugin) clineBinary(ctx context.Context) (string, error) {
|
|||
}
|
||||
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's Cline config/default behavior.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
|
|
@ -252,20 +173,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
|||
*cmd = append(*cmd, "--yolo")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -114,7 +114,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
|
|
@ -126,7 +126,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
|
|
@ -245,11 +245,11 @@ func TestUninstallHooksRemovesClineHooks(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, spec := range clineManagedHooks {
|
||||
if fileExists(filepath.Join(hooksDir, spec.Event)) {
|
||||
if hookutil.FileExists(filepath.Join(hooksDir, spec.Event)) {
|
||||
t.Fatalf("%s still present after uninstall", spec.Event)
|
||||
}
|
||||
}
|
||||
if !fileExists(userHook) {
|
||||
if !hookutil.FileExists(userHook) {
|
||||
t.Fatal("user PostToolUse hook was removed by uninstall")
|
||||
}
|
||||
}
|
||||
|
|
@ -326,8 +326,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "session-123",
|
||||
clineTitleMetadataKey: "Fix login redirect",
|
||||
clineSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
|
|
@ -369,29 +369,6 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDeriveActivityState(t *testing.T) {
|
||||
tests := []struct {
|
||||
event string
|
||||
want domain.ActivityState
|
||||
wantOK bool
|
||||
}{
|
||||
{"session-start", domain.ActivityActive, true},
|
||||
{"user-prompt-submit", domain.ActivityActive, true},
|
||||
{"stop", domain.ActivityIdle, true},
|
||||
{"permission-request", domain.ActivityWaitingInput, true},
|
||||
{"unknown", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.event, func(t *testing.T) {
|
||||
got, ok := DeriveActivityState(tt.event, nil)
|
||||
if got != tt.want || ok != tt.wantOK {
|
||||
t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, got, ok, tt.want, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellationIsHonored(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -143,15 +128,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[ports.MetadataKeyTitle],
|
||||
Summary: session.Metadata[ports.MetadataKeySummary],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// AuthStatus checks Codex's local login state without making a model call.
|
||||
|
|
@ -345,7 +323,7 @@ func appendTerminalCompatibilityFlags(cmd *[]string) {
|
|||
}
|
||||
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// Codex sessions are AO-managed and run headlessly inside a terminal
|
||||
// mux pane; default to no approval prompts unless project settings
|
||||
|
|
@ -360,18 +338,7 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
|||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
// fileExists is a package var so tests can stub it to scope candidate probing.
|
||||
var fileExists = func(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -23,15 +23,12 @@ package continueagent
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
|
@ -40,9 +37,22 @@ import (
|
|||
// (NOT the Go package name "continueagent").
|
||||
const adapterID = "continue"
|
||||
|
||||
var continueBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "cn",
|
||||
Names: []string{"cn"},
|
||||
WinNames: []string{"cn.cmd", "cn.exe", "cn"},
|
||||
UnixPaths: []string{"/usr/local/bin/cn", "/opt/homebrew/bin/cn"},
|
||||
UnixHomePaths: [][]string{{".npm-global", "bin", "cn"}, {".local", "bin", "cn"}, {".npm", "bin", "cn"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "cn.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "cn.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// Plugin is the Continue CLI agent adapter. It is safe for concurrent use; the
|
||||
// binary path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -68,14 +78,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports no agent-specific config keys yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds `cn --print [--auto|--readonly] [--rule <rule>] <prompt>`.
|
||||
//
|
||||
// `--print` runs Continue in non-interactive (headless) mode. The prompt is the
|
||||
|
|
@ -100,14 +102,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetAgentHooks reuses the Claude Code hook installer because the Continue CLI
|
||||
// natively reads Claude Code hook settings.
|
||||
//
|
||||
|
|
@ -158,15 +152,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[ports.MetadataKeyTitle],
|
||||
Summary: session.Metadata[ports.MetadataKeySummary],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// ResolveContinueBinary finds the `cn` binary (Continue CLI), searching PATH then
|
||||
|
|
@ -174,63 +161,7 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
// callers get the shell's normal command-not-found behavior if Continue is
|
||||
// absent.
|
||||
func ResolveContinueBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"cn.cmd", "cn.exe", "cn"} {
|
||||
if path, err := exec.LookPath(name); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "cn.cmd"),
|
||||
filepath.Join(appData, "npm", "cn.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("cn: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("cn"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/cn",
|
||||
"/opt/homebrew/bin/cn",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".npm-global", "bin", "cn"),
|
||||
filepath.Join(home, ".local", "bin", "cn"),
|
||||
filepath.Join(home, ".npm", "bin", "cn"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("cn: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, continueBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) continueBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -255,7 +186,7 @@ func (p *Plugin) continueBinary(ctx context.Context) (string, error) {
|
|||
// and the two flags are mutually exclusive. Default and AcceptEdits emit no flag
|
||||
// so Continue defers to the user's own config / default behavior.
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's Continue config / default behavior.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
|
|
@ -276,20 +207,3 @@ func appendSystemPromptRule(cmd *[]string, inline, file string) {
|
|||
*cmd = append(*cmd, "--rule", file)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -28,20 +28,17 @@ import (
|
|||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
adapterID = "copilot"
|
||||
|
||||
copilotTitleMetadataKey = "title"
|
||||
copilotSummaryMetadataKey = "summary"
|
||||
)
|
||||
const adapterID = "copilot"
|
||||
|
||||
// Plugin is the GitHub Copilot CLI agent adapter. It is safe for concurrent use;
|
||||
// the binary path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -67,14 +64,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports the agent-specific config keys. Copilot exposes none yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds the argv to start a new interactive Copilot session:
|
||||
//
|
||||
// [env COPILOT_CUSTOM_INSTRUCTIONS_DIRS=<ao-dir>[,<existing>]] copilot [permission flags]
|
||||
|
|
@ -102,7 +91,9 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Copilot receives its prompt after the
|
||||
// interactive process starts.
|
||||
// interactive process starts. This overrides the agentbase.Base default
|
||||
// (in-command) because Copilot's `-p` programmatic mode exits when done, which
|
||||
// would leave AO's terminal pane dead.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
|
|
@ -149,21 +140,16 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[copilotTitleMetadataKey],
|
||||
Summary: session.Metadata[copilotSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// ResolveCopilotBinary returns the path to the copilot binary on this machine,
|
||||
// searching PATH then a handful of well-known install locations (npm global,
|
||||
// Homebrew). Returns "copilot" as a last-ditch fallback so callers see a clear
|
||||
// "command not found" rather than an empty argv.
|
||||
// Homebrew, the VS Code extension's bundled CLI). When the resolved path is the
|
||||
// npm-loader shim, the platform-native binary is returned instead. This resolver
|
||||
// stays hand-rolled (rather than binaryutil.ResolveBinary) because of that
|
||||
// native-loader indirection.
|
||||
func ResolveCopilotBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
|
|
@ -190,7 +176,7 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) {
|
|||
candidates = append(candidates, filepath.Join(home, ".copilot", "bin", "copilot.exe"))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
if hookutil.FileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
|
|
@ -222,7 +208,7 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) {
|
|||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
if hookutil.FileExists(candidate) {
|
||||
if native := copilotNativeBinaryForLoader(candidate); native != "" {
|
||||
return native, nil
|
||||
}
|
||||
|
|
@ -249,7 +235,7 @@ func copilotNativeBinaryForLoader(path string) string {
|
|||
platform = "darwin"
|
||||
}
|
||||
native := filepath.Join(filepath.Dir(resolved), "node_modules", ".bin", "copilot-"+platform+"-"+runtime.GOARCH)
|
||||
if fileExists(native) {
|
||||
if hookutil.FileExists(native) {
|
||||
return native
|
||||
}
|
||||
return ""
|
||||
|
|
@ -305,12 +291,12 @@ func copilotInstructionsEnvPrefix(inlinePrompt, promptFile string) ([]string, er
|
|||
// appendApprovalFlags maps AO's 4 permission modes onto Copilot CLI approval
|
||||
// flags (https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-programmatic-reference):
|
||||
//
|
||||
// default → no flag (defer to ~/.copilot config / per-tool prompts)
|
||||
// accept-edits → --allow-tool 'write' (auto-approve file edits only)
|
||||
// auto → --allow-all-tools (auto-approve every tool, still scoped paths/urls)
|
||||
// bypass-permissions → --allow-all (full bypass: tools, paths, urls)
|
||||
// default -> no flag (defer to ~/.copilot config / per-tool prompts)
|
||||
// accept-edits -> --allow-tool 'write' (auto-approve file edits only)
|
||||
// auto -> --allow-all-tools (auto-approve every tool, still scoped paths/urls)
|
||||
// bypass-permissions -> --allow-all (full bypass: tools, paths, urls)
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's ~/.copilot config / interactive prompts.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
|
|
@ -321,20 +307,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
|||
*cmd = append(*cmd, "--allow-all")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import (
|
|||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -147,7 +146,7 @@ func TestGetLaunchCommandRespectsCanceledContext(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
|
|
@ -159,7 +158,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
|
|
@ -371,8 +370,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "uuid-123",
|
||||
copilotTitleMetadataKey: "Fix login redirect",
|
||||
copilotSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
|
|
@ -569,29 +568,6 @@ func TestCopilotManagedHooksUseDocumentedEventNames(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDeriveActivityState(t *testing.T) {
|
||||
tests := []struct {
|
||||
event string
|
||||
wantState domain.ActivityState
|
||||
wantOK bool
|
||||
}{
|
||||
{"session-start", domain.ActivityActive, true},
|
||||
{"user-prompt-submit", domain.ActivityActive, true},
|
||||
{"stop", domain.ActivityIdle, true},
|
||||
{"permission-request", domain.ActivityWaitingInput, true},
|
||||
{"unknown", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.event, func(t *testing.T) {
|
||||
state, ok := DeriveActivityState(tt.event, nil)
|
||||
if state != tt.wantState || ok != tt.wantOK {
|
||||
t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, state, ok, tt.wantState, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func contains(values []string, needle string) bool {
|
||||
for _, value := range values {
|
||||
if value == needle {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -166,15 +142,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[droidTitleMetadataKey],
|
||||
Summary: session.Metadata[droidSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// droidAutonomyLevel maps an AO permission mode onto Droid's
|
||||
|
|
@ -187,7 +156,7 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
// bypass-permissions → high (max interactive autonomy; Droid's interactive
|
||||
// TUI has no exec-style --skip-permissions-unsafe)
|
||||
func droidAutonomyLevel(mode ports.PermissionMode) string {
|
||||
switch normalizePermissionMode(mode) {
|
||||
switch ports.NormalizePermissionMode(mode) {
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
return "low"
|
||||
case ports.PermissionModeAuto:
|
||||
|
|
@ -254,73 +223,24 @@ func sanitizeSessionID(id string) string {
|
|||
return b.String()
|
||||
}
|
||||
|
||||
// ResolveDroidBinary finds the `droid` binary (Factory Droid CLI), searching
|
||||
// PATH then a handful of well-known install locations. Returns "droid" as a
|
||||
// last-ditch fallback so callers see a clear "command not found" rather than an
|
||||
// empty argv.
|
||||
var droidBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "droid",
|
||||
Names: []string{"droid"},
|
||||
WinNames: []string{"droid.cmd", "droid.exe", "droid"},
|
||||
UnixPaths: []string{"/usr/local/bin/droid", "/opt/homebrew/bin/droid"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "droid"}, {".factory", "bin", "droid"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "droid.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "droid.exe"}},
|
||||
{Base: binaryutil.WinHome, Parts: []string{".local", "bin", "droid.exe"}},
|
||||
{Base: binaryutil.WinHome, Parts: []string{".factory", "bin", "droid.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// ResolveDroidBinary returns the path to the droid binary, or a wrapped
|
||||
// ports.ErrAgentBinaryNotFound when it is absent.
|
||||
func ResolveDroidBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"droid.cmd", "droid.exe", "droid"} {
|
||||
if path, err := exec.LookPath(name); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "droid.cmd"),
|
||||
filepath.Join(appData, "npm", "droid.exe"),
|
||||
)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "droid.exe"),
|
||||
filepath.Join(home, ".factory", "bin", "droid.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("droid: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("droid"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/droid",
|
||||
"/opt/homebrew/bin/droid",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "droid"),
|
||||
filepath.Join(home, ".factory", "bin", "droid"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("droid: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, droidBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) droidBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -338,21 +258,3 @@ func (p *Plugin) droidBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
// Empty or unrecognized: defer to Droid's own settings (no flag).
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,8 +180,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "droid-ses-1",
|
||||
droidTitleMetadataKey: "Fix login redirect",
|
||||
droidSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -164,15 +144,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[gooseTitleMetadataKey],
|
||||
Summary: session.Metadata[gooseSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// systemPromptText returns the system instructions to inject. Goose's `--system`
|
||||
|
|
@ -228,7 +201,7 @@ func gooseModeEnvPrefix(mode ports.PermissionMode) []string {
|
|||
// - bypass-permissions → auto: Goose's fully-autonomous mode is the nearest
|
||||
// equivalent to bypass.
|
||||
func gooseMode(mode ports.PermissionMode) string {
|
||||
switch normalizePermissionMode(mode) {
|
||||
switch ports.NormalizePermissionMode(mode) {
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
return "smart_approve"
|
||||
case ports.PermissionModeAuto:
|
||||
|
|
@ -240,90 +213,26 @@ func gooseMode(mode ports.PermissionMode) string {
|
|||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
// Empty or unrecognized: defer to Goose's own config (no env).
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
// gooseBinarySpec locates the goose binary: PATH first, then the install
|
||||
// script's ~/.local/bin, Homebrew, Cargo, and npm global locations.
|
||||
var gooseBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "goose",
|
||||
Names: []string{"goose"},
|
||||
WinNames: []string{"goose.cmd", "goose.exe", "goose"},
|
||||
UnixPaths: []string{"/usr/local/bin/goose", "/opt/homebrew/bin/goose"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "goose"}, {".cargo", "bin", "goose"}, {".npm", "bin", "goose"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "goose.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "goose.exe"}},
|
||||
{Base: binaryutil.WinLocalAppData, Parts: []string{"Programs", "goose", "goose.exe"}},
|
||||
{Base: binaryutil.WinHome, Parts: []string{".cargo", "bin", "goose.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// ResolveGooseBinary returns the path to the goose binary on this machine,
|
||||
// searching PATH then a handful of well-known install locations (the install
|
||||
// script's ~/.local/bin, Homebrew, Cargo, npm global). Returns "goose" as a
|
||||
// last-ditch fallback so callers see a clear "command not found" rather than an
|
||||
// empty argv.
|
||||
// ResolveGooseBinary returns the path to the goose binary, or a wrapped
|
||||
// ports.ErrAgentBinaryNotFound when it is absent.
|
||||
func ResolveGooseBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"goose.cmd", "goose.exe", "goose"} {
|
||||
if path, err := exec.LookPath(name); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "goose.cmd"),
|
||||
filepath.Join(appData, "npm", "goose.exe"),
|
||||
)
|
||||
}
|
||||
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
|
||||
candidates = append(candidates, filepath.Join(localAppData, "Programs", "goose", "goose.exe"))
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "goose.exe"))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("goose: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("goose"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/goose",
|
||||
"/opt/homebrew/bin/goose",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "goose"),
|
||||
filepath.Join(home, ".cargo", "bin", "goose"),
|
||||
filepath.Join(home, ".npm", "bin", "goose"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("goose: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, gooseBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) gooseBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -341,8 +250,3 @@ func (p *Plugin) gooseBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -141,7 +142,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "goose"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
|
|
@ -153,7 +154,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "goose"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
|
|
@ -421,8 +422,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "thread-123",
|
||||
gooseTitleMetadataKey: "Fix login redirect",
|
||||
gooseSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
|
|
@ -513,7 +514,13 @@ func containsSubsequence(values []string, needle []string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func countGooseHookCommand(entries []gooseMatcherGroup, command string) int {
|
||||
// gooseHookFile is the on-disk shape of the hooks file, used to decode and
|
||||
// assert on what GetAgentHooks wrote.
|
||||
type gooseHookFile struct {
|
||||
Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"`
|
||||
}
|
||||
|
||||
func countGooseHookCommand(entries []hooksjson.MatcherGroup, command string) int {
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
for _, hook := range entry.Hooks {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,13 +25,12 @@ 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/ports"
|
||||
)
|
||||
|
||||
|
|
@ -39,18 +38,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
|
||||
}
|
||||
|
|
@ -76,16 +69,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:
|
||||
//
|
||||
|
|
@ -118,15 +101,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 [--agent <ao-agent>] --session <agentSessionId>`.
|
||||
// It re-applies the permission env and per-session AO agent prompt (resume
|
||||
|
|
@ -167,15 +141,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
|
||||
|
|
@ -191,15 +158,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:
|
||||
|
|
@ -296,81 +263,22 @@ func kilocodeAOAgentName(sessionID string) string {
|
|||
return "ao-" + name
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -388,8 +296,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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -119,7 +120,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 {
|
||||
|
|
@ -131,7 +132,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 {
|
||||
|
|
@ -402,8 +403,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",
|
||||
},
|
||||
})
|
||||
|
|
@ -460,9 +461,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -15,32 +15,27 @@
|
|||
// 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"
|
||||
"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"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -66,14 +61,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 [--agent ao] --no-interactive [trust flags] -- <prompt>`.
|
||||
//
|
||||
|
|
@ -105,15 +92,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 [--agent ao] --no-interactive --resume-id <agentSessionId> [trust flags]`.
|
||||
// ok is false when the hook-derived native session id has not landed yet, so
|
||||
|
|
@ -151,15 +129,24 @@ 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"}},
|
||||
},
|
||||
}
|
||||
|
||||
const kiroPromptAgentName = "ao"
|
||||
|
|
@ -205,79 +192,10 @@ func setKiroRaw(values map[string]json.RawMessage, key string, value any) error
|
|||
}
|
||||
|
||||
// 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) {
|
||||
|
|
@ -301,7 +219,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:
|
||||
|
|
@ -312,20 +230,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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
@ -187,7 +186,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 {
|
||||
|
|
@ -199,7 +198,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 {
|
||||
|
|
@ -471,8 +470,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",
|
||||
},
|
||||
})
|
||||
|
|
@ -514,29 +513,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
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ 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"
|
||||
|
||||
|
|
@ -40,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
|
||||
}
|
||||
|
|
@ -77,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:
|
||||
//
|
||||
|
|
@ -119,15 +111,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: `[env OPENCODE_CONFIG=<ao-config>] opencode [--dangerously-skip-permissions] [--agent <ao-agent>] --session <agentSessionId>`.
|
||||
// It re-applies the permission flag and the generated AO agent config (resume
|
||||
|
|
@ -169,15 +152,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[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
|
||||
|
|
@ -368,7 +344,7 @@ 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -448,19 +424,6 @@ func opencodeAOAgentName(sessionID string) string {
|
|||
return "ao-" + name
|
||||
}
|
||||
|
||||
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).
|
||||
|
|
@ -483,7 +446,7 @@ func ResolveOpenCodeBinary(ctx context.Context) (string, error) {
|
|||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
if hookutil.FileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -506,7 +469,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 {
|
||||
|
|
@ -532,8 +495,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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -390,7 +390,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 {
|
||||
|
|
@ -402,7 +402,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 {
|
||||
|
|
@ -686,8 +686,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",
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -158,77 +133,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) {
|
||||
|
|
@ -246,8 +167,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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,24 +17,19 @@ 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 +55,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
|
||||
|
|
@ -96,15 +83,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
|
||||
|
|
@ -168,83 +146,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) {
|
||||
|
|
@ -267,7 +188,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:
|
||||
|
|
@ -278,20 +199,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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
@ -126,7 +126,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 {
|
||||
|
|
@ -138,7 +138,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 {
|
||||
|
|
@ -177,6 +177,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()
|
||||
|
|
@ -240,7 +244,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 {
|
||||
|
|
@ -399,8 +403,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",
|
||||
},
|
||||
})
|
||||
|
|
@ -442,29 +446,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 {
|
||||
|
|
@ -498,7 +479,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 {
|
||||
|
|
|
|||
|
|
@ -28,14 +28,14 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"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"
|
||||
)
|
||||
|
|
@ -45,6 +45,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
|
||||
}
|
||||
|
|
@ -70,14 +71,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-or-ao-agent>] [--auto-approve] -p <prompt>
|
||||
|
|
@ -116,21 +109,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.
|
||||
|
|
@ -163,15 +141,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)
|
||||
|
|
@ -182,7 +153,7 @@ func appendWorkdirFlag(cmd *[]string, workspacePath string) {
|
|||
// profiles. PermissionModeDefault (and the empty mode) emit no flag so Vibe
|
||||
// resolves its starting agent from the user's `default_agent` config.
|
||||
func appendAgentFlags(cmd *[]string, mode ports.PermissionMode) {
|
||||
switch mode {
|
||||
switch ports.NormalizePermissionMode(mode) {
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
*cmd = append(*cmd, "--agent", "accept-edits")
|
||||
case ports.PermissionModeAuto:
|
||||
|
|
@ -193,7 +164,7 @@ func appendAgentFlags(cmd *[]string, mode ports.PermissionMode) {
|
|||
}
|
||||
|
||||
func appendCustomAgentApprovalFlags(cmd *[]string, mode ports.PermissionMode) {
|
||||
switch mode {
|
||||
switch ports.NormalizePermissionMode(mode) {
|
||||
case ports.PermissionModeAuto, ports.PermissionModeBypassPermissions:
|
||||
*cmd = append(*cmd, "--auto-approve")
|
||||
}
|
||||
|
|
@ -256,70 +227,22 @@ func vibeAgentTOML(agentName string, mode ports.PermissionMode) string {
|
|||
return b.String()
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -337,8 +260,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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type agentListOptions struct {
|
||||
refresh bool
|
||||
json bool
|
||||
}
|
||||
|
||||
// agentInfo mirrors the daemon's agent Info body for the CLI client.
|
||||
type agentInfo struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
AuthStatus string `json:"authStatus,omitempty"`
|
||||
}
|
||||
|
||||
// agentInventory mirrors GET /api/v1/agents and POST /api/v1/agents/refresh.
|
||||
type agentInventory struct {
|
||||
Supported []agentInfo `json:"supported"`
|
||||
Installed []agentInfo `json:"installed"`
|
||||
Authorized []agentInfo `json:"authorized"`
|
||||
}
|
||||
|
||||
func newAgentCommand(ctx *commandContext) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "Inspect agent catalog readiness",
|
||||
}
|
||||
cmd.AddCommand(newAgentListCommand(ctx))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAgentListCommand(ctx *commandContext) *cobra.Command {
|
||||
var opts agentListOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List supported agents and local auth readiness",
|
||||
Args: noArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
inv, err := ctx.fetchAgentInventory(cmd.Context(), opts.refresh)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.json {
|
||||
return writeJSON(cmd.OutOrStdout(), inv)
|
||||
}
|
||||
return writeAgentList(cmd, inv)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.refresh, "refresh", false, "Refresh local install/auth probes before listing")
|
||||
cmd.Flags().BoolVar(&opts.json, "json", false, "Output raw agent catalog JSON")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func writeAgentList(cmd *cobra.Command, inv agentInventory) error {
|
||||
out := cmd.OutOrStdout()
|
||||
if len(inv.Supported) == 0 {
|
||||
_, err := fmt.Fprintln(out, "No agents supported by this daemon.")
|
||||
return err
|
||||
}
|
||||
|
||||
sort.Slice(inv.Supported, func(i, j int) bool {
|
||||
return inv.Supported[i].ID < inv.Supported[j].ID
|
||||
})
|
||||
installed := agentInfoByID(inv.Installed)
|
||||
authorized := agentInfoByID(inv.Authorized)
|
||||
|
||||
tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
if _, err := fmt.Fprintln(tw, "ID\tLABEL\tINSTALL\tAUTH"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, info := range inv.Supported {
|
||||
installLabel := "needs install"
|
||||
authLabel := "auth unknown"
|
||||
if installedInfo, ok := installed[info.ID]; ok {
|
||||
installLabel = "installed"
|
||||
switch installedInfo.AuthStatus {
|
||||
case "authorized":
|
||||
authLabel = "authorized"
|
||||
case "unauthorized":
|
||||
authLabel = "needs auth"
|
||||
default:
|
||||
authLabel = "auth unknown"
|
||||
}
|
||||
}
|
||||
if _, ok := authorized[info.ID]; ok {
|
||||
installLabel = "installed"
|
||||
authLabel = "authorized"
|
||||
}
|
||||
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", info.ID, info.Label, installLabel, authLabel); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func agentInfoByID(infos []agentInfo) map[string]agentInfo {
|
||||
out := make(map[string]agentInfo, len(infos))
|
||||
for _, info := range infos {
|
||||
out[info.ID] = info
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentListUsesCachedCatalogByDefault(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/agents" {
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "agent", "ls")
|
||||
if err != nil {
|
||||
t.Fatalf("agent ls failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(out, "codex") || !strings.Contains(out, "needs install") {
|
||||
t.Fatalf("output missing table labels:\n%s", out)
|
||||
}
|
||||
want := []string{"GET /api/v1/agents"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentListRefreshAndStatuses(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh" {
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"aider","label":"Aider"},{"id":"codex","label":"Codex"},{"id":"goose","label":"Goose"},{"id":"opencode","label":"OpenCode"}],"installed":[{"id":"aider","label":"Aider","authStatus":"unauthorized"},{"id":"codex","label":"Codex","authStatus":"authorized"},{"id":"goose","label":"Goose","authStatus":"unknown"}],"authorized":[{"id":"codex","label":"Codex","authStatus":"authorized"}]}`)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "agent", "ls", "--refresh")
|
||||
if err != nil {
|
||||
t.Fatalf("agent ls --refresh failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
for _, want := range []string{"codex", "authorized", "aider", "needs auth", "goose", "auth unknown", "opencode", "needs install"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
want := []string{"POST /api/v1/agents/refresh"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentListJSONEmitsRawCatalog(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/agents" {
|
||||
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "agent", "ls", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("agent ls --json failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
var inv agentInventory
|
||||
if err := json.Unmarshal([]byte(out), &inv); err != nil {
|
||||
t.Fatalf("json output did not decode: %v\n%s", err, out)
|
||||
}
|
||||
if len(inv.Supported) != 1 || len(inv.Installed) != 1 || len(inv.Authorized) != 1 {
|
||||
t.Fatalf("inventory = %#v", inv)
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,18 @@ type apiError struct {
|
|||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
type apiResponseError struct {
|
||||
StatusCode int
|
||||
ErrorBody apiError
|
||||
}
|
||||
|
||||
func (e apiResponseError) Error() string {
|
||||
if e.ErrorBody.Message == "" {
|
||||
return fmt.Sprintf("daemon returned HTTP %d", e.StatusCode)
|
||||
}
|
||||
return e.ErrorBody.String()
|
||||
}
|
||||
|
||||
// String renders the envelope for the user: "<message> (<code>) [request <id>]",
|
||||
// omitting whichever parts the daemon left empty.
|
||||
func (e apiError) String() string {
|
||||
|
|
@ -128,10 +140,7 @@ func (c *commandContext) doJSONPath(ctx context.Context, method, path string, bo
|
|||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
var e apiError
|
||||
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||
if e.Message == "" {
|
||||
return fmt.Errorf("daemon returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("%s", e.String())
|
||||
return apiResponseError{StatusCode: resp.StatusCode, ErrorBody: e}
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import (
|
|||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
||||
agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent"
|
||||
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
||||
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
|
||||
)
|
||||
|
|
@ -106,6 +107,32 @@ func (f *fakeSessionService) ClaimPR(context.Context, domain.SessionID, string,
|
|||
return sessionsvc.ClaimPRResult{}, nil
|
||||
}
|
||||
|
||||
type fakeAgentCatalog struct{}
|
||||
|
||||
var _ controllers.AgentCatalog = (*fakeAgentCatalog)(nil)
|
||||
|
||||
func (f *fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) {
|
||||
return authorizedCodexInventory(), nil
|
||||
}
|
||||
|
||||
func (f *fakeAgentCatalog) Refresh(context.Context) (agentsvc.Inventory, error) {
|
||||
return authorizedCodexInventory(), nil
|
||||
}
|
||||
|
||||
func (f *fakeAgentCatalog) Probe(_ context.Context, agentID string) (agentsvc.ProbeResult, error) {
|
||||
info := agentsvc.Info{ID: agentID, Label: agentID, AuthStatus: "authorized"}
|
||||
return agentsvc.ProbeResult{Agent: info, Supported: true, Installed: true}, nil
|
||||
}
|
||||
|
||||
func authorizedCodexInventory() agentsvc.Inventory {
|
||||
info := agentsvc.Info{ID: "codex", Label: "Codex", AuthStatus: "authorized"}
|
||||
return agentsvc.Inventory{
|
||||
Supported: []agentsvc.Info{info},
|
||||
Installed: []agentsvc.Info{info},
|
||||
Authorized: []agentsvc.Info{info},
|
||||
}
|
||||
}
|
||||
|
||||
// fakeProjectManager captures the project.AddInput the controller decodes from
|
||||
// the CLI's request body. Every other method is a no-op so it satisfies the
|
||||
// projectsvc.Manager interface.
|
||||
|
|
@ -119,8 +146,9 @@ func (f *fakeProjectManager) List(context.Context) ([]projectsvc.Summary, error)
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeProjectManager) Get(context.Context, domain.ProjectID) (projectsvc.GetResult, error) {
|
||||
return projectsvc.GetResult{}, nil
|
||||
func (f *fakeProjectManager) Get(_ context.Context, id domain.ProjectID) (projectsvc.GetResult, error) {
|
||||
project := projectsvc.Project{ID: id, Path: "/repo/" + string(id)}
|
||||
return projectsvc.GetResult{Status: "ok", Project: &project}, nil
|
||||
}
|
||||
|
||||
func (f *fakeProjectManager) Add(_ context.Context, in projectsvc.AddInput) (projectsvc.Project, error) {
|
||||
|
|
@ -150,6 +178,7 @@ func startDriftTestDaemon(t *testing.T, sessions controllers.SessionService, pro
|
|||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router := httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{
|
||||
Agents: &fakeAgentCatalog{},
|
||||
Sessions: sessions,
|
||||
Projects: projects,
|
||||
}, httpd.ControlDeps{})
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ func NewRootCommand(deps Deps) *cobra.Command {
|
|||
root.AddCommand(newStopCommand(ctx))
|
||||
root.AddCommand(newStatusCommand(ctx))
|
||||
root.AddCommand(newDoctorCommand(ctx))
|
||||
root.AddCommand(newAgentCommand(ctx))
|
||||
root.AddCommand(newSpawnCommand(ctx))
|
||||
root.AddCommand(newSendCommand(ctx))
|
||||
root.AddCommand(newPreviewCommand(ctx))
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@ package cli
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
|
|
@ -19,14 +24,15 @@ import (
|
|||
const maxDisplayNameLen = 20
|
||||
|
||||
type spawnOptions struct {
|
||||
project string
|
||||
harness string
|
||||
branch string
|
||||
prompt string
|
||||
issue string
|
||||
name string
|
||||
claimPR string
|
||||
noTakeover bool
|
||||
project string
|
||||
harness string
|
||||
branch string
|
||||
prompt string
|
||||
issue string
|
||||
name string
|
||||
claimPR string
|
||||
noTakeover bool
|
||||
skipAgentCheck bool
|
||||
}
|
||||
|
||||
// spawnRequest mirrors the daemon's SpawnSessionRequest body for
|
||||
|
|
@ -37,7 +43,7 @@ type spawnRequest struct {
|
|||
Harness string `json:"harness,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
DisplayName string `json:"displayName"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
}
|
||||
|
||||
type spawnResult struct {
|
||||
|
|
@ -47,6 +53,12 @@ type spawnResult struct {
|
|||
} `json:"session"`
|
||||
}
|
||||
|
||||
type agentProbeResult struct {
|
||||
Agent agentInfo `json:"agent"`
|
||||
Supported bool `json:"supported"`
|
||||
Installed bool `json:"installed"`
|
||||
}
|
||||
|
||||
func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
||||
var opts spawnOptions
|
||||
cmd := &cobra.Command{
|
||||
|
|
@ -57,25 +69,33 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
|||
"fresh git worktree. Register the project first with `ao project add`.",
|
||||
Args: noArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if opts.project == "" {
|
||||
return usageError{fmt.Errorf("--project is required")}
|
||||
}
|
||||
name := strings.TrimSpace(opts.name)
|
||||
if name == "" {
|
||||
return usageError{fmt.Errorf("--name is required")}
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxDisplayNameLen {
|
||||
return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)}
|
||||
}
|
||||
if opts.noTakeover && opts.claimPR == "" {
|
||||
return usageError{fmt.Errorf("--no-takeover requires --claim-pr")}
|
||||
}
|
||||
claimRef := ""
|
||||
if opts.claimPR != "" {
|
||||
project, err := ctx.fetchProjectDetails(cmd.Context(), opts.project)
|
||||
if err != nil {
|
||||
if explicitName := strings.TrimSpace(opts.name); utf8.RuneCountInString(explicitName) > maxDisplayNameLen {
|
||||
return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)}
|
||||
}
|
||||
|
||||
project, err := ctx.resolveSpawnProject(cmd.Context(), opts.project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.project = project.ID
|
||||
|
||||
harness, err := resolveSpawnHarness(opts.harness, project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.harness = harness
|
||||
|
||||
name := resolveSpawnDisplayName(opts.name, opts.prompt)
|
||||
if !opts.skipAgentCheck {
|
||||
if err := ctx.preflightSpawnAgentAuth(cmd.Context(), cmd, opts.harness); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
claimRef := ""
|
||||
if opts.claimPR != "" {
|
||||
claimRef, err = ctx.resolvePRRef(cmd.Context(), opts.claimPR, project)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -123,7 +143,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
|||
} else {
|
||||
attach = "Attach from the AO dashboard (ConPTY sessions have no CLI attach command)"
|
||||
}
|
||||
_, err := fmt.Fprintf(out, "attach with: %s\n", attach)
|
||||
_, err = fmt.Fprintf(out, "attach with: %s\n", attach)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
|
@ -136,17 +156,274 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
|||
}
|
||||
return pflag.NormalizedName(name)
|
||||
})
|
||||
f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)")
|
||||
f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (default: AO_PROJECT_ID or current registered repo)")
|
||||
f.StringVar(&opts.harness, "harness", "", "Agent harness / --agent: claude-code, codex, aider, opencode, grok, droid, amp, agy, crush, cursor, qwen, copilot, goose, auggie, continue, devin, cline, kimi, kiro, kilocode, vibe, pi, autohand (default: project worker.agent; required if the project has none)")
|
||||
f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>/root)")
|
||||
f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent")
|
||||
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")
|
||||
f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (required, max 20 characters)")
|
||||
f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (default: derived from --prompt, max 20 characters)")
|
||||
f.StringVar(&opts.claimPR, "claim-pr", "", "Immediately claim an existing PR for the spawned session")
|
||||
f.BoolVar(&opts.noTakeover, "no-takeover", false, "Refuse if another active session owns the claimed PR (requires --claim-pr)")
|
||||
f.BoolVar(&opts.skipAgentCheck, "skip-agent-check", false, "Skip advisory agent catalog install/auth preflight before spawning")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (c *commandContext) fetchAgentInventory(ctx context.Context, refresh bool) (agentInventory, error) {
|
||||
var inv agentInventory
|
||||
if refresh {
|
||||
if err := c.postJSON(ctx, "agents/refresh", struct{}{}, &inv); err != nil {
|
||||
return agentInventory{}, err
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
if err := c.getJSON(ctx, "agents", &inv); err != nil {
|
||||
return agentInventory{}, err
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
func (c *commandContext) resolveSpawnProject(ctx context.Context, explicit string) (projectDetails, error) {
|
||||
if id := strings.TrimSpace(explicit); id != "" {
|
||||
return c.fetchProjectDetails(ctx, id)
|
||||
}
|
||||
if id := strings.TrimSpace(os.Getenv("AO_PROJECT_ID")); id != "" {
|
||||
return c.fetchProjectDetails(ctx, id)
|
||||
}
|
||||
if sessionID := strings.TrimSpace(os.Getenv("AO_SESSION_ID")); sessionID != "" {
|
||||
project, err := c.resolveProjectFromSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return projectDetails{}, err
|
||||
}
|
||||
return project, nil
|
||||
}
|
||||
project, ok, err := c.resolveProjectFromCWD(ctx)
|
||||
if err != nil {
|
||||
return projectDetails{}, err
|
||||
}
|
||||
if ok {
|
||||
return project, nil
|
||||
}
|
||||
return projectDetails{}, usageError{fmt.Errorf("project could not be resolved; pass --project or run `ao project add --path <repo-path> --worker-agent <agent>`")}
|
||||
}
|
||||
|
||||
func (c *commandContext) resolveProjectFromSession(ctx context.Context, sessionID string) (projectDetails, error) {
|
||||
sess, err := c.fetchScopedSession(ctx, sessionID, "")
|
||||
if err != nil {
|
||||
return projectDetails{}, usageError{fmt.Errorf("project could not be resolved from AO_SESSION_ID %q; pass --project", sessionID)}
|
||||
}
|
||||
if strings.TrimSpace(sess.ProjectID) == "" {
|
||||
return projectDetails{}, usageError{fmt.Errorf("project could not be resolved from AO_SESSION_ID %q; pass --project", sessionID)}
|
||||
}
|
||||
return c.fetchProjectDetails(ctx, sess.ProjectID)
|
||||
}
|
||||
|
||||
func (c *commandContext) resolveProjectFromCWD(ctx context.Context) (projectDetails, bool, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return projectDetails{}, false, err
|
||||
}
|
||||
cwd, err = normalizeProjectMatchPath(cwd)
|
||||
if err != nil {
|
||||
return projectDetails{}, false, err
|
||||
}
|
||||
|
||||
var list projectListResult
|
||||
if err := c.getJSON(ctx, "projects", &list); err != nil {
|
||||
return projectDetails{}, false, err
|
||||
}
|
||||
sort.Slice(list.Projects, func(i, j int) bool {
|
||||
return list.Projects[i].ID < list.Projects[j].ID
|
||||
})
|
||||
|
||||
var best projectDetails
|
||||
bestLen := -1
|
||||
ambiguous := false
|
||||
for _, summary := range list.Projects {
|
||||
project, err := c.fetchProjectDetails(ctx, summary.ID)
|
||||
if err != nil {
|
||||
return projectDetails{}, false, err
|
||||
}
|
||||
if project.Path == "" {
|
||||
continue
|
||||
}
|
||||
projectPath, err := normalizeProjectMatchPath(project.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !pathContains(projectPath, cwd) {
|
||||
continue
|
||||
}
|
||||
pathLen := len(projectPath)
|
||||
switch {
|
||||
case pathLen > bestLen:
|
||||
best = project
|
||||
bestLen = pathLen
|
||||
ambiguous = false
|
||||
case pathLen == bestLen:
|
||||
ambiguous = true
|
||||
}
|
||||
}
|
||||
if bestLen == -1 {
|
||||
return projectDetails{}, false, nil
|
||||
}
|
||||
if ambiguous {
|
||||
return projectDetails{}, false, usageError{fmt.Errorf("current directory matches multiple registered projects; pass --project")}
|
||||
}
|
||||
return best, true, nil
|
||||
}
|
||||
|
||||
func normalizeProjectMatchPath(path string) (string, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if realPath, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
abs = realPath
|
||||
}
|
||||
return filepath.Clean(abs), nil
|
||||
}
|
||||
|
||||
func pathContains(root, child string) bool {
|
||||
if root == child {
|
||||
return true
|
||||
}
|
||||
rel, err := filepath.Rel(root, child)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func resolveSpawnHarness(explicit string, project projectDetails) (string, error) {
|
||||
if harness := strings.TrimSpace(explicit); harness != "" {
|
||||
return harness, nil
|
||||
}
|
||||
if project.Config != nil {
|
||||
if harness := strings.TrimSpace(project.Config.Worker.Agent); harness != "" {
|
||||
return harness, nil
|
||||
}
|
||||
}
|
||||
return "", usageError{fmt.Errorf("agent could not be resolved; pass --agent or configure `ao project set-config %s --worker-agent <agent>`", project.ID)}
|
||||
}
|
||||
|
||||
func resolveSpawnDisplayName(explicit, prompt string) string {
|
||||
if name := strings.TrimSpace(explicit); name != "" {
|
||||
return name
|
||||
}
|
||||
return deriveDisplayNameFromPrompt(prompt)
|
||||
}
|
||||
|
||||
func deriveDisplayNameFromPrompt(prompt string) string {
|
||||
fields := strings.Fields(strings.TrimSpace(prompt))
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, field := range fields {
|
||||
next := strings.Trim(field, " \t\r\n.,;:!?()[]{}\"'")
|
||||
if next == "" {
|
||||
continue
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
next = " " + next
|
||||
}
|
||||
if utf8.RuneCountInString(b.String()+next) > maxDisplayNameLen {
|
||||
break
|
||||
}
|
||||
b.WriteString(next)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (c *commandContext) preflightSpawnAgentAuth(ctx context.Context, cmd *cobra.Command, agentID string) error {
|
||||
inv, err := c.fetchAgentInventory(ctx, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state := agentCatalogStateFor(inv, agentID)
|
||||
if !state.supported {
|
||||
return fmt.Errorf("agent %q is not supported by this daemon; pass a supported --agent or run `ao agent ls`", agentID)
|
||||
}
|
||||
if !state.installed || state.authStatus == "unauthorized" {
|
||||
fresh, err := c.probeSpawnAgent(ctx, agentID)
|
||||
if err != nil {
|
||||
if agentProbeUnavailable(err) {
|
||||
_, err = fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %q fresh readiness probe is unavailable; continuing and letting spawn validate runtime readiness\n", agentID)
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !fresh.Supported {
|
||||
return fmt.Errorf("agent %q is not supported by this daemon; pass a supported --agent or run `ao agent ls`", agentID)
|
||||
}
|
||||
if !fresh.Installed {
|
||||
return fmt.Errorf("agent %q needs install; install the agent CLI or pass --skip-agent-check to let spawn validate it", agentID)
|
||||
}
|
||||
state.installed = true
|
||||
state.authorized = fresh.Agent.AuthStatus == "authorized"
|
||||
state.authStatus = fresh.Agent.AuthStatus
|
||||
}
|
||||
if state.authorized {
|
||||
return nil
|
||||
}
|
||||
if state.authStatus == "unauthorized" {
|
||||
_, err = fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %q may need auth according to a fresh local probe; continuing and letting spawn validate runtime readiness\n", agentID)
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %q auth status is unknown; continuing and letting spawn validate runtime readiness\n", agentID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *commandContext) probeSpawnAgent(ctx context.Context, agentID string) (agentProbeResult, error) {
|
||||
var result agentProbeResult
|
||||
if err := c.postJSON(ctx, "agents/"+url.PathEscape(agentID)+"/probe", struct{}{}, &result); err != nil {
|
||||
return agentProbeResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func agentProbeUnavailable(err error) bool {
|
||||
var apiErr apiResponseError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return false
|
||||
}
|
||||
return apiErr.StatusCode == http.StatusNotFound || apiErr.StatusCode == http.StatusNotImplemented
|
||||
}
|
||||
|
||||
type agentCatalogState struct {
|
||||
supported bool
|
||||
installed bool
|
||||
authorized bool
|
||||
authStatus string
|
||||
}
|
||||
|
||||
func agentCatalogStateFor(inv agentInventory, agentID string) agentCatalogState {
|
||||
state := agentCatalogState{}
|
||||
for _, info := range inv.Supported {
|
||||
if info.ID == agentID {
|
||||
state.supported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, info := range inv.Authorized {
|
||||
if info.ID == agentID {
|
||||
state.installed = true
|
||||
state.authorized = true
|
||||
state.authStatus = "authorized"
|
||||
return state
|
||||
}
|
||||
}
|
||||
for _, info := range inv.Installed {
|
||||
if info.ID == agentID {
|
||||
state.installed = true
|
||||
state.authorized = info.AuthStatus == "authorized"
|
||||
state.authStatus = info.AuthStatus
|
||||
return state
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// rollbackSpawnedSession reverses a partial `spawn` whose out-of-band follow-up
|
||||
// (PR claim) failed. It calls the daemon's `/rollback` endpoint, which deletes
|
||||
// the seed-state row outright instead of marking it terminated — so the user
|
||||
|
|
|
|||
|
|
@ -6,23 +6,44 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSpawnCommand_RequiresProject asserts `ao spawn` rejects a missing
|
||||
// --project before touching the network, so it fails fast without a daemon.
|
||||
func TestSpawnCommand_RequiresProject(t *testing.T) {
|
||||
var out, errb bytes.Buffer
|
||||
root := NewRootCommand(Deps{Out: &out, Err: &errb})
|
||||
root.SetArgs([]string{"spawn"})
|
||||
err := root.Execute()
|
||||
func authorizedAgentsJSON(agent string) string {
|
||||
info := `{"id":` + jsonQuote(agent) + `,"label":` + jsonQuote(agent) + `,"authStatus":"authorized"}`
|
||||
return `{"supported":[` + info + `],"installed":[` + info + `],"authorized":[` + info + `]}`
|
||||
}
|
||||
|
||||
// TestSpawnCommand_MissingProjectContext asserts `ao spawn` gives a project
|
||||
// setup hint when neither --project, AO_PROJECT_ID, nor cwd can resolve one.
|
||||
func TestSpawnCommand_MissingProjectContext(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects" {
|
||||
_, _ = io.WriteString(w, `{"projects":[]}`)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when --project is missing")
|
||||
t.Fatal("expected an error when project context is missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--project is required") {
|
||||
t.Fatalf("error = %v, want it to mention --project is required", err)
|
||||
if !strings.Contains(err.Error(), "ao project add --path <repo-path> --worker-agent <agent>") {
|
||||
t.Fatalf("error = %v, want project add hint", err)
|
||||
}
|
||||
if want := []string{"GET /api/v1/projects"}; !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +71,8 @@ func TestSpawnClaimPRWiring(t *testing.T) {
|
|||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"https://github.com/aoagents/agent-orchestrator","defaultBranch":"main"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-9","status":"idle"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-9/pr/claim":
|
||||
|
|
@ -66,14 +89,14 @@ func TestSpawnClaimPRWiring(t *testing.T) {
|
|||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142", "--no-takeover")
|
||||
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker", "--claim-pr", "142", "--no-takeover")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn claim-pr failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(out, "claimed https://github.com/aoagents/agent-orchestrator/pull/142") {
|
||||
t.Fatalf("output missing claimed label: %s", out)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-9/pr/claim"}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-9/pr/claim"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
|
|
@ -89,6 +112,8 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
|
|||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"https://github.com/aoagents/agent-orchestrator","defaultBranch":"main"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
sessions["demo-10"] = true
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-10","status":"idle"}}`)
|
||||
|
|
@ -108,7 +133,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
|
|||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142")
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker", "--claim-pr", "142")
|
||||
if err == nil {
|
||||
t.Fatal("expected spawn claim failure")
|
||||
}
|
||||
|
|
@ -119,7 +144,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
|
|||
if sessions["demo-10"] {
|
||||
t.Fatalf("spawned session still present after claim rollback: %#v", sessions)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-10/pr/claim", "POST /api/v1/sessions/demo-10/rollback"}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-10/pr/claim", "POST /api/v1/sessions/demo-10/rollback"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
|
|
@ -132,15 +157,6 @@ func TestSpawnNoTakeoverRequiresClaimPR(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestSpawnCommand_RequiresName asserts `ao spawn` rejects a missing --name
|
||||
// before touching the network, mirroring the --project guard.
|
||||
func TestSpawnCommand_RequiresName(t *testing.T) {
|
||||
_, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo")
|
||||
if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "--name is required") {
|
||||
t.Fatalf("err=%v exit=%d, want --name is required", err, ExitCode(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpawnCommand_RejectsOverlongName asserts `ao spawn` rejects a --name
|
||||
// longer than 20 characters without contacting the daemon.
|
||||
func TestSpawnCommand_RejectsOverlongName(t *testing.T) {
|
||||
|
|
@ -149,3 +165,529 @@ func TestSpawnCommand_RejectsOverlongName(t *testing.T) {
|
|||
t.Fatalf("err=%v exit=%d, want 20 characters or fewer", err, ExitCode(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnResolvesProjectFromEnvAndDefaultAgent(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","config":{"worker":{"agent":"codex"}}}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-11","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
t.Setenv("AO_PROJECT_ID", "demo")
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix failing tests in auth")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(out, "spawned session demo-11") {
|
||||
t.Fatalf("output missing spawn: %s", out)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" || req.DisplayName != "Fix failing tests in" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnResolvesProjectFromAOSessionID(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/demo-1":
|
||||
_, _ = io.WriteString(w, `{"session":`+sessionJSON("demo-1", "demo", "worker", "idle", false)+`}`)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","config":{"worker":{"agent":"codex"}}}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-15","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
t.Setenv("AO_SESSION_ID", "demo-1")
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix tests")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/sessions/demo-1", "GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnAOSessionIDFailureRequiresProject(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/missing":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `{"error":"not_found","code":"SESSION_NOT_FOUND","message":"Session not found"}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
t.Setenv("AO_SESSION_ID", "missing")
|
||||
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex")
|
||||
if err == nil || !strings.Contains(err.Error(), `project could not be resolved from AO_SESSION_ID "missing"; pass --project`) {
|
||||
t.Fatalf("err=%v, want AO_SESSION_ID project error", err)
|
||||
}
|
||||
want := []string{"GET /api/v1/sessions/missing"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnResolvesProjectFromCWD(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
repo := filepath.Join(t.TempDir(), "repo")
|
||||
subdir := filepath.Join(repo, "pkg")
|
||||
if err := os.MkdirAll(subdir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(subdir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldwd) })
|
||||
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects":
|
||||
_, _ = io.WriteString(w, `{"projects":[{"id":"demo","name":"Demo"}]}`)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":`+jsonQuote(repo)+`,"config":{"worker":{"agent":"codex"}}}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix tests")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnStaleUnauthorizedAgentRefreshesProbesThenAllows(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unauthorized"}],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||
_, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex","authStatus":"authorized"},"supported":true,"installed":true}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if errOut != "" {
|
||||
t.Fatalf("stderr = %q, want no warning after fresh authorized probe", errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnFreshUnauthorizedWarnsAndAllows(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unauthorized"}],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||
_, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex","authStatus":"unauthorized"},"supported":true,"installed":true}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(errOut, "may need auth according to a fresh local probe") {
|
||||
t.Fatalf("stderr missing warning: %s", errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnUnavailableFreshProbeWarnsAndAllows(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unauthorized"}],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
_, _ = io.WriteString(w, `{"message":"not implemented","code":"NOT_IMPLEMENTED"}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(errOut, "fresh readiness probe is unavailable") {
|
||||
t.Fatalf("stderr missing warning: %s", errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnUnsupportedAgentRefreshesThenBlocks(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "unknown")
|
||||
if err == nil || !strings.Contains(err.Error(), "agent \"unknown\" is not supported") {
|
||||
t.Fatalf("err=%v, want unsupported", err)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnNotInstalledAgentRefreshesThenBlocks(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||
_, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex"},"supported":true,"installed":false}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err == nil || !strings.Contains(err.Error(), "agent \"codex\" needs install") {
|
||||
t.Fatalf("err=%v, want needs install", err)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnStaleNotInstalledFreshInstalledWarnsAndAllows(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||
_, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex","authStatus":"unknown"},"supported":true,"installed":true}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(errOut, "auth status is unknown") {
|
||||
t.Fatalf("stderr missing warning: %s", errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnUnavailableFreshProbeForNotInstalledWarnsAndAllows(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `{"message":"not found","code":"NOT_FOUND"}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(errOut, "fresh readiness probe is unavailable") {
|
||||
t.Fatalf("stderr missing warning: %s", errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnFreshProbeServerErrorBlocks(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, `{"message":"probe failed","code":"PROBE_FAILED","requestId":"req-1"}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err == nil || !strings.Contains(err.Error(), "probe failed (PROBE_FAILED) [request req-1]") {
|
||||
t.Fatalf("err=%v, want probe server error", err)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnSkipAgentCheckBypassesOnlyPreflight(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-14","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "unsupported", "--skip-agent-check")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "unsupported" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnUnknownAuthRefreshesWarnsAndAllows(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unknown"}],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-13","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(errOut, "auth status is unknown") {
|
||||
t.Fatalf("stderr missing warning: %s", errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,10 +133,16 @@ func Run() error {
|
|||
}
|
||||
lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, tracker, log)
|
||||
previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx)
|
||||
agentSvc := agentsvc.New()
|
||||
go func() {
|
||||
if _, err := agentSvc.Refresh(ctx); err != nil {
|
||||
log.Warn("initial agent catalog refresh failed", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{
|
||||
Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, DefaultHarness: domain.AgentHarness(cfg.Agent), Telemetry: telemetrySink}),
|
||||
Agents: agentsvc.New(),
|
||||
Agents: agentSvc,
|
||||
Sessions: sessionSvc,
|
||||
Reviews: reviewSvc,
|
||||
Notifications: notifier,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,45 @@ paths:
|
|||
summary: Return cached supported and locally installed agent adapters
|
||||
tags:
|
||||
- agents
|
||||
/api/v1/agents/{agent}/probe:
|
||||
post:
|
||||
operationId: probeAgent
|
||||
parameters:
|
||||
- description: Agent adapter identifier.
|
||||
in: path
|
||||
name: agent
|
||||
required: true
|
||||
schema:
|
||||
description: Agent adapter identifier.
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProbeAgentResponse'
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/APIError'
|
||||
description: Bad Request
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/APIError'
|
||||
description: Internal Server Error
|
||||
"501":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/APIError'
|
||||
description: Not Implemented
|
||||
summary: Run a fresh local readiness probe for one agent adapter
|
||||
tags:
|
||||
- agents
|
||||
/api/v1/agents/refresh:
|
||||
post:
|
||||
operationId: refreshAgents
|
||||
|
|
@ -1975,6 +2014,19 @@ components:
|
|||
- targetSha
|
||||
- status
|
||||
type: object
|
||||
ProbeAgentResponse:
|
||||
properties:
|
||||
agent:
|
||||
$ref: '#/components/schemas/AgentInfo'
|
||||
installed:
|
||||
type: boolean
|
||||
supported:
|
||||
type: boolean
|
||||
required:
|
||||
- agent
|
||||
- supported
|
||||
- installed
|
||||
type: object
|
||||
Project:
|
||||
properties:
|
||||
agent:
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ var schemaNames = map[string]string{
|
|||
// httpd/controllers (wire envelopes)
|
||||
"ControllersListProjectsResponse": "ListProjectsResponse",
|
||||
"ControllersProjectResponse": "ProjectResponse",
|
||||
"ControllersAgentIDParam": "AgentIDParam",
|
||||
"ControllersGetProjectResponse": "ProjectGetResponse",
|
||||
"ControllersProjectOrDegraded": "ProjectOrDegraded",
|
||||
"ControllersListSessionsQuery": "ListSessionsQuery",
|
||||
|
|
@ -174,6 +175,7 @@ var schemaNames = map[string]string{
|
|||
"ControllersOrchestratorResponse": "OrchestratorResponse",
|
||||
"AgentInventory": "ListAgentsResponse",
|
||||
"AgentInfo": "AgentInfo",
|
||||
"AgentProbeResult": "ProbeAgentResponse",
|
||||
"ControllersListNotificationsQuery": "ListNotificationsQuery",
|
||||
"ControllersNotificationStreamQuery": "NotificationStreamQuery",
|
||||
"ControllersNotificationIDParam": "NotificationIDParam",
|
||||
|
|
@ -313,6 +315,17 @@ func agentOperations() []operation {
|
|||
{http.StatusNotImplemented, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
method: http.MethodPost, path: "/api/v1/agents/{agent}/probe", id: "probeAgent", tag: "agents",
|
||||
summary: "Run a fresh local readiness probe for one agent adapter",
|
||||
pathParams: []any{controllers.AgentIDParam{}},
|
||||
resps: []respUnit{
|
||||
{http.StatusOK, controllers.ProbeAgentResponse{}},
|
||||
{http.StatusBadRequest, envelope.APIError{}},
|
||||
{http.StatusInternalServerError, envelope.APIError{}},
|
||||
{http.StatusNotImplemented, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package controllers
|
|||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ import (
|
|||
type AgentCatalog interface {
|
||||
List(ctx context.Context) (agentsvc.Inventory, error)
|
||||
Refresh(ctx context.Context) (agentsvc.Inventory, error)
|
||||
Probe(ctx context.Context, agentID string) (agentsvc.ProbeResult, error)
|
||||
}
|
||||
|
||||
// AgentsController owns the /agents routes.
|
||||
|
|
@ -26,6 +28,7 @@ type AgentsController struct {
|
|||
func (c *AgentsController) Register(r chi.Router) {
|
||||
r.Get("/agents", c.list)
|
||||
r.Post("/agents/refresh", c.refresh)
|
||||
r.Post("/agents/{agent}/probe", c.probe)
|
||||
}
|
||||
|
||||
func (c *AgentsController) list(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -53,3 +56,21 @@ func (c *AgentsController) refresh(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
envelope.WriteJSON(w, http.StatusOK, inventory)
|
||||
}
|
||||
|
||||
func (c *AgentsController) probe(w http.ResponseWriter, r *http.Request) {
|
||||
if c.Catalog == nil {
|
||||
apispec.NotImplemented(w, r, "POST", "/api/v1/agents/{agent}/probe")
|
||||
return
|
||||
}
|
||||
agentID := strings.TrimSpace(chi.URLParam(r, "agent"))
|
||||
if agentID == "" {
|
||||
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "AGENT_REQUIRED", "agent is required", nil)
|
||||
return
|
||||
}
|
||||
result, err := c.Catalog.Probe(r.Context(), agentID)
|
||||
if err != nil {
|
||||
envelope.WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,12 @@ import (
|
|||
type fakeAgentCatalog struct {
|
||||
inventory agentsvc.Inventory
|
||||
refreshed agentsvc.Inventory
|
||||
probed agentsvc.ProbeResult
|
||||
err error
|
||||
listCalls int
|
||||
refreshCalls int
|
||||
probeCalls int
|
||||
probeAgent string
|
||||
}
|
||||
|
||||
func (f *fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) {
|
||||
|
|
@ -35,6 +38,12 @@ func (f *fakeAgentCatalog) Refresh(context.Context) (agentsvc.Inventory, error)
|
|||
return f.inventory, f.err
|
||||
}
|
||||
|
||||
func (f *fakeAgentCatalog) Probe(_ context.Context, agentID string) (agentsvc.ProbeResult, error) {
|
||||
f.probeCalls++
|
||||
f.probeAgent = agentID
|
||||
return f.probed, f.err
|
||||
}
|
||||
|
||||
func TestListAgents(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
catalog := &fakeAgentCatalog{inventory: agentsvc.Inventory{
|
||||
|
|
@ -92,3 +101,31 @@ func TestRefreshAgents(t *testing.T) {
|
|||
t.Fatalf("calls: list=%d refresh=%d, want list=0 refresh=1", catalog.listCalls, catalog.refreshCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeAgent(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
catalog := &fakeAgentCatalog{
|
||||
probed: agentsvc.ProbeResult{
|
||||
Agent: agentsvc.Info{ID: "codex", Label: "Codex", AuthStatus: "authorized"},
|
||||
Supported: true,
|
||||
Installed: true,
|
||||
},
|
||||
}
|
||||
srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{
|
||||
Agents: catalog,
|
||||
}, httpd.ControlDeps{}))
|
||||
defer srv.Close()
|
||||
|
||||
body, status, _ := doRequest(t, srv, http.MethodPost, "/api/v1/agents/codex/probe", "")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("POST /agents/codex/probe = %d, body=%s", status, body)
|
||||
}
|
||||
for _, want := range []string{`"supported":true`, `"installed":true`, `"id":"codex"`, `"authStatus":"authorized"`} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Fatalf("body missing %s: %s", want, body)
|
||||
}
|
||||
}
|
||||
if catalog.probeCalls != 1 || catalog.probeAgent != "codex" {
|
||||
t.Fatalf("probe calls=%d agent=%q, want one codex probe", catalog.probeCalls, catalog.probeAgent)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ type ProjectIDParam struct {
|
|||
ID string `path:"id" description:"Project identifier (registry key)."`
|
||||
}
|
||||
|
||||
// AgentIDParam is the {agent} path parameter for one-agent catalog probes.
|
||||
type AgentIDParam struct {
|
||||
Agent string `path:"agent" description:"Agent adapter identifier."`
|
||||
}
|
||||
|
||||
// ListProjectsResponse is the body of GET /api/v1/projects.
|
||||
type ListProjectsResponse struct {
|
||||
Projects []projectsvc.Summary `json:"projects"`
|
||||
|
|
@ -442,6 +447,9 @@ type ListAgentsResponse = agentsvc.Inventory
|
|||
// RefreshAgentsResponse is the body of POST /api/v1/agents/refresh.
|
||||
type RefreshAgentsResponse = agentsvc.Inventory
|
||||
|
||||
// ProbeAgentResponse is the body of POST /api/v1/agents/{agent}/probe.
|
||||
type ProbeAgentResponse = agentsvc.ProbeResult
|
||||
|
||||
// AgentInfo is one supported or installed agent entry.
|
||||
type AgentInfo = agentsvc.Info
|
||||
|
||||
|
|
|
|||
|
|
@ -197,6 +197,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
|
||||
|
||||
|
|
|
|||
|
|
@ -280,6 +280,61 @@ func TestRefreshIsRateLimited(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProbeBypassesRefreshRateLimitForOneAgent(t *testing.T) {
|
||||
previous := agentRefreshMinInterval
|
||||
agentRefreshMinInterval = time.Hour
|
||||
t.Cleanup(func() { agentRefreshMinInterval = previous })
|
||||
|
||||
probes := 0
|
||||
svc := NewWithAgents([]agentregistry.HarnessAgent{
|
||||
{
|
||||
Harness: domain.AgentHarness("codex"),
|
||||
Manifest: adapters.Manifest{
|
||||
ID: "codex",
|
||||
Name: "Codex",
|
||||
},
|
||||
Agent: probeTrackingAgent{fakeAgent: fakeAgent{}, onProbe: func() { probes++ }},
|
||||
},
|
||||
harnessAgent("missing", "Missing", ports.ErrAgentBinaryNotFound),
|
||||
})
|
||||
|
||||
if _, err := svc.Refresh(context.Background()); err != nil {
|
||||
t.Fatalf("Refresh: %v", err)
|
||||
}
|
||||
got, err := svc.Probe(context.Background(), "codex")
|
||||
if err != nil {
|
||||
t.Fatalf("Probe: %v", err)
|
||||
}
|
||||
if !got.Supported || !got.Installed || got.Agent.ID != "codex" {
|
||||
t.Fatalf("Probe = %#v, want supported installed codex", got)
|
||||
}
|
||||
if probes != 2 {
|
||||
t.Fatalf("probes = %d, want refresh plus fresh probe", probes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeReportsUnsupportedAndMissingAgent(t *testing.T) {
|
||||
svc := NewWithAgents([]agentregistry.HarnessAgent{
|
||||
harnessAgent("missing", "Missing", ports.ErrAgentBinaryNotFound),
|
||||
})
|
||||
|
||||
missing, err := svc.Probe(context.Background(), "missing")
|
||||
if err != nil {
|
||||
t.Fatalf("Probe missing: %v", err)
|
||||
}
|
||||
if !missing.Supported || missing.Installed {
|
||||
t.Fatalf("Probe missing = %#v, want supported but not installed", missing)
|
||||
}
|
||||
|
||||
unsupported, err := svc.Probe(context.Background(), "unknown")
|
||||
if err != nil {
|
||||
t.Fatalf("Probe unknown: %v", err)
|
||||
}
|
||||
if unsupported.Supported || unsupported.Installed || unsupported.Agent.ID != "unknown" {
|
||||
t.Fatalf("Probe unknown = %#v, want unsupported unknown", unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
func harnessAgent(id, label string, err error) agentregistry.HarnessAgent {
|
||||
return agentregistry.HarnessAgent{
|
||||
Harness: domain.AgentHarness(id),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@ type probeResult struct {
|
|||
authorized bool
|
||||
}
|
||||
|
||||
// ProbeResult describes a fresh readiness probe for one supported agent.
|
||||
type ProbeResult struct {
|
||||
Agent Info `json:"agent"`
|
||||
Supported bool `json:"supported"`
|
||||
Installed bool `json:"installed"`
|
||||
}
|
||||
|
||||
// Info is the user-facing identity for an agent adapter.
|
||||
type Info struct {
|
||||
ID string `json:"id"`
|
||||
|
|
@ -140,6 +147,31 @@ func (s *Service) Refresh(ctx context.Context) (Inventory, error) {
|
|||
return next, nil
|
||||
}
|
||||
|
||||
// Probe runs a fresh bounded binary/auth probe for one agent, bypassing the
|
||||
// catalog refresh rate limit. It is intended for user-initiated preflight paths
|
||||
// where a cached negative catalog result may be stale.
|
||||
func (s *Service) Probe(ctx context.Context, agentID string) (ProbeResult, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ProbeResult{}, err
|
||||
}
|
||||
for _, item := range s.agents {
|
||||
info := Info{ID: string(item.Harness), Label: item.Manifest.Name}
|
||||
if info.Label == "" {
|
||||
info.Label = info.ID
|
||||
}
|
||||
if info.ID != agentID {
|
||||
continue
|
||||
}
|
||||
res := probeAgent(ctx, item)
|
||||
return ProbeResult{
|
||||
Agent: res.info,
|
||||
Supported: true,
|
||||
Installed: res.installed,
|
||||
}, nil
|
||||
}
|
||||
return ProbeResult{Agent: Info{ID: agentID}, Supported: false, Installed: false}, nil
|
||||
}
|
||||
|
||||
func supportedInfos(agents []agentregistry.HarnessAgent) []Info {
|
||||
supported := make([]Info, 0, len(agents))
|
||||
for _, item := range agents {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,18 @@ It starts, discovers, inspects, and stops the daemon through the loopback HTTP
|
|||
surface and the `running.json` handshake. It must not open SQLite directly or
|
||||
call runtime, workspace, tracker, or agent adapters in-process.
|
||||
|
||||
When using the CLI directly from a shell, make sure the daemon is running first
|
||||
with `ao start` or by opening the desktop app. Product commands such as
|
||||
`ao agent ls` and `ao spawn` call the loopback daemon and will fail with a
|
||||
"daemon is not running" error if no `running.json` points at a live process. From
|
||||
a source checkout, build and run the local binary explicitly, for example:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go build -o ./bin/ao ./cmd/ao
|
||||
./bin/ao agent ls
|
||||
```
|
||||
|
||||
## Current commands
|
||||
|
||||
Every product command resolves to a daemon HTTP route. Run `ao <command>
|
||||
|
|
@ -31,6 +43,8 @@ Every product command resolves to a daemon HTTP route. Run `ao <command>
|
|||
| `ao project get <id>` | `GET /api/v1/projects/{id}` |
|
||||
| `ao project set-config <id>` | `PUT /api/v1/projects/{id}/config` |
|
||||
| `ao project rm <id>` | `DELETE /api/v1/projects/{id}` |
|
||||
| `ao agent ls` | `GET /api/v1/agents` |
|
||||
| `ao agent ls --refresh` | `POST /api/v1/agents/refresh` |
|
||||
| `ao spawn` | `POST /api/v1/sessions` |
|
||||
| `ao session ls` | `GET /api/v1/sessions` |
|
||||
| `ao session get <id>` | `GET /api/v1/sessions/{id}` |
|
||||
|
|
@ -44,6 +58,23 @@ Every product command resolves to a daemon HTTP route. Run `ao <command>
|
|||
| `ao preview [url]` | `POST /api/v1/sessions/{id}/preview` |
|
||||
| `ao hooks <agent> <event>` | `POST /api/v1/sessions/{id}/activity` (hidden) |
|
||||
|
||||
`ao agent ls` prints the daemon-supported agent catalog with local install/auth
|
||||
readiness. Use `--refresh` to rerun the bounded local probes and `--json` to
|
||||
print the raw inventory response.
|
||||
|
||||
`ao spawn` resolves project context in this order: explicit `--project`,
|
||||
`AO_PROJECT_ID`, `AO_SESSION_ID` (by fetching the current session from the
|
||||
daemon), then the current working directory matched against registered project
|
||||
paths. If `AO_SESSION_ID` is set but the session cannot be fetched, pass
|
||||
`--project` explicitly.
|
||||
|
||||
If `--agent` / `--harness` is omitted, `ao spawn` uses the resolved project's
|
||||
`worker.agent` config. Before spawning, the CLI refreshes the advisory agent
|
||||
catalog and fails early when the selected agent is unsupported, not installed,
|
||||
or unauthorized. It warns-but-continues when auth remains unknown because daemon
|
||||
spawn remains the authoritative runtime validation point. Use
|
||||
`--skip-agent-check` to bypass only this CLI-side preflight.
|
||||
|
||||
`ao preview` resolves its session from the `AO_SESSION_ID` environment variable
|
||||
(it is meant to run inside a session), not a flag. With no argument it
|
||||
autodetects an `index.html` in the session workspace; with a URL argument it
|
||||
|
|
|
|||
|
|
@ -21,6 +21,23 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/agents/{agent}/probe": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Run a fresh local readiness probe for one agent adapter */
|
||||
post: operations["probeAgent"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/agents/refresh": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -708,6 +725,11 @@ export interface components {
|
|||
targetSha: string;
|
||||
title: string;
|
||||
};
|
||||
ProbeAgentResponse: {
|
||||
agent: components["schemas"]["AgentInfo"];
|
||||
installed: boolean;
|
||||
supported: boolean;
|
||||
};
|
||||
Project: {
|
||||
agent?: string;
|
||||
config?: components["schemas"]["ProjectConfig"];
|
||||
|
|
@ -1030,6 +1052,56 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
probeAgent: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Agent adapter identifier. */
|
||||
agent: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProbeAgentResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["APIError"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["APIError"];
|
||||
};
|
||||
};
|
||||
/** @description Not Implemented */
|
||||
501: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["APIError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
refreshAgents: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Reference in New Issue