Add agent adapters and wire per-session agents into the session manager (#65)

* feat(plugin): add agents plugin (first iteration)

Faithful copy of the agents plugin implementation from yyovil/better-ao
(internal/plugin/ -> backend/internal/plugin/) plus its PRD
(prds/plugins/agents/PRD.md), as a first-iteration proposal for review.

Imports are left at their original github.com/yyovil/better-ao/... paths and
are NOT yet reconciled to this repo's module; see PR description for the
integration deltas (module path, missing internal/utils dependency).

Co-authored-by: Claude <noreply@anthropic.com>

* Move agent adapters under backend adapters

* Keep daemon ports and session out of adapter move

* Remove Better-AO naming from flake

* Keep flake as dev shell only

* Use goimports for local formatting

* Wire session manager to per-session agent adapters

Move the Agent port into internal/ports and have the claude-code and
codex adapters implement it directly, alongside their workspace-local
activity hooks and a manifest-keyed adapter registry. Rename
RuntimeConfig.LaunchCommand to Argv and update the tmux and zellij
runtimes to match.

The session Manager now resolves a real agent adapter per session via a
new ports.AgentResolver: from cfg.Harness on Spawn and the stored harness
on Restore, so one daemon runs claude-code and codex sessions side by
side. The daemon backs the resolver with the registry; AO_AGENT selects
the default harness (default claude-code), validated at startup. Removes
the temporary noopAgent stub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(agent): point the agent contract at internal/ports/agent.go

The Agent interface moved from internal/adapters/agent to internal/ports;
update the PRD's Goal and Agent Contract sections (and the SessionInfo
references) to match the code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Wire the session service into the daemon

daemon.Run now builds the controller-facing session service — a session
manager over the zellij runtime, a gitworktree workspace, the shared
store + LCM, and the per-session agent resolver (AO_AGENT default,
validated at startup) — and mounts it at httpd APIDeps.Sessions, so the
session REST routes are backed by a real service. startLifecycle moves
ahead of the HTTP server so both share one LCM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address Greptile review: complete the live spawn path

- Spawn and Restore now install workspace-local activity hooks
  (GetAgentHooks) and run the adapter's optional PreLaunch step before
  launch, via a shared prepareWorkspace helper. PreLaunch is how Claude
  Code records workspace trust, so its interactive "trust this folder?"
  dialog can't hang the headless pane; the spawned env now also carries
  AO_DATA_DIR so the installed hook commands find the store.
- claudecode and codex hook/config writes are now atomic (temp + rename)
  instead of os.WriteFile, so a crash mid-write can't leave a partial
  file the agent fails to parse.
- ensureWorkspaceTrusted serializes its read-modify-write under a package
  mutex, so concurrent spawns to different workspaces don't drop each
  other's ~/.claude.json trust entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ports): pin MetadataKeyAgentSessionID to domain.SessionMetadata json tag

The equality between ports.MetadataKeyAgentSessionID and the json tag on
domain.SessionMetadata.AgentSessionID is a hand-maintained invariant; this
test fails loudly if either side drifts.

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

* refactor(adapters): use ports.MetadataKeyAgentSessionID in claudecode + codex

The native session id metadata key is defined in ports for cross-package
consumption; drop the duplicated literals in each adapter so the constant
has one home.

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

* test(codex): cover ensureCodexHooksFeatureEnabled TOML edge cases

The helper is a string editor over config.toml; pin its content
transformation for missing/empty files, existing [features] blocks,
the no-op case, and the legacy codex_hooks=true migration paths.

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

* docs(adapters): document Registry concurrency contract

Registry registration runs at daemon boot before any goroutine calls Get,
so the underlying map needs no lock; pin that contract in the doc comment
so a future change doesn't quietly introduce a race.

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

* style(codex): gofmt codex_test.go after constant rename

The previous commit (7c5b2a9) replaced codexAgentSessionIDMetadataKey with
ports.MetadataKeyAgentSessionID inside a map literal; the longer key threw
off gofmt's column alignment on the adjacent codexTitleMetadataKey /
codexSummaryMetadataKey lines. Caught by agent-ci's Check formatting step.

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

Co-authored-by: harshitsinghbhandari <dev@theharshitsingh.com>
This commit is contained in:
yyovil 2026-06-02 16:51:32 +05:30 committed by GitHub
parent 3a93e33331
commit 3346c6cb6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 3405 additions and 70 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
use flake

3
.gitignore vendored
View File

@ -1,5 +1,6 @@
# Node / Electron
node_modules/
.pnpm/
dist/
out/
build/
@ -9,6 +10,7 @@ yarn-debug.log*
yarn-error.log*
# Go
.go/
bin/
*.test
*.out
@ -30,6 +32,7 @@ session-events.jsonl.*
.ao/
# Environment
.direnv/
.env
.env.*
!.env.example

View File

@ -85,6 +85,7 @@ linters:
excludes:
- G104 # unchecked errors — errcheck owns this
- G304 # file inclusion via variable — paths are config/run-file/worktree-derived, not user input
- G703 # path traversal via taint analysis — same as G304: binary-resolution and worktree-derived paths, not user input
exclusions:
generated: lax # skip sqlc/codegen ("Code generated ... DO NOT EDIT")
@ -107,7 +108,6 @@ linters:
formatters:
enable:
- gofmt
- goimports
settings:
goimports:

View File

@ -0,0 +1,455 @@
// Package claudecode implements the Claude Code agent adapter.
//
// It builds the argv to launch `claude` as an interactive session inside a
// session's worktree, installs worktree-local hooks that report normalized
// session metadata (native id, title, summary) back into AO's store,
// and supports resume: GetLaunchCommand pins a stable `--session-id` so
// GetRestoreCommand can rebuild `claude --resume <uuid>`. SessionInfo reads the
// hook-captured metadata from the store — it does not parse transcripts.
// GetConfigSpec remains a no-op (no agent-specific config keys yet).
//
// Claude Code starts an interactive session by default (no -p/--print), which
// is exactly what AO wants: a live agent the user can attach to in the
// browser terminal or via `zellij attach`. The initial task prompt is passed
// as the positional argument; the orchestrator system prompt (if any) is
// appended to Claude's default system prompt so its built-in coding
// instructions are preserved.
package claudecode
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"github.com/google/uuid"
"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`.
adapterID = "claude-code"
// Normalized session-metadata keys the Claude Code hooks persist into the
// AO session store and SessionInfo reads back. Shared vocabulary
// with the Codex adapter so the dashboard treats every agent uniformly.
// The native session id key lives in ports as MetadataKeyAgentSessionID
// because the Session Manager also reads it.
claudeTitleMetadataKey = "title"
claudeSummaryMetadataKey = "summary"
)
// claudeSessionNamespace seeds the UUIDv5 derivation that maps an AO
// session id onto a stable Claude Code `--session-id`. A fixed namespace makes
// the mapping deterministic, so GetLaunchCommand (which pins --session-id at
// launch) and GetRestoreCommand (which recomputes it as a fallback for
// pre-hook sessions) agree without persisting anything.
var claudeSessionNamespace = uuid.MustParse("a1f0c3d2-7b54-4e96-8a2b-0d9e1f2a3b4c")
// 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 {
binaryMu sync.Mutex
resolvedBinary string
}
// New returns a ready-to-register Claude Code 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: "Claude Code",
Description: "Run Claude Code worker sessions.",
Version: "0.0.1",
Capabilities: []adapters.Capability{
adapters.CapabilityAgent,
},
}
}
// GetConfigSpec reports the agent-specific config keys. Claude 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 an interactive Claude Code
// session. Shape:
//
// claude [--session-id <uuid>] \
// [--permission-mode <mode>] \
// [--append-system-prompt <system prompt>] \
// [-- <prompt>]
//
// --session-id pins Claude's native session UUID to a value derived from the
// AO session id, so the session is resumable later (see
// GetRestoreCommand) and its transcript is locatable (see SessionInfo) without
// a separate capture step.
//
// <mode> is acceptEdits, auto, or bypassPermissions. AO's "default"
// mode emits no --permission-mode flag, so Claude's TUI resolves the starting
// mode from ~/.claude/settings.json exactly as a normal launch.
//
// The prompt is passed after `--` so a prompt beginning with "-" is not
// mistaken for a flag.
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
binary, err := p.claudeBinary(ctx)
if err != nil {
return nil, err
}
cmd = []string{binary}
if cfg.SessionID != "" {
cmd = append(cmd, "--session-id", claudeSessionUUID(cfg.SessionID))
}
appendPermissionFlags(&cmd, cfg.Permissions)
systemPrompt, err := resolveSystemPrompt(cfg)
if err != nil {
return nil, err
}
if systemPrompt != "" {
// Append rather than replace: Claude Code's default system prompt
// carries its tool-use and coding instructions, which we want to
// keep. The orchestrator prompt layers on top.
cmd = append(cmd, "--append-system-prompt", systemPrompt)
}
if cfg.Prompt != "" {
cmd = append(cmd, "--", cfg.Prompt)
}
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
// directory. Every AO worktree is a fresh path, so without this the
// agent would hang at that prompt with no one to answer it.
//
// An AO worktree is derived from the repo the user is already running
// AO in, so it is inherently trusted. PreLaunch records that trust in
// ~/.claude.json before launch, additively and atomically, so it cannot
// clobber a concurrently-running Claude instance's config.
func (p *Plugin) PreLaunch(ctx context.Context, cfg ports.LaunchConfig) error {
if err := ctx.Err(); err != nil {
return err
}
if cfg.WorkspacePath == "" {
return nil
}
cfgPath, err := claudeConfigPath()
if err != nil {
return err
}
return ensureWorkspaceTrusted(cfgPath, cfg.WorkspacePath)
}
// GetRestoreCommand rebuilds the argv that continues an existing Claude Code
// session: `claude [--permission-mode <mode>] --resume <agentSessionId>`. It
// prefers the hook-captured native session id from
// cfg.Session.Metadata["agentSessionId"]; for sessions created before hooks
// captured it, it falls back to the deterministic UUID AO pins via
// --session-id at launch. ok is false only when neither is available, so the
// caller fresh-spawns. The command re-applies the permission mode (resume
// otherwise reverts to the configured default) but not the prompt/system
// prompt, which the session already carries.
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
}
sessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
if sessionID == "" && cfg.Session.ID != "" {
// Explicit fallback for pre-hook sessions: the id AO
// deterministically pinned via --session-id at launch.
sessionID = claudeSessionUUID(cfg.Session.ID)
}
if sessionID == "" {
return nil, false, nil
}
binary, err := p.claudeBinary(ctx)
if err != nil {
return nil, false, err
}
cmd = make([]string, 0, 5)
cmd = append(cmd, binary)
appendPermissionFlags(&cmd, cfg.Permissions)
cmd = append(cmd, "--resume", sessionID)
return cmd, true, nil
}
// SessionInfo surfaces the normalized session metadata that the Claude Code
// hooks persisted into AO's store: the native session id, the title (the
// first user prompt), and the summary (the final assistant message). It reads
// only from session.Metadata — never from transcript files — and returns
// ok=false when none of those fields are present. Metadata is intentionally nil:
// there is no Claude-specific field callers need beyond the normalized ones.
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[claudeTitleMetadataKey],
Summary: session.Metadata[claudeSummaryMetadataKey],
}
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
return ports.SessionInfo{}, false, nil
}
return info, true, nil
}
// claudeSessionUUID maps an AO session id onto a stable Claude Code
// session UUID via UUIDv5 over a fixed namespace, so the same AO session
// always resolves to the same Claude session.
func claudeSessionUUID(aoSessionID string) string {
return uuid.NewSHA1(claudeSessionNamespace, []byte(aoSessionID)).String()
}
// resolveSystemPrompt returns the system prompt text to append, preferring
// SystemPromptFile (read from disk) over an inline SystemPrompt.
func resolveSystemPrompt(cfg ports.LaunchConfig) (string, error) {
if cfg.SystemPromptFile != "" {
data, err := os.ReadFile(cfg.SystemPromptFile)
if err != nil {
return "", fmt.Errorf("claude-code: read system prompt file: %w", err)
}
return strings.TrimRight(string(data), "\n"), nil
}
return cfg.SystemPrompt, nil
}
// appendPermissionFlags maps AO's permission modes onto Claude Code's
// --permission-mode values:
// - default → no flag. Claude's TUI resolves the starting mode
// from ~/.claude/settings.json (defaultMode), exactly as a normal launch.
// - accept-edits → --permission-mode acceptEdits (auto-accept edits +
// safe filesystem bash; still prompts for network/system bash, MCP, web)
// - auto → --permission-mode auto (classifier-gated
// auto-approval; auto-runs what a safety model deems safe)
// - bypass-permissions → --permission-mode bypassPermissions (skip all
// checks; equivalent to --dangerously-skip-permissions)
//
// Empty/unrecognized normalizes to default, so no flag is emitted.
func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) {
switch normalizePermissionMode(permissions) {
case ports.PermissionModeDefault:
// No flag: defer to the user's settings.json defaultMode.
case ports.PermissionModeAcceptEdits:
*cmd = append(*cmd, "--permission-mode", "acceptEdits")
case ports.PermissionModeAuto:
*cmd = append(*cmd, "--permission-mode", "auto")
case ports.PermissionModeBypassPermissions:
*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:
// Empty or unrecognized: defer to settings.json (no flag).
return ports.PermissionModeDefault
}
}
// 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.
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 "claude", nil
}
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 "claude", nil
}
func (p *Plugin) claudeBinary(ctx context.Context) (string, error) {
p.binaryMu.Lock()
defer p.binaryMu.Unlock()
if p.resolvedBinary != "" {
return p.resolvedBinary, nil
}
binary, err := ResolveClaudeBinary(ctx)
if err != nil {
return "", err
}
p.resolvedBinary = binary
return binary, nil
}
// claudeConfigPath returns the path to Claude Code's global config file,
// ~/.claude.json.
func claudeConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("claude-code: resolve home directory: %w", err)
}
return filepath.Join(home, ".claude.json"), nil
}
// ensureWorkspaceTrusted records workspacePath as trusted in Claude Code's
// config so the interactive trust dialog does not block a spawned session.
//
// It is additive and concurrency-safe: it reads the existing config, sets
// only projects[workspacePath].hasTrustDialogAccepted = true (preserving the
// rest of the entry and every other project), and writes back via a
// temp-file + atomic rename. If the path is already trusted, it makes no
// write at all. A missing config file is treated as an empty one.
// claudeTrustMu serializes ensureWorkspaceTrusted within the process. Concurrent
// spawns to different workspaces otherwise read the same ~/.claude.json snapshot
// and the last rename drops the other's trust entry.
var claudeTrustMu sync.Mutex
func ensureWorkspaceTrusted(configPath, workspacePath string) error {
claudeTrustMu.Lock()
defer claudeTrustMu.Unlock()
root := map[string]any{}
data, err := os.ReadFile(configPath)
switch {
case err == nil:
if len(data) > 0 {
if err := json.Unmarshal(data, &root); err != nil {
return fmt.Errorf("claude-code: parse %s: %w", configPath, err)
}
}
case os.IsNotExist(err):
// Treat as empty config; we'll create it.
default:
return fmt.Errorf("claude-code: read %s: %w", configPath, err)
}
projects, _ := root["projects"].(map[string]any)
if projects == nil {
projects = map[string]any{}
root["projects"] = projects
}
entry, _ := projects[workspacePath].(map[string]any)
if entry == nil {
entry = map[string]any{}
projects[workspacePath] = entry
}
if trusted, ok := entry["hasTrustDialogAccepted"].(bool); ok && trusted {
// Already trusted — no write needed, so no race window at all.
return nil
}
entry["hasTrustDialogAccepted"] = true
out, err := json.MarshalIndent(root, "", " ")
if err != nil {
return fmt.Errorf("claude-code: encode %s: %w", configPath, err)
}
// Atomic write: temp file in the same directory, then rename. Matches
// how Claude Code itself updates this file, so concurrent updates are
// last-writer-wins rather than corrupting.
dir := filepath.Dir(configPath)
tmp, err := os.CreateTemp(dir, ".claude.json.tmp-*")
if err != nil {
return fmt.Errorf("claude-code: create temp config: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
if _, err := tmp.Write(out); err != nil {
_ = tmp.Close()
return fmt.Errorf("claude-code: write temp config: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("claude-code: close temp config: %w", err)
}
if err := os.Rename(tmpName, configPath); err != nil {
return fmt.Errorf("claude-code: replace config: %w", err)
}
return nil
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}

View File

@ -0,0 +1,539 @@
package claudecode
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/google/uuid"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestGetLaunchCommandBypassWithPrompt(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Permissions: ports.PermissionModeBypassPermissions,
Prompt: "-add a health check",
})
if err != nil {
t.Fatal(err)
}
want := []string{
"claude",
"--permission-mode", "bypassPermissions",
"--", "-add a health check",
}
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
}{
{"default omits flag (defers to settings.json)", ports.PermissionModeDefault, nil, "--permission-mode"},
{"accept-edits", ports.PermissionModeAcceptEdits, []string{"--permission-mode", "acceptEdits"}, ""},
{"auto", ports.PermissionModeAuto, []string{"--permission-mode", "auto"}, ""},
{"bypass-permissions", ports.PermissionModeBypassPermissions, []string{"--permission-mode", "bypassPermissions"}, ""},
{"empty omits permission flags", "", nil, "--permission-mode"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
cmd, err := p.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 unexpectedly contains %q", cmd, tt.notExpected)
}
})
}
}
func TestGetLaunchCommandAppendsSystemPromptFromFile(t *testing.T) {
dir := t.TempDir()
promptFile := filepath.Join(dir, "system.md")
if err := os.WriteFile(promptFile, []byte("You are an orchestrator.\n"), 0o644); err != nil {
t.Fatal(err)
}
p := &Plugin{resolvedBinary: "claude"}
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
SystemPromptFile: promptFile,
Prompt: "do the thing",
})
if err != nil {
t.Fatal(err)
}
want := []string{
"claude",
"--append-system-prompt", "You are an orchestrator.",
"--", "do the thing",
}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetLaunchCommandInlineSystemPrompt(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
SystemPrompt: "inline instructions",
})
if err != nil {
t.Fatal(err)
}
if !containsSubsequence(cmd, []string{"--append-system-prompt", "inline instructions"}) {
t.Fatalf("command %#v does not append inline system prompt", cmd)
}
}
func TestGetLaunchCommandMissingSystemPromptFileErrors(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
_, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
SystemPromptFile: filepath.Join(t.TempDir(), "does-not-exist.md"),
})
if err == nil {
t.Fatal("expected error for missing system prompt file")
}
}
func TestGetLaunchCommandInjectsSessionID(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
SessionID: "e0tt49",
Prompt: "do the thing",
})
if err != nil {
t.Fatal(err)
}
wantUUID := claudeSessionUUID("e0tt49")
if !containsSubsequence(cmd, []string{"--session-id", wantUUID}) {
t.Fatalf("command %#v missing --session-id %q", cmd, wantUUID)
}
// No SessionID → no --session-id flag.
cmd, err = p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Prompt: "x"})
if err != nil {
t.Fatal(err)
}
if contains(cmd, "--session-id") {
t.Fatalf("command %#v unexpectedly contains --session-id", cmd)
}
}
func TestClaudeSessionUUIDDeterministicAndUnique(t *testing.T) {
a1 := claudeSessionUUID("alpha")
a2 := claudeSessionUUID("alpha")
b := claudeSessionUUID("beta")
if a1 != a2 {
t.Fatalf("derivation not deterministic: %q != %q", a1, a2)
}
if a1 == b {
t.Fatalf("distinct ids collided: both %q", a1)
}
if _, err := uuid.Parse(a1); err != nil {
t.Fatalf("derived value is not a valid UUID: %q (%v)", a1, err)
}
}
func TestGetAgentHooksInstallsClaudeHooks(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
workspace := t.TempDir()
settingsDir := filepath.Join(workspace, ".claude")
if err := os.MkdirAll(settingsDir, 0o755); err != nil {
t.Fatal(err)
}
settingsPath := filepath.Join(settingsDir, "settings.local.json")
// Pre-seed a user's own Stop hook + an unrelated setting; both must survive.
existing := `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"my own stop hook","timeout":5}]}]},"permissions":{"defaultMode":"plan"}}`
if err := os.WriteFile(settingsPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace}
if err := p.GetAgentHooks(context.Background(), cfg); err != nil {
t.Fatal(err)
}
// A second install must not duplicate AO hook commands.
if err := p.GetAgentHooks(context.Background(), cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(settingsPath)
if err != nil {
t.Fatal(err)
}
var config struct {
Hooks map[string][]claudeMatcherGroup `json:"hooks"`
Permissions json.RawMessage `json:"permissions"`
}
if err := json.Unmarshal(data, &config); err != nil {
t.Fatal(err)
}
if config.Hooks == nil {
t.Fatalf("hooks object missing: %s", data)
}
// Every managed command is installed exactly once under its event.
for _, spec := range claudeManagedHooks {
if got := countClaudeHookCommand(config.Hooks[spec.Event], spec.Command); got != 1 {
t.Fatalf("%s command %q count = %d, want 1", spec.Event, spec.Command, got)
}
}
// Existing user hook preserved.
if countClaudeHookCommand(config.Hooks["Stop"], "my own stop hook") != 1 {
t.Fatalf("existing Stop hook not preserved: %#v", config.Hooks["Stop"])
}
// Unrelated settings preserved.
if len(config.Permissions) == 0 {
t.Fatalf("unrelated settings clobbered: %s", data)
}
// SessionStart carries the required matcher; UserPromptSubmit omits it.
if m := matcherForCommand(config.Hooks["SessionStart"], "ao hooks claude-code session-start"); m == nil || *m != "startup" {
t.Fatalf("SessionStart matcher = %v, want startup", m)
}
if m := matcherForCommand(config.Hooks["UserPromptSubmit"], "ao hooks claude-code user-prompt-submit"); m != nil {
t.Fatalf("UserPromptSubmit matcher = %v, want none", m)
}
}
func TestUninstallHooksRemovesClaudeHooks(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
workspace := t.TempDir()
settingsPath := filepath.Join(workspace, ".claude", "settings.local.json")
ctx := context.Background()
cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace}
// Pre-seed a user's own Stop hook + an unrelated setting; both must survive.
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
t.Fatal(err)
}
existing := `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"my own stop hook","timeout":5}]}]},"permissions":{"defaultMode":"plan"}}`
if err := os.WriteFile(settingsPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
if err := p.GetAgentHooks(ctx, cfg); err != nil {
t.Fatal(err)
}
if installed, err := p.AreHooksInstalled(ctx, workspace); err != nil || !installed {
t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err)
}
if err := p.UninstallHooks(ctx, workspace); err != nil {
t.Fatal(err)
}
if installed, err := p.AreHooksInstalled(ctx, workspace); err != nil || installed {
t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err)
}
data, err := os.ReadFile(settingsPath)
if err != nil {
t.Fatal(err)
}
var config struct {
Hooks map[string][]claudeMatcherGroup `json:"hooks"`
Permissions json.RawMessage `json:"permissions"`
}
if err := json.Unmarshal(data, &config); err != nil {
t.Fatal(err)
}
// No managed command survives; the SessionStart/UserPromptSubmit events,
// which held only AO hooks, are removed entirely.
for _, spec := range claudeManagedHooks {
if got := countClaudeHookCommand(config.Hooks[spec.Event], spec.Command); got != 0 {
t.Fatalf("%s command %q count = %d after uninstall, want 0", spec.Event, spec.Command, got)
}
}
// The user's own Stop hook and unrelated settings are preserved.
if countClaudeHookCommand(config.Hooks["Stop"], "my own stop hook") != 1 {
t.Fatalf("user Stop hook not preserved: %#v", config.Hooks["Stop"])
}
if len(config.Permissions) == 0 {
t.Fatalf("unrelated settings clobbered: %s", data)
}
// Uninstall is idempotent: a second call is a clean no-op.
if err := p.UninstallHooks(ctx, workspace); err != nil {
t.Fatalf("second uninstall: %v", err)
}
}
func TestUninstallHooksNoSettingsFile(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
workspace := t.TempDir()
if err := p.UninstallHooks(context.Background(), workspace); err != nil {
t.Fatalf("uninstall with no settings file: %v", err)
}
if installed, err := p.AreHooksInstalled(context.Background(), workspace); err != nil || installed {
t.Fatalf("AreHooksInstalled = (%v, %v), want (false, nil)", installed, err)
}
}
func TestSessionInfoReadsHookMetadata(t *testing.T) {
info, ok, err := (&Plugin{resolvedBinary: "claude"}).SessionInfo(context.Background(), ports.SessionRef{
WorkspacePath: "/some/path",
Metadata: map[string]string{
ports.MetadataKeyAgentSessionID: "claude-native-1",
claudeTitleMetadataKey: "Fix login redirect",
claudeSummaryMetadataKey: "Updated the auth callback and tests.",
"ignored": "not returned",
},
})
if err != nil || !ok {
t.Fatalf("SessionInfo = (ok=%v, err=%v), want ok", ok, err)
}
if info.AgentSessionID != "claude-native-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)
}
if info.Metadata != nil {
t.Fatalf("Metadata = %#v, want nil for Claude", info.Metadata)
}
}
func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
info, ok, err := (&Plugin{resolvedBinary: "claude"}).SessionInfo(context.Background(), ports.SessionRef{
WorkspacePath: "/some/path",
Metadata: map[string]string{},
})
if err != nil {
t.Fatalf("err = %v", err)
}
if ok {
t.Fatalf("ok = true, want false")
}
if !reflect.DeepEqual(info, ports.SessionInfo{}) {
t.Fatalf("info = %#v, want zero", info)
}
}
// 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 {
count := 0
for _, group := range groups {
for _, hook := range group.Hooks {
if hook.Command == command {
count++
}
}
}
return count
}
// 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 {
for _, group := range groups {
for _, hook := range group.Hooks {
if hook.Command == command {
return group.Matcher
}
}
}
return nil
}
func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
cmd, ok, err := (&Plugin{resolvedBinary: "claude"}).GetRestoreCommand(context.Background(), ports.RestoreConfig{
Permissions: ports.PermissionModeBypassPermissions,
Session: ports.SessionRef{
ID: "sess-r",
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "claude-native-1"},
},
})
if err != nil || !ok {
t.Fatalf("restore = (ok=%v, err=%v), want ok", ok, err)
}
// The hook-captured native id wins over the derived fallback.
want := []string{"claude", "--permission-mode", "bypassPermissions", "--resume", "claude-native-1"}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetRestoreCommandFallsBackToDerivedUUID(t *testing.T) {
// No agentSessionId captured (pre-hook session) → derive deterministically
// from the AO session id, the explicit fallback.
cmd, ok, err := (&Plugin{resolvedBinary: "claude"}).GetRestoreCommand(context.Background(), ports.RestoreConfig{
Permissions: ports.PermissionModeBypassPermissions,
Session: ports.SessionRef{ID: "sess-r"},
})
if err != nil || !ok {
t.Fatalf("restore = (ok=%v, err=%v), want ok", ok, err)
}
want := []string{"claude", "--permission-mode", "bypassPermissions", "--resume", claudeSessionUUID("sess-r")}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetRestoreCommandFalseWithoutSessionID(t *testing.T) {
cases := []struct {
name string
ref ports.SessionRef
}{
{"empty ref", ports.SessionRef{}},
{"blank agent session, no id", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}},
{"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd, ok, err := (&Plugin{resolvedBinary: "claude"}).GetRestoreCommand(context.Background(),
ports.RestoreConfig{Permissions: ports.PermissionModeBypassPermissions, Session: tc.ref})
if err != nil || ok || cmd != nil {
t.Fatalf("restore = (%#v, %v, %v), want (nil,false,nil)", cmd, ok, err)
}
})
}
}
func TestManifestID(t *testing.T) {
if got := New().Manifest().ID; got != "claude-code" {
t.Fatalf("manifest id = %q, want claude-code", got)
}
}
func TestEnsureWorkspaceTrustedCreatesEntry(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, ".claude.json")
// Seed an existing config with another project + a top-level key, to
// prove we preserve unrelated state.
seed := `{"userID":"abc","projects":{"/existing/proj":{"hasTrustDialogAccepted":true,"lastCost":1.5}}}`
if err := os.WriteFile(cfgPath, []byte(seed), 0o600); err != nil {
t.Fatal(err)
}
work := "/Users/me/.ao/worktrees/01ABC"
if err := ensureWorkspaceTrusted(cfgPath, work); err != nil {
t.Fatalf("ensureWorkspaceTrusted: %v", err)
}
root := readJSON(t, cfgPath)
projects := root["projects"].(map[string]any)
// New entry trusted.
newEntry := projects[work].(map[string]any)
if newEntry["hasTrustDialogAccepted"] != true {
t.Fatalf("new entry not trusted: %#v", newEntry)
}
// Existing project preserved (including its other fields).
existing := projects["/existing/proj"].(map[string]any)
if existing["hasTrustDialogAccepted"] != true || existing["lastCost"].(float64) != 1.5 {
t.Fatalf("existing project clobbered: %#v", existing)
}
// Top-level key preserved.
if root["userID"] != "abc" {
t.Fatalf("top-level key clobbered: %#v", root["userID"])
}
}
func TestEnsureWorkspaceTrustedIsIdempotentAndNoWriteWhenAlreadyTrusted(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, ".claude.json")
work := "/w"
if err := os.WriteFile(cfgPath, []byte(`{"projects":{"/w":{"hasTrustDialogAccepted":true}}}`), 0o600); err != nil {
t.Fatal(err)
}
info1, err := os.Stat(cfgPath)
if err != nil {
t.Fatal(err)
}
if err := ensureWorkspaceTrusted(cfgPath, work); err != nil {
t.Fatalf("ensureWorkspaceTrusted: %v", err)
}
// Already trusted → no rewrite → mtime unchanged.
info2, err := os.Stat(cfgPath)
if err != nil {
t.Fatal(err)
}
if !info1.ModTime().Equal(info2.ModTime()) {
t.Fatal("expected no rewrite when already trusted")
}
}
func TestEnsureWorkspaceTrustedCreatesMissingConfig(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, ".claude.json") // does not exist yet
work := "/fresh/worktree"
if err := ensureWorkspaceTrusted(cfgPath, work); err != nil {
t.Fatalf("ensureWorkspaceTrusted: %v", err)
}
root := readJSON(t, cfgPath)
projects := root["projects"].(map[string]any)
entry := projects[work].(map[string]any)
if entry["hasTrustDialogAccepted"] != true {
t.Fatalf("entry not trusted in freshly-created config: %#v", entry)
}
}
func readJSON(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return m
}
func contains(values []string, needle string) bool {
for _, v := range values {
if v == needle {
return true
}
}
return false
}
func containsSubsequence(values, needle []string) bool {
if len(needle) == 0 {
return true
}
for start := 0; start+len(needle) <= len(values); start++ {
ok := true
for i, w := range needle {
if values[start+i] != w {
ok = false
break
}
}
if ok {
return true
}
}
return false
}

View File

@ -0,0 +1,360 @@
package claudecode
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"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.
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, and Stop. Each
// reports normalized session metadata back into AO's store.
var claudeManagedHooks = []claudeHookSpec{
{Event: "SessionStart", Matcher: &claudeStartupMatcher, Command: claudeHookCommandPrefix + "session-start"},
{Event: "UserPromptSubmit", Command: claudeHookCommandPrefix + "user-prompt-submit"},
{Event: "Stop", Command: claudeHookCommandPrefix + "stop"},
}
// 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) report normalized session metadata 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)
}
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
}
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
}
// 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 := atomicWriteFile(settingsPath, data, 0o600); err != nil {
return fmt.Errorf("write %s: %w", settingsPath, 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 Claude 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.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
// 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
}

View File

@ -0,0 +1,258 @@
// Package codex implements the Codex agent adapter: launching new sessions,
// resuming hook-tracked sessions, installing workspace-local hooks, and reading
// hook-derived session info.
//
// AO-managed sessions derive native session identity and display
// metadata from Codex hooks instead of transcript/cache scans.
package codex
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 (
codexTitleMetadataKey = "title"
codexSummaryMetadataKey = "summary"
)
// 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 {
binaryMu sync.Mutex
resolvedBinary string
}
// New returns a ready-to-register Codex 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: "codex",
Name: "Codex",
Description: "Run Codex worker sessions.",
Version: "0.0.1",
Capabilities: []adapters.Capability{
adapters.CapabilityAgent,
},
}
}
// 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 and approval flags, optional system-prompt instructions, and
// the initial prompt (passed after `--` so a leading "-" is not read as a flag).
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
binary, err := p.codexBinary(ctx)
if err != nil {
return nil, err
}
cmd = []string{binary}
appendNoUpdateCheckFlag(&cmd)
appendApprovalFlags(&cmd, cfg.Permissions)
if cfg.SystemPromptFile != "" {
cmd = append(cmd, "-c", "model_instructions_file="+cfg.SystemPromptFile)
} else if cfg.SystemPrompt != "" {
cmd = append(cmd, "-c", "developer_instructions="+cfg.SystemPrompt)
}
if cfg.Prompt != "" {
cmd = append(cmd, "--", cfg.Prompt)
}
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
// launch behavior.
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.codexBinary(ctx)
if err != nil {
return nil, false, err
}
cmd = make([]string, 0, 8)
cmd = append(cmd, binary, "resume")
appendNoUpdateCheckFlag(&cmd)
appendApprovalFlags(&cmd, cfg.Permissions)
cmd = append(cmd, agentSessionID)
return cmd, true, nil
}
// SessionInfo surfaces Codex hook-derived metadata. Metadata is intentionally
// nil for Codex: callers get the normalized fields directly.
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[codexTitleMetadataKey],
Summary: session.Metadata[codexSummaryMetadataKey],
}
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
return ports.SessionInfo{}, false, nil
}
return info, true, nil
}
// ResolveCodexBinary returns the path to the codex binary on this machine,
// searching PATH then a handful of well-known install locations
// (Homebrew, Cargo, npm global). Returns "codex" as a last-ditch fallback
// so callers see a clear "command not found" rather than an empty argv.
func ResolveCodexBinary(ctx context.Context) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if runtime.GOOS == "windows" {
for _, name := range []string{"codex.cmd", "codex.exe", "codex"} {
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", "codex.cmd"),
filepath.Join(appData, "npm", "codex.exe"),
)
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "codex.exe"))
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "codex", nil
}
if path, err := exec.LookPath("codex"); err == nil && path != "" {
return path, nil
}
candidates := []string{
"/usr/local/bin/codex",
"/opt/homebrew/bin/codex",
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates,
filepath.Join(home, ".cargo", "bin", "codex"),
filepath.Join(home, ".npm", "bin", "codex"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "codex", nil
}
func (p *Plugin) codexBinary(ctx context.Context) (string, error) {
p.binaryMu.Lock()
defer p.binaryMu.Unlock()
if p.resolvedBinary != "" {
return p.resolvedBinary, nil
}
binary, err := ResolveCodexBinary(ctx)
if err != nil {
return "", err
}
p.resolvedBinary = binary
return binary, nil
}
func appendNoUpdateCheckFlag(cmd *[]string) {
*cmd = append(*cmd, "-c", "check_for_update_on_startup=false")
}
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
switch normalizePermissionMode(permissions) {
case ports.PermissionModeDefault:
// No flag: defer to the user's Codex config/default behavior.
case ports.PermissionModeAcceptEdits:
*cmd = append(*cmd, "--ask-for-approval", "on-request")
case ports.PermissionModeAuto:
*cmd = append(*cmd, "--ask-for-approval", "on-request", "-c", `approvals_reviewer="auto_review"`)
case ports.PermissionModeBypassPermissions:
*cmd = append(*cmd, "--dangerously-bypass-approvals-and-sandbox")
}
}
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
switch mode {
case ports.PermissionModeDefault,
ports.PermissionModeAcceptEdits,
ports.PermissionModeAuto,
ports.PermissionModeBypassPermissions:
return mode
default:
return ports.PermissionModeDefault
}
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}

View File

@ -0,0 +1,476 @@
package codex
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) {
plugin := &Plugin{resolvedBinary: "codex"}
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Permissions: ports.PermissionModeBypassPermissions,
Prompt: "-fix this",
SystemPromptFile: filepath.Join("tmp", "prompt with spaces.md"),
SystemPrompt: "ignored",
})
if err != nil {
t.Fatal(err)
}
want := []string{
"codex",
"-c", "check_for_update_on_startup=false",
"--dangerously-bypass-approvals-and-sandbox",
"-c", "model_instructions_file=" + filepath.Join("tmp", "prompt with spaces.md"),
"--", "-fix this",
}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
tests := []struct {
name string
permission ports.PermissionMode
want []string
notExpected string
}{
{
name: "default",
permission: ports.PermissionModeDefault,
notExpected: "--ask-for-approval",
},
{
name: "accept-edits",
permission: ports.PermissionModeAcceptEdits,
want: []string{"--ask-for-approval", "on-request"},
},
{
name: "auto",
permission: ports.PermissionModeAuto,
want: []string{"--ask-for-approval", "on-request", "-c", `approvals_reviewer="auto_review"`},
},
{
name: "bypass-permissions",
permission: ports.PermissionModeBypassPermissions,
want: []string{"--dangerously-bypass-approvals-and-sandbox"},
},
{
name: "empty",
permission: "",
notExpected: "--ask-for-approval",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plugin := &Plugin{resolvedBinary: "codex"}
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: "codex"}
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
if err != nil {
t.Fatal(err)
}
if got != ports.PromptDeliveryInCommand {
t.Fatalf("unexpected strategy: %q", got)
}
}
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
plugin := &Plugin{resolvedBinary: "codex"}
spec, err := plugin.GetConfigSpec(context.Background())
if err != nil {
t.Fatal(err)
}
if len(spec.Fields) != 0 {
t.Fatalf("unexpected config fields: %#v", spec.Fields)
}
}
func TestGetAgentHooksInstallsCodexHooks(t *testing.T) {
plugin := &Plugin{resolvedBinary: "codex"}
workspace := t.TempDir()
hooksDir := filepath.Join(workspace, ".codex")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
hooksPath := filepath.Join(hooksDir, "hooks.json")
existing := `{"hooks":{"Stop":[{"matcher":null,"hooks":[{"type":"command","command":"custom stop hook","timeout":3}]}]}}`
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
cfg := ports.WorkspaceHookConfig{
DataDir: t.TempDir(),
SessionID: "sess-1",
WorkspacePath: workspace,
}
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
t.Fatal(err)
}
// A second install must not duplicate AO hook commands.
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(hooksPath)
if err != nil {
t.Fatal(err)
}
var config codexHookFile
if err := json.Unmarshal(data, &config); err != nil {
t.Fatal(err)
}
if config.Hooks == nil {
t.Fatalf("hooks config missing hooks object: %#v", config)
}
for _, spec := range codexManagedHooks {
entries := config.Hooks[spec.Event]
if count := countCodexHookCommand(entries, spec.Command); count != 1 {
t.Fatalf("%s command count = %d, want 1 in %#v", spec.Event, count, entries)
}
}
stopEntries := config.Hooks["Stop"]
if countCodexHookCommand(stopEntries, "custom stop hook") != 1 {
t.Fatalf("existing Stop hook was not preserved: %#v", stopEntries)
}
configData, err := os.ReadFile(filepath.Join(workspace, ".codex", "config.toml"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(configData), codexHooksFeatureLine) {
t.Fatalf("config.toml missing hooks feature flag: %s", configData)
}
}
func TestUninstallHooksRemovesCodexHooks(t *testing.T) {
plugin := &Plugin{resolvedBinary: "codex"}
workspace := t.TempDir()
hooksPath := filepath.Join(workspace, ".codex", "hooks.json")
ctx := context.Background()
cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace}
// Pre-seed a user's own Stop hook; it must survive uninstall.
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil {
t.Fatal(err)
}
existing := `{"hooks":{"Stop":[{"matcher":null,"hooks":[{"type":"command","command":"custom stop hook","timeout":3}]}]}}`
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
if err := plugin.GetAgentHooks(ctx, cfg); err != nil {
t.Fatal(err)
}
if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed {
t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err)
}
if err := plugin.UninstallHooks(ctx, workspace); err != nil {
t.Fatal(err)
}
if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed {
t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err)
}
data, err := os.ReadFile(hooksPath)
if err != nil {
t.Fatal(err)
}
var config codexHookFile
if err := json.Unmarshal(data, &config); err != nil {
t.Fatal(err)
}
for _, spec := range codexManagedHooks {
if got := countCodexHookCommand(config.Hooks[spec.Event], spec.Command); got != 0 {
t.Fatalf("%s command %q count = %d after uninstall, want 0", spec.Event, spec.Command, got)
}
}
if countCodexHookCommand(config.Hooks["Stop"], "custom stop hook") != 1 {
t.Fatalf("user Stop hook not preserved: %#v", config.Hooks["Stop"])
}
// The shared hooks feature flag in config.toml is left in place — it enables
// every Codex hook, not just AO's.
configData, err := os.ReadFile(filepath.Join(workspace, ".codex", "config.toml"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(configData), codexHooksFeatureLine) {
t.Fatalf("config.toml hooks feature flag removed by uninstall: %s", configData)
}
}
func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
plugin := &Plugin{resolvedBinary: "codex"}
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
Permissions: ports.PermissionModeAuto,
Session: ports.SessionRef{
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "thread-123"},
},
})
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if !ok {
t.Fatal("ok = false, want true")
}
want := []string{
"codex",
"resume",
"-c", "check_for_update_on_startup=false",
"--ask-for-approval", "on-request",
"-c", `approvals_reviewer="auto_review"`,
"thread-123",
}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) {
plugin := &Plugin{resolvedBinary: "codex"}
cases := []struct {
name string
ref ports.SessionRef
}{
{"empty session ref", ports.SessionRef{}},
{"empty metadata", ports.SessionRef{Metadata: map[string]string{}}},
{"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}},
{"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
Permissions: ports.PermissionModeAuto,
Session: tc.ref,
})
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if ok {
t.Fatalf("ok = true, want false")
}
if cmd != nil {
t.Fatalf("cmd = %#v, want nil", cmd)
}
})
}
}
func TestSessionInfoReadsHookMetadata(t *testing.T) {
plugin := &Plugin{resolvedBinary: "codex"}
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
WorkspacePath: "/some/path",
Metadata: map[string]string{
ports.MetadataKeyAgentSessionID: "thread-123",
codexTitleMetadataKey: "Fix login redirect",
codexSummaryMetadataKey: "Updated the auth callback and tests.",
"ignored": "not returned",
},
})
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if !ok {
t.Fatalf("ok = false, want true")
}
if info.AgentSessionID != "thread-123" {
t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID)
}
if info.Title != "Fix login redirect" {
t.Fatalf("Title = %q, want hook title", info.Title)
}
if info.Summary != "Updated the auth callback and tests." {
t.Fatalf("Summary = %q, want hook summary", info.Summary)
}
if info.Metadata != nil {
t.Fatalf("Metadata = %#v, want nil for Codex", info.Metadata)
}
}
func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
plugin := &Plugin{resolvedBinary: "codex"}
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
WorkspacePath: "/some/path",
Metadata: map[string]string{},
})
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if ok {
t.Fatalf("ok = true, want false")
}
if !reflect.DeepEqual(info, ports.SessionInfo{}) {
t.Fatalf("info = %#v, want zero value", info)
}
}
func TestEnsureCodexHooksFeatureEnabledEdgeCases(t *testing.T) {
tests := []struct {
name string
seed *string // nil means do not create config.toml
wantHas []string
wantMiss []string
}{
{
name: "missing config.toml is created with features block",
seed: nil,
wantHas: []string{"[features]", codexHooksFeatureLine},
},
{
name: "empty config.toml is populated with features block",
seed: strPtr(""),
wantHas: []string{"[features]", codexHooksFeatureLine},
},
{
name: "existing features block without hooks gains hooks=true",
seed: strPtr("[features]\nother = true\n"),
wantHas: []string{"[features]", codexHooksFeatureLine, "other = true"},
},
{
name: "hooks=true already present is a no-op",
seed: strPtr("[features]\nhooks = true\n"),
wantHas: []string{"[features]", codexHooksFeatureLine},
wantMiss: []string{codexLegacyHookFeatureLine},
},
{
name: "legacy codex_hooks=true is replaced with hooks=true",
seed: strPtr("[features]\ncodex_hooks = true\n"),
wantHas: []string{"[features]", codexHooksFeatureLine},
wantMiss: []string{codexLegacyHookFeatureLine},
},
{
name: "both hooks=true and legacy line keep only the new line",
seed: strPtr("[features]\nhooks = true\ncodex_hooks = true\n"),
wantHas: []string{"[features]", codexHooksFeatureLine},
wantMiss: []string{codexLegacyHookFeatureLine},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
workspace := t.TempDir()
configDir := filepath.Join(workspace, codexHooksDirName)
configPath := filepath.Join(configDir, codexConfigFileName)
if tt.seed != nil {
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(configPath, []byte(*tt.seed), 0o600); err != nil {
t.Fatal(err)
}
}
// No-op check: snapshot the file content before and after for
// the cases the helper documents as no-ops.
var before []byte
if tt.seed != nil && strings.Contains(*tt.seed, codexHooksFeatureLine) && !strings.Contains(*tt.seed, codexLegacyHookFeatureLine) {
before = []byte(*tt.seed)
}
if err := ensureCodexHooksFeatureEnabled(workspace); err != nil {
t.Fatalf("ensureCodexHooksFeatureEnabled: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read %s: %v", configPath, err)
}
got := string(data)
for _, want := range tt.wantHas {
if !strings.Contains(got, want) {
t.Fatalf("config.toml missing %q\n--- got ---\n%s", want, got)
}
}
for _, miss := range tt.wantMiss {
if strings.Contains(got, miss) {
t.Fatalf("config.toml unexpectedly contains %q\n--- got ---\n%s", miss, got)
}
}
if before != nil && string(data) != string(before) {
t.Fatalf("expected no-op, content changed\n--- before ---\n%s\n--- after ---\n%s", before, data)
}
})
}
}
func strPtr(s string) *string { return &s }
func contains(values []string, needle string) bool {
for _, value := range values {
if value == needle {
return true
}
}
return false
}
func containsSubsequence(values []string, needle []string) bool {
if len(needle) == 0 {
return true
}
for start := range values {
if start+len(needle) > len(values) {
return false
}
ok := true
for offset, want := range needle {
if values[start+offset] != want {
ok = false
break
}
}
if ok {
return true
}
}
return false
}
func countCodexHookCommand(entries []codexMatcherGroup, command string) int {
count := 0
for _, entry := range entries {
for _, hook := range entry.Hooks {
if hook.Command == command {
count++
}
}
}
return count
}

View File

@ -0,0 +1,409 @@
package codex
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
const (
codexHooksDirName = ".codex"
codexHooksFileName = "hooks.json"
codexConfigFileName = "config.toml"
codexHooksFeatureLine = "hooks = true"
codexLegacyHookFeatureLine = "codex_hooks = true"
// codexHookCommandPrefix identifies the hook commands AO owns, so
// install skips duplicates and uninstall recognizes AO entries by
// prefix without an embedded template to diff against.
codexHookCommandPrefix = "ao hooks codex "
codexHookTimeout = 30
)
// codexHookFile is the on-disk shape of .codex/hooks.json. It is used by tests
// to decode the written file.
type codexHookFile struct {
Hooks map[string][]codexMatcherGroup `json:"hooks"`
}
type codexMatcherGroup struct {
Matcher *string `json:"matcher"`
Hooks []codexHookEntry `json:"hooks"`
}
type codexHookEntry struct {
Type string `json:"type"`
Command string `json:"command"`
Timeout int `json:"timeout,omitempty"`
}
// codexHookSpec describes one hook AO installs, defined in code rather
// than read from an embedded hooks file.
type codexHookSpec struct {
Event string
Command string
}
// codexManagedHooks is the source of truth for the hooks AO installs.
// Codex groups every hook under the nil matcher.
var codexManagedHooks = []codexHookSpec{
{Event: "SessionStart", Command: codexHookCommandPrefix + "session-start"},
{Event: "UserPromptSubmit", Command: codexHookCommandPrefix + "user-prompt-submit"},
{Event: "Stop", Command: codexHookCommandPrefix + "stop"},
}
// GetAgentHooks installs AO's Codex hooks into the worktree-local
// .codex/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("codex.GetAgentHooks: WorkspacePath is required")
}
hooksPath := codexHooksPath(cfg.WorkspacePath)
topLevel, rawHooks, err := readCodexHooks(hooksPath)
if err != nil {
return fmt.Errorf("codex.GetAgentHooks: %w", err)
}
for event, specs := range groupCodexHooksByEvent() {
var existingGroups []codexMatcherGroup
if err := parseCodexHookType(rawHooks, event, &existingGroups); err != nil {
return fmt.Errorf("codex.GetAgentHooks: %w", err)
}
for _, spec := range specs {
if !codexHookCommandExists(existingGroups, spec.Command) {
entry := codexHookEntry{Type: "command", Command: spec.Command, Timeout: codexHookTimeout}
existingGroups = addCodexHook(existingGroups, entry)
}
}
if err := marshalCodexHookType(rawHooks, event, existingGroups); err != nil {
return fmt.Errorf("codex.GetAgentHooks: %w", err)
}
}
if err := writeCodexHooks(hooksPath, topLevel, rawHooks); err != nil {
return fmt.Errorf("codex.GetAgentHooks: %w", err)
}
if err := ensureCodexHooksFeatureEnabled(cfg.WorkspacePath); err != nil {
return fmt.Errorf("codex.GetAgentHooks: enable hooks feature: %w", err)
}
return nil
}
// UninstallHooks removes AO's Codex hooks from the workspace-local
// .codex/hooks.json file, leaving user-defined hooks untouched. A missing file
// is a no-op. The .codex/config.toml `hooks = true` feature flag is left in
// place because it enables every Codex hook, not just AO's.
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("codex.UninstallHooks: workspacePath is required")
}
hooksPath := codexHooksPath(workspacePath)
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
return nil
}
topLevel, rawHooks, err := readCodexHooks(hooksPath)
if err != nil {
return fmt.Errorf("codex.UninstallHooks: %w", err)
}
for _, event := range codexManagedEvents() {
var groups []codexMatcherGroup
if err := parseCodexHookType(rawHooks, event, &groups); err != nil {
return fmt.Errorf("codex.UninstallHooks: %w", err)
}
groups = removeCodexManagedHooks(groups)
if err := marshalCodexHookType(rawHooks, event, groups); err != nil {
return fmt.Errorf("codex.UninstallHooks: %w", err)
}
}
if err := writeCodexHooks(hooksPath, topLevel, rawHooks); err != nil {
return fmt.Errorf("codex.UninstallHooks: %w", err)
}
return nil
}
// AreHooksInstalled reports whether any AO Codex 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("codex.AreHooksInstalled: workspacePath is required")
}
hooksPath := codexHooksPath(workspacePath)
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
return false, nil
}
_, rawHooks, err := readCodexHooks(hooksPath)
if err != nil {
return false, fmt.Errorf("codex.AreHooksInstalled: %w", err)
}
for _, event := range codexManagedEvents() {
var groups []codexMatcherGroup
if err := parseCodexHookType(rawHooks, event, &groups); err != nil {
return false, fmt.Errorf("codex.AreHooksInstalled: %w", err)
}
for _, group := range groups {
for _, hook := range group.Hooks {
if isCodexManagedHook(hook.Command) {
return true, nil
}
}
}
}
return false, nil
}
func codexHooksPath(workspacePath string) string {
return filepath.Join(workspacePath, codexHooksDirName, codexHooksFileName)
}
// readCodexHooks 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 readCodexHooks(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
}
// writeCodexHooks folds rawHooks back into topLevel and writes the file. An
// empty hooks map drops the "hooks" key entirely.
func writeCodexHooks(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
}
// atomicWriteFile writes data to path via a temp file + rename, so a crash mid-
// write can't leave a truncated/empty file that Codex 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)
}
// groupCodexHooksByEvent groups the managed hook specs by their Codex event so
// each event's array is rewritten once.
func groupCodexHooksByEvent() map[string][]codexHookSpec {
byEvent := map[string][]codexHookSpec{}
for _, spec := range codexManagedHooks {
byEvent[spec.Event] = append(byEvent[spec.Event], spec)
}
return byEvent
}
// codexManagedEvents returns the distinct Codex events AO manages, in the
// order they first appear in codexManagedHooks.
func codexManagedEvents() []string {
seen := map[string]bool{}
events := make([]string, 0, len(codexManagedHooks))
for _, spec := range codexManagedHooks {
if !seen[spec.Event] {
seen[spec.Event] = true
events = append(events, spec.Event)
}
}
return events
}
func isCodexManagedHook(command string) bool {
return strings.HasPrefix(command, codexHookCommandPrefix)
}
// removeCodexManagedHooks strips AO hook entries from every group,
// dropping any group left without hooks.
func removeCodexManagedHooks(groups []codexMatcherGroup) []codexMatcherGroup {
result := make([]codexMatcherGroup, 0, len(groups))
for _, group := range groups {
kept := make([]codexHookEntry, 0, len(group.Hooks))
for _, hook := range group.Hooks {
if !isCodexManagedHook(hook.Command) {
kept = append(kept, hook)
}
}
if len(kept) > 0 {
group.Hooks = kept
result = append(result, group)
}
}
return result
}
func parseCodexHookType(rawHooks map[string]json.RawMessage, event string, target *[]codexMatcherGroup) 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 marshalCodexHookType(rawHooks map[string]json.RawMessage, event string, groups []codexMatcherGroup) 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 codexHookCommandExists(groups []codexMatcherGroup, command string) bool {
for _, group := range groups {
for _, hook := range group.Hooks {
if hook.Command == command {
return true
}
}
}
return false
}
func addCodexHook(groups []codexMatcherGroup, hook codexHookEntry) []codexMatcherGroup {
for i, group := range groups {
if group.Matcher == nil {
groups[i].Hooks = append(groups[i].Hooks, hook)
return groups
}
}
return append(groups, codexMatcherGroup{
Matcher: nil,
Hooks: []codexHookEntry{hook},
})
}
func ensureCodexHooksFeatureEnabled(workspacePath string) error {
configPath := filepath.Join(workspacePath, codexHooksDirName, codexConfigFileName)
data, err := os.ReadFile(configPath) //nolint:gosec // path built from caller-owned workspace dir
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read config.toml: %w", err)
}
content := string(data)
hasNew := containsCodexFeatureLine(content, codexHooksFeatureLine)
hasLegacy := containsCodexFeatureLine(content, codexLegacyHookFeatureLine)
switch {
case hasNew && hasLegacy:
content = stripCodexLegacyHookFeatureLine(content)
case hasNew:
return nil
case hasLegacy:
content = strings.Replace(content, codexLegacyHookFeatureLine, codexHooksFeatureLine, 1)
case strings.Contains(content, "[features]"):
content = strings.Replace(content, "[features]", "[features]\n"+codexHooksFeatureLine, 1)
default:
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
content += "\n[features]\n" + codexHooksFeatureLine + "\n"
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil {
return fmt.Errorf("create .codex directory: %w", err)
}
if err := atomicWriteFile(configPath, []byte(content), 0o600); err != nil {
return fmt.Errorf("write config.toml: %w", err)
}
return nil
}
func containsCodexFeatureLine(content, line string) bool {
for raw := range strings.SplitSeq(content, "\n") {
if strings.TrimSpace(raw) == line {
return true
}
}
return false
}
func stripCodexLegacyHookFeatureLine(content string) string {
idx := strings.Index(content, codexLegacyHookFeatureLine)
if idx < 0 {
return content
}
end := idx + len(codexLegacyHookFeatureLine)
if end < len(content) && content[end] == '\n' {
end++
}
return content[:idx] + content[end:]
}

View File

@ -0,0 +1,83 @@
package adapters
import (
"fmt"
"sort"
)
// Capability identifies a feature an adapter provides, such as running an
// agent or backing an issue tracker.
type Capability string
// Capabilities an adapter can advertise in its Manifest.
const (
CapabilityAgent Capability = "agent"
CapabilityIssueTracker Capability = "issue-tracker"
)
// Manifest is an adapter's self-description: its id, human-facing name and
// description, version, and the capabilities it provides.
type Manifest struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Capabilities []Capability `json:"capabilities"`
}
// Adapter is the minimal contract every registered adapter satisfies.
type Adapter interface {
Manifest() Manifest
}
// Registry holds registered adapters keyed by their manifest id.
//
// Registry is not safe for concurrent registration: every Register call is
// expected at daemon boot, before any goroutine calls Get. Concurrent
// Register and Get would race on the underlying map.
type Registry struct {
adapters map[string]Adapter
}
// NewRegistry returns an empty Registry ready to Register adapters.
func NewRegistry() *Registry {
return &Registry{
adapters: make(map[string]Adapter),
}
}
// Register adds adapter under its manifest id. It returns an error when the id
// is empty or already registered.
func (r *Registry) Register(adapter Adapter) error {
manifest := adapter.Manifest()
if manifest.ID == "" {
return fmt.Errorf("adapter id is required")
}
if _, exists := r.adapters[manifest.ID]; exists {
return fmt.Errorf("adapter %q is already registered", manifest.ID)
}
r.adapters[manifest.ID] = adapter
return nil
}
// Get returns the registered adapter with the given id, or nil and false
// when no such adapter exists.
func (r *Registry) Get(id string) (Adapter, bool) {
p, ok := r.adapters[id]
return p, ok
}
// Manifests returns every registered adapter's manifest, sorted by id.
func (r *Registry) Manifests() []Manifest {
manifests := make([]Manifest, 0, len(r.adapters))
for _, adapter := range r.adapters {
manifests = append(manifests, adapter.Manifest())
}
sort.Slice(manifests, func(i, j int) bool {
return manifests[i].ID < manifests[j].ID
})
return manifests
}

View File

@ -128,7 +128,7 @@ func wrapLaunchCommandUnix(cfg ports.RuntimeConfig, shellPath string) string {
b.WriteString(shellQuote(path))
b.WriteString("; ")
}
b.WriteString(cfg.LaunchCommand)
b.WriteString(quoteArgvUnix(cfg.Argv))
b.WriteString("; exec ")
b.WriteString(shellQuote(shellPath))
b.WriteString(" -i")
@ -157,7 +157,7 @@ func wrapLaunchCommandPowerShell(cfg ports.RuntimeConfig) string {
b.WriteString(psQuote(path))
b.WriteString("; ")
}
b.WriteString(cfg.LaunchCommand)
b.WriteString(quoteArgvPowerShell(cfg.Argv))
return b.String()
}
@ -183,7 +183,7 @@ func wrapLaunchCommandCmd(cfg ports.RuntimeConfig) string {
b.WriteString(cmdQuote(path))
b.WriteString("\" && ")
}
b.WriteString(cfg.LaunchCommand)
b.WriteString(quoteArgvCmd(cfg.Argv))
return b.String()
}
@ -233,6 +233,40 @@ func cmdQuote(s string) string {
return strings.ReplaceAll(s, "\"", "\"\"")
}
// quoteArgvUnix renders argv as a POSIX-shell command, single-quoting each
// argument so a value with spaces stays one word under `sh -lc`.
func quoteArgvUnix(argv []string) string {
parts := make([]string, len(argv))
for i, a := range argv {
parts[i] = shellQuote(a)
}
return strings.Join(parts, " ")
}
// quoteArgvPowerShell renders argv for `powershell -Command`. The call operator
// `&` is required so a quoted first token is invoked as a command rather than
// echoed as a string literal.
func quoteArgvPowerShell(argv []string) string {
if len(argv) == 0 {
return ""
}
parts := make([]string, len(argv))
for i, a := range argv {
parts[i] = psQuote(a)
}
return "& " + strings.Join(parts, " ")
}
// quoteArgvCmd renders argv for cmd.exe, wrapping each argument in double quotes
// (doubling any embedded quote) so spaces don't split a single argument.
func quoteArgvCmd(argv []string) string {
parts := make([]string, len(argv))
for i, a := range argv {
parts[i] = "\"" + strings.ReplaceAll(a, "\"", "\"\"") + "\""
}
return strings.Join(parts, " ")
}
func kdlQuote(s string) string {
return strconv.Quote(s)
}

View File

@ -112,7 +112,7 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru
if cfg.WorkspacePath == "" {
return ports.RuntimeHandle{}, errors.New("zellij runtime: workspace path is required")
}
if cfg.LaunchCommand == "" {
if len(cfg.Argv) == 0 {
return ports.RuntimeHandle{}, errors.New("zellij runtime: launch command is required")
}
if err := validateEnvKeys(cfg.Env); err != nil {

View File

@ -30,7 +30,7 @@ func TestRuntimeIntegration(t *testing.T) {
h, err := r.Create(ctx, ports.RuntimeConfig{
SessionID: "ao_itest_zj",
WorkspacePath: t.TempDir(),
LaunchCommand: "printf ready-$AO_SESSION_ID\\n",
Argv: []string{"sh", "-c", "printf ready-$AO_SESSION_ID\\n"},
Env: map[string]string{"AO_SESSION_ID": id},
})
if err != nil {
@ -96,7 +96,7 @@ func TestRuntimeIntegrationUsesExactSessionParsing(t *testing.T) {
h, err := r.Create(ctx, ports.RuntimeConfig{
SessionID: "ao_zj_exact_long",
WorkspacePath: t.TempDir(),
LaunchCommand: "printf ready\\n",
Argv: []string{"printf", "ready\\n"},
})
if err != nil {
t.Fatalf("Create: %v", err)

View File

@ -102,7 +102,7 @@ func TestBuildLayoutExportsEnvAndKeepsPaneAlive(t *testing.T) {
}
defer func() { getenv = oldGetenv }()
got := buildLayout(ports.RuntimeConfig{WorkspacePath: "/tmp/ws", LaunchCommand: "ao run", Env: map[string]string{
got := buildLayout(ports.RuntimeConfig{WorkspacePath: "/tmp/ws", Argv: []string{"ao", "run"}, Env: map[string]string{
"AO_SESSION_ID": "sess-1",
"ODD": "can't",
"PATH": "/custom/bin:/usr/bin",
@ -114,7 +114,7 @@ func TestBuildLayoutExportsEnvAndKeepsPaneAlive(t *testing.T) {
"export AO_SESSION_ID='sess-1';",
"export ODD='can'\\\\''t';",
"export PATH='/custom/bin:/usr/bin';",
"ao run; exec '/bin/zsh' -i",
"'ao' 'run'; exec '/bin/zsh' -i",
} {
if !strings.Contains(got, want) {
t.Fatalf("layout missing %q in %q", want, got)
@ -132,7 +132,7 @@ func TestBuildLayoutUsesPowerShellLaunchOnWindowsShells(t *testing.T) {
}
defer func() { getenv = oldGetenv }()
got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, LaunchCommand: "Write-Host ready", Env: map[string]string{
got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, Argv: []string{"Write-Host", "ready"}, Env: map[string]string{
"AO_SESSION_ID": "sess-1",
}}, `C:\Program Files\PowerShell\7\pwsh.exe`)
@ -141,7 +141,7 @@ func TestBuildLayoutUsesPowerShellLaunchOnWindowsShells(t *testing.T) {
`args "-NoLogo" "-NoProfile" "-NoExit" "-Command"`,
"$env:AO_SESSION_ID = 'sess-1';",
"$env:PATH = ",
"Write-Host ready",
"& 'Write-Host' 'ready'",
} {
if !strings.Contains(got, want) {
t.Fatalf("powershell layout missing %q in %q", want, got)
@ -156,7 +156,7 @@ func TestBuildLayoutUsesCmdLaunchOnCmdShells(t *testing.T) {
}
defer func() { getenv = oldGetenv }()
got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, LaunchCommand: "echo ready", Env: map[string]string{
got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, Argv: []string{"echo", "ready"}, Env: map[string]string{
"AO_SESSION_ID": "sess-1",
}}, `C:\Windows\System32\cmd.exe`)
@ -164,7 +164,7 @@ func TestBuildLayoutUsesCmdLaunchOnCmdShells(t *testing.T) {
`pane command="C:\\Windows\\System32\\cmd.exe" name="agent"`,
`args "/D" "/S" "/K"`,
`AO_SESSION_ID=sess-1`,
"echo ready",
`\"echo\" \"ready\"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("cmd layout missing %q in %q", want, got)
@ -178,7 +178,7 @@ func TestCreateRejectsInvalidEnvKeys(t *testing.T) {
_, err := r.Create(context.Background(), ports.RuntimeConfig{
SessionID: "sess-1",
WorkspacePath: "/tmp/ws",
LaunchCommand: "echo ready",
Argv: []string{"echo", "ready"},
Env: map[string]string{"BAD KEY": "x"},
})
if err == nil || !strings.Contains(err.Error(), "invalid env key") {
@ -194,7 +194,7 @@ func TestCreateStartsSessionAndDiscoversPane(t *testing.T) {
handle, err := r.Create(context.Background(), ports.RuntimeConfig{
SessionID: "sess-1",
WorkspacePath: "/tmp/ws",
LaunchCommand: "echo ready",
Argv: []string{"echo", "ready"},
Env: map[string]string{"AO_SESSION_ID": "sess-1"},
})
if err != nil {

View File

@ -28,6 +28,9 @@ const (
// DefaultShutdownTimeout is the hard cap on graceful shutdown. After this
// the process exits even if connections are still draining.
DefaultShutdownTimeout = 10 * time.Second
// DefaultAgent is the agent adapter id the daemon wires when AO_AGENT is
// unset. It matches the claude-code adapter's manifest id.
DefaultAgent = "claude-code"
)
// Config is the fully-resolved daemon configuration. It is immutable once
@ -47,6 +50,10 @@ type Config struct {
// DataDir is the directory holding durable SQLite state: DB and WAL files.
// It is created on first use by the storage layer.
DataDir string
// Agent is the id of the agent adapter the daemon wires into the Session
// Manager (see DefaultAgent). Selected by AO_AGENT; startSession fails fast
// if no adapter with this id is registered.
Agent string
}
// Addr returns the host:port the HTTP server binds. It uses net.JoinHostPort so
@ -66,6 +73,7 @@ func (c Config) Addr() string {
// AO_SHUTDOWN_TIMEOUT shutdown deadline (Go duration > 0, default 10s)
// AO_RUN_FILE running.json path (default <state-dir>/running.json)
// AO_DATA_DIR durable state dir (default <state-dir>/data)
// AO_AGENT agent adapter id (default claude-code)
//
// The bind host is not configurable: the daemon is loopback-only by design.
func Load() (Config, error) {
@ -74,6 +82,7 @@ func Load() (Config, error) {
Port: DefaultPort,
RequestTimeout: DefaultRequestTimeout,
ShutdownTimeout: DefaultShutdownTimeout,
Agent: DefaultAgent,
}
if raw := os.Getenv("AO_PORT"); raw != "" {
@ -103,6 +112,10 @@ func Load() (Config, error) {
cfg.ShutdownTimeout = d
}
if raw := os.Getenv("AO_AGENT"); raw != "" {
cfg.Agent = raw
}
runFile, err := resolveRunFilePath()
if err != nil {
return Config{}, err

View File

@ -66,20 +66,37 @@ func Run() error {
termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log)
defer termMgr.Close()
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{Projects: projectsvc.New(store)})
// Bring up the Lifecycle Manager and the reaper first: it makes the session
// lifecycle write path live (reducer write -> store -> DB trigger ->
// change_log -> poller -> broadcaster) and gives startSession the shared LCM.
lcStack := startLifecycle(ctx, store, runtimeAdapter, log)
// Wire the controller-facing session service over the same store + LCM, the
// zellij runtime, a gitworktree workspace, and the per-session agent resolver
// (AO_AGENT default, validated here), then mount it on the API.
sessionSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, log)
if err != nil {
stop()
lcStack.Stop()
if cdcErr := cdcPipe.Stop(); cdcErr != nil {
log.Error("cdc pipeline shutdown", "err", cdcErr)
}
return fmt.Errorf("wire session service: %w", err)
}
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{
Projects: projectsvc.New(store),
Sessions: sessionSvc,
})
if err != nil {
stop()
lcStack.Stop()
if cdcErr := cdcPipe.Stop(); cdcErr != nil {
log.Error("cdc pipeline shutdown", "err", cdcErr)
}
return err
}
// Bring up the Lifecycle Manager and the reaper. This makes the session
// lifecycle write path live end-to-end: reducer write -> store -> DB trigger
// -> change_log -> poller -> broadcaster.
lcStack := startLifecycle(ctx, store, runtimeAdapter, log)
runErr := srv.Run(ctx)
// Shut the background goroutines down in order: cancel the context FIRST so

View File

@ -2,17 +2,31 @@ package daemon
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"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/workspace/gitworktree"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/lifecycle"
"github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
)
// lifecycleStack owns the runtime reaper goroutine started with the lifecycle
// reducer. The reducer itself is only used for wiring observations into storage.
type lifecycleStack struct {
// LCM is the Lifecycle Manager (the canonical write path). It is exposed so
// startSession can share the same reducer the reaper drives, rather than
// standing up a second store+LCM pair that would diverge under writes.
LCM *lifecycle.Manager
reaperDone <-chan struct{}
}
@ -21,9 +35,113 @@ type lifecycleStack struct {
func startLifecycle(ctx context.Context, store *sqlite.Store, runtime ports.Runtime, logger *slog.Logger) *lifecycleStack {
lcm := lifecycle.New(store, nil)
rp := reaper.New(lcm, store, runtime, reaper.Config{Logger: logger})
return &lifecycleStack{reaperDone: rp.Start(ctx)}
return &lifecycleStack{LCM: lcm, reaperDone: rp.Start(ctx)}
}
// Stop waits for the reaper goroutine to exit. The caller must cancel the ctx
// passed to startLifecycle before calling Stop.
func (l *lifecycleStack) Stop() { <-l.reaperDone }
// noopMessenger is a stub ports.AgentMessenger: durable writes and notifications
// work without it; only live agent nudges are absent until the runtime/agent
// nudge path is wired.
type noopMessenger struct{}
func (noopMessenger) Send(context.Context, domain.SessionID, string) error { return nil }
// startSession builds the controller-facing session service: a session manager
// over the real zellij runtime, a per-session gitworktree workspace, the shared
// store + LCM, and the per-session agent resolver (AO_AGENT default). The
// Messenger is a stub until the live agent-nudge path lands. The returned
// service is mounted at httpd APIDeps.Sessions.
func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, log *slog.Logger) (*sessionsvc.Service, error) {
agents, err := buildAgentResolver(cfg.Agent, log)
if err != nil {
return nil, err
}
ws, err := gitworktree.New(gitworktree.Options{
// Per-session worktrees live under the data dir, so a single AO_DATA_DIR
// override moves all durable per-user state together.
ManagedRoot: filepath.Join(cfg.DataDir, "worktrees"),
// An empty resolver fails every project lookup with a clear
// "no repo configured for project" error until the projects table feeds
// repo paths in — better than silently misrouting spawns.
RepoResolver: gitworktree.StaticRepoResolver{},
})
if err != nil {
return nil, fmt.Errorf("session workspace: %w", err)
}
mgr := sessionmanager.New(sessionmanager.Deps{
Runtime: runtime,
Agents: agents,
Workspace: ws,
Store: store,
Messenger: noopMessenger{},
Lifecycle: lcm,
DataDir: cfg.DataDir,
})
return sessionsvc.New(mgr, store), nil
}
// buildAgentRegistry returns a registry populated with the agent adapters the
// daemon ships, keyed by manifest id. Registration only fails on an
// empty/duplicate id — a programmer error, not a runtime condition.
func buildAgentRegistry() (*adapters.Registry, error) {
reg := adapters.NewRegistry()
for _, a := range []adapters.Adapter{claudecode.New(), codex.New()} {
if err := reg.Register(a); err != nil {
return nil, fmt.Errorf("register agent adapter %q: %w", a.Manifest().ID, err)
}
}
return reg, nil
}
// agentRegistry adapts the generic adapter Registry to ports.AgentResolver: it
// maps a session's harness onto the registered adapter of the same id and
// asserts that adapter drives an agent. An empty harness falls back to the
// daemon's configured default (AO_AGENT), so a spawn that names no harness still
// gets a real agent.
type agentRegistry struct {
reg *adapters.Registry
defaultHarness domain.AgentHarness
}
var _ ports.AgentResolver = agentRegistry{}
func (a agentRegistry) Agent(harness domain.AgentHarness) (ports.Agent, bool) {
if harness == "" {
harness = a.defaultHarness
}
adapter, ok := a.reg.Get(string(harness))
if !ok {
return nil, false
}
agent, ok := adapter.(ports.Agent)
return agent, ok
}
// buildAgentResolver constructs the per-session agent resolver the Session
// Manager consumes (sessionmanager.Deps.Agents): a registry of the shipped
// adapters plus the configured default harness. It fails fast if the default
// does not resolve, so a typo'd AO_AGENT surfaces at startup. The session lane
// plugs this in when it mounts the controller-facing session service at the
// httpd APIDeps.Sessions slot.
func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResolver, error) {
if defaultAgent == "" {
defaultAgent = config.DefaultAgent
}
reg, err := buildAgentRegistry()
if err != nil {
return nil, err
}
resolver := agentRegistry{reg: reg, defaultHarness: domain.AgentHarness(defaultAgent)}
if _, ok := resolver.Agent(""); !ok {
return nil, fmt.Errorf("configured default agent %q is not a registered adapter", defaultAgent)
}
ids := make([]string, 0)
for _, mf := range reg.Manifests() {
ids = append(ids, mf.ID)
}
log.Info("built per-session agent resolver", "default", defaultAgent, "registered", ids)
return resolver, nil
}

View File

@ -2,11 +2,16 @@ package daemon
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij"
"github.com/aoagents/agent-orchestrator/backend/internal/cdc"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/lifecycle"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
@ -68,3 +73,62 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) {
t.Fatalf("expected a change_log event for %s to reach the broadcaster, got %d events", rec.ID, len(got))
}
}
// TestWiring_AgentResolverResolvesRealAdapters asserts buildAgentResolver wires a
// real registry-backed per-session resolver: each harness resolves to the
// matching registered adapter, an empty harness falls back to the AO_AGENT
// default, and an unknown harness misses.
func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
resolver, err := buildAgentResolver("", log) // empty default → claude-code
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
harness domain.AgentHarness
wantID string
}{
{domain.HarnessClaudeCode, "claude-code"},
{domain.HarnessCodex, "codex"},
{"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default
} {
agent, ok := resolver.Agent(tc.harness)
if !ok {
t.Fatalf("resolver has no agent for harness %q", tc.harness)
}
described, ok := agent.(adapters.Adapter)
if !ok {
t.Fatalf("agent for harness %q is %T, not a registered adapters.Adapter", tc.harness, agent)
}
if got := described.Manifest().ID; got != tc.wantID {
t.Fatalf("harness %q resolved to adapter %q, want %q", tc.harness, got, tc.wantID)
}
}
if _, ok := resolver.Agent("definitely-not-an-agent"); ok {
t.Fatal("unknown harness resolved to an agent; want a miss")
}
}
// TestWiring_StartSessionBuildsSessionService asserts the daemon's startSession
// constructs a real controller-facing session service end to end (resolver +
// gitworktree workspace + session manager over the shared store/LCM), which is
// what gets mounted at httpd APIDeps.Sessions.
func TestWiring_StartSessionBuildsSessionService(t *testing.T) {
store, err := sqlite.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
lcm := lifecycle.New(store, nil)
cfg := config.Config{DataDir: t.TempDir()}
svc, err := startSession(cfg, zellij.New(zellij.Options{}), store, lcm, log)
if err != nil {
t.Fatalf("startSession: %v", err)
}
if svc == nil {
t.Fatal("startSession returned nil session service")
}
}

View File

@ -26,11 +26,30 @@ func (s *stubRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error
type stubAgent struct{}
func (stubAgent) GetLaunchCommand(ports.AgentConfig) string { return "launch" }
func (stubAgent) GetEnvironment(ports.AgentConfig) map[string]string {
return map[string]string{"X": "1"}
func (stubAgent) GetConfigSpec(context.Context) (ports.ConfigSpec, error) {
return ports.ConfigSpec{}, nil
}
func (stubAgent) GetRestoreCommand(id string) string { return "resume " + id }
func (stubAgent) GetLaunchCommand(context.Context, ports.LaunchConfig) ([]string, error) {
return []string{"launch"}, nil
}
func (stubAgent) GetPromptDeliveryStrategy(context.Context, ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
return ports.PromptDeliveryInCommand, nil
}
func (stubAgent) GetAgentHooks(context.Context, ports.WorkspaceHookConfig) error { return nil }
func (stubAgent) GetRestoreCommand(_ context.Context, cfg ports.RestoreConfig) ([]string, bool, error) {
if id := cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]; id != "" {
return []string{"resume", id}, true, nil
}
return nil, false, nil
}
func (stubAgent) SessionInfo(context.Context, ports.SessionRef) (ports.SessionInfo, bool, error) {
return ports.SessionInfo{}, false, nil
}
// stubAgents resolves every harness to the same stubAgent.
type stubAgents struct{}
func (stubAgents) Agent(domain.AgentHarness) (ports.Agent, bool) { return stubAgent{}, true }
type stubWorkspace struct{ destroyed int }
@ -78,7 +97,7 @@ func newStack(t *testing.T) *stack {
prm := prsvc.New(prsvc.Deps{Writer: store, Lifecycle: lcm})
rt := &stubRuntime{}
ws := &stubWorkspace{}
mgr := sessionmanager.New(sessionmanager.Deps{Runtime: rt, Agent: stubAgent{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm})
mgr := sessionmanager.New(sessionmanager.Deps{Runtime: rt, Agents: stubAgents{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm})
sm := sessionsvc.New(mgr, store)
return &stack{store: store, sm: sm, lcm: lcm, prm: prm, rt: rt, ws: ws, msg: msg}
}

View File

@ -0,0 +1,146 @@
package ports
import (
"context"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// Agent is the contract every CLI coding agent adapter (claude-code, codex, …)
// must satisfy. It supplies the argv and process configuration the Session
// Manager needs to launch, restore, and read back a native agent session.
type Agent interface {
// GetConfigSpec describes the agent-specific config keys AO can
// expose to users in the AO config.
GetConfigSpec(ctx context.Context) (ConfigSpec, error)
// GetLaunchCommand builds the argv AO should run to start this agent.
GetLaunchCommand(ctx context.Context, cfg LaunchConfig) (cmd []string, err error)
// GetPromptDeliveryStrategy tells AO whether the prompt is included in
// the launch command or must be sent after the agent process starts.
GetPromptDeliveryStrategy(ctx context.Context, cfg LaunchConfig) (PromptDeliveryStrategy, error)
// GetAgentHooks installs or merges AO hooks into the agent's
// native workspace-local hook config. It must preserve user-defined hooks.
GetAgentHooks(ctx context.Context, cfg WorkspaceHookConfig) error
// GetRestoreCommand builds an argv that continues an existing native agent
// session. ok=false means no existing native session can be continued.
GetRestoreCommand(ctx context.Context, cfg RestoreConfig) (cmd []string, ok bool, err error)
// SessionInfo reads agent-owned session metadata such as native session id,
// display title, or summary. ok=false means no info is available.
SessionInfo(ctx context.Context, session SessionRef) (info SessionInfo, ok bool, err error)
}
// AgentResolver maps a session's harness onto the Agent adapter that drives it,
// so the Session Manager can spawn (and restore) a different agent per session
// without depending on the concrete adapter registry. ok=false means no adapter
// is registered for that harness.
type AgentResolver interface {
Agent(harness domain.AgentHarness) (Agent, bool)
}
// MetadataKeyAgentSessionID is the SessionRef.Metadata key that carries an
// agent's native session id. It matches the json tag on
// domain.SessionMetadata.AgentSessionID and the key the adapters read, so the
// Session Manager can bridge its typed metadata onto a SessionRef without
// either side hard-coding the other's vocabulary.
const MetadataKeyAgentSessionID = "agentSessionId"
// AgentConfig holds values loaded from the selected agent's config section.
// Agent adapters own validation for their custom keys.
type AgentConfig map[string]any
// ConfigSpec describes the agent-specific config keys AO can expose to users.
type ConfigSpec struct {
Fields []ConfigField
}
// ConfigField describes one user-facing agent config key.
type ConfigField struct {
Key string
Type ConfigFieldType
Description string
Required bool
Default any
Enum []string
}
// ConfigFieldType is the primitive value kind AO expects for a field.
type ConfigFieldType string
// The primitive value kinds a ConfigField can declare.
const (
ConfigFieldString ConfigFieldType = "string"
ConfigFieldBool ConfigFieldType = "bool"
ConfigFieldNumber ConfigFieldType = "number"
ConfigFieldStringList ConfigFieldType = "string_list"
ConfigFieldEnum ConfigFieldType = "enum"
)
// LaunchConfig carries inputs needed to build a new agent launch command.
type LaunchConfig struct {
Config AgentConfig
IssueID string
Permissions PermissionMode
Prompt string
SessionID string
SystemPrompt string
SystemPromptFile string
WorkspacePath string
}
// WorkspaceHookConfig carries inputs needed to install workspace-local agent hooks.
type WorkspaceHookConfig struct {
Config AgentConfig
DataDir string
SessionID string
WorkspacePath string
}
// RestoreConfig carries inputs needed to continue an existing native agent session.
type RestoreConfig struct {
Config AgentConfig
Permissions PermissionMode
Session SessionRef
}
// SessionRef identifies an AO session whose agent-owned metadata may be read.
type SessionRef struct {
ID string
Metadata map[string]string
WorkspacePath string
}
// SessionInfo contains agent-owned session metadata.
type SessionInfo struct {
AgentSessionID string
Metadata map[string]string
Title string
Summary string
}
// PermissionMode controls how much review an agent requires before acting.
type PermissionMode string
// The permission modes adapters map onto their agent's native approval flags.
const (
// PermissionModeDefault is special: adapters emit no flag for it so the
// agent resolves its starting mode from the user's own config (e.g.
// Claude's TUI reading ~/.claude/settings.json defaultMode).
PermissionModeDefault PermissionMode = "default"
PermissionModeAcceptEdits PermissionMode = "accept-edits"
PermissionModeAuto PermissionMode = "auto"
PermissionModeBypassPermissions PermissionMode = "bypass-permissions"
)
// PromptDeliveryStrategy describes how AO should deliver the initial prompt.
type PromptDeliveryStrategy string
// How the orchestrator hands the initial prompt to a freshly launched agent.
const (
PromptDeliveryInCommand PromptDeliveryStrategy = "in_command"
PromptDeliveryAfterStart PromptDeliveryStrategy = "after_start"
)

View File

@ -0,0 +1,24 @@
package ports_test
import (
"reflect"
"strings"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
// TestMetadataKeyAgentSessionIDMatchesDomainJSONTag pins the hand-maintained
// invariant documented on ports.MetadataKeyAgentSessionID: a silent rename on
// either side would break session restore.
func TestMetadataKeyAgentSessionIDMatchesDomainJSONTag(t *testing.T) {
field, ok := reflect.TypeOf(domain.SessionMetadata{}).FieldByName("AgentSessionID")
if !ok {
t.Fatalf("domain.SessionMetadata has no AgentSessionID field")
}
name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
if name != ports.MetadataKeyAgentSessionID {
t.Fatalf("json tag %q != ports.MetadataKeyAgentSessionID %q", name, ports.MetadataKeyAgentSessionID)
}
}

View File

@ -31,10 +31,13 @@ type Runtime interface {
}
// RuntimeConfig is the spec for launching a session's process in a Runtime.
// Argv is the agent's launch command as discrete arguments; each Runtime
// shell-quotes it for its own shell, so the command survives args with spaces
// (e.g. a prompt) without the caller guessing the target shell's quoting.
type RuntimeConfig struct {
SessionID domain.SessionID
WorkspacePath string
LaunchCommand string
Argv []string
Env map[string]string
}
@ -44,20 +47,7 @@ type RuntimeHandle struct {
ID string
}
// Agent is the AI coding tool driving a session (claude-code, codex, …): it
// supplies the launch/restore commands and the process environment.
type Agent interface {
GetLaunchCommand(cfg AgentConfig) string
GetEnvironment(cfg AgentConfig) map[string]string
GetRestoreCommand(agentSessionID string) string
}
// AgentConfig is the per-session input to an Agent's command and environment.
type AgentConfig struct {
SessionID domain.SessionID
WorkspacePath string
Prompt string
}
// The Agent port and its supporting types live in agent.go.
// Workspace is the isolated checkout an agent works in (a git worktree or clone).
type Workspace interface {

View File

@ -24,6 +24,8 @@ const (
EnvSessionID = "AO_SESSION_ID"
EnvProjectID = "AO_PROJECT_ID"
EnvIssueID = "AO_ISSUE_ID"
// EnvDataDir tells a spawned agent's AO hook commands where the store lives.
EnvDataDir = "AO_DATA_DIR"
)
type lifecycleRecorder interface {
@ -47,23 +49,27 @@ type Store interface {
// the outbound ports. User-facing read-model assembly lives in the service package.
type Manager struct {
runtime runtimeController
agent ports.Agent
agents ports.AgentResolver
workspace ports.Workspace
store Store
messenger ports.AgentMessenger
lcm lifecycleRecorder
dataDir string
clock func() time.Time
}
// Deps are the collaborators a Session Manager needs; New wires them together.
type Deps struct {
Runtime runtimeController
Agent ports.Agent
Agents ports.AgentResolver
Workspace ports.Workspace
Store Store
Messenger ports.AgentMessenger
Lifecycle lifecycleRecorder
Clock func() time.Time
// DataDir is exported to spawned agents as AO_DATA_DIR so their hook
// commands can open the same store.
DataDir string
Clock func() time.Time
}
// New builds a Session Manager from its dependencies, defaulting the clock to
@ -71,11 +77,12 @@ type Deps struct {
func New(d Deps) *Manager {
m := &Manager{
runtime: d.Runtime,
agent: d.Agent,
agents: d.Agents,
workspace: d.Workspace,
store: d.Store,
messenger: d.Messenger,
lcm: d.Lifecycle,
dataDir: d.DataDir,
clock: d.Clock,
}
if m.clock == nil {
@ -100,12 +107,34 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
return domain.SessionRecord{}, fmt.Errorf("spawn %s: workspace: %w", id, err)
}
agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path, Prompt: buildPrompt(cfg)}
prompt := buildPrompt(cfg)
agent, ok := m.agents.Agent(cfg.Harness)
if !ok {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness)
}
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err)
}
argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{
SessionID: string(id),
WorkspacePath: ws.Path,
Prompt: prompt,
IssueID: string(cfg.IssueID),
})
if err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: launch command: %w", id, err)
}
handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{
SessionID: id,
WorkspacePath: ws.Path,
LaunchCommand: m.agent.GetLaunchCommand(agentCfg),
Env: spawnEnv(m.agent.GetEnvironment(agentCfg), id, cfg.ProjectID, cfg.IssueID),
Argv: argv,
Env: spawnEnv(id, cfg.ProjectID, cfg.IssueID, m.dataDir),
})
if err != nil {
_ = m.workspace.Destroy(ctx, ws)
@ -113,7 +142,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
return domain.SessionRecord{}, fmt.Errorf("spawn %s: runtime: %w", id, err)
}
metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandleID: handle.ID, Prompt: agentCfg.Prompt}
metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandleID: handle.ID, Prompt: prompt}
if err := m.lcm.MarkSpawned(ctx, id, metadata); err != nil {
_ = m.runtime.Destroy(ctx, handle)
_ = m.workspace.Destroy(ctx, ws)
@ -180,16 +209,22 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
if err != nil {
return domain.SessionRecord{}, fmt.Errorf("restore %s: workspace: %w", id, err)
}
agentCfg := ports.AgentConfig{SessionID: id, WorkspacePath: ws.Path, Prompt: meta.Prompt}
launch := m.agent.GetRestoreCommand(meta.AgentSessionID)
if meta.AgentSessionID == "" {
launch = m.agent.GetLaunchCommand(agentCfg)
agent, ok := m.agents.Agent(rec.Harness)
if !ok {
return domain.SessionRecord{}, fmt.Errorf("restore %s: no agent adapter for harness %q", id, rec.Harness)
}
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil {
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
}
argv, err := restoreArgv(ctx, agent, id, ws.Path, meta)
if err != nil {
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
}
handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{
SessionID: id,
WorkspacePath: ws.Path,
LaunchCommand: launch,
Env: spawnEnv(m.agent.GetEnvironment(agentCfg), id, rec.ProjectID, rec.IssueID),
Argv: argv,
Env: spawnEnv(id, rec.ProjectID, rec.IssueID, m.dataDir),
})
if err != nil {
return domain.SessionRecord{}, fmt.Errorf("restore %s: runtime: %w", id, err)
@ -275,15 +310,68 @@ func buildPrompt(cfg ports.SpawnConfig) string {
}
}
func spawnEnv(base map[string]string, id domain.SessionID, project domain.ProjectID, issue domain.IssueID) map[string]string {
env := make(map[string]string, len(base)+3)
for k, v := range base {
env[k] = v
func spawnEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueID, dataDir string) map[string]string {
return map[string]string{
EnvSessionID: string(id),
EnvProjectID: string(project),
EnvIssueID: string(issue),
EnvDataDir: dataDir,
}
env[EnvSessionID] = string(id)
env[EnvProjectID] = string(project)
env[EnvIssueID] = string(issue)
return env
}
// preLauncher is an optional Agent capability: a step the manager runs before
// launch. Claude Code implements it to record workspace trust in ~/.claude.json
// so its interactive "do you trust this folder?" dialog can't block the headless
// pane. Adapters that don't need it simply omit the method.
type preLauncher interface {
PreLaunch(ctx context.Context, cfg ports.LaunchConfig) error
}
// prepareWorkspace runs the per-session pre-launch steps before the runtime
// starts the agent: installing the workspace-local activity hooks (so early
// startup hooks can update the already-created session row), then any optional
// PreLaunch step. Shared by Spawn and Restore.
func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string) error {
if err := agent.GetAgentHooks(ctx, ports.WorkspaceHookConfig{
SessionID: string(id),
WorkspacePath: workspacePath,
DataDir: m.dataDir,
}); err != nil {
return fmt.Errorf("install hooks: %w", err)
}
if pl, ok := agent.(preLauncher); ok {
if err := pl.PreLaunch(ctx, ports.LaunchConfig{SessionID: string(id), WorkspacePath: workspacePath}); err != nil {
return fmt.Errorf("pre-launch: %w", err)
}
}
return nil
}
// restoreArgv builds the argv to relaunch a torn-down session: the agent's
// native resume command when it can continue the session, else a fresh launch.
// The agent signals via ok=false (e.g. no native session id captured yet).
func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string, meta domain.SessionMetadata) ([]string, error) {
ref := ports.SessionRef{
ID: string(id),
WorkspacePath: workspacePath,
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: meta.AgentSessionID},
}
cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref})
if err != nil {
return nil, fmt.Errorf("restore command: %w", err)
}
if ok {
return cmd, nil
}
argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{
SessionID: string(id),
WorkspacePath: workspacePath,
Prompt: meta.Prompt,
})
if err != nil {
return nil, fmt.Errorf("launch command: %w", err)
}
return argv, nil
}
func runtimeHandle(meta domain.SessionMetadata) ports.RuntimeHandle {

View File

@ -97,11 +97,30 @@ func (r *fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { r.de
type fakeAgent struct{}
func (fakeAgent) GetLaunchCommand(ports.AgentConfig) string { return "launch" }
func (fakeAgent) GetEnvironment(ports.AgentConfig) map[string]string {
return map[string]string{"X": "1"}
func (fakeAgent) GetConfigSpec(context.Context) (ports.ConfigSpec, error) {
return ports.ConfigSpec{}, nil
}
func (fakeAgent) GetRestoreCommand(id string) string { return "resume " + id }
func (fakeAgent) GetLaunchCommand(context.Context, ports.LaunchConfig) ([]string, error) {
return []string{"launch"}, nil
}
func (fakeAgent) GetPromptDeliveryStrategy(context.Context, ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
return ports.PromptDeliveryInCommand, nil
}
func (fakeAgent) GetAgentHooks(context.Context, ports.WorkspaceHookConfig) error { return nil }
func (fakeAgent) GetRestoreCommand(_ context.Context, cfg ports.RestoreConfig) ([]string, bool, error) {
if id := cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]; id != "" {
return []string{"resume", id}, true, nil
}
return nil, false, nil
}
func (fakeAgent) SessionInfo(context.Context, ports.SessionRef) (ports.SessionInfo, bool, error) {
return ports.SessionInfo{}, false, nil
}
// fakeAgents resolves every harness to the same fakeAgent.
type fakeAgents struct{}
func (fakeAgents) Agent(domain.AgentHarness) (ports.Agent, bool) { return fakeAgent{}, true }
type fakeWorkspace struct {
destroyErr error
@ -130,7 +149,7 @@ func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) {
st := newFakeStore()
rt := &fakeRuntime{}
ws := &fakeWorkspace{}
m := New(Deps{Runtime: rt, Agent: fakeAgent{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}})
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}})
return m, st, rt, ws
}
func seedTerminal(st *fakeStore, id domain.SessionID, meta domain.SessionMetadata) {

View File

@ -34,7 +34,7 @@ func TestSessionStreamsRealZellijPane(t *testing.T) {
handle, err := rt.Create(context.Background(), ports.RuntimeConfig{
SessionID: domain.SessionID(name),
WorkspacePath: t.TempDir(),
LaunchCommand: "printf AO_READY\n",
Argv: []string{"printf", "AO_READY\\n"},
})
if err != nil {
t.Fatalf("Create: %v", err)

117
docs/agent/README.md Normal file
View File

@ -0,0 +1,117 @@
# Agent Adapter PRD
## Goal
Agent adapters let AO run and observe different CLI coding agents without hardcoding agent-specific behavior into the spawn engine. Every CLI coding agent must implement the contract in `backend/internal/ports/agent.go`.
The important current slice is hook-derived session info. AO should know a running worker's native agent session id, title, and summary from agent hooks installed in the per-session worktree, not from scanning agent transcript/cache files.
## Current Decisions
- AO only needs to derive session info for AO-managed sessions.
- Hook installation happens at worktree/session creation time.
- `SessionInfo` reads normalized metadata persisted in AO's session store.
- `SessionInfo` must not infer display info by reading agent transcript/cache files.
- `SummaryIsFallback` is removed from `ports.SessionInfo`.
- `TranscriptPath` is removed from `ports.SessionInfo`.
- `Title` and `Summary` are both first-class fields.
- `Title` is derived from the user prompt hook.
- `Summary` is derived from the stop/final assistant hook.
- Agent adapter `Metadata` should stay nil/empty unless an adapter has a real extra field that does not belong in the normalized contract.
## Agent Contract
The shared contract lives in `backend/internal/ports/agent.go`.
Required adapter behavior:
- `GetConfigSpec` describes user-facing agent config.
- `GetLaunchCommand` builds the native agent command.
- `GetPromptDeliveryStrategy` says whether the prompt is passed in argv or sent after launch.
- `GetAgentHooks` installs or merges AO hooks into the agent's workspace-local hook config.
- `GetRestoreCommand` builds a native resume command when restore is supported.
- `SessionInfo` returns normalized metadata:
- `AgentSessionID`
- `Title`
- `Summary`
- optional adapter-specific `Metadata`
Implementation layout:
- Agent-specific hook installation should live beside the agent adapter in `backend/internal/adapters/agent/<agent>/hooks.go`; the hook commands are defined in code, not embedded template files.
- Launch, restore, and session-info behavior can stay in the main agent implementation unless the file grows enough to justify another split.
## Metadata Keys
Hook callbacks persist these normalized keys in the session metadata JSON blob:
- `agentSessionId`: native agent session id.
- `title`: display title, derived from the first user prompt hook for the session.
- `summary`: display summary, derived from the final assistant message exposed to the stop hook.
The original spawn prompt may remain in metadata as `prompt` for launch/debug fallback, but `title` is the preferred display title once hook metadata lands.
## Hook Methodology
Agent adapters install hooks into the worktree-local config owned by the native agent.
Hook callbacks run through hidden AO CLI commands:
```text
ao hooks <agent-adapter> <event>
```
The callback:
1. Reads the native hook JSON payload from stdin.
2. Reads the AO session id from `AO_SESSION_ID`.
3. Opens the AO SQLite store (`ao.db`) in the data dir — `AO_DATA_DIR`, default `<user config dir>/agent-orchestrator/data`.
4. Merges normalized metadata into the matching session row.
5. Publishes `session.updated` when metadata changed.
6. Prints `{}` and exits 0 for successful no-op cases, including non-AO sessions or missing rows.
The spawn engine inserts the AO session row before launching the durability provider so early startup hooks can update an existing row. If launch fails after insertion, spawn deletes the row during rollback.
## Restore Boundary
Session display info and native restore are separate concerns.
Some agents may still need transcript-derived or deterministic native ids for `GetRestoreCommand` until restore is redesigned for that agent. Do not remove restore support just because `SessionInfo` stops reading transcripts.
For `SessionInfo`, transcript/cache files are not an acceptable source of title or summary.
## UI And Events
The workspace adapter prefers:
- `metadata.title` as session title.
- `metadata.summary` as session description.
- `metadata.prompt` only as fallback.
Hook metadata changes publish `session.updated`. The frontend listens to `session.created`, `session.terminated`, and `session.updated` and invalidates the workspace query.
## Acceptance Criteria
Agent adapter behavior:
- Agent hook installation preserves user hooks and deduplicates AO hooks.
- Hook callbacks persist native session id, title, and summary.
- `SessionInfo` returns normalized fields from persisted metadata.
- `SessionInfo` does not read transcripts or caches for title/summary.
- Adapter-specific metadata stays nil/empty unless a concrete feature requires it.
Engine and UI:
- Spawn installs hooks before launching the native agent.
- The session row exists before launch so hooks can merge metadata.
- Launch failure after row insertion deletes the row.
- Metadata updates publish `session.updated`.
- The dashboard refreshes title/summary without a manual reload.
Verification:
```sh
(cd backend && go test ./...)
(cd frontend && npm run typecheck)
```

61
flake.lock Normal file
View File

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1780030872,
"narHash": "sha256-u6WU/yd/o8iYQrHX3RAwO1hYa3LkoSL+WNQD0rJfJZQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e9a7635a57597d9754eccebdfc7045e6c8600e6b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

41
flake.nix Normal file
View File

@ -0,0 +1,41 @@
{
description = "agent-orchestrator development shell";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
nixpkgs,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs { inherit system; };
go = pkgs.go_1_25;
in
{
devShells.default = pkgs.mkShell {
buildInputs = [
go
pkgs.gotools
pkgs.nodejs_22
pkgs.pnpm_10
pkgs.just
];
shellHook = ''
export GOROOT="${go}/share/go"
export GOPATH="$PWD/.go"
export GOBIN="$GOPATH/bin"
export PNPM_HOME="$PWD/.pnpm"
export PATH="$GOBIN:$PNPM_HOME:$PATH"
'';
};
}
);
}