diff --git a/docs/prerequisites.md b/docs/prerequisites.md index 62714e0..d1069aa 100644 --- a/docs/prerequisites.md +++ b/docs/prerequisites.md @@ -14,6 +14,7 @@ The [`autopilot`](../skills/autopilot) skill requires these tools: - Git - At least one supported harness CLI: Claude Code, Codex, Pi, or OpenCode - Optional: an installed and active `pi-subagents` 0.53.0 or newer for the process-local Pi backend; the packaged Autopilot Pi extension probes it before launch, and direct Pi remains a distinct fallback +- Optional: an explicitly supplied `@anthropic-ai/claude-agent-sdk` 0.3.246 or newer and matching Claude Code executable for `claude-agent-sdk`; set their absolute paths through `AUTOPILOT_CLAUDE_AGENT_SDK_ROOT` and `AUTOPILOT_CLAUDE_AGENT_SDK_CLI` - `gh` for GitHub delivery or `glab` for GitLab delivery Autopilot checks these tools automatically before starting a run and reports anything missing. It never installs tools, downloads runtimes, or changes authentication. diff --git a/skills/autopilot/README.md b/skills/autopilot/README.md index 158630c..bfba7b5 100644 --- a/skills/autopilot/README.md +++ b/skills/autopilot/README.md @@ -6,7 +6,7 @@ Autopilot is not a prompt loop. It seals your request into an immutable charter, ## Use it -You need Node.js 24+, Git, and at least one supported harness CLI. Remote delivery also needs authenticated `gh` or `glab`. Pi can use the packaged Autopilot extension with an installed and active `pi-subagents` 0.53.0+ owner for process-local execution; otherwise it reports the distinct direct CLI fallback. Autopilot checks the environment before launch; it never installs tools, enables extensions, or signs you in. +You need Node.js 24+, Git, and at least one supported harness CLI. Remote delivery also needs authenticated `gh` or `glab`. Pi can use the packaged Autopilot extension with an installed and active `pi-subagents` 0.53.0+ owner for process-local execution; otherwise it reports the distinct direct CLI fallback. Claude can use an explicitly supplied Agent SDK 0.3.246+ and matching executable for same-query implementation execution. Autopilot checks the environment before launch; it never installs tools, finds private caches, enables extensions, or signs you in. From the project repository, invoke the skill through your host: @@ -21,6 +21,8 @@ changes and rollback notes. Open a PR, but do not merge or deploy it. When using Pi's process-local backend, load `runtime/dist/src/pi-extension-entry.js` through Pi's documented extension or package mechanism, then start the sealed charter with `/autopilot-start `. The owning Pi process must remain uninterrupted; losing it makes the exact attempt unknown and never triggers a replacement. +Claude offers two explicit charter modes. `claude-agent-sdk` binds implementation to one in-memory query, captured child, exact session, and caller-selected user message; query or process loss becomes unknown. Set `AUTOPILOT_CLAUDE_AGENT_SDK_ROOT` and `AUTOPILOT_CLAUDE_AGENT_SDK_CLI` to operator-provided matching artifacts. `claude-code` remains the direct CLI mode, and Agent SDK independent review still uses that direct path. + Codex offers two explicit charter modes. `codex-app-server` binds an implementation to one per-attempt app-server instance and exact ephemeral thread/turn; connection or server loss becomes unknown. `codex` retains the direct CLI fallback and POSIX process-supervised restart reattachment. App-server independent review still uses the direct read-only CLI path. OpenCode also offers two explicit modes. `opencode-server` owns one authenticated loopback server, dedicated session, caller-selected message, and uninterrupted event stream per implementation attempt; any identity or stream gap becomes unknown. `opencode` remains the direct CLI mode. Server-mode independent review still uses the direct CLI path. diff --git a/skills/autopilot/SKILL.md b/skills/autopilot/SKILL.md index 9276701..19d3b1f 100644 --- a/skills/autopilot/SKILL.md +++ b/skills/autopilot/SKILL.md @@ -7,7 +7,7 @@ disable-model-invocation: true # Autopilot -Autopilot delegates bounded coding work to a fresh Claude Code, Codex, Pi, or OpenCode execution. Pi implementations prefer the packaged process-local extension backend. Codex charters select either the exact same-instance `codex-app-server` backend or the distinct direct `codex` CLI path. OpenCode charters similarly select the same-instance `opencode-server` backend or distinct direct `opencode` CLI path. Other modes and fallbacks use their declared boundaries. The runtime owns lifecycle state, Git commits, verification, remote delivery, and completion decisions. +Autopilot delegates bounded coding work to a fresh Claude Code, Codex, Pi, or OpenCode execution. Pi implementations prefer the packaged process-local extension backend. Claude charters select either the exact same-instance `claude-agent-sdk` backend or the distinct direct `claude-code` CLI path. Codex charters select either the exact same-instance `codex-app-server` backend or the distinct direct `codex` CLI path. OpenCode charters similarly select the same-instance `opencode-server` backend or distinct direct `opencode` CLI path. Other modes and fallbacks use their declared boundaries. The runtime owns lifecycle state, Git commits, verification, remote delivery, and completion decisions. ## New-run preconditions @@ -20,6 +20,7 @@ For a new implementation run: 5. Do not install tools, download runtimes, authenticate providers, or modify global configuration. 6. For Codex, select `codex-app-server` only when the operator accepts same-instance cooperative terminality; select `codex` for the distinct direct CLI mode. Never switch between them after admission or infer one mode's guarantees from the other. 7. For OpenCode, select `opencode-server` only when the operator accepts one owned loopback server, an uninterrupted non-replayable event stream, and same-instance cooperative terminality; select `opencode` for the distinct direct CLI mode. Never reuse a shared server or switch modes after admission. +8. For Claude, select `claude-agent-sdk` only when the operator explicitly supplies SDK 0.3.246 or newer and its matching Claude Code executable and accepts same-query-instance cooperative terminality; select `claude-code` for the distinct direct CLI mode. Never discover private caches, resume a lost query, replace an unknown execution, or switch modes after admission. Treat “I am going to sleep,” “work overnight,” and “have this ready in the morning” as explicit unattended-run intent. Preserve that intent in `sourceText`, but do not turn it into a deadline, merge grant, deployment grant, or guarantee of completion time. diff --git a/skills/autopilot/docs/2026-08-30-continuity-evidence-implementation-plan.md b/skills/autopilot/docs/2026-08-30-continuity-evidence-implementation-plan.md index 5632308..f6c677a 100644 --- a/skills/autopilot/docs/2026-08-30-continuity-evidence-implementation-plan.md +++ b/skills/autopilot/docs/2026-08-30-continuity-evidence-implementation-plan.md @@ -65,7 +65,7 @@ The implementation began from this boundary: 3. Reports listed receipt IDs but did not provide one evidence result for every acceptance predicate. 4. The baseline package suite contained 111 passing Node tests. -Phases 0–6A replaced that boundary with versioned attempt context, structured predicate receipts and reports, continuity status, an exact-tree review gate, journal-safe pause, verified-item continuation, effect reconciliation, and exact-subject provider heartbeat waiting. The exact-tree review gate passed with Pi 0.84.4, Codex 0.151.0, and OpenCode 1.18.25. Claude Code 2.1.251 now reaches its identity-linked API key but requires an `ANTHROPIC_WORKSPACE_ID` absent from the validation environment, and production notification wake remains unverified. +Phases 0–6A replaced that boundary with versioned attempt context, structured predicate receipts and reports, continuity status, an exact-tree review gate, journal-safe pause, verified-item continuation, effect reconciliation, and exact-subject provider heartbeat waiting. The exact-tree review gate passed with Pi 0.84.4, Claude Code 2.1.251, Codex 0.151.0, and OpenCode 1.18.25. The direct Claude Code adapter also passed an exact disposable edit without changing HEAD, Git configuration, or global Claude configuration; production notification wake remains unverified. ## Phase 0: Align documentation with executable support @@ -487,7 +487,7 @@ skills/autopilot/runtime/test/fault-injection.test.ts ## Phase 7: Finish documentation and packaging -**Result:** Implemented for Phases 0–6A. Phase 6B is not promoted under the recorded no-receiver decision and is not a current release blocker. The current validation baseline is 234 Node tests locally after adding Codex app-server and OpenCode server contract coverage. The earlier 187-test native-free, 200-test Pi process-local, 162-test, and 137-test baselines passed at their recorded revisions on Ubuntu and Windows. +**Result:** Implemented for Phases 0–6A. Phase 6B is not promoted under the recorded no-receiver decision and is not a current release blocker. The current validation baseline is 253 Node tests locally after adding Claude Agent SDK, Codex app-server, and OpenCode server contract coverage. The earlier 187-test native-free, 200-test Pi process-local, 162-test, and 137-test baselines passed at their recorded revisions on Ubuntu and Windows. ### Files diff --git a/skills/autopilot/docs/2026-08-31-cooperative-harness-execution-plan.md b/skills/autopilot/docs/2026-08-31-cooperative-harness-execution-plan.md index f8bfdbc..cc933a0 100644 --- a/skills/autopilot/docs/2026-08-31-cooperative-harness-execution-plan.md +++ b/skills/autopilot/docs/2026-08-31-cooperative-harness-execution-plan.md @@ -1,6 +1,6 @@ # Cooperative harness execution implementation plan -- **Status:** Implemented through the explicit OpenCode server backend; the Claude Agent SDK boundary has a conditional GO and remains unimplemented +- **Status:** Implemented through the explicit Claude Agent SDK, Codex app-server, and OpenCode server backends - **Date:** 2026-08-31 - **Audience:** Autopilot implementers and reviewers - **Related:** [Architecture](architecture.md), [continuity implementation plan](2026-08-30-continuity-evidence-implementation-plan.md), [OpenCode server evaluation](2026-08-31-opencode-server-evaluation.md), [Claude Agent SDK evaluation](2026-09-04-claude-agent-sdk-evaluation.md), [durable event engine ADR](adr/0001-durable-event-engine.md) @@ -379,7 +379,7 @@ The default stdio transport cannot reconnect after coordinator loss. WebSocket t Investigate and implement each remaining provider as a separate boundary. - OpenCode: the [server evaluation](2026-08-31-opencode-server-evaluation.md) established the same-instance contract. The explicit `opencode-server` adapter owns one server process, dedicated session, caller-selected message ID, uninterrupted live events, and fresh REST reconciliation. Controlled fault coverage passes, and live completion and cancellation passed with OpenCode 1.18.28. -- Claude Code: the [Agent SDK evaluation](2026-09-04-claude-agent-sdk-evaluation.md) established a conditional same-instance contract for a distinct `claude-agent-sdk` implementation backend. Promotion requires an explicitly supplied SDK at 0.3.246 or later, controlled fault coverage, and exact-version live completion and cancellation. The direct `claude-code` mode remains session-scoped. +- Claude Code: the [Agent SDK evaluation](2026-09-04-claude-agent-sdk-evaluation.md) established the same-instance contract for a distinct `claude-agent-sdk` implementation backend. An explicitly supplied SDK 0.3.260 and matching Claude Code 2.1.260 passed controlled fault coverage and exact-version live completion and cancellation. The direct `claude-code` mode remains separate and session-scoped for review. Do not add a provider-neutral durable-subject framework based only on hypothetical future consumers. @@ -468,6 +468,6 @@ Pause and revisit the design if implementation shows any of the following: - No provider currently proves Windows process-tree quiescence through its public subagent contract. - Codex app-server 0.151.0 exact completion and interruption were exercised over one uninterrupted stdio connection. Cross-connection live rejoin is intentionally unimplemented because the default transport is not reconnectable; continuity loss remains unknown. - OpenCode 1.18.25 protocol probes and the production adapter's 1.18.28 live completion and cancellation passed. An earlier isolated probe process was later found still running after its parent harness had ended, invalidating that probe's cleanup claim and reinforcing the documented absence of whole-harness process containment. The production live run awaited cleanup and a post-run process scan found no remaining server. Disconnect remains intentionally unrecoverable because SSE has no replay cursor. -- Agent SDK 0.3.220 with bundled Claude Code 2.1.220 produced an exact natural completion and an interrupt receipt followed by `aborted_tools`, with normal-path cleanup and unchanged Claude configuration digests. Its cancellation result lacked the required user-message UUID. The current 0.3.260 API shape closes that identity gap, but controlled and live validation on 0.3.246 or later remains required before implementation promotion. +- Agent SDK 0.3.220 with bundled Claude Code 2.1.220 produced an exact natural completion and an interrupt receipt followed by `aborted_tools`, but its cancellation result lacked the required user-message UUID. The promoted adapter used an explicitly supplied SDK 0.3.260 and Claude Code 2.1.260; controlled faults plus exact live completion and cancellation passed with the required identities, isolated configuration, and awaited direct-child cleanup. - Cooperative terminality does not prevent external effects performed by worker tools before terminal response. - The Pi entry point follows the documented package manifest at `runtime/dist/src/pi-extension-entry.js` and registers `/autopilot-start`, `/autopilot-resume`, and `/autopilot-recover`; callers must load it through Pi's normal package or extension mechanism. diff --git a/skills/autopilot/docs/2026-09-04-claude-agent-sdk-evaluation.md b/skills/autopilot/docs/2026-09-04-claude-agent-sdk-evaluation.md index 5277f28..82b7ab5 100644 --- a/skills/autopilot/docs/2026-09-04-claude-agent-sdk-evaluation.md +++ b/skills/autopilot/docs/2026-09-04-claude-agent-sdk-evaluation.md @@ -1,9 +1,10 @@ # Claude Agent SDK execution evaluation -- **Status:** Evaluation complete; production adapter not yet implemented -- **Decision:** CONDITIONAL GO for a distinct `claude-agent-sdk` implementation backend +- **Status:** Evaluation complete; production adapter implemented and validated separately +- **Decision:** GO for a distinct `claude-agent-sdk` implementation backend at the tested boundary - **API baseline:** `@anthropic-ai/claude-agent-sdk` 0.3.260, tag `v0.3.260` at commit `a79d677cbd0a627bddf8ad37d8d010c727c71fc7` -- **Live probe baseline:** Agent SDK 0.3.220 with its bundled Claude Code 2.1.220 +- **Evaluation probe baseline:** Agent SDK 0.3.220 with its bundled Claude Code 2.1.220 +- **Promotion evidence:** [Agent SDK implementation validation](2026-09-04-claude-agent-sdk-validation.md) - **Scope:** Implementation execution only; independent review remains on the direct `claude-code` adapter ## Decision summary @@ -12,7 +13,7 @@ The TypeScript Agent SDK now exposes enough identity and lifecycle data for an e Autopilot can therefore implement a backend whose subject is the original in-memory `Query`, its Claude Code subprocess, the reported session ID, and the caller-selected user-message UUID. Completion or cancellation is acceptable only while that exact query remains uninterrupted and only from a terminal result bound to both identities. -This is a conditional GO rather than production promotion. The locally available 0.3.220 SDK proved natural completion and cooperative interruption, but its cancellation result did not echo the user-message UUID. The required identity fields arrived in later releases. Autopilot must not ship the backend until an explicitly provided SDK at 0.3.246 or later passes the controlled fault matrix and live completion and cancellation probes. The runtime must not install that SDK or its platform binary. +The evaluation originally issued a conditional GO because the locally available 0.3.220 cancellation result did not echo the user-message UUID. The required identity fields arrived in later releases. An explicitly operator-supplied 0.3.260 SDK and matching Claude Code 2.1.260 executable subsequently passed the controlled fault matrix and live completion and cancellation probes, allowing the distinct backend to ship. The runtime still must not install the SDK or its platform binary. The backend must remain separate from `claude-code`. It must advertise no restart reattachment and must not use transcript resume to infer the state of an interrupted query. SDK or subprocess loss before an exact terminal result becomes `EXECUTION_STATE_UNKNOWN` and cannot launch a replacement automatically. @@ -252,4 +253,4 @@ The implementation must not claim: ## Outcome -Agent SDK 0.3.260 resolves the earlier API-shape blocker: current result and first-reply messages can bind the provider terminal to a caller-selected user-message UUID, while interrupt receipts and structured terminal reasons distinguish acknowledgment from cancellation. The remaining blocker is empirical, not architectural. Autopilot needs an explicitly supplied current SDK, a controlled fault implementation, and exact-version live completion and cancellation before promoting `claude-agent-sdk` into the charter schema. +Agent SDK 0.3.260 resolves the earlier API-shape blocker: current result and first-reply messages bind the provider terminal to a caller-selected user-message UUID, while interrupt receipts and structured terminal reasons distinguish acknowledgment from cancellation. The [promotion validation](2026-09-04-claude-agent-sdk-validation.md) supplied the required controlled and exact-version live evidence. `claude-agent-sdk` is now an explicit charter value while `claude-code` remains the distinct direct CLI and independent-review path. diff --git a/skills/autopilot/docs/2026-09-04-claude-agent-sdk-validation.md b/skills/autopilot/docs/2026-09-04-claude-agent-sdk-validation.md new file mode 100644 index 0000000..ee6aad4 --- /dev/null +++ b/skills/autopilot/docs/2026-09-04-claude-agent-sdk-validation.md @@ -0,0 +1,75 @@ +# Claude Agent SDK implementation validation + +- **Date:** 2026-09-04 +- **Runtime subject:** `claude-agent-sdk` implementation adapter +- **Agent SDK:** 0.3.260 +- **Bundled Claude Code:** 2.1.260, Darwin ARM64 +- **Scope:** Controlled contract tests plus live completion and cooperative cancellation + +## Supplied packages + +The operator explicitly authorized temporary retrieval of the exact SDK and matching platform package. Neither package was installed into the project or global package trees. + +| Package | Registry SHA-1 | Registry integrity | +|---|---|---| +| `@anthropic-ai/claude-agent-sdk@0.3.260` | `7f183685e0a9583378949a8966704ec8a5082101` | `sha512-PmABtP4Rwd6l95itQrqzguv6rS9uACqikPB9g8BPeWRKZOpy3xpEOjJLYauof3BFk2wNZnfhr0Ttx8ttcZzq0w==` | +| `@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.260` | `2a7aa29dd36b62cce48010158cd5e62c83f7f237` | `sha512-0af2gRe6+sk13yYNX2gdDhcO15Kj1qd8B7ZQlv8mDt2lA1xFhTJqIvRwgrCHeCWZryWTmoRgtMoAfJOhQ9yn1g==` | + +The runtime does not know these temporary paths. Production discovery requires both `AUTOPILOT_CLAUDE_AGENT_SDK_ROOT` and `AUTOPILOT_CLAUDE_AGENT_SDK_CLI`. Doctor reports missing, unsupported, and accepted versions without running npm, installing packages, searching private application caches, or authenticating Claude. + +## Implemented boundary + +`claude-agent-sdk` is a distinct charter value. Implementation requests use one in-memory SDK query, one captured direct child, one session ID, and one caller-selected user-message UUID. Review requests remain on the direct `claude-code` adapter and reuse the explicitly supplied Claude Code executable. + +Admission verifies: + +- Agent SDK 0.3.246 or newer and a bounded `--version` check matching the package-declared Claude Code version; +- one exact child executable and one `system/init` identity; +- a realpath-equivalent attempt worktree; +- `dontAsk`, the exact implementation tool list, and required protocol capabilities; +- no MCP servers or plugins and disabled skills; +- `settingSources: []`, no session persistence, no continuation or resume, and an isolated temporary Claude configuration directory; and +- the first assistant or terminal frame bound only to the caller-selected user-message UUID. + +A `PreToolUse` hook fences every exposed tool name. This is a cooperative tool-surface control, not operating-system containment. The runtime still verifies the repository and owns Git and provider effects. + +Completion accepts only one exact `success`, `is_error: false`, `terminal_reason: completed` result from the admitted session and user message. Cancellation additionally requires the original query's interrupt receipt, an empty `still_queued` set, and an exact `aborted_tools` or `aborted_streaming` result. Natural completion may win. Query, iterator, child, protocol, identity, deadline, or idle loss before accepted terminality becomes `EXECUTION_STATE_UNKNOWN`; there is no resume or replacement path. + +## Controlled evidence + +Nineteen focused tests cover: + +- explicit discovery, minimum-version enforcement, and absent-package diagnostics; +- implementation/review assurance separation; +- exact init, session, tool, capability, worktree, and first-reply user-message identity, with later UUID-omitting tool-use replies inherited only inside the admitted one-query session; +- settings, MCP, plugin, skill, and exact child-environment isolation; +- `PreToolUse` allow and deny behavior; +- natural completion, buffered terminal delivery across child exit, and structured provider failure; +- both accepted cancellation terminal reasons, exact completion/failure races, and a nonresponsive interrupt call; +- missing and non-empty interrupt receipts; +- missing, foreign, and merged first, subsequent, and terminal reply identities; +- malformed authority, iterator failure, child loss, duplicate spawn, absent child, and oversized protocol records; +- idle and deadline loss; +- UTF-8-safe retained-output bounds and secret redaction across output, chunks, and lifecycle errors; +- wrong-harness and consumed-handle rejection; and +- awaited direct-child cleanup and adapter-state release. + +The broader runtime suite remains the authority for journal, exact-tree, Git, verification, delivery, and recovery behavior. + +## Live completion + +The production adapter loaded the explicitly supplied SDK 0.3.260 and executable 2.1.260, admitted one exact subject, used the expected same-harness-instance assurance, and returned a successful bounded summary without invoking tools or changing the disposable fixture. The exact result carried `terminal_reason: completed` and the caller-selected user-message identity. The runtime awaited direct-child cleanup. + +## Live cancellation + +A second production-adapter run admitted one implementation turn instructed to execute `sleep 30`. The exact `PreToolUse` hook ran for Bash. The adapter interrupted the original query, received `still_queued: []`, then consumed an exact error result with `terminal_reason: aborted_tools` and the same session and user-message identity. It returned `cancelled` with exit code 130. Neither the Claude Code child nor `sleep 30` remained after cleanup. + +## Configuration isolation observation + +An initial raw SDK probe, before the production adapter was exercised, did not set `CLAUDE_CONFIG_DIR`. Claude Code 2.1.260 advanced the global `.claude.json` migration marker from 13 to 14 and rewrote `settings.json`, despite `settingSources: []`. The CLI-created `.claude.json` backup matched the pre-probe digest and was restored. No matching byte-for-byte `settings.json` backup was available, so its pre-probe bytes could not be restored. + +The production adapter therefore creates a fresh temporary `CLAUDE_CONFIG_DIR` for every implementation attempt and deletes it after awaited cleanup. Both production-adapter live runs left the then-current global `.claude.json` and `settings.json` digests unchanged. This evidence does not claim that a raw SDK consumer is configuration-neutral. + +## Result and limits + +The 0.3.260 promotion criteria exercised here passed, so `claude-agent-sdk` is available as an explicit implementation adapter. It remains cooperative and same-instance only. It does not claim restart attachment, transcript recovery, descendant quiescence, filesystem or network sandboxing, rollback of external effects, cleanup after whole-harness loss, or compatibility beyond tested package pairs and platforms. diff --git a/skills/autopilot/docs/README.md b/skills/autopilot/docs/README.md index 812a7ad..4a50302 100644 --- a/skills/autopilot/docs/README.md +++ b/skills/autopilot/docs/README.md @@ -19,7 +19,8 @@ The [main README](../README.md) is the short path for starting an unattended run - [Continuity and evidence plan](2026-08-30-continuity-evidence-implementation-plan.md): attempt context, predicate evidence, independent review, and blocked/deferred supervision work. - [Cooperative harness execution plan](2026-08-31-cooperative-harness-execution-plan.md): approved binary-free Windows execution assurance, unknown-state recovery, Pi integration, and delivery sequence. - [OpenCode server execution evaluation](2026-08-31-opencode-server-evaluation.md): version-pinned protocol evidence, controlled probes, GO decision, assurance boundary, and implementation acceptance criteria. -- [Claude Agent SDK execution evaluation](2026-09-04-claude-agent-sdk-evaluation.md): current API identity evidence, live 0.3.220 probes, conditional GO decision, and promotion requirements. +- [Claude Agent SDK execution evaluation](2026-09-04-claude-agent-sdk-evaluation.md): API identity analysis and the same-query execution contract. +- [Claude Agent SDK implementation validation](2026-09-04-claude-agent-sdk-validation.md): controlled fault coverage, exact 0.3.260 completion and cancellation evidence, and configuration-isolation findings. - [Restack successor lifecycle design](restack-successor-design.md): explicit authority, verification, Git mutation, and recovery contract for successful-stack restacking. - [GitLab sealed-restack live validation](2026-09-04-gitlab-restack-validation.md): exact remote mutation, provider reconciliation, receipt, and cleanup evidence from an authorized reusable project. - [Runtime CLI reference](runtime-cli.md): maintainer automation, state overrides, journal repair, and build commands. @@ -42,4 +43,4 @@ Both figures use the vendored Diagram Design default profile. The PNG exports ar ## Verification status -The runtime has 234 Node tests locally covering deterministic attempt context, predicate evidence maps, exact-tree review fixtures, local Git lifecycle behavior, crash reconciliation, intentional pause, exact-subject provider waiting, hooks, queues, stacks, sealed review-feedback amendments, GitHub and GitLab provider contracts, sibling worktrees, POSIX descendant process cancellation, native-free package inventory, Codex app-server and OpenCode server continuity contracts, and wrap-up. Controlled Pi 0.84.4 with pi-subagents 0.60.0 process-local tests cover exact admission, cancellation, extension-context loss, direct fallback, and runtime-core completion in a reused local repository. Codex app-server 0.151.0 live completion and interruption passed on one uninterrupted stdio connection without changing the tested Codex config digest; its connection-loss behavior has controlled fault coverage. OpenCode server live completion and exact aborted-message cancellation passed with 1.18.28 without changing the OpenCode config digest or leaving a server process after normal cleanup; whole-harness loss remains intentionally uncontained and unknown. Agent SDK 0.3.220 with bundled Claude Code 2.1.220 produced an exact natural completion and an interrupt receipt followed by `aborted_tools`; that older cancellation result lacked the required user-message UUID, so production promotion requires a live 0.3.246-or-later probe. Disposable exact-tree reviews also passed with Pi 0.84.4, Codex 0.151.0, and OpenCode 1.18.25; direct Claude Code review remains unverified. GitHub PR creation, marker reconciliation, exact review-thread resolution, exact-head amendment, merge, and wrap-up passed on an authorized private reusable validation project with `gh` 2.98.0. GitLab MR creation and reconciliation, exact discussion resolution, duplicate-status latest selection, exact-head amendment, merge, and wrap-up passed on an authorized private reusable validation project with `glab` 1.115.0. On the same authorized project, `glab` 1.116.0 live-validated the normal sealed-restack mutation path: the child branch advanced by ordinary fast-forward push to the exact two-parent candidate, the existing MR reported that exact head and base, fresh exact-tree receipts passed, `main` remained unchanged, and temporary refs were removed. +The runtime has 253 Node tests locally covering deterministic attempt context, predicate evidence maps, exact-tree review fixtures, local Git lifecycle behavior, crash reconciliation, intentional pause, exact-subject provider waiting, hooks, queues, stacks, sealed review-feedback amendments, GitHub and GitLab provider contracts, sibling worktrees, POSIX descendant process cancellation, native-free package inventory, Claude Agent SDK, Codex app-server, and OpenCode server continuity contracts, and wrap-up. Controlled Pi 0.84.4 with pi-subagents 0.60.0 process-local tests cover exact admission, cancellation, extension-context loss, direct fallback, and runtime-core completion in a reused local repository. Agent SDK 0.3.260 with Claude Code 2.1.260 passed exact completion and `aborted_tools` cancellation through the production adapter, with caller-selected identity, isolated configuration, exact tool fencing, and no remaining child or `sleep 30` process; its focused tests cover faults, bounds, redaction, and cleanup. Codex app-server 0.151.0 live completion and interruption passed on one uninterrupted stdio connection without changing the tested Codex config digest; its connection-loss behavior has controlled fault coverage. OpenCode server live completion and exact aborted-message cancellation passed with 1.18.28 without changing the OpenCode config digest or leaving a server process after normal cleanup; whole-harness loss remains intentionally uncontained and unknown. Disposable exact-tree reviews also passed with Pi 0.84.4, Claude Code 2.1.251, Codex 0.151.0, and OpenCode 1.18.25; the direct Claude Code adapter also passed an exact disposable edit without changing HEAD, Git configuration, or global Claude configuration. GitHub PR creation, marker reconciliation, exact review-thread resolution, exact-head amendment, merge, and wrap-up passed on an authorized private reusable validation project with `gh` 2.98.0. GitLab MR creation and reconciliation, exact discussion resolution, duplicate-status latest selection, exact-head amendment, merge, and wrap-up passed on an authorized private reusable validation project with `glab` 1.115.0. On the same authorized project, `glab` 1.116.0 live-validated the normal sealed-restack mutation path: the child branch advanced by ordinary fast-forward push to the exact two-parent candidate, the existing MR reported that exact head and base, fresh exact-tree receipts passed, `main` remained unchanged, and temporary refs were removed. diff --git a/skills/autopilot/docs/architecture.md b/skills/autopilot/docs/architecture.md index 1e95803..597ffbc 100644 --- a/skills/autopilot/docs/architecture.md +++ b/skills/autopilot/docs/architecture.md @@ -1,6 +1,6 @@ # Harness-agnostic Autopilot design -- **Status:** Developer-preview implementation available; POSIX attempt-scoped process reattachment, fenced unknown recovery, Pi process-local cooperative execution, native-free Windows packaging, controlled-fixture sealed restack successors, and the normal live GitLab restack path are complete. Notification wake is not promoted, and live GitHub restack mutation remains unverified. +- **Status:** Developer-preview implementation available; POSIX attempt-scoped process reattachment, fenced unknown recovery, Pi and Claude Agent SDK same-instance execution, native-free Windows packaging, controlled-fixture sealed restack successors, and the normal live GitLab restack path are complete. Notification wake is not promoted, and live GitHub restack mutation remains unverified. - **Date:** 2026-08-22 - **Audience:** Coding-harness maintainers and adapter authors - **Implementation plan:** [Autopilot implementation plan](implementation-plan.md) @@ -136,7 +136,7 @@ interface RunCharter { readonly baseCommit: string; readonly writableRoots: readonly string[]; }; - readonly harnessAdapter: "pi" | "claude-code" | "codex" | "codex-app-server" | "opencode" | "opencode-server"; + readonly harnessAdapter: "pi" | "claude-code" | "claude-agent-sdk" | "codex" | "codex-app-server" | "opencode" | "opencode-server"; readonly mode: "single" | "independent-queue" | "ordered-stack"; readonly work: readonly WorkItem[]; readonly delivery: "local-commits" | "change-request-ready" | "merge-verified"; @@ -310,7 +310,9 @@ Capability degradation is explicit: - Missing or incomplete attempt-supervisor evidence fails closed as `EXECUTION_STATE_UNKNOWN`; the runtime never spends another attempt on a speculative replacement. - Missing a required delivery or enforcement capability stops before edits. -The first adapters target Claude Code, Codex, Pi, and OpenCode. They share one conformance suite. The `codex`/`codex-app-server` and `opencode`/`opencode-server` charter pairs are distinct execution modes: direct CLIs retain runtime-owned POSIX supervision, while server-backed implementation attempts use harness-owned same-instance cooperative terminality. OpenCode server terminality additionally requires an uninterrupted non-replayable SSE stream and fresh exact-message REST reconciliation. +The first adapters target Claude Code, Codex, Pi, and OpenCode. They share one conformance suite. The `claude-code`/`claude-agent-sdk`, `codex`/`codex-app-server`, and `opencode`/`opencode-server` charter pairs are distinct execution modes: direct CLIs retain runtime-owned POSIX supervision, while SDK/server-backed implementation attempts use harness-owned same-instance cooperative terminality. OpenCode server terminality additionally requires an uninterrupted non-replayable SSE stream and fresh exact-message REST reconciliation. + +The Claude Agent SDK adapter requires explicit paths to an operator-provided SDK 0.3.246 or newer and its matching Claude Code executable. It creates one streaming-input query and captures one direct child, then admits only the exact package/CLI version, realpath worktree, permission and tool surface, required capabilities, session ID, and caller-selected user-message UUID. An isolated temporary Claude configuration directory, disabled settings sources and skills, strict empty MCP, exact tools, and `PreToolUse` narrow the cooperative surface. Completion and cancellation require exact results from the uninterrupted query; an interrupt receipt or `Query.close()` alone is not terminality. Query, child, iterator, coordinator, or identity loss is `EXECUTION_STATE_UNKNOWN`, with no resume, continuation, reinitialization, or replacement. Independent review remains on the direct Claude Code adapter. The Codex app-server adapter starts one per-attempt stdio server with its process working directory set to the dedicated worktree. After `initialize`, it creates an ephemeral workspace-write thread with unattended approvals and deliberately omits the `cwd` request field, avoiding Codex's persistent project-trust update while still verifying the returned working directory. It journals an exact subject derived from the app-server instance nonce, thread ID, and turn ID. Only the matching `turn/completed` notification on the same connection is terminal; interruption must finish that turn as `interrupted`. Server requests are denied. The stdio connection cannot be reattached, while reconnectable Codex transports remain experimental or require separately managed daemon state, so any coordinator, connection, or server loss is `EXECUTION_STATE_UNKNOWN`. Independent review remains on the direct read-only Codex CLI adapter. @@ -518,6 +520,7 @@ skills/autopilot/ │ ├── report.ts │ └── cli.ts ├── adapters/ + │ ├── claude-agent-sdk/ │ ├── claude-code/ │ ├── codex/ │ ├── pi/ diff --git a/skills/autopilot/docs/getting-started.md b/skills/autopilot/docs/getting-started.md index aef5a1b..cde0a74 100644 --- a/skills/autopilot/docs/getting-started.md +++ b/skills/autopilot/docs/getting-started.md @@ -11,6 +11,7 @@ Install these yourself before launching a run: - Claude Code, Codex, Pi, or OpenCode - `gh` for GitHub delivery or `glab` for GitLab delivery - optionally, an installed and active `pi-subagents` 0.53.0 or newer for Pi process-local structured delegation +- optionally, an operator-provided `@anthropic-ai/claude-agent-sdk` 0.3.246 or newer and its matching Claude Code executable for the same-query Claude implementation backend Autopilot checks these requirements before launch. It reports missing or unverified capabilities without installing dependencies, downloading runtimes, authenticating providers, or changing global configuration. @@ -38,6 +39,15 @@ Autopilot converts the request into a proposed charter. Review the charter when For the Pi process-local backend, load the packaged runtime extension through Pi's documented package or `--extension` mechanism and start the charter with `/autopilot-start `. The extension checks that the compatible `pi-subagents` owner is active in the same process; otherwise it reports and uses the distinct direct Pi CLI fallback. Autopilot never installs or enables either extension. Leave the owning Pi process running. Extension reload, session replacement, or process loss makes an admitted in-process execution unknown rather than launching a replacement. +For Claude, choose `claude-agent-sdk` only when the operator has supplied an SDK root and its matching executable: + +```bash +export AUTOPILOT_CLAUDE_AGENT_SDK_ROOT=/absolute/path/to/@anthropic-ai/claude-agent-sdk +export AUTOPILOT_CLAUDE_AGENT_SDK_CLI=/absolute/path/to/matching/claude +``` + +The SDK must be 0.3.246 or newer. The adapter validates the package metadata and the CLI version observed at session initialization. It creates an isolated temporary Claude configuration directory for each attempt, so authentication must be available through explicitly granted environment credentials rather than ambient login files. The adapter never installs packages, finds private caches, resumes a transcript, or replaces a lost query. Independent review and the `claude-code` charter value continue to use the direct CLI boundary. + For Codex, choose the charter adapter deliberately. `codex-app-server` runs one exact ephemeral thread and turn over a per-attempt stdio app-server connection; losing that coordinator, connection, or server makes the attempt unknown. `codex` keeps the direct CLI boundary, including POSIX process-supervised restart reattachment. The app-server mode never bootstraps a daemon or changes Codex authentication. It starts the server in the worktree and omits explicit thread `cwd` so Codex does not persist project trust. For OpenCode, `opencode-server` starts one authenticated `--pure` loopback server and dedicated session for each implementation attempt. It requires one uninterrupted event stream and fresh reconciliation of the exact caller-selected message and terminal assistant. Stream or identity loss makes the attempt unknown; OpenCode events and abort acknowledgments alone never prove terminality. `opencode` preserves the distinct direct CLI mode, and independent review remains direct in both cases. Autopilot never reuses or bootstraps a shared OpenCode server. @@ -74,7 +84,7 @@ Natural requests work too: `status` rebuilds progress from the sealed charter, hash-linked journal, Git identities, and receipts. It reports the last durable milestone, unmet predicate identities, normalized failure, remaining budgets, repeated no-change attempts, and next legal action. `pause` asks the live coordinator to cancel active implementation work, prove quiescence, retire the exact lease, and enter nonterminal waiting. A cancellation caused solely by pause remains auditable but does not consume an attempt. `resume` continues a paused or interrupted nonterminal run within its original limits. Verified items reconcile their checkpoint and effects without rerunning implementation. It does not restart a run that still has a live coordinator. `stop` asks a live coordinator to cancel active adapter work and record a durable terminal stop; if the coordinator is gone, Autopilot records the stop under the run lock. Branches, worktrees, receipts, and evidence remain intact. -A stopped run cannot be resumed. Changed authority, budgets, or objectives require a sealed successor. After coordinator loss on supported POSIX hosts, Autopilot reattaches built-in supervised implementation executions and waits for terminal process-tree evidence. Autopilot contains no Windows native containment helper, so direct Windows CLI execution is session-scoped and continuity loss becomes `EXECUTION_STATE_UNKNOWN`. Legacy attempts, review executions, and incomplete or mismatched supervisor artifacts also refuse a replacement launch until quiescence can be proven. Pi in-process and Codex app-server implementations become unknown when their exact owning harness instance is lost. Resume them through `/autopilot-resume [run-id]` in a loaded Autopilot extension; use fenced `/autopilot-recover` or the runtime CLI to abandon, adopt, or stop an unknown attempt. +A stopped run cannot be resumed. Changed authority, budgets, or objectives require a sealed successor. After coordinator loss on supported POSIX hosts, Autopilot reattaches built-in supervised implementation executions and waits for terminal process-tree evidence. Autopilot contains no Windows native containment helper, so direct Windows CLI execution is session-scoped and continuity loss becomes `EXECUTION_STATE_UNKNOWN`. Legacy attempts, review executions, and incomplete or mismatched supervisor artifacts also refuse a replacement launch until quiescence can be proven. Pi in-process, Claude Agent SDK, and Codex/OpenCode server implementations become unknown when their exact owning harness instance is lost. Reconcile a retained Pi run through `/autopilot-resume [run-id]` in a loaded Autopilot extension. Reconcile retained SDK or server-mode runs with the runtime CLI `resume`; it records the lost execution as unknown and cannot attach or launch a replacement. Then use fenced `/autopilot-recover` or the runtime CLI to abandon, adopt, or stop the unknown attempt. If several runs match, Autopilot lists their title, short ID, state, progress, and last update. It changes nothing until you choose one, for example `resume 1` or `status spring-boot-4`. diff --git a/skills/autopilot/docs/implementation-plan.md b/skills/autopilot/docs/implementation-plan.md index b06666e..541d32a 100644 --- a/skills/autopilot/docs/implementation-plan.md +++ b/skills/autopilot/docs/implementation-plan.md @@ -1,6 +1,6 @@ # Harness-agnostic Autopilot implementation plan -- **Status:** Developer-preview implementation available; POSIX attempt-scoped reattachment, versioned execution assurance, fenced unknown recovery, Pi process-local structured delegation, and the Codex same-instance app-server backend are packaged. The Windows native-helper source, runtime path, build scripts, and tests have been removed; Windows restart reattachment remains unsupported. +- **Status:** Developer-preview implementation available; POSIX attempt-scoped reattachment, versioned execution assurance, fenced unknown recovery, Pi process-local structured delegation, and the Claude Agent SDK, Codex app-server, and OpenCode server same-instance backends are packaged. The Windows native-helper source, runtime path, build scripts, and tests have been removed; Windows restart reattachment remains unsupported. - **Date:** 2026-08-22 - **Audience:** Autopilot implementers and reviewers - **Governing design:** [Autopilot architecture](architecture.md) @@ -41,7 +41,7 @@ The completed first release must: ## Developer-preview evidence -The implementation currently has 234 Node test cases (232 passing and 2 platform skips on macOS) and a clean-copy package smoke test. Generated attempt context, predicate-to-evidence reports, exact-tree independent review, intentional pause, exact-subject provider waiting, and both Codex app-server and OpenCode server admission, cancellation, continuity loss, identity fencing, interactive-request denial, reconciliation, redaction, cleanup, and protocol bounds have controlled coverage. Disposable exact-tree review runs passed with Pi 0.84.4 through pi-subagents 0.60.0, Codex 0.151.0, and OpenCode 1.18.25. Claude Code 2.1.251 reached its adapter but reported no usable noninteractive credential source, so its edit and review flows remain unverified. The same suite passes in Node 24 CI on Ubuntu and Windows; Windows coverage includes locking, atomic writes, Git worktrees and governed hooks, provider fixtures, cancellation, and descendant process-tree termination. An authorized GitHub wrap-up was exercised against merged chat4j PR #69. Authorized private GitHub project `drafael/autopilot-amendment-validation` PR #1 exercised immutable feedback capture, exact-head successor adoption, fast-forward update, exact thread resolution, merge, and amendment-chain wrap-up with `gh` 2.98.0. Authorized private GitLab project `drafael/autopilot-amendment-validation` MR !2 exercised the equivalent complete amendment workflow with `glab` 1.115.0. +The implementation currently has 253 Node test cases (251 passing and 2 platform skips on macOS) and a clean-copy package smoke test. Generated attempt context, predicate-to-evidence reports, exact-tree independent review, intentional pause, exact-subject provider waiting, and Claude Agent SDK, Codex app-server, and OpenCode server admission, cancellation, continuity loss, identity fencing, interactive-request denial, reconciliation, redaction, cleanup, and protocol bounds have controlled coverage. Agent SDK 0.3.260 with Claude Code 2.1.260 passed exact live completion and `aborted_tools` cancellation through the production adapter with isolated configuration and awaited cleanup. Disposable exact-tree review runs passed with Pi 0.84.4 through pi-subagents 0.60.0, Claude Code 2.1.251, Codex 0.151.0, and OpenCode 1.18.25. The direct Claude Code adapter also completed an exact disposable edit while leaving HEAD, Git configuration, and global Claude configuration unchanged. The same suite passes in Node 24 CI on Ubuntu and Windows; Windows coverage includes locking, atomic writes, Git worktrees and governed hooks, provider fixtures, cancellation, and descendant process-tree termination. An authorized GitHub wrap-up was exercised against merged chat4j PR #69. Authorized private GitHub project `drafael/autopilot-amendment-validation` PR #1 exercised immutable feedback capture, exact-head successor adoption, fast-forward update, exact thread resolution, merge, and amendment-chain wrap-up with `gh` 2.98.0. Authorized private GitLab project `drafael/autopilot-amendment-validation` MR !2 exercised the equivalent complete amendment workflow with `glab` 1.115.0. ## Planned package boundary diff --git a/skills/autopilot/docs/runtime-cli.md b/skills/autopilot/docs/runtime-cli.md index 59cd26c..5f9b9ff 100644 --- a/skills/autopilot/docs/runtime-cli.md +++ b/skills/autopilot/docs/runtime-cli.md @@ -57,7 +57,7 @@ If no coordinator owns the run, `stop` acquires the run lock and records the ter `recover` requires an inactive coordinator and the exact current unknown item, attempt, and lease epoch. Every action records the owning run-lock token hash and explicit operator attestation. `abandon` permanently moves the uncertain worktree aside before a fresh attempt; `adopt` requires the exact freshly observed `--tree` and runs verification without an implementation launch; `stop` preserves the evidence and terminalizes the run. -`stop` is terminal. A stopped or successful run requires a sealed successor for changed work. On supported POSIX hosts, built-in direct CLI adapters reattach supervised implementation executions after coordinator loss and wait for terminal process-tree evidence before retrying. The `codex-app-server` mode instead binds one stdio connection, thread, turn, and app-server instance; `opencode-server` binds one owned loopback server, uninterrupted SSE stream, dedicated session, and caller-selected message. Coordinator, connection or stream, server, or exact identity loss records `EXECUTION_STATE_UNKNOWN` and cannot reconnect or launch a replacement. Autopilot packages no Windows native containment helper: direct Windows CLI executions are session-scoped and fail closed on continuity loss. Legacy attempts, review executions, and incomplete or mismatched supervisor artifacts fail closed the same way. +`stop` is terminal. A stopped or successful run requires a sealed successor for changed work. On supported POSIX hosts, built-in direct CLI adapters reattach supervised implementation executions after coordinator loss and wait for terminal process-tree evidence before retrying. The `claude-agent-sdk` mode instead binds one in-memory query, captured child, session, and caller-selected user message; `codex-app-server` binds one stdio connection, thread, turn, and app-server instance; `opencode-server` binds one owned loopback server, uninterrupted SSE stream, dedicated session, and caller-selected message. Coordinator, query, connection or stream, child or server, or exact identity loss records `EXECUTION_STATE_UNKNOWN` and cannot reconnect or launch a replacement. Autopilot packages no Windows native containment helper: direct Windows CLI executions are session-scoped and fail closed on continuity loss. Legacy attempts, review executions, and incomplete or mismatched supervisor artifacts fail closed the same way. ## Journal repair diff --git a/skills/autopilot/references/adapters.md b/skills/autopilot/references/adapters.md index e01a461..bfe9a7d 100644 --- a/skills/autopilot/references/adapters.md +++ b/skills/autopilot/references/adapters.md @@ -7,7 +7,8 @@ Adapters start fresh noninteractive sessions and normalize observations. They ca | Adapter | Command surface | Assurance | Restart reattachment | Verification status | |---|---|---|---|---| | Pi | Owning Pi extension plus process-local `pi-subagents` structured delegation when version 0.53.0+ is installed and active; direct Pi CLI fallback otherwise | Cooperative | In-process implementations require the same uninterrupted extension instance; direct fallback retains supervised POSIX execution and session-scoped Windows execution | Local in-process runtime invocation, exact admission, cancellation, terminal response, continuity loss, direct fallback, and exact-tree verification have controlled coverage with Pi 0.84.4 and pi-subagents 0.60.0; whole-process live fault evidence remains bounded to the documented local matrix | -| Claude Code | `claude --print --output-format stream-json --safe-mode ...` | Cooperative | Supervised implementation attempts on POSIX; session-scoped on Windows | The current 2.1.251 credential reaches the API but requires `ANTHROPIC_WORKSPACE_ID`; authenticated edit and review flows remain unverified | +| Claude Code | `claude --print --output-format stream-json --safe-mode ...` | Cooperative | Supervised implementation attempts on POSIX; session-scoped on Windows | Version 2.1.251 passed an exact disposable edit and exact-tree review without changing HEAD, Git configuration, or global Claude configuration | +| Claude Agent SDK | One in-memory `Query`, captured direct child, session, and caller-selected user-message UUID | Harness-owned, same-query-instance cooperative implementation | No reattachment; coordinator, query, iterator, child, or identity loss is unknown | Nineteen focused contract tests passed; live completion and exact `aborted_tools` cancellation passed with SDK 0.3.260 and Claude Code 2.1.260 | | Codex app-server | Per-attempt `codex app-server --listen stdio://`, v2 `thread/start`, and `turn/start` | Harness-owned, same-app-server-instance cooperative implementation; exact thread/turn identity and terminal notification required | No reattachment; connection, coordinator, or app-server loss is unknown | Contract, loss, cancellation, identity, server-request denial, and output-bound tests passed; live completion and cancellation passed with 0.151.0 | | Codex CLI | `codex exec --json --ephemeral --sandbox workspace-write ...` | Cooperative overall; Codex enforces the workspace sandbox, while item-path restrictions are post-checked | Supervised implementation attempts on POSIX; session-scoped on Windows | Distinct fallback; disposable local edit, exact-tree review, verification, and commit passed with 0.151.0 | | OpenCode server | Per-attempt authenticated `opencode serve --pure`, dedicated session, caller-selected message, live SSE, and fresh REST reconciliation | Harness-owned, same-server-instance cooperative implementation | No reattachment; event-stream, coordinator, server, or identity loss is unknown | Protocol, loss, cancellation, identity, interactive-request rejection, redaction, bounds, and cleanup have controlled coverage; live completion and cancellation passed with 1.18.28 | @@ -21,7 +22,11 @@ Protocol-v1 manifests may provide separate versioned execution-assurance profile Before launch, `ATTEMPT_STARTED` persists the selected assurance and exact request identity. After launch returns, `ATTEMPT_EXECUTION_ADMITTED` binds the adapter and harness versions, adapter execution ID, backend ID, subject ID, and harness-instance ID when required. Losing a single-shot launch reply, changing any exact reattachment identity, or losing a cooperative harness instance becomes `EXECUTION_STATE_UNKNOWN`. A missing reattachment never falls back to `launch()`. -For a `review` gate, the runtime sends a separate role-scoped request with no writable roots or worker write/process grants. Claude Code receives only read/search tools, Codex uses its read-only sandbox, and direct Pi receives only its read tool. OpenCode and any ambient operating-system access remain cooperative. The adapter extracts exactly one structured review marker; missing, contradictory, malformed, truncated, timed-out, or inconclusive output is `UNVERIFIED`. The runtime compares the complete tree, HEAD, refs, and Git configuration before and after review and rejects any mutation. Version-pinned disposable exact-tree runs passed with Pi 0.84.4, Codex 0.151.0, and OpenCode 1.18.25. Claude Code 2.1.251 now reaches its API credential path, but the identity-linked key requires an `ANTHROPIC_WORKSPACE_ID` that is not present in the validation environment, so edit and review remain unverified. +For Claude, `claude-agent-sdk` and `claude-code` are separate charter values. Agent SDK implementation attempts require an explicitly operator-provided SDK root and matching Claude Code executable through `AUTOPILOT_CLAUDE_AGENT_SDK_ROOT` and `AUTOPILOT_CLAUDE_AGENT_SDK_CLI`; the minimum SDK is 0.3.246. Autopilot never installs either package or searches private application caches. It starts one streaming-input query, captures one direct child, verifies the package-declared CLI version, realpath worktree, `dontAsk`, exact tool surface, required capabilities, empty MCP/plugin/skill surfaces, and first reply identity, then journals a subject derived from the harness-instance nonce, session ID, and caller-selected user-message UUID. + +The Agent SDK input remains open until the exact result is consumed. Completion requires `success`, `is_error: false`, `terminal_reason: completed`, and only the admitted session and user-message identities. Interruption acknowledgment is not cancellation: the original query must return an empty `still_queued` set and the same iterator must emit exact `aborted_tools` or `aborted_streaming` terminality. `Query.close()` is cleanup only. A fresh temporary Claude configuration directory prevents implementation attempts from rewriting ambient user settings; `settingSources: []`, strict empty MCP, disabled skills, exact tools, and `PreToolUse` narrow the cooperative surface. Resume, continuation, and reinitialization are never recovery evidence. Independent review remains on direct `claude-code`. + +For a `review` gate, the runtime sends a separate role-scoped request with no writable roots or worker write/process grants. Claude Code receives only read/search tools, Codex uses its read-only sandbox, and direct Pi receives only its read tool. OpenCode and any ambient operating-system access remain cooperative. The adapter extracts exactly one structured review marker; missing, contradictory, malformed, truncated, timed-out, or inconclusive output is `UNVERIFIED`. The runtime compares the complete tree, HEAD, refs, and Git configuration before and after review and rejects any mutation. Version-pinned disposable exact-tree runs passed with Pi 0.84.4, Claude Code 2.1.251, Codex 0.151.0, and OpenCode 1.18.25. The direct Claude Code run also passed an exact disposable edit without changing HEAD, Git configuration, or global Claude configuration. For Codex, `codex-app-server` and `codex` are separate charter values and never silently inherit one another's guarantees. The app-server implementation starts a fresh stdio server in the attempt worktree, initializes one connection, creates one ephemeral thread with `approvalPolicy: never` and `workspace-write`, then starts one turn. It deliberately omits `cwd` from `thread/start`: Codex uses the app-server process working directory, while avoiding the app-server behavior that persists project trust when an explicit writable `cwd` is supplied. Autopilot verifies the returned working directory, thread ID, turn ID, ephemeral flag, approval policy, and sandbox before journaling the subject. It rejects every server-initiated request, binds the subject to a process-instance nonce, and accepts only the matching `turn/completed` notification on that uninterrupted connection. `turn/interrupt` is only accepted as cancellation after the same exact turn reports `interrupted`. @@ -54,7 +59,7 @@ Unit tests use controlled fake CLIs for command construction, changed-head denia - Queue execution becomes serial when an adapter reports concurrency one. - Missing required assurance or grants stops before edits. -- Pi in-process, Codex app-server, and OpenCode server implementations accept terminality only through their uninterrupted owning harness instance. Harness loss remains `EXECUTION_STATE_UNKNOWN`; a fresh instance never repeats admission. Direct CLI implementations retain supervised POSIX behavior, while direct Windows, legacy attempts, reviews, and incomplete supervisor bootstraps fail closed at their declared boundaries. +- Pi in-process, Claude Agent SDK, Codex app-server, and OpenCode server implementations accept terminality only through their uninterrupted owning harness instance. Harness loss remains `EXECUTION_STATE_UNKNOWN`; a fresh instance never repeats admission. Direct CLI implementations retain supervised POSIX behavior, while direct Windows, legacy attempts, reviews, and incomplete supervisor bootstraps fail closed at their declared boundaries. - Late results from expired leases are quarantined. - Provider head changes block merge. - Review findings block the current attempt and enter the next deterministic attempt context as untrusted data. diff --git a/skills/autopilot/references/charter.md b/skills/autopilot/references/charter.md index e258b13..9311623 100644 --- a/skills/autopilot/references/charter.md +++ b/skills/autopilot/references/charter.md @@ -11,7 +11,7 @@ Runtime validation in `runtime/src/charter.ts` is authoritative. `runtime/schema - `sourceText`: original user request - `createdAt`: ISO timestamp - `repository`: canonical real root, immutable base ref/commit, and maximum writable roots -- `harnessAdapter`: `pi`, `claude-code`, `codex`, `codex-app-server`, `opencode`, or `opencode-server`; the Codex and OpenCode pairs are distinct direct-CLI and same-server-instance execution modes +- `harnessAdapter`: `pi`, `claude-code`, `claude-agent-sdk`, `codex`, `codex-app-server`, `opencode`, or `opencode-server`; Claude, Codex, and OpenCode same-instance modes remain distinct from their direct-CLI modes - `mode`: `single`, `independent-queue`, or `ordered-stack` - `work`: resolved items with concise change-request titles, full objectives, dependencies, predicates, writable roots, and branches - `delivery`: `local-commits`, `change-request-ready`, or `merge-verified` diff --git a/skills/autopilot/runtime/adapters/claude-agent-sdk/index.ts b/skills/autopilot/runtime/adapters/claude-agent-sdk/index.ts new file mode 100644 index 0000000..d44bd9a --- /dev/null +++ b/skills/autopilot/runtime/adapters/claude-agent-sdk/index.ts @@ -0,0 +1,784 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Transform, type TransformCallback } from "node:stream"; +import { pathToFileURL } from "node:url"; +import { createClaudeCodeAdapter } from "../claude-code/index.js"; +import { + executionAssuranceFor, + type CancelResult, + type CapabilityManifest, + type ExecutionHandle, + type ExecutionObservation, + type ExecutionRequest, + type ExecutionSubject, + type HarnessPort, +} from "../../src/adapter-protocol.js"; +import { + adapterCredentialNames, + adapterEnvironment, + redactSecrets, + redactionValues, +} from "../../src/adapter-process.js"; +import { renderAttemptContext } from "../../src/attempt-context.js"; +import { + CLAUDE_AGENT_SDK_CLI_ENVIRONMENT, + inspectClaudeAgentSdkInstallation, + isClaudeAgentSdkScriptCli, + type ClaudeAgentSdkInstallation, +} from "../../src/claude-agent-sdk.js"; +import { AutopilotError } from "../../src/errors.js"; +import { canonicalJson, isRecord, sha256 } from "../../src/json.js"; +import { boundUtf8, StreamingRedactor, terminateDirectChild } from "../../src/process.js"; + +const IMPLEMENTATION_TOOLS = ["Bash", "Edit", "Glob", "Grep", "Read", "Write"] as const; +const REQUIRED_CAPABILITIES = ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1"] as const; +const CHILD_CLOSE_TIMEOUT_MS = 5_000; + +interface ClaudeQuery extends AsyncIterable { + interrupt(): Promise; + close(): void; +} + +interface ClaudeAgentSdkModule { + query(input: { readonly prompt: AsyncIterable; readonly options: Readonly> }): ClaudeQuery; +} + +export interface ClaudeAgentSdkAdapterOptions { + readonly reviewAdapter: HarnessPort; + readonly installation?: ClaudeAgentSdkInstallation; + readonly sdk?: ClaudeAgentSdkModule; +} + +interface Deferred { + readonly promise: Promise; + readonly resolve: (value: T) => void; + readonly reject: (error: Error) => void; +} + +interface PendingExecution { + readonly request: ExecutionRequest; + readonly query: ClaudeQuery; + readonly harnessInstanceId: string; + readonly userMessageId: string; + readonly subject: ExecutionSubject; + readonly terminal: Promise; + readonly interrupt: () => Promise; + readonly dispose: () => Promise; +} + +function deferred(): Deferred { + let resolvePromise: (value: T) => void = () => undefined; + let rejectPromise: (error: Error) => void = () => undefined; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { promise, resolve: resolvePromise, reject: rejectPromise }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function requiredRecord(value: unknown, label: string): Readonly> { + if (!isRecord(value)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `${label} is malformed`); + } + return value; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `${label} is malformed`); + } + return value; +} + +function exactStringSet(value: unknown, expected: readonly string[]): boolean { + return Array.isArray(value) + && value.every((item): item is string => typeof item === "string") + && [...value].toSorted().join("\0") === [...expected].toSorted().join("\0"); +} + +function isInterruptedTerminalReason(value: unknown): boolean { + return value === "aborted_tools" || value === "aborted_streaming"; +} + +function hasExactUserMessageIdentity(message: Readonly>, userMessageId: string): boolean { + if (message.user_message_uuid !== userMessageId) { + return false; + } + return message.user_message_uuids === undefined + || (Array.isArray(message.user_message_uuids) + && message.user_message_uuids.length === 1 + && message.user_message_uuids[0] === userMessageId); +} + +function hasNoUserMessageIdentity(message: Readonly>): boolean { + return message.user_message_uuid === undefined && message.user_message_uuids === undefined; +} + +function assistantText(message: Readonly>): string { + if (!isRecord(message.message) || !Array.isArray(message.message.content)) { + return ""; + } + return message.message.content.flatMap((part): readonly string[] => + isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [] + ).join(""); +} + +function resultText(message: Readonly>): string { + if (typeof message.result === "string") { + return message.result; + } + return Array.isArray(message.errors) + ? message.errors.filter((value): value is string => typeof value === "string").join("\n") + : ""; +} + +class LineBoundTransform extends Transform { + readonly #maximumLineBytes: number; + #lineBytes = 0; + + constructor(maximumLineBytes: number) { + super(); + this.#maximumLineBytes = maximumLineBytes; + } + + override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void { + for (const byte of chunk) { + if (byte === 0x0a) { + this.#lineBytes = 0; + } else { + this.#lineBytes += 1; + if (this.#lineBytes > this.#maximumLineBytes) { + callback(new AutopilotError( + "EXECUTION_STATE_UNKNOWN", + "Claude Agent SDK protocol record exceeded the configured line bound", + )); + return; + } + } + } + callback(undefined, chunk); + } +} + +function subjectMatches(pending: PendingExecution, handle: ExecutionHandle): boolean { + return handle.subject?.schemaVersion === 1 + && handle.subject.backendId === pending.subject.backendId + && handle.subject.subjectId === pending.subject.subjectId + && handle.subject.harnessInstanceId === pending.harnessInstanceId; +} + +export class ClaudeAgentSdkAdapter implements HarnessPort { + readonly #options: ClaudeAgentSdkAdapterOptions; + readonly #pending = new Map(); + readonly #reviewHandles = new Set(); + #installation: ClaudeAgentSdkInstallation | undefined; + #sdk: ClaudeAgentSdkModule | undefined; + + constructor(options: ClaudeAgentSdkAdapterOptions) { + this.#options = options; + } + + async describe(): Promise { + const [reviewManifest, loaded] = await Promise.all([ + this.#options.reviewAdapter.describe(), + this.#loadSdk(), + ]); + this.#installation = loaded.installation; + this.#sdk = loaded.sdk; + return { + protocolVersion: 1, + adapterName: "claude-agent-sdk", + adapterVersion: "1", + harnessVersion: `claude-agent-sdk ${loaded.installation.sdkVersion} / Claude Code ${loaded.installation.claudeCodeVersion}`, + families: reviewManifest.families, + assurance: "cooperative", + unattended: true, + maxConcurrency: 1, + eventStreaming: true, + cancellation: true, + restartReattachment: false, + executionAssurance: { + schemaVersion: 1, + implementation: { + schemaVersion: 1, + owner: "harness", + continuity: "same-harness-instance", + terminality: "cooperative", + admission: "single-shot", + }, + review: executionAssuranceFor(reviewManifest, "review"), + }, + restrictions: "cooperative", + limitations: [ + "Implementation terminality requires the exact uninterrupted Agent SDK query, child, session, and caller-selected user-message identity.", + "Coordinator, query, iterator, or child loss is execution-state-unknown and cannot launch a replacement or resume a transcript.", + "Tool restrictions are cooperative and do not provide operating-system filesystem, network, subprocess, or descendant containment.", + "The SDK and matching Claude Code executable must be supplied explicitly; Autopilot never installs or discovers them from private caches.", + "Independent review uses the direct Claude Code CLI adapter and remains session-scoped.", + ], + }; + } + + async launch(request: ExecutionRequest): Promise { + if (request.role === "review") { + const handle = await this.#options.reviewAdapter.launch(request); + this.#reviewHandles.add(handle.adapterExecutionId); + return handle; + } + if (request.protocolVersion !== 1) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "execution request protocol version is not supported"); + } + if (this.#installation === undefined || this.#sdk === undefined) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "Claude Agent SDK capabilities must be loaded before launch"); + } + + const installation = this.#installation; + const sdk = this.#sdk; + const adapterExecutionId = randomUUID(); + const harnessInstanceId = randomUUID(); + const userMessageId = randomUUID(); + const startedAt = new Date().toISOString(); + const admission = deferred(); + const terminal = deferred(); + void admission.promise.catch(() => undefined); + void terminal.promise.catch(() => undefined); + const releaseInput = deferred(); + void releaseInput.promise.catch(() => undefined); + const credentials = adapterCredentialNames(request); + const stderrRedactor = new StreamingRedactor(redactionValues(credentials)); + const stderr = { value: "", truncated: false, finished: false }; + const expectedCwd = await realpath(request.worktreePath); + const configDirectory = await mkdtemp(join(tmpdir(), "autopilot-claude-agent-sdk-")); + const timeoutMs = Math.max(1, Date.parse(request.deadline) - Date.now()); + let child: ChildProcessWithoutNullStreams | undefined; + let childClosed: Promise | undefined; + let protocolOutput: LineBoundTransform | undefined; + let query: ClaudeQuery | undefined; + let sessionId: string | undefined; + let subject: ExecutionSubject | undefined; + let admitted = false; + let terminalAccepted = false; + let settledResult: Readonly> | undefined; + let cancelRequested = false; + let interruptReceiptAccepted = false; + let interruption: Promise | undefined; + let output = ""; + let outputTruncated = false; + let spawnCount = 0; + let idleTimer: NodeJS.Timeout | undefined; + let deadlineTimer: NodeJS.Timeout | undefined; + let disposal: Promise | undefined; + let disposing = false; + + const appendOutput = (text: string, deduplicateWholeOutput = false): void => { + if (text.length === 0) { + return; + } + const combined = output.length === 0 || (deduplicateWholeOutput && output === text) + ? text : `${output}\n${text}`; + const bounded = boundUtf8(redactSecrets(combined, credentials), request.maximumOutputBytes); + output = bounded.value; + outputTruncated ||= bounded.truncated; + }; + const appendStderr = (text: string): void => { + const bounded = boundUtf8(`${stderr.value}${text}`, request.maximumOutputBytes); + stderr.value = bounded.value; + stderr.truncated ||= bounded.truncated; + }; + const finishStderr = (): void => { + if (!stderr.finished) { + stderr.finished = true; + appendStderr(stderrRedactor.end()); + } + }; + const resetIdleTimer = (): void => { + if (disposing || terminalAccepted) { + return; + } + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + idleTimer = setTimeout(() => { + failUnknown("Claude Agent SDK exceeded the harness idle timeout without an exact terminal result"); + }, request.idleTimeoutMs); + idleTimer.unref(); + }; + const dispose = (): Promise => { + disposal ??= (async () => { + disposing = true; + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + if (deadlineTimer !== undefined) { + clearTimeout(deadlineTimer); + } + releaseInput.resolve(); + try { + try { + query?.close(); + } catch { + // Query.close() is cleanup only; terminality was established separately. + } + protocolOutput?.destroy(); + if (child !== undefined) { + child.stdout.destroy(); + try { + await terminateDirectChild(child, "Claude Agent SDK"); + if (childClosed === undefined) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK child close observation is unavailable"); + } + let closeTimer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + childClosed, + new Promise((_resolve, reject) => { + closeTimer = setTimeout(() => { + reject(new AutopilotError( + "EXECUTION_STATE_UNKNOWN", + "Claude Agent SDK child streams did not close after direct-child termination", + )); + }, CHILD_CLOSE_TIMEOUT_MS); + closeTimer.unref(); + }), + ]); + } finally { + if (closeTimer !== undefined) { + clearTimeout(closeTimer); + } + } + finishStderr(); + } finally { + child.stdin.destroy(); + child.stderr.destroy(); + child.unref(); + } + } + } finally { + await rm(configDirectory, { recursive: true, force: true }); + } + })(); + return disposal; + }; + const rejectAfterCleanup = (error: AutopilotError): void => { + void dispose().then(() => { + if (admitted) { + terminal.reject(error); + } else { + admission.reject(error); + terminal.reject(error); + } + }, (cleanupError) => { + const combined = new AutopilotError("EXECUTION_STATE_UNKNOWN", error.message, { + ...error.details, + cleanup: redactSecrets(errorMessage(cleanupError), credentials), + }); + if (admitted) { + terminal.reject(combined); + } else { + admission.reject(combined); + terminal.reject(combined); + } + }); + }; + const failUnknown = (message: string, cause?: unknown): void => { + if (terminalAccepted) { + return; + } + terminalAccepted = true; + rejectAfterCleanup(new AutopilotError("EXECUTION_STATE_UNKNOWN", message, { + ...(cause === undefined ? {} : { cause: redactSecrets(errorMessage(cause), credentials) }), + })); + }; + const validateInit = async (message: Readonly>): Promise => { + if (sessionId !== undefined || spawnCount !== 1 || child === undefined) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK emitted a conflicting initialization identity"); + } + const initSessionId = requiredString(message.session_id, "Claude Agent SDK session id"); + const cwd = requiredString(message.cwd, "Claude Agent SDK working directory"); + const actualCwd = await realpath(cwd); + if (actualCwd !== expectedCwd) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK admitted a different working directory"); + } + if (message.claude_code_version !== installation.claudeCodeVersion) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK reported a different Claude Code version"); + } + if (message.permissionMode !== "dontAsk" || !exactStringSet(message.tools, IMPLEMENTATION_TOOLS)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK changed the unattended tool or permission surface"); + } + if (!Array.isArray(message.mcp_servers) || message.mcp_servers.length !== 0 + || !Array.isArray(message.skills) || message.skills.length !== 0 + || !Array.isArray(message.plugins) || message.plugins.length !== 0) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK admitted ambient MCP, skill, or plugin authority"); + } + const capabilities = message.capabilities; + if (!Array.isArray(capabilities) + || !REQUIRED_CAPABILITIES.every((capability) => capabilities.includes(capability))) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK lacks required identity or interruption capabilities"); + } + sessionId = initSessionId; + }; + const admit = (): void => { + if (admitted || sessionId === undefined) { + return; + } + subject = { + schemaVersion: 1, + backendId: `claude-agent-sdk@${installation.sdkVersion}/claude-code@${installation.claudeCodeVersion}`, + subjectId: sha256(canonicalJson({ harnessInstanceId, sessionId, userMessageId })), + harnessInstanceId, + }; + admitted = true; + admission.resolve({ protocolVersion: 1, adapterExecutionId, startedAt, subject }); + }; + const finalize = (): void => { + if (terminalAccepted || settledResult === undefined || !admitted) { + return; + } + const terminalReason = settledResult.terminal_reason; + const interrupted = isInterruptedTerminalReason(terminalReason); + if (cancelRequested && interrupted && !interruptReceiptAccepted) { + return; + } + terminalAccepted = true; + const completed = settledResult.subtype === "success" + && settledResult.is_error === false + && terminalReason === "completed"; + const cancelled = cancelRequested && interruptReceiptAccepted && interrupted; + appendOutput(resultText(settledResult), true); + void dispose().then(() => terminal.resolve({ + protocolVersion: 1, + adapterExecutionId, + status: completed ? "completed" : cancelled ? "cancelled" : "failed", + exitCode: completed ? 0 : cancelled ? 130 : 1, + completedAt: new Date().toISOString(), + stdout: output, + stderr: stderr.value, + truncated: outputTruncated || stderr.truncated, + }), (error) => { + terminal.reject(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK cleanup could not prove direct-child termination", { + cause: redactSecrets(errorMessage(error), credentials), + })); + }); + }; + + const environment = { + ...adapterEnvironment(request), + CLAUDE_CONFIG_DIR: configDirectory, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + CLAUDE_CODE_DISABLE_TELEMETRY: "1", + CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1", + DISABLE_AUTOUPDATER: "1", + }; + async function* prompt(): AsyncGenerator { + yield { + type: "user", + message: { role: "user", content: renderAttemptContext(request.context) }, + parent_tool_use_id: null, + uuid: userMessageId, + session_id: "", + }; + await releaseInput.promise; + } + + try { + query = sdk.query({ + prompt: prompt(), + options: { + cwd: request.worktreePath, + pathToClaudeCodeExecutable: installation.cliPath, + env: environment, + settingSources: [], + tools: [...IMPLEMENTATION_TOOLS], + allowedTools: [...IMPLEMENTATION_TOOLS], + disallowedTools: ["WebFetch", "WebSearch", "Task", "Skill", "AskUserQuestion", "EnterPlanMode", "ExitPlanMode"], + permissionMode: "dontAsk", + persistSession: false, + strictMcpConfig: true, + mcpServers: {}, + skills: [], + plugins: [], + additionalDirectories: [], + extraArgs: { "disable-slash-commands": null }, + hooks: { + PreToolUse: [{ + hooks: [async (input: unknown) => { + const hookInput = requiredRecord(input, "Claude Agent SDK pre-tool hook input"); + const allowed = typeof hookInput.tool_name === "string" && IMPLEMENTATION_TOOLS.includes( + hookInput.tool_name as (typeof IMPLEMENTATION_TOOLS)[number], + ); + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: allowed ? "allow" : "deny", + permissionDecisionReason: allowed + ? "Tool is within the sealed implementation surface" + : "Autopilot rejects tools outside the sealed implementation surface", + }, + }; + }], + }], + }, + spawnClaudeCodeProcess: (spawnOptions: unknown) => { + spawnCount += 1; + if (spawnCount !== 1) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK attempted to spawn more than one child"); + } + const options = requiredRecord(spawnOptions, "Claude Agent SDK spawn request"); + const scriptCli = isClaudeAgentSdkScriptCli(installation.cliPath); + const expectedCommand = scriptCli ? "node" : installation.cliPath; + if (options.command !== expectedCommand + || !Array.isArray(options.args) + || options.args.some((value) => typeof value !== "string") + || (scriptCli && options.args[0] !== installation.cliPath) + || typeof options.cwd !== "string" + || !isRecord(options.env) + || Object.values(options.env).some((value) => value !== undefined && typeof value !== "string")) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK changed the admitted process identity"); + } + const processEnvironment: NodeJS.ProcessEnv = { + ...environment, + CLAUDE_CODE_ENTRYPOINT: "sdk-ts", + CLAUDE_AGENT_SDK_VERSION: installation.sdkVersion, + }; + const suppliedEnvironment = options.env as NodeJS.ProcessEnv; + const expectedEnvironmentEntries = Object.entries(processEnvironment); + if (Object.keys(suppliedEnvironment).length !== expectedEnvironmentEntries.length + || expectedEnvironmentEntries.some(([name, value]) => suppliedEnvironment[name] !== value)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK changed the isolated process environment"); + } + child = spawn(scriptCli ? process.execPath : installation.cliPath, options.args as string[], { + cwd: request.worktreePath, + env: processEnvironment, + signal: options.signal instanceof AbortSignal ? options.signal : undefined, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + childClosed = new Promise((resolve) => { + child?.once("close", () => resolve()); + }); + protocolOutput = new LineBoundTransform(request.maximumLineBytes); + child.stdout.pipe(protocolOutput); + protocolOutput.once("error", (error) => { + failUnknown("Claude Agent SDK protocol stream failed", error); + }); + child.stderr.on("data", (chunk: Buffer) => { + resetIdleTimer(); + appendStderr(stderrRedactor.write(chunk)); + }); + child.stderr.once("end", finishStderr); + child.once("error", (error) => { + failUnknown("Claude Agent SDK child process failed", error); + }); + return { + pid: child.pid, + stdin: child.stdin, + stdout: protocolOutput, + get killed() { return child?.killed ?? true; }, + get exitCode() { return child?.exitCode ?? null; }, + get signalCode() { return child?.signalCode ?? null; }, + kill: (signal: NodeJS.Signals) => child?.kill(signal) ?? false, + on: (event: "exit" | "error", listener: (...arguments_: unknown[]) => void) => { + child?.on(event, listener); + }, + once: (event: "exit" | "error", listener: (...arguments_: unknown[]) => void) => { + child?.once(event, listener); + }, + off: (event: "exit" | "error", listener: (...arguments_: unknown[]) => void) => { + child?.off(event, listener); + }, + }; + }, + }, + }); + } catch (error) { + failUnknown("Claude Agent SDK query launch failed", error); + return await admission.promise; + } + + const activeQuery = query; + resetIdleTimer(); + deadlineTimer = setTimeout(() => { + failUnknown("Claude Agent SDK exceeded the attempt deadline without an exact terminal result"); + }, timeoutMs); + deadlineTimer.unref(); + + void (async () => { + try { + for await (const value of activeQuery) { + if (terminalAccepted) { + break; + } + resetIdleTimer(); + const message = requiredRecord(value, "Claude Agent SDK message"); + if (message.type === "system" && message.subtype === "init") { + await validateInit(message); + continue; + } + if (sessionId !== undefined && message.session_id !== undefined && message.session_id !== sessionId) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK message changed the admitted session identity"); + } + if (message.type === "assistant") { + const exactUserMessageIdentity = hasExactUserMessageIdentity(message, userMessageId); + // Claude omits the UUID on later tool-use replies; the admitted one-query session retains that identity. + const mayInheritAdmittedIdentity = admitted && hasNoUserMessageIdentity(message); + if (sessionId === undefined || message.session_id !== sessionId + || (!exactUserMessageIdentity && !mayInheritAdmittedIdentity)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK reply lacks the exact session or user-message identity"); + } + if (!admitted) { + admit(); + } + appendOutput(assistantText(message)); + continue; + } + if (message.type === "result") { + if (settledResult !== undefined || sessionId === undefined || message.session_id !== sessionId + || !hasExactUserMessageIdentity(message, userMessageId)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK terminal result identity is missing or conflicting"); + } + if (!admitted) { + admit(); + } + settledResult = message; + finalize(); + } + } + const awaitingInterruptReceipt = cancelRequested && settledResult !== undefined + && isInterruptedTerminalReason(settledResult.terminal_reason) && !interruptReceiptAccepted; + if (!terminalAccepted && !awaitingInterruptReceipt) { + failUnknown("Claude Agent SDK iterator ended before exact terminal acceptance"); + } + } catch (error) { + failUnknown("Claude Agent SDK iterator failed before exact terminal acceptance", error); + } + })(); + + const handle = await admission.promise; + if (subject === undefined || handle.subject !== subject) { + failUnknown("Claude Agent SDK admission did not produce an exact subject"); + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK admission did not produce an exact subject"); + } + + const pending: PendingExecution = { + request, + query: activeQuery, + harnessInstanceId, + userMessageId, + subject, + terminal: terminal.promise, + interrupt: async (): Promise => { + if (terminalAccepted) { + return false; + } + cancelRequested = true; + interruption ??= (async () => { + let interruptRequest: Promise; + try { + interruptRequest = activeQuery.interrupt(); + } catch (error) { + failUnknown("Claude Agent SDK interrupt request failed", error); + await terminal.promise; + return false; + } + const interrupt = interruptRequest.then( + (receipt) => ({ kind: "receipt" as const, receipt }), + (error: unknown) => ({ kind: "error" as const, error }), + ); + const terminalResult = terminal.promise.then( + (observation) => ({ kind: "terminal" as const, observation }), + ); + const first = await Promise.race([interrupt, terminalResult]); + if (first.kind === "terminal") { + return first.observation.status === "cancelled"; + } + if (first.kind === "error") { + failUnknown("Claude Agent SDK interrupt request failed", first.error); + await terminal.promise; + return false; + } + const { receipt } = first; + if (!isRecord(receipt) || !Array.isArray(receipt.still_queued) + || receipt.still_queued.some((value) => typeof value !== "string") + || receipt.still_queued.length !== 0) { + failUnknown("Claude Agent SDK interrupt did not prove an empty surviving queue"); + await terminal.promise; + return false; + } + interruptReceiptAccepted = true; + finalize(); + const observation = await terminal.promise; + return observation.status === "cancelled"; + })(); + return await interruption; + }, + dispose, + }; + this.#pending.set(adapterExecutionId, pending); + return handle; + } + + async observe(handle: ExecutionHandle): Promise { + if (this.#reviewHandles.has(handle.adapterExecutionId)) { + try { + return await this.#options.reviewAdapter.observe(handle); + } finally { + this.#reviewHandles.delete(handle.adapterExecutionId); + } + } + const pending = this.#pending.get(handle.adapterExecutionId); + if (pending === undefined || !subjectMatches(pending, handle)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK handle is not owned by this harness instance"); + } + try { + return await pending.terminal; + } finally { + try { + await pending.dispose(); + } finally { + this.#pending.delete(handle.adapterExecutionId); + } + } + } + + async cancel(handle: ExecutionHandle): Promise { + if (this.#reviewHandles.has(handle.adapterExecutionId)) { + return await this.#options.reviewAdapter.cancel(handle); + } + const pending = this.#pending.get(handle.adapterExecutionId); + if (pending === undefined || !subjectMatches(pending, handle)) { + return { protocolVersion: 1, accepted: false }; + } + return { protocolVersion: 1, accepted: await pending.interrupt() }; + } + + async #loadSdk(): Promise<{ readonly installation: ClaudeAgentSdkInstallation; readonly sdk: ClaudeAgentSdkModule }> { + const installation = this.#options.installation ?? await inspectClaudeAgentSdkInstallation(); + if (this.#options.sdk !== undefined) { + return { installation, sdk: this.#options.sdk }; + } + let moduleValue: unknown; + try { + moduleValue = await import(pathToFileURL(installation.modulePath).href) as unknown; + } catch (error) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "the supplied Claude Agent SDK could not be loaded", { + cause: errorMessage(error), + }); + } + if (!isRecord(moduleValue) || typeof moduleValue.query !== "function") { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "the supplied Claude Agent SDK does not export query()"); + } + return { installation, sdk: moduleValue as unknown as ClaudeAgentSdkModule }; + } +} + +export function createClaudeAgentSdkAdapter(): ClaudeAgentSdkAdapter { + const reviewExecutable = process.env[CLAUDE_AGENT_SDK_CLI_ENVIRONMENT] || "claude"; + return new ClaudeAgentSdkAdapter({ reviewAdapter: createClaudeCodeAdapter(reviewExecutable) }); +} diff --git a/skills/autopilot/runtime/adapters/claude-code/index.ts b/skills/autopilot/runtime/adapters/claude-code/index.ts index 1bec1b6..d59c237 100644 --- a/skills/autopilot/runtime/adapters/claude-code/index.ts +++ b/skills/autopilot/runtime/adapters/claude-code/index.ts @@ -1,11 +1,15 @@ import { CliHarnessAdapter } from "../../src/adapter-process.js"; +import { isClaudeAgentSdkScriptCli } from "../../src/claude-agent-sdk.js"; -export function createClaudeCodeAdapter(): CliHarnessAdapter { +export function createClaudeCodeAdapter(executable = "claude"): CliHarnessAdapter { + const scriptCli = isClaudeAgentSdkScriptCli(executable); + const argumentPrefix = scriptCli ? [executable] : []; return new CliHarnessAdapter({ name: "claude-code", - executable: "claude", - versionArguments: ["--version"], + executable: scriptCli ? process.execPath : executable, + versionArguments: [...argumentPrefix, "--version"], buildArguments: (request, prompt) => [ + ...argumentPrefix, "--print", "--output-format", "stream-json", "--verbose", @@ -24,7 +28,7 @@ export function createClaudeCodeAdapter(): CliHarnessAdapter { "Bash restrictions are cooperative.", "Safe mode excludes customizations; admin-managed policy may still apply.", "Implementation executions use the attempt-scoped supervisor for restart reattachment; review executions remain session-scoped.", - "The exact-tree review role is implemented but has no version-pinned live verification.", + "Version 2.1.251 passed a disposable exact-tree review without changing HEAD, Git configuration, or global Claude configuration.", ], expectsJsonLines: true, }); diff --git a/skills/autopilot/runtime/dist/adapters/claude-agent-sdk/index.d.ts b/skills/autopilot/runtime/dist/adapters/claude-agent-sdk/index.d.ts new file mode 100644 index 0000000..c632085 --- /dev/null +++ b/skills/autopilot/runtime/dist/adapters/claude-agent-sdk/index.d.ts @@ -0,0 +1,27 @@ +import { type CancelResult, type CapabilityManifest, type ExecutionHandle, type ExecutionObservation, type ExecutionRequest, type HarnessPort } from "../../src/adapter-protocol.js"; +import { type ClaudeAgentSdkInstallation } from "../../src/claude-agent-sdk.js"; +interface ClaudeQuery extends AsyncIterable { + interrupt(): Promise; + close(): void; +} +interface ClaudeAgentSdkModule { + query(input: { + readonly prompt: AsyncIterable; + readonly options: Readonly>; + }): ClaudeQuery; +} +export interface ClaudeAgentSdkAdapterOptions { + readonly reviewAdapter: HarnessPort; + readonly installation?: ClaudeAgentSdkInstallation; + readonly sdk?: ClaudeAgentSdkModule; +} +export declare class ClaudeAgentSdkAdapter implements HarnessPort { + #private; + constructor(options: ClaudeAgentSdkAdapterOptions); + describe(): Promise; + launch(request: ExecutionRequest): Promise; + observe(handle: ExecutionHandle): Promise; + cancel(handle: ExecutionHandle): Promise; +} +export declare function createClaudeAgentSdkAdapter(): ClaudeAgentSdkAdapter; +export {}; diff --git a/skills/autopilot/runtime/dist/adapters/claude-agent-sdk/index.js b/skills/autopilot/runtime/dist/adapters/claude-agent-sdk/index.js new file mode 100644 index 0000000..edd5a81 --- /dev/null +++ b/skills/autopilot/runtime/dist/adapters/claude-agent-sdk/index.js @@ -0,0 +1,701 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Transform } from "node:stream"; +import { pathToFileURL } from "node:url"; +import { createClaudeCodeAdapter } from "../claude-code/index.js"; +import { executionAssuranceFor, } from "../../src/adapter-protocol.js"; +import { adapterCredentialNames, adapterEnvironment, redactSecrets, redactionValues, } from "../../src/adapter-process.js"; +import { renderAttemptContext } from "../../src/attempt-context.js"; +import { CLAUDE_AGENT_SDK_CLI_ENVIRONMENT, inspectClaudeAgentSdkInstallation, isClaudeAgentSdkScriptCli, } from "../../src/claude-agent-sdk.js"; +import { AutopilotError } from "../../src/errors.js"; +import { canonicalJson, isRecord, sha256 } from "../../src/json.js"; +import { boundUtf8, StreamingRedactor, terminateDirectChild } from "../../src/process.js"; +const IMPLEMENTATION_TOOLS = ["Bash", "Edit", "Glob", "Grep", "Read", "Write"]; +const REQUIRED_CAPABILITIES = ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1"]; +const CHILD_CLOSE_TIMEOUT_MS = 5_000; +function deferred() { + let resolvePromise = () => undefined; + let rejectPromise = () => undefined; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { promise, resolve: resolvePromise, reject: rejectPromise }; +} +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} +function requiredRecord(value, label) { + if (!isRecord(value)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `${label} is malformed`); + } + return value; +} +function requiredString(value, label) { + if (typeof value !== "string" || value.length === 0) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `${label} is malformed`); + } + return value; +} +function exactStringSet(value, expected) { + return Array.isArray(value) + && value.every((item) => typeof item === "string") + && [...value].toSorted().join("\0") === [...expected].toSorted().join("\0"); +} +function isInterruptedTerminalReason(value) { + return value === "aborted_tools" || value === "aborted_streaming"; +} +function hasExactUserMessageIdentity(message, userMessageId) { + if (message.user_message_uuid !== userMessageId) { + return false; + } + return message.user_message_uuids === undefined + || (Array.isArray(message.user_message_uuids) + && message.user_message_uuids.length === 1 + && message.user_message_uuids[0] === userMessageId); +} +function hasNoUserMessageIdentity(message) { + return message.user_message_uuid === undefined && message.user_message_uuids === undefined; +} +function assistantText(message) { + if (!isRecord(message.message) || !Array.isArray(message.message.content)) { + return ""; + } + return message.message.content.flatMap((part) => isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : []).join(""); +} +function resultText(message) { + if (typeof message.result === "string") { + return message.result; + } + return Array.isArray(message.errors) + ? message.errors.filter((value) => typeof value === "string").join("\n") + : ""; +} +class LineBoundTransform extends Transform { + #maximumLineBytes; + #lineBytes = 0; + constructor(maximumLineBytes) { + super(); + this.#maximumLineBytes = maximumLineBytes; + } + _transform(chunk, _encoding, callback) { + for (const byte of chunk) { + if (byte === 0x0a) { + this.#lineBytes = 0; + } + else { + this.#lineBytes += 1; + if (this.#lineBytes > this.#maximumLineBytes) { + callback(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK protocol record exceeded the configured line bound")); + return; + } + } + } + callback(undefined, chunk); + } +} +function subjectMatches(pending, handle) { + return handle.subject?.schemaVersion === 1 + && handle.subject.backendId === pending.subject.backendId + && handle.subject.subjectId === pending.subject.subjectId + && handle.subject.harnessInstanceId === pending.harnessInstanceId; +} +export class ClaudeAgentSdkAdapter { + #options; + #pending = new Map(); + #reviewHandles = new Set(); + #installation; + #sdk; + constructor(options) { + this.#options = options; + } + async describe() { + const [reviewManifest, loaded] = await Promise.all([ + this.#options.reviewAdapter.describe(), + this.#loadSdk(), + ]); + this.#installation = loaded.installation; + this.#sdk = loaded.sdk; + return { + protocolVersion: 1, + adapterName: "claude-agent-sdk", + adapterVersion: "1", + harnessVersion: `claude-agent-sdk ${loaded.installation.sdkVersion} / Claude Code ${loaded.installation.claudeCodeVersion}`, + families: reviewManifest.families, + assurance: "cooperative", + unattended: true, + maxConcurrency: 1, + eventStreaming: true, + cancellation: true, + restartReattachment: false, + executionAssurance: { + schemaVersion: 1, + implementation: { + schemaVersion: 1, + owner: "harness", + continuity: "same-harness-instance", + terminality: "cooperative", + admission: "single-shot", + }, + review: executionAssuranceFor(reviewManifest, "review"), + }, + restrictions: "cooperative", + limitations: [ + "Implementation terminality requires the exact uninterrupted Agent SDK query, child, session, and caller-selected user-message identity.", + "Coordinator, query, iterator, or child loss is execution-state-unknown and cannot launch a replacement or resume a transcript.", + "Tool restrictions are cooperative and do not provide operating-system filesystem, network, subprocess, or descendant containment.", + "The SDK and matching Claude Code executable must be supplied explicitly; Autopilot never installs or discovers them from private caches.", + "Independent review uses the direct Claude Code CLI adapter and remains session-scoped.", + ], + }; + } + async launch(request) { + if (request.role === "review") { + const handle = await this.#options.reviewAdapter.launch(request); + this.#reviewHandles.add(handle.adapterExecutionId); + return handle; + } + if (request.protocolVersion !== 1) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "execution request protocol version is not supported"); + } + if (this.#installation === undefined || this.#sdk === undefined) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "Claude Agent SDK capabilities must be loaded before launch"); + } + const installation = this.#installation; + const sdk = this.#sdk; + const adapterExecutionId = randomUUID(); + const harnessInstanceId = randomUUID(); + const userMessageId = randomUUID(); + const startedAt = new Date().toISOString(); + const admission = deferred(); + const terminal = deferred(); + void admission.promise.catch(() => undefined); + void terminal.promise.catch(() => undefined); + const releaseInput = deferred(); + void releaseInput.promise.catch(() => undefined); + const credentials = adapterCredentialNames(request); + const stderrRedactor = new StreamingRedactor(redactionValues(credentials)); + const stderr = { value: "", truncated: false, finished: false }; + const expectedCwd = await realpath(request.worktreePath); + const configDirectory = await mkdtemp(join(tmpdir(), "autopilot-claude-agent-sdk-")); + const timeoutMs = Math.max(1, Date.parse(request.deadline) - Date.now()); + let child; + let childClosed; + let protocolOutput; + let query; + let sessionId; + let subject; + let admitted = false; + let terminalAccepted = false; + let settledResult; + let cancelRequested = false; + let interruptReceiptAccepted = false; + let interruption; + let output = ""; + let outputTruncated = false; + let spawnCount = 0; + let idleTimer; + let deadlineTimer; + let disposal; + let disposing = false; + const appendOutput = (text, deduplicateWholeOutput = false) => { + if (text.length === 0) { + return; + } + const combined = output.length === 0 || (deduplicateWholeOutput && output === text) + ? text : `${output}\n${text}`; + const bounded = boundUtf8(redactSecrets(combined, credentials), request.maximumOutputBytes); + output = bounded.value; + outputTruncated ||= bounded.truncated; + }; + const appendStderr = (text) => { + const bounded = boundUtf8(`${stderr.value}${text}`, request.maximumOutputBytes); + stderr.value = bounded.value; + stderr.truncated ||= bounded.truncated; + }; + const finishStderr = () => { + if (!stderr.finished) { + stderr.finished = true; + appendStderr(stderrRedactor.end()); + } + }; + const resetIdleTimer = () => { + if (disposing || terminalAccepted) { + return; + } + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + idleTimer = setTimeout(() => { + failUnknown("Claude Agent SDK exceeded the harness idle timeout without an exact terminal result"); + }, request.idleTimeoutMs); + idleTimer.unref(); + }; + const dispose = () => { + disposal ??= (async () => { + disposing = true; + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + if (deadlineTimer !== undefined) { + clearTimeout(deadlineTimer); + } + releaseInput.resolve(); + try { + try { + query?.close(); + } + catch { + // Query.close() is cleanup only; terminality was established separately. + } + protocolOutput?.destroy(); + if (child !== undefined) { + child.stdout.destroy(); + try { + await terminateDirectChild(child, "Claude Agent SDK"); + if (childClosed === undefined) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK child close observation is unavailable"); + } + let closeTimer; + try { + await Promise.race([ + childClosed, + new Promise((_resolve, reject) => { + closeTimer = setTimeout(() => { + reject(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK child streams did not close after direct-child termination")); + }, CHILD_CLOSE_TIMEOUT_MS); + closeTimer.unref(); + }), + ]); + } + finally { + if (closeTimer !== undefined) { + clearTimeout(closeTimer); + } + } + finishStderr(); + } + finally { + child.stdin.destroy(); + child.stderr.destroy(); + child.unref(); + } + } + } + finally { + await rm(configDirectory, { recursive: true, force: true }); + } + })(); + return disposal; + }; + const rejectAfterCleanup = (error) => { + void dispose().then(() => { + if (admitted) { + terminal.reject(error); + } + else { + admission.reject(error); + terminal.reject(error); + } + }, (cleanupError) => { + const combined = new AutopilotError("EXECUTION_STATE_UNKNOWN", error.message, { + ...error.details, + cleanup: redactSecrets(errorMessage(cleanupError), credentials), + }); + if (admitted) { + terminal.reject(combined); + } + else { + admission.reject(combined); + terminal.reject(combined); + } + }); + }; + const failUnknown = (message, cause) => { + if (terminalAccepted) { + return; + } + terminalAccepted = true; + rejectAfterCleanup(new AutopilotError("EXECUTION_STATE_UNKNOWN", message, { + ...(cause === undefined ? {} : { cause: redactSecrets(errorMessage(cause), credentials) }), + })); + }; + const validateInit = async (message) => { + if (sessionId !== undefined || spawnCount !== 1 || child === undefined) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK emitted a conflicting initialization identity"); + } + const initSessionId = requiredString(message.session_id, "Claude Agent SDK session id"); + const cwd = requiredString(message.cwd, "Claude Agent SDK working directory"); + const actualCwd = await realpath(cwd); + if (actualCwd !== expectedCwd) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK admitted a different working directory"); + } + if (message.claude_code_version !== installation.claudeCodeVersion) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK reported a different Claude Code version"); + } + if (message.permissionMode !== "dontAsk" || !exactStringSet(message.tools, IMPLEMENTATION_TOOLS)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK changed the unattended tool or permission surface"); + } + if (!Array.isArray(message.mcp_servers) || message.mcp_servers.length !== 0 + || !Array.isArray(message.skills) || message.skills.length !== 0 + || !Array.isArray(message.plugins) || message.plugins.length !== 0) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK admitted ambient MCP, skill, or plugin authority"); + } + const capabilities = message.capabilities; + if (!Array.isArray(capabilities) + || !REQUIRED_CAPABILITIES.every((capability) => capabilities.includes(capability))) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK lacks required identity or interruption capabilities"); + } + sessionId = initSessionId; + }; + const admit = () => { + if (admitted || sessionId === undefined) { + return; + } + subject = { + schemaVersion: 1, + backendId: `claude-agent-sdk@${installation.sdkVersion}/claude-code@${installation.claudeCodeVersion}`, + subjectId: sha256(canonicalJson({ harnessInstanceId, sessionId, userMessageId })), + harnessInstanceId, + }; + admitted = true; + admission.resolve({ protocolVersion: 1, adapterExecutionId, startedAt, subject }); + }; + const finalize = () => { + if (terminalAccepted || settledResult === undefined || !admitted) { + return; + } + const terminalReason = settledResult.terminal_reason; + const interrupted = isInterruptedTerminalReason(terminalReason); + if (cancelRequested && interrupted && !interruptReceiptAccepted) { + return; + } + terminalAccepted = true; + const completed = settledResult.subtype === "success" + && settledResult.is_error === false + && terminalReason === "completed"; + const cancelled = cancelRequested && interruptReceiptAccepted && interrupted; + appendOutput(resultText(settledResult), true); + void dispose().then(() => terminal.resolve({ + protocolVersion: 1, + adapterExecutionId, + status: completed ? "completed" : cancelled ? "cancelled" : "failed", + exitCode: completed ? 0 : cancelled ? 130 : 1, + completedAt: new Date().toISOString(), + stdout: output, + stderr: stderr.value, + truncated: outputTruncated || stderr.truncated, + }), (error) => { + terminal.reject(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK cleanup could not prove direct-child termination", { + cause: redactSecrets(errorMessage(error), credentials), + })); + }); + }; + const environment = { + ...adapterEnvironment(request), + CLAUDE_CONFIG_DIR: configDirectory, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + CLAUDE_CODE_DISABLE_TELEMETRY: "1", + CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1", + DISABLE_AUTOUPDATER: "1", + }; + async function* prompt() { + yield { + type: "user", + message: { role: "user", content: renderAttemptContext(request.context) }, + parent_tool_use_id: null, + uuid: userMessageId, + session_id: "", + }; + await releaseInput.promise; + } + try { + query = sdk.query({ + prompt: prompt(), + options: { + cwd: request.worktreePath, + pathToClaudeCodeExecutable: installation.cliPath, + env: environment, + settingSources: [], + tools: [...IMPLEMENTATION_TOOLS], + allowedTools: [...IMPLEMENTATION_TOOLS], + disallowedTools: ["WebFetch", "WebSearch", "Task", "Skill", "AskUserQuestion", "EnterPlanMode", "ExitPlanMode"], + permissionMode: "dontAsk", + persistSession: false, + strictMcpConfig: true, + mcpServers: {}, + skills: [], + plugins: [], + additionalDirectories: [], + extraArgs: { "disable-slash-commands": null }, + hooks: { + PreToolUse: [{ + hooks: [async (input) => { + const hookInput = requiredRecord(input, "Claude Agent SDK pre-tool hook input"); + const allowed = typeof hookInput.tool_name === "string" && IMPLEMENTATION_TOOLS.includes(hookInput.tool_name); + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: allowed ? "allow" : "deny", + permissionDecisionReason: allowed + ? "Tool is within the sealed implementation surface" + : "Autopilot rejects tools outside the sealed implementation surface", + }, + }; + }], + }], + }, + spawnClaudeCodeProcess: (spawnOptions) => { + spawnCount += 1; + if (spawnCount !== 1) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK attempted to spawn more than one child"); + } + const options = requiredRecord(spawnOptions, "Claude Agent SDK spawn request"); + const scriptCli = isClaudeAgentSdkScriptCli(installation.cliPath); + const expectedCommand = scriptCli ? "node" : installation.cliPath; + if (options.command !== expectedCommand + || !Array.isArray(options.args) + || options.args.some((value) => typeof value !== "string") + || (scriptCli && options.args[0] !== installation.cliPath) + || typeof options.cwd !== "string" + || !isRecord(options.env) + || Object.values(options.env).some((value) => value !== undefined && typeof value !== "string")) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK changed the admitted process identity"); + } + const processEnvironment = { + ...environment, + CLAUDE_CODE_ENTRYPOINT: "sdk-ts", + CLAUDE_AGENT_SDK_VERSION: installation.sdkVersion, + }; + const suppliedEnvironment = options.env; + const expectedEnvironmentEntries = Object.entries(processEnvironment); + if (Object.keys(suppliedEnvironment).length !== expectedEnvironmentEntries.length + || expectedEnvironmentEntries.some(([name, value]) => suppliedEnvironment[name] !== value)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK changed the isolated process environment"); + } + child = spawn(scriptCli ? process.execPath : installation.cliPath, options.args, { + cwd: request.worktreePath, + env: processEnvironment, + signal: options.signal instanceof AbortSignal ? options.signal : undefined, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + childClosed = new Promise((resolve) => { + child?.once("close", () => resolve()); + }); + protocolOutput = new LineBoundTransform(request.maximumLineBytes); + child.stdout.pipe(protocolOutput); + protocolOutput.once("error", (error) => { + failUnknown("Claude Agent SDK protocol stream failed", error); + }); + child.stderr.on("data", (chunk) => { + resetIdleTimer(); + appendStderr(stderrRedactor.write(chunk)); + }); + child.stderr.once("end", finishStderr); + child.once("error", (error) => { + failUnknown("Claude Agent SDK child process failed", error); + }); + return { + pid: child.pid, + stdin: child.stdin, + stdout: protocolOutput, + get killed() { return child?.killed ?? true; }, + get exitCode() { return child?.exitCode ?? null; }, + get signalCode() { return child?.signalCode ?? null; }, + kill: (signal) => child?.kill(signal) ?? false, + on: (event, listener) => { + child?.on(event, listener); + }, + once: (event, listener) => { + child?.once(event, listener); + }, + off: (event, listener) => { + child?.off(event, listener); + }, + }; + }, + }, + }); + } + catch (error) { + failUnknown("Claude Agent SDK query launch failed", error); + return await admission.promise; + } + const activeQuery = query; + resetIdleTimer(); + deadlineTimer = setTimeout(() => { + failUnknown("Claude Agent SDK exceeded the attempt deadline without an exact terminal result"); + }, timeoutMs); + deadlineTimer.unref(); + void (async () => { + try { + for await (const value of activeQuery) { + if (terminalAccepted) { + break; + } + resetIdleTimer(); + const message = requiredRecord(value, "Claude Agent SDK message"); + if (message.type === "system" && message.subtype === "init") { + await validateInit(message); + continue; + } + if (sessionId !== undefined && message.session_id !== undefined && message.session_id !== sessionId) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK message changed the admitted session identity"); + } + if (message.type === "assistant") { + const exactUserMessageIdentity = hasExactUserMessageIdentity(message, userMessageId); + // Claude omits the UUID on later tool-use replies; the admitted one-query session retains that identity. + const mayInheritAdmittedIdentity = admitted && hasNoUserMessageIdentity(message); + if (sessionId === undefined || message.session_id !== sessionId + || (!exactUserMessageIdentity && !mayInheritAdmittedIdentity)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK reply lacks the exact session or user-message identity"); + } + if (!admitted) { + admit(); + } + appendOutput(assistantText(message)); + continue; + } + if (message.type === "result") { + if (settledResult !== undefined || sessionId === undefined || message.session_id !== sessionId + || !hasExactUserMessageIdentity(message, userMessageId)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK terminal result identity is missing or conflicting"); + } + if (!admitted) { + admit(); + } + settledResult = message; + finalize(); + } + } + const awaitingInterruptReceipt = cancelRequested && settledResult !== undefined + && isInterruptedTerminalReason(settledResult.terminal_reason) && !interruptReceiptAccepted; + if (!terminalAccepted && !awaitingInterruptReceipt) { + failUnknown("Claude Agent SDK iterator ended before exact terminal acceptance"); + } + } + catch (error) { + failUnknown("Claude Agent SDK iterator failed before exact terminal acceptance", error); + } + })(); + const handle = await admission.promise; + if (subject === undefined || handle.subject !== subject) { + failUnknown("Claude Agent SDK admission did not produce an exact subject"); + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK admission did not produce an exact subject"); + } + const pending = { + request, + query: activeQuery, + harnessInstanceId, + userMessageId, + subject, + terminal: terminal.promise, + interrupt: async () => { + if (terminalAccepted) { + return false; + } + cancelRequested = true; + interruption ??= (async () => { + let interruptRequest; + try { + interruptRequest = activeQuery.interrupt(); + } + catch (error) { + failUnknown("Claude Agent SDK interrupt request failed", error); + await terminal.promise; + return false; + } + const interrupt = interruptRequest.then((receipt) => ({ kind: "receipt", receipt }), (error) => ({ kind: "error", error })); + const terminalResult = terminal.promise.then((observation) => ({ kind: "terminal", observation })); + const first = await Promise.race([interrupt, terminalResult]); + if (first.kind === "terminal") { + return first.observation.status === "cancelled"; + } + if (first.kind === "error") { + failUnknown("Claude Agent SDK interrupt request failed", first.error); + await terminal.promise; + return false; + } + const { receipt } = first; + if (!isRecord(receipt) || !Array.isArray(receipt.still_queued) + || receipt.still_queued.some((value) => typeof value !== "string") + || receipt.still_queued.length !== 0) { + failUnknown("Claude Agent SDK interrupt did not prove an empty surviving queue"); + await terminal.promise; + return false; + } + interruptReceiptAccepted = true; + finalize(); + const observation = await terminal.promise; + return observation.status === "cancelled"; + })(); + return await interruption; + }, + dispose, + }; + this.#pending.set(adapterExecutionId, pending); + return handle; + } + async observe(handle) { + if (this.#reviewHandles.has(handle.adapterExecutionId)) { + try { + return await this.#options.reviewAdapter.observe(handle); + } + finally { + this.#reviewHandles.delete(handle.adapterExecutionId); + } + } + const pending = this.#pending.get(handle.adapterExecutionId); + if (pending === undefined || !subjectMatches(pending, handle)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Claude Agent SDK handle is not owned by this harness instance"); + } + try { + return await pending.terminal; + } + finally { + try { + await pending.dispose(); + } + finally { + this.#pending.delete(handle.adapterExecutionId); + } + } + } + async cancel(handle) { + if (this.#reviewHandles.has(handle.adapterExecutionId)) { + return await this.#options.reviewAdapter.cancel(handle); + } + const pending = this.#pending.get(handle.adapterExecutionId); + if (pending === undefined || !subjectMatches(pending, handle)) { + return { protocolVersion: 1, accepted: false }; + } + return { protocolVersion: 1, accepted: await pending.interrupt() }; + } + async #loadSdk() { + const installation = this.#options.installation ?? await inspectClaudeAgentSdkInstallation(); + if (this.#options.sdk !== undefined) { + return { installation, sdk: this.#options.sdk }; + } + let moduleValue; + try { + moduleValue = await import(pathToFileURL(installation.modulePath).href); + } + catch (error) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "the supplied Claude Agent SDK could not be loaded", { + cause: errorMessage(error), + }); + } + if (!isRecord(moduleValue) || typeof moduleValue.query !== "function") { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "the supplied Claude Agent SDK does not export query()"); + } + return { installation, sdk: moduleValue }; + } +} +export function createClaudeAgentSdkAdapter() { + const reviewExecutable = process.env[CLAUDE_AGENT_SDK_CLI_ENVIRONMENT] || "claude"; + return new ClaudeAgentSdkAdapter({ reviewAdapter: createClaudeCodeAdapter(reviewExecutable) }); +} diff --git a/skills/autopilot/runtime/dist/adapters/claude-code/index.d.ts b/skills/autopilot/runtime/dist/adapters/claude-code/index.d.ts index c579614..fdb7830 100644 --- a/skills/autopilot/runtime/dist/adapters/claude-code/index.d.ts +++ b/skills/autopilot/runtime/dist/adapters/claude-code/index.d.ts @@ -1,2 +1,2 @@ import { CliHarnessAdapter } from "../../src/adapter-process.js"; -export declare function createClaudeCodeAdapter(): CliHarnessAdapter; +export declare function createClaudeCodeAdapter(executable?: string): CliHarnessAdapter; diff --git a/skills/autopilot/runtime/dist/adapters/claude-code/index.js b/skills/autopilot/runtime/dist/adapters/claude-code/index.js index 6c5c0d1..43fdd26 100644 --- a/skills/autopilot/runtime/dist/adapters/claude-code/index.js +++ b/skills/autopilot/runtime/dist/adapters/claude-code/index.js @@ -1,10 +1,14 @@ import { CliHarnessAdapter } from "../../src/adapter-process.js"; -export function createClaudeCodeAdapter() { +import { isClaudeAgentSdkScriptCli } from "../../src/claude-agent-sdk.js"; +export function createClaudeCodeAdapter(executable = "claude") { + const scriptCli = isClaudeAgentSdkScriptCli(executable); + const argumentPrefix = scriptCli ? [executable] : []; return new CliHarnessAdapter({ name: "claude-code", - executable: "claude", - versionArguments: ["--version"], + executable: scriptCli ? process.execPath : executable, + versionArguments: [...argumentPrefix, "--version"], buildArguments: (request, prompt) => [ + ...argumentPrefix, "--print", "--output-format", "stream-json", "--verbose", @@ -23,7 +27,7 @@ export function createClaudeCodeAdapter() { "Bash restrictions are cooperative.", "Safe mode excludes customizations; admin-managed policy may still apply.", "Implementation executions use the attempt-scoped supervisor for restart reattachment; review executions remain session-scoped.", - "The exact-tree review role is implemented but has no version-pinned live verification.", + "Version 2.1.251 passed a disposable exact-tree review without changing HEAD, Git configuration, or global Claude configuration.", ], expectsJsonLines: true, }); diff --git a/skills/autopilot/runtime/dist/src/adapters.d.ts b/skills/autopilot/runtime/dist/src/adapters.d.ts index 6f6a39a..5084ffe 100644 --- a/skills/autopilot/runtime/dist/src/adapters.d.ts +++ b/skills/autopilot/runtime/dist/src/adapters.d.ts @@ -1,3 +1,3 @@ import type { HarnessPort } from "./adapter-protocol.js"; -export type AdapterName = "pi" | "claude-code" | "codex" | "codex-app-server" | "opencode" | "opencode-server"; +export type AdapterName = "pi" | "claude-code" | "claude-agent-sdk" | "codex" | "codex-app-server" | "opencode" | "opencode-server"; export declare function createAdapter(name: string): HarnessPort; diff --git a/skills/autopilot/runtime/dist/src/adapters.js b/skills/autopilot/runtime/dist/src/adapters.js index 645657e..ae6fff9 100644 --- a/skills/autopilot/runtime/dist/src/adapters.js +++ b/skills/autopilot/runtime/dist/src/adapters.js @@ -1,3 +1,4 @@ +import { createClaudeAgentSdkAdapter } from "../adapters/claude-agent-sdk/index.js"; import { createClaudeCodeAdapter } from "../adapters/claude-code/index.js"; import { createCodexAdapter, createCodexAppServerAdapter } from "../adapters/codex/index.js"; import { createOpenCodeAdapter, createOpenCodeServerAdapter } from "../adapters/opencode/index.js"; @@ -9,6 +10,8 @@ export function createAdapter(name) { return createPiAdapter(); case "claude-code": return createClaudeCodeAdapter(); + case "claude-agent-sdk": + return createClaudeAgentSdkAdapter(); case "codex": return createCodexAdapter(); case "codex-app-server": diff --git a/skills/autopilot/runtime/dist/src/charter.d.ts b/skills/autopilot/runtime/dist/src/charter.d.ts index 194fb3f..c3f9265 100644 --- a/skills/autopilot/runtime/dist/src/charter.d.ts +++ b/skills/autopilot/runtime/dist/src/charter.d.ts @@ -147,7 +147,7 @@ export interface ProposedRunCharter { readonly sourceText: string; readonly createdAt: string; readonly repository: RepositorySpec; - readonly harnessAdapter: "pi" | "claude-code" | "codex" | "codex-app-server" | "opencode" | "opencode-server"; + readonly harnessAdapter: "pi" | "claude-code" | "claude-agent-sdk" | "codex" | "codex-app-server" | "opencode" | "opencode-server"; readonly mode: RunMode; readonly work: readonly WorkItem[]; readonly delivery: DeliveryMode; diff --git a/skills/autopilot/runtime/dist/src/charter.js b/skills/autopilot/runtime/dist/src/charter.js index f0c278d..e0b878e 100644 --- a/skills/autopilot/runtime/dist/src/charter.js +++ b/skills/autopilot/runtime/dist/src/charter.js @@ -267,7 +267,7 @@ function parseProposed(value, allowHash) { sourceText: expectString(object.sourceText, "charter.sourceText"), createdAt: expectString(object.createdAt, "charter.createdAt"), repository: parseRepository(object.repository, "charter.repository"), - harnessAdapter: expectLiteral(object.harnessAdapter, ["pi", "claude-code", "codex", "codex-app-server", "opencode", "opencode-server"], "charter.harnessAdapter"), + harnessAdapter: expectLiteral(object.harnessAdapter, ["pi", "claude-code", "claude-agent-sdk", "codex", "codex-app-server", "opencode", "opencode-server"], "charter.harnessAdapter"), mode: expectLiteral(object.mode, ["single", "independent-queue", "ordered-stack"], "charter.mode"), work: object.work.map((entry, index) => parseWorkItem(entry, `charter.work[${index}]`)), delivery: expectLiteral(object.delivery, ["local-commits", "change-request-ready", "merge-verified"], "charter.delivery"), diff --git a/skills/autopilot/runtime/dist/src/claude-agent-sdk.d.ts b/skills/autopilot/runtime/dist/src/claude-agent-sdk.d.ts new file mode 100644 index 0000000..13fd41d --- /dev/null +++ b/skills/autopilot/runtime/dist/src/claude-agent-sdk.d.ts @@ -0,0 +1,13 @@ +export declare const CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT = "AUTOPILOT_CLAUDE_AGENT_SDK_ROOT"; +export declare const CLAUDE_AGENT_SDK_CLI_ENVIRONMENT = "AUTOPILOT_CLAUDE_AGENT_SDK_CLI"; +export declare const MINIMUM_CLAUDE_AGENT_SDK_VERSION = "0.3.246"; +export interface ClaudeAgentSdkInstallation { + readonly root: string; + readonly modulePath: string; + readonly cliPath: string; + readonly sdkVersion: string; + readonly claudeCodeVersion: string; +} +export declare function isClaudeAgentSdkScriptCli(cliPath: string): boolean; +export declare function isSupportedClaudeAgentSdkVersion(value: string): boolean; +export declare function inspectClaudeAgentSdkInstallation(rootValue?: string | undefined, cliValue?: string | undefined): Promise; diff --git a/skills/autopilot/runtime/dist/src/claude-agent-sdk.js b/skills/autopilot/runtime/dist/src/claude-agent-sdk.js new file mode 100644 index 0000000..6e4a950 --- /dev/null +++ b/skills/autopilot/runtime/dist/src/claude-agent-sdk.js @@ -0,0 +1,104 @@ +import { constants } from "node:fs"; +import { access, readFile, realpath, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { AutopilotError } from "./errors.js"; +import { runProcess } from "./process.js"; +import { isRecord } from "./json.js"; +export const CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT = "AUTOPILOT_CLAUDE_AGENT_SDK_ROOT"; +export const CLAUDE_AGENT_SDK_CLI_ENVIRONMENT = "AUTOPILOT_CLAUDE_AGENT_SDK_CLI"; +export const MINIMUM_CLAUDE_AGENT_SDK_VERSION = "0.3.246"; +const CLAUDE_AGENT_SDK_SCRIPT_SUFFIXES = [".js", ".mjs", ".tsx", ".ts", ".jsx"]; +function parseVersion(value) { + const match = /^(\d+)\.(\d+)\.(\d+)$/u.exec(value); + if (match === null) { + return undefined; + } + const parts = match.slice(1).map(Number); + return parts.every(Number.isSafeInteger) ? parts : undefined; +} +export function isClaudeAgentSdkScriptCli(cliPath) { + return CLAUDE_AGENT_SDK_SCRIPT_SUFFIXES.some((suffix) => cliPath.endsWith(suffix)); +} +export function isSupportedClaudeAgentSdkVersion(value) { + const actual = parseVersion(value); + const minimum = parseVersion(MINIMUM_CLAUDE_AGENT_SDK_VERSION); + if (actual === undefined || minimum === undefined) { + return false; + } + for (const index of [0, 1, 2]) { + if (actual[index] !== minimum[index]) { + return actual[index] > minimum[index]; + } + } + return true; +} +export async function inspectClaudeAgentSdkInstallation(rootValue = process.env[CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT], cliValue = process.env[CLAUDE_AGENT_SDK_CLI_ENVIRONMENT]) { + if (rootValue === undefined || rootValue.length === 0 || cliValue === undefined || cliValue.length === 0) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", `${CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT} and ${CLAUDE_AGENT_SDK_CLI_ENVIRONMENT} must name an operator-provided Agent SDK and Claude Code executable`); + } + let root; + let cliPath; + try { + [root, cliPath] = await Promise.all([realpath(rootValue), realpath(cliValue)]); + const [, , cliStat] = await Promise.all([ + access(join(root, "sdk.mjs"), constants.R_OK), + access(cliPath, constants.R_OK | constants.X_OK), + stat(cliPath), + ]); + if (!cliStat.isFile()) { + throw new Error("the supplied Claude Code executable is not a regular file"); + } + } + catch (error) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "the supplied Agent SDK root or Claude Code executable is unavailable", { + cause: error instanceof Error ? error.message : String(error), + }); + } + const packagePath = join(root, "package.json"); + const modulePath = join(root, "sdk.mjs"); + let packageValue; + try { + packageValue = JSON.parse(await readFile(packagePath, "utf8")); + } + catch (error) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "the supplied Agent SDK package metadata is unavailable", { + cause: error instanceof Error ? error.message : String(error), + }); + } + if (!isRecord(packageValue) + || packageValue.name !== "@anthropic-ai/claude-agent-sdk" + || typeof packageValue.version !== "string" + || typeof packageValue.claudeCodeVersion !== "string") { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "the supplied package is not a compatible Claude Agent SDK"); + } + if (!isSupportedClaudeAgentSdkVersion(packageValue.version)) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", `Claude Agent SDK ${packageValue.version} is older than ${MINIMUM_CLAUDE_AGENT_SDK_VERSION}`); + } + try { + const scriptCli = isClaudeAgentSdkScriptCli(cliPath); + const versionResult = await runProcess({ + executable: scriptCli ? process.execPath : cliPath, + arguments: scriptCli ? [cliPath, "--version"] : ["--version"], + cwd: root, + timeoutMs: 5_000, + idleTimeoutMs: 5_000, + maxOutputBytes: 16_384, + }); + const observedVersion = /(?:^|[^\d])v?(\d+\.\d+\.\d+)(?:[^\d]|$)/u.exec(`${versionResult.stdout}\n${versionResult.stderr}`)?.[1]; + if (versionResult.exitCode !== 0 || observedVersion !== packageValue.claudeCodeVersion) { + throw new Error("the supplied Claude Code executable version does not match the Agent SDK package"); + } + } + catch (error) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "the supplied Claude Code executable version could not be verified", { + cause: error instanceof Error ? error.message : String(error), + }); + } + return { + root, + modulePath, + cliPath, + sdkVersion: packageValue.version, + claudeCodeVersion: packageValue.claudeCodeVersion, + }; +} diff --git a/skills/autopilot/runtime/dist/src/doctor.js b/skills/autopilot/runtime/dist/src/doctor.js index b16555e..1db08f9 100644 --- a/skills/autopilot/runtime/dist/src/doctor.js +++ b/skills/autopilot/runtime/dist/src/doctor.js @@ -2,6 +2,7 @@ import { constants } from "node:fs"; import { access, mkdtemp, open, rename, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { CLAUDE_AGENT_SDK_CLI_ENVIRONMENT, CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT, inspectClaudeAgentSdkInstallation, } from "./claude-agent-sdk.js"; import { findPiSubagentsInstallation } from "./pi-subagents.js"; import { runProcess } from "./process.js"; async function commandCheck(name, executable, versionArguments, setup) { @@ -29,6 +30,28 @@ async function authenticationCheck(name, executable, arguments_) { return { name, status: "missing", detail: `${executable} is unavailable` }; } } +async function claudeAgentSdkCheck() { + try { + const installation = await inspectClaudeAgentSdkInstallation(); + return { + name: "claude-agent-sdk", + status: "ok", + detail: `${installation.sdkVersion} with Claude Code ${installation.claudeCodeVersion}`, + }; + } + catch (error) { + const missing = process.env[CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT] === undefined + || process.env[CLAUDE_AGENT_SDK_CLI_ENVIRONMENT] === undefined; + return { + name: "claude-agent-sdk", + status: missing ? "unverified" : "unsupported", + detail: missing + ? "an operator-provided SDK root and matching Claude Code executable were not configured" + : error instanceof Error ? error.message : String(error), + setup: `Set ${CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT} and ${CLAUDE_AGENT_SDK_CLI_ENVIRONMENT}; Autopilot never installs or searches private caches for them.`, + }; + } +} async function filesystemCheck() { const directory = await mkdtemp(join(tmpdir(), "autopilot-doctor-")); try { @@ -68,6 +91,6 @@ export async function runDoctor() { const piSubagents = findPiSubagentsInstallation(); checks.push(await commandCheck("git", "git", ["--version"], "Install Git and make it available on PATH."), await commandCheck("pi", "pi", ["--version"], "Install Pi only if you plan to use the Pi adapter."), piSubagents === undefined ? { name: "pi-subagents", status: "unverified", detail: "version 0.53.0 or newer was not found; Pi will use its distinct direct CLI fallback", setup: "Install and enable pi-subagents through Pi to use the process-local backend; Autopilot never installs it." } - : { name: "pi-subagents", status: "ok", detail: `${piSubagents.version} at ${piSubagents.extensionPath}; process-local owner availability is checked by the Autopilot Pi extension before launch` }, await commandCheck("claude-code", "claude", ["--version"], "Install Claude Code only if you plan to use that adapter."), await commandCheck("codex", "codex", ["--version"], "Install Codex only if you plan to use that adapter."), await commandCheck("codex-app-server", "codex", ["app-server", "--help"], "Install a Codex version with app-server support only if you plan to use that adapter."), await commandCheck("opencode", "opencode", ["--version"], "Install OpenCode only if you plan to use that adapter."), await commandCheck("opencode-server", "opencode", ["serve", "--help"], "Install an OpenCode version with server support only if you plan to use that adapter."), await commandCheck("github-cli", "gh", ["--version"], "Install gh only for GitHub delivery."), await commandCheck("gitlab-cli", "glab", ["--version"], "Install glab only for GitLab delivery."), await authenticationCheck("claude-auth-config", "claude", ["auth", "status"]), await authenticationCheck("codex-auth-config", "codex", ["login", "status"]), await authenticationCheck("opencode-auth-config", "opencode", ["providers", "list"]), await authenticationCheck("github-auth", "gh", ["auth", "status"]), await authenticationCheck("gitlab-auth", "glab", ["auth", "status"]), await filesystemCheck()); + : { name: "pi-subagents", status: "ok", detail: `${piSubagents.version} at ${piSubagents.extensionPath}; process-local owner availability is checked by the Autopilot Pi extension before launch` }, await commandCheck("claude-code", "claude", ["--version"], "Install Claude Code only if you plan to use that adapter."), await claudeAgentSdkCheck(), await commandCheck("codex", "codex", ["--version"], "Install Codex only if you plan to use that adapter."), await commandCheck("codex-app-server", "codex", ["app-server", "--help"], "Install a Codex version with app-server support only if you plan to use that adapter."), await commandCheck("opencode", "opencode", ["--version"], "Install OpenCode only if you plan to use that adapter."), await commandCheck("opencode-server", "opencode", ["serve", "--help"], "Install an OpenCode version with server support only if you plan to use that adapter."), await commandCheck("github-cli", "gh", ["--version"], "Install gh only for GitHub delivery."), await commandCheck("gitlab-cli", "glab", ["--version"], "Install glab only for GitLab delivery."), await authenticationCheck("claude-auth-config", "claude", ["auth", "status"]), await authenticationCheck("codex-auth-config", "codex", ["login", "status"]), await authenticationCheck("opencode-auth-config", "opencode", ["providers", "list"]), await authenticationCheck("github-auth", "gh", ["auth", "status"]), await authenticationCheck("gitlab-auth", "glab", ["auth", "status"]), await filesystemCheck()); return checks; } diff --git a/skills/autopilot/runtime/schemas/charter.schema.json b/skills/autopilot/runtime/schemas/charter.schema.json index 7965b0d..0bdea6e 100644 --- a/skills/autopilot/runtime/schemas/charter.schema.json +++ b/skills/autopilot/runtime/schemas/charter.schema.json @@ -28,7 +28,7 @@ "sourceText": { "type": "string", "minLength": 1 }, "createdAt": { "type": "string", "format": "date-time" }, "repository": { "$ref": "#/$defs/repository" }, - "harnessAdapter": { "enum": ["pi", "claude-code", "codex", "codex-app-server", "opencode", "opencode-server"] }, + "harnessAdapter": { "enum": ["pi", "claude-code", "claude-agent-sdk", "codex", "codex-app-server", "opencode", "opencode-server"] }, "mode": { "enum": ["single", "independent-queue", "ordered-stack"] }, "work": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/workItem" } }, "delivery": { "enum": ["local-commits", "change-request-ready", "merge-verified"] }, diff --git a/skills/autopilot/runtime/src/adapters.ts b/skills/autopilot/runtime/src/adapters.ts index 2fd92c8..ca6d75f 100644 --- a/skills/autopilot/runtime/src/adapters.ts +++ b/skills/autopilot/runtime/src/adapters.ts @@ -1,3 +1,4 @@ +import { createClaudeAgentSdkAdapter } from "../adapters/claude-agent-sdk/index.js"; import { createClaudeCodeAdapter } from "../adapters/claude-code/index.js"; import { createCodexAdapter, createCodexAppServerAdapter } from "../adapters/codex/index.js"; import { createOpenCodeAdapter, createOpenCodeServerAdapter } from "../adapters/opencode/index.js"; @@ -5,7 +6,7 @@ import { createPiAdapter } from "../adapters/pi/index.js"; import type { HarnessPort } from "./adapter-protocol.js"; import { AutopilotError } from "./errors.js"; -export type AdapterName = "pi" | "claude-code" | "codex" | "codex-app-server" | "opencode" | "opencode-server"; +export type AdapterName = "pi" | "claude-code" | "claude-agent-sdk" | "codex" | "codex-app-server" | "opencode" | "opencode-server"; export function createAdapter(name: string): HarnessPort { switch (name) { @@ -13,6 +14,8 @@ export function createAdapter(name: string): HarnessPort { return createPiAdapter(); case "claude-code": return createClaudeCodeAdapter(); + case "claude-agent-sdk": + return createClaudeAgentSdkAdapter(); case "codex": return createCodexAdapter(); case "codex-app-server": diff --git a/skills/autopilot/runtime/src/charter.ts b/skills/autopilot/runtime/src/charter.ts index 3fa9ebb..98c3c8e 100644 --- a/skills/autopilot/runtime/src/charter.ts +++ b/skills/autopilot/runtime/src/charter.ts @@ -186,7 +186,7 @@ export interface ProposedRunCharter { readonly sourceText: string; readonly createdAt: string; readonly repository: RepositorySpec; - readonly harnessAdapter: "pi" | "claude-code" | "codex" | "codex-app-server" | "opencode" | "opencode-server"; + readonly harnessAdapter: "pi" | "claude-code" | "claude-agent-sdk" | "codex" | "codex-app-server" | "opencode" | "opencode-server"; readonly mode: RunMode; readonly work: readonly WorkItem[]; readonly delivery: DeliveryMode; @@ -482,7 +482,7 @@ function parseProposed(value: unknown, allowHash: boolean): ProposedRunCharter { repository: parseRepository(object.repository, "charter.repository"), harnessAdapter: expectLiteral( object.harnessAdapter, - ["pi", "claude-code", "codex", "codex-app-server", "opencode", "opencode-server"], + ["pi", "claude-code", "claude-agent-sdk", "codex", "codex-app-server", "opencode", "opencode-server"], "charter.harnessAdapter", ), mode: expectLiteral(object.mode, ["single", "independent-queue", "ordered-stack"], "charter.mode"), diff --git a/skills/autopilot/runtime/src/claude-agent-sdk.ts b/skills/autopilot/runtime/src/claude-agent-sdk.ts new file mode 100644 index 0000000..3cf4970 --- /dev/null +++ b/skills/autopilot/runtime/src/claude-agent-sdk.ts @@ -0,0 +1,124 @@ +import { constants } from "node:fs"; +import { access, readFile, realpath, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { AutopilotError } from "./errors.js"; +import { runProcess } from "./process.js"; +import { isRecord } from "./json.js"; + +export const CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT = "AUTOPILOT_CLAUDE_AGENT_SDK_ROOT"; +export const CLAUDE_AGENT_SDK_CLI_ENVIRONMENT = "AUTOPILOT_CLAUDE_AGENT_SDK_CLI"; +export const MINIMUM_CLAUDE_AGENT_SDK_VERSION = "0.3.246"; + +const CLAUDE_AGENT_SDK_SCRIPT_SUFFIXES = [".js", ".mjs", ".tsx", ".ts", ".jsx"] as const; + +export interface ClaudeAgentSdkInstallation { + readonly root: string; + readonly modulePath: string; + readonly cliPath: string; + readonly sdkVersion: string; + readonly claudeCodeVersion: string; +} + +function parseVersion(value: string): readonly [number, number, number] | undefined { + const match = /^(\d+)\.(\d+)\.(\d+)$/u.exec(value); + if (match === null) { + return undefined; + } + const parts = match.slice(1).map(Number); + return parts.every(Number.isSafeInteger) ? parts as unknown as readonly [number, number, number] : undefined; +} + +export function isClaudeAgentSdkScriptCli(cliPath: string): boolean { + return CLAUDE_AGENT_SDK_SCRIPT_SUFFIXES.some((suffix) => cliPath.endsWith(suffix)); +} + +export function isSupportedClaudeAgentSdkVersion(value: string): boolean { + const actual = parseVersion(value); + const minimum = parseVersion(MINIMUM_CLAUDE_AGENT_SDK_VERSION); + if (actual === undefined || minimum === undefined) { + return false; + } + for (const index of [0, 1, 2] as const) { + if (actual[index] !== minimum[index]) { + return actual[index] > minimum[index]; + } + } + return true; +} + +export async function inspectClaudeAgentSdkInstallation( + rootValue = process.env[CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT], + cliValue = process.env[CLAUDE_AGENT_SDK_CLI_ENVIRONMENT], +): Promise { + if (rootValue === undefined || rootValue.length === 0 || cliValue === undefined || cliValue.length === 0) { + throw new AutopilotError( + "ADAPTER_UNSUPPORTED", + `${CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT} and ${CLAUDE_AGENT_SDK_CLI_ENVIRONMENT} must name an operator-provided Agent SDK and Claude Code executable`, + ); + } + let root: string; + let cliPath: string; + try { + [root, cliPath] = await Promise.all([realpath(rootValue), realpath(cliValue)]); + const [, , cliStat] = await Promise.all([ + access(join(root, "sdk.mjs"), constants.R_OK), + access(cliPath, constants.R_OK | constants.X_OK), + stat(cliPath), + ]); + if (!cliStat.isFile()) { + throw new Error("the supplied Claude Code executable is not a regular file"); + } + } catch (error) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "the supplied Agent SDK root or Claude Code executable is unavailable", { + cause: error instanceof Error ? error.message : String(error), + }); + } + const packagePath = join(root, "package.json"); + const modulePath = join(root, "sdk.mjs"); + let packageValue: unknown; + try { + packageValue = JSON.parse(await readFile(packagePath, "utf8")) as unknown; + } catch (error) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "the supplied Agent SDK package metadata is unavailable", { + cause: error instanceof Error ? error.message : String(error), + }); + } + if (!isRecord(packageValue) + || packageValue.name !== "@anthropic-ai/claude-agent-sdk" + || typeof packageValue.version !== "string" + || typeof packageValue.claudeCodeVersion !== "string") { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "the supplied package is not a compatible Claude Agent SDK"); + } + if (!isSupportedClaudeAgentSdkVersion(packageValue.version)) { + throw new AutopilotError( + "ADAPTER_UNSUPPORTED", + `Claude Agent SDK ${packageValue.version} is older than ${MINIMUM_CLAUDE_AGENT_SDK_VERSION}`, + ); + } + try { + const scriptCli = isClaudeAgentSdkScriptCli(cliPath); + const versionResult = await runProcess({ + executable: scriptCli ? process.execPath : cliPath, + arguments: scriptCli ? [cliPath, "--version"] : ["--version"], + cwd: root, + timeoutMs: 5_000, + idleTimeoutMs: 5_000, + maxOutputBytes: 16_384, + }); + const observedVersion = /(?:^|[^\d])v?(\d+\.\d+\.\d+)(?:[^\d]|$)/u.exec(`${versionResult.stdout}\n${versionResult.stderr}`)?.[1]; + if (versionResult.exitCode !== 0 || observedVersion !== packageValue.claudeCodeVersion) { + throw new Error("the supplied Claude Code executable version does not match the Agent SDK package"); + } + } catch (error) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "the supplied Claude Code executable version could not be verified", { + cause: error instanceof Error ? error.message : String(error), + }); + } + return { + root, + modulePath, + cliPath, + sdkVersion: packageValue.version, + claudeCodeVersion: packageValue.claudeCodeVersion, + }; +} diff --git a/skills/autopilot/runtime/src/doctor.ts b/skills/autopilot/runtime/src/doctor.ts index b768200..10991b8 100644 --- a/skills/autopilot/runtime/src/doctor.ts +++ b/skills/autopilot/runtime/src/doctor.ts @@ -2,6 +2,11 @@ import { constants } from "node:fs"; import { access, mkdtemp, open, rename, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + CLAUDE_AGENT_SDK_CLI_ENVIRONMENT, + CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT, + inspectClaudeAgentSdkInstallation, +} from "./claude-agent-sdk.js"; import { findPiSubagentsInstallation } from "./pi-subagents.js"; import { runProcess } from "./process.js"; @@ -37,6 +42,28 @@ async function authenticationCheck(name: string, executable: string, arguments_: } } +async function claudeAgentSdkCheck(): Promise { + try { + const installation = await inspectClaudeAgentSdkInstallation(); + return { + name: "claude-agent-sdk", + status: "ok", + detail: `${installation.sdkVersion} with Claude Code ${installation.claudeCodeVersion}`, + }; + } catch (error) { + const missing = process.env[CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT] === undefined + || process.env[CLAUDE_AGENT_SDK_CLI_ENVIRONMENT] === undefined; + return { + name: "claude-agent-sdk", + status: missing ? "unverified" : "unsupported", + detail: missing + ? "an operator-provided SDK root and matching Claude Code executable were not configured" + : error instanceof Error ? error.message : String(error), + setup: `Set ${CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT} and ${CLAUDE_AGENT_SDK_CLI_ENVIRONMENT}; Autopilot never installs or searches private caches for them.`, + }; + } +} + async function filesystemCheck(): Promise { const directory = await mkdtemp(join(tmpdir(), "autopilot-doctor-")); try { @@ -80,6 +107,7 @@ export async function runDoctor(): Promise { ? { name: "pi-subagents", status: "unverified", detail: "version 0.53.0 or newer was not found; Pi will use its distinct direct CLI fallback", setup: "Install and enable pi-subagents through Pi to use the process-local backend; Autopilot never installs it." } : { name: "pi-subagents", status: "ok", detail: `${piSubagents.version} at ${piSubagents.extensionPath}; process-local owner availability is checked by the Autopilot Pi extension before launch` }, await commandCheck("claude-code", "claude", ["--version"], "Install Claude Code only if you plan to use that adapter."), + await claudeAgentSdkCheck(), await commandCheck("codex", "codex", ["--version"], "Install Codex only if you plan to use that adapter."), await commandCheck( "codex-app-server", diff --git a/skills/autopilot/runtime/test/adapter-compatibility.test.ts b/skills/autopilot/runtime/test/adapter-compatibility.test.ts index b4053b5..d9eb941 100644 --- a/skills/autopilot/runtime/test/adapter-compatibility.test.ts +++ b/skills/autopilot/runtime/test/adapter-compatibility.test.ts @@ -3,7 +3,7 @@ import { test } from "node:test"; import { createAdapter } from "../src/adapters.js"; test("all documented harness adapter names resolve through one port factory", () => { - for (const name of ["pi", "claude-code", "codex", "codex-app-server", "opencode", "opencode-server"]) { + for (const name of ["pi", "claude-code", "claude-agent-sdk", "codex", "codex-app-server", "opencode", "opencode-server"]) { const adapter = createAdapter(name); assert.equal(typeof adapter.describe, "function"); diff --git a/skills/autopilot/runtime/test/charter.test.ts b/skills/autopilot/runtime/test/charter.test.ts index 2e0a5cf..489de9d 100644 --- a/skills/autopilot/runtime/test/charter.test.ts +++ b/skills/autopilot/runtime/test/charter.test.ts @@ -21,7 +21,7 @@ test("sealCharter accepts explicit server-backed harness adapter names", async ( const repository = await createRepository(); const proposed = proposedCharter(repository.root, repository.baseCommit); - for (const harnessAdapter of ["codex-app-server", "opencode-server"] as const) { + for (const harnessAdapter of ["claude-agent-sdk", "codex-app-server", "opencode-server"] as const) { assert.equal(sealCharter({ ...proposed, harnessAdapter }).harnessAdapter, harnessAdapter); } }); diff --git a/skills/autopilot/runtime/test/claude-agent-sdk.test.ts b/skills/autopilot/runtime/test/claude-agent-sdk.test.ts new file mode 100644 index 0000000..f4345b9 --- /dev/null +++ b/skills/autopilot/runtime/test/claude-agent-sdk.test.ts @@ -0,0 +1,781 @@ +import assert from "node:assert/strict"; +import { access, chmod, mkdtemp, realpath, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import type { + CancelResult, + CapabilityManifest, + ExecutionHandle, + ExecutionObservation, + ExecutionRequest, + HarnessPort, +} from "../src/adapter-protocol.js"; +import { + ClaudeAgentSdkAdapter, + createClaudeAgentSdkAdapter, +} from "../adapters/claude-agent-sdk/index.js"; +import { + CLAUDE_AGENT_SDK_CLI_ENVIRONMENT, + CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT, + inspectClaudeAgentSdkInstallation, + isSupportedClaudeAgentSdkVersion, + type ClaudeAgentSdkInstallation, +} from "../src/claude-agent-sdk.js"; +import { attemptContextFixture } from "./helpers.js"; + +interface Deferred { + readonly promise: Promise; + readonly resolve: (value: T) => void; + readonly reject: (error: Error) => void; +} + +interface FakeScenario { + readonly init?: Readonly>; + readonly reply?: "exact" | "missing" | "foreign" | "merged" | "none"; + readonly secondReply?: "exact" | "missing" | "foreign" | "merged"; + readonly secondReplyOutput?: string; + readonly result?: "completed" | "failed" | "missing" | "foreign" | "merged" | "none"; + readonly iteratorFailure?: boolean; + readonly iteratorFailureMessage?: string; + readonly waitForInterrupt?: boolean; + readonly interruptReceipt?: unknown; + readonly interruptHangs?: boolean; + readonly interruptThrowsSynchronously?: boolean; + readonly interruptDelayMs?: number; + readonly cancellationResult?: "aborted_tools" | "aborted_streaming" | "completed" | "error" | "none"; + readonly child?: "idle" | "exit" | "exit-stderr" | "oversized" | "stderr"; + readonly secondSpawn?: boolean; + readonly scriptCliPath?: string; + readonly noSpawn?: boolean; + readonly spawnEnvironment?: "missing-config" | "foreign-config" | "extra"; + readonly output?: string; +} + +function deferred(): Deferred { + let resolvePromise: (value: T) => void = () => undefined; + let rejectPromise: (error: Error) => void = () => undefined; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { promise, resolve: resolvePromise, reject: rejectPromise }; +} + +class FakeReviewAdapter implements HarnessPort { + failNextObservation = false; + + async describe(): Promise { + return { + protocolVersion: 1, + adapterName: "claude-code", + adapterVersion: "1", + harnessVersion: "2.1.260", + families: ["files.read", "files.write", "process.execute", "network.access", "credentials.use"], + assurance: "cooperative", + unattended: true, + maxConcurrency: 1, + eventStreaming: true, + cancellation: true, + restartReattachment: false, + restrictions: "cooperative", + limitations: [], + }; + } + + async launch(): Promise { + return { protocolVersion: 1, adapterExecutionId: "review", startedAt: new Date().toISOString() }; + } + + async observe(): Promise { + if (this.failNextObservation) { + this.failNextObservation = false; + throw new Error("review observation failed"); + } + return { + protocolVersion: 1, + adapterExecutionId: "review", + status: "completed", + exitCode: 0, + completedAt: new Date().toISOString(), + stdout: "direct review", + stderr: "", + truncated: false, + }; + } + + async cancel(): Promise { + return { protocolVersion: 1, accepted: true }; + } +} + +function request(worktreePath: string, attemptId: string, role: ExecutionRequest["role"] = "implementation"): ExecutionRequest { + return { + protocolVersion: 1, + role, + runId: "run", + itemId: "item", + attemptId, + worktreePath, + objective: "test Claude Agent SDK", + acceptanceSummary: "test", + context: attemptContextFixture(attemptId), + contextHash: `context-${attemptId}`, + writableRoots: ["."], + grants: [{ family: "credentials.use", actor: "adapter", environmentNames: ["AUTOPILOT_CLAUDE_TEST_TOKEN"] }], + deadline: new Date(Date.now() + 10_000).toISOString(), + idleTimeoutMs: 500, + maximumLineBytes: 1024, + maximumOutputBytes: 4096, + }; +} + +function installation(cliPath = process.execPath): ClaudeAgentSdkInstallation { + return { + root: "/operator/sdk", + modulePath: "/operator/sdk/sdk.mjs", + cliPath, + sdkVersion: "0.3.260", + claudeCodeVersion: "2.1.260", + }; +} + +function identityFields(kind: FakeScenario["reply"] | FakeScenario["result"], userMessageId: string): Readonly> { + switch (kind) { + case "exact": + case "completed": + case "failed": + return { user_message_uuid: userMessageId, user_message_uuids: [userMessageId] }; + case "foreign": + return { user_message_uuid: "foreign", user_message_uuids: ["foreign"] }; + case "merged": + return { user_message_uuid: userMessageId, user_message_uuids: ["foreign", userMessageId] }; + case "missing": + case "none": + case undefined: + return {}; + } +} + +function fakeSdk(scenario: FakeScenario, captured: { options?: Readonly>; pid?: number }): { + query(input: { readonly prompt: AsyncIterable; readonly options: Readonly> }): { + readonly interrupt: () => Promise; + readonly close: () => void; + readonly [Symbol.asyncIterator]: () => AsyncIterator; + }; +} { + return { + query(input) { + captured.options = input.options; + const spawnProcess = input.options.spawnClaudeCodeProcess; + if (typeof spawnProcess !== "function") { + throw new Error("missing spawn hook"); + } + const childMode = scenario.child ?? "idle"; + const childExited = deferred(); + const protocolFailed = deferred(); + const childScript = childMode === "exit" + ? "process.exit(17)" + : childMode === "exit-stderr" + ? 'process.stderr.write("abc"); process.nextTick(() => { process.stderr.write("def"); process.exit(0); })' + : childMode === "oversized" + ? `process.stdout.write("x".repeat(4096) + "\\n"); setInterval(() => {}, 1000)` + : childMode === "stderr" + ? `process.stderr.write("abc"); setTimeout(() => process.stderr.write("def"), 5); setInterval(() => {}, 1000)` + : "setInterval(() => {}, 1000)"; + if (!scenario.noSpawn) { + const spawnEnvironment: NodeJS.ProcessEnv = { + ...(input.options.env as Readonly>), + CLAUDE_CODE_ENTRYPOINT: "sdk-ts", + CLAUDE_AGENT_SDK_VERSION: "0.3.260", + }; + if (scenario.spawnEnvironment === "missing-config") { + delete spawnEnvironment.CLAUDE_CONFIG_DIR; + } else if (scenario.spawnEnvironment === "foreign-config") { + spawnEnvironment.CLAUDE_CONFIG_DIR = "/ambient/config"; + } else if (scenario.spawnEnvironment === "extra") { + spawnEnvironment.AUTOPILOT_UNEXPECTED = "1"; + } + const spawned = spawnProcess({ + command: scenario.scriptCliPath === undefined ? process.execPath : "node", + args: scenario.scriptCliPath === undefined ? ["-e", childScript] : [scenario.scriptCliPath], + cwd: input.options.cwd, + env: spawnEnvironment, + signal: new AbortController().signal, + }) as { + readonly pid?: number; + readonly once?: (event: string, listener: () => void) => void; + readonly stdout?: { readonly once: (event: string, listener: () => void) => void }; + }; + if (spawned.pid !== undefined) { + captured.pid = spawned.pid; + } + spawned.once?.("exit", () => childExited.resolve()); + spawned.stdout?.once("error", () => protocolFailed.resolve()); + if (scenario.secondSpawn) { + spawnProcess({ + command: process.execPath, + args: ["-e", childScript], + cwd: input.options.cwd, + env: input.options.env, + signal: new AbortController().signal, + }); + } + } + const interrupted = deferred(); + let closed = false; + const messages = (async function*(): AsyncGenerator { + const promptIterator = input.prompt[Symbol.asyncIterator](); + const first = await promptIterator.next(); + const userMessage = first.value as { readonly uuid: string }; + const userMessageId = userMessage.uuid; + const cwd = input.options.cwd; + if (scenario.child === "exit" || scenario.child === "exit-stderr") { + await childExited.promise; + } else if (scenario.child === "oversized") { + await protocolFailed.promise; + } + const init = { + type: "system", + subtype: "init", + session_id: "session-1", + claude_code_version: "2.1.260", + cwd, + permissionMode: "dontAsk", + tools: ["Bash", "Edit", "Glob", "Grep", "Read", "Write"], + mcp_servers: [], + skills: [], + plugins: [], + capabilities: ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1"], + ...scenario.init, + }; + yield init; + if (scenario.child === "stderr") { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (scenario.reply !== "none") { + yield { + type: "assistant", + session_id: "session-1", + parent_tool_use_id: null, + message: { content: [{ type: "text", text: scenario.output ?? "worker output abcdef" }] }, + ...identityFields(scenario.reply ?? "exact", userMessageId), + }; + } + if (scenario.secondReply !== undefined) { + yield { + type: "assistant", + session_id: "session-1", + parent_tool_use_id: null, + message: { content: [{ type: "text", text: scenario.secondReplyOutput ?? "second reply" }] }, + ...identityFields(scenario.secondReply, userMessageId), + }; + } + if (scenario.iteratorFailure) { + throw new Error(scenario.iteratorFailureMessage ?? "iterator failed"); + } + if (scenario.waitForInterrupt) { + await interrupted.promise; + if (scenario.cancellationResult === "none") { + await new Promise(() => undefined); + } + const terminalReason = scenario.cancellationResult ?? "aborted_tools"; + yield { + type: "result", + subtype: terminalReason === "completed" ? "success" : "error_during_execution", + is_error: terminalReason !== "completed", + result: terminalReason === "completed" ? "natural completion" : undefined, + errors: terminalReason === "completed" ? undefined : ["cancelled"], + terminal_reason: terminalReason, + session_id: "session-1", + ...identityFields("completed", userMessageId), + }; + return; + } + if (scenario.result === "none") { + return; + } + const resultKind = scenario.result ?? "completed"; + yield { + type: "result", + subtype: resultKind === "failed" ? "error_during_execution" : "success", + is_error: resultKind === "failed", + result: resultKind === "completed" ? scenario.output ?? "worker output abcdef" : undefined, + errors: resultKind === "failed" ? ["provider failed abcdef"] : undefined, + terminal_reason: resultKind === "failed" ? "error" : "completed", + session_id: "session-1", + ...identityFields(resultKind, userMessageId), + }; + })(); + return { + interrupt: () => { + if (scenario.interruptThrowsSynchronously) { + throw new Error("synchronous interrupt failure"); + } + return (async () => { + interrupted.resolve(); + if (scenario.interruptHangs) { + await new Promise(() => undefined); + } + if (scenario.interruptDelayMs !== undefined) { + await new Promise((resolve) => setTimeout(resolve, scenario.interruptDelayMs)); + } + return Object.hasOwn(scenario, "interruptReceipt") + ? scenario.interruptReceipt + : { still_queued: [] }; + })(); + }, + close: () => { + closed = true; + }, + [Symbol.asyncIterator]: () => ({ + next: async () => closed ? { done: true, value: undefined } : await messages.next(), + }), + }; + }, + }; +} + +async function createAdapter( + scenario: FakeScenario, + installationValue = installation(), +): Promise<{ + readonly adapter: ClaudeAgentSdkAdapter; + readonly reviewAdapter: FakeReviewAdapter; + readonly worktreePath: string; + readonly captured: { options?: Readonly>; pid?: number }; +}> { + const worktreePath = await mkdtemp(join(tmpdir(), "autopilot-claude-agent-sdk-test-")); + const captured: { options?: Readonly>; pid?: number } = {}; + const reviewAdapter = new FakeReviewAdapter(); + return { + worktreePath, + captured, + reviewAdapter, + adapter: new ClaudeAgentSdkAdapter({ + reviewAdapter, + installation: installationValue, + sdk: fakeSdk(scenario, captured), + }), + }; +} + +function isExecutionUnknown(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "EXECUTION_STATE_UNKNOWN"; +} + +test("Claude Agent SDK discovery accepts only an explicit supported package and executable", async () => { + const root = await mkdtemp(join(tmpdir(), "autopilot-claude-agent-sdk-discovery-")); + const cliPath = process.execPath; + const cliVersion = process.versions.node; + await writeFile(join(root, "sdk.mjs"), "export const query = () => undefined;\n"); + await writeFile(join(root, "package.json"), JSON.stringify({ + name: "@anthropic-ai/claude-agent-sdk", + version: "0.3.260", + claudeCodeVersion: cliVersion, + })); + + const found = await inspectClaudeAgentSdkInstallation(root, cliPath); + + assert.equal(found.root, await realpath(root)); + assert.equal(found.cliPath, await realpath(cliPath)); + assert.equal(found.sdkVersion, "0.3.260"); + assert.equal(isSupportedClaudeAgentSdkVersion("0.3.245"), false); + assert.equal(isSupportedClaudeAgentSdkVersion("0.3.246"), true); + assert.equal(isSupportedClaudeAgentSdkVersion("1.0.0"), true); + await assert.rejects(inspectClaudeAgentSdkInstallation(undefined, undefined), /must name an operator-provided/); + + await writeFile(join(root, "package.json"), JSON.stringify({ + name: "@anthropic-ai/claude-agent-sdk", + version: "0.3.245", + claudeCodeVersion: cliVersion, + })); + await assert.rejects(inspectClaudeAgentSdkInstallation(root, cliPath), /older than 0\.3\.246/); + + await writeFile(join(root, "package.json"), JSON.stringify({ + name: "@anthropic-ai/claude-agent-sdk", + version: "0.3.260", + claudeCodeVersion: "0.0.0", + })); + await assert.rejects(inspectClaudeAgentSdkInstallation(root, cliPath), /version could not be verified/); + + const scriptCliPath = join(root, "claude.mjs"); + await writeFile( + scriptCliPath, + "if (process.argv.includes('--version')) console.log('2.1.260'); else setInterval(() => {}, 1000);\n", + ); + await chmod(scriptCliPath, 0o755); + await writeFile(join(root, "package.json"), JSON.stringify({ + name: "@anthropic-ai/claude-agent-sdk", + version: "0.3.260", + claudeCodeVersion: "2.1.260", + })); + assert.equal((await inspectClaudeAgentSdkInstallation(root, scriptCliPath)).cliPath, await realpath(scriptCliPath)); + + const previousRoot = process.env[CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT]; + const previousCli = process.env[CLAUDE_AGENT_SDK_CLI_ENVIRONMENT]; + process.env[CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT] = root; + process.env[CLAUDE_AGENT_SDK_CLI_ENVIRONMENT] = scriptCliPath; + try { + assert.equal((await createClaudeAgentSdkAdapter().describe()).adapterName, "claude-agent-sdk"); + } finally { + if (previousRoot === undefined) { + delete process.env[CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT]; + } else { + process.env[CLAUDE_AGENT_SDK_ROOT_ENVIRONMENT] = previousRoot; + } + if (previousCli === undefined) { + delete process.env[CLAUDE_AGENT_SDK_CLI_ENVIRONMENT]; + } else { + process.env[CLAUDE_AGENT_SDK_CLI_ENVIRONMENT] = previousCli; + } + } + await assert.rejects(inspectClaudeAgentSdkInstallation(root, root), (error: unknown) => + error instanceof Error && "details" in error && JSON.stringify(error.details).includes("not a regular file") + ); + + const { adapter, worktreePath } = await createAdapter( + { scriptCliPath }, + installation(scriptCliPath), + ); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "script-cli")); + assert.equal((await adapter.observe(handle)).status, "completed"); +}); + +test("Claude Agent SDK admits exact identity with an isolated authority surface", async () => { + process.env.AUTOPILOT_CLAUDE_TEST_TOKEN = "abcdef"; + try { + const { adapter, worktreePath, captured } = await createAdapter({ child: "stderr" }); + + const manifest = await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "complete")); + const observation = await adapter.observe(handle); + + assert.equal(manifest.adapterName, "claude-agent-sdk"); + assert.equal(manifest.restartReattachment, false); + assert.deepEqual(manifest.executionAssurance?.implementation, { + schemaVersion: 1, + owner: "harness", + continuity: "same-harness-instance", + terminality: "cooperative", + admission: "single-shot", + }); + assert.match(handle.subject?.backendId ?? "", /^claude-agent-sdk@0\.3\.260\/claude-code@2\.1\.260$/u); + assert.ok(handle.subject?.harnessInstanceId); + assert.equal(observation.status, "completed"); + await assert.rejects(adapter.observe(handle), isExecutionUnknown); + assert.equal((await adapter.cancel(handle)).accepted, false); + assert.doesNotMatch(observation.stdout, /abcdef/u); + assert.match(observation.stdout, /\*\*\*\*/u); + assert.doesNotMatch(observation.stderr, /abcdef/u); + assert.match(observation.stderr, /\*\*\*\*/u); + assert.deepEqual(captured.options?.settingSources, []); + assert.deepEqual(captured.options?.mcpServers, {}); + assert.deepEqual(captured.options?.skills, []); + assert.deepEqual(captured.options?.plugins, []); + assert.deepEqual(captured.options?.extraArgs, { "disable-slash-commands": null }); + assert.equal(captured.options?.persistSession, false); + assert.equal(captured.options?.permissionMode, "dontAsk"); + const hookConfiguration = captured.options?.hooks as { + readonly PreToolUse: readonly [{ + readonly hooks: readonly [(input: unknown) => Promise<{ + readonly hookSpecificOutput: { readonly permissionDecision: string }; + }>]; + }]; + }; + const preToolUse = hookConfiguration.PreToolUse[0].hooks[0]; + assert.equal((await preToolUse({ tool_name: "Read" })).hookSpecificOutput.permissionDecision, "allow"); + assert.equal((await preToolUse({ tool_name: "Task" })).hookSpecificOutput.permissionDecision, "deny"); + const environment = captured.options?.env as Readonly>; + const configDirectory = environment.CLAUDE_CONFIG_DIR; + if (configDirectory === undefined) { + throw new Error("missing isolated Claude configuration directory"); + } + assert.ok(configDirectory.includes("autopilot-claude-agent-sdk-")); + await assert.rejects(access(configDirectory)); + } finally { + delete process.env.AUTOPILOT_CLAUDE_TEST_TOKEN; + } +}); + +test("Claude Agent SDK reports exact structured provider failure", async () => { + const { adapter, worktreePath } = await createAdapter({ result: "failed" }); + await adapter.describe(); + + const handle = await adapter.launch(request(worktreePath, "failure")); + const observation = await adapter.observe(handle); + + assert.equal(observation.status, "failed"); + assert.equal(observation.exitCode, 1); + assert.match(observation.stdout, /provider failed/u); +}); + +test("Claude Agent SDK cancellation requires an empty receipt and exact aborted result", async () => { + for (const cancellationResult of ["aborted_tools", "aborted_streaming"] as const) { + const { adapter, worktreePath } = await createAdapter({ + waitForInterrupt: true, + interruptDelayMs: 20, + cancellationResult, + }); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, cancellationResult)); + + const cancellation = await adapter.cancel(handle); + const observation = await adapter.observe(handle); + + assert.equal(cancellation.accepted, true); + assert.equal(observation.status, "cancelled"); + assert.equal(observation.exitCode, 130); + } +}); + +test("Claude Agent SDK exact completion or failure wins a hanging cancellation race", async () => { + for (const [cancellationResult, expectedStatus] of [ + ["completed", "completed"], + ["error", "failed"], + ] as const) { + const { adapter, worktreePath } = await createAdapter({ + waitForInterrupt: true, + interruptHangs: true, + cancellationResult, + }); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, cancellationResult)); + + const cancellation = await adapter.cancel(handle); + const observation = await adapter.observe(handle); + + assert.equal(cancellation.accepted, false); + assert.equal(observation.status, expectedStatus); + } +}); + +test("Claude Agent SDK rejects missing or surviving interrupt receipts", async () => { + for (const interruptReceipt of [undefined, { still_queued: ["queued"] }] as const) { + const scenario = interruptReceipt === undefined + ? { waitForInterrupt: true, cancellationResult: "aborted_tools" as const, interruptReceipt: null } + : { waitForInterrupt: true, cancellationResult: "aborted_tools" as const, interruptReceipt }; + const { adapter, worktreePath } = await createAdapter(scenario); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, `receipt-${String(interruptReceipt)}`)); + + await assert.rejects(adapter.cancel(handle), isExecutionUnknown); + await assert.rejects(adapter.observe(handle), isExecutionUnknown); + } + + const { adapter, worktreePath } = await createAdapter({ + waitForInterrupt: true, + interruptThrowsSynchronously: true, + }); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "synchronous-interrupt-failure")); + await assert.rejects(adapter.cancel(handle), isExecutionUnknown); + await assert.rejects(adapter.observe(handle), isExecutionUnknown); +}); + +test("Claude Agent SDK rejects missing, foreign, and merged terminal user-message identities", async () => { + for (const result of ["missing", "foreign", "merged"] as const) { + const { adapter, worktreePath } = await createAdapter({ result }); + await adapter.describe(); + const execution = adapter.launch(request(worktreePath, result)).then(async (handle) => await adapter.observe(handle)); + + await assert.rejects(execution, isExecutionUnknown); + } +}); + +test("Claude Agent SDK rejects a first reply without exact user-message identity", async () => { + for (const reply of ["missing", "foreign", "merged"] as const) { + const { adapter, worktreePath } = await createAdapter({ reply }); + await adapter.describe(); + + await assert.rejects(adapter.launch(request(worktreePath, `reply-${reply}`)), isExecutionUnknown); + } +}); + +test("Claude Agent SDK validates post-admission identity without dropping repeated replies", async () => { + for (const secondReply of ["foreign", "merged"] as const) { + const { adapter, worktreePath } = await createAdapter({ secondReply }); + await adapter.describe(); + const execution = adapter.launch(request(worktreePath, `second-${secondReply}`)) + .then(async (handle) => await adapter.observe(handle)); + + await assert.rejects(execution, isExecutionUnknown); + } + + for (const secondReply of ["missing", "exact"] as const) { + const { adapter, worktreePath } = await createAdapter({ + secondReply, + secondReplyOutput: "worker output abcdef", + result: "failed", + }); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, `repeated-assistant-${secondReply}`)); + const observation = await adapter.observe(handle); + assert.equal(observation.stdout, "worker output abcdef\nworker output abcdef\nprovider failed abcdef"); + } +}); + +test("Claude Agent SDK rejects changed init authority and identity", async () => { + const worktreePath = await mkdtemp(join(tmpdir(), "autopilot-claude-wrong-cwd-")); + const cases: readonly Readonly>[] = [ + { claude_code_version: "2.1.259" }, + { permissionMode: "default" }, + { tools: ["Read"] }, + { mcp_servers: [{ name: "ambient", status: "connected" }] }, + { skills: ["ambient"] }, + { plugins: [{ name: "ambient", path: "/tmp/ambient" }] }, + { capabilities: ["msg_lifecycle_v1"] }, + { cwd: tmpdir() }, + ]; + for (const [index, init] of cases.entries()) { + const captured: { options?: Readonly>; pid?: number } = {}; + const adapter = new ClaudeAgentSdkAdapter({ + reviewAdapter: new FakeReviewAdapter(), + installation: installation(), + sdk: fakeSdk({ init }, captured), + }); + await adapter.describe(); + + await assert.rejects(adapter.launch(request(worktreePath, `init-${index}`)), isExecutionUnknown); + } +}); + +test("Claude Agent SDK redacts credential values from lifecycle errors", async () => { + process.env.AUTOPILOT_CLAUDE_TEST_TOKEN = "abcdef"; + try { + const { adapter, worktreePath } = await createAdapter({ + iteratorFailure: true, + iteratorFailureMessage: "iterator failed with abcdef", + }); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "redacted-error")); + + await assert.rejects(adapter.observe(handle), (error: unknown) => { + const serialized = JSON.stringify(error); + return !serialized.includes("abcdef") && serialized.includes("****"); + }); + } finally { + delete process.env.AUTOPILOT_CLAUDE_TEST_TOKEN; + } +}); + +test("Claude Agent SDK rejects changes to the isolated child environment", async () => { + for (const spawnEnvironment of ["missing-config", "foreign-config", "extra"] as const) { + const { adapter, worktreePath } = await createAdapter({ spawnEnvironment }); + await adapter.describe(); + + await assert.rejects(adapter.launch(request(worktreePath, spawnEnvironment)), isExecutionUnknown); + } +}); + +test("Claude Agent SDK accepts an exact terminal result and drains stderr buffered across child exit", async () => { + const { adapter, worktreePath } = await createAdapter({ child: "exit-stderr" }); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "buffered-exit")); + + const observation = await adapter.observe(handle); + + assert.equal(observation.status, "completed"); + assert.equal(observation.stderr, "abcdef"); +}); + +test("Claude Agent SDK maps iterator, child, and protocol loss to unknown", async () => { + for (const scenario of [ + { iteratorFailure: true }, + { result: "none" as const }, + { child: "exit" as const, result: "none" as const }, + { child: "oversized" as const }, + { secondSpawn: true }, + { noSpawn: true }, + ]) { + const { adapter, worktreePath } = await createAdapter(scenario); + await adapter.describe(); + const execution = adapter.launch(request(worktreePath, `loss-${JSON.stringify(scenario)}`)) + .then(async (handle) => await adapter.observe(handle)); + + await assert.rejects(execution, isExecutionUnknown); + } +}); + +test("Claude Agent SDK rejects handles from another harness instance", async () => { + const { adapter, worktreePath } = await createAdapter({ waitForInterrupt: true }); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "wrong-handle")); + if (handle.subject === undefined) { + throw new Error("missing exact subject"); + } + + await assert.rejects(adapter.observe({ + ...handle, + subject: { ...handle.subject, harnessInstanceId: "other" }, + }), isExecutionUnknown); + assert.equal((await adapter.cancel({ + ...handle, + subject: { ...handle.subject, backendId: "other" }, + })).accepted, false); + + await adapter.cancel(handle); +}); + +test("Claude Agent SDK idle and deadline expiry remain execution-state-unknown", async () => { + for (const timing of [ + { idleTimeoutMs: 20, deadline: new Date(Date.now() + 10_000).toISOString() }, + { idleTimeoutMs: 10_000, deadline: new Date(Date.now() + 200).toISOString() }, + ]) { + const { adapter, worktreePath } = await createAdapter({ waitForInterrupt: true }); + await adapter.describe(); + const base = request(worktreePath, `timing-${timing.idleTimeoutMs}`); + const handle = await adapter.launch({ ...base, ...timing }); + + await assert.rejects(adapter.observe(handle), isExecutionUnknown); + } +}); + +test("Claude Agent SDK bounds retained output before observation", async () => { + const { adapter, worktreePath } = await createAdapter({ output: "é".repeat(100) }); + await adapter.describe(); + const base = request(worktreePath, "bounded-output"); + const handle = await adapter.launch({ ...base, maximumOutputBytes: 9 }); + + const observation = await adapter.observe(handle); + + assert.equal(Buffer.byteLength(observation.stdout), 9); + assert.equal(observation.truncated, true); + assert.doesNotMatch(observation.stdout, /�/u); +}); + +test("Claude Agent SDK awaits direct-child cleanup after terminal acceptance", { + skip: process.platform === "win32", +}, async () => { + const { adapter, worktreePath, captured } = await createAdapter({}); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "cleanup")); + + await adapter.observe(handle); + + assert.ok(captured.pid !== undefined); + assert.throws(() => process.kill(captured.pid as number, 0), (error: unknown) => + error instanceof Error && "code" in error && error.code === "ESRCH" + ); +}); + +test("Claude Agent SDK keeps independent review on the direct adapter", async () => { + const { adapter, reviewAdapter, worktreePath } = await createAdapter({}); + await adapter.describe(); + + const failedHandle = await adapter.launch(request(worktreePath, "review-failure", "review")); + reviewAdapter.failNextObservation = true; + await assert.rejects(adapter.observe(failedHandle), /review observation failed/); + assert.equal((await adapter.cancel(failedHandle)).accepted, false); + + const handle = await adapter.launch(request(worktreePath, "review", "review")); + const observationPromise = adapter.observe(handle); + + assert.equal(handle.adapterExecutionId, "review"); + assert.equal((await adapter.cancel(handle)).accepted, true); + const observation = await observationPromise; + assert.equal(observation.stdout, "direct review"); + assert.equal((await adapter.cancel(handle)).accepted, false); + await assert.rejects(adapter.observe(handle), isExecutionUnknown); +}); diff --git a/skills/autopilot/runtime/test/packaging.test.ts b/skills/autopilot/runtime/test/packaging.test.ts index 39ffcd5..f941a22 100644 --- a/skills/autopilot/runtime/test/packaging.test.ts +++ b/skills/autopilot/runtime/test/packaging.test.ts @@ -28,9 +28,12 @@ test("compiled skill CLI starts from a clean copy without node_modules", async ( assert.ok(Array.isArray(checks)); const node = checks.find((entry) => isRecord(entry) && entry.name === "node"); const processSupervision = checks.find((entry) => isRecord(entry) && entry.name === "process-supervision"); + const claudeAgentSdk = checks.find((entry) => isRecord(entry) && entry.name === "claude-agent-sdk"); assert.ok(isRecord(node)); assert.ok(isRecord(processSupervision)); + assert.ok(isRecord(claudeAgentSdk)); assert.equal(node.status, "ok"); + assert.equal(claudeAgentSdk.status, "unverified"); assert.equal(processSupervision.status, process.platform === "win32" ? "unsupported" : "ok"); });