diff --git a/backend/internal/adapters/agent/activitydispatch/dispatch.go b/backend/internal/adapters/agent/activitydispatch/dispatch.go index f2e1c84..ccb94ed 100644 --- a/backend/internal/adapters/agent/activitydispatch/dispatch.go +++ b/backend/internal/adapters/agent/activitydispatch/dispatch.go @@ -11,6 +11,7 @@ package activitydispatch import ( "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/droid" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) @@ -33,6 +34,7 @@ var Derivers = map[string]DeriveFunc{ "devin": claudecode.DeriveActivityState, "opencode": opencode.DeriveActivityState, "codex": codex.DeriveActivityState, + "droid": droid.DeriveActivityState, } // Derive looks up the deriver for an agent token and applies it. ok=false when diff --git a/backend/internal/adapters/agent/droid/activity.go b/backend/internal/adapters/agent/droid/activity.go new file mode 100644 index 0000000..500eaaf --- /dev/null +++ b/backend/internal/adapters/agent/droid/activity.go @@ -0,0 +1,60 @@ +package droid + +import ( + "encoding/json" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// DeriveActivityState maps a Droid hook event (and its native stdin payload) +// onto an AO activity state. The bool is false when the event carries no +// activity signal — e.g. SessionStart (metadata only) or a SessionEnd reason +// that doesn't actually end the AO session — in which case the caller reports +// nothing. +// +// event is the AO hook sub-command name installed in droidManagedHooks +// ("user-prompt-submit", "stop", "notification", "session-end", ...), NOT the +// native Droid event name. Keeping this beside hooks.go means the events AO +// installs and what they mean live in one place. +// +// Droid's payload shapes differ from Claude Code's in one way that matters here: +// the Notification payload carries no notification_type discriminator (it only +// has a free-form message), but Droid only fires Notification when it needs a +// permission decision or has been idle awaiting input for 60s — both mean the +// agent is blocked on the user — so every Notification maps to waiting_input. +func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bool) { + switch event { + case "user-prompt-submit": + return domain.ActivityActive, true + case "stop": + // End of a turn: the agent is idle but alive (not exited). A following + // Notification upgrades this to the sticky waiting_input. + return domain.ActivityIdle, true + case "notification": + return domain.ActivityWaitingInput, true + case "session-end": + return sessionEndState(payload) + default: + return "", false + } +} + +// sessionEndState reports exited for reasons that actually end the session. +// "clear" keeps the same AO session alive (a new native session continues in +// the worktree), so it reports nothing. Any other reason — logout, +// prompt_input_exit, other, or an absent/unknown reason on a SessionEnd that did +// fire — is treated as a real exit. SessionEnd is not guaranteed on crash, so +// the reaper remains the backstop; both paths guard on IsTerminated, so +// whichever lands first wins. +func sessionEndState(payload []byte) (domain.ActivityState, bool) { + var p struct { + Reason string `json:"reason"` + } + _ = json.Unmarshal(payload, &p) + switch p.Reason { + case "clear": + return "", false + default: + return domain.ActivityExited, true + } +} diff --git a/backend/internal/adapters/agent/droid/activity_test.go b/backend/internal/adapters/agent/droid/activity_test.go new file mode 100644 index 0000000..0581094 --- /dev/null +++ b/backend/internal/adapters/agent/droid/activity_test.go @@ -0,0 +1,42 @@ +package droid + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestDeriveActivityState(t *testing.T) { + tests := []struct { + name string + event string + payload string + want domain.ActivityState + wantOK bool + }{ + {"user prompt -> active", "user-prompt-submit", `{}`, domain.ActivityActive, true}, + {"stop -> idle", "stop", `{}`, domain.ActivityIdle, true}, + // Droid notifications fire only on permission-needed or 60s-idle, both of + // which mean the agent is blocked on the user — and the payload carries no + // notification_type to discriminate — so every notification is waiting_input. + {"notification -> waiting_input", "notification", `{"message":"Droid needs your permission"}`, domain.ActivityWaitingInput, true}, + {"notification empty payload -> waiting_input", "notification", `{}`, domain.ActivityWaitingInput, true}, + {"notification malformed payload -> waiting_input", "notification", `not json`, domain.ActivityWaitingInput, true}, + {"session-end logout -> exited", "session-end", `{"reason":"logout"}`, domain.ActivityExited, true}, + {"session-end prompt_input_exit -> exited", "session-end", `{"reason":"prompt_input_exit"}`, domain.ActivityExited, true}, + {"session-end other -> exited", "session-end", `{"reason":"other"}`, domain.ActivityExited, true}, + {"session-end absent reason -> exited", "session-end", `{}`, domain.ActivityExited, true}, + {"session-end clear -> no signal", "session-end", `{"reason":"clear"}`, "", false}, + {"session-start -> no signal", "session-start", `{}`, "", false}, + {"unknown event -> no signal", "frobnicate", `{}`, "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := DeriveActivityState(tt.event, []byte(tt.payload)) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("DeriveActivityState(%q, %q) = (%q, %v), want (%q, %v)", + tt.event, tt.payload, got, ok, tt.want, tt.wantOK) + } + }) + } +} diff --git a/backend/internal/adapters/agent/droid/droid.go b/backend/internal/adapters/agent/droid/droid.go new file mode 100644 index 0000000..7315d4f --- /dev/null +++ b/backend/internal/adapters/agent/droid/droid.go @@ -0,0 +1,353 @@ +// Package droid implements the Droid (Factory) agent adapter: launching new +// interactive sessions, resuming hook-tracked sessions, installing +// workspace-local hooks, and reading hook-derived session info. +// +// Droid is Factory's terminal coding agent (binary "droid"). Unlike Grok it has +// no Claude Code compatibility layer, so AO installs its own hooks into the +// worktree-local .factory/hooks.json (see hooks.go). The hook JSON structure +// matches Claude Code's, but Droid's Notification payload omits notification_type +// and its hooks live under .factory/, so the adapter ships its own activity +// deriver (see activity.go) rather than reusing Claude's. +// +// Launch uses the interactive `droid [prompt]` command (the prompt is a +// positional argument). Droid's interactive TUI exposes no per-launch permission +// flag (--auto / --skip-permissions-unsafe live only on `droid exec`), so AO's +// graduated permission modes are delivered by writing a process-scoped runtime +// settings file (sessionDefaultSettings.autonomyLevel) and passing it via the +// root `--settings ` flag. Restore prefers the hook-captured native +// session id via `-r `. +package droid + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // Normalized session-metadata keys the hooks persist into the AO session + // store and SessionInfo reads back. Shared vocabulary with the Codex, Grok, + // and opencode adapters so the dashboard treats every agent uniformly. + droidTitleMetadataKey = "title" + droidSummaryMetadataKey = "summary" +) + +// Plugin is the Droid agent adapter. It is safe for concurrent use; the binary +// path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Droid adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: "droid", + Name: "Droid", + Description: "Run Factory Droid worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports no agent-specific config keys yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new interactive Droid session: +// +// droid [--settings ] [--append-system-prompt[-file] ] [prompt] +// +// The prompt is delivered as a positional argument (in command). Droid resolves +// its model and other defaults from the user's own settings; only the autonomy +// level is overridden, and only for non-default permission modes (see +// permissionSettingsArgs). System-prompt text/file is appended (not replaced), +// matching Droid's --append-system-prompt semantics. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.droidBinary(ctx) + if err != nil { + return nil, err + } + + cmd = make([]string, 0, 6) + cmd = append(cmd, binary) + + settingsArgs, err := permissionSettingsArgs(cfg.SessionID, cfg.Permissions) + if err != nil { + return nil, err + } + cmd = append(cmd, settingsArgs...) + + if cfg.SystemPromptFile != "" { + cmd = append(cmd, "--append-system-prompt-file", cfg.SystemPromptFile) + } else if cfg.SystemPrompt != "" { + cmd = append(cmd, "--append-system-prompt", cfg.SystemPrompt) + } + + if cfg.Prompt != "" { + cmd = append(cmd, cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Droid receives its prompt in the launch +// command itself (the positional prompt argument). +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetRestoreCommand rebuilds the argv that continues an existing Droid session: +// `droid [--settings ] -r `. It re-applies the permission +// autonomy (resume otherwise reverts to the configured default) but not the +// prompt, which the session already carries. ok is false when the hook-derived +// native session id has not landed yet, so callers fall back to fresh launch +// behavior — mirroring the Codex and opencode adapters. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.droidBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 5) + cmd = append(cmd, binary) + settingsArgs, err := permissionSettingsArgs(cfg.Session.ID, cfg.Permissions) + if err != nil { + return nil, false, err + } + cmd = append(cmd, settingsArgs...) + cmd = append(cmd, "-r", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Droid hook-derived metadata. Metadata is intentionally +// nil: callers get the normalized fields directly, matching the Codex adapter. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[droidTitleMetadataKey], + Summary: session.Metadata[droidSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// droidAutonomyLevel maps an AO permission mode onto Droid's +// sessionDefaultSettings.autonomyLevel (off|low|medium|high). The empty string +// means "no override" — defer to the user's own Droid settings — so the default +// mode emits no --settings flag and writes no file. +// +// accept-edits → low (safe file operations) +// auto → medium (local dev operations) +// bypass-permissions → high (max interactive autonomy; Droid's interactive +// TUI has no exec-style --skip-permissions-unsafe) +func droidAutonomyLevel(mode ports.PermissionMode) string { + switch normalizePermissionMode(mode) { + case ports.PermissionModeAcceptEdits: + return "low" + case ports.PermissionModeAuto: + return "medium" + case ports.PermissionModeBypassPermissions: + return "high" + default: + return "" + } +} + +// permissionSettingsArgs renders a non-default permission mode as a +// `--settings ` argv pair, writing a process-scoped runtime settings file +// that overrides only sessionDefaultSettings.autonomyLevel. The default mode +// returns nil (no flag, no file) so Droid uses the user's own settings. +// +// Interactive `droid` exposes no per-launch permission flag (--auto and +// --skip-permissions-unsafe exist only on `droid exec`), so autonomy must be +// delivered through settings. The file is written under the OS temp dir, keyed +// by session id, rather than into the worktree so it never lands in a commit. +func permissionSettingsArgs(sessionID string, mode ports.PermissionMode) ([]string, error) { + level := droidAutonomyLevel(mode) + if level == "" { + return nil, nil + } + + blob, err := json.Marshal(map[string]any{ + "sessionDefaultSettings": map[string]any{"autonomyLevel": level}, + }) + if err != nil { + return nil, fmt.Errorf("droid: encode runtime settings: %w", err) + } + + path := runtimeSettingsPath(sessionID) + if err := hookutil.AtomicWriteFile(path, append(blob, '\n'), 0o600); err != nil { + return nil, fmt.Errorf("droid: write runtime settings: %w", err) + } + return []string{"--settings", path}, nil +} + +// runtimeSettingsPath is the deterministic temp-dir path for a session's +// process-scoped runtime settings file. A stable name keyed by session id means +// relaunches overwrite rather than accumulate files. +func runtimeSettingsPath(sessionID string) string { + name := sanitizeSessionID(sessionID) + if name == "" { + name = "default" + } + return filepath.Join(os.TempDir(), "ao-droid-"+name+"-settings.json") +} + +// sanitizeSessionID keeps only filename-safe characters so the session id can +// be embedded in a temp file name without path traversal or separators. +func sanitizeSessionID(id string) string { + var b strings.Builder + for _, r := range id { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + return b.String() +} + +// ResolveDroidBinary finds the `droid` binary (Factory Droid CLI), searching +// PATH then a handful of well-known install locations. Returns "droid" as a +// last-ditch fallback so callers see a clear "command not found" rather than an +// empty argv. +func ResolveDroidBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"droid.cmd", "droid.exe", "droid"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "droid.cmd"), + filepath.Join(appData, "npm", "droid.exe"), + ) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "droid.exe"), + filepath.Join(home, ".factory", "bin", "droid.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + return "droid", nil + } + + if path, err := exec.LookPath("droid"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/droid", + "/opt/homebrew/bin/droid", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "droid"), + filepath.Join(home, ".factory", "bin", "droid"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "droid", nil +} + +func (p *Plugin) droidBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveDroidBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + // Empty or unrecognized: defer to Droid's own settings (no flag). + return ports.PermissionModeDefault + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/droid/droid_test.go b/backend/internal/adapters/agent/droid/droid_test.go new file mode 100644 index 0000000..2607372 --- /dev/null +++ b/backend/internal/adapters/agent/droid/droid_test.go @@ -0,0 +1,320 @@ +package droid + +import ( + "context" + "encoding/json" + "os" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifest(t *testing.T) { + m := (&Plugin{}).Manifest() + if m.ID != "droid" { + t.Fatalf("ID = %q, want droid", m.ID) + } + if m.Name != "Droid" { + t.Fatalf("Name = %q", m.Name) + } + hasAgent := false + for _, c := range m.Capabilities { + if c == adapters.CapabilityAgent { + hasAgent = true + } + } + if !hasAgent { + t.Fatal("missing CapabilityAgent") + } +} + +func TestGetConfigSpecEmpty(t *testing.T) { + spec, err := (&Plugin{}).GetConfigSpec(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(spec.Fields) != 0 { + t.Fatalf("expected no fields, got %d", len(spec.Fields)) + } +} + +func TestGetPromptDeliveryStrategy(t *testing.T) { + s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if s != ports.PromptDeliveryInCommand { + t.Fatalf("strategy = %q, want in_command", s) + } +} + +func TestGetLaunchCommandDefaultPerms(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SessionID: "mer-1", + Prompt: "do the thing", + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"droid", "do the thing"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } + if strings.Contains(strings.Join(cmd, " "), "--settings") { + t.Fatal("default perms should not emit --settings") + } +} + +func TestGetLaunchCommandBypassWritesSettings(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + settingsPath := runtimeSettingsPath("mer-2") + t.Cleanup(func() { _ = os.Remove(settingsPath) }) + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SessionID: "mer-2", + Prompt: "refactor auth", + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"droid", "--settings", settingsPath, "refactor auth"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } + + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("read settings file: %v", err) + } + var parsed struct { + SessionDefaultSettings struct { + AutonomyLevel string `json:"autonomyLevel"` + } `json:"sessionDefaultSettings"` + } + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("parse settings file: %v", err) + } + if parsed.SessionDefaultSettings.AutonomyLevel != "high" { + t.Fatalf("autonomyLevel = %q, want high", parsed.SessionDefaultSettings.AutonomyLevel) + } +} + +func TestGetLaunchCommandAutonomyLevels(t *testing.T) { + for _, tc := range []struct { + mode ports.PermissionMode + level string + }{ + {ports.PermissionModeAcceptEdits, "low"}, + {ports.PermissionModeAuto, "medium"}, + {ports.PermissionModeBypassPermissions, "high"}, + } { + if got := droidAutonomyLevel(tc.mode); got != tc.level { + t.Fatalf("droidAutonomyLevel(%q) = %q, want %q", tc.mode, got, tc.level) + } + } + if got := droidAutonomyLevel(ports.PermissionModeDefault); got != "" { + t.Fatalf("default autonomy = %q, want empty", got) + } +} + +func TestGetLaunchCommandSystemPrompt(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SessionID: "mer-3", + Prompt: "fix it", + SystemPrompt: "follow AGENTS.md", + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"droid", "--append-system-prompt", "follow AGENTS.md", "fix it"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + ID: "mer-4", + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "droid-ses-1", + }, + }, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Fatal("ok=false, want true") + } + want := []string{"droid", "-r", "droid-ses-1"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommandNoID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + _, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{Metadata: map[string]string{}}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatal("ok=true with no agentSessionId, want false") + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "droid-ses-1", + droidTitleMetadataKey: "Fix login redirect", + droidSummaryMetadataKey: "Updated the auth callback and tests.", + }, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Fatal("ok=false, want true") + } + if info.AgentSessionID != "droid-ses-1" { + t.Fatalf("AgentSessionID = %q", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q", info.Summary) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatal("ok=true with empty metadata, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero", info) + } +} + +func TestGetAgentHooksInstallsIntoFactoryHooksJSON(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + ws := t.TempDir() + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{ + WorkspacePath: ws, + SessionID: "mer-5", + }); err != nil { + t.Fatalf("GetAgentHooks: %v", err) + } + + data, err := os.ReadFile(droidHooksPath(ws)) + if err != nil { + t.Fatalf("read hooks.json: %v", err) + } + body := string(data) + for _, spec := range droidManagedHooks { + if !strings.Contains(body, spec.Command) { + t.Fatalf("hooks.json missing managed command %q:\n%s", spec.Command, body) + } + } + if !strings.Contains(body, `"startup"`) { + t.Fatalf("SessionStart hook missing startup matcher:\n%s", body) + } + + installed, err := plugin.AreHooksInstalled(context.Background(), ws) + if err != nil { + t.Fatalf("AreHooksInstalled: %v", err) + } + if !installed { + t.Fatal("AreHooksInstalled=false after install, want true") + } +} + +func TestGetAgentHooksIdempotentAndPreservesUserHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + ws := t.TempDir() + // Seed a user-defined hook AO must preserve. + if err := os.MkdirAll(droidHooksPath(ws)[:len(droidHooksPath(ws))-len(droidHooksFileName)], 0o750); err != nil { + t.Fatal(err) + } + seed := `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"echo mine"}]}]}}` + if err := os.WriteFile(droidHooksPath(ws), []byte(seed), 0o600); err != nil { + t.Fatal(err) + } + + for i := 0; i < 2; i++ { + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: ws}); err != nil { + t.Fatalf("GetAgentHooks #%d: %v", i, err) + } + } + + data, err := os.ReadFile(droidHooksPath(ws)) + if err != nil { + t.Fatal(err) + } + body := string(data) + if !strings.Contains(body, "echo mine") { + t.Fatalf("user hook dropped:\n%s", body) + } + // The AO stop command must appear exactly once despite two installs. + if n := strings.Count(body, droidHookCommandPrefix+"stop"); n != 1 { + t.Fatalf("AO stop command count = %d, want 1 (idempotent):\n%s", n, body) + } +} + +func TestUninstallHooksRemovesAOHooksLeavesUserHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "droid"} + ws := t.TempDir() + dir := droidHooksPath(ws)[:len(droidHooksPath(ws))-len(droidHooksFileName)] + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + seed := `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"echo mine"}]}]}}` + if err := os.WriteFile(droidHooksPath(ws), []byte(seed), 0o600); err != nil { + t.Fatal(err) + } + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: ws}); err != nil { + t.Fatal(err) + } + if err := plugin.UninstallHooks(context.Background(), ws); err != nil { + t.Fatalf("UninstallHooks: %v", err) + } + + data, err := os.ReadFile(droidHooksPath(ws)) + if err != nil { + t.Fatal(err) + } + body := string(data) + if strings.Contains(body, droidHookCommandPrefix) { + t.Fatalf("AO hooks not removed:\n%s", body) + } + if !strings.Contains(body, "echo mine") { + t.Fatalf("user hook dropped on uninstall:\n%s", body) + } + + installed, err := plugin.AreHooksInstalled(context.Background(), ws) + if err != nil { + t.Fatal(err) + } + if installed { + t.Fatal("AreHooksInstalled=true after uninstall, want false") + } +} diff --git a/backend/internal/adapters/agent/droid/hooks.go b/backend/internal/adapters/agent/droid/hooks.go new file mode 100644 index 0000000..e9ed328 --- /dev/null +++ b/backend/internal/adapters/agent/droid/hooks.go @@ -0,0 +1,351 @@ +package droid + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + droidSettingsDirName = ".factory" + droidHooksFileName = "hooks.json" + + // droidHookCommandPrefix identifies the hook commands AO owns. Every managed + // command starts with it, so install can skip duplicates and uninstall can + // recognize AO entries by prefix without an embedded template to diff + // against. The CLI dispatcher routes `ao hooks droid ` to the Droid + // activity deriver. + droidHookCommandPrefix = "ao hooks droid " + droidHookTimeout = 30 +) + +type droidMatcherGroup struct { + // Matcher is a pointer so it round-trips exactly: SessionStart serializes + // with its "startup" matcher; UserPromptSubmit/Stop/Notification/SessionEnd + // omit it (Droid ignores matcher for those events). omitempty drops a nil + // matcher on write. + Matcher *string `json:"matcher,omitempty"` + Hooks []droidHookEntry `json:"hooks"` +} + +type droidHookEntry struct { + Type string `json:"type"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// droidHookSpec describes one hook AO installs, defined in code rather than read +// from an embedded settings file. +type droidHookSpec struct { + Event string + Matcher *string + Command string +} + +// droidStartupMatcher is referenced by pointer so SessionStart serializes with +// its "startup" source matcher. +var droidStartupMatcher = "startup" + +// droidManagedHooks is the source of truth for the hooks AO installs: +// SessionStart (under the "startup" matcher), UserPromptSubmit, Stop, +// Notification, and SessionEnd. They report normalized activity-state signals +// back into AO's store (see DeriveActivityState). The non-SessionStart events +// carry no matcher: each installs once and fires for every sub-type, and the +// handler filters on the payload where it must. +var droidManagedHooks = []droidHookSpec{ + {Event: "SessionStart", Matcher: &droidStartupMatcher, Command: droidHookCommandPrefix + "session-start"}, + {Event: "UserPromptSubmit", Command: droidHookCommandPrefix + "user-prompt-submit"}, + {Event: "Stop", Command: droidHookCommandPrefix + "stop"}, + {Event: "Notification", Command: droidHookCommandPrefix + "notification"}, + {Event: "SessionEnd", Command: droidHookCommandPrefix + "session-end"}, +} + +// GetAgentHooks installs AO's Droid hooks into the worktree-local +// .factory/hooks.json file (the project-scope hooks config Droid reads). The +// hooks report normalized activity-state signals back into AO's store. Existing +// hooks and unrelated keys are preserved, and duplicate AO commands are not +// appended, so the install is idempotent. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(cfg.WorkspacePath) == "" { + return errors.New("droid.GetAgentHooks: WorkspacePath is required") + } + + hooksPath := droidHooksPath(cfg.WorkspacePath) + topLevel, rawHooks, err := readDroidHooks(hooksPath) + if err != nil { + return fmt.Errorf("droid.GetAgentHooks: %w", err) + } + + byEvent := groupDroidHooksByEvent() + events := make([]string, 0, len(byEvent)) + for event := range byEvent { + events = append(events, event) + } + sort.Strings(events) + for _, event := range events { + specs := byEvent[event] + var existingGroups []droidMatcherGroup + if err := parseDroidHookType(rawHooks, event, &existingGroups); err != nil { + return fmt.Errorf("droid.GetAgentHooks: %w", err) + } + for _, spec := range specs { + if !droidHookCommandExists(existingGroups, spec.Command) { + entry := droidHookEntry{Type: "command", Command: spec.Command, Timeout: droidHookTimeout} + existingGroups = addDroidHook(existingGroups, entry, spec.Matcher) + } + } + if err := marshalDroidHookType(rawHooks, event, existingGroups); err != nil { + return fmt.Errorf("droid.GetAgentHooks: %w", err) + } + } + + if err := writeDroidHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("droid.GetAgentHooks: %w", err) + } + return nil +} + +// UninstallHooks removes AO's Droid hooks from the workspace-local +// .factory/hooks.json file, leaving user-defined hooks and unrelated keys +// untouched. A missing file is a no-op. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return errors.New("droid.UninstallHooks: workspacePath is required") + } + + hooksPath := droidHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return nil + } + topLevel, rawHooks, err := readDroidHooks(hooksPath) + if err != nil { + return fmt.Errorf("droid.UninstallHooks: %w", err) + } + + for _, event := range droidManagedEvents() { + var groups []droidMatcherGroup + if err := parseDroidHookType(rawHooks, event, &groups); err != nil { + return fmt.Errorf("droid.UninstallHooks: %w", err) + } + groups = removeDroidManagedHooks(groups) + if err := marshalDroidHookType(rawHooks, event, groups); err != nil { + return fmt.Errorf("droid.UninstallHooks: %w", err) + } + } + + if err := writeDroidHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("droid.UninstallHooks: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether any AO Droid hook is present in the +// workspace-local hooks file. A missing file means none are installed. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if strings.TrimSpace(workspacePath) == "" { + return false, errors.New("droid.AreHooksInstalled: workspacePath is required") + } + + hooksPath := droidHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return false, nil + } + _, rawHooks, err := readDroidHooks(hooksPath) + if err != nil { + return false, fmt.Errorf("droid.AreHooksInstalled: %w", err) + } + + for _, event := range droidManagedEvents() { + var groups []droidMatcherGroup + if err := parseDroidHookType(rawHooks, event, &groups); err != nil { + return false, fmt.Errorf("droid.AreHooksInstalled: %w", err) + } + for _, group := range groups { + for _, hook := range group.Hooks { + if isDroidManagedHook(hook.Command) { + return true, nil + } + } + } + } + return false, nil +} + +func droidHooksPath(workspacePath string) string { + return filepath.Join(workspacePath, droidSettingsDirName, droidHooksFileName) +} + +// readDroidHooks loads the hooks file into a top-level raw map plus the decoded +// "hooks" sub-map, preserving every key AO doesn't manage. A missing or empty +// file yields empty maps. +func readDroidHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { + topLevel = map[string]json.RawMessage{} + rawHooks = map[string]json.RawMessage{} + + data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return topLevel, rawHooks, nil + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return topLevel, rawHooks, nil + } + if err := json.Unmarshal(data, &topLevel); err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) + } + if hooksRaw, ok := topLevel["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) + } + } + return topLevel, rawHooks, nil +} + +// writeDroidHooks folds rawHooks back into topLevel and writes the file. An +// empty hooks map drops the "hooks" key entirely. +func writeDroidHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { + if len(rawHooks) == 0 { + delete(topLevel, "hooks") + } else { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("encode hooks: %w", err) + } + topLevel["hooks"] = hooksJSON + } + + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + return fmt.Errorf("create hooks dir: %w", err) + } + data, err := json.MarshalIndent(topLevel, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", hooksPath, err) + } + data = append(data, '\n') + if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", hooksPath, err) + } + return nil +} + +// groupDroidHooksByEvent groups the managed hook specs by their Droid event so +// each event's array is rewritten once. +func groupDroidHooksByEvent() map[string][]droidHookSpec { + byEvent := map[string][]droidHookSpec{} + for _, spec := range droidManagedHooks { + byEvent[spec.Event] = append(byEvent[spec.Event], spec) + } + return byEvent +} + +// droidManagedEvents returns the distinct Droid events AO manages, in the order +// they first appear in droidManagedHooks. +func droidManagedEvents() []string { + seen := map[string]bool{} + events := make([]string, 0, len(droidManagedHooks)) + for _, spec := range droidManagedHooks { + if !seen[spec.Event] { + seen[spec.Event] = true + events = append(events, spec.Event) + } + } + return events +} + +func isDroidManagedHook(command string) bool { + return strings.HasPrefix(command, droidHookCommandPrefix) +} + +// removeDroidManagedHooks strips AO hook entries from every group, dropping any +// group left without hooks so the event array doesn't accumulate empty matcher +// objects. +func removeDroidManagedHooks(groups []droidMatcherGroup) []droidMatcherGroup { + result := make([]droidMatcherGroup, 0, len(groups)) + for _, group := range groups { + kept := make([]droidHookEntry, 0, len(group.Hooks)) + for _, hook := range group.Hooks { + if !isDroidManagedHook(hook.Command) { + kept = append(kept, hook) + } + } + if len(kept) > 0 { + group.Hooks = kept + result = append(result, group) + } + } + return result +} + +func parseDroidHookType(rawHooks map[string]json.RawMessage, event string, target *[]droidMatcherGroup) error { + data, ok := rawHooks[event] + if !ok { + return nil + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("parse %s hooks: %w", event, err) + } + return nil +} + +func marshalDroidHookType(rawHooks map[string]json.RawMessage, event string, groups []droidMatcherGroup) error { + if len(groups) == 0 { + delete(rawHooks, event) + return nil + } + data, err := json.Marshal(groups) + if err != nil { + return fmt.Errorf("encode %s hooks: %w", event, err) + } + rawHooks[event] = data + return nil +} + +func droidHookCommandExists(groups []droidMatcherGroup, command string) bool { + for _, group := range groups { + for _, hook := range group.Hooks { + if hook.Command == command { + return true + } + } + } + return false +} + +// addDroidHook appends hook to an existing group with the same matcher (so a +// SessionStart hook lands under its "startup" matcher), creating that group if +// none matches. +func addDroidHook(groups []droidMatcherGroup, hook droidHookEntry, matcher *string) []droidMatcherGroup { + for i, group := range groups { + if matchersEqual(group.Matcher, matcher) { + groups[i].Hooks = append(groups[i].Hooks, hook) + return groups + } + } + return append(groups, droidMatcherGroup{Matcher: matcher, Hooks: []droidHookEntry{hook}}) +} + +func matchersEqual(a, b *string) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} diff --git a/backend/internal/adapters/agent/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index c4210c0..4a6f7c0 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -9,6 +9,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" + "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" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -25,6 +26,7 @@ func Constructors() []adapters.Adapter { codex.New(), opencode.New(), grok.New(), + droid.New(), } } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 3ca4bce..62793c2 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -94,6 +94,7 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { {domain.HarnessCodex, "codex"}, {domain.HarnessOpenCode, "opencode"}, {domain.HarnessGrok, "grok"}, + {domain.HarnessDroid, "droid"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness)