agent-orchestrator/backend/internal/adapters/agent/claudecode/claudecode.go

495 lines
17 KiB
Go

// 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"
)
// 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,
},
}
}
// 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 <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) {
// 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)
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
}
// 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, 7)
cmd = append(cmd, binary)
appendPermissionFlags(&cmd, cfg.Permissions)
if cfg.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", cfg.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 := ports.SessionInfo{
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
Title: session.Metadata[ports.MetadataKeyTitle],
Summary: session.Metadata[ports.MetadataKeySummary],
}
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
return ports.SessionInfo{}, false, nil
}
return info, true, nil
}
// 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 "", fmt.Errorf("claude: %w", ports.ErrAgentBinaryNotFound)
}
if path, err := exec.LookPath("claude"); err == nil && path != "" {
return path, nil
}
candidates := []string{
"/usr/local/bin/claude",
"/opt/homebrew/bin/claude",
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates,
filepath.Join(home, ".local", "bin", "claude"),
filepath.Join(home, ".npm", "bin", "claude"),
filepath.Join(home, ".claude", "local", "claude"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
return candidate, nil
}
if err := ctx.Err(); err != nil {
return "", err
}
}
return "", fmt.Errorf("claude: %w", ports.ErrAgentBinaryNotFound)
}
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()
}