diff --git a/backend/internal/adapters/agent/crush/activity.go b/backend/internal/adapters/agent/crush/activity.go new file mode 100644 index 0000000..d02dc2d --- /dev/null +++ b/backend/internal/adapters/agent/crush/activity.go @@ -0,0 +1,14 @@ +package crush + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// DeriveActivityState maps a Crush hook event onto an AO activity state. +// Currently a no-op since Crush doesn't have full hooks support like Claude Code and Codex. +// The bool is false to indicate no activity signal is available. +// +// TODO(crush): Implement activity state mapping once Crush has native hook support. +// Until then, runtime exit falls back to the reaper. +func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + // No-op for now since Crush doesn't have full hooks support + return "", false +} diff --git a/backend/internal/adapters/agent/crush/activity_test.go b/backend/internal/adapters/agent/crush/activity_test.go new file mode 100644 index 0000000..81f1595 --- /dev/null +++ b/backend/internal/adapters/agent/crush/activity_test.go @@ -0,0 +1,15 @@ +package crush + +import ( + "testing" +) + +func TestDeriveActivityStateReturnsFalse(t *testing.T) { + state, ok := DeriveActivityState("some-event", []byte("payload")) + if ok { + t.Fatalf("unexpected ok: got true, want false (DeriveActivityState is a no-op for Crush)") + } + if state != "" { + t.Fatalf("unexpected non-empty state: got %q", state) + } +} diff --git a/backend/internal/adapters/agent/crush/crush.go b/backend/internal/adapters/agent/crush/crush.go new file mode 100644 index 0000000..da01e75 --- /dev/null +++ b/backend/internal/adapters/agent/crush/crush.go @@ -0,0 +1,243 @@ +// Package crush implements the Crush agent adapter: launching new sessions, +// resuming sessions by native ID, and reading session info. +// +// Crush differs from other agents in that it doesn't have full hooks support, +// so GetAgentHooks and SessionInfo are no-ops for now. Session tracking is +// done through basic session ID management only. +package crush + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // adapterID is the registry id and the value users pass to + // `ao spawn --agent`. It matches domain.HarnessCrush. + adapterID = "crush" +) + +// Plugin is the Crush agent adapter. It is safe for concurrent use; the +// binary path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Crush adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Crush", + Description: "Run Crush worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Crush exposes none yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start an interactive Crush session. +// Shape: +// +// crush [--cwd ] [--yolo] [-- ] +// +// The session runs in the worktree (cwd is set by the runtime). Crush doesn't +// have native system prompt support, so cfg.SystemPrompt / SystemPromptFile are +// intentionally ignored. The initial task prompt is delivered as a positional +// argument after `--`. The --yolo flag corresponds to bypass-permissions mode. +// +// We intentionally do not pass --session on launch: cfg.SessionID is the +// AO-internal id, not a Crush-native session id. Letting Crush mint its own +// native session id (captured by hooks into session metadata) keeps launch +// consistent with GetRestoreCommand, which resumes using that native id. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.crushBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + + // Crush uses --cwd to set working directory + if cfg.WorkspacePath != "" { + cmd = append(cmd, "--cwd", cfg.WorkspacePath) + } + + // Handle permission modes + if cfg.Permissions == ports.PermissionModeBypassPermissions { + cmd = append(cmd, "--yolo") + } + + // Prompt is passed after `--` so a leading "-" is not read as a flag + if cfg.Prompt != "" { + cmd = append(cmd, "--", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Crush receives its prompt in the +// launch command itself as a positional argument. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetRestoreCommand rebuilds the argv that continues an existing Crush session: +// `crush [--cwd ] [--yolo] --session `. +// It re-applies the permission flag but not the prompt, which the session +// already carries. ok is false when the native session id is not available. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.crushBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = []string{binary} + + if cfg.Session.WorkspacePath != "" { + cmd = append(cmd, "--cwd", cfg.Session.WorkspacePath) + } + + if cfg.Permissions == ports.PermissionModeBypassPermissions { + cmd = append(cmd, "--yolo") + } + + cmd = append(cmd, "--session", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Crush session metadata. Currently a no-op since Crush +// doesn't have full hooks support like Claude Code and Codex. Returns false +// to indicate no metadata is available. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + // No-op for now since Crush doesn't have full hooks support + return ports.SessionInfo{}, false, nil +} + +// ResolveCrushBinary returns the path to the crush binary on this machine, +// searching PATH then a handful of well-known install locations. +// Returns "crush" as a last-ditch fallback. +func ResolveCrushBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"crush.cmd", "crush.exe", "crush"} { + path, err := exec.LookPath(name) + if err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "crush.cmd"), + filepath.Join(appData, "npm", "crush.exe"), + ) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "crush.exe")) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "crush", nil + } + + if path, err := exec.LookPath("crush"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/crush", + "/opt/homebrew/bin/crush", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "crush"), + filepath.Join(home, ".cargo", "bin", "crush"), + filepath.Join(home, ".npm", "bin", "crush"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "crush", nil +} + +func (p *Plugin) crushBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveCrushBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/crush/crush_test.go b/backend/internal/adapters/agent/crush/crush_test.go new file mode 100644 index 0000000..45756dc --- /dev/null +++ b/backend/internal/adapters/agent/crush/crush_test.go @@ -0,0 +1,263 @@ +package crush + +import ( + "context" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "crush"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "fix this", + WorkspacePath: "/tmp/workspace", + SessionID: "test-session-id", + }) + if err != nil { + t.Fatal(err) + } + + // cfg.SessionID is the AO-internal id and must NOT be passed as --session on + // launch; Crush mints its own native id, which GetRestoreCommand resumes by. + want := []string{ + "crush", + "--cwd", "/tmp/workspace", + "--yolo", + "--", "fix this", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + want []string + notExpected string + }{ + { + name: "default", + permission: ports.PermissionModeDefault, + notExpected: "--yolo", + }, + { + name: "accept-edits", + permission: ports.PermissionModeAcceptEdits, + want: nil, // Crush doesn't have granular permission modes + notExpected: "--yolo", + }, + { + name: "auto", + permission: ports.PermissionModeAuto, + want: nil, // Crush doesn't have granular permission modes + notExpected: "--yolo", + }, + { + name: "bypass-permissions", + permission: ports.PermissionModeBypassPermissions, + want: []string{"--yolo"}, + }, + { + name: "empty", + permission: "", + notExpected: "--yolo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "crush"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: tt.permission, + }) + if err != nil { + t.Fatal(err) + } + if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) { + t.Fatalf("command %#v does not contain %#v", cmd, tt.want) + } + if tt.notExpected != "" && contains(cmd, tt.notExpected) { + t.Fatalf("command %#v contains %q", cmd, tt.notExpected) + } + }) + } +} + +func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "crush"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + + if got != ports.PromptDeliveryInCommand { + t.Fatalf("unexpected prompt delivery strategy: got %v, want %v", got, ports.PromptDeliveryInCommand) + } +} + +func TestGetRestoreCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "crush"} + + tests := []struct { + name string + agentSessionID string + workspacePath string + permission ports.PermissionMode + wantOk bool + wantContains []string + }{ + { + name: "restore with session id", + agentSessionID: "crush-session-123", + workspacePath: "/tmp/workspace", + permission: ports.PermissionModeDefault, + wantOk: true, + wantContains: []string{"--cwd", "/tmp/workspace", "--session", "crush-session-123"}, + }, + { + name: "restore with bypass permissions", + agentSessionID: "crush-session-456", + workspacePath: "/tmp/workspace", + permission: ports.PermissionModeBypassPermissions, + wantOk: true, + wantContains: []string{"--cwd", "/tmp/workspace", "--yolo", "--session", "crush-session-456"}, + }, + { + name: "no session id", + agentSessionID: "", + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{"agentSessionId": tt.agentSessionID}, + WorkspacePath: tt.workspacePath, + }, + Permissions: tt.permission, + }) + if err != nil { + t.Fatal(err) + } + if ok != tt.wantOk { + t.Fatalf("unexpected ok: got %v, want %v", ok, tt.wantOk) + } + if tt.wantOk && len(tt.wantContains) > 0 && !containsSubsequence(cmd, tt.wantContains) { + t.Fatalf("command %#v does not contain %#v", cmd, tt.wantContains) + } + }) + } +} + +func TestSessionInfoReturnsFalse(t *testing.T) { + plugin := &Plugin{} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + ID: "session-123", + Metadata: map[string]string{"agentSessionId": "crush-session-123"}, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatalf("unexpected ok: got true, want false (SessionInfo is a no-op for Crush)") + } + if info.AgentSessionID != "" || info.Title != "" || info.Summary != "" { + t.Fatalf("unexpected non-empty info: got %#v", info) + } +} + +func TestManifest(t *testing.T) { + plugin := &Plugin{} + + manifest := plugin.Manifest() + if manifest.ID != adapterID { + t.Fatalf("unexpected manifest ID: got %q, want %q", manifest.ID, adapterID) + } + if manifest.Name != "Crush" { + t.Fatalf("unexpected manifest name: got %q, want \"Crush\"", manifest.Name) + } + if len(manifest.Capabilities) != 1 { + t.Fatalf("unexpected capabilities count: got %d, want 1", len(manifest.Capabilities)) + } +} + +func TestGetConfigSpecReturnsEmpty(t *testing.T) { + plugin := &Plugin{} + + spec, err := plugin.GetConfigSpec(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(spec.Fields) != 0 { + t.Fatalf("unexpected config spec fields: got %d, want 0", len(spec.Fields)) + } +} + +func TestGetAgentHooksIsNoOp(t *testing.T) { + plugin := &Plugin{} + + err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{ + WorkspacePath: "/tmp/workspace", + }) + if err != nil { + t.Fatalf("unexpected error from GetAgentHooks (no-op): %v", err) + } +} + +func TestUninstallHooksIsNoOp(t *testing.T) { + plugin := &Plugin{} + + err := plugin.UninstallHooks(context.Background(), "/tmp/workspace") + if err != nil { + t.Fatalf("unexpected error from UninstallHooks (no-op): %v", err) + } +} + +func TestAreHooksInstalledReturnsFalse(t *testing.T) { + plugin := &Plugin{} + + installed, err := plugin.AreHooksInstalled(context.Background(), "/tmp/workspace") + if err != nil { + t.Fatalf("unexpected error from AreHooksInstalled (no-op): %v", err) + } + if installed { + t.Fatalf("unexpected installed status: got true, want false (hooks are no-op for Crush)") + } +} + +// Helper functions from codex_test.go + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +func containsSubsequence(haystack, needle []string) bool { + for i := 0; i <= len(haystack)-len(needle); i++ { + match := true + for j, n := range needle { + if haystack[i+j] != n { + match = false + break + } + } + if match { + return true + } + } + return false +} diff --git a/backend/internal/adapters/agent/crush/hooks.go b/backend/internal/adapters/agent/crush/hooks.go new file mode 100644 index 0000000..fc00da6 --- /dev/null +++ b/backend/internal/adapters/agent/crush/hooks.go @@ -0,0 +1,39 @@ +package crush + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// GetAgentHooks is a no-op for Crush since it doesn't have full hooks support +// like Claude Code and Codex. Crush doesn't have a native hook configuration system +// that AO can integrate with for session metadata tracking. +// +// TODO(crush): Implement hook installation once Crush has native hook support. +// Until then, session metadata tracking is not available. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + // No-op for now since Crush doesn't have full hooks support + return nil +} + +// UninstallHooks is a no-op for Crush. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + // No-op for now since Crush doesn't have full hooks support + return nil +} + +// AreHooksInstalled is a no-op for Crush. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + // No-op for now since Crush doesn't have full hooks support + return false, nil +} diff --git a/backend/internal/adapters/agent/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index ca63a1a..f4b6549 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -11,6 +11,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/amp" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/crush" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/grok" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" @@ -31,6 +32,7 @@ func Constructors() []adapters.Adapter { droid.New(), amp.New(), agy.New(), + crush.New(), } } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index f2acfe9..772a25f 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -97,6 +97,7 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { {domain.HarnessDroid, "droid"}, {domain.HarnessAmp, "amp"}, {domain.HarnessAgy, "agy"}, + {domain.HarnessCrush, "crush"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness)