// 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 `. 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 `tmux 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 ( "bytes" "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "sync" "time" "github.com/google/uuid" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) const ( // adapterID is the registry id and the value users pass to // `ao spawn --agent`. adapterID = "claude-code" ) // 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 { agentbase.Base 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) var _ ports.AgentAuthChecker = (*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, }, } } // permissionConfigEnum lists the permission modes the "permissions" config key // accepts. It mirrors the ports.PermissionMode constants so a project's stored // config validates against the same vocabulary the launch command maps. var permissionConfigEnum = []string{ string(ports.PermissionModeDefault), string(ports.PermissionModeAcceptEdits), string(ports.PermissionModeAuto), string(ports.PermissionModeBypassPermissions), } // GetConfigSpec reports the per-project agent config keys Claude Code // understands: a model override and a starting permission mode. func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { if err := ctx.Err(); err != nil { return ports.ConfigSpec{}, err } return ports.ConfigSpec{ Fields: []ports.ConfigField{ { Key: "model", Type: ports.ConfigFieldString, Description: "Model override passed to `claude --model` (e.g. claude-opus-4-5).", }, { Key: "permissions", Type: ports.ConfigFieldEnum, Description: "Starting permission mode.", Enum: permissionConfigEnum, }, }, }, nil } // GetLaunchCommand builds the argv to start an interactive Claude Code // session. Shape: // // claude [--session-id ] \ // [--permission-mode ] \ // [--append-system-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. // // 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) { // Defense-in-depth: the project service validates on write, but re-check // here so a config written by any other path can't launch a bad command. if err := cfg.Config.Validate(); err != nil { return nil, fmt.Errorf("claude-code: %w", err) } 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)) } // A project's configured permissions drive the starting mode; the explicit // LaunchConfig.Permissions wins when set so a per-spawn override still takes // precedence over the stored project default. permissions := cfg.Permissions if permissions == "" { permissions = cfg.Config.Permissions } appendPermissionFlags(&cmd, permissions) appendToolFlags(&cmd, cfg.AllowedTools, cfg.DisallowedTools) if model := strings.TrimSpace(cfg.Config.Model); model != "" { cmd = append(cmd, "--model", model) } 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 } // 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 ] --resume `. 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, 7) cmd = append(cmd, binary) appendPermissionFlags(&cmd, cfg.Permissions) systemPrompt, err := resolveRestoreSystemPrompt(cfg) if err != nil { return nil, false, err } if systemPrompt != "" { // --resume rebuilds the system prompt from the current flags (it is // not stored in the transcript), so standing instructions must be // re-appended or a restored orchestrator loses its role. cmd = append(cmd, "--append-system-prompt", systemPrompt) } 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, ok := agentbase.StandardSessionInfo(session) return info, ok, nil } // AuthStatus checks Claude Code's local authentication state without starting a // session. func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { binary, err := p.claudeBinary(ctx) if err != nil { return ports.AgentAuthStatusUnknown, err } if status, ok, err := claudeLocalAuthStatus(ctx); err != nil { return ports.AgentAuthStatusUnknown, err } else if ok { return status, nil } probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() out, err := exec.CommandContext(probeCtx, binary, "auth", "status").CombinedOutput() if probeCtx.Err() != nil { return ports.AgentAuthStatusUnknown, probeCtx.Err() } if status, ok := claudeAuthStatusFromOutput(out); ok { return status, nil } if err != nil { return ports.AgentAuthStatusUnauthorized, nil } return ports.AgentAuthStatusUnknown, nil } func claudeAuthStatusFromOutput(out []byte) (ports.AgentAuthStatus, bool) { start := bytes.IndexByte(out, '{') end := bytes.LastIndexByte(out, '}') if start < 0 || end < start { return ports.AgentAuthStatusUnknown, false } var status struct { LoggedIn bool `json:"loggedIn"` } if json.Unmarshal(out[start:end+1], &status) != nil { return ports.AgentAuthStatusUnknown, false } if status.LoggedIn { return ports.AgentAuthStatusAuthorized, true } return ports.AgentAuthStatusUnauthorized, true } func claudeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { if err := ctx.Err(); err != nil { return ports.AgentAuthStatusUnknown, false, err } if strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) != "" { return ports.AgentAuthStatusAuthorized, true, nil } cfgPath, err := claudeConfigPath() if err != nil { return ports.AgentAuthStatusUnknown, false, err } return claudeConfigAuthStatus(cfgPath) } func claudeConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { data, err := os.ReadFile(path) if os.IsNotExist(err) { return ports.AgentAuthStatusUnknown, false, nil } if err != nil { return ports.AgentAuthStatusUnknown, false, err } if strings.TrimSpace(string(data)) == "" { return ports.AgentAuthStatusUnknown, false, nil } var root map[string]json.RawMessage if err := json.Unmarshal(data, &root); err != nil { return ports.AgentAuthStatusUnknown, false, err } var hasSubscription bool if raw := root["hasAvailableSubscription"]; len(raw) > 0 { _ = json.Unmarshal(raw, &hasSubscription) } var userID string if raw := root["userID"]; len(raw) > 0 { _ = json.Unmarshal(raw, &userID) } if strings.TrimSpace(userID) != "" { return ports.AgentAuthStatusAuthorized, true, nil } var oauthAccount map[string]any if raw := root["oauthAccount"]; len(raw) > 0 { if err := json.Unmarshal(raw, &oauthAccount); err != nil { return ports.AgentAuthStatusUnknown, false, err } } if len(oauthAccount) == 0 { return ports.AgentAuthStatusUnknown, false, nil } if hasSubscription { return ports.AgentAuthStatusAuthorized, true, nil } if accountUUID, ok := oauthAccount["accountUuid"].(string); ok && strings.TrimSpace(accountUUID) != "" { return ports.AgentAuthStatusAuthorized, true, nil } return ports.AgentAuthStatusUnknown, false, 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 // inline instructions when AO has them. func resolveSystemPrompt(cfg ports.LaunchConfig) (string, error) { if cfg.SystemPrompt != "" { return cfg.SystemPrompt, nil } 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 "", nil } func resolveRestoreSystemPrompt(cfg ports.RestoreConfig) (string, error) { if cfg.SystemPrompt != "" { return cfg.SystemPrompt, nil } 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 "", 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 ports.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") } } // appendToolFlags emits --allowedTools / --disallowedTools for a tool-scoped // launch. Each list is joined with commas into one value so rules that contain // spaces (e.g. "Bash(git diff:*)") are not split into separate tool names. // Empty lists emit nothing, so an unrestricted launch is unchanged. These rules // only bite when the launch is off bypassPermissions, which ignores them. func appendToolFlags(cmd *[]string, allowed, disallowed []string) { if len(allowed) > 0 { *cmd = append(*cmd, "--allowedTools", strings.Join(allowed, ",")) } if len(disallowed) > 0 { *cmd = append(*cmd, "--disallowedTools", strings.Join(disallowed, ",")) } } // claudeBinarySpec locates the claude binary: PATH first, then the native // installer's locations, npm global, Homebrew, and the claude-managed dir. var claudeBinarySpec = binaryutil.BinarySpec{ Label: "claude", Names: []string{"claude"}, WinNames: []string{"claude.cmd", "claude.exe", "claude"}, UnixPaths: []string{"/usr/local/bin/claude", "/opt/homebrew/bin/claude"}, UnixHomePaths: [][]string{{".local", "bin", "claude"}, {".npm", "bin", "claude"}, {".claude", "local", "claude"}}, WinPaths: []binaryutil.WinPath{ {Base: binaryutil.WinAppData, Parts: []string{"npm", "claude.cmd"}}, {Base: binaryutil.WinAppData, Parts: []string{"npm", "claude.exe"}}, }, } // ResolveClaudeBinary returns the path to the claude binary, or a wrapped // ports.ErrAgentBinaryNotFound when it is absent. func ResolveClaudeBinary(ctx context.Context) (string, error) { return binaryutil.ResolveBinary(ctx, claudeBinarySpec) } 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 }