/** * claude-code spawn adapter: runs one subagent as a headless `claude -p` * subprocess with `--output-format json` and unwraps the result envelope. * * Two hard-won rules from the Phase 1 spike, both load-bearing: * * 1. **Scrub inherited `CLAUDE*` env vars — delete, don't blank.** A workflow * launched from inside a Claude Code session inherits session env that makes * the nested CLI exit 1 with empty output. Setting a var to "" still reads * as set; only deletion works. * 2. **Mark the child as a workflow child instead of disabling hooks.** With * the host repo's hooks active, the coordination Stop hook blocks a headless * child for skipping the end-of-turn ritual (observed: num_turns burned on * re-prompts → error_max_turns). `--settings '{"disableAllHooks":true}'` * fixes that but also kills the coord capture that makes workflow children * visible to peers — the point of running them under harnery. So the child * gets HARNERY_WORKFLOW_CHILD=1 and the stop-hook rule exempts it * (stop-hook.ts), keeping heartbeats + events on. */ import { exec } from "../../lib/exec.ts"; import { builtinAdapterProfile, validateAdapterEffort } from "../adapters/profiles.ts"; import type { AdapterInvocation, AdapterRawResult } from "../adapters/types.ts"; import { notFoundError } from "./adapters.ts"; import { buildChildEnv } from "./child-env.ts"; import { resolveSandboxProjection } from "./sandbox-projection.ts"; import { isUpstreamFailureText, vendorFailureText } from "./spawn-failure.ts"; import type { Spawner, SpawnRequest, SpawnResult } from "./types.ts"; interface ClaudeEnvelope { type?: string; subtype?: string; is_error?: boolean; result?: string; session_id?: string; total_cost_usd?: number; errors?: string[]; } export function buildClaudeInvocation(req: SpawnRequest): AdapterInvocation { validateAdapterEffort("claude-code", req.effort); if (req.filesystemPolicy) { // Declared unrepresentable: refuse rather than drop it silently (ADR 0039). resolveSandboxProjection( "claude-code", builtinAdapterProfile("claude-code")?.sandboxProjection, req.filesystemPolicy, ); } const argv = [ "claude", "-p", req.prompt, "--output-format", "json", "--max-turns", String(req.maxTurns), ]; if (req.model) argv.push("--model", req.model); if (req.effort) argv.push("--effort", req.effort); return { argv }; } export function normalizeClaudeResult(raw: AdapterRawResult): SpawnResult { if (raw.timedOut) { return { ok: false, text: "", durationMs: raw.durationMs, error: `claude timed out after ${raw.durationMs}ms and was killed`, }; } // Structural environment signal: the binary was never there (spawned directly, // so a missing binary surfaces as ENOENT). Uncharged and not retried. if (raw.spawnErrno === "ENOENT") { return { ok: false, text: "", durationMs: raw.durationMs, error: notFoundError("claude-code"), class: "environment", }; } if (raw.exitCode === 127) { // A bare 127 with no errno is a shell/vendor 127, indistinguishable from a // legitimate one — charged as work rather than classed environment. return { ok: false, text: "", durationMs: raw.durationMs, error: notFoundError("claude-code"), }; } if (raw.exitCode !== 0) { const failureText = vendorFailureText(raw); return { ok: false, text: "", durationMs: raw.durationMs, error: `claude exited ${raw.exitCode}: ${failureText}`, ...(isUpstreamFailureText(failureText) ? { class: "upstream" as const } : {}), }; } let envelope: ClaudeEnvelope; try { envelope = JSON.parse(raw.stdout) as ClaudeEnvelope; } catch { return { ok: false, text: "", durationMs: raw.durationMs, error: `result envelope was not JSON: ${raw.stdout.slice(0, 300)}`, }; } if (envelope.is_error) { const envelopeError = `${envelope.subtype ?? ""} ${(envelope.errors ?? []).join("; ")} ${String( envelope.result ?? "", )}`; return { ok: false, text: String(envelope.result ?? ""), sessionId: envelope.session_id, costUsd: envelope.total_cost_usd, durationMs: raw.durationMs, error: `adapter error (${envelope.subtype ?? "unknown"}): ${(envelope.errors ?? []).join("; ") || "see envelope"}`, ...(isUpstreamFailureText(envelopeError) ? { class: "upstream" as const } : {}), }; } return { ok: true, text: String(envelope.result ?? ""), sessionId: envelope.session_id, costUsd: envelope.total_cost_usd, durationMs: raw.durationMs, }; } export const claudeCodeSpawner: Spawner = async (req: SpawnRequest): Promise => { const t0 = Date.now(); let invocation: AdapterInvocation; try { invocation = buildClaudeInvocation(req); } catch (error) { return { ok: false, text: "", durationMs: 0, error: (error as Error).message }; } const r = await exec(invocation.argv, { cwd: req.cwd, env: buildChildEnv(req.runId, { subscriptionOnly: req.subscriptionOnly, agentId: req.agentId, }), timeout: req.timeoutMs, }); return normalizeClaudeResult({ ...r, durationMs: Date.now() - t0 }); };