diff --git a/skills/autopilot/README.md b/skills/autopilot/README.md index 54c69a6..158630c 100644 --- a/skills/autopilot/README.md +++ b/skills/autopilot/README.md @@ -23,6 +23,8 @@ When using Pi's process-local backend, load `runtime/dist/src/pi-extension-entry 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. + Autopilot will: 1. turn the request into explicit work items, completion predicates, budgets, and grants; diff --git a/skills/autopilot/SKILL.md b/skills/autopilot/SKILL.md index 7c43447..9276701 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. Other modes and fallbacks use their declared CLI 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. 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 @@ -19,6 +19,7 @@ For a new implementation run: 4. Resolve how the repository selects each required build toolchain before sealing command gates. Prefer checked-in wrappers or toolchain configuration; when a gate or hook still requires a named environment selector, forward only that name and authorize it for the runtime. 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. 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 2107f54..5632308 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 @@ -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 205 Node tests locally after adding Codex app-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 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. ### 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 c83c287..66e9c5f 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 Codex app-server integration: decision/promotion shutdown, execution assurance, fenced unknown-execution recovery, Pi process-local integration, Windows native-path removal, and exact same-instance Codex turns are complete; the OpenCode server boundary has a separate implementation-ready evaluation, while Claude remains separate +- **Status:** Implemented through the explicit OpenCode server backend and controlled fault matrix; the separate Claude boundary remains pending - **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), [durable event engine ADR](adr/0001-durable-event-engine.md) @@ -378,7 +378,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) establishes an implementation-ready same-instance contract based on one owned server process, one dedicated session, one caller-selected message ID, uninterrupted live events, and fresh REST reconciliation. Production code and fault coverage remain unimplemented. +- 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: remain session-scoped until an active execution attachment surface exists. Do not add a provider-neutral durable-subject framework based only on hypothetical future consumers. @@ -467,6 +467,6 @@ Pause and revisit the design if implementation shows any of the following: - Controlled Pi process-local tests cover exact admission, cancellation, terminal-before-shutdown precedence, reload/session invalidation, lost admission, late/mismatched result rejection, direct fallback, and runtime-core completion in one reused local repository fixture. Whole-process live fault evidence remains environment-specific and does not prove OS quiescence or provider parity. - 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 same-process completion and cancellation passed controlled live probes. Disconnect remains intentionally unrecoverable because SSE has no replay cursor; production fault handling and Claude interruption behavior remain unimplemented. +- 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; Claude interruption behavior remains unimplemented. - 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-08-31-opencode-server-evaluation.md b/skills/autopilot/docs/2026-08-31-opencode-server-evaluation.md index a01fd95..1448f21 100644 --- a/skills/autopilot/docs/2026-08-31-opencode-server-evaluation.md +++ b/skills/autopilot/docs/2026-08-31-opencode-server-evaluation.md @@ -1,6 +1,6 @@ # OpenCode server execution evaluation -- **Status:** Complete +- **Status:** Implemented with controlled fault coverage and live production-adapter validation - **Decision:** GO for a controlled implementation of a distinct `opencode-server` backend - **Evidence baseline:** OpenCode 1.18.25, upstream tag `v1.18.25` at commit `cb7d8b2f5e44876ef98b661dc10590c915af3a9f` - **Scope:** Implementation execution only; independent review remains on the direct `opencode` CLI adapter @@ -11,7 +11,7 @@ OpenCode 1.18.25 provides enough identity and lifecycle information for a same-h The implementation must remain separate from the current `opencode` charter value. It must not claim restart reattachment, replayable event delivery, operating-system process-tree quiescence, path sandboxing, or durable recovery after loss of the owning Autopilot process. -This is not approval to reuse a global OpenCode server. Autopilot must start and stop one loopback server for each attempt. +This is not approval to reuse a global OpenCode server. Autopilot must start one loopback server for each attempt and await direct-child cleanup on every path while the owning harness survives. Whole-harness loss remains unknown and can leave the process running because no OS containment is claimed. ## Constraints @@ -138,9 +138,9 @@ A temporary home and temporary XDG data, config, cache, and state roots were use 4. created and read a dedicated session with explicit deny-all permissions; 5. deleted the session; 6. disposed the directory instance; and -7. stopped the server process. +7. attempted to stop the server process. -All OpenCode files created by this probe remained under the temporary root. +The process was later found orphaned under PID 42930 after the probe's parent harness had ended and was removed manually. That invalidates the probe's process-cleanup claim, though not its HTTP/SSE protocol observations. It also confirms the stated boundary: whole-harness loss does not provide process containment or descendant quiescence. All OpenCode files created by this probe remained under the temporary root. ### Version-pinned live probe @@ -165,9 +165,15 @@ Cancellation produced: The tracked repository tree remained clean. The digest of files under `~/.config/opencode` was unchanged. These probes establish the normal same-process path; they do not establish restart recovery or descendant quiescence. +### Production adapter validation + +The implemented adapter was then exercised with auto-update disabled against installed OpenCode 1.18.28 in a disposable local Git repository. A completion attempt created only the requested untracked `result.txt` containing `validation`, returned an exact completed observation, and left tracked files unchanged. A second attempt ran a long shell command; cancellation returned accepted only after fresh reconciliation produced the exact assistant `MessageAbortedError` and exit code 130. + +The adapter reported `opencode-server@1.18.28`, `same-harness-instance` continuity, cooperative terminality, single-shot admission, and no restart reattachment. The digest under `~/.config/opencode` was unchanged. Awaited cleanup completed, and a post-run process scan found no OpenCode server. This validates normal live cleanup while the owning harness survives; it does not supersede the whole-harness-loss limitation demonstrated by the orphaned earlier probe. + ## Required backend contract -A future implementation should advertise: +The controlled implementation advertises: ```json { @@ -185,7 +191,7 @@ The direct `opencode` backend remains unchanged. It retains POSIX process-superv ## Implementation acceptance criteria -Production promotion requires controlled tests for: +Production promotion used controlled tests for: - exact loopback server startup, Basic Auth, health version, and worktree routing; - durable admission intent followed by one session and one caller-selected message ID; diff --git a/skills/autopilot/docs/README.md b/skills/autopilot/docs/README.md index 705f827..aac450f 100644 --- a/skills/autopilot/docs/README.md +++ b/skills/autopilot/docs/README.md @@ -40,4 +40,4 @@ Both figures use the vendored Diagram Design default profile. The PNG exports ar ## Verification status -The runtime has 205 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 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. Disposable exact-tree reviews also passed with Pi 0.84.4, Codex 0.151.0, and OpenCode 1.18.25; Claude Code 2.1.251 remains unverified because its identity-linked API key requires an `ANTHROPIC_WORKSPACE_ID` that is not present in the validation environment. 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. +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. Disposable exact-tree reviews also passed with Pi 0.84.4, Codex 0.151.0, and OpenCode 1.18.25; Claude Code 2.1.251 remains unverified because its identity-linked API key requires an `ANTHROPIC_WORKSPACE_ID` that is not present in the validation environment. 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. diff --git a/skills/autopilot/docs/architecture.md b/skills/autopilot/docs/architecture.md index c0cb100..201639f 100644 --- a/skills/autopilot/docs/architecture.md +++ b/skills/autopilot/docs/architecture.md @@ -136,7 +136,7 @@ interface RunCharter { readonly baseCommit: string; readonly writableRoots: readonly string[]; }; - readonly harnessAdapter: "pi" | "claude-code" | "codex" | "codex-app-server" | "opencode"; + readonly harnessAdapter: "pi" | "claude-code" | "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,7 @@ 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` and `codex-app-server` charter values are distinct execution modes: direct Codex CLI retains runtime-owned POSIX supervision, while app-server implementation turns use harness-owned same-instance cooperative terminality. +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 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. diff --git a/skills/autopilot/docs/getting-started.md b/skills/autopilot/docs/getting-started.md index ab58caf..aef5a1b 100644 --- a/skills/autopilot/docs/getting-started.md +++ b/skills/autopilot/docs/getting-started.md @@ -40,6 +40,8 @@ For the Pi process-local backend, load the packaged runtime extension through Pi 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. + ## Understand the run A run uses one of three graph modes: diff --git a/skills/autopilot/docs/implementation-plan.md b/skills/autopilot/docs/implementation-plan.md index b6e274f..b06666e 100644 --- a/skills/autopilot/docs/implementation-plan.md +++ b/skills/autopilot/docs/implementation-plan.md @@ -41,7 +41,7 @@ The completed first release must: ## Developer-preview evidence -The implementation currently has 205 Node test cases (203 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 Codex app-server admission, cancellation, continuity loss, identity fencing, server-request denial, 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 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. ## Planned package boundary diff --git a/skills/autopilot/docs/runtime-cli.md b/skills/autopilot/docs/runtime-cli.md index a035532..59cd26c 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; coordinator, connection, or server 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 `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. ## Journal repair diff --git a/skills/autopilot/references/adapters.md b/skills/autopilot/references/adapters.md index 222a5ee..e01a461 100644 --- a/skills/autopilot/references/adapters.md +++ b/skills/autopilot/references/adapters.md @@ -10,7 +10,8 @@ Adapters start fresh noninteractive sessions and normalize observations. They ca | 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 | | 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 | `opencode run --format json --pure --auto ...` | Cooperative | Supervised implementation attempts on POSIX; session-scoped on Windows | Disposable local edit, exact-tree review, verification, and commit passed with 1.18.25 | +| 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 | +| OpenCode CLI | `opencode run --format json --pure --auto ...` | Cooperative | Supervised implementation attempts on POSIX; session-scoped on Windows | Distinct direct mode; disposable local edit, exact-tree review, verification, and commit passed with 1.18.25 | The adapter parser bounds output and rejects malformed JSON-mode output. The runtime ignores model completion claims and inspects the worktree directly. @@ -26,6 +27,10 @@ For Codex, `codex-app-server` and `codex` are separate charter values and never The default stdio transport cannot be reconnected. Codex's reconnectable WebSocket transport is experimental and unsupported, and Autopilot does not bootstrap a global daemon or modify Codex configuration to create a reconnectable endpoint. Therefore this backend reports `same-harness-instance`, not durable reattachment. Process, connection, coordinator, malformed-event, deadline, or identity loss becomes `EXECUTION_STATE_UNKNOWN`. Logical completion does not prove that background terminals or external effects are quiescent. Independent review stays on the direct read-only Codex CLI path. The `codex` charter value preserves the pre-existing direct CLI adapter, including POSIX process-supervised implementation reattachment. +For OpenCode, `opencode-server` and `opencode` are separate charter values. Server mode starts one `--pure` loopback process with a generated Basic Auth password and auto-update disabled in the exact attempt worktree, verifies its observed version and routed realpath, establishes SSE before delegation, creates one dedicated permission-scoped session, and submits one caller-selected message ID. The admitted subject binds a process-instance nonce, session ID, and message ID. Completion requires an uninterrupted exact-session busy-to-idle sequence plus fresh REST observations of the same session, user message content, and one terminal assistant parented to that message. SSE notifications and abort responses are hints only. + +OpenCode SSE has no replay cursor. Any event-stream, coordinator, process, session, or message identity gap becomes `EXECUTION_STATE_UNKNOWN`; the adapter never reconnects and infers completion or launches a replacement. Cancellation is accepted only when the fresh exact assistant carries `MessageAbortedError`; natural completion may win. Unexpected permission and question requests are rejected without expanding authority. The server is a direct child and cleanup is awaited, but logical terminality does not prove descendant quiescence, filesystem or network confinement, rollback external effects, or absence of persistent provider data. Independent review remains on the direct OpenCode CLI adapter. + For Pi, the packaged Autopilot extension probes the process-local `pi-subagents` owner before sealing an execution mode. A compatible installed and active owner receives one public structured delegation request only after `ATTEMPT_STARTED` is durable. Autopilot binds the exact `requestId`, run-scoped `ownerRunId`, attempt-and-context-derived `nodeId`, subject digest, and extension-instance identity in `ATTEMPT_EXECUTION_ADMITTED`. It accepts at most one matching terminal response. Reload, session replacement, stale context, missing admission, or missing terminal response becomes `EXECUTION_STATE_UNKNOWN`; cancellation is only a request until the same tuple returns terminal cancellation. The in-process worker stays in Autopilot's dedicated worktree and appears through Pi's ordinary foreground subagent observability. Bounded activity is also written to stderr without copying child output or tool arguments. Independent review remains a separate direct read-only Pi execution. An absent, inactive, or old `pi-subagents` installation selects the distinct direct CLI fallback before admission; it cannot inherit the in-process backend's harness-owned same-instance assurance. Direct POSIX execution retains its proven process supervisor, while direct Windows execution remains session-scoped. Autopilot never installs, activates, or updates either extension. @@ -49,7 +54,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 and Codex app-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, 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 e8085f1..e258b13 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`, or `opencode`; the two Codex values are distinct direct-CLI and same-app-server-instance execution modes +- `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 - `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/codex/app-server.ts b/skills/autopilot/runtime/adapters/codex/app-server.ts index 66f8dac..96269b9 100644 --- a/skills/autopilot/runtime/adapters/codex/app-server.ts +++ b/skills/autopilot/runtime/adapters/codex/app-server.ts @@ -20,7 +20,7 @@ import { import { renderAttemptContext } from "../../src/attempt-context.js"; import { AutopilotError } from "../../src/errors.js"; import { canonicalJson, isRecord, sha256 } from "../../src/json.js"; -import { boundUtf8, runProcess, StreamingRedactor } from "../../src/process.js"; +import { boundUtf8, runProcess, StreamingRedactor, terminateDirectChild } from "../../src/process.js"; interface CodexAppServerAdapterOptions { readonly executable: string; @@ -129,38 +129,6 @@ function turnFromNotification(value: unknown): { }; } -async function waitForChildClose(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise { - if (child.exitCode !== null || child.signalCode !== null) { - return true; - } - return await new Promise((resolvePromise) => { - const timer = setTimeout(() => { - child.off("close", onClose); - resolvePromise(false); - }, timeoutMs); - timer.unref(); - const onClose = (): void => { - clearTimeout(timer); - resolvePromise(true); - }; - child.once("close", onClose); - }); -} - -async function terminateAppServer(child: ChildProcessWithoutNullStreams): Promise { - if (child.exitCode !== null || child.signalCode !== null) { - return; - } - child.kill(); - if (await waitForChildClose(child, 5_000)) { - return; - } - child.kill("SIGKILL"); - if (!await waitForChildClose(child, 5_000)) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server process did not terminate after forced cleanup"); - } -} - class AppServerConnection { readonly #child: ChildProcessWithoutNullStreams; readonly #maximumLineBytes: number; @@ -366,7 +334,7 @@ class AppServerConnection { this.#child.stdout.destroy(); this.#child.stderr.destroy(); try { - await terminateAppServer(this.#child); + await terminateDirectChild(this.#child, "Codex app-server"); } finally { this.#child.stdin.off("error", this.#onStdinError); this.#child.stdout.off("error", this.#onStdoutError); diff --git a/skills/autopilot/runtime/adapters/opencode/index.ts b/skills/autopilot/runtime/adapters/opencode/index.ts index 963c1ea..5d4c173 100644 --- a/skills/autopilot/runtime/adapters/opencode/index.ts +++ b/skills/autopilot/runtime/adapters/opencode/index.ts @@ -1,4 +1,5 @@ import { CliHarnessAdapter } from "../../src/adapter-process.js"; +import { OpenCodeServerAdapter } from "./server.js"; export function createOpenCodeAdapter(): CliHarnessAdapter { return new CliHarnessAdapter({ @@ -24,3 +25,10 @@ export function createOpenCodeAdapter(): CliHarnessAdapter { expectsJsonLines: true, }); } + +export function createOpenCodeServerAdapter(): OpenCodeServerAdapter { + return new OpenCodeServerAdapter({ + executable: "opencode", + reviewAdapter: createOpenCodeAdapter(), + }); +} diff --git a/skills/autopilot/runtime/adapters/opencode/server.ts b/skills/autopilot/runtime/adapters/opencode/server.ts new file mode 100644 index 0000000..5caf52f --- /dev/null +++ b/skills/autopilot/runtime/adapters/opencode/server.ts @@ -0,0 +1,779 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomBytes, randomUUID } from "node:crypto"; +import { realpath } from "node:fs/promises"; +import { StringDecoder } from "node:string_decoder"; +import { + executionAssuranceFor, + type CancelResult, + type CapabilityManifest, + type ExecutionHandle, + type ExecutionObservation, + type ExecutionRequest, + type ExecutionSubject, + type HarnessPort, +} from "../../src/adapter-protocol.js"; +import { adapterCredentialNames, adapterEnvironment, redactionValues } from "../../src/adapter-process.js"; +import { renderAttemptContext } from "../../src/attempt-context.js"; +import { AutopilotError } from "../../src/errors.js"; +import { canonicalJson, isRecord, sha256 } from "../../src/json.js"; +import { boundUtf8, runProcess, StreamingRedactor, terminateDirectChild } from "../../src/process.js"; + +interface OpenCodeServerAdapterOptions { + readonly executable: string; + readonly reviewAdapter: HarnessPort; +} + +interface Deferred { + readonly promise: Promise; + readonly resolve: (value: T) => void; + readonly reject: (error: Error) => void; +} + +interface PendingExecution { + readonly harnessInstanceId: string; + readonly subject: ExecutionSubject; + readonly terminal: Promise; + readonly isTerminalAccepted: () => boolean; + readonly abort: () => Promise; + readonly dispose: () => Promise; +} + +interface HttpClient { + readonly json: (path: string, init?: RequestInit) => Promise; + readonly eventResponse: () => Promise; +} + +function deferred(): Deferred { + let resolvePromise: (value: T) => void = () => undefined; + let rejectPromise: (error: Error) => void = () => undefined; + const promise = new Promise((resolvePromiseValue, rejectPromiseValue) => { + resolvePromise = resolvePromiseValue; + rejectPromise = rejectPromiseValue; + }); + 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): Record { + 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 redact(text: string, values: readonly string[]): string { + return [...new Set(values.filter((value) => value.length > 0))] + .sort((left, right) => right.length - left.length) + .reduce((current, value) => current.split(value).join("****"), text); +} + +function nestedStrings(value: unknown): readonly string[] { + if (typeof value === "string") { + return [value]; + } + if (Array.isArray(value)) { + return value.flatMap(nestedStrings); + } + if (isRecord(value)) { + return Object.values(value).flatMap(nestedStrings); + } + return []; +} + +function isTerminalAssistant(info: Record, messageId: string): boolean { + if (info.role !== "assistant" || info.parentID !== messageId || !isRecord(info.time) + || typeof info.time.completed !== "number" || !Number.isFinite(info.time.completed)) { + return false; + } + if (info.error !== undefined) { + return true; + } + return typeof info.finish === "string" && !["tool-calls", "unknown"].includes(info.finish); +} + +function isAbortedError(value: unknown): boolean { + return isRecord(value) && value.name === "MessageAbortedError"; +} + +function textOutput(value: unknown): string { + const response = requiredRecord(value, "OpenCode assistant message"); + const parts = Array.isArray(response.parts) ? response.parts : []; + return parts.flatMap((part): readonly string[] => + isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [] + ).join(""); +} + +async function responseBody(response: Response, maximumBytes: number, label: string): Promise { + if (response.body === null) { + return ""; + } + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let size = 0; + for (;;) { + const item = await reader.read(); + if (item.done) { + break; + } + const chunk = Buffer.from(item.value); + size += chunk.byteLength; + if (size > maximumBytes) { + await reader.cancel(); + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `${label} exceeded the configured protocol bound`); + } + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function createHttpClient(options: { + readonly baseUrl: string; + readonly password: string; + readonly worktreePath: string; + readonly maximumBytes: number; + readonly redactionValues: readonly string[]; + readonly signal: AbortSignal; +}): HttpClient { + const authorization = `Basic ${Buffer.from(`autopilot:${options.password}`).toString("base64")}`; + const headers = { + authorization, + "x-opencode-directory": options.worktreePath, + }; + const request = async (path: string, init: RequestInit = {}): Promise => { + let response: Response; + try { + response = await fetch(`${options.baseUrl}${path}`, { + ...init, + headers: { ...headers, ...init.headers }, + signal: options.signal, + }); + } catch (error) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `OpenCode server request failed: ${path}`, { + cause: errorMessage(error), + }); + } + if (!response.ok) { + const body = await responseBody(response, options.maximumBytes, "OpenCode error response"); + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `OpenCode server request failed: ${path}`, { + status: response.status, + body: redact(body, options.redactionValues), + }); + } + return response; + }; + return { + async json(path: string, init: RequestInit = {}): Promise { + const response = await request(path, init); + if (response.status === 204) { + return undefined; + } + const body = await responseBody(response, options.maximumBytes, "OpenCode JSON response"); + try { + return JSON.parse(body) as unknown; + } catch (error) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server returned malformed JSON", { + cause: errorMessage(error), + }); + } + }, + async eventResponse(): Promise { + const response = await request("/event", { headers: { accept: "text/event-stream" } }); + if (response.body === null) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server returned an empty event stream"); + } + return response; + }, + }; +} + +async function consumeEvents(options: { + readonly response: Response; + readonly maximumLineBytes: number; + readonly onEvent: (value: unknown) => Promise; +}): Promise { + if (options.response.body === null) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server returned an empty event stream"); + } + const reader = options.response.body.getReader(); + const decoder = new StringDecoder("utf8"); + let buffer = ""; + let eventData: string[] = []; + for (;;) { + const item = await reader.read(); + if (item.done) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server event stream ended before terminal state"); + } + buffer += decoder.write(Buffer.from(item.value)); + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + let line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (line.endsWith("\r")) { + line = line.slice(0, -1); + } + if (Buffer.byteLength(line) > options.maximumLineBytes) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server event exceeded the configured line bound"); + } + if (line.length === 0) { + if (eventData.length > 0) { + const data = eventData.join("\n"); + eventData = []; + let value: unknown; + try { + value = JSON.parse(data) as unknown; + } catch (error) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server emitted malformed event JSON", { + cause: errorMessage(error), + }); + } + await options.onEvent(value); + } + } else if (line.startsWith("data:")) { + const data = line.slice(5).trimStart(); + const size = Buffer.byteLength(eventData.join("\n")) + Buffer.byteLength(data); + if (size > options.maximumLineBytes) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server event exceeded the configured line bound"); + } + eventData.push(data); + } else if (!line.startsWith(":")) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server emitted an unsupported SSE field"); + } + newline = buffer.indexOf("\n"); + } + if (Buffer.byteLength(buffer) > options.maximumLineBytes) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server event exceeded the configured line bound"); + } + } +} + +async function validatePath(value: unknown, worktreePath: string): Promise { + const response = requiredRecord(value, "OpenCode path response"); + const [expected, directory, worktree] = await Promise.all([ + realpath(worktreePath), + realpath(requiredString(response.directory, "OpenCode directory")), + realpath(requiredString(response.worktree, "OpenCode worktree")), + ]); + if (directory !== expected || worktree !== expected) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server selected a different worktree"); + } +} + +export class OpenCodeServerAdapter implements HarnessPort { + readonly #options: OpenCodeServerAdapterOptions; + readonly #pending = new Map(); + readonly #reviewHandles = new Set(); + #harnessVersion: string | undefined; + + constructor(options: OpenCodeServerAdapterOptions) { + this.#options = options; + } + + async describe(): Promise { + const directManifest = await this.#options.reviewAdapter.describe(); + const server = await runProcess({ + executable: this.#options.executable, + arguments: ["serve", "--help"], + cwd: process.cwd(), + timeoutMs: 10_000, + maxOutputBytes: 65_536, + }); + if (server.exitCode !== 0) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "OpenCode server is unavailable"); + } + this.#harnessVersion = directManifest.harnessVersion; + return { + protocolVersion: 1, + adapterName: "opencode-server", + adapterVersion: "1", + harnessVersion: directManifest.harnessVersion, + families: directManifest.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(directManifest, "review"), + }, + restrictions: "cooperative", + limitations: [ + "OpenCode server implementation completion requires one uninterrupted event stream plus fresh reconciliation of the exact session and caller-selected message.", + "Coordinator, event-stream, server-process, or identity loss is execution-state-unknown and cannot launch a replacement prompt.", + "Logical completion and abort do not prove operating-system process-tree quiescence, filesystem confinement, or rollback external effects.", + "OpenCode session data can persist in the provider data store after the per-attempt server process exits.", + "Independent review uses the direct OpenCode CLI adapter.", + ], + }; + } + + 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.#harnessVersion === undefined) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "OpenCode server capabilities must be loaded before launch"); + } + + const harnessVersion = this.#harnessVersion; + const adapterExecutionId = randomUUID(); + const harnessInstanceId = randomUUID(); + const password = randomBytes(24).toString("hex"); + const startedAt = new Date().toISOString(); + const messageId = `msg_${randomUUID().replaceAll("-", "")}`; + const credentials = adapterCredentialNames(request); + const secrets = [...redactionValues(credentials), password]; + const stderrRedactor = new StreamingRedactor(secrets); + const stderr = { value: "", truncated: false, finished: false }; + const terminal = deferred(); + const serverConnected = deferred(); + const startupUrl = deferred(); + void terminal.promise.catch(() => undefined); + void serverConnected.promise.catch(() => undefined); + void startupUrl.promise.catch(() => undefined); + + let child: ChildProcessWithoutNullStreams | undefined; + let client: HttpClient | undefined; + let sessionId: string | undefined; + let startupOrigin: string | undefined; + let outputTruncated = false; + let terminalAccepted = false; + let executionUnknown: AutopilotError | undefined; + let seenBusy = false; + let seenSessionError = false; + let reconciling = false; + let disposal: Promise | undefined; + let idleTimer: NodeJS.Timeout | undefined; + let deadlineTimer: NodeJS.Timeout | undefined; + const fetchController = new AbortController(); + const assistantInfos = new Map>(); + const prompt = renderAttemptContext(request.context); + const maximumResponseBytes = Math.max( + request.maximumLineBytes, + request.maximumOutputBytes, + Buffer.byteLength(prompt) + request.maximumLineBytes, + ); + const timeoutMs = Math.max(1, Date.parse(request.deadline) - Date.now()); + + 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 (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + idleTimer = setTimeout(() => { + failUnknown("OpenCode server exceeded the harness idle timeout without an exact terminal response"); + }, request.idleTimeoutMs); + idleTimer.unref(); + }; + const dispose = (): Promise => { + disposal ??= (async () => { + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + if (deadlineTimer !== undefined) { + clearTimeout(deadlineTimer); + } + fetchController.abort(); + if (child !== undefined) { + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + try { + await terminateDirectChild(child, "OpenCode server"); + } finally { + child.unref(); + } + } + })(); + return disposal; + }; + const failUnknown = (message: string, cause?: unknown): void => { + if (terminalAccepted) { + return; + } + terminalAccepted = true; + const error = new AutopilotError("EXECUTION_STATE_UNKNOWN", message, { + ...(cause === undefined ? {} : { cause: errorMessage(cause) }), + }); + executionUnknown = error; + terminal.reject(error); + serverConnected.reject(error); + startupUrl.reject(error); + void dispose().catch(() => undefined); + }; + const assertAdmissionContinuity = (): void => { + if (executionUnknown !== undefined) { + throw executionUnknown; + } + }; + const resolveTerminal = (observation: ExecutionObservation): void => { + if (terminalAccepted) { + return; + } + terminalAccepted = true; + finishStderr(); + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + if (deadlineTimer !== undefined) { + clearTimeout(deadlineTimer); + } + terminal.resolve(observation); + }; + const reconcile = async (): Promise => { + if (terminalAccepted || reconciling || !seenBusy || client === undefined || sessionId === undefined) { + return; + } + reconciling = true; + try { + const session = requiredRecord(await client.json(`/session/${sessionId}`), "OpenCode session response"); + if (session.id !== sessionId || session.version !== harnessVersion) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server changed the exact session identity"); + } + const [expectedDirectory, observedDirectory] = await Promise.all([ + realpath(request.worktreePath), + realpath(requiredString(session.directory, "OpenCode session directory")), + ]); + if (expectedDirectory !== observedDirectory) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server changed the exact session directory"); + } + const user = requiredRecord(await client.json(`/session/${sessionId}/message/${messageId}`), "OpenCode user message"); + const userInfo = requiredRecord(user.info, "OpenCode user message info"); + const userParts = Array.isArray(user.parts) ? user.parts : []; + if (userInfo.id !== messageId || userInfo.sessionID !== sessionId || userInfo.role !== "user" + || userParts.length !== 1 || !isRecord(userParts[0]) || userParts[0].type !== "text" + || userParts[0].text !== prompt) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server changed the admitted user message identity or content"); + } + const candidates = [...assistantInfos.values()].filter((info) => isTerminalAssistant(info, messageId)); + if (candidates.length !== 1) { + throw new AutopilotError( + "EXECUTION_STATE_UNKNOWN", + seenSessionError + ? "OpenCode reported a session error without one fresh exact terminal assistant message" + : "OpenCode server did not expose one exact terminal assistant message", + ); + } + const candidate = candidates[0]; + if (candidate === undefined) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server terminal assistant identity is missing"); + } + const assistantId = requiredString(candidate.id, "OpenCode assistant message id"); + const assistant = requiredRecord( + await client.json(`/session/${sessionId}/message/${assistantId}`), + "OpenCode assistant message", + ); + const assistantInfo = requiredRecord(assistant.info, "OpenCode assistant message info"); + const candidateTime = requiredRecord(candidate.time, "OpenCode event assistant completion time"); + const assistantTime = requiredRecord(assistantInfo.time, "OpenCode fresh assistant completion time"); + if (assistantInfo.id !== assistantId || assistantInfo.sessionID !== sessionId + || assistantInfo.parentID !== messageId || !isTerminalAssistant(assistantInfo, messageId) + || assistantTime.completed !== candidateTime.completed + || canonicalJson(assistantInfo.error) !== canonicalJson(candidate.error) + || assistantInfo.finish !== candidate.finish) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode fresh terminal observation changed exact identity"); + } + const boundedOutput = boundUtf8(redact(textOutput(assistant), secrets), request.maximumOutputBytes); + outputTruncated ||= boundedOutput.truncated; + const status: ExecutionObservation["status"] = assistantInfo.error === undefined + ? "completed" : isAbortedError(assistantInfo.error) ? "cancelled" : "failed"; + finishStderr(); + const errorText = status === "failed" ? redact(nestedStrings(assistantInfo.error).join(" "), secrets) : ""; + const boundedError = boundUtf8(`${stderr.value}${errorText}`, request.maximumOutputBytes); + resolveTerminal({ + protocolVersion: 1, + adapterExecutionId, + status, + exitCode: status === "completed" ? 0 : status === "cancelled" ? 130 : 1, + completedAt: new Date().toISOString(), + stdout: boundedOutput.value, + stderr: boundedError.value, + truncated: outputTruncated || stderr.truncated || boundedError.truncated, + }); + } catch (error) { + failUnknown("OpenCode server terminal reconciliation failed", error); + } finally { + reconciling = false; + } + }; + const rejectInteractiveRequest = async (type: string, properties: Record): Promise => { + if (client === undefined || sessionId === undefined || properties.sessionID !== sessionId) { + return; + } + const id = requiredString(properties.id, `OpenCode ${type} request id`); + if (type === "permission.asked") { + await client.json(`/session/${sessionId}/permissions/${id}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ response: "reject" }), + }); + } else { + await client.json(`/question/${id}/reject`, { method: "POST" }); + } + failUnknown("OpenCode server requested interactive authority during unattended execution"); + }; + const onEvent = async (value: unknown): Promise => { + const event = requiredRecord(value, "OpenCode server event"); + const type = requiredString(event.type, "OpenCode server event type"); + const properties = requiredRecord(event.properties, "OpenCode server event properties"); + if (type === "server.connected") { + serverConnected.resolve(); + return; + } + if (type === "server.instance.disposed") { + failUnknown("OpenCode server disposed the admitted worktree instance before terminal state"); + return; + } + if (type === "permission.asked" || type === "question.asked") { + await rejectInteractiveRequest(type, properties); + return; + } + if (sessionId === undefined || properties.sessionID !== sessionId) { + return; + } + resetIdleTimer(); + if (type === "message.updated") { + const info = requiredRecord(properties.info, "OpenCode message.updated info"); + if (info.role === "assistant" && info.parentID === messageId) { + assistantInfos.set(requiredString(info.id, "OpenCode assistant message id"), info); + } + return; + } + if (type === "session.error") { + seenSessionError = true; + return; + } + if (type === "session.status") { + const status = requiredRecord(properties.status, "OpenCode session status"); + if (status.type === "busy") { + seenBusy = true; + return; + } + if (status.type === "idle" && seenBusy) { + await reconcile(); + } + } + }; + + try { + child = spawn(this.#options.executable, ["serve", "--pure", "--hostname", "127.0.0.1", "--port", "0"], { + cwd: request.worktreePath, + env: { + ...adapterEnvironment(request), + OPENCODE_DISABLE_AUTOUPDATE: "1", + OPENCODE_SERVER_USERNAME: "autopilot", + OPENCODE_SERVER_PASSWORD: password, + }, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + const stdoutDecoder = new StringDecoder("utf8"); + let stdoutBuffer = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdoutBuffer += stdoutDecoder.write(chunk); + let newline = stdoutBuffer.indexOf("\n"); + while (newline >= 0) { + const line = stdoutBuffer.slice(0, newline).replace(/\r$/, ""); + stdoutBuffer = stdoutBuffer.slice(newline + 1); + if (Buffer.byteLength(line) > request.maximumLineBytes) { + failUnknown("OpenCode server startup output exceeded the configured line bound"); + return; + } + const match = line.match(/opencode server listening on (http:\/\/[^\s]+)/); + if (match?.[1] !== undefined) { + const parsed = new URL(match[1]); + if (parsed.hostname !== "127.0.0.1") { + failUnknown("OpenCode server selected a non-loopback endpoint"); + return; + } + if (startupOrigin !== undefined && startupOrigin !== parsed.origin) { + failUnknown("OpenCode server reported conflicting loopback endpoints"); + return; + } + startupOrigin = parsed.origin; + startupUrl.resolve(parsed.origin); + } + newline = stdoutBuffer.indexOf("\n"); + } + if (Buffer.byteLength(stdoutBuffer) > request.maximumLineBytes) { + failUnknown("OpenCode server startup output exceeded the configured line bound"); + } + }); + child.stdout.on("error", (error) => failUnknown("OpenCode server stdout failed", error)); + child.stderr.on("data", (chunk: Buffer) => appendStderr(stderrRedactor.write(chunk))); + child.stderr.on("error", (error) => failUnknown("OpenCode server stderr failed", error)); + child.stdin.on("error", (error) => failUnknown("OpenCode server stdin failed", error)); + child.on("error", (error) => failUnknown("OpenCode server process failed", error)); + child.on("close", (code, signal) => { + if (!terminalAccepted) { + failUnknown("OpenCode server exited before exact terminal state", { code, signal }); + } + }); + resetIdleTimer(); + deadlineTimer = setTimeout(() => { + if (client !== undefined && sessionId !== undefined) { + void client.json(`/session/${sessionId}/abort`, { method: "POST" }).catch(() => undefined); + } + failUnknown("OpenCode server did not return exact terminal state before the attempt deadline"); + }, timeoutMs); + deadlineTimer.unref(); + + const baseUrl = await startupUrl.promise; + client = createHttpClient({ + baseUrl, + password, + worktreePath: await realpath(request.worktreePath), + maximumBytes: maximumResponseBytes, + redactionValues: secrets, + signal: fetchController.signal, + }); + const health = requiredRecord(await client.json("/global/health"), "OpenCode health response"); + if (health.healthy !== true || health.version !== harnessVersion) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server health changed the expected version"); + } + await validatePath(await client.json("/path"), request.worktreePath); + const eventResponse = await client.eventResponse(); + void consumeEvents({ response: eventResponse, maximumLineBytes: request.maximumLineBytes, onEvent }) + .catch((error) => failUnknown("OpenCode server event continuity was lost", error)); + await serverConnected.promise; + resetIdleTimer(); + + const session = requiredRecord(await client.json("/session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + title: `Autopilot ${request.runId}/${request.itemId}/${request.attemptId}`, + permission: [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "question", pattern: "*", action: "deny" }, + { permission: "external_directory", pattern: "*", action: "deny" }, + ], + }), + }), "OpenCode session creation response"); + sessionId = requiredString(session.id, "OpenCode session id"); + if (session.version !== harnessVersion) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode session version changed during admission"); + } + const [expectedDirectory, admittedDirectory] = await Promise.all([ + realpath(request.worktreePath), + realpath(requiredString(session.directory, "OpenCode admitted session directory")), + ]); + if (expectedDirectory !== admittedDirectory) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode admitted a session in another directory"); + } + resetIdleTimer(); + await client.json(`/session/${sessionId}/prompt_async`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messageID: messageId, + parts: [{ type: "text", text: prompt }], + }), + }); + assertAdmissionContinuity(); + resetIdleTimer(); + + const subject: ExecutionSubject = { + schemaVersion: 1, + backendId: `opencode-server@${harnessVersion}`, + subjectId: sha256(canonicalJson({ harnessInstanceId, sessionId, messageId })), + harnessInstanceId, + }; + this.#pending.set(adapterExecutionId, { + harnessInstanceId, + subject, + terminal: terminal.promise, + isTerminalAccepted: () => terminalAccepted, + abort: async () => { + if (client === undefined) { + return false; + } + const result = await client.json(`/session/${sessionId}/abort`, { method: "POST" }); + return result === true; + }, + dispose, + }); + return { protocolVersion: 1, adapterExecutionId, startedAt, subject }; + } catch (error) { + failUnknown("OpenCode server admission did not return an exact session and message identity", error); + await dispose().catch(() => undefined); + throw new AutopilotError( + "EXECUTION_STATE_UNKNOWN", + "OpenCode server admission did not return an exact session and message identity", + { cause: errorMessage(error) }, + ); + } + } + + async observe(handle: ExecutionHandle): Promise { + if (this.#reviewHandles.delete(handle.adapterExecutionId)) { + return await this.#options.reviewAdapter.observe(handle); + } + const pending = this.#pending.get(handle.adapterExecutionId); + if (pending === undefined || handle.subject?.backendId !== pending.subject.backendId + || handle.subject.subjectId !== pending.subject.subjectId + || handle.subject.harnessInstanceId !== pending.harnessInstanceId) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode execution is not attached to the exact server 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 || pending.isTerminalAccepted() + || handle.subject?.backendId !== pending.subject.backendId + || handle.subject.subjectId !== pending.subject.subjectId + || handle.subject.harnessInstanceId !== pending.harnessInstanceId) { + return { protocolVersion: 1, accepted: false }; + } + const abort = pending.abort().then(() => ({ kind: "response" as const })); + const terminal = pending.terminal.then((observation) => ({ kind: "terminal" as const, observation })); + const first = await Promise.race([abort, terminal]); + const observation = first.kind === "terminal" ? first.observation : await pending.terminal; + return { protocolVersion: 1, accepted: observation.status === "cancelled" }; + } +} diff --git a/skills/autopilot/runtime/dist/adapters/codex/app-server.js b/skills/autopilot/runtime/dist/adapters/codex/app-server.js index 982e9a0..d146c6c 100644 --- a/skills/autopilot/runtime/dist/adapters/codex/app-server.js +++ b/skills/autopilot/runtime/dist/adapters/codex/app-server.js @@ -6,7 +6,7 @@ import { adapterCredentialNames, adapterEnvironment, redactSecrets, redactionVal import { renderAttemptContext } from "../../src/attempt-context.js"; import { AutopilotError } from "../../src/errors.js"; import { canonicalJson, isRecord, sha256 } from "../../src/json.js"; -import { boundUtf8, runProcess, StreamingRedactor } from "../../src/process.js"; +import { boundUtf8, runProcess, StreamingRedactor, terminateDirectChild } from "../../src/process.js"; function deferred() { let resolvePromise = () => undefined; let rejectPromise = () => undefined; @@ -71,36 +71,6 @@ function turnFromNotification(value) { agentOutput, }; } -async function waitForChildClose(child, timeoutMs) { - if (child.exitCode !== null || child.signalCode !== null) { - return true; - } - return await new Promise((resolvePromise) => { - const timer = setTimeout(() => { - child.off("close", onClose); - resolvePromise(false); - }, timeoutMs); - timer.unref(); - const onClose = () => { - clearTimeout(timer); - resolvePromise(true); - }; - child.once("close", onClose); - }); -} -async function terminateAppServer(child) { - if (child.exitCode !== null || child.signalCode !== null) { - return; - } - child.kill(); - if (await waitForChildClose(child, 5_000)) { - return; - } - child.kill("SIGKILL"); - if (!await waitForChildClose(child, 5_000)) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server process did not terminate after forced cleanup"); - } -} class AppServerConnection { #child; #maximumLineBytes; @@ -286,7 +256,7 @@ class AppServerConnection { this.#child.stdout.destroy(); this.#child.stderr.destroy(); try { - await terminateAppServer(this.#child); + await terminateDirectChild(this.#child, "Codex app-server"); } finally { this.#child.stdin.off("error", this.#onStdinError); diff --git a/skills/autopilot/runtime/dist/adapters/opencode/index.d.ts b/skills/autopilot/runtime/dist/adapters/opencode/index.d.ts index f30fb15..3cd3060 100644 --- a/skills/autopilot/runtime/dist/adapters/opencode/index.d.ts +++ b/skills/autopilot/runtime/dist/adapters/opencode/index.d.ts @@ -1,2 +1,4 @@ import { CliHarnessAdapter } from "../../src/adapter-process.js"; +import { OpenCodeServerAdapter } from "./server.js"; export declare function createOpenCodeAdapter(): CliHarnessAdapter; +export declare function createOpenCodeServerAdapter(): OpenCodeServerAdapter; diff --git a/skills/autopilot/runtime/dist/adapters/opencode/index.js b/skills/autopilot/runtime/dist/adapters/opencode/index.js index 0b1beac..4022a2a 100644 --- a/skills/autopilot/runtime/dist/adapters/opencode/index.js +++ b/skills/autopilot/runtime/dist/adapters/opencode/index.js @@ -1,4 +1,5 @@ import { CliHarnessAdapter } from "../../src/adapter-process.js"; +import { OpenCodeServerAdapter } from "./server.js"; export function createOpenCodeAdapter() { return new CliHarnessAdapter({ name: "opencode", @@ -23,3 +24,9 @@ export function createOpenCodeAdapter() { expectsJsonLines: true, }); } +export function createOpenCodeServerAdapter() { + return new OpenCodeServerAdapter({ + executable: "opencode", + reviewAdapter: createOpenCodeAdapter(), + }); +} diff --git a/skills/autopilot/runtime/dist/adapters/opencode/server.d.ts b/skills/autopilot/runtime/dist/adapters/opencode/server.d.ts new file mode 100644 index 0000000..c080dce --- /dev/null +++ b/skills/autopilot/runtime/dist/adapters/opencode/server.d.ts @@ -0,0 +1,14 @@ +import { type CancelResult, type CapabilityManifest, type ExecutionHandle, type ExecutionObservation, type ExecutionRequest, type HarnessPort } from "../../src/adapter-protocol.js"; +interface OpenCodeServerAdapterOptions { + readonly executable: string; + readonly reviewAdapter: HarnessPort; +} +export declare class OpenCodeServerAdapter implements HarnessPort { + #private; + constructor(options: OpenCodeServerAdapterOptions); + describe(): Promise; + launch(request: ExecutionRequest): Promise; + observe(handle: ExecutionHandle): Promise; + cancel(handle: ExecutionHandle): Promise; +} +export {}; diff --git a/skills/autopilot/runtime/dist/adapters/opencode/server.js b/skills/autopilot/runtime/dist/adapters/opencode/server.js new file mode 100644 index 0000000..81fccf3 --- /dev/null +++ b/skills/autopilot/runtime/dist/adapters/opencode/server.js @@ -0,0 +1,704 @@ +import { spawn } from "node:child_process"; +import { randomBytes, randomUUID } from "node:crypto"; +import { realpath } from "node:fs/promises"; +import { StringDecoder } from "node:string_decoder"; +import { executionAssuranceFor, } from "../../src/adapter-protocol.js"; +import { adapterCredentialNames, adapterEnvironment, redactionValues } from "../../src/adapter-process.js"; +import { renderAttemptContext } from "../../src/attempt-context.js"; +import { AutopilotError } from "../../src/errors.js"; +import { canonicalJson, isRecord, sha256 } from "../../src/json.js"; +import { boundUtf8, runProcess, StreamingRedactor, terminateDirectChild } from "../../src/process.js"; +function deferred() { + let resolvePromise = () => undefined; + let rejectPromise = () => undefined; + const promise = new Promise((resolvePromiseValue, rejectPromiseValue) => { + resolvePromise = resolvePromiseValue; + rejectPromise = rejectPromiseValue; + }); + 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 redact(text, values) { + return [...new Set(values.filter((value) => value.length > 0))] + .sort((left, right) => right.length - left.length) + .reduce((current, value) => current.split(value).join("****"), text); +} +function nestedStrings(value) { + if (typeof value === "string") { + return [value]; + } + if (Array.isArray(value)) { + return value.flatMap(nestedStrings); + } + if (isRecord(value)) { + return Object.values(value).flatMap(nestedStrings); + } + return []; +} +function isTerminalAssistant(info, messageId) { + if (info.role !== "assistant" || info.parentID !== messageId || !isRecord(info.time) + || typeof info.time.completed !== "number" || !Number.isFinite(info.time.completed)) { + return false; + } + if (info.error !== undefined) { + return true; + } + return typeof info.finish === "string" && !["tool-calls", "unknown"].includes(info.finish); +} +function isAbortedError(value) { + return isRecord(value) && value.name === "MessageAbortedError"; +} +function textOutput(value) { + const response = requiredRecord(value, "OpenCode assistant message"); + const parts = Array.isArray(response.parts) ? response.parts : []; + return parts.flatMap((part) => isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : []).join(""); +} +async function responseBody(response, maximumBytes, label) { + if (response.body === null) { + return ""; + } + const reader = response.body.getReader(); + const chunks = []; + let size = 0; + for (;;) { + const item = await reader.read(); + if (item.done) { + break; + } + const chunk = Buffer.from(item.value); + size += chunk.byteLength; + if (size > maximumBytes) { + await reader.cancel(); + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `${label} exceeded the configured protocol bound`); + } + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} +function createHttpClient(options) { + const authorization = `Basic ${Buffer.from(`autopilot:${options.password}`).toString("base64")}`; + const headers = { + authorization, + "x-opencode-directory": options.worktreePath, + }; + const request = async (path, init = {}) => { + let response; + try { + response = await fetch(`${options.baseUrl}${path}`, { + ...init, + headers: { ...headers, ...init.headers }, + signal: options.signal, + }); + } + catch (error) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `OpenCode server request failed: ${path}`, { + cause: errorMessage(error), + }); + } + if (!response.ok) { + const body = await responseBody(response, options.maximumBytes, "OpenCode error response"); + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `OpenCode server request failed: ${path}`, { + status: response.status, + body: redact(body, options.redactionValues), + }); + } + return response; + }; + return { + async json(path, init = {}) { + const response = await request(path, init); + if (response.status === 204) { + return undefined; + } + const body = await responseBody(response, options.maximumBytes, "OpenCode JSON response"); + try { + return JSON.parse(body); + } + catch (error) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server returned malformed JSON", { + cause: errorMessage(error), + }); + } + }, + async eventResponse() { + const response = await request("/event", { headers: { accept: "text/event-stream" } }); + if (response.body === null) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server returned an empty event stream"); + } + return response; + }, + }; +} +async function consumeEvents(options) { + if (options.response.body === null) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server returned an empty event stream"); + } + const reader = options.response.body.getReader(); + const decoder = new StringDecoder("utf8"); + let buffer = ""; + let eventData = []; + for (;;) { + const item = await reader.read(); + if (item.done) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server event stream ended before terminal state"); + } + buffer += decoder.write(Buffer.from(item.value)); + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + let line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (line.endsWith("\r")) { + line = line.slice(0, -1); + } + if (Buffer.byteLength(line) > options.maximumLineBytes) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server event exceeded the configured line bound"); + } + if (line.length === 0) { + if (eventData.length > 0) { + const data = eventData.join("\n"); + eventData = []; + let value; + try { + value = JSON.parse(data); + } + catch (error) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server emitted malformed event JSON", { + cause: errorMessage(error), + }); + } + await options.onEvent(value); + } + } + else if (line.startsWith("data:")) { + const data = line.slice(5).trimStart(); + const size = Buffer.byteLength(eventData.join("\n")) + Buffer.byteLength(data); + if (size > options.maximumLineBytes) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server event exceeded the configured line bound"); + } + eventData.push(data); + } + else if (!line.startsWith(":")) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server emitted an unsupported SSE field"); + } + newline = buffer.indexOf("\n"); + } + if (Buffer.byteLength(buffer) > options.maximumLineBytes) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server event exceeded the configured line bound"); + } + } +} +async function validatePath(value, worktreePath) { + const response = requiredRecord(value, "OpenCode path response"); + const [expected, directory, worktree] = await Promise.all([ + realpath(worktreePath), + realpath(requiredString(response.directory, "OpenCode directory")), + realpath(requiredString(response.worktree, "OpenCode worktree")), + ]); + if (directory !== expected || worktree !== expected) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server selected a different worktree"); + } +} +export class OpenCodeServerAdapter { + #options; + #pending = new Map(); + #reviewHandles = new Set(); + #harnessVersion; + constructor(options) { + this.#options = options; + } + async describe() { + const directManifest = await this.#options.reviewAdapter.describe(); + const server = await runProcess({ + executable: this.#options.executable, + arguments: ["serve", "--help"], + cwd: process.cwd(), + timeoutMs: 10_000, + maxOutputBytes: 65_536, + }); + if (server.exitCode !== 0) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "OpenCode server is unavailable"); + } + this.#harnessVersion = directManifest.harnessVersion; + return { + protocolVersion: 1, + adapterName: "opencode-server", + adapterVersion: "1", + harnessVersion: directManifest.harnessVersion, + families: directManifest.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(directManifest, "review"), + }, + restrictions: "cooperative", + limitations: [ + "OpenCode server implementation completion requires one uninterrupted event stream plus fresh reconciliation of the exact session and caller-selected message.", + "Coordinator, event-stream, server-process, or identity loss is execution-state-unknown and cannot launch a replacement prompt.", + "Logical completion and abort do not prove operating-system process-tree quiescence, filesystem confinement, or rollback external effects.", + "OpenCode session data can persist in the provider data store after the per-attempt server process exits.", + "Independent review uses the direct OpenCode CLI adapter.", + ], + }; + } + 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.#harnessVersion === undefined) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "OpenCode server capabilities must be loaded before launch"); + } + const harnessVersion = this.#harnessVersion; + const adapterExecutionId = randomUUID(); + const harnessInstanceId = randomUUID(); + const password = randomBytes(24).toString("hex"); + const startedAt = new Date().toISOString(); + const messageId = `msg_${randomUUID().replaceAll("-", "")}`; + const credentials = adapterCredentialNames(request); + const secrets = [...redactionValues(credentials), password]; + const stderrRedactor = new StreamingRedactor(secrets); + const stderr = { value: "", truncated: false, finished: false }; + const terminal = deferred(); + const serverConnected = deferred(); + const startupUrl = deferred(); + void terminal.promise.catch(() => undefined); + void serverConnected.promise.catch(() => undefined); + void startupUrl.promise.catch(() => undefined); + let child; + let client; + let sessionId; + let startupOrigin; + let outputTruncated = false; + let terminalAccepted = false; + let executionUnknown; + let seenBusy = false; + let seenSessionError = false; + let reconciling = false; + let disposal; + let idleTimer; + let deadlineTimer; + const fetchController = new AbortController(); + const assistantInfos = new Map(); + const prompt = renderAttemptContext(request.context); + const maximumResponseBytes = Math.max(request.maximumLineBytes, request.maximumOutputBytes, Buffer.byteLength(prompt) + request.maximumLineBytes); + const timeoutMs = Math.max(1, Date.parse(request.deadline) - Date.now()); + 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 (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + idleTimer = setTimeout(() => { + failUnknown("OpenCode server exceeded the harness idle timeout without an exact terminal response"); + }, request.idleTimeoutMs); + idleTimer.unref(); + }; + const dispose = () => { + disposal ??= (async () => { + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + if (deadlineTimer !== undefined) { + clearTimeout(deadlineTimer); + } + fetchController.abort(); + if (child !== undefined) { + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + try { + await terminateDirectChild(child, "OpenCode server"); + } + finally { + child.unref(); + } + } + })(); + return disposal; + }; + const failUnknown = (message, cause) => { + if (terminalAccepted) { + return; + } + terminalAccepted = true; + const error = new AutopilotError("EXECUTION_STATE_UNKNOWN", message, { + ...(cause === undefined ? {} : { cause: errorMessage(cause) }), + }); + executionUnknown = error; + terminal.reject(error); + serverConnected.reject(error); + startupUrl.reject(error); + void dispose().catch(() => undefined); + }; + const assertAdmissionContinuity = () => { + if (executionUnknown !== undefined) { + throw executionUnknown; + } + }; + const resolveTerminal = (observation) => { + if (terminalAccepted) { + return; + } + terminalAccepted = true; + finishStderr(); + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + if (deadlineTimer !== undefined) { + clearTimeout(deadlineTimer); + } + terminal.resolve(observation); + }; + const reconcile = async () => { + if (terminalAccepted || reconciling || !seenBusy || client === undefined || sessionId === undefined) { + return; + } + reconciling = true; + try { + const session = requiredRecord(await client.json(`/session/${sessionId}`), "OpenCode session response"); + if (session.id !== sessionId || session.version !== harnessVersion) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server changed the exact session identity"); + } + const [expectedDirectory, observedDirectory] = await Promise.all([ + realpath(request.worktreePath), + realpath(requiredString(session.directory, "OpenCode session directory")), + ]); + if (expectedDirectory !== observedDirectory) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server changed the exact session directory"); + } + const user = requiredRecord(await client.json(`/session/${sessionId}/message/${messageId}`), "OpenCode user message"); + const userInfo = requiredRecord(user.info, "OpenCode user message info"); + const userParts = Array.isArray(user.parts) ? user.parts : []; + if (userInfo.id !== messageId || userInfo.sessionID !== sessionId || userInfo.role !== "user" + || userParts.length !== 1 || !isRecord(userParts[0]) || userParts[0].type !== "text" + || userParts[0].text !== prompt) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server changed the admitted user message identity or content"); + } + const candidates = [...assistantInfos.values()].filter((info) => isTerminalAssistant(info, messageId)); + if (candidates.length !== 1) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", seenSessionError + ? "OpenCode reported a session error without one fresh exact terminal assistant message" + : "OpenCode server did not expose one exact terminal assistant message"); + } + const candidate = candidates[0]; + if (candidate === undefined) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server terminal assistant identity is missing"); + } + const assistantId = requiredString(candidate.id, "OpenCode assistant message id"); + const assistant = requiredRecord(await client.json(`/session/${sessionId}/message/${assistantId}`), "OpenCode assistant message"); + const assistantInfo = requiredRecord(assistant.info, "OpenCode assistant message info"); + const candidateTime = requiredRecord(candidate.time, "OpenCode event assistant completion time"); + const assistantTime = requiredRecord(assistantInfo.time, "OpenCode fresh assistant completion time"); + if (assistantInfo.id !== assistantId || assistantInfo.sessionID !== sessionId + || assistantInfo.parentID !== messageId || !isTerminalAssistant(assistantInfo, messageId) + || assistantTime.completed !== candidateTime.completed + || canonicalJson(assistantInfo.error) !== canonicalJson(candidate.error) + || assistantInfo.finish !== candidate.finish) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode fresh terminal observation changed exact identity"); + } + const boundedOutput = boundUtf8(redact(textOutput(assistant), secrets), request.maximumOutputBytes); + outputTruncated ||= boundedOutput.truncated; + const status = assistantInfo.error === undefined + ? "completed" : isAbortedError(assistantInfo.error) ? "cancelled" : "failed"; + finishStderr(); + const errorText = status === "failed" ? redact(nestedStrings(assistantInfo.error).join(" "), secrets) : ""; + const boundedError = boundUtf8(`${stderr.value}${errorText}`, request.maximumOutputBytes); + resolveTerminal({ + protocolVersion: 1, + adapterExecutionId, + status, + exitCode: status === "completed" ? 0 : status === "cancelled" ? 130 : 1, + completedAt: new Date().toISOString(), + stdout: boundedOutput.value, + stderr: boundedError.value, + truncated: outputTruncated || stderr.truncated || boundedError.truncated, + }); + } + catch (error) { + failUnknown("OpenCode server terminal reconciliation failed", error); + } + finally { + reconciling = false; + } + }; + const rejectInteractiveRequest = async (type, properties) => { + if (client === undefined || sessionId === undefined || properties.sessionID !== sessionId) { + return; + } + const id = requiredString(properties.id, `OpenCode ${type} request id`); + if (type === "permission.asked") { + await client.json(`/session/${sessionId}/permissions/${id}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ response: "reject" }), + }); + } + else { + await client.json(`/question/${id}/reject`, { method: "POST" }); + } + failUnknown("OpenCode server requested interactive authority during unattended execution"); + }; + const onEvent = async (value) => { + const event = requiredRecord(value, "OpenCode server event"); + const type = requiredString(event.type, "OpenCode server event type"); + const properties = requiredRecord(event.properties, "OpenCode server event properties"); + if (type === "server.connected") { + serverConnected.resolve(); + return; + } + if (type === "server.instance.disposed") { + failUnknown("OpenCode server disposed the admitted worktree instance before terminal state"); + return; + } + if (type === "permission.asked" || type === "question.asked") { + await rejectInteractiveRequest(type, properties); + return; + } + if (sessionId === undefined || properties.sessionID !== sessionId) { + return; + } + resetIdleTimer(); + if (type === "message.updated") { + const info = requiredRecord(properties.info, "OpenCode message.updated info"); + if (info.role === "assistant" && info.parentID === messageId) { + assistantInfos.set(requiredString(info.id, "OpenCode assistant message id"), info); + } + return; + } + if (type === "session.error") { + seenSessionError = true; + return; + } + if (type === "session.status") { + const status = requiredRecord(properties.status, "OpenCode session status"); + if (status.type === "busy") { + seenBusy = true; + return; + } + if (status.type === "idle" && seenBusy) { + await reconcile(); + } + } + }; + try { + child = spawn(this.#options.executable, ["serve", "--pure", "--hostname", "127.0.0.1", "--port", "0"], { + cwd: request.worktreePath, + env: { + ...adapterEnvironment(request), + OPENCODE_DISABLE_AUTOUPDATE: "1", + OPENCODE_SERVER_USERNAME: "autopilot", + OPENCODE_SERVER_PASSWORD: password, + }, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + const stdoutDecoder = new StringDecoder("utf8"); + let stdoutBuffer = ""; + child.stdout.on("data", (chunk) => { + stdoutBuffer += stdoutDecoder.write(chunk); + let newline = stdoutBuffer.indexOf("\n"); + while (newline >= 0) { + const line = stdoutBuffer.slice(0, newline).replace(/\r$/, ""); + stdoutBuffer = stdoutBuffer.slice(newline + 1); + if (Buffer.byteLength(line) > request.maximumLineBytes) { + failUnknown("OpenCode server startup output exceeded the configured line bound"); + return; + } + const match = line.match(/opencode server listening on (http:\/\/[^\s]+)/); + if (match?.[1] !== undefined) { + const parsed = new URL(match[1]); + if (parsed.hostname !== "127.0.0.1") { + failUnknown("OpenCode server selected a non-loopback endpoint"); + return; + } + if (startupOrigin !== undefined && startupOrigin !== parsed.origin) { + failUnknown("OpenCode server reported conflicting loopback endpoints"); + return; + } + startupOrigin = parsed.origin; + startupUrl.resolve(parsed.origin); + } + newline = stdoutBuffer.indexOf("\n"); + } + if (Buffer.byteLength(stdoutBuffer) > request.maximumLineBytes) { + failUnknown("OpenCode server startup output exceeded the configured line bound"); + } + }); + child.stdout.on("error", (error) => failUnknown("OpenCode server stdout failed", error)); + child.stderr.on("data", (chunk) => appendStderr(stderrRedactor.write(chunk))); + child.stderr.on("error", (error) => failUnknown("OpenCode server stderr failed", error)); + child.stdin.on("error", (error) => failUnknown("OpenCode server stdin failed", error)); + child.on("error", (error) => failUnknown("OpenCode server process failed", error)); + child.on("close", (code, signal) => { + if (!terminalAccepted) { + failUnknown("OpenCode server exited before exact terminal state", { code, signal }); + } + }); + resetIdleTimer(); + deadlineTimer = setTimeout(() => { + if (client !== undefined && sessionId !== undefined) { + void client.json(`/session/${sessionId}/abort`, { method: "POST" }).catch(() => undefined); + } + failUnknown("OpenCode server did not return exact terminal state before the attempt deadline"); + }, timeoutMs); + deadlineTimer.unref(); + const baseUrl = await startupUrl.promise; + client = createHttpClient({ + baseUrl, + password, + worktreePath: await realpath(request.worktreePath), + maximumBytes: maximumResponseBytes, + redactionValues: secrets, + signal: fetchController.signal, + }); + const health = requiredRecord(await client.json("/global/health"), "OpenCode health response"); + if (health.healthy !== true || health.version !== harnessVersion) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server health changed the expected version"); + } + await validatePath(await client.json("/path"), request.worktreePath); + const eventResponse = await client.eventResponse(); + void consumeEvents({ response: eventResponse, maximumLineBytes: request.maximumLineBytes, onEvent }) + .catch((error) => failUnknown("OpenCode server event continuity was lost", error)); + await serverConnected.promise; + resetIdleTimer(); + const session = requiredRecord(await client.json("/session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + title: `Autopilot ${request.runId}/${request.itemId}/${request.attemptId}`, + permission: [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "question", pattern: "*", action: "deny" }, + { permission: "external_directory", pattern: "*", action: "deny" }, + ], + }), + }), "OpenCode session creation response"); + sessionId = requiredString(session.id, "OpenCode session id"); + if (session.version !== harnessVersion) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode session version changed during admission"); + } + const [expectedDirectory, admittedDirectory] = await Promise.all([ + realpath(request.worktreePath), + realpath(requiredString(session.directory, "OpenCode admitted session directory")), + ]); + if (expectedDirectory !== admittedDirectory) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode admitted a session in another directory"); + } + resetIdleTimer(); + await client.json(`/session/${sessionId}/prompt_async`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messageID: messageId, + parts: [{ type: "text", text: prompt }], + }), + }); + assertAdmissionContinuity(); + resetIdleTimer(); + const subject = { + schemaVersion: 1, + backendId: `opencode-server@${harnessVersion}`, + subjectId: sha256(canonicalJson({ harnessInstanceId, sessionId, messageId })), + harnessInstanceId, + }; + this.#pending.set(adapterExecutionId, { + harnessInstanceId, + subject, + terminal: terminal.promise, + isTerminalAccepted: () => terminalAccepted, + abort: async () => { + if (client === undefined) { + return false; + } + const result = await client.json(`/session/${sessionId}/abort`, { method: "POST" }); + return result === true; + }, + dispose, + }); + return { protocolVersion: 1, adapterExecutionId, startedAt, subject }; + } + catch (error) { + failUnknown("OpenCode server admission did not return an exact session and message identity", error); + await dispose().catch(() => undefined); + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode server admission did not return an exact session and message identity", { cause: errorMessage(error) }); + } + } + async observe(handle) { + if (this.#reviewHandles.delete(handle.adapterExecutionId)) { + return await this.#options.reviewAdapter.observe(handle); + } + const pending = this.#pending.get(handle.adapterExecutionId); + if (pending === undefined || handle.subject?.backendId !== pending.subject.backendId + || handle.subject.subjectId !== pending.subject.subjectId + || handle.subject.harnessInstanceId !== pending.harnessInstanceId) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "OpenCode execution is not attached to the exact server 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 || pending.isTerminalAccepted() + || handle.subject?.backendId !== pending.subject.backendId + || handle.subject.subjectId !== pending.subject.subjectId + || handle.subject.harnessInstanceId !== pending.harnessInstanceId) { + return { protocolVersion: 1, accepted: false }; + } + const abort = pending.abort().then(() => ({ kind: "response" })); + const terminal = pending.terminal.then((observation) => ({ kind: "terminal", observation })); + const first = await Promise.race([abort, terminal]); + const observation = first.kind === "terminal" ? first.observation : await pending.terminal; + return { protocolVersion: 1, accepted: observation.status === "cancelled" }; + } +} diff --git a/skills/autopilot/runtime/dist/src/adapters.d.ts b/skills/autopilot/runtime/dist/src/adapters.d.ts index 6591b49..6f6a39a 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"; +export type AdapterName = "pi" | "claude-code" | "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 dfc277f..645657e 100644 --- a/skills/autopilot/runtime/dist/src/adapters.js +++ b/skills/autopilot/runtime/dist/src/adapters.js @@ -1,6 +1,6 @@ import { createClaudeCodeAdapter } from "../adapters/claude-code/index.js"; import { createCodexAdapter, createCodexAppServerAdapter } from "../adapters/codex/index.js"; -import { createOpenCodeAdapter } from "../adapters/opencode/index.js"; +import { createOpenCodeAdapter, createOpenCodeServerAdapter } from "../adapters/opencode/index.js"; import { createPiAdapter } from "../adapters/pi/index.js"; import { AutopilotError } from "./errors.js"; export function createAdapter(name) { @@ -15,6 +15,8 @@ export function createAdapter(name) { return createCodexAppServerAdapter(); case "opencode": return createOpenCodeAdapter(); + case "opencode-server": + return createOpenCodeServerAdapter(); default: throw new AutopilotError("ADAPTER_UNSUPPORTED", `unknown harness adapter: ${name}`); } diff --git a/skills/autopilot/runtime/dist/src/charter.d.ts b/skills/autopilot/runtime/dist/src/charter.d.ts index a9ab079..194fb3f 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"; + readonly harnessAdapter: "pi" | "claude-code" | "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 81368cf..f0c278d 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"], "charter.harnessAdapter"), + harnessAdapter: expectLiteral(object.harnessAdapter, ["pi", "claude-code", "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/doctor.js b/skills/autopilot/runtime/dist/src/doctor.js index 2dcd0ba..b16555e 100644 --- a/skills/autopilot/runtime/dist/src/doctor.js +++ b/skills/autopilot/runtime/dist/src/doctor.js @@ -68,6 +68,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("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 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/dist/src/process.d.ts b/skills/autopilot/runtime/dist/src/process.d.ts index 2200e2c..ed5404a 100644 --- a/skills/autopilot/runtime/dist/src/process.d.ts +++ b/skills/autopilot/runtime/dist/src/process.d.ts @@ -1,3 +1,4 @@ +import { type ChildProcessWithoutNullStreams } from "node:child_process"; export interface ProcessRequest { readonly executable: string; readonly arguments: readonly string[]; @@ -22,6 +23,7 @@ export interface ProcessResult { readonly stderr: string; readonly truncated: boolean; } +export declare function terminateDirectChild(child: ChildProcessWithoutNullStreams, label: string): Promise; export declare function terminateProcessTree(pid: number, executable: string): Promise; export declare class StreamingRedactor { #private; diff --git a/skills/autopilot/runtime/dist/src/process.js b/skills/autopilot/runtime/dist/src/process.js index 52bfd12..9fe2be3 100644 --- a/skills/autopilot/runtime/dist/src/process.js +++ b/skills/autopilot/runtime/dist/src/process.js @@ -17,6 +17,36 @@ async function waitForPosixProcessGroupExit(pid, timeoutMs) { } return false; } +async function waitForDirectChildClose(child, timeoutMs) { + if (child.exitCode !== null || child.signalCode !== null) { + return true; + } + return await new Promise((resolvePromise) => { + const timer = setTimeout(() => { + child.off("close", onClose); + resolvePromise(false); + }, timeoutMs); + timer.unref(); + const onClose = () => { + clearTimeout(timer); + resolvePromise(true); + }; + child.once("close", onClose); + }); +} +export async function terminateDirectChild(child, label) { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + child.kill(); + if (await waitForDirectChildClose(child, 5_000)) { + return; + } + child.kill("SIGKILL"); + if (!await waitForDirectChildClose(child, 5_000)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `${label} process did not terminate after forced cleanup`); + } +} export async function terminateProcessTree(pid, executable) { if (process.platform === "win32") { await new Promise((resolveTermination, rejectTermination) => { diff --git a/skills/autopilot/runtime/schemas/charter.schema.json b/skills/autopilot/runtime/schemas/charter.schema.json index 762bd24..7965b0d 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"] }, + "harnessAdapter": { "enum": ["pi", "claude-code", "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 e2d0b46..2fd92c8 100644 --- a/skills/autopilot/runtime/src/adapters.ts +++ b/skills/autopilot/runtime/src/adapters.ts @@ -1,11 +1,11 @@ import { createClaudeCodeAdapter } from "../adapters/claude-code/index.js"; import { createCodexAdapter, createCodexAppServerAdapter } from "../adapters/codex/index.js"; -import { createOpenCodeAdapter } from "../adapters/opencode/index.js"; +import { createOpenCodeAdapter, createOpenCodeServerAdapter } from "../adapters/opencode/index.js"; 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"; +export type AdapterName = "pi" | "claude-code" | "codex" | "codex-app-server" | "opencode" | "opencode-server"; export function createAdapter(name: string): HarnessPort { switch (name) { @@ -19,6 +19,8 @@ export function createAdapter(name: string): HarnessPort { return createCodexAppServerAdapter(); case "opencode": return createOpenCodeAdapter(); + case "opencode-server": + return createOpenCodeServerAdapter(); default: throw new AutopilotError("ADAPTER_UNSUPPORTED", `unknown harness adapter: ${name}`); } diff --git a/skills/autopilot/runtime/src/charter.ts b/skills/autopilot/runtime/src/charter.ts index 05d3ad7..3fa9ebb 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"; + readonly harnessAdapter: "pi" | "claude-code" | "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"], + ["pi", "claude-code", "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/doctor.ts b/skills/autopilot/runtime/src/doctor.ts index 3f0d75a..b768200 100644 --- a/skills/autopilot/runtime/src/doctor.ts +++ b/skills/autopilot/runtime/src/doctor.ts @@ -88,6 +88,12 @@ export async function runDoctor(): Promise { "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"]), diff --git a/skills/autopilot/runtime/src/process.ts b/skills/autopilot/runtime/src/process.ts index 759df84..3329319 100644 --- a/skills/autopilot/runtime/src/process.ts +++ b/skills/autopilot/runtime/src/process.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { StringDecoder } from "node:string_decoder"; import { TextDecoder } from "node:util"; import { AutopilotError } from "./errors.js"; @@ -44,6 +44,38 @@ async function waitForPosixProcessGroupExit(pid: number, timeoutMs: number): Pro return false; } +async function waitForDirectChildClose(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return true; + } + return await new Promise((resolvePromise) => { + const timer = setTimeout(() => { + child.off("close", onClose); + resolvePromise(false); + }, timeoutMs); + timer.unref(); + const onClose = (): void => { + clearTimeout(timer); + resolvePromise(true); + }; + child.once("close", onClose); + }); +} + +export async function terminateDirectChild(child: ChildProcessWithoutNullStreams, label: string): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + child.kill(); + if (await waitForDirectChildClose(child, 5_000)) { + return; + } + child.kill("SIGKILL"); + if (!await waitForDirectChildClose(child, 5_000)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `${label} process did not terminate after forced cleanup`); + } +} + export async function terminateProcessTree(pid: number, executable: string): Promise { if (process.platform === "win32") { await new Promise((resolveTermination, rejectTermination) => { diff --git a/skills/autopilot/runtime/test/adapter-compatibility.test.ts b/skills/autopilot/runtime/test/adapter-compatibility.test.ts index ab91ab3..b4053b5 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"]) { + for (const name of ["pi", "claude-code", "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 9dade51..2e0a5cf 100644 --- a/skills/autopilot/runtime/test/charter.test.ts +++ b/skills/autopilot/runtime/test/charter.test.ts @@ -17,6 +17,15 @@ test("sealCharter accepts each supported graph mode and produces a stable immuta } }); +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) { + assert.equal(sealCharter({ ...proposed, harnessAdapter }).harnessAdapter, harnessAdapter); + } +}); + test("sealCharter accepts bounded provider-check waiting only for merge-verified delivery", async () => { const repository = await createRepository(); const proposed = proposedCharter(repository.root, repository.baseCommit); diff --git a/skills/autopilot/runtime/test/codex-app-server.test.ts b/skills/autopilot/runtime/test/codex-app-server.test.ts index 05b3cb8..bd88828 100644 --- a/skills/autopilot/runtime/test/codex-app-server.test.ts +++ b/skills/autopilot/runtime/test/codex-app-server.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import type { ExecutionRequest } from "../src/adapter-protocol.js"; +import type { ExecutionHandle, ExecutionRequest } from "../src/adapter-protocol.js"; import { CliHarnessAdapter } from "../src/adapter-process.js"; import { CodexAppServerAdapter } from "../adapters/codex/app-server.js"; import { attemptContextFixture, writeNodeExecutable } from "./helpers.js"; @@ -386,24 +386,29 @@ test("Codex app-server force-stops a direct child that ignores graceful cleanup" ); }); -test("Codex app-server awaits forced direct-child cleanup on unknown admission, timeout, and protocol paths", { +test("Codex app-server awaits forced direct-child cleanup on unknown admission and protocol paths", { skip: process.platform === "win32", }, async () => { - for (const scenario of ["admission", "timeout", "oversized"] as const) { + for (const scenario of ["admission", "oversized"] as const) { const { adapter, worktreePath } = await createAdapter(`ignore-term-${scenario}`); await adapter.describe(); const baseRequest = request(worktreePath, `ignore-term-${scenario}`); const executionRequest = scenario === "admission" ? { ...baseRequest, idleTimeoutMs: 50 } - : scenario === "timeout" - ? { ...baseRequest, deadline: new Date(Date.now() + 50).toISOString() } - : baseRequest; + : baseRequest; if (scenario === "admission") { await assert.rejects(adapter.launch(executionRequest), isExecutionUnknown); } else { - const handle = await adapter.launch(executionRequest); - await assert.rejects(adapter.observe(handle), isExecutionUnknown); + let handle: ExecutionHandle | undefined; + try { + handle = await adapter.launch(executionRequest); + } catch (error) { + assert.equal(isExecutionUnknown(error), true); + } + if (handle !== undefined) { + await assert.rejects(adapter.observe(handle), isExecutionUnknown); + } } const pid = Number.parseInt(await readFile(join(worktreePath, "fake.pid"), "utf8"), 10); diff --git a/skills/autopilot/runtime/test/opencode-server.test.ts b/skills/autopilot/runtime/test/opencode-server.test.ts new file mode 100644 index 0000000..d912c0d --- /dev/null +++ b/skills/autopilot/runtime/test/opencode-server.test.ts @@ -0,0 +1,520 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { test } from "node:test"; +import { OpenCodeServerAdapter } from "../adapters/opencode/server.js"; +import type { ExecutionRequest } from "../src/adapter-protocol.js"; +import { CliHarnessAdapter } from "../src/adapter-process.js"; +import { attemptContextFixture, writeNodeExecutable } from "./helpers.js"; + +const FAKE_OPENCODE = `#!/usr/bin/env node +const mode = ${JSON.stringify("MODE")}; +const fs = process.getBuiltinModule("node:fs"); +const http = process.getBuiltinModule("node:http"); +if (process.argv.includes("--version")) { + process.stdout.write("1.18.25\\n"); + process.exit(0); +} +if (process.argv.includes("--help")) { + process.stdout.write("Usage: opencode serve\\n"); + process.exit(0); +} +if (process.argv.includes("run")) { + process.stdout.write("direct review\\n"); + process.exit(0); +} +const version = "1.18.25"; +const directory = process.cwd(); +const sessionID = "ses_test"; +const assistantID = "msg_assistant"; +const streams = new Set(); +const messages = new Map(); +if (process.platform === "win32") { + process.stdin.resume(); + process.stdin.on("end", () => process.exit(0)); +} +let completed = false; +const event = (type, properties = {}) => ({ type, properties }); +const emit = (value) => { + const data = "data: " + JSON.stringify(value) + "\\n\\n"; + for (const response of streams) response.write(data); +}; +const json = (response, status, value) => { + const body = value === undefined ? "" : JSON.stringify(value); + response.writeHead(status, { "content-type": "application/json" }); + response.end(body); +}; +const readBody = async (request) => { + let body = ""; + for await (const chunk of request) body += chunk; + return body === "" ? {} : JSON.parse(body); +}; +const assistant = (error, parentID = sessionID) => ({ + info: { + id: assistantID, + sessionID, + role: "assistant", + parentID, + time: { created: Date.now(), completed: Date.now() }, + finish: error ? "unknown" : "stop", + ...(error ? { error } : {}), + }, + parts: [{ type: "text", text: "completed secret=" + (process.env.AUTOPILOT_OPENCODE_TEST_TOKEN ?? "") }], +}); +const complete = (error, parentID) => { + if (completed) return; + completed = true; + const value = assistant(error, parentID ?? [...messages.keys()][0]); + messages.set(assistantID, value); + emit(event("message.updated", { sessionID, info: value.info })); + emit(event("session.status", { sessionID, status: { type: "idle" } })); +}; +const server = http.createServer(async (request, response) => { + const expectedAuth = "Basic " + Buffer.from("autopilot:" + process.env.OPENCODE_SERVER_PASSWORD).toString("base64"); + if (request.headers.authorization !== expectedAuth || request.headers["x-opencode-directory"] !== directory) { + json(response, 401, { error: "bad auth or directory" }); + return; + } + const url = new URL(request.url, "http://127.0.0.1"); + if (url.pathname === "/global/health") { + json(response, 200, { healthy: true, version: mode === "wrong-version" ? "9.9.9" : version }); + return; + } + if (url.pathname === "/path") { + json(response, 200, { directory, worktree: mode === "wrong-path" ? process.getBuiltinModule("node:os").tmpdir() : directory }); + return; + } + if (url.pathname === "/event") { + response.writeHead(200, { "content-type": "text/event-stream", connection: "keep-alive" }); + streams.add(response); + response.on("close", () => streams.delete(response)); + response.write("data: " + JSON.stringify(event("server.connected", {})) + "\\n\\n"); + return; + } + if (url.pathname === "/session" && request.method === "POST") { + const input = await readBody(request); + if (!Array.isArray(input.permission) || input.permission.at(-1)?.permission !== "external_directory" + || process.env.OPENCODE_DISABLE_AUTOUPDATE !== "1") { + json(response, 400, { error: "missing permissions or auto-update fence" }); + return; + } + json(response, 200, { id: sessionID, version, directory }); + return; + } + if (url.pathname === "/session/" + sessionID && request.method === "GET") { + json(response, 200, { + id: mode === "wrong-session" ? "ses_other" : sessionID, + version, + directory, + }); + return; + } + if (url.pathname === "/session/" + sessionID + "/prompt_async") { + const input = await readBody(request); + messages.set(input.messageID, { + info: { id: input.messageID, sessionID, role: "user", time: { created: Date.now() } }, + parts: input.parts, + }); + if (mode === "response-loss") { + request.socket.destroy(); + } else { + json(response, 204); + } + setImmediate(() => { + emit(event("session.status", { sessionID, status: { type: "busy" } })); + if (mode === "disconnect") { + for (const stream of streams) stream.end(); + } else if (mode === "malformed-event") { + for (const stream of streams) stream.write("data: {\\n\\n"); + } else if (mode === "oversized-event") { + for (const stream of streams) stream.write("data: " + "x".repeat(4096) + "\\n\\n"); + } else if (mode === "question") { + emit(event("question.asked", { id: "que_test", sessionID, questions: [] })); + } else if (mode === "permission") { + emit(event("permission.asked", { id: "per_test", sessionID, permission: "bash", patterns: ["*"] })); + } else if (mode === "stderr-split") { + const secret = process.env.AUTOPILOT_OPENCODE_TEST_TOKEN ?? ""; + process.stderr.write(secret.slice(0, 3)); + setTimeout(() => { + process.stderr.write(secret.slice(3)); + complete(); + }, 20); + } else if (mode === "wrong-parent") { + setTimeout(() => complete(undefined, "msg_other"), 10); + } else if (mode === "session-error") { + emit(event("session.error", { sessionID, error: { name: "ProviderError", message: "provider failed" } })); + emit(event("session.status", { sessionID, status: { type: "idle" } })); + } else if (mode === "assistant-error") { + setTimeout(() => complete({ name: "ProviderError", message: "provider failed" }), 10); + } else if (mode === "exit") { + process.exit(17); + } else if (mode === "instance-disposed") { + emit(event("server.instance.disposed", { directory })); + } else if (mode === "conflicting-terminals") { + const first = assistant(undefined, input.messageID); + const second = { + info: { ...first.info, id: "msg_assistant_2", time: { ...first.info.time, completed: Date.now() + 1 } }, + parts: first.parts, + }; + messages.set(assistantID, first); + messages.set("msg_assistant_2", second); + emit(event("message.updated", { sessionID, info: first.info })); + emit(event("message.updated", { sessionID, info: second.info })); + emit(event("session.status", { sessionID, status: { type: "idle" } })); + } else if (mode === "unrelated-events") { + const intermediate = { + info: { + id: "msg_tool_step", + sessionID, + role: "assistant", + parentID: input.messageID, + time: { created: Date.now(), completed: Date.now() }, + finish: "tool-calls", + }, + parts: [], + }; + messages.set("msg_tool_step", intermediate); + emit(event("session.status", { sessionID: "ses_other", status: { type: "idle" } })); + emit(event("message.updated", { sessionID, info: intermediate.info })); + emit(event("session.status", { sessionID, status: { type: "busy" } })); + setTimeout(() => complete(), 10); + } else if (!mode.startsWith("hang") && mode !== "natural-wins" && mode !== "abort-ack-only") { + setTimeout(() => complete(), 10); + } + }); + return; + } + const messagePrefix = "/session/" + sessionID + "/message/"; + if (url.pathname.startsWith(messagePrefix)) { + const id = url.pathname.slice(messagePrefix.length); + const value = messages.get(id); + if (!value) { + json(response, 404, { error: "missing" }); + return; + } + if (id === assistantID && mode === "fresh-conflict") { + json(response, 200, { ...value, info: { ...value.info, parentID: "msg_conflict" } }); + return; + } + if (id !== assistantID && mode === "wrong-user-content") { + json(response, 200, { ...value, parts: [{ type: "text", text: "changed" }] }); + return; + } + json(response, 200, value); + return; + } + if (url.pathname === "/session/" + sessionID + "/abort" && request.method === "POST") { + json(response, 200, true); + if (mode === "natural-wins") { + setImmediate(() => complete()); + } else if (mode !== "abort-ack-only") { + setImmediate(() => complete({ name: "MessageAbortedError", message: "Aborted" })); + } + return; + } + if (url.pathname === "/question/que_test/reject" && request.method === "POST") { + fs.writeFileSync("question-rejected", "yes"); + json(response, 204); + return; + } + if (url.pathname === "/session/" + sessionID + "/permissions/per_test" && request.method === "POST") { + const input = await readBody(request); + if (input.response === "reject") fs.writeFileSync("permission-rejected", "yes"); + json(response, 200, true); + return; + } + json(response, 404, { error: "not found", path: url.pathname }); +}); +if (mode.includes("ignore-term")) { + fs.writeFileSync("fake.pid", String(process.pid)); + process.on("SIGTERM", () => { + fs.writeFileSync("sigterm-seen", "yes"); + process.exit(0); + }); +} +server.listen(0, "127.0.0.1", () => { + const address = server.address(); + process.stdout.write("opencode server listening on http://127.0.0.1:" + address.port + "\\n"); +}); +`; + +function request( + worktreePath: string, + attemptId: string, + role: ExecutionRequest["role"] = "implementation", + timeoutMs = 10_000, +): ExecutionRequest { + return { + protocolVersion: 1, + role, + runId: "run", + itemId: "item", + attemptId, + worktreePath, + objective: "test OpenCode server", + acceptanceSummary: "test", + context: attemptContextFixture(attemptId), + contextHash: `context-${attemptId}`, + writableRoots: ["."], + grants: [{ family: "credentials.use", actor: "adapter", environmentNames: ["AUTOPILOT_OPENCODE_TEST_TOKEN"] }], + deadline: new Date(Date.now() + timeoutMs).toISOString(), + idleTimeoutMs: Math.min(500, timeoutMs), + maximumLineBytes: 2_048, + maximumOutputBytes: 4_096, + }; +} + +async function createAdapter(mode: string): Promise<{ + readonly adapter: OpenCodeServerAdapter; + readonly worktreePath: string; +}> { + const worktreePath = await mkdtemp(join(tmpdir(), "autopilot-opencode-server-")); + const executable = await writeNodeExecutable(worktreePath, "opencode", FAKE_OPENCODE.replace('"MODE"', JSON.stringify(mode))); + const reviewAdapter = new CliHarnessAdapter({ + name: "opencode", + executable, + versionArguments: ["--version"], + buildArguments: () => ["run"], + assurance: "cooperative", + maxConcurrency: 1, + cancellation: true, + limitations: [], + expectsJsonLines: false, + }); + return { + worktreePath, + adapter: new OpenCodeServerAdapter({ executable, reviewAdapter }), + }; +} + +function isExecutionUnknown(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "EXECUTION_STATE_UNKNOWN"; +} + +test("OpenCode server admits one exact session and reconciles completion", async () => { + process.env.AUTOPILOT_OPENCODE_TEST_TOKEN = "opencode-test-secret"; + try { + const { adapter, worktreePath } = await createAdapter("complete"); + + const manifest = await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "complete")); + const observation = await adapter.observe(handle); + + assert.equal(manifest.adapterName, "opencode-server"); + assert.equal(manifest.restartReattachment, false); + assert.deepEqual(manifest.executionAssurance?.implementation, { + schemaVersion: 1, + owner: "harness", + continuity: "same-harness-instance", + terminality: "cooperative", + admission: "single-shot", + }); + assert.equal(handle.subject?.backendId, "opencode-server@1.18.25"); + assert.ok(handle.subject?.harnessInstanceId); + assert.equal(observation.status, "completed"); + assert.match(observation.stdout, /completed secret=\*\*\*\*/); + assert.doesNotMatch(observation.stdout, /opencode-test-secret/); + } finally { + delete process.env.AUTOPILOT_OPENCODE_TEST_TOKEN; + } +}); + +test("OpenCode server accepts cancellation only after exact MessageAbortedError reconciliation", async () => { + const { adapter, worktreePath } = await createAdapter("hang"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "cancel")); + + 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("OpenCode server lets natural completion win cancellation", async () => { + const { adapter, worktreePath } = await createAdapter("natural-wins"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "natural-wins")); + + const cancellation = await adapter.cancel(handle); + const observation = await adapter.observe(handle); + + assert.equal(cancellation.accepted, false); + assert.equal(observation.status, "completed"); +}); + +test("OpenCode server rejects wrong subject identities", async () => { + const { adapter, worktreePath } = await createAdapter("hang"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "wrong-subject", "implementation", 5_000)); + assert.ok(handle.subject); + const wrong = { + ...handle, + subject: { ...handle.subject, backendId: "opencode-server@other" }, + }; + + assert.deepEqual(await adapter.cancel(wrong), { protocolVersion: 1, accepted: false }); + await assert.rejects(adapter.observe(wrong), isExecutionUnknown); + assert.deepEqual(await adapter.cancel(handle), { protocolVersion: 1, accepted: true }); + assert.equal((await adapter.observe(handle)).status, "cancelled"); +}); + +for (const [mode, label] of [ + ["disconnect", "event-stream loss"], + ["malformed-event", "malformed SSE JSON"], + ["oversized-event", "oversized SSE events"], + ["wrong-parent", "wrong parent identity"], + ["fresh-conflict", "fresh message conflicts"], + ["wrong-session", "fresh session conflicts"], + ["wrong-user-content", "fresh user-message content conflicts"], + ["conflicting-terminals", "conflicting terminal assistants"], + ["session-error", "session errors without a fresh terminal message"], + ["exit", "server-process loss"], + ["instance-disposed", "worktree-instance disposal"], +] as const) { + test(`OpenCode server fails closed on ${label}`, async () => { + const { adapter, worktreePath } = await createAdapter(mode); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, mode)); + + await assert.rejects(adapter.observe(handle), isExecutionUnknown); + }); +} + +test("OpenCode server ignores unrelated events and nonterminal tool-step assistants", async () => { + const { adapter, worktreePath } = await createAdapter("unrelated-events"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "unrelated-events")); + + const observation = await adapter.observe(handle); + + assert.equal(observation.status, "completed"); +}); + +test("OpenCode server maps a fresh exact assistant error to failure", async () => { + const { adapter, worktreePath } = await createAdapter("assistant-error"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "assistant-error")); + + const observation = await adapter.observe(handle); + + assert.equal(observation.status, "failed"); + assert.match(observation.stderr, /provider failed/); +}); + +for (const [mode, marker] of [ + ["question", "question-rejected"], + ["permission", "permission-rejected"], +] as const) { + test(`OpenCode server rejects unattended ${mode} requests and fails closed`, async () => { + const { adapter, worktreePath } = await createAdapter(mode); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, mode)); + + await assert.rejects(adapter.observe(handle), isExecutionUnknown); + assert.equal(await readFile(join(worktreePath, marker), "utf8"), "yes"); + }); +} + +test("OpenCode server treats prompt response loss as unknown admission", async () => { + const { adapter, worktreePath } = await createAdapter("response-loss"); + await adapter.describe(); + + await assert.rejects(adapter.launch(request(worktreePath, "response-loss")), isExecutionUnknown); +}); + +for (const [mode, label] of [ + ["wrong-version", "observed version"], + ["wrong-path", "observed worktree"], +] as const) { + test(`OpenCode server rejects admission with a changed ${label}`, async () => { + const { adapter, worktreePath } = await createAdapter(mode); + await adapter.describe(); + + await assert.rejects(adapter.launch(request(worktreePath, mode)), isExecutionUnknown); + }); +} + +test("OpenCode server does not accept abort acknowledgment without an exact aborted message", async () => { + const { adapter, worktreePath } = await createAdapter("abort-ack-only"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "abort-ack-only", "implementation", 1_000)); + const observation = adapter.observe(handle); + + await assert.rejects(adapter.cancel(handle), isExecutionUnknown); + await assert.rejects(observation, isExecutionUnknown); +}); + +test("OpenCode server redacts secrets split across stderr chunks", async () => { + process.env.AUTOPILOT_OPENCODE_TEST_TOKEN = "abcdef"; + try { + const { adapter, worktreePath } = await createAdapter("stderr-split"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "stderr-split")); + + const observation = await adapter.observe(handle); + + assert.equal(observation.status, "completed"); + assert.equal(observation.stderr, "****"); + assert.doesNotMatch(observation.stdout, /abcdef/); + } finally { + delete process.env.AUTOPILOT_OPENCODE_TEST_TOKEN; + } +}); + +test("OpenCode server awaits cleanup after idle continuity loss", { + skip: process.platform === "win32", +}, async () => { + const { adapter, worktreePath } = await createAdapter("hang-ignore-term"); + await adapter.describe(); + const baseRequest = request(worktreePath, "idle-cleanup", "implementation", 5_000); + const handle = await adapter.launch({ ...baseRequest, idleTimeoutMs: 120 }); + + await assert.rejects(adapter.observe(handle), isExecutionUnknown); + await delay(30); + assert.equal(await readFile(join(worktreePath, "sigterm-seen"), "utf8"), "yes"); + const pid = Number.parseInt(await readFile(join(worktreePath, "fake.pid"), "utf8"), 10); + assert.throws(() => process.kill(pid, 0), (error: unknown) => + error instanceof Error && "code" in error && error.code === "ESRCH" + ); +}); + +test("OpenCode server fails closed at the attempt deadline", async () => { + const { adapter, worktreePath } = await createAdapter("hang"); + await adapter.describe(); + const baseRequest = request(worktreePath, "deadline", "implementation", 1_000); + const handle = await adapter.launch({ ...baseRequest, idleTimeoutMs: 5_000 }); + + await assert.rejects(adapter.observe(handle), isExecutionUnknown); +}); + +test("OpenCode server awaits cleanup after normal completion", { + skip: process.platform === "win32", +}, async () => { + const { adapter, worktreePath } = await createAdapter("complete-ignore-term"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "complete-cleanup")); + + const observation = await adapter.observe(handle); + + assert.equal(observation.status, "completed"); + const pid = Number.parseInt(await readFile(join(worktreePath, "fake.pid"), "utf8"), 10); + assert.throws(() => process.kill(pid, 0), (error: unknown) => + error instanceof Error && "code" in error && error.code === "ESRCH" + ); +}); + +test("OpenCode server preserves direct OpenCode review", async () => { + const { adapter, worktreePath } = await createAdapter("complete"); + const manifest = await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "review", "review")); + + const observation = await adapter.observe(handle); + + assert.equal(manifest.executionAssurance?.review.owner, "runtime"); + assert.equal(observation.status, "completed"); + assert.equal(observation.stdout.trim(), "direct review"); +});