From 8deba48c30e52aee3395b890a0a98ba2c010a5bf Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Wed, 8 Jul 2026 00:26:00 +0200 Subject: [PATCH 01/32] feat: convert Codex plugin to OpenCode plugin (Phase 0+1) Convert the Codex Claude Code plugin into an OpenCode plugin that delegates work to a headless `opencode serve` over HTTP + SSE. Phase 0 (scaffold): - Move plugins/codex -> plugins/opencode; rename manifests, env vars (OPENCODE_COMPANION_*), commands, agent, and skills. - Remove the Codex app-server type-generation build (prebuild/build, tsconfig.app-server.json, app-server-protocol.d.ts) and its CI step; drop the now-unused typescript devDependency. Phase 1 (runtime bridge): - Replace the Codex app-server + Unix-socket broker with OpenCode's native server: server-lifecycle.mjs (spawn/reuse/teardown `opencode serve`, health-poll startup), opencode-server.mjs (zero-dep fetch + SSE client), opencode.mjs (SSE turn capture). - Read-only via the `plan` agent; write turns auto-approve permissions via a wildcard rule; models map spark -> openai/gpt-5.3-codex-spark and provider/model -> {providerID, modelID} on the message. - Do not send `directory`/`model` on create-session (real server 400s); permission replies use {response: always|reject}. - Fake `opencode serve` test harness; 30/30 tests green. Verified live against a real `opencode serve`: write turn (edit auto-approved, file written), read-only turn (no edits), sessions visible in `opencode session list`, progress streamed to the Claude TUI. transfer remains stubbed (Phase 3). See IMPLEMENTATION_NOTES.md and docs/opencode-adaptation-plan.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VT7Nv9u8ckVJ5vRxzvegiw --- .claude-plugin/marketplace.json | 14 +- .github/workflows/pull-request-ci.yml | 3 - .gitignore | 4 +- IMPLEMENTATION_NOTES.md | 35 + README.md | 211 +- docs/opencode-adaptation-plan.md | 181 ++ package-lock.json | 21 +- package.json | 9 +- plugins/codex/.claude-plugin/plugin.json | 8 - plugins/codex/CHANGELOG.md | 5 - plugins/codex/commands/cancel.md | 8 - plugins/codex/commands/rescue.md | 49 - plugins/codex/commands/setup.md | 37 - plugins/codex/scripts/app-server-broker.mjs | 252 -- .../scripts/lib/app-server-protocol.d.ts | 75 - plugins/codex/scripts/lib/app-server.mjs | 354 --- plugins/codex/scripts/lib/broker-endpoint.mjs | 41 - .../codex/scripts/lib/broker-lifecycle.mjs | 209 -- plugins/codex/scripts/lib/codex.mjs | 1219 --------- .../skills/codex-result-handling/SKILL.md | 21 - plugins/opencode/.claude-plugin/plugin.json | 8 + plugins/opencode/CHANGELOG.md | 5 + plugins/{codex => opencode}/LICENSE | 0 plugins/{codex => opencode}/NOTICE | 0 .../agents/opencode-rescue.md} | 32 +- .../commands/adversarial-review.md | 18 +- plugins/opencode/commands/cancel.md | 8 + plugins/opencode/commands/rescue.md | 49 + .../{codex => opencode}/commands/result.md | 6 +- .../{codex => opencode}/commands/review.md | 18 +- plugins/opencode/commands/setup.md | 37 + .../{codex => opencode}/commands/status.md | 4 +- .../{codex => opencode}/commands/transfer.md | 6 +- plugins/{codex => opencode}/hooks/hooks.json | 2 +- .../prompts/adversarial-review.md | 2 +- .../prompts/stop-review-gate.md | 2 +- .../schemas/review-output.schema.json | 0 .../{codex => opencode}/scripts/lib/args.mjs | 0 .../scripts/lib/claude-session-transfer.mjs | 4 +- .../{codex => opencode}/scripts/lib/fs.mjs | 2 +- .../{codex => opencode}/scripts/lib/git.mjs | 0 .../scripts/lib/job-control.mjs | 22 +- .../opencode/scripts/lib/opencode-server.mjs | 248 ++ plugins/opencode/scripts/lib/opencode.mjs | 920 +++++++ .../scripts/lib/process.mjs | 0 .../scripts/lib/prompts.mjs | 0 .../scripts/lib/render.mjs | 70 +- .../opencode/scripts/lib/server-lifecycle.mjs | 266 ++ .../{codex => opencode}/scripts/lib/state.mjs | 2 +- .../scripts/lib/tracked-jobs.mjs | 4 +- .../scripts/lib/workspace.mjs | 0 .../scripts/opencode-companion.mjs} | 194 +- .../scripts/session-lifecycle-hook.mjs | 48 +- .../scripts/stop-review-gate-hook.mjs | 24 +- .../skills/gpt-5-4-prompting/SKILL.md | 20 +- .../references/codex-prompt-antipatterns.md | 4 +- .../references/codex-prompt-recipes.md | 8 +- .../references/prompt-blocks.md | 6 +- .../skills/opencode-cli-runtime}/SKILL.md | 28 +- .../skills/opencode-result-handling/SKILL.md | 21 + scripts/bump-version.mjs | 8 +- tests/broker-endpoint.test.mjs | 22 - tests/bump-version.test.mjs | 18 +- tests/commands.test.mjs | 231 +- tests/fake-opencode-fixture.mjs | 283 ++ tests/git.test.mjs | 2 +- tests/helpers.mjs | 4 +- tests/process.test.mjs | 2 +- tests/render.test.mjs | 14 +- tests/runtime.test.mjs | 2329 +---------------- tests/server-lifecycle.test.mjs | 53 + tests/state.test.mjs | 140 +- tsconfig.app-server.json | 23 - 73 files changed, 2727 insertions(+), 5246 deletions(-) create mode 100644 IMPLEMENTATION_NOTES.md create mode 100644 docs/opencode-adaptation-plan.md delete mode 100644 plugins/codex/.claude-plugin/plugin.json delete mode 100644 plugins/codex/CHANGELOG.md delete mode 100644 plugins/codex/commands/cancel.md delete mode 100644 plugins/codex/commands/rescue.md delete mode 100644 plugins/codex/commands/setup.md delete mode 100644 plugins/codex/scripts/app-server-broker.mjs delete mode 100644 plugins/codex/scripts/lib/app-server-protocol.d.ts delete mode 100644 plugins/codex/scripts/lib/app-server.mjs delete mode 100644 plugins/codex/scripts/lib/broker-endpoint.mjs delete mode 100644 plugins/codex/scripts/lib/broker-lifecycle.mjs delete mode 100644 plugins/codex/scripts/lib/codex.mjs delete mode 100644 plugins/codex/skills/codex-result-handling/SKILL.md create mode 100644 plugins/opencode/.claude-plugin/plugin.json create mode 100644 plugins/opencode/CHANGELOG.md rename plugins/{codex => opencode}/LICENSE (100%) rename plugins/{codex => opencode}/NOTICE (100%) rename plugins/{codex/agents/codex-rescue.md => opencode/agents/opencode-rescue.md} (54%) rename plugins/{codex => opencode}/commands/adversarial-review.md (77%) create mode 100644 plugins/opencode/commands/cancel.md create mode 100644 plugins/opencode/commands/rescue.md rename plugins/{codex => opencode}/commands/result.md (62%) rename plugins/{codex => opencode}/commands/review.md (76%) create mode 100644 plugins/opencode/commands/setup.md rename plugins/{codex => opencode}/commands/status.md (77%) rename plugins/{codex => opencode}/commands/transfer.md (56%) rename plugins/{codex => opencode}/hooks/hooks.json (91%) rename plugins/{codex => opencode}/prompts/adversarial-review.md (98%) rename plugins/{codex => opencode}/prompts/stop-review-gate.md (95%) rename plugins/{codex => opencode}/schemas/review-output.schema.json (100%) rename plugins/{codex => opencode}/scripts/lib/args.mjs (100%) rename plugins/{codex => opencode}/scripts/lib/claude-session-transfer.mjs (88%) rename plugins/{codex => opencode}/scripts/lib/fs.mjs (94%) rename plugins/{codex => opencode}/scripts/lib/git.mjs (100%) rename plugins/{codex => opencode}/scripts/lib/job-control.mjs (91%) create mode 100644 plugins/opencode/scripts/lib/opencode-server.mjs create mode 100644 plugins/opencode/scripts/lib/opencode.mjs rename plugins/{codex => opencode}/scripts/lib/process.mjs (100%) rename plugins/{codex => opencode}/scripts/lib/prompts.mjs (100%) rename plugins/{codex => opencode}/scripts/lib/render.mjs (85%) create mode 100644 plugins/opencode/scripts/lib/server-lifecycle.mjs rename plugins/{codex => opencode}/scripts/lib/state.mjs (98%) rename plugins/{codex => opencode}/scripts/lib/tracked-jobs.mjs (97%) rename plugins/{codex => opencode}/scripts/lib/workspace.mjs (100%) rename plugins/{codex/scripts/codex-companion.mjs => opencode/scripts/opencode-companion.mjs} (81%) rename plugins/{codex => opencode}/scripts/session-lifecycle-hook.mjs (74%) rename plugins/{codex => opencode}/scripts/stop-review-gate-hook.mjs (77%) rename plugins/{codex => opencode}/skills/gpt-5-4-prompting/SKILL.md (66%) rename plugins/{codex => opencode}/skills/gpt-5-4-prompting/references/codex-prompt-antipatterns.md (94%) rename plugins/{codex => opencode}/skills/gpt-5-4-prompting/references/codex-prompt-recipes.md (91%) rename plugins/{codex => opencode}/skills/gpt-5-4-prompting/references/prompt-blocks.md (95%) rename plugins/{codex/skills/codex-cli-runtime => opencode/skills/opencode-cli-runtime}/SKILL.md (64%) create mode 100644 plugins/opencode/skills/opencode-result-handling/SKILL.md delete mode 100644 tests/broker-endpoint.test.mjs create mode 100644 tests/fake-opencode-fixture.mjs create mode 100644 tests/server-lifecycle.test.mjs delete mode 100644 tsconfig.app-server.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5e2ea5a..01a1402 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,21 +1,21 @@ { - "name": "openai-codex", + "name": "opencode", "owner": { - "name": "OpenAI" + "name": "OpenCode" }, "metadata": { - "description": "Codex plugins to use in Claude Code for delegation and code review.", + "description": "OpenCode plugins to use in Claude Code for delegation and code review.", "version": "1.0.5" }, "plugins": [ { - "name": "codex", - "description": "Use Codex from Claude Code to review code or delegate tasks.", + "name": "opencode", + "description": "Use OpenCode from Claude Code to review code or delegate tasks.", "version": "1.0.5", "author": { - "name": "OpenAI" + "name": "OpenCode" }, - "source": "./plugins/codex" + "source": "./plugins/opencode" } ] } diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index ebcff0b..8a36be1 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -30,6 +30,3 @@ jobs: - name: Run test suite run: npm test - - - name: Run build - run: npm run build diff --git a/.gitignore b/.gitignore index 3d573ee..016526c 100644 --- a/.gitignore +++ b/.gitignore @@ -147,4 +147,6 @@ vite.config.ts.timestamp-* .vite/ output/ -plugins/codex/.generated/ + +# Local Codex rescue-subagent config (not part of the OpenCode plugin) +.codex/ diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md new file mode 100644 index 0000000..0bbf44d --- /dev/null +++ b/IMPLEMENTATION_NOTES.md @@ -0,0 +1,35 @@ +# Implementation Notes + +## Status: Phase 0 + Phase 1 complete and verified live + +### Phase 0 — scaffold (done) +- Plugin moved to `plugins/opencode`; manifests/package metadata updated; `OPENCODE_COMPANION_*` env vars adopted; Codex app-server type generation and TS build removed. + +### Phase 1 — runtime bridge (done, verified against a real `opencode serve`) +- `server-lifecycle.mjs` (ensure/reuse/teardown a headless `opencode serve`, health-poll startup, `OPENCODE_COMPANION_SERVER_URL` override), `opencode-server.mjs` (zero-dep fetch + SSE client), `opencode.mjs` (SSE turn capture). Codex app-server broker files deleted. +- Fake `opencode serve` test harness with HTTP/SSE endpoints and permission prompts. + +### Fixes applied after the initial implementation (all verified) +The first pass shipped unrun (the build sandbox couldn't bind 127.0.0.1). Running against a real server surfaced and fixed: +1. **create-session body** — removed `directory` and `model` (the real `POST /session` returns HTTP 400 on both; `additionalProperties:false`, and `model` is sent per-message). `title` included only when a non-empty string. +2. **Headless permission auto-approval** — write sessions attach `{permission:"*",action:"allow",pattern:"*"}`; read-only uses the built-in `plan` agent. `respondPermission` posts `{ response: "always" | "reject" }` (the endpoint enum is once|always|reject, additionalProperties:false). +3. **Model normalization** — `spark` → `openai/gpt-5.3-codex-spark`, `provider/model` → `{providerID,modelID}`; bare/partial model → null (avoids a 400). +4. **Final message** — reasoning parts excluded so the answer isn't prefixed with reasoning text. +5. **`extractPermissionId`** — prefers the permission id over the `evt_` envelope id. +6. **`session.error`** — extracts a readable message instead of `[object Object]`. +7. **Fake fixture** — `/event` flushes SSE headers; create-session now rejects `directory`/`model`, and the permission endpoint enforces the `response` enum, so the tests guard these regressions. + +### Live verification (real `opencode serve`, this machine) +- Write turn: edit auto-approved with no permission stall; `smoke.txt` written. ✓ +- Read-only turn: clean answer, no edits (read-only respected). ✓ +- Created sessions visible in `opencode session list` for the project dir (characteristic #2). ✓ +- Progress streamed to the Claude TUI (Starting / Session ready / Edited / Assistant message / Turn completed). ✓ +- `npm test` — 30/30 green. ✓ +- Codex read-only review of the fixes: all 7 confirmed correct; LOW findings (title/model guards, fixture schema enforcement) applied. + +## Deferred to Phase 2 +- **`ensureServer` has no inter-process lock** — concurrent plugin commands could spawn duplicate `opencode serve` processes and orphan one. Needs a lock file (or single-flight) around load/spawn/save. LOW (commands rarely overlap; tests run sequentially). +- **`transfer`** — still stubbed (`OpenCode transfer is not implemented in Phase 1`). Phase 3: replay a Claude JSONL transcript into an OpenCode session via `noReply`. +- **Background cancel** — `interruptServerTurn` may connect to a different server instance than the one running a backgrounded job's turn; the abort may not land. +- **Review sessions persist** — read-only review sessions are not deleted; acceptable, or delete for ephemeral parity. +- **Housekeeping** — `typescript` devDep is now unused (build step removed); add `.codex/` to `.gitignore` (local Codex-subagent config, not part of the plugin). diff --git a/README.md b/README.md index 937a303..9d35c3c 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@ -# Codex plugin for Claude Code +# OpenCode plugin for Claude Code -Use Codex from inside Claude Code for code reviews or to delegate tasks to Codex. +Use OpenCode from inside Claude Code for code reviews or to delegate tasks to OpenCode. -This plugin is for Claude Code users who want an easy way to start using Codex from the workflow +This plugin is for Claude Code users who want an easy way to start using OpenCode from the workflow they already have. ## What You Get -- `/codex:review` for a normal read-only Codex review -- `/codex:adversarial-review` for a steerable challenge review -- `/codex:rescue`, `/codex:transfer`, `/codex:status`, `/codex:result`, and `/codex:cancel` to delegate work, hand off sessions, and manage background jobs +- `/opencode:review` for a normal read-only OpenCode review +- `/opencode:adversarial-review` for a steerable challenge review +- `/opencode:rescue`, `/opencode:transfer`, `/opencode:status`, `/opencode:result`, and `/opencode:cancel` to delegate work, hand off sessions, and manage background jobs ## Requirements -- **ChatGPT subscription (incl. Free) or OpenAI API key.** - - Usage will contribute to your Codex usage limits. [Learn more](https://developers.openai.com/codex/pricing). +- **A working local OpenCode install and provider configuration.** + - Usage is billed by whichever OpenCode provider/model you select. - **Node.js 18.18 or later** ## Install @@ -24,13 +24,13 @@ they already have. Add the marketplace in Claude Code: ```bash -/plugin marketplace add openai/codex-plugin-cc +/plugin marketplace add opencode ``` Install the plugin: ```bash -/plugin install codex@openai-codex +/plugin install opencode@opencode ``` Reload plugins: @@ -42,41 +42,37 @@ Reload plugins: Then run: ```bash -/codex:setup +/opencode:setup ``` -`/codex:setup` will tell you whether Codex is ready. If Codex is missing and npm is available, it can offer to install Codex for you. +`/opencode:setup` will tell you whether OpenCode is ready. If OpenCode is missing and npm is available, it can offer to install OpenCode for you. -If you prefer to install Codex yourself, use: +If you prefer to install OpenCode yourself, use: ```bash -npm install -g @openai/codex +npm install -g opencode-ai ``` -If Codex is installed but not logged in yet, run: - -```bash -!codex login -``` +If OpenCode is installed but no provider is configured yet, follow your OpenCode provider setup flow and rerun `/opencode:setup`. After install, you should see: - the slash commands listed below -- the `codex:codex-rescue` subagent in `/agents` +- the `opencode:opencode-rescue` subagent in `/agents` One simple first run is: ```bash -/codex:review --background -/codex:status -/codex:result +/opencode:review --background +/opencode:status +/opencode:result ``` ## Usage -### `/codex:review` +### `/opencode:review` -Runs a normal Codex review on your current work. It gives you the same quality of code review as running `/review` inside Codex directly. +Runs a normal OpenCode review on your current work. It gives you the same quality of code review as running `/review` inside OpenCode directly. > [!NOTE] > Code review especially for multi-file changes might take a while. It's generally recommended to run it in the background. @@ -86,26 +82,26 @@ Use it when you want: - a review of your current uncommitted changes - a review of your branch compared to a base branch like `main` -Use `--base ` for branch review. It also supports `--wait` and `--background`. It is not steerable and does not take custom focus text. Use [`/codex:adversarial-review`](#codexadversarial-review) when you want to challenge a specific decision or risk area. +Use `--base ` for branch review. It also supports `--wait` and `--background`. It is not steerable and does not take custom focus text. Use [`/opencode:adversarial-review`](#opencodeadversarial-review) when you want to challenge a specific decision or risk area. Examples: ```bash -/codex:review -/codex:review --base main -/codex:review --background +/opencode:review +/opencode:review --base main +/opencode:review --background ``` -This command is read-only and will not perform any changes. When run in the background you can use [`/codex:status`](#codexstatus) to check on the progress and [`/codex:cancel`](#codexcancel) to cancel the ongoing task. +This command is read-only and will not perform any changes. When run in the background you can use [`/opencode:status`](#opencodestatus) to check on the progress and [`/opencode:cancel`](#opencodecancel) to cancel the ongoing task. -### `/codex:adversarial-review` +### `/opencode:adversarial-review` Runs a **steerable** review that questions the chosen implementation and design. It can be used to pressure-test assumptions, tradeoffs, failure modes, and whether a different approach would have been safer or simpler. -It uses the same review target selection as `/codex:review`, including `--base ` for branch review. -It also supports `--wait` and `--background`. Unlike `/codex:review`, it can take extra focus text after the flags. +It uses the same review target selection as `/opencode:review`, including `--base ` for branch review. +It also supports `--wait` and `--background`. Unlike `/opencode:review`, it can take extra focus text after the flags. Use it when you want: @@ -116,22 +112,22 @@ Use it when you want: Examples: ```bash -/codex:adversarial-review -/codex:adversarial-review --base main challenge whether this was the right caching and retry design -/codex:adversarial-review --background look for race conditions and question the chosen approach +/opencode:adversarial-review +/opencode:adversarial-review --base main challenge whether this was the right caching and retry design +/opencode:adversarial-review --background look for race conditions and question the chosen approach ``` This command is read-only. It does not fix code. -### `/codex:rescue` +### `/opencode:rescue` -Hands a task to Codex through the `codex:codex-rescue` subagent. +Hands a task to OpenCode through the `opencode:opencode-rescue` subagent. -Use it when you want Codex to: +Use it when you want OpenCode to: - investigate a bug - try a fix -- continue a previous Codex task +- continue a previous OpenCode task - take a faster or cheaper pass with a smaller model > [!NOTE] @@ -142,50 +138,50 @@ It supports `--background`, `--wait`, `--resume`, and `--fresh`. If you omit `-- Examples: ```bash -/codex:rescue investigate why the tests started failing -/codex:rescue fix the failing test with the smallest safe patch -/codex:rescue --resume apply the top fix from the last run -/codex:rescue --model gpt-5.4-mini --effort medium investigate the flaky integration test -/codex:rescue --model spark fix the issue quickly -/codex:rescue --background investigate the regression +/opencode:rescue investigate why the tests started failing +/opencode:rescue fix the failing test with the smallest safe patch +/opencode:rescue --resume apply the top fix from the last run +/opencode:rescue --model openai/gpt-5.4-mini --effort high investigate the flaky integration test +/opencode:rescue --model spark fix the issue quickly +/opencode:rescue --background investigate the regression ``` -You can also just ask for a task to be delegated to Codex: +You can also just ask for a task to be delegated to OpenCode: ```text -Ask Codex to redesign the database connection to be more resilient. +Ask OpenCode to redesign the database connection to be more resilient. ``` **Notes:** -- if you do not pass `--model` or `--effort`, Codex chooses its own defaults. -- if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark` -- follow-up rescue requests can continue the latest Codex task in the repo +- if you do not pass `--model` or `--effort`, OpenCode chooses its own defaults. +- if you say `spark`, the plugin maps that to `openai/gpt-5.3-codex-spark` +- follow-up rescue requests can continue the latest OpenCode task in the repo -### `/codex:transfer` +### `/opencode:transfer` -Creates a persistent Codex thread from the current Claude Code session and prints a `codex resume ` command. +Transfer is planned for the next phase of the OpenCode port. The command is present but currently reports that transfer is not implemented in Phase 1. -Use it when you started a debugging or implementation conversation in Claude Code and want to continue that same context directly in Codex. +Once implemented, use it when you started a debugging or implementation conversation in Claude Code and want to continue that same context directly in OpenCode. Examples: ```bash -/codex:transfer -/codex:transfer --source ~/.claude/projects/-Users-me-repo/.jsonl +/opencode:transfer +/opencode:transfer --source ~/.claude/projects/-Users-me-repo/.jsonl ``` -The plugin's existing `SessionStart` hook supplies the current transcript path automatically; `--source` is available as a manual override. The transfer uses Codex's external-agent session importer, so it follows the same conversion rules as importing Claude history in the Codex App and creates visible turns that can be continued in the App or TUI. The source must be under `~/.claude/projects`, and older Codex versions that do not expose session import must be upgraded before using this command. +The plugin's existing `SessionStart` hook supplies the current transcript path automatically; `--source` is available as a manual override. -### `/codex:status` +### `/opencode:status` -Shows running and recent Codex jobs for the current repository. +Shows running and recent OpenCode jobs for the current repository. Examples: ```bash -/codex:status -/codex:status task-abc123 +/opencode:status +/opencode:status task-abc123 ``` Use it to: @@ -194,127 +190,120 @@ Use it to: - see the latest completed job - confirm whether a task is still running -### `/codex:result` +### `/opencode:result` -Shows the final stored Codex output for a finished job. -When available, it also includes the Codex session ID so you can reopen that run directly in Codex with `codex resume `. +Shows the final stored OpenCode output for a finished job. +When available, it also includes the OpenCode session ID so you can reopen that run directly in OpenCode with `opencode --session `. Examples: ```bash -/codex:result -/codex:result task-abc123 +/opencode:result +/opencode:result task-abc123 ``` -### `/codex:cancel` +### `/opencode:cancel` -Cancels an active background Codex job. +Cancels an active background OpenCode job. Examples: ```bash -/codex:cancel -/codex:cancel task-abc123 +/opencode:cancel +/opencode:cancel task-abc123 ``` -### `/codex:setup` +### `/opencode:setup` -Checks whether Codex is installed and authenticated. -If Codex is missing and npm is available, it can offer to install Codex for you. +Checks whether OpenCode is installed and authenticated. +If OpenCode is missing and npm is available, it can offer to install OpenCode for you. -You can also use `/codex:setup` to manage the optional review gate. +You can also use `/opencode:setup` to manage the optional review gate. #### Enabling review gate ```bash -/codex:setup --enable-review-gate -/codex:setup --disable-review-gate +/opencode:setup --enable-review-gate +/opencode:setup --disable-review-gate ``` -When the review gate is enabled, the plugin uses a `Stop` hook to run a targeted Codex review based on Claude's response. If that review finds issues, the stop is blocked so Claude can address them first. +When the review gate is enabled, the plugin uses a `Stop` hook to run a targeted OpenCode review based on Claude's response. If that review finds issues, the stop is blocked so Claude can address them first. > [!WARNING] -> The review gate can create a long-running Claude/Codex loop and may drain usage limits quickly. Only enable it when you plan to actively monitor the session. +> The review gate can create a long-running Claude/OpenCode loop and may drain usage limits quickly. Only enable it when you plan to actively monitor the session. ## Typical Flows ### Review Before Shipping ```bash -/codex:review +/opencode:review ``` -### Hand A Problem To Codex +### Hand A Problem To OpenCode ```bash -/codex:rescue investigate why the build is failing in CI +/opencode:rescue investigate why the build is failing in CI ``` ### Start Something Long-Running ```bash -/codex:adversarial-review --background -/codex:rescue --background investigate the flaky test +/opencode:adversarial-review --background +/opencode:rescue --background investigate the flaky test ``` Then check in with: ```bash -/codex:status -/codex:result +/opencode:status +/opencode:result ``` -## Codex Integration +## OpenCode Integration -The Codex plugin wraps the [Codex app server](https://developers.openai.com/codex/app-server). It uses the global `codex` binary installed in your environment and [applies the same configuration](https://developers.openai.com/codex/config-basic). +The OpenCode plugin wraps a local `opencode serve` process. It uses the global `opencode` binary installed in your environment and OpenCode's normal provider/configuration state. ### Common Configurations -If you want to change the default reasoning effort or the default model that gets used by the plugin, you can define that inside your user-level or project-level `config.toml`. For example to always use `gpt-5.4-mini` on `high` for a specific project you can add the following to a `.codex/config.toml` file at the root of the directory you started Claude in: +If you want to change the default model used by the plugin, configure it in OpenCode. You can also pass a provider/model pair explicitly: -```toml -model = "gpt-5.4-mini" -model_reasoning_effort = "high" +```bash +/opencode:rescue --model openai/gpt-5.4-mini --effort high investigate the flaky integration test ``` -Your configuration will be picked up based on: - -- user-level config in `~/.codex/config.toml` -- project-level overrides in `.codex/config.toml` -- project-level overrides only load when the [project is trusted](https://developers.openai.com/codex/config-advanced#project-config-files-codexconfigtoml) - -Check out the Codex docs for more [configuration options](https://developers.openai.com/codex/config-reference). +`--effort` is forwarded to OpenCode as the provider-specific message `variant`. -### Moving The Work Over To Codex +### Moving The Work Over To OpenCode -Delegated tasks and any [stop gate](#what-does-the-review-gate-do) run can also be directly resumed inside Codex by running `codex resume` either with the specific session ID you received from running `/codex:result` or `/codex:status` or by selecting it from the list. +Delegated tasks and any [stop gate](#what-does-the-review-gate-do) run can also be directly resumed inside OpenCode by running `opencode --session ` with the specific session ID you received from `/opencode:result` or `/opencode:status`, or by selecting it from the list. -This way you can review the Codex work or continue the work there. +This way you can review the OpenCode work or continue the work there. ## FAQ -### Do I need a separate Codex account for this plugin? +### Do I need a separate OpenCode account for this plugin? -If you are already signed into Codex on this machine, that account should work immediately here too. This plugin uses your local Codex CLI authentication. +If you are already signed into OpenCode on this machine, that account should work immediately here too. This plugin uses your local OpenCode CLI authentication. -If you only use Claude Code today and have not used Codex yet, you will also need to sign in to Codex with either a ChatGPT account or an API key. [Codex is available with your ChatGPT subscription](https://developers.openai.com/codex/pricing/), and [`codex login`](https://developers.openai.com/codex/cli/reference/#codex-login) supports both ChatGPT and API key sign-in. Run `/codex:setup` to check whether Codex is ready, and use `!codex login` if it is not. +If you only use Claude Code today and have not used OpenCode yet, configure an OpenCode provider first. Run `/opencode:setup` to check whether OpenCode is ready. -### Does the plugin use a separate Codex runtime? +### Does the plugin use a separate OpenCode runtime? -No. This plugin delegates through your local [Codex CLI](https://developers.openai.com/codex/cli/) and [Codex app server](https://developers.openai.com/codex/app-server/) on the same machine. +No. This plugin delegates through your local OpenCode CLI and a headless `opencode serve` process on the same machine. That means: -- it uses the same Codex install you would use directly +- it uses the same OpenCode install you would use directly - it uses the same local authentication state - it uses the same repository checkout and machine-local environment -### Will it use the same Codex config I already have? +### Will it use the same OpenCode config I already have? -Yes. If you already use Codex, the plugin picks up the same [configuration](#common-configurations). +Yes. If you already use OpenCode, the plugin picks up the same [configuration](#common-configurations). ### Can I keep using my current API key or base URL setup? -Yes. Because the plugin uses your local Codex CLI, your existing sign-in method and config still apply. +Yes. Because the plugin uses your local OpenCode CLI, your existing sign-in method and config still apply. -If you need to point the built-in OpenAI provider at a different endpoint, set `openai_base_url` in your [Codex config](https://developers.openai.com/codex/config-advanced/#config-and-state-locations). +If you need to point a provider at a different endpoint, configure that in OpenCode and rerun `/opencode:setup`. diff --git a/docs/opencode-adaptation-plan.md b/docs/opencode-adaptation-plan.md new file mode 100644 index 0000000..46ebd18 --- /dev/null +++ b/docs/opencode-adaptation-plan.md @@ -0,0 +1,181 @@ +# Adapting `codex-plugin-cc` to OpenCode + +Analysis of the current Codex plugin and a concrete plan to build an **`opencode` plugin for Claude Code** that delegates work to OpenCode the same way this repo delegates to Codex — with `/opencode:rescue`, `/opencode:review`, etc. + +**Verdict up front:** The approach ports cleanly, and the OpenCode version is *architecturally simpler* than the Codex one. Both target characteristics are retainable: + +1. **Subagents shown in the Claude Code TUI** — ✅ fully retained (unchanged mechanism). +2. **Subagent sessions appear automatically in the OpenCode app** — ✅ retained (with one small fidelity note about *live* vs *on-refresh*, covered below). + +--- + +## 1. How the Codex plugin works today + +The mechanism has four layers: + +| Layer | Files | Role | +|---|---|---| +| **Claude Code surface** | `commands/*.md`, `agents/codex-rescue.md`, `skills/*`, `hooks/hooks.json` | Slash commands, the `codex:codex-rescue` subagent, session-lifecycle hooks. | +| **Companion CLI** | `scripts/codex-companion.mjs` | One Node entrypoint with subcommands: `setup`, `review`, `adversarial-review`, `task`, `transfer`, `status`, `result`, `cancel`, plus internal `task-worker` / `task-resume-candidate`. | +| **Runtime bridge** | `lib/codex.mjs`, `lib/app-server.mjs`, `app-server-broker.mjs`, `lib/broker-lifecycle.mjs`, `lib/broker-endpoint.mjs` | Talks to `codex app-server` over JSON-RPC/stdio; a **broker** multiplexes one app-server across concurrent calls via a Unix socket / named pipe. | +| **Runtime-agnostic plumbing** | `lib/state.mjs`, `lib/job-control.mjs`, `lib/tracked-jobs.mjs`, `lib/git.mjs`, `lib/render.mjs`, `lib/args.mjs`, `lib/process.mjs`, `lib/workspace.mjs`, `lib/prompts.mjs`, `lib/fs.mjs` | Job state files, background workers, git target selection, output rendering, arg parsing. **Not Codex-specific.** | + +### How the two target characteristics are produced + +**(1) Subagent in the Claude Code TUI.** `/codex:rescue` (`commands/rescue.md`) routes through the Claude Code **`Agent` tool** (`subagent_type: "codex:codex-rescue"`). `agents/codex-rescue.md` defines a *thin forwarder* subagent whose only job is one `Bash` call to `codex-companion.mjs task …`. Because it's a native Claude Code subagent, Claude Code renders it as a running subagent in the TUI. **This has nothing to do with Codex** — it's pure Claude Code plumbing. + +**(2) Session appears in the Codex app.** `lib/codex.mjs` starts threads on the shared `codex app-server` with `ephemeral: false` + a thread name (`buildThreadParams` / `runAppServerTurn` with `persistThread: true`). Because the app-server writes to the same `~/.codex` session store the Codex app reads, the thread shows up in the Codex app automatically and is resumable via `codex resume `. The broker exists so *all* concurrent plugin calls share *one* app-server → one consistent session store and one auth/config context. + +Progress is surfaced by `captureTurn` in `lib/codex.mjs`: it subscribes to app-server notifications (`turn/started`, `item/started|completed`, `thread/started`, `turn/completed`, …), maps them to phases (`running`, `editing`, `verifying`, `investigating`, `finalizing`), and even surfaces Codex's *own* internal subagents (`collabAgentToolCall` / `receiverThreadIds`) as `Subagent X: …` log lines in the Claude TUI. + +--- + +## 2. OpenCode capability mapping (verified locally, `opencode 1.17.10`) + +OpenCode has a **client/server architecture** that maps onto the Codex model almost 1:1 — and removes the need for a custom broker. + +| Need | Codex | OpenCode (verified) | +|---|---|---| +| Headless runtime | `codex app-server` (JSON-RPC/stdio) | **`opencode serve`** → HTTP server on `127.0.0.1:` | +| Multiplex concurrent callers | custom Unix-socket **broker** | **built in** — the HTTP server is already multi-client | +| Create a thread/session | `thread/start` | `POST /session` → `{ id, directory, title, agent, model, … }` | +| Run a turn (blocking) | `turn/start` | `POST /session/{id}/message` → returns `{ info, parts }` when the turn completes (synchronous) | +| Fire-and-forget turn | — | `POST /session/{id}/prompt_async` | +| Stream progress | JSON-RPC notifications | **`GET /event`** (SSE); 86 event types incl. `session.next.text.*`, `.reasoning.*`, `.tool.*`, `.shell.*`, `.step.*`, `file.edited`, `session.idle`, `session.error`, `permission.asked` | +| Turn-complete signal | `turn/completed` | **`session.idle`** (carries `sessionID`) | +| Cancel | `turn/interrupt` | `POST /session/{id}/abort` | +| Resume a session | `codex resume ` | `opencode --session ` (TUI) / `opencode run --session ` / `opencode attach ` | +| List sessions | `thread/list` | `GET /session` (events carry `sessionID`; sessions carry `directory` for project scoping) | +| Structured output | `outputSchema` on turn | `format: { type: "json_schema", schema }` on message | +| Read-only vs write | `sandbox: read-only \| workspace-write` + `approvalPolicy: never` | per-turn **`agent`** (`plan` = read-only, `build` = write) and/or session **`permission`** rules (`{permission, action: allow\|ask\|deny, pattern}`) | +| Model select | `--model`, alias `spark`→`gpt-5.3-codex-spark` | `model: { providerID, modelID }`; `openai/gpt-5.3-codex-spark` etc. present in `opencode models` | +| Reasoning effort | `--effort none…xhigh` | **`variant`** (provider-specific: `minimal`/`high`/`max`, …) | +| Append history w/o a reply | — (native importer) | **`noReply: true`** on `POST /session/{id}/message` — the key to `transfer` | +| Auth/config | `account/read`, `config/read` | `GET /config`, `GET /provider`, `GET /config/providers`; `opencode providers list`; creds in `~/.local/share/opencode/auth.json` | +| Session storage | `~/.codex` | **`~/.local/share/opencode/opencode.db`** (shared SQLite/WAL) + `storage/` — read by every `opencode` process → sessions are visible across TUI / `serve` / `run` | + +**Consequence:** we can **delete the broker** (`app-server-broker.mjs`, `broker-endpoint.mjs`, most of the socket handling) and replace the JSON-RPC stdio client with a tiny `fetch` + SSE HTTP client. Everything in the "runtime-agnostic plumbing" row stays. + +--- + +## 3. Do the two characteristics survive? (detailed) + +### (1) Subagent in the Claude Code TUI — ✅ unchanged +Rename `agents/codex-rescue.md` → `agents/opencode-rescue.md`, keep it a thin forwarder to `opencode-companion.mjs task …`, and route `/opencode:rescue` through the `Agent` tool exactly as today. Claude Code renders it identically. Additionally, OpenCode's *own* subagents (child sessions via `parentID`, `session.next.agent.switched`, `POST /experimental/session/{id}/background`) surface as `session.created`/`message.updated` events with a different `sessionID`, so the progress reporter can still print `Subagent X: …` lines — the same UX as Codex's `collabAgentToolCall`. + +### (2) Session appears in the OpenCode app — ✅ retained (one nuance) + +The original characteristic was the **Codex *desktop app*** (not the Codex TUI) auto-listing the plugin's subagent sessions. The OpenCode analog holds because **the TUI and the OpenCode desktop app are both just clients over one shared local store** — verified empirically on this machine: + +- CLI canonical data dir (`opencode debug paths`) → `~/.local/share/opencode`, session DB `opencode.db` (SQLite/WAL). +- OpenCode desktop app is installed (`/Applications/OpenCode.app`, `ai.opencode.desktop`). Its Electron support dir holds **only** Chromium shell state + workspace-preference `.dat` files + a `locks/` dir — **no session `.db` of its own**. Its embedded **sidecar server** (an Electron `utilityProcess` running the standard OpenCode server) therefore reads/writes the **same** `~/.local/share/opencode/opencode.db`. + +So a session the plugin creates via its own `opencode serve` (or `opencode run`) lands in that shared DB, scoped by `directory`, and **shows up in the OpenCode desktop app's session list** for that project — the direct analog of the Codex desktop app picking up subagent sessions. Resume from anywhere with `opencode --session `. + +This is the *same* fidelity Codex offered: the Codex desktop app also has its own backend and shares `~/.codex` storage — "appeared automatically" means "auto-listed from shared local state," not real-time streaming into an open window. + +**Nuance — auto-listed vs live-streamed.** The desktop app runs its *own* sidecar server with its *own* `/event` bus, separate from the plugin's `opencode serve`. So new sessions appear in the desktop app's **list** (shared DB), which matches the Codex bar; whether an *already-open* desktop window repaints instantly depends on how it refreshes its list. Two levels: +- **Good (default, Codex-parity):** plugin runs its own `opencode serve`; sessions auto-appear in the desktop app's and TUI's session lists (same DB), resumable in the app. +- **Best (true-live, beyond Codex):** OpenCode uniquely supports `opencode attach ` — multiple clients on one server share a live event stream. Print the plugin's server URL and support attaching to a pre-existing server via `OPENCODE_COMPANION_SERVER_URL`, so a TUI/web client attached to the plugin's server streams turns in real time. + +We'll implement the "good" default (Codex-parity) and expose the "best" path. + +--- + +## 4. Recommended architecture + +**Primary: a per-Claude-session shared `opencode serve` + a zero-dependency HTTP/SSE client.** This mirrors the Codex broker lifecycle but uses OpenCode's native server instead of a custom multiplexer. + +``` +Claude Code + ├─ /opencode:* commands ──► opencode-companion.mjs + │ │ + │ ├─ lib/server-lifecycle.mjs (ensure `opencode serve`, health-check /global/health, reuse if alive, store {url,pid}) + │ └─ lib/opencode.mjs (HTTP+SSE client: createSession / sendMessage / abort / subscribeEvents / captureTurn) + │ │ fetch + ReadableStream SSE + │ ▼ + │ opencode serve (HTTP 127.0.0.1:PORT) + │ │ writes + │ ▼ + └─ agents/opencode-rescue.md (Agent tool) ~/.local/share/opencode/opencode.db ◄── user's opencode TUI reads the same DB +``` + +- **Lifecycle** (`lib/server-lifecycle.mjs`, replaces `broker-lifecycle.mjs`): `SessionStart` hook exports env; first `/opencode:*` command lazily runs `ensureServer(cwd)` → if a healthy server URL is stored (or `OPENCODE_COMPANION_SERVER_URL` is set), reuse it; else `spawn("opencode", ["serve","--hostname","127.0.0.1","--port","0", …], {detached})`, parse `opencode server listening on http://127.0.0.1:PORT` from stdout (or health-poll), store `{url, pid}` in plugin state. `SessionEnd` hook posts `/global/dispose` (or kills the pid) and clears state. Reuse across concurrent calls = free (HTTP is multi-client). +- **Client** (`lib/opencode.mjs`, replaces `codex.mjs`+`app-server.mjs`): Node 18+ global `fetch`; SSE parsed by reading the response body stream and splitting on `\n\n`. `captureTurn(sessionId, …)` subscribes to `GET /event`, filters by `sessionID` (and child session IDs for subagents), maps events → the *existing* progress-reporter contract, resolves on `session.idle`, captures final assistant text from `message.updated` parts and touched files from `file.edited`. + +**Alternative (lighter, or for the background worker): `opencode run --format json`.** One subprocess per task, JSON events on stdout, `--session ` to resume, `--agent`/`--model`/`--variant` for routing, kill-to-cancel. Simpler but no shared multiplexed runtime and coarser cancel. Recommendation: use the server for foreground + status/cancel; optionally use `run` inside the detached background worker. + +### Read-only vs write (the `sandbox` analog) +- **Review / read-only task:** send the turn with `agent: "plan"` (OpenCode's read-only agent) and/or create the session with `permission` rules denying edits. +- **`--write` task:** `agent: "build"` and create the session with `permission` rules set to `allow` for the edit/shell tools (equivalent to Codex `approvalPolicy: "never"` + `workspace-write`). In headless mode there's no human to approve, so the client must **auto-approve**: either pre-set `permission` on `POST /session`, or reply `allow` to `permission.asked` / `permission.v2.asked` events via `POST /session/{id}/permissions/{permID}`. Design the client to auto-approve in write mode and auto-deny-edits in read-only mode. **This is the single most important implementation detail to get right.** + +### Model / effort mapping +- `--model spark` → `openai/gpt-5.3-codex-spark`; `--model openai/gpt-5.4` → `{providerID:"openai", modelID:"gpt-5.4"}`; unset → OpenCode default. Add an alias map + a `provider/model` splitter. +- `--effort ` → `variant: ` (pass through; note valid values are provider-specific, e.g. `minimal|high|max`). Optionally also accept `--variant` directly. + +--- + +## 5. File-by-file plan + +**Rename plugin identity:** `plugins/codex/` → `plugins/opencode/`; `marketplace.json` + `plugin.json` name/owner → opencode; env vars `CODEX_COMPANION_*` → `OPENCODE_COMPANION_*`; user-facing strings `codex …`/`Codex` → `opencode …`/`OpenCode`; resume hint `codex resume ` → `opencode --session `. + +| Current | Action | Notes | +|---|---|---| +| `scripts/lib/app-server.mjs` | **Replace** → `lib/opencode-server.mjs` | Hand-rolled `fetch`+SSE `OpencodeServerClient`: `createSession`, `sendMessage`, `promptAsync`, `abort`, `subscribeEvents`, `getConfig`, `listSessions`, `respondPermission`. Zero deps. | +| `scripts/app-server-broker.mjs` | **Delete** | `opencode serve` is the multiplexer. | +| `scripts/lib/broker-endpoint.mjs` | **Delete / fold in** | Store `http://127.0.0.1:PORT` string instead of socket/pipe. | +| `scripts/lib/broker-lifecycle.mjs` | **Rewrite** → `lib/server-lifecycle.mjs` | `ensureServer`/`loadServer`/`teardownServer`; spawn `opencode serve`, health via `GET /global/health`, reuse if alive. | +| `scripts/lib/codex.mjs` | **Rewrite** → `lib/opencode.mjs` | Turn capture from SSE; `runServerTurn`, `runReview`, `getAvailability`, `getAuthStatus`, `interruptTurn` (abort), `findLatestTaskSession`, `importClaudeSession` (transfer). | +| `scripts/lib/app-server-protocol.d.ts` + `prebuild` | **Replace** | Drop `codex app-server generate-ts`. Either hand-write minimal types or generate from `GET /doc` (OpenAPI). Simplest v1: plain `.mjs`, no typed build. | +| `scripts/codex-companion.mjs` | **Adapt** → `opencode-companion.mjs` | Same subcommands & control flow; swap `runAppServer*`→`runServer*`, `interruptAppServerTurn`→abort, model/effort normalization → provider/model + variant. | +| `scripts/session-lifecycle-hook.mjs` | **Keep + adapt** | Start/stop the opencode server (was broker); rename env vars. | +| `scripts/stop-review-gate-hook.mjs` | **Keep + adapt** | Point the gate at the opencode review path. | +| `scripts/lib/claude-session-transfer.mjs` | **Keep as-is** | Claude-side JSONL path resolution; unchanged. | +| `state.mjs`, `job-control.mjs`, `tracked-jobs.mjs`, `git.mjs`, `render.mjs`, `args.mjs`, `process.mjs`, `workspace.mjs`, `prompts.mjs`, `fs.mjs` | **Keep** (env/string renames only) | Runtime-agnostic. `render.mjs` strings + resume hint updated. | +| `schemas/review-output.schema.json` | **Keep** | Used with `format: json_schema`. | +| `agents/codex-rescue.md` | **Rename** → `opencode-rescue.md` | Thin forwarder to `opencode-companion.mjs task`. | +| `commands/*.md` | **Adapt** | `rescue`, `review`, `adversarial-review`, `transfer`, `status`, `result`, `cancel`, `setup`; `codex:`→`opencode:`, resume hints updated. | +| `skills/codex-cli-runtime` | **Rename** → `opencode-cli-runtime` | Same forwarder contract. | +| `skills/codex-result-handling` | **Rename** → `opencode-result-handling` | String updates. | +| `skills/gpt-5-4-prompting` | **Keep or generalize** | Still valid when routing to `openai/gpt-5.*`; consider a provider-neutral prompting skill since OpenCode can target many providers. | +| `tests/*.test.mjs` | **Adapt** | Replace `fake-codex-fixture.mjs` with a **fake `opencode serve`** (a tiny local HTTP+SSE server) for `runtime`/`broker-endpoint`/`process`/`render`/`commands` tests. `git`/`state`/`bump-version` tests largely unchanged. | + +### Command behavior deltas +- **`review` / `adversarial-review`:** OpenCode has no built-in reviewer RPC, so *both* become prompt-driven read-only turns (reuse `prompts/adversarial-review.md` + `git.mjs` target selection + `review-output.schema.json` via `format: json_schema`, `agent: "plan"`). This actually *unifies* the two paths (Codex special-cased native review). `review` = fixed prompt, non-steerable; `adversarial-review` = steerable focus text. Optionally `DELETE /session/{id}` after a review to mimic Codex's ephemeral review threads (or keep them — they're harmless and resumable). +- **`task` (`/opencode:rescue`):** create/resume a persistent session (title `OpenCode Companion Task: `), `agent: build`+auto-allow when `--write`, else `plan`. `--resume-last` finds the newest task session in this `directory` (`GET /session` filtered by title prefix), mirroring `findLatestTaskThread`. +- **`transfer`:** convert the Claude JSONL transcript → OpenCode session: `POST /session` (title from the conversation), then replay each transcript message with `POST /session/{id}/message` `{ noReply: true, parts:[{type:"text",…}] }` to build visible, resumable history. Print `opencode --session ` to continue. *(Hardest command — Phase 3; ships value even if deferred.)* +- **`status` / `result` / `cancel`:** unchanged plumbing; `cancel` calls `POST /session/{id}/abort` (was `turn/interrupt`) then falls back to killing the tracked pid. +- **`setup`:** check `opencode --version`; auth via `GET /config`+`GET /provider` (or `opencode providers list`) + `~/.local/share/opencode/auth.json`; offer `opencode` install if missing; keep the review-gate toggle. + +--- + +## 6. Phased delivery + +- **Phase 0 — scaffold:** rename plugin dir/manifests/env; strip the `codex app-server generate-ts` build; keep tests green on the runtime-agnostic libs. +- **Phase 1 — runtime bridge:** `lib/server-lifecycle.mjs` + `lib/opencode-server.mjs` + `lib/opencode.mjs` (`runServerTurn`, `captureTurn` from SSE, availability/auth). Fake-server test harness. **Milestone:** `opencode-companion.mjs task "…"` runs a foreground turn, streams progress into the Claude TUI, and the session shows up in `opencode` TUI. +- **Phase 2 — full command surface:** `task` (+ `--write`/read-only permission wiring, `--resume-last`, background worker, `--model`/`--effort`→variant), `review`/`adversarial-review`, `status`/`result`/`cancel`, `setup`. Adapt `agents/opencode-rescue.md`, `commands/*`, `skills/*`. **Milestone:** parity with Codex minus transfer; both target characteristics demonstrably working. +- **Phase 3 — transfer + polish:** Claude-JSONL→OpenCode replay via `noReply`; stop-review gate; README; optional `attach`-for-live-parity docs; provider-neutral prompting skill. + +--- + +## 7. Risks / open questions + +1. **Headless permission auto-approval (highest risk).** Write-mode turns must not hang on `permission.asked`. Validate the create-session `permission` rule set *and* the `permission.v2.*` reply flow against a real write task early in Phase 1. +2. **Live TUI visibility.** DB sharing guarantees *list* visibility; *live* streaming into an already-open user TUI needs shared-server attach. Decide whether to auto-attach to a detected user server or just document `opencode attach`. +3. **`opencode serve` startup handshake.** Confirm the reliable "ready" signal (stdout line vs `GET /global/health` poll) and port capture when `--port 0`. +4. **Sync `POST /message` for long turns.** Confirm it holds the connection for multi-minute turns without idle timeouts; if not, use `prompt_async` + SSE `session.idle` as the completion path (already needed for progress anyway). +5. **`variant`/effort validity.** Values are provider-specific; validate/pass-through rather than hard-coding Codex's `none…xhigh` set. +6. **Ephemeral reviews.** OpenCode persists every session; decide keep-vs-delete for review sessions (Codex made them ephemeral). +7. **Transfer fidelity.** `noReply` replay reconstructs history but not tool-call/diff artifacts; confirm it's "good enough" to resume meaningfully in the OpenCode TUI. + +--- + +## Appendix — key OpenCode facts (verified `opencode 1.17.10`, this machine) + +- Server: `opencode serve --hostname 127.0.0.1 --port ` → `opencode server listening on http://127.0.0.1:`; OpenAPI at `GET /doc`. +- Core endpoints: `POST /session`, `POST /session/{id}/message` (sync `{info,parts}`), `POST /session/{id}/prompt_async`, `POST /session/{id}/abort`, `GET /event` (SSE), `GET /session`, `GET /config`, `GET /provider`, `POST /session/{id}/permissions/{permID}`, `POST /experimental/session/{id}/background`, `GET /global/health`, `POST /global/dispose`. +- Message body: `{ parts:[{type:"text",text}], agent, model:{providerID,modelID}, variant, noReply, format:{type:"json_schema",schema}, system }`. +- Completion signal: `session.idle` (has `sessionID`). Progress: `session.next.text|reasoning|tool|shell|step.*`, `file.edited`, `session.error`. Events carry `sessionID` for filtering (incl. child/subagent sessions). +- Storage: shared `~/.local/share/opencode/opencode.db` (SQLite/WAL) — every `opencode` process sees the same sessions, scoped by `directory`. +- Resume: `opencode --session ` (TUI) / `opencode run --session ` / `opencode attach `. +- Models incl. `openai/gpt-5.4`, `openai/gpt-5.3-codex-spark`; read-only `plan` agent + write `build` agent; per-session `permission` rules `{permission,action,pattern}`. diff --git a/package-lock.json b/package-lock.json index 06f8922..47c08cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { - "name": "@openai/codex-plugin-cc", + "name": "@opencode/claude-code-plugin", "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@openai/codex-plugin-cc", + "name": "@opencode/claude-code-plugin", "version": "1.0.5", "license": "Apache-2.0", "devDependencies": { - "@types/node": "^25.5.0", - "typescript": "^6.0.2" + "@types/node": "^25.5.0" }, "engines": { "node": ">=18.18.0" @@ -26,20 +25,6 @@ "undici-types": "~7.18.0" } }, - "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", diff --git a/package.json b/package.json index bfe52e0..9f9cac1 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { - "name": "@openai/codex-plugin-cc", + "name": "@opencode/claude-code-plugin", "version": "1.0.5", "private": true, "type": "module", - "description": "Use Codex from Claude Code to review code or delegate tasks.", + "description": "Use OpenCode from Claude Code to review code or delegate tasks.", "license": "Apache-2.0", "engines": { "node": ">=18.18.0" @@ -11,12 +11,9 @@ "scripts": { "bump-version": "node scripts/bump-version.mjs", "check-version": "node scripts/bump-version.mjs --check", - "prebuild": "mkdir -p plugins/codex/.generated/app-server-types && codex app-server generate-ts --out plugins/codex/.generated/app-server-types", - "build": "tsc -p tsconfig.app-server.json", "test": "node --test tests/*.test.mjs" }, "devDependencies": { - "@types/node": "^25.5.0", - "typescript": "^6.0.2" + "@types/node": "^25.5.0" } } diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json deleted file mode 100644 index 168b342..0000000 --- a/plugins/codex/.claude-plugin/plugin.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "codex", - "version": "1.0.5", - "description": "Use Codex from Claude Code to review code or delegate tasks.", - "author": { - "name": "OpenAI" - } -} diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md deleted file mode 100644 index d647561..0000000 --- a/plugins/codex/CHANGELOG.md +++ /dev/null @@ -1,5 +0,0 @@ -# Changelog - -## 1.0.0 - -- Initial version of the Codex plugin for Claude Code diff --git a/plugins/codex/commands/cancel.md b/plugins/codex/commands/cancel.md deleted file mode 100644 index a1472b8..0000000 --- a/plugins/codex/commands/cancel.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -description: Cancel an active background Codex job in this repository -argument-hint: '[job-id]' -disable-model-invocation: true -allowed-tools: Bash(node:*) ---- - -!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" cancel "$ARGUMENTS"` diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md deleted file mode 100644 index 56de955..0000000 --- a/plugins/codex/commands/rescue.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent -argument-hint: "[--background|--wait] [--resume|--fresh] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" -allowed-tools: Bash(node:*), AskUserQuestion, Agent ---- - -Invoke the `codex:codex-rescue` subagent via the `Agent` tool (`subagent_type: "codex:codex-rescue"`), forwarding the raw user request as the prompt. -`codex:codex-rescue` is a subagent, not a skill — do not call `Skill(codex:codex-rescue)` (no such skill) or `Skill(codex:rescue)` (that re-enters this command and hangs the session). The command runs inline so the `Agent` tool stays in scope; forked general-purpose subagents do not expose it. -The final user-visible response must be Codex's output verbatim. - -Raw user request: -$ARGUMENTS - -Execution mode: - -- If the request includes `--background`, run the `codex:codex-rescue` subagent in the background. -- If the request includes `--wait`, run the `codex:codex-rescue` subagent in the foreground. -- If neither flag is present, default to foreground. -- `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. -- `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. -- If the request includes `--resume`, do not ask whether to continue. The user already chose. -- If the request includes `--fresh`, do not ask whether to continue. The user already chose. -- Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running: - -```bash -node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task-resume-candidate --json -``` - -- If that helper reports `available: true`, use `AskUserQuestion` exactly once to ask whether to continue the current Codex thread or start a new one. -- The two choices must be: - - `Continue current Codex thread` - - `Start a new Codex thread` -- If the user is clearly giving a follow-up instruction such as "continue", "keep going", "resume", "apply the top fix", or "dig deeper", put `Continue current Codex thread (Recommended)` first. -- Otherwise put `Start a new Codex thread (Recommended)` first. -- If the user chooses continue, add `--resume` before routing to the subagent. -- If the user chooses a new thread, add `--fresh` before routing to the subagent. -- If the helper reports `available: false`, do not ask. Route normally. - -Operating rules: - -- The subagent is a thin forwarder only. It should use one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task ...` and return that command's stdout as-is. -- Return the Codex companion stdout verbatim to the user. -- Do not paraphrase, summarize, rewrite, or add commentary before or after it. -- Do not ask the subagent to inspect files, monitor progress, poll `/codex:status`, fetch `/codex:result`, call `/codex:cancel`, summarize output, or do follow-up work of its own. -- Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort. -- Leave the model unset unless the user explicitly asks for one. If they ask for `spark`, map it to `gpt-5.3-codex-spark`. -- Leave `--resume` and `--fresh` in the forwarded request. The subagent handles that routing when it builds the `task` command. -- If the helper reports that Codex is missing or unauthenticated, stop and tell the user to run `/codex:setup`. -- If the user did not supply a request, ask what Codex should investigate or fix. diff --git a/plugins/codex/commands/setup.md b/plugins/codex/commands/setup.md deleted file mode 100644 index fb33a15..0000000 --- a/plugins/codex/commands/setup.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -description: Check whether the local Codex CLI is ready and optionally toggle the stop-time review gate -argument-hint: '[--enable-review-gate|--disable-review-gate]' -allowed-tools: Bash(node:*), Bash(npm:*), AskUserQuestion ---- - -Run: - -```bash -node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" setup --json $ARGUMENTS -``` - -If the result says Codex is unavailable and npm is available: -- Use `AskUserQuestion` exactly once to ask whether Claude should install Codex now. -- Put the install option first and suffix it with `(Recommended)`. -- Use these two options: - - `Install Codex (Recommended)` - - `Skip for now` -- If the user chooses install, run: - -```bash -npm install -g @openai/codex -``` - -- Then rerun: - -```bash -node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" setup --json $ARGUMENTS -``` - -If Codex is already installed or npm is unavailable: -- Do not ask about installation. - -Output rules: -- Present the final setup output to the user. -- If installation was skipped, present the original setup output. -- If Codex is installed but not authenticated, preserve the guidance to run `!codex login`. diff --git a/plugins/codex/scripts/app-server-broker.mjs b/plugins/codex/scripts/app-server-broker.mjs deleted file mode 100644 index 1954274..0000000 --- a/plugins/codex/scripts/app-server-broker.mjs +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env node - -import fs from "node:fs"; -import net from "node:net"; -import path from "node:path"; -import process from "node:process"; - -import { parseArgs } from "./lib/args.mjs"; -import { BROKER_BUSY_RPC_CODE, CodexAppServerClient } from "./lib/app-server.mjs"; -import { parseBrokerEndpoint } from "./lib/broker-endpoint.mjs"; - -const STREAMING_METHODS = new Set(["turn/start", "review/start", "thread/compact/start"]); - -function buildStreamThreadIds(method, params, result) { - const threadIds = new Set(); - if (params?.threadId) { - threadIds.add(params.threadId); - } - if (method === "review/start" && result?.reviewThreadId) { - threadIds.add(result.reviewThreadId); - } - return threadIds; -} - -function buildJsonRpcError(code, message, data) { - return data === undefined ? { code, message } : { code, message, data }; -} - -function send(socket, message) { - if (socket.destroyed) { - return; - } - socket.write(`${JSON.stringify(message)}\n`); -} - -function isInterruptRequest(message) { - return message?.method === "turn/interrupt"; -} - -function writePidFile(pidFile) { - if (!pidFile) { - return; - } - fs.mkdirSync(path.dirname(pidFile), { recursive: true }); - fs.writeFileSync(pidFile, `${process.pid}\n`, "utf8"); -} - -async function main() { - const [subcommand, ...argv] = process.argv.slice(2); - if (subcommand !== "serve") { - throw new Error("Usage: node scripts/app-server-broker.mjs serve --endpoint [--cwd ] [--pid-file ]"); - } - - const { options } = parseArgs(argv, { - valueOptions: ["cwd", "pid-file", "endpoint"] - }); - - if (!options.endpoint) { - throw new Error("Missing required --endpoint."); - } - - const cwd = options.cwd ? path.resolve(process.cwd(), options.cwd) : process.cwd(); - const endpoint = String(options.endpoint); - const listenTarget = parseBrokerEndpoint(endpoint); - const pidFile = options["pid-file"] ? path.resolve(options["pid-file"]) : null; - writePidFile(pidFile); - - const appClient = await CodexAppServerClient.connect(cwd, { disableBroker: true }); - let activeRequestSocket = null; - let activeStreamSocket = null; - let activeStreamThreadIds = null; - const sockets = new Set(); - - function clearSocketOwnership(socket) { - if (activeRequestSocket === socket) { - activeRequestSocket = null; - } - if (activeStreamSocket === socket) { - activeStreamSocket = null; - activeStreamThreadIds = null; - } - } - - function routeNotification(message) { - const target = activeRequestSocket ?? activeStreamSocket; - if (!target) { - return; - } - send(target, message); - if (message.method === "turn/completed" && activeStreamSocket === target) { - const threadId = message.params?.threadId ?? null; - if (!threadId || !activeStreamThreadIds || activeStreamThreadIds.has(threadId)) { - activeStreamSocket = null; - activeStreamThreadIds = null; - if (activeRequestSocket === target) { - activeRequestSocket = null; - } - } - } - } - - async function shutdown(server) { - for (const socket of sockets) { - socket.end(); - } - await appClient.close().catch(() => {}); - await new Promise((resolve) => server.close(resolve)); - if (listenTarget.kind === "unix" && fs.existsSync(listenTarget.path)) { - fs.unlinkSync(listenTarget.path); - } - if (pidFile && fs.existsSync(pidFile)) { - fs.unlinkSync(pidFile); - } - } - - appClient.setNotificationHandler(routeNotification); - - const server = net.createServer((socket) => { - sockets.add(socket); - socket.setEncoding("utf8"); - let buffer = ""; - - socket.on("data", async (chunk) => { - buffer += chunk; - let newlineIndex = buffer.indexOf("\n"); - while (newlineIndex !== -1) { - const line = buffer.slice(0, newlineIndex); - buffer = buffer.slice(newlineIndex + 1); - newlineIndex = buffer.indexOf("\n"); - - if (!line.trim()) { - continue; - } - - let message; - try { - message = JSON.parse(line); - } catch (error) { - send(socket, { - id: null, - error: buildJsonRpcError(-32700, `Invalid JSON: ${error.message}`) - }); - continue; - } - - if (message.id !== undefined && message.method === "initialize") { - send(socket, { - id: message.id, - result: { - userAgent: "codex-companion-broker" - } - }); - continue; - } - - if (message.method === "initialized" && message.id === undefined) { - continue; - } - - if (message.id !== undefined && message.method === "broker/shutdown") { - send(socket, { id: message.id, result: {} }); - await shutdown(server); - process.exit(0); - } - - if (message.id === undefined) { - continue; - } - - const allowInterruptDuringActiveStream = - isInterruptRequest(message) && activeStreamSocket && activeStreamSocket !== socket && !activeRequestSocket; - - if ( - ((activeRequestSocket && activeRequestSocket !== socket) || (activeStreamSocket && activeStreamSocket !== socket)) && - !allowInterruptDuringActiveStream - ) { - send(socket, { - id: message.id, - error: buildJsonRpcError(BROKER_BUSY_RPC_CODE, "Shared Codex broker is busy.") - }); - continue; - } - - if (allowInterruptDuringActiveStream) { - try { - const result = await appClient.request(message.method, message.params ?? {}); - send(socket, { id: message.id, result }); - } catch (error) { - send(socket, { - id: message.id, - error: buildJsonRpcError(error.rpcCode ?? -32000, error.message) - }); - } - continue; - } - - const isStreaming = STREAMING_METHODS.has(message.method); - activeRequestSocket = socket; - - try { - const result = await appClient.request(message.method, message.params ?? {}); - send(socket, { id: message.id, result }); - if (isStreaming) { - activeStreamSocket = socket; - activeStreamThreadIds = buildStreamThreadIds(message.method, message.params ?? {}, result); - } - if (activeRequestSocket === socket) { - activeRequestSocket = null; - } - } catch (error) { - send(socket, { - id: message.id, - error: buildJsonRpcError(error.rpcCode ?? -32000, error.message) - }); - if (activeRequestSocket === socket) { - activeRequestSocket = null; - } - if (activeStreamSocket === socket && !isStreaming) { - activeStreamSocket = null; - } - } - } - }); - - socket.on("close", () => { - sockets.delete(socket); - clearSocketOwnership(socket); - }); - - socket.on("error", () => { - sockets.delete(socket); - clearSocketOwnership(socket); - }); - }); - - process.on("SIGTERM", async () => { - await shutdown(server); - process.exit(0); - }); - - process.on("SIGINT", async () => { - await shutdown(server); - process.exit(0); - }); - - server.listen(listenTarget.path); -} - -main().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exit(1); -}); diff --git a/plugins/codex/scripts/lib/app-server-protocol.d.ts b/plugins/codex/scripts/lib/app-server-protocol.d.ts deleted file mode 100644 index f61a458..0000000 --- a/plugins/codex/scripts/lib/app-server-protocol.d.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { - ClientInfo, - InitializeCapabilities, - InitializeParams, - InitializeResponse, - ServerNotification -} from "../../.generated/app-server-types/index.js"; -import type { - ExternalAgentConfigImportParams, - ExternalAgentConfigImportResponse, - ReviewStartParams, - ReviewStartResponse, - ReviewTarget, - Thread, - ThreadItem, - ThreadListParams, - ThreadListResponse, - ThreadResumeParams as RawThreadResumeParams, - ThreadResumeResponse, - ThreadSetNameParams, - ThreadSetNameResponse, - ThreadStartParams as RawThreadStartParams, - ThreadStartResponse, - Turn, - TurnInterruptParams, - TurnInterruptResponse, - TurnStartParams, - TurnStartResponse, - UserInput -} from "../../.generated/app-server-types/v2/index.js"; - -export type { - ClientInfo, - InitializeCapabilities, - InitializeParams, - InitializeResponse, - ReviewTarget, - Thread, - ThreadItem, - ThreadListParams, - Turn, - TurnInterruptParams, - TurnStartParams, - UserInput -}; - -export type ThreadStartParams = Omit; -export type ThreadResumeParams = Omit; - -export interface CodexAppServerClientOptions { - env?: NodeJS.ProcessEnv; - clientInfo?: ClientInfo; - capabilities?: InitializeCapabilities; - brokerEndpoint?: string; - disableBroker?: boolean; - reuseExistingBroker?: boolean; -} - -export interface AppServerMethodMap { - initialize: { params: InitializeParams; result: InitializeResponse }; - "externalAgentConfig/import": { params: ExternalAgentConfigImportParams; result: ExternalAgentConfigImportResponse }; - "thread/start": { params: ThreadStartParams; result: ThreadStartResponse }; - "thread/resume": { params: ThreadResumeParams; result: ThreadResumeResponse }; - "thread/name/set": { params: ThreadSetNameParams; result: ThreadSetNameResponse }; - "thread/list": { params: ThreadListParams; result: ThreadListResponse }; - "review/start": { params: ReviewStartParams; result: ReviewStartResponse }; - "turn/start": { params: TurnStartParams; result: TurnStartResponse }; - "turn/interrupt": { params: TurnInterruptParams; result: TurnInterruptResponse }; -} - -export type AppServerMethod = keyof AppServerMethodMap; -export type AppServerRequestParams = AppServerMethodMap[M]["params"]; -export type AppServerResponse = AppServerMethodMap[M]["result"]; -export type AppServerNotification = ServerNotification; -export type AppServerNotificationHandler = (message: AppServerNotification) => void; diff --git a/plugins/codex/scripts/lib/app-server.mjs b/plugins/codex/scripts/lib/app-server.mjs deleted file mode 100644 index 72b30a7..0000000 --- a/plugins/codex/scripts/lib/app-server.mjs +++ /dev/null @@ -1,354 +0,0 @@ -/** - * @typedef {Error & { data?: unknown, rpcCode?: number }} ProtocolError - * @typedef {import("./app-server-protocol").AppServerMethod} AppServerMethod - * @typedef {import("./app-server-protocol").AppServerNotification} AppServerNotification - * @typedef {import("./app-server-protocol").AppServerNotificationHandler} AppServerNotificationHandler - * @typedef {import("./app-server-protocol").ClientInfo} ClientInfo - * @typedef {import("./app-server-protocol").CodexAppServerClientOptions} CodexAppServerClientOptions - * @typedef {import("./app-server-protocol").InitializeCapabilities} InitializeCapabilities - */ -import fs from "node:fs"; -import net from "node:net"; -import process from "node:process"; -import { spawn } from "node:child_process"; -import readline from "node:readline"; -import { parseBrokerEndpoint } from "./broker-endpoint.mjs"; -import { ensureBrokerSession, loadBrokerSession } from "./broker-lifecycle.mjs"; -import { terminateProcessTree } from "./process.mjs"; - -const PLUGIN_MANIFEST_URL = new URL("../../.claude-plugin/plugin.json", import.meta.url); -const PLUGIN_MANIFEST = JSON.parse(fs.readFileSync(PLUGIN_MANIFEST_URL, "utf8")); - -export const BROKER_ENDPOINT_ENV = "CODEX_COMPANION_APP_SERVER_ENDPOINT"; -export const BROKER_BUSY_RPC_CODE = -32001; - -/** @type {ClientInfo} */ -const DEFAULT_CLIENT_INFO = { - title: "Codex Plugin", - name: "Claude Code", - version: PLUGIN_MANIFEST.version ?? "0.0.0" -}; - -/** @type {InitializeCapabilities} */ -const DEFAULT_CAPABILITIES = { - experimentalApi: false, - requestAttestation: false, - optOutNotificationMethods: [ - "item/agentMessage/delta", - "item/reasoning/summaryTextDelta", - "item/reasoning/summaryPartAdded", - "item/reasoning/textDelta" - ] -}; - -function buildJsonRpcError(code, message, data) { - return data === undefined ? { code, message } : { code, message, data }; -} - -function createProtocolError(message, data) { - const error = /** @type {ProtocolError} */ (new Error(message)); - error.data = data; - if (data?.code !== undefined) { - error.rpcCode = data.code; - } - return error; -} - -class AppServerClientBase { - constructor(cwd, options = {}) { - this.cwd = cwd; - this.options = options; - this.pending = new Map(); - this.nextId = 1; - this.stderr = ""; - this.closed = false; - this.exitError = null; - /** @type {AppServerNotificationHandler | null} */ - this.notificationHandler = null; - this.lineBuffer = ""; - this.transport = "unknown"; - - this.exitPromise = new Promise((resolve) => { - this.resolveExit = resolve; - }); - } - - setNotificationHandler(handler) { - this.notificationHandler = handler; - } - - /** - * @template {AppServerMethod} M - * @param {M} method - * @param {import("./app-server-protocol").AppServerRequestParams} params - * @returns {Promise>} - */ - request(method, params) { - if (this.closed) { - throw new Error("codex app-server client is closed."); - } - - const id = this.nextId; - this.nextId += 1; - - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject, method }); - this.sendMessage({ id, method, params }); - }); - } - - notify(method, params = {}) { - if (this.closed) { - return; - } - this.sendMessage({ method, params }); - } - - handleChunk(chunk) { - this.lineBuffer += chunk; - let newlineIndex = this.lineBuffer.indexOf("\n"); - while (newlineIndex !== -1) { - const line = this.lineBuffer.slice(0, newlineIndex); - this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1); - this.handleLine(line); - newlineIndex = this.lineBuffer.indexOf("\n"); - } - } - - handleLine(line) { - if (!line.trim()) { - return; - } - - let message; - try { - message = JSON.parse(line); - } catch (error) { - this.handleExit(createProtocolError(`Failed to parse codex app-server JSONL: ${error.message}`, { line })); - return; - } - - if (message.id !== undefined && message.method) { - this.handleServerRequest(message); - return; - } - - if (message.id !== undefined) { - const pending = this.pending.get(message.id); - if (!pending) { - return; - } - this.pending.delete(message.id); - - if (message.error) { - pending.reject(createProtocolError(message.error.message ?? `codex app-server ${pending.method} failed.`, message.error)); - } else { - pending.resolve(message.result ?? {}); - } - return; - } - - if (message.method && this.notificationHandler) { - this.notificationHandler(/** @type {AppServerNotification} */ (message)); - } - } - - handleServerRequest(message) { - this.sendMessage({ - id: message.id, - error: buildJsonRpcError(-32601, `Unsupported server request: ${message.method}`) - }); - } - - handleExit(error) { - if (this.exitResolved) { - return; - } - - this.exitResolved = true; - this.exitError = error ?? null; - - for (const pending of this.pending.values()) { - pending.reject(this.exitError ?? new Error("codex app-server connection closed.")); - } - this.pending.clear(); - this.resolveExit(undefined); - } - - sendMessage(_message) { - throw new Error("sendMessage must be implemented by subclasses."); - } -} - -class SpawnedCodexAppServerClient extends AppServerClientBase { - constructor(cwd, options = {}) { - super(cwd, options); - this.transport = "direct"; - } - - async initialize() { - this.proc = spawn("codex", ["app-server"], { - cwd: this.cwd, - env: this.options.env ?? process.env, - stdio: ["pipe", "pipe", "pipe"], - shell: process.platform === "win32" ? (process.env.SHELL || true) : false, - windowsHide: true - }); - - this.proc.stdout.setEncoding("utf8"); - this.proc.stderr.setEncoding("utf8"); - - this.proc.stderr.on("data", (chunk) => { - this.stderr += chunk; - }); - - this.proc.on("error", (error) => { - this.handleExit(error); - }); - - this.proc.on("exit", (code, signal) => { - const stderr = this.stderr.trim(); - const detail = - code === 0 - ? null - : createProtocolError( - `codex app-server exited unexpectedly (${signal ? `signal ${signal}` : `exit ${code}`}).${stderr ? `\n${stderr}` : ""}` - ); - this.handleExit(detail); - }); - - this.readline = readline.createInterface({ input: this.proc.stdout }); - this.readline.on("line", (line) => { - this.handleLine(line); - }); - - await this.request("initialize", { - clientInfo: this.options.clientInfo ?? DEFAULT_CLIENT_INFO, - capabilities: this.options.capabilities ?? DEFAULT_CAPABILITIES - }); - this.notify("initialized", {}); - } - - async close() { - if (this.closed) { - await this.exitPromise; - return; - } - - this.closed = true; - - if (this.readline) { - this.readline.close(); - } - - if (this.proc && !this.proc.killed) { - this.proc.stdin.end(); - setTimeout(() => { - if (this.proc && !this.proc.killed && this.proc.exitCode === null) { - // On Windows with shell: true, the direct child is cmd.exe. - // Use terminateProcessTree to kill the entire tree including - // the grandchild node process. - if (process.platform === "win32") { - try { - terminateProcessTree(this.proc.pid); - } catch { - // Best-effort cleanup inside an unref'd timer — swallow errors - // to avoid crashing the host process during shutdown. - } - } else { - this.proc.kill("SIGTERM"); - } - } - }, 50).unref?.(); - } - - await this.exitPromise; - } - - sendMessage(message) { - const line = `${JSON.stringify(message)}\n`; - const stdin = this.proc?.stdin; - if (!stdin) { - throw new Error("codex app-server stdin is not available."); - } - stdin.write(line); - } -} - -class BrokerCodexAppServerClient extends AppServerClientBase { - constructor(cwd, options = {}) { - super(cwd, options); - this.transport = "broker"; - this.endpoint = options.brokerEndpoint; - } - - async initialize() { - await new Promise((resolve, reject) => { - const target = parseBrokerEndpoint(this.endpoint); - this.socket = net.createConnection({ path: target.path }); - this.socket.setEncoding("utf8"); - this.socket.on("connect", resolve); - this.socket.on("data", (chunk) => { - this.handleChunk(chunk); - }); - this.socket.on("error", (error) => { - if (!this.exitResolved) { - reject(error); - } - this.handleExit(error); - }); - this.socket.on("close", () => { - this.handleExit(this.exitError); - }); - }); - - await this.request("initialize", { - clientInfo: this.options.clientInfo ?? DEFAULT_CLIENT_INFO, - capabilities: this.options.capabilities ?? DEFAULT_CAPABILITIES - }); - this.notify("initialized", {}); - } - - async close() { - if (this.closed) { - await this.exitPromise; - return; - } - - this.closed = true; - if (this.socket) { - this.socket.end(); - } - await this.exitPromise; - } - - sendMessage(message) { - const line = `${JSON.stringify(message)}\n`; - const socket = this.socket; - if (!socket) { - throw new Error("codex app-server broker connection is not connected."); - } - socket.write(line); - } -} - -export class CodexAppServerClient { - static async connect(cwd, options = {}) { - let brokerEndpoint = null; - if (!options.disableBroker) { - brokerEndpoint = options.brokerEndpoint ?? options.env?.[BROKER_ENDPOINT_ENV] ?? process.env[BROKER_ENDPOINT_ENV] ?? null; - if (!brokerEndpoint && options.reuseExistingBroker) { - brokerEndpoint = loadBrokerSession(cwd)?.endpoint ?? null; - } - if (!brokerEndpoint && !options.reuseExistingBroker) { - const brokerSession = await ensureBrokerSession(cwd, { env: options.env }); - brokerEndpoint = brokerSession?.endpoint ?? null; - } - } - const client = brokerEndpoint - ? new BrokerCodexAppServerClient(cwd, { ...options, brokerEndpoint }) - : new SpawnedCodexAppServerClient(cwd, options); - await client.initialize(); - return client; - } -} diff --git a/plugins/codex/scripts/lib/broker-endpoint.mjs b/plugins/codex/scripts/lib/broker-endpoint.mjs deleted file mode 100644 index 8abdcc7..0000000 --- a/plugins/codex/scripts/lib/broker-endpoint.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import path from "node:path"; -import process from "node:process"; - -function sanitizePipeName(value) { - return String(value ?? "") - .replace(/[^A-Za-z0-9._-]/g, "-") - .replace(/^-+|-+$/g, ""); -} - -export function createBrokerEndpoint(sessionDir, platform = process.platform) { - if (platform === "win32") { - const pipeName = sanitizePipeName(`${path.win32.basename(sessionDir)}-codex-app-server`); - return `pipe:\\\\.\\pipe\\${pipeName}`; - } - - return `unix:${path.join(sessionDir, "broker.sock")}`; -} - -export function parseBrokerEndpoint(endpoint) { - if (typeof endpoint !== "string" || endpoint.length === 0) { - throw new Error("Missing broker endpoint."); - } - - if (endpoint.startsWith("pipe:")) { - const pipePath = endpoint.slice("pipe:".length); - if (!pipePath) { - throw new Error("Broker pipe endpoint is missing its path."); - } - return { kind: "pipe", path: pipePath }; - } - - if (endpoint.startsWith("unix:")) { - const socketPath = endpoint.slice("unix:".length); - if (!socketPath) { - throw new Error("Broker Unix socket endpoint is missing its path."); - } - return { kind: "unix", path: socketPath }; - } - - throw new Error(`Unsupported broker endpoint: ${endpoint}`); -} diff --git a/plugins/codex/scripts/lib/broker-lifecycle.mjs b/plugins/codex/scripts/lib/broker-lifecycle.mjs deleted file mode 100644 index ef76381..0000000 --- a/plugins/codex/scripts/lib/broker-lifecycle.mjs +++ /dev/null @@ -1,209 +0,0 @@ -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; -import { spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { createBrokerEndpoint, parseBrokerEndpoint } from "./broker-endpoint.mjs"; -import { resolveStateDir } from "./state.mjs"; - -export const PID_FILE_ENV = "CODEX_COMPANION_APP_SERVER_PID_FILE"; -export const LOG_FILE_ENV = "CODEX_COMPANION_APP_SERVER_LOG_FILE"; -const BROKER_STATE_FILE = "broker.json"; - -export function createBrokerSessionDir(prefix = "cxc-") { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -function connectToEndpoint(endpoint) { - const target = parseBrokerEndpoint(endpoint); - return net.createConnection({ path: target.path }); -} - -export async function waitForBrokerEndpoint(endpoint, timeoutMs = 2000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const ready = await new Promise((resolve) => { - const socket = connectToEndpoint(endpoint); - socket.on("connect", () => { - socket.end(); - resolve(true); - }); - socket.on("error", () => resolve(false)); - }); - if (ready) { - return true; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - return false; -} - -export async function sendBrokerShutdown(endpoint) { - await new Promise((resolve) => { - const socket = connectToEndpoint(endpoint); - socket.setEncoding("utf8"); - socket.on("connect", () => { - socket.write(`${JSON.stringify({ id: 1, method: "broker/shutdown", params: {} })}\n`); - }); - socket.on("data", () => { - socket.end(); - resolve(); - }); - socket.on("error", resolve); - socket.on("close", resolve); - }); -} - -export function spawnBrokerProcess({ scriptPath, cwd, endpoint, pidFile, logFile, env = process.env }) { - const logFd = fs.openSync(logFile, "a"); - const child = spawn(process.execPath, [scriptPath, "serve", "--endpoint", endpoint, "--cwd", cwd, "--pid-file", pidFile], { - cwd, - env, - detached: true, - stdio: ["ignore", logFd, logFd] - }); - child.unref(); - fs.closeSync(logFd); - return child; -} - -function resolveBrokerStateFile(cwd) { - return path.join(resolveStateDir(cwd), BROKER_STATE_FILE); -} - -export function loadBrokerSession(cwd) { - const stateFile = resolveBrokerStateFile(cwd); - if (!fs.existsSync(stateFile)) { - return null; - } - - try { - return JSON.parse(fs.readFileSync(stateFile, "utf8")); - } catch { - return null; - } -} - -export function saveBrokerSession(cwd, session) { - const stateDir = resolveStateDir(cwd); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(resolveBrokerStateFile(cwd), `${JSON.stringify(session, null, 2)}\n`, "utf8"); -} - -export function clearBrokerSession(cwd) { - const stateFile = resolveBrokerStateFile(cwd); - if (fs.existsSync(stateFile)) { - fs.unlinkSync(stateFile); - } -} - -async function isBrokerEndpointReady(endpoint) { - if (!endpoint) { - return false; - } - try { - return await waitForBrokerEndpoint(endpoint, 150); - } catch { - return false; - } -} - -export async function ensureBrokerSession(cwd, options = {}) { - const existing = loadBrokerSession(cwd); - if (existing && (await isBrokerEndpointReady(existing.endpoint))) { - return existing; - } - - if (existing) { - teardownBrokerSession({ - endpoint: existing.endpoint ?? null, - pidFile: existing.pidFile ?? null, - logFile: existing.logFile ?? null, - sessionDir: existing.sessionDir ?? null, - pid: existing.pid ?? null, - killProcess: options.killProcess ?? null - }); - clearBrokerSession(cwd); - } - - const sessionDir = createBrokerSessionDir(); - const endpointFactory = options.createBrokerEndpoint ?? createBrokerEndpoint; - const endpoint = endpointFactory(sessionDir, options.platform); - const pidFile = path.join(sessionDir, "broker.pid"); - const logFile = path.join(sessionDir, "broker.log"); - const scriptPath = - options.scriptPath ?? - fileURLToPath(new URL("../app-server-broker.mjs", import.meta.url)); - - const child = spawnBrokerProcess({ - scriptPath, - cwd, - endpoint, - pidFile, - logFile, - env: options.env ?? process.env - }); - - const ready = await waitForBrokerEndpoint(endpoint, options.timeoutMs ?? 2000); - if (!ready) { - teardownBrokerSession({ - endpoint, - pidFile, - logFile, - sessionDir, - pid: child.pid ?? null, - killProcess: options.killProcess ?? null - }); - return null; - } - - const session = { - endpoint, - pidFile, - logFile, - sessionDir, - pid: child.pid ?? null - }; - saveBrokerSession(cwd, session); - return session; -} - -export function teardownBrokerSession({ endpoint = null, pidFile, logFile, sessionDir = null, pid = null, killProcess = null }) { - if (Number.isFinite(pid) && killProcess) { - try { - killProcess(pid); - } catch { - // Ignore missing or already-exited broker processes. - } - } - - if (pidFile && fs.existsSync(pidFile)) { - fs.unlinkSync(pidFile); - } - - if (logFile && fs.existsSync(logFile)) { - fs.unlinkSync(logFile); - } - - if (endpoint) { - try { - const target = parseBrokerEndpoint(endpoint); - if (target.kind === "unix" && fs.existsSync(target.path)) { - fs.unlinkSync(target.path); - } - } catch { - // Ignore malformed or already-removed broker endpoints during teardown. - } - } - - const resolvedSessionDir = sessionDir ?? (pidFile ? path.dirname(pidFile) : logFile ? path.dirname(logFile) : null); - if (resolvedSessionDir && fs.existsSync(resolvedSessionDir)) { - try { - fs.rmdirSync(resolvedSessionDir); - } catch { - // Ignore non-empty or missing directories. - } - } -} diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs deleted file mode 100644 index fead00c..0000000 --- a/plugins/codex/scripts/lib/codex.mjs +++ /dev/null @@ -1,1219 +0,0 @@ -/** - * @typedef {import("./app-server-protocol").AppServerNotification} AppServerNotification - * @typedef {import("./app-server-protocol").ReviewTarget} ReviewTarget - * @typedef {import("./app-server-protocol").ThreadItem} ThreadItem - * @typedef {import("./app-server-protocol").ThreadResumeParams} ThreadResumeParams - * @typedef {import("./app-server-protocol").ThreadStartParams} ThreadStartParams - * @typedef {import("./app-server-protocol").Turn} Turn - * @typedef {import("./app-server-protocol").UserInput} UserInput - * @typedef {((update: string | { message: string, phase: string | null, threadId?: string | null, turnId?: string | null, stderrMessage?: string | null, logTitle?: string | null, logBody?: string | null }) => void)} ProgressReporter - * @typedef {{ - * threadId: string, - * rootThreadId: string, - * threadIds: Set, - * threadTurnIds: Map, - * threadLabels: Map, - * turnId: string | null, - * bufferedNotifications: AppServerNotification[], - * completion: Promise, - * resolveCompletion: (state: TurnCaptureState) => void, - * rejectCompletion: (error: unknown) => void, - * finalTurn: Turn | null, - * completed: boolean, - * finalAnswerSeen: boolean, - * pendingCollaborations: Set, - * activeSubagentTurns: Set, - * completionTimer: ReturnType | null, - * lastAgentMessage: string, - * reviewText: string, - * reasoningSummary: string[], - * error: unknown, - * messages: Array<{ lifecycle: string, phase: string | null, text: string }>, - * fileChanges: ThreadItem[], - * commandExecutions: ThreadItem[], - * onProgress: ProgressReporter | null - * }} TurnCaptureState - */ -import crypto from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { readJsonFile } from "./fs.mjs"; -import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs"; -import { loadBrokerSession } from "./broker-lifecycle.mjs"; -import { binaryAvailable } from "./process.mjs"; - -const SERVICE_NAME = "claude_code_codex_plugin"; -const TASK_THREAD_PREFIX = "Codex Companion Task"; -const DEFAULT_CONTINUE_PROMPT = - "Continue from the current thread state. Pick the next highest-value step and follow through until the task is resolved."; -const EXTERNAL_AGENT_IMPORT_COMPLETED = "externalAgentConfig/import/completed"; -const EXTERNAL_AGENT_IMPORT_TIMEOUT_MS = 2 * 60 * 1000; - -function cleanCodexStderr(stderr) { - return stderr - .split(/\r?\n/) - .map((line) => line.trimEnd()) - .filter((line) => line && !line.startsWith("WARNING: proceeding, even though we could not update PATH:")) - .join("\n"); -} - -/** @returns {ThreadStartParams} */ -function buildThreadParams(cwd, options = {}) { - return { - cwd, - model: options.model ?? null, - approvalPolicy: options.approvalPolicy ?? "never", - sandbox: options.sandbox ?? "read-only", - serviceName: SERVICE_NAME, - ephemeral: options.ephemeral ?? true - }; -} - -/** @returns {ThreadResumeParams} */ -function buildResumeParams(threadId, cwd, options = {}) { - return { - threadId, - cwd, - model: options.model ?? null, - approvalPolicy: options.approvalPolicy ?? "never", - sandbox: options.sandbox ?? "read-only" - }; -} - -/** @returns {UserInput[]} */ -function buildTurnInput(prompt) { - return [{ type: "text", text: prompt, text_elements: [] }]; -} - -function shorten(text, limit = 72) { - const normalized = String(text ?? "").trim().replace(/\s+/g, " "); - if (!normalized) { - return ""; - } - if (normalized.length <= limit) { - return normalized; - } - return `${normalized.slice(0, limit - 3)}...`; -} - -function looksLikeVerificationCommand(command) { - return /\b(test|tests|lint|build|typecheck|type-check|check|verify|validate|pytest|jest|vitest|cargo test|npm test|pnpm test|yarn test|go test|mvn test|gradle test|tsc|eslint|ruff)\b/i.test( - command - ); -} - -function buildTaskThreadName(prompt) { - const excerpt = shorten(prompt, 56); - return excerpt ? `${TASK_THREAD_PREFIX}: ${excerpt}` : TASK_THREAD_PREFIX; -} - -function extractThreadId(message) { - return message?.params?.threadId ?? null; -} - -function extractTurnId(message) { - if (message?.params?.turnId) { - return message.params.turnId; - } - if (message?.params?.turn?.id) { - return message.params.turn.id; - } - return null; -} - -function collectTouchedFiles(fileChanges) { - const paths = new Set(); - for (const fileChange of fileChanges) { - for (const change of fileChange.changes ?? []) { - if (change.path) { - paths.add(change.path); - } - } - } - return [...paths]; -} - -function normalizeReasoningText(text) { - return String(text ?? "").replace(/\s+/g, " ").trim(); -} - -function extractReasoningSections(value) { - if (!value) { - return []; - } - - if (typeof value === "string") { - const normalized = normalizeReasoningText(value); - return normalized ? [normalized] : []; - } - - if (Array.isArray(value)) { - return value.flatMap((entry) => extractReasoningSections(entry)); - } - - if (typeof value === "object") { - if (typeof value.text === "string") { - return extractReasoningSections(value.text); - } - if ("summary" in value) { - return extractReasoningSections(value.summary); - } - if ("content" in value) { - return extractReasoningSections(value.content); - } - if ("parts" in value) { - return extractReasoningSections(value.parts); - } - } - - return []; -} - -function mergeReasoningSections(existingSections, nextSections) { - const merged = []; - for (const section of [...existingSections, ...nextSections]) { - const normalized = normalizeReasoningText(section); - if (!normalized || merged.includes(normalized)) { - continue; - } - merged.push(normalized); - } - return merged; -} - -/** - * @param {ProgressReporter | null | undefined} onProgress - * @param {string | null | undefined} message - * @param {string | null | undefined} [phase] - */ -function emitProgress(onProgress, message, phase = null, extra = {}) { - if (!onProgress || !message) { - return; - } - if (!phase && Object.keys(extra).length === 0) { - onProgress(message); - return; - } - onProgress({ message, phase, ...extra }); -} - -function emitLogEvent(onProgress, options = {}) { - if (!onProgress) { - return; - } - - onProgress({ - message: options.message ?? "", - phase: options.phase ?? null, - stderrMessage: options.stderrMessage ?? null, - logTitle: options.logTitle ?? null, - logBody: options.logBody ?? null - }); -} - -function labelForThread(state, threadId) { - if (!threadId || threadId === state.rootThreadId || threadId === state.threadId) { - return null; - } - return state.threadLabels.get(threadId) ?? threadId; -} - -function registerThread(state, threadId, options = {}) { - if (!threadId) { - return; - } - - state.threadIds.add(threadId); - const label = - options.threadName ?? - options.name ?? - options.agentNickname ?? - options.agentRole ?? - state.threadLabels.get(threadId) ?? - null; - if (label) { - state.threadLabels.set(threadId, label); - } -} - -function describeStartedItem(state, item) { - switch (item.type) { - case "enteredReviewMode": - return { message: `Reviewer started: ${item.review}`, phase: "reviewing" }; - case "commandExecution": - return { - message: `Running command: ${shorten(item.command, 96)}`, - phase: looksLikeVerificationCommand(item.command) ? "verifying" : "running" - }; - case "fileChange": - return { message: `Applying ${item.changes.length} file change(s).`, phase: "editing" }; - case "mcpToolCall": - return { message: `Calling ${item.server}/${item.tool}.`, phase: "investigating" }; - case "dynamicToolCall": - return { message: `Running tool: ${item.tool}.`, phase: "investigating" }; - case "collabAgentToolCall": { - const subagents = (item.receiverThreadIds ?? []).map((threadId) => labelForThread(state, threadId) ?? threadId); - const summary = - subagents.length > 0 - ? `Starting subagent ${subagents.join(", ")} via collaboration tool: ${item.tool}.` - : `Starting collaboration tool: ${item.tool}.`; - return { message: summary, phase: "investigating" }; - } - case "webSearch": - return { message: `Searching: ${shorten(item.query, 96)}`, phase: "investigating" }; - default: - return null; - } -} - -function describeCompletedItem(state, item) { - switch (item.type) { - case "commandExecution": { - const exitCode = item.exitCode ?? "?"; - const statusLabel = item.status === "completed" ? "completed" : item.status; - return { - message: `Command ${statusLabel}: ${shorten(item.command, 96)} (exit ${exitCode})`, - phase: looksLikeVerificationCommand(item.command) ? "verifying" : "running" - }; - } - case "fileChange": - return { message: `File changes ${item.status}.`, phase: "editing" }; - case "mcpToolCall": - return { message: `Tool ${item.server}/${item.tool} ${item.status}.`, phase: "investigating" }; - case "dynamicToolCall": - return { message: `Tool ${item.tool} ${item.status}.`, phase: "investigating" }; - case "collabAgentToolCall": { - const subagents = (item.receiverThreadIds ?? []).map((threadId) => labelForThread(state, threadId) ?? threadId); - const summary = - subagents.length > 0 - ? `Subagent ${subagents.join(", ")} ${item.status}.` - : `Collaboration tool ${item.tool} ${item.status}.`; - return { message: summary, phase: "investigating" }; - } - case "exitedReviewMode": - return { message: "Reviewer finished.", phase: "finalizing" }; - default: - return null; - } -} - -/** @returns {TurnCaptureState} */ -function createTurnCaptureState(threadId, options = {}) { - let resolveCompletion; - let rejectCompletion; - const completion = new Promise((resolve, reject) => { - resolveCompletion = resolve; - rejectCompletion = reject; - }); - - return { - threadId, - rootThreadId: threadId, - threadIds: new Set([threadId]), - threadTurnIds: new Map(), - threadLabels: new Map(), - turnId: null, - bufferedNotifications: [], - completion, - resolveCompletion, - rejectCompletion, - finalTurn: null, - completed: false, - finalAnswerSeen: false, - pendingCollaborations: new Set(), - activeSubagentTurns: new Set(), - completionTimer: null, - lastAgentMessage: "", - reviewText: "", - reasoningSummary: [], - error: null, - messages: [], - fileChanges: [], - commandExecutions: [], - onProgress: options.onProgress ?? null - }; -} - -function clearCompletionTimer(state) { - if (state.completionTimer) { - clearTimeout(state.completionTimer); - state.completionTimer = null; - } -} - -function completeTurn(state, turn = null, options = {}) { - if (state.completed) { - return; - } - - clearCompletionTimer(state); - state.completed = true; - - if (turn) { - state.finalTurn = turn; - if (!state.turnId) { - state.turnId = turn.id; - } - } else if (!state.finalTurn) { - state.finalTurn = { - id: state.turnId ?? "inferred-turn", - status: "completed" - }; - } - - if (options.inferred) { - emitProgress(state.onProgress, "Turn completion inferred after the main thread finished and subagent work drained.", "finalizing"); - } - - state.resolveCompletion(state); -} - -function scheduleInferredCompletion(state) { - if (state.completed || state.finalTurn || !state.finalAnswerSeen) { - return; - } - - if (state.pendingCollaborations.size > 0 || state.activeSubagentTurns.size > 0) { - return; - } - - clearCompletionTimer(state); - state.completionTimer = setTimeout(() => { - state.completionTimer = null; - if (state.completed || state.finalTurn || !state.finalAnswerSeen) { - return; - } - if (state.pendingCollaborations.size > 0 || state.activeSubagentTurns.size > 0) { - return; - } - completeTurn(state, null, { inferred: true }); - }, 250); - state.completionTimer.unref?.(); -} - -function belongsToTurn(state, message) { - const messageThreadId = extractThreadId(message); - if (!messageThreadId || !state.threadIds.has(messageThreadId)) { - return false; - } - const trackedTurnId = state.threadTurnIds.get(messageThreadId) ?? null; - const messageTurnId = extractTurnId(message); - return trackedTurnId === null || messageTurnId === null || messageTurnId === trackedTurnId; -} - -function recordItem(state, item, lifecycle, threadId = null) { - if (item.type === "collabAgentToolCall") { - if (!threadId || threadId === state.threadId) { - if (lifecycle === "started" || item.status === "inProgress") { - state.pendingCollaborations.add(item.id); - } else if (lifecycle === "completed") { - state.pendingCollaborations.delete(item.id); - scheduleInferredCompletion(state); - } - } - for (const receiverThreadId of item.receiverThreadIds ?? []) { - registerThread(state, receiverThreadId); - } - } - - if (item.type === "agentMessage") { - state.messages.push({ - lifecycle, - phase: item.phase ?? null, - text: item.text ?? "" - }); - if (item.text) { - if (!threadId || threadId === state.threadId) { - state.lastAgentMessage = item.text; - if (lifecycle === "completed" && item.phase === "final_answer") { - state.finalAnswerSeen = true; - scheduleInferredCompletion(state); - } - } - if (lifecycle === "completed") { - const sourceLabel = labelForThread(state, threadId); - emitLogEvent(state.onProgress, { - message: sourceLabel ? `Subagent ${sourceLabel}: ${shorten(item.text, 96)}` : `Assistant message captured: ${shorten(item.text, 96)}`, - stderrMessage: null, - phase: item.phase === "final_answer" ? "finalizing" : null, - logTitle: sourceLabel ? `Subagent ${sourceLabel} message` : "Assistant message", - logBody: item.text - }); - } - } - return; - } - - if (item.type === "exitedReviewMode") { - state.reviewText = item.review ?? ""; - if (lifecycle === "completed" && item.review) { - emitLogEvent(state.onProgress, { - message: "Review output captured.", - stderrMessage: null, - phase: "finalizing", - logTitle: "Review output", - logBody: item.review - }); - } - return; - } - - if (item.type === "reasoning" && lifecycle === "completed") { - const nextSections = extractReasoningSections(item.summary); - state.reasoningSummary = mergeReasoningSections(state.reasoningSummary, nextSections); - if (nextSections.length > 0) { - const sourceLabel = labelForThread(state, threadId); - emitLogEvent(state.onProgress, { - message: sourceLabel - ? `Subagent ${sourceLabel} reasoning: ${shorten(nextSections[0], 96)}` - : `Reasoning summary captured: ${shorten(nextSections[0], 96)}`, - stderrMessage: null, - logTitle: sourceLabel ? `Subagent ${sourceLabel} reasoning summary` : "Reasoning summary", - logBody: nextSections.map((section) => `- ${section}`).join("\n") - }); - } - return; - } - - if (item.type === "fileChange" && lifecycle === "completed") { - state.fileChanges.push(item); - return; - } - - if (item.type === "commandExecution" && lifecycle === "completed") { - state.commandExecutions.push(item); - } -} - -function applyTurnNotification(state, message) { - switch (message.method) { - case "thread/started": - registerThread(state, message.params.thread.id, { - threadName: message.params.thread.name, - name: message.params.thread.name, - agentNickname: message.params.thread.agentNickname, - agentRole: message.params.thread.agentRole - }); - break; - case "thread/name/updated": - registerThread(state, message.params.threadId, { - threadName: message.params.threadName ?? null - }); - break; - case "turn/started": - registerThread(state, message.params.threadId); - state.threadTurnIds.set(message.params.threadId, message.params.turn.id); - if ((message.params.threadId ?? null) !== state.threadId) { - state.activeSubagentTurns.add(message.params.threadId); - } - emitProgress( - state.onProgress, - `Turn started (${message.params.turn.id}).`, - "starting", - (message.params.threadId ?? null) === state.threadId - ? { - threadId: message.params.threadId ?? null, - turnId: message.params.turn.id ?? null - } - : {} - ); - break; - case "item/started": - recordItem(state, message.params.item, "started", message.params.threadId ?? null); - { - const update = describeStartedItem(state, message.params.item); - emitProgress(state.onProgress, update?.message, update?.phase ?? null); - } - break; - case "item/completed": - recordItem(state, message.params.item, "completed", message.params.threadId ?? null); - { - const update = describeCompletedItem(state, message.params.item); - emitProgress(state.onProgress, update?.message, update?.phase ?? null); - } - break; - case "error": - state.error = message.params.error; - emitProgress(state.onProgress, `Codex error: ${message.params.error.message}`, "failed"); - break; - case "turn/completed": - if ((message.params.threadId ?? null) !== state.threadId) { - state.activeSubagentTurns.delete(message.params.threadId); - scheduleInferredCompletion(state); - break; - } - emitProgress( - state.onProgress, - `Turn ${message.params.turn.status === "completed" ? "completed" : message.params.turn.status}.`, - "finalizing" - ); - completeTurn(state, message.params.turn); - break; - default: - break; - } -} - -async function captureTurn(client, threadId, startRequest, options = {}) { - const state = createTurnCaptureState(threadId, options); - const previousHandler = client.notificationHandler; - - client.setNotificationHandler((message) => { - if (!state.turnId) { - state.bufferedNotifications.push(message); - return; - } - - if (message.method === "thread/started" || message.method === "thread/name/updated") { - applyTurnNotification(state, message); - return; - } - - if (!belongsToTurn(state, message)) { - if (previousHandler) { - previousHandler(message); - } - return; - } - - applyTurnNotification(state, message); - }); - - try { - const response = await startRequest(); - options.onResponse?.(response, state); - state.turnId = response.turn?.id ?? null; - if (state.turnId) { - state.threadTurnIds.set(state.threadId, state.turnId); - } - for (const message of state.bufferedNotifications) { - if (belongsToTurn(state, message)) { - applyTurnNotification(state, message); - } else { - if (previousHandler) { - previousHandler(message); - } - } - } - state.bufferedNotifications.length = 0; - - if (response.turn?.status && response.turn.status !== "inProgress") { - completeTurn(state, response.turn); - } - - return await state.completion; - } finally { - clearCompletionTimer(state); - client.setNotificationHandler(previousHandler ?? null); - } -} - -async function withAppServer(cwd, fn) { - let client = null; - try { - client = await CodexAppServerClient.connect(cwd); - const result = await fn(client); - await client.close(); - return result; - } catch (error) { - const brokerRequested = client?.transport === "broker" || Boolean(process.env[BROKER_ENDPOINT_ENV]); - const shouldRetryDirect = - (client?.transport === "broker" && error?.rpcCode === BROKER_BUSY_RPC_CODE) || - (brokerRequested && (error?.code === "ENOENT" || error?.code === "ECONNREFUSED")); - - if (client) { - await client.close().catch(() => {}); - client = null; - } - - if (!shouldRetryDirect) { - throw error; - } - - const directClient = await CodexAppServerClient.connect(cwd, { disableBroker: true }); - try { - return await fn(directClient); - } finally { - await directClient.close(); - } - } -} - -async function withDirectAppServer(cwd, fn) { - const client = await CodexAppServerClient.connect(cwd, { disableBroker: true }); - try { - return await fn(client); - } finally { - await client.close(); - } -} - -function resolveCodexHome() { - return path.resolve(process.env.CODEX_HOME || path.join(os.homedir(), ".codex")); -} - -function sourceContentSha256(sourcePath) { - return crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"); -} - -function importedThreadIdForSource(sourcePath) { - const ledgerPath = path.join(resolveCodexHome(), "external_agent_session_imports.json"); - if (!fs.existsSync(ledgerPath)) { - return null; - } - const ledger = readJsonFile(ledgerPath); - const canonicalSource = fs.realpathSync(sourcePath); - const contentSha256 = sourceContentSha256(canonicalSource); - const records = Array.isArray(ledger?.records) ? ledger.records : []; - const match = records - .filter( - (record) => - record?.source_path === canonicalSource && - record?.content_sha256 === contentSha256 && - typeof record?.imported_thread_id === "string" - ) - .at(-1); - return match?.imported_thread_id ?? null; -} - -function externalAgentSessionMigration(sourcePath, cwd) { - return { - migrationItems: [ - { - itemType: "SESSIONS", - description: `Transfer Claude session ${path.basename(sourcePath)}`, - cwd: null, - details: { - plugins: [], - sessions: [{ path: sourcePath, cwd, title: null }], - mcpServers: [], - hooks: [], - subagents: [], - commands: [] - } - } - ] - }; -} - -async function requestExternalAgentSessionImport(client, params) { - const previousHandler = client.notificationHandler; - let timeout = null; - let resolveCompleted; - let rejectCompleted; - const completed = new Promise((resolve, reject) => { - resolveCompleted = resolve; - rejectCompleted = reject; - }); - void completed.catch(() => {}); - - client.setNotificationHandler((message) => { - if (message.method === EXTERNAL_AGENT_IMPORT_COMPLETED) { - resolveCompleted(); - return; - } - previousHandler?.(message); - }); - timeout = setTimeout(() => { - rejectCompleted(new Error("Timed out waiting for Codex to finish importing the Claude session.")); - }, EXTERNAL_AGENT_IMPORT_TIMEOUT_MS); - - try { - await client.request("externalAgentConfig/import", params); - await completed; - } finally { - clearTimeout(timeout); - client.setNotificationHandler(previousHandler ?? null); - } -} - -async function startThread(client, cwd, options = {}) { - const response = await client.request("thread/start", buildThreadParams(cwd, options)); - const threadId = response.thread.id; - if (options.threadName) { - try { - await client.request("thread/name/set", { threadId, name: options.threadName }); - } catch (err) { - // Only suppress "unknown variant/method" errors from older CLI versions - // that don't support thread/name/set. Rethrow auth, network, or server errors. - const msg = String(err?.message ?? err ?? ""); - if (!msg.includes("unknown variant") && !msg.includes("unknown method")) { - throw err; - } - } - } - return response; -} - -async function resumeThread(client, threadId, cwd, options = {}) { - return client.request("thread/resume", buildResumeParams(threadId, cwd, options)); -} - -function buildResultStatus(turnState) { - return turnState.finalTurn?.status === "completed" ? 0 : 1; -} - -const BUILTIN_PROVIDER_LABELS = new Map([ - ["openai", "OpenAI"], - ["ollama", "Ollama"], - ["lmstudio", "LM Studio"] -]); - -function normalizeProviderId(value) { - const providerId = typeof value === "string" ? value.trim() : ""; - return providerId || null; -} - -function formatProviderLabel(providerId, providerConfig = null) { - const configuredName = typeof providerConfig?.name === "string" ? providerConfig.name.trim() : ""; - if (configuredName) { - return configuredName; - } - if (!providerId) { - return "The active provider"; - } - return BUILTIN_PROVIDER_LABELS.get(providerId) ?? providerId; -} - -function buildAuthStatus(fields = {}) { - return { - available: true, - loggedIn: false, - detail: "not authenticated", - source: "unknown", - authMethod: null, - verified: null, - requiresOpenaiAuth: null, - provider: null, - ...fields - }; -} - -function resolveProviderConfig(configResponse) { - const config = configResponse?.config; - if (!config || typeof config !== "object") { - return { - providerId: null, - providerConfig: null - }; - } - - const providerId = normalizeProviderId(config.model_provider); - const providers = - config.model_providers && typeof config.model_providers === "object" && !Array.isArray(config.model_providers) - ? config.model_providers - : null; - const providerConfig = - providerId && providers?.[providerId] && typeof providers[providerId] === "object" ? providers[providerId] : null; - - return { - providerId, - providerConfig - }; -} - -function buildAppServerAuthStatus(accountResponse, configResponse) { - const account = accountResponse?.account ?? null; - const requiresOpenaiAuth = - typeof accountResponse?.requiresOpenaiAuth === "boolean" ? accountResponse.requiresOpenaiAuth : null; - const { providerId, providerConfig } = resolveProviderConfig(configResponse); - const providerLabel = formatProviderLabel(providerId, providerConfig); - - if (account?.type === "chatgpt") { - const email = typeof account.email === "string" && account.email.trim() ? account.email.trim() : null; - return buildAuthStatus({ - loggedIn: true, - detail: email ? `ChatGPT login active for ${email}` : "ChatGPT login active", - source: "app-server", - authMethod: "chatgpt", - verified: true, - requiresOpenaiAuth, - provider: providerId - }); - } - - if (account?.type === "apiKey") { - return buildAuthStatus({ - loggedIn: true, - detail: "API key configured (unverified)", - source: "app-server", - authMethod: "apiKey", - verified: false, - requiresOpenaiAuth, - provider: providerId - }); - } - - if (requiresOpenaiAuth === false) { - return buildAuthStatus({ - loggedIn: true, - detail: `${providerLabel} is configured and does not require OpenAI authentication`, - source: "app-server", - requiresOpenaiAuth, - provider: providerId - }); - } - - return buildAuthStatus({ - loggedIn: false, - detail: `${providerLabel} requires OpenAI authentication`, - source: "app-server", - requiresOpenaiAuth, - provider: providerId - }); -} - -async function getCodexAuthStatusFromClient(client, cwd) { - try { - const accountResponse = await client.request("account/read", { refreshToken: false }); - const configResponse = await client.request("config/read", { - includeLayers: false, - cwd - }); - - return buildAppServerAuthStatus(accountResponse, configResponse); - } catch (error) { - return buildAuthStatus({ - loggedIn: false, - detail: error instanceof Error ? error.message : String(error), - source: "app-server" - }); - } -} - -export function getCodexAvailability(cwd) { - const versionStatus = binaryAvailable("codex", ["--version"], { cwd }); - if (!versionStatus.available) { - return versionStatus; - } - - const appServerStatus = binaryAvailable("codex", ["app-server", "--help"], { cwd }); - if (!appServerStatus.available) { - return { - available: false, - detail: `${versionStatus.detail}; advanced runtime unavailable: ${appServerStatus.detail}` - }; - } - - return { - available: true, - detail: `${versionStatus.detail}; advanced runtime available` - }; -} - -export function getSessionRuntimeStatus(env = process.env, cwd = process.cwd()) { - const endpoint = env?.[BROKER_ENDPOINT_ENV] ?? loadBrokerSession(cwd)?.endpoint ?? null; - if (endpoint) { - return { - mode: "shared", - label: "shared session", - detail: "This Claude session is configured to reuse one shared Codex runtime.", - endpoint - }; - } - - return { - mode: "direct", - label: "direct startup", - detail: "No shared Codex runtime is active yet. The first review or task command will start one on demand.", - endpoint: null - }; -} - -export async function getCodexAuthStatus(cwd, options = {}) { - const availability = getCodexAvailability(cwd); - if (!availability.available) { - return { - available: false, - loggedIn: false, - detail: availability.detail, - source: "availability", - authMethod: null, - verified: null, - requiresOpenaiAuth: null, - provider: null - }; - } - - let client = null; - try { - client = await CodexAppServerClient.connect(cwd, { - env: options.env, - reuseExistingBroker: true - }); - return await getCodexAuthStatusFromClient(client, cwd); - } catch (error) { - return buildAuthStatus({ - loggedIn: false, - detail: error instanceof Error ? error.message : String(error), - source: "app-server" - }); - } finally { - if (client) { - await client.close().catch(() => {}); - } - } -} - -export async function interruptAppServerTurn(cwd, { threadId, turnId }) { - if (!threadId || !turnId) { - return { - attempted: false, - interrupted: false, - transport: null, - detail: "missing threadId or turnId" - }; - } - - const availability = getCodexAvailability(cwd); - if (!availability.available) { - return { - attempted: false, - interrupted: false, - transport: null, - detail: availability.detail - }; - } - - let client = null; - try { - client = await CodexAppServerClient.connect(cwd, { reuseExistingBroker: true }); - await client.request("turn/interrupt", { threadId, turnId }); - return { - attempted: true, - interrupted: true, - transport: client.transport, - detail: `Interrupted ${turnId} on ${threadId}.` - }; - } catch (error) { - return { - attempted: true, - interrupted: false, - transport: client?.transport ?? null, - detail: error instanceof Error ? error.message : String(error) - }; - } finally { - await client?.close().catch(() => {}); - } -} - -export async function runAppServerReview(cwd, options = {}) { - const availability = getCodexAvailability(cwd); - if (!availability.available) { - throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`."); - } - - return withAppServer(cwd, async (client) => { - emitProgress(options.onProgress, "Starting Codex review thread.", "starting"); - const thread = await startThread(client, cwd, { - model: options.model, - sandbox: "read-only", - ephemeral: true, - threadName: options.threadName - }); - const sourceThreadId = thread.thread.id; - emitProgress(options.onProgress, `Thread ready (${sourceThreadId}).`, "starting", { - threadId: sourceThreadId - }); - const delivery = options.delivery ?? "inline"; - - const turnState = await captureTurn( - client, - sourceThreadId, - () => - client.request("review/start", { - threadId: sourceThreadId, - delivery, - target: options.target - }), - { - onProgress: options.onProgress, - onResponse(response, state) { - if (response.reviewThreadId) { - state.threadIds.add(response.reviewThreadId); - if (delivery === "detached") { - state.threadId = response.reviewThreadId; - } - } - } - } - ); - - return { - status: buildResultStatus(turnState), - threadId: turnState.threadId, - sourceThreadId, - turnId: turnState.turnId, - reviewText: turnState.reviewText, - reasoningSummary: turnState.reasoningSummary, - turn: turnState.finalTurn, - error: turnState.error, - stderr: cleanCodexStderr(client.stderr) - }; - }); -} - -export async function importExternalAgentSession(cwd, options = {}) { - const availability = getCodexAvailability(cwd); - if (!availability.available) { - throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`."); - } - if (!options.sourcePath) { - throw new Error("A Claude session source path is required."); - } - - return withDirectAppServer(cwd, async (client) => { - emitProgress(options.onProgress, "Importing Claude session into Codex.", "transferring"); - try { - await requestExternalAgentSessionImport(client, externalAgentSessionMigration(options.sourcePath, cwd)); - } catch (error) { - if (error?.rpcCode === -32601) { - throw new Error( - "This Codex version does not support Claude session transfer. Update Codex with `npm install -g @openai/codex@latest`, then retry.", - { cause: error } - ); - } - throw error; - } - const threadId = importedThreadIdForSource(options.sourcePath); - if (!threadId) { - const stderr = cleanCodexStderr(client.stderr); - throw new Error( - `Codex reported that the Claude import completed, but did not record an imported thread.${stderr ? `\n${stderr}` : " Check the Codex app-server logs for the underlying import error."}` - ); - } - emitProgress(options.onProgress, `Claude session imported (${threadId}).`, "completed", { threadId }); - return { - threadId, - stderr: cleanCodexStderr(client.stderr) - }; - }); -} - -export async function runAppServerTurn(cwd, options = {}) { - const availability = getCodexAvailability(cwd); - if (!availability.available) { - throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`."); - } - - return withAppServer(cwd, async (client) => { - let threadId; - - if (options.resumeThreadId) { - emitProgress(options.onProgress, `Resuming thread ${options.resumeThreadId}.`, "starting"); - const response = await resumeThread(client, options.resumeThreadId, cwd, { - model: options.model, - sandbox: options.sandbox, - ephemeral: false - }); - threadId = response.thread.id; - } else { - emitProgress(options.onProgress, "Starting Codex task thread.", "starting"); - const response = await startThread(client, cwd, { - model: options.model, - sandbox: options.sandbox, - ephemeral: options.persistThread ? false : true, - threadName: options.persistThread ? options.threadName : options.threadName ?? null - }); - threadId = response.thread.id; - } - - emitProgress(options.onProgress, `Thread ready (${threadId}).`, "starting", { - threadId - }); - - const prompt = options.prompt?.trim() || options.defaultPrompt || ""; - if (!prompt) { - throw new Error("A prompt is required for this Codex run."); - } - - const turnState = await captureTurn( - client, - threadId, - () => - client.request("turn/start", { - threadId, - input: buildTurnInput(prompt), - model: options.model ?? null, - effort: options.effort ?? null, - outputSchema: options.outputSchema ?? null - }), - { onProgress: options.onProgress } - ); - - return { - status: buildResultStatus(turnState), - threadId, - turnId: turnState.turnId, - finalMessage: turnState.lastAgentMessage, - reasoningSummary: turnState.reasoningSummary, - turn: turnState.finalTurn, - error: turnState.error, - stderr: cleanCodexStderr(client.stderr), - fileChanges: turnState.fileChanges, - touchedFiles: collectTouchedFiles(turnState.fileChanges), - commandExecutions: turnState.commandExecutions - }; - }); -} - -export async function findLatestTaskThread(cwd) { - const availability = getCodexAvailability(cwd); - if (!availability.available) { - throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`."); - } - - return withAppServer(cwd, async (client) => { - const response = await client.request("thread/list", { - cwd, - limit: 20, - sortKey: "updated_at", - sourceKinds: ["appServer"], - searchTerm: TASK_THREAD_PREFIX - }); - - return ( - response.data.find((thread) => typeof thread.name === "string" && thread.name.startsWith(TASK_THREAD_PREFIX)) ?? - null - ); - }); -} - -export function buildPersistentTaskThreadName(prompt) { - return buildTaskThreadName(prompt); -} - -export function parseStructuredOutput(rawOutput, fallback = {}) { - if (!rawOutput) { - return { - parsed: null, - parseError: fallback.failureMessage ?? "Codex did not return a final structured message.", - rawOutput: rawOutput ?? "", - ...fallback - }; - } - - try { - return { - parsed: JSON.parse(rawOutput), - parseError: null, - rawOutput, - ...fallback - }; - } catch (error) { - return { - parsed: null, - parseError: error.message, - rawOutput, - ...fallback - }; - } -} - -export function readOutputSchema(schemaPath) { - return readJsonFile(schemaPath); -} - -export { DEFAULT_CONTINUE_PROMPT, TASK_THREAD_PREFIX }; diff --git a/plugins/codex/skills/codex-result-handling/SKILL.md b/plugins/codex/skills/codex-result-handling/SKILL.md deleted file mode 100644 index e189654..0000000 --- a/plugins/codex/skills/codex-result-handling/SKILL.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: codex-result-handling -description: Internal guidance for presenting Codex helper output back to the user -user-invocable: false ---- - -# Codex Result Handling - -When the helper returns Codex output: -- Preserve the helper's verdict, summary, findings, and next steps structure. -- For review output, present findings first and keep them ordered by severity. -- Use the file paths and line numbers exactly as the helper reports them. -- Preserve evidence boundaries. If Codex marked something as an inference, uncertainty, or follow-up question, keep that distinction. -- Preserve output sections when the prompt asked for them, such as observed facts, inferences, open questions, touched files, or next steps. -- If there are no findings, say that explicitly and keep the residual-risk note brief. -- If Codex made edits, say so explicitly and list the touched files when the helper provides them. -- For `codex:codex-rescue`, do not turn a failed or incomplete Codex run into a Claude-side implementation attempt. Report the failure and stop. -- For `codex:codex-rescue`, if Codex was never successfully invoked, do not generate a substitute answer at all. -- CRITICAL: After presenting review findings, STOP. Do not make any code changes. Do not fix any issues. You MUST explicitly ask the user which issues, if any, they want fixed before touching a single file. Auto-applying fixes from a review is strictly forbidden, even if the fix is obvious. -- If the helper reports malformed output or a failed Codex run, include the most actionable stderr lines and stop there instead of guessing. -- If the helper reports that setup or authentication is required, direct the user to `/codex:setup` and do not improvise alternate auth flows. diff --git a/plugins/opencode/.claude-plugin/plugin.json b/plugins/opencode/.claude-plugin/plugin.json new file mode 100644 index 0000000..f84adb6 --- /dev/null +++ b/plugins/opencode/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "opencode", + "version": "1.0.5", + "description": "Use OpenCode from Claude Code to review code or delegate tasks.", + "author": { + "name": "OpenCode" + } +} diff --git a/plugins/opencode/CHANGELOG.md b/plugins/opencode/CHANGELOG.md new file mode 100644 index 0000000..f3667cf --- /dev/null +++ b/plugins/opencode/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +- Initial version of the OpenCode plugin for Claude Code diff --git a/plugins/codex/LICENSE b/plugins/opencode/LICENSE similarity index 100% rename from plugins/codex/LICENSE rename to plugins/opencode/LICENSE diff --git a/plugins/codex/NOTICE b/plugins/opencode/NOTICE similarity index 100% rename from plugins/codex/NOTICE rename to plugins/opencode/NOTICE diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/opencode/agents/opencode-rescue.md similarity index 54% rename from plugins/codex/agents/codex-rescue.md rename to plugins/opencode/agents/opencode-rescue.md index 7009ec8..7a41915 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/opencode/agents/opencode-rescue.md @@ -1,46 +1,46 @@ --- -name: codex-rescue -description: Proactively use when Claude Code is stuck, wants a second implementation or diagnosis pass, needs a deeper root-cause investigation, or should hand a substantial coding task to Codex through the shared runtime +name: opencode-rescue +description: Proactively use when Claude Code is stuck, wants a second implementation or diagnosis pass, needs a deeper root-cause investigation, or should hand a substantial coding task to OpenCode through the shared runtime model: sonnet tools: Bash skills: - - codex-cli-runtime + - opencode-cli-runtime - gpt-5-4-prompting --- -You are a thin forwarding wrapper around the Codex companion task runtime. +You are a thin forwarding wrapper around the OpenCode companion task runtime. -Your only job is to forward the user's rescue request to the Codex companion script. Do not do anything else. +Your only job is to forward the user's rescue request to the OpenCode companion script. Do not do anything else. Selection guidance: -- Do not wait for the user to explicitly ask for Codex. Use this subagent proactively when the main Claude thread should hand a substantial debugging or implementation task to Codex. +- Do not wait for the user to explicitly ask for OpenCode. Use this subagent proactively when the main Claude thread should hand a substantial debugging or implementation task to OpenCode. - Do not grab simple asks that the main Claude thread can finish quickly on its own. Forwarding rules: -- Use exactly one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task ...`. +- Use exactly one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" task ...`. - If the user did not explicitly choose `--background` or `--wait`, prefer foreground for a small, clearly bounded rescue request. -- If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution. -- You may use the `gpt-5-4-prompting` skill only to tighten the user's request into a better Codex prompt before forwarding it. +- If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep OpenCode running for a long time, prefer background execution. +- You may use the `gpt-5-4-prompting` skill only to tighten the user's request into a better OpenCode prompt before forwarding it. - Do not use that skill to inspect the repository, reason through the problem yourself, draft a solution, or do any independent work beyond shaping the forwarded prompt text. - Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own. - Do not call `review`, `adversarial-review`, `status`, `result`, or `cancel`. This subagent only forwards to `task`. - Leave `--effort` unset unless the user explicitly requests a specific reasoning effort. - Leave model unset by default. Only add `--model` when the user explicitly asks for a specific model. -- If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`. -- If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`. +- If the user asks for `spark`, map that to `--model openai/gpt-5.3-codex-spark`. +- If the user asks for a concrete model name such as `openai/gpt-5.4-mini`, pass it through with `--model`. - Treat `--effort ` and `--model ` as runtime controls and do not include them in the task text you pass through. -- Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. +- Default to a write-capable OpenCode run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. - Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through. - `--resume` means add `--resume-last`. - `--fresh` means do not add `--resume-last`. -- If the user is clearly asking to continue prior Codex work in this repository, such as "continue", "keep going", "resume", "apply the top fix", or "dig deeper", add `--resume-last` unless `--fresh` is present. +- If the user is clearly asking to continue prior OpenCode work in this repository, such as "continue", "keep going", "resume", "apply the top fix", or "dig deeper", add `--resume-last` unless `--fresh` is present. - Otherwise forward the task as a fresh `task` run. - Preserve the user's task text as-is apart from stripping routing flags. -- Return the stdout of the `codex-companion` command exactly as-is. -- If the Bash call fails or Codex cannot be invoked, return nothing. +- Return the stdout of the `opencode-companion` command exactly as-is. +- If the Bash call fails or OpenCode cannot be invoked, return nothing. Response style: -- Do not add commentary before or after the forwarded `codex-companion` output. +- Do not add commentary before or after the forwarded `opencode-companion` output. diff --git a/plugins/codex/commands/adversarial-review.md b/plugins/opencode/commands/adversarial-review.md similarity index 77% rename from plugins/codex/commands/adversarial-review.md rename to plugins/opencode/commands/adversarial-review.md index da440ab..5d5577b 100644 --- a/plugins/codex/commands/adversarial-review.md +++ b/plugins/opencode/commands/adversarial-review.md @@ -1,11 +1,11 @@ --- -description: Run a Codex review that challenges the implementation approach and design choices +description: Run a OpenCode review that challenges the implementation approach and design choices argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch] [focus ...]' disable-model-invocation: true allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion --- -Run an adversarial Codex review through the shared plugin runtime. +Run an adversarial OpenCode review through the shared plugin runtime. Position it as a challenge review that questions the chosen implementation, design choices, tradeoffs, and assumptions. It is not just a stricter pass over implementation defects. @@ -15,7 +15,7 @@ Raw slash-command arguments: Core constraint: - This command is review-only. - Do not fix issues, apply patches, or suggest that you are about to make changes. -- Your only job is to run the review and return Codex's output verbatim to the user. +- Your only job is to run the review and return OpenCode's output verbatim to the user. - Keep the framing focused on whether the current approach is the right one, what assumptions it depends on, and where the design could fail under real-world conditions. Execution mode rules: @@ -39,15 +39,15 @@ Argument handling: - Do not strip `--wait` or `--background` yourself. - Do not weaken the adversarial framing or rewrite the user's focus text. - The companion script parses `--wait` and `--background`, but Claude Code's `Bash(..., run_in_background: true)` is what actually detaches the run. -- `/codex:adversarial-review` uses the same review target selection as `/codex:review`. +- `/opencode:adversarial-review` uses the same review target selection as `/opencode:review`. - It supports working-tree review, branch review, and `--base `. - It does not support `--scope staged` or `--scope unstaged`. -- Unlike `/codex:review`, it can still take extra focus text after the flags. +- Unlike `/opencode:review`, it can still take extra focus text after the flags. Foreground flow: - Run: ```bash -node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" adversarial-review "$ARGUMENTS" +node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" adversarial-review "$ARGUMENTS" ``` - Return the command stdout verbatim, exactly as-is. - Do not paraphrase, summarize, or add commentary before or after it. @@ -57,10 +57,10 @@ Background flow: - Launch the review with `Bash` in the background: ```typescript Bash({ - command: `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" adversarial-review "$ARGUMENTS"`, - description: "Codex adversarial review", + command: `node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" adversarial-review "$ARGUMENTS"`, + description: "OpenCode adversarial review", run_in_background: true }) ``` - Do not call `BashOutput` or wait for completion in this turn. -- After launching the command, tell the user: "Codex adversarial review started in the background. Check `/codex:status` for progress." +- After launching the command, tell the user: "OpenCode adversarial review started in the background. Check `/opencode:status` for progress." diff --git a/plugins/opencode/commands/cancel.md b/plugins/opencode/commands/cancel.md new file mode 100644 index 0000000..786b9f3 --- /dev/null +++ b/plugins/opencode/commands/cancel.md @@ -0,0 +1,8 @@ +--- +description: Cancel an active background OpenCode job in this repository +argument-hint: '[job-id]' +disable-model-invocation: true +allowed-tools: Bash(node:*) +--- + +!`node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" cancel "$ARGUMENTS"` diff --git a/plugins/opencode/commands/rescue.md b/plugins/opencode/commands/rescue.md new file mode 100644 index 0000000..3490288 --- /dev/null +++ b/plugins/opencode/commands/rescue.md @@ -0,0 +1,49 @@ +--- +description: Delegate investigation, an explicit fix request, or follow-up rescue work to the OpenCode rescue subagent +argument-hint: "[--background|--wait] [--resume|--fresh] [--model ] [--effort ] [what OpenCode should investigate, solve, or continue]" +allowed-tools: Bash(node:*), AskUserQuestion, Agent +--- + +Invoke the `opencode:opencode-rescue` subagent via the `Agent` tool (`subagent_type: "opencode:opencode-rescue"`), forwarding the raw user request as the prompt. +`opencode:opencode-rescue` is a subagent, not a skill. Do not call `Skill(opencode:opencode-rescue)` or `Skill(opencode:rescue)`. The command runs inline so the `Agent` tool stays in scope; forked general-purpose subagents do not expose it. +The final user-visible response must be OpenCode's output verbatim. + +Raw user request: +$ARGUMENTS + +Execution mode: + +- If the request includes `--background`, run the `opencode:opencode-rescue` subagent in the background. +- If the request includes `--wait`, run the `opencode:opencode-rescue` subagent in the foreground. +- If neither flag is present, default to foreground. +- `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. +- `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. +- If the request includes `--resume`, do not ask whether to continue. The user already chose. +- If the request includes `--fresh`, do not ask whether to continue. The user already chose. +- Otherwise, before starting OpenCode, check for a resumable rescue thread from this Claude session by running: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" task-resume-candidate --json +``` + +- If that helper reports `available: true`, use `AskUserQuestion` exactly once to ask whether to continue the current OpenCode thread or start a new one. +- The two choices must be: + - `Continue current OpenCode thread` + - `Start a new OpenCode thread` +- If the user is clearly giving a follow-up instruction such as "continue", "keep going", "resume", "apply the top fix", or "dig deeper", put `Continue current OpenCode thread (Recommended)` first. +- Otherwise put `Start a new OpenCode thread (Recommended)` first. +- If the user chooses continue, add `--resume` before routing to the subagent. +- If the user chooses a new thread, add `--fresh` before routing to the subagent. +- If the helper reports `available: false`, do not ask. Route normally. + +Operating rules: + +- The subagent is a thin forwarder only. It should use one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" task ...` and return that command's stdout as-is. +- Return the OpenCode companion stdout verbatim to the user. +- Do not paraphrase, summarize, rewrite, or add commentary before or after it. +- Do not ask the subagent to inspect files, monitor progress, poll `/opencode:status`, fetch `/opencode:result`, call `/opencode:cancel`, summarize output, or do follow-up work of its own. +- Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort. +- Leave the model unset unless the user explicitly asks for one. If they ask for `spark`, map it to `openai/gpt-5.3-codex-spark`. +- Leave `--resume` and `--fresh` in the forwarded request. The subagent handles that routing when it builds the `task` command. +- If the helper reports that OpenCode is missing or unauthenticated, stop and tell the user to run `/opencode:setup`. +- If the user did not supply a request, ask what OpenCode should investigate or fix. diff --git a/plugins/codex/commands/result.md b/plugins/opencode/commands/result.md similarity index 62% rename from plugins/codex/commands/result.md rename to plugins/opencode/commands/result.md index 3abc2d9..c9350c9 100644 --- a/plugins/codex/commands/result.md +++ b/plugins/opencode/commands/result.md @@ -1,15 +1,15 @@ --- -description: Show the stored final output for a finished Codex job in this repository +description: Show the stored final output for a finished OpenCode job in this repository argument-hint: '[job-id]' disable-model-invocation: true allowed-tools: Bash(node:*) --- -!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" result "$ARGUMENTS"` +!`node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" result "$ARGUMENTS"` Present the full command output to the user. Do not summarize or condense it. Preserve all details including: - Job ID and status - The complete result payload, including verdict, summary, findings, details, artifacts, and next steps - File paths and line numbers exactly as reported - Any error messages or parse errors -- Follow-up commands such as `/codex:status ` and `/codex:review` +- Follow-up commands such as `/opencode:status ` and `/opencode:review` diff --git a/plugins/codex/commands/review.md b/plugins/opencode/commands/review.md similarity index 76% rename from plugins/codex/commands/review.md rename to plugins/opencode/commands/review.md index fb70a48..6f57345 100644 --- a/plugins/codex/commands/review.md +++ b/plugins/opencode/commands/review.md @@ -1,11 +1,11 @@ --- -description: Run a Codex code review against local git state +description: Run a OpenCode code review against local git state argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch]' disable-model-invocation: true allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion --- -Run a Codex review through the shared built-in reviewer. +Run a OpenCode review through the shared built-in reviewer. Raw slash-command arguments: `$ARGUMENTS` @@ -13,7 +13,7 @@ Raw slash-command arguments: Core constraint: - This command is review-only. - Do not fix issues, apply patches, or suggest that you are about to make changes. -- Your only job is to run the review and return Codex's output verbatim to the user. +- Your only job is to run the review and return OpenCode's output verbatim to the user. Execution mode rules: - If the raw arguments include `--wait`, do not ask. Run the review in the foreground. @@ -36,13 +36,13 @@ Argument handling: - Do not strip `--wait` or `--background` yourself. - Do not add extra review instructions or rewrite the user's intent. - The companion script parses `--wait` and `--background`, but Claude Code's `Bash(..., run_in_background: true)` is what actually detaches the run. -- `/codex:review` is native-review only. It does not support staged-only review, unstaged-only review, or extra focus text. -- If the user needs custom review instructions or more adversarial framing, they should use `/codex:adversarial-review`. +- `/opencode:review` is native-review only. It does not support staged-only review, unstaged-only review, or extra focus text. +- If the user needs custom review instructions or more adversarial framing, they should use `/opencode:adversarial-review`. Foreground flow: - Run: ```bash -node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" review "$ARGUMENTS" +node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" review "$ARGUMENTS" ``` - Return the command stdout verbatim, exactly as-is. - Do not paraphrase, summarize, or add commentary before or after it. @@ -52,10 +52,10 @@ Background flow: - Launch the review with `Bash` in the background: ```typescript Bash({ - command: `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" review "$ARGUMENTS"`, - description: "Codex review", + command: `node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" review "$ARGUMENTS"`, + description: "OpenCode review", run_in_background: true }) ``` - Do not call `BashOutput` or wait for completion in this turn. -- After launching the command, tell the user: "Codex review started in the background. Check `/codex:status` for progress." +- After launching the command, tell the user: "OpenCode review started in the background. Check `/opencode:status` for progress." diff --git a/plugins/opencode/commands/setup.md b/plugins/opencode/commands/setup.md new file mode 100644 index 0000000..1b92abf --- /dev/null +++ b/plugins/opencode/commands/setup.md @@ -0,0 +1,37 @@ +--- +description: Check whether the local OpenCode CLI is ready and optionally toggle the stop-time review gate +argument-hint: '[--enable-review-gate|--disable-review-gate]' +allowed-tools: Bash(node:*), Bash(npm:*), AskUserQuestion +--- + +Run: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" setup --json $ARGUMENTS +``` + +If the result says OpenCode is unavailable and npm is available: +- Use `AskUserQuestion` exactly once to ask whether Claude should install OpenCode now. +- Put the install option first and suffix it with `(Recommended)`. +- Use these two options: + - `Install OpenCode (Recommended)` + - `Skip for now` +- If the user chooses install, run: + +```bash +npm install -g opencode-ai +``` + +- Then rerun: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" setup --json $ARGUMENTS +``` + +If OpenCode is already installed or npm is unavailable: +- Do not ask about installation. + +Output rules: +- Present the final setup output to the user. +- If installation was skipped, present the original setup output. +- If OpenCode is installed but not configured, preserve the setup output's provider guidance. diff --git a/plugins/codex/commands/status.md b/plugins/opencode/commands/status.md similarity index 77% rename from plugins/codex/commands/status.md rename to plugins/opencode/commands/status.md index 8f70663..158fe2f 100644 --- a/plugins/codex/commands/status.md +++ b/plugins/opencode/commands/status.md @@ -1,11 +1,11 @@ --- -description: Show active and recent Codex jobs for this repository, including review-gate status +description: Show active and recent OpenCode jobs for this repository, including review-gate status argument-hint: '[job-id] [--wait] [--timeout-ms ] [--all]' disable-model-invocation: true allowed-tools: Bash(node:*) --- -!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" status "$ARGUMENTS"` +!`node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" status "$ARGUMENTS"` If the user did not pass a job ID: - Render the command output as a single Markdown table for the current and past runs in this session. diff --git a/plugins/codex/commands/transfer.md b/plugins/opencode/commands/transfer.md similarity index 56% rename from plugins/codex/commands/transfer.md rename to plugins/opencode/commands/transfer.md index 42170e5..69ec93c 100644 --- a/plugins/codex/commands/transfer.md +++ b/plugins/opencode/commands/transfer.md @@ -1,10 +1,10 @@ --- -description: Transfer the current Claude Code session into a resumable Codex thread +description: Transfer the current Claude Code session into a resumable OpenCode thread argument-hint: "[--source ]" disable-model-invocation: true allowed-tools: Bash(node:*) --- -!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" transfer "$ARGUMENTS"` +!`node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" transfer "$ARGUMENTS"` -Present the command output to the user exactly as returned. Preserve the Codex session ID and the `codex resume ` command. +Present the command output to the user exactly as returned. Preserve the OpenCode session ID and the `opencode --session ` command. diff --git a/plugins/codex/hooks/hooks.json b/plugins/opencode/hooks/hooks.json similarity index 91% rename from plugins/codex/hooks/hooks.json rename to plugins/opencode/hooks/hooks.json index 19e33b8..c76f993 100644 --- a/plugins/codex/hooks/hooks.json +++ b/plugins/opencode/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Optional stop-time review gate for Codex Companion.", + "description": "Optional stop-time review gate for OpenCode Companion.", "hooks": { "SessionStart": [ { diff --git a/plugins/codex/prompts/adversarial-review.md b/plugins/opencode/prompts/adversarial-review.md similarity index 98% rename from plugins/codex/prompts/adversarial-review.md rename to plugins/opencode/prompts/adversarial-review.md index 78668af..7f2b71f 100644 --- a/plugins/codex/prompts/adversarial-review.md +++ b/plugins/opencode/prompts/adversarial-review.md @@ -1,5 +1,5 @@ -You are Codex performing an adversarial software review. +You are OpenCode performing an adversarial software review. Your job is to break confidence in the change, not to validate it. diff --git a/plugins/codex/prompts/stop-review-gate.md b/plugins/opencode/prompts/stop-review-gate.md similarity index 95% rename from plugins/codex/prompts/stop-review-gate.md rename to plugins/opencode/prompts/stop-review-gate.md index 8ed4d12..d136e98 100644 --- a/plugins/codex/prompts/stop-review-gate.md +++ b/plugins/opencode/prompts/stop-review-gate.md @@ -3,7 +3,7 @@ Run a stop-gate review of the previous Claude turn. Only review the work from the previous Claude turn. Only review it if Claude actually did code changes in that turn. Pure status, setup, or reporting output does not count as reviewable work. -For example, the output of /codex:setup or /codex:status does not count. +For example, the output of /opencode:setup or /opencode:status does not count. Only direct edits made in that specific turn count. If the previous Claude turn was only a status update, a summary, a setup/login check, a review result, or output from a command that did not itself make direct edits in that turn, return ALLOW immediately and do no further work. Challenge whether that specific work and its design choices should ship. diff --git a/plugins/codex/schemas/review-output.schema.json b/plugins/opencode/schemas/review-output.schema.json similarity index 100% rename from plugins/codex/schemas/review-output.schema.json rename to plugins/opencode/schemas/review-output.schema.json diff --git a/plugins/codex/scripts/lib/args.mjs b/plugins/opencode/scripts/lib/args.mjs similarity index 100% rename from plugins/codex/scripts/lib/args.mjs rename to plugins/opencode/scripts/lib/args.mjs diff --git a/plugins/codex/scripts/lib/claude-session-transfer.mjs b/plugins/opencode/scripts/lib/claude-session-transfer.mjs similarity index 88% rename from plugins/codex/scripts/lib/claude-session-transfer.mjs rename to plugins/opencode/scripts/lib/claude-session-transfer.mjs index eea0aeb..55f7c75 100644 --- a/plugins/codex/scripts/lib/claude-session-transfer.mjs +++ b/plugins/opencode/scripts/lib/claude-session-transfer.mjs @@ -4,7 +4,7 @@ import path from "node:path"; import { ensureAbsolutePath } from "./fs.mjs"; -export const TRANSCRIPT_PATH_ENV = "CODEX_COMPANION_TRANSCRIPT_PATH"; +export const TRANSCRIPT_PATH_ENV = "OPENCODE_COMPANION_TRANSCRIPT_PATH"; const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects"); function resolveUserPath(cwd, value) { @@ -38,7 +38,7 @@ export function resolveClaudeSessionPath(cwd, options = {}) { } const relative = path.relative(projects, source); if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { - throw new Error(`Codex can import Claude sessions only from ${CLAUDE_PROJECTS_DIR}: ${source}`); + throw new Error(`OpenCode can import Claude sessions only from ${CLAUDE_PROJECTS_DIR}: ${source}`); } return source; } diff --git a/plugins/codex/scripts/lib/fs.mjs b/plugins/opencode/scripts/lib/fs.mjs similarity index 94% rename from plugins/codex/scripts/lib/fs.mjs rename to plugins/opencode/scripts/lib/fs.mjs index 0275224..21bbc87 100644 --- a/plugins/codex/scripts/lib/fs.mjs +++ b/plugins/opencode/scripts/lib/fs.mjs @@ -6,7 +6,7 @@ export function ensureAbsolutePath(cwd, maybePath) { return path.isAbsolute(maybePath) ? maybePath : path.resolve(cwd, maybePath); } -export function createTempDir(prefix = "codex-plugin-") { +export function createTempDir(prefix = "opencode-plugin-") { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } diff --git a/plugins/codex/scripts/lib/git.mjs b/plugins/opencode/scripts/lib/git.mjs similarity index 100% rename from plugins/codex/scripts/lib/git.mjs rename to plugins/opencode/scripts/lib/git.mjs diff --git a/plugins/codex/scripts/lib/job-control.mjs b/plugins/opencode/scripts/lib/job-control.mjs similarity index 91% rename from plugins/codex/scripts/lib/job-control.mjs rename to plugins/opencode/scripts/lib/job-control.mjs index ad152c1..27296c0 100644 --- a/plugins/codex/scripts/lib/job-control.mjs +++ b/plugins/opencode/scripts/lib/job-control.mjs @@ -1,6 +1,6 @@ import fs from "node:fs"; -import { getSessionRuntimeStatus } from "./codex.mjs"; +import { getSessionRuntimeStatus } from "./opencode.mjs"; import { getConfig, listJobs, readJobFile, resolveJobFile } from "./state.mjs"; import { SESSION_ID_ENV } from "./tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; @@ -122,7 +122,7 @@ function inferLegacyJobPhase(job, progressPreview = []) { for (let index = progressPreview.length - 1; index >= 0; index -= 1) { const line = progressPreview[index].toLowerCase(); - if (line.startsWith("starting codex") || line.startsWith("thread ready") || line.startsWith("turn started")) { + if (line.startsWith("starting opencode") || line.startsWith("session ready") || line.startsWith("turn started")) { return "starting"; } if (line.startsWith("reviewer started") || line.includes("review mode")) { @@ -150,7 +150,7 @@ function inferLegacyJobPhase(job, progressPreview = []) { if (line.startsWith("turn completed")) { return "finalizing"; } - if (line.startsWith("codex error:") || line.startsWith("failed:")) { + if (line.startsWith("opencode error:") || line.startsWith("failed:")) { return "failed"; } } @@ -207,7 +207,7 @@ function matchJobReference(jobs, reference, predicate = () => true) { throw new Error(`Job reference "${reference}" is ambiguous. Use a longer job id.`); } - throw new Error(`No job found for "${reference}". Run /codex:status to list known jobs.`); + throw new Error(`No job found for "${reference}". Run /opencode:status to list known jobs.`); } export function buildStatusSnapshot(cwd, options = {}) { @@ -244,7 +244,7 @@ export function buildSingleJobSnapshot(cwd, reference, options = {}) { const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)); const selected = matchJobReference(jobs, reference); if (!selected) { - throw new Error(`No job found for "${reference}". Run /codex:status to inspect known jobs.`); + throw new Error(`No job found for "${reference}". Run /opencode:status to inspect known jobs.`); } return { @@ -268,14 +268,14 @@ export function resolveResultJob(cwd, reference) { const active = matchJobReference(jobs, reference, (job) => job.status === "queued" || job.status === "running"); if (active) { - throw new Error(`Job ${active.id} is still ${active.status}. Check /codex:status and try again once it finishes.`); + throw new Error(`Job ${active.id} is still ${active.status}. Check /opencode:status and try again once it finishes.`); } if (reference) { - throw new Error(`No finished job found for "${reference}". Run /codex:status to inspect active jobs.`); + throw new Error(`No finished job found for "${reference}". Run /opencode:status to inspect active jobs.`); } - throw new Error("No finished Codex jobs found for this repository yet."); + throw new Error("No finished OpenCode jobs found for this repository yet."); } export function resolveCancelableJob(cwd, reference, options = {}) { @@ -297,12 +297,12 @@ export function resolveCancelableJob(cwd, reference, options = {}) { return { workspaceRoot, job: sessionScopedActiveJobs[0] }; } if (sessionScopedActiveJobs.length > 1) { - throw new Error("Multiple Codex jobs are active. Pass a job id to /codex:cancel."); + throw new Error("Multiple OpenCode jobs are active. Pass a job id to /opencode:cancel."); } if (getCurrentSessionId(options)) { - throw new Error("No active Codex jobs to cancel for this session."); + throw new Error("No active OpenCode jobs to cancel for this session."); } - throw new Error("No active Codex jobs to cancel."); + throw new Error("No active OpenCode jobs to cancel."); } diff --git a/plugins/opencode/scripts/lib/opencode-server.mjs b/plugins/opencode/scripts/lib/opencode-server.mjs new file mode 100644 index 0000000..02e63d0 --- /dev/null +++ b/plugins/opencode/scripts/lib/opencode-server.mjs @@ -0,0 +1,248 @@ +export class OpencodeHttpError extends Error { + constructor(message, options = {}) { + super(message); + this.name = "OpencodeHttpError"; + this.status = options.status ?? null; + this.body = options.body ?? ""; + this.url = options.url ?? null; + } +} + +function trimBaseUrl(url) { + return String(url ?? "").replace(/\/+$/, ""); +} + +function encodePathSegment(value) { + return encodeURIComponent(String(value)); +} + +async function parseResponseBody(response) { + const text = await response.text(); + if (!text) { + return {}; + } + + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return JSON.parse(text); + } + + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function parseSseBlock(block) { + const event = { + eventName: "message", + id: null, + data: "" + }; + const dataLines = []; + + for (const rawLine of block.replace(/\r/g, "").split("\n")) { + if (!rawLine || rawLine.startsWith(":")) { + continue; + } + const colonIndex = rawLine.indexOf(":"); + const field = colonIndex === -1 ? rawLine : rawLine.slice(0, colonIndex); + let value = colonIndex === -1 ? "" : rawLine.slice(colonIndex + 1); + if (value.startsWith(" ")) { + value = value.slice(1); + } + + if (field === "event") { + event.eventName = value || "message"; + } else if (field === "id") { + event.id = value || null; + } else if (field === "data") { + dataLines.push(value); + } + } + + event.data = dataLines.join("\n"); + return event; +} + +async function dispatchSseBlocks(buffer, onEvent) { + let nextBuffer = buffer; + for (;;) { + const normalized = nextBuffer.replace(/\r\n/g, "\n"); + const blockEnd = normalized.indexOf("\n\n"); + if (blockEnd === -1) { + return nextBuffer; + } + + const block = normalized.slice(0, blockEnd); + nextBuffer = normalized.slice(blockEnd + 2); + if (!block.trim()) { + continue; + } + + const parsed = parseSseBlock(block); + if (!parsed.data) { + continue; + } + + let data; + try { + data = JSON.parse(parsed.data); + } catch { + data = parsed.data; + } + await onEvent(data, { + eventName: parsed.eventName, + id: parsed.id, + raw: block + }); + } +} + +export class OpencodeServerClient { + constructor(baseUrl, options = {}) { + this.baseUrl = trimBaseUrl(baseUrl); + this.fetch = options.fetch ?? globalThis.fetch; + if (!this.baseUrl) { + throw new Error("OpenCode server URL is required."); + } + if (typeof this.fetch !== "function") { + throw new Error("OpenCode server client requires global fetch (Node >= 18)."); + } + } + + url(path) { + const suffix = String(path ?? "").startsWith("/") ? path : `/${path}`; + return `${this.baseUrl}${suffix}`; + } + + async request(method, path, options = {}) { + const url = this.url(path); + const headers = { + ...(options.body == null ? {} : { "content-type": "application/json" }), + ...(options.headers ?? {}) + }; + const response = await this.fetch(url, { + method, + headers, + body: options.body == null ? undefined : JSON.stringify(options.body), + signal: options.signal + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new OpencodeHttpError(`OpenCode ${method} ${path} failed with HTTP ${response.status}.`, { + status: response.status, + body, + url + }); + } + + return parseResponseBody(response); + } + + createSession(params = {}, options = {}) { + return this.request("POST", "/session", { + body: params, + signal: options.signal + }); + } + + sendMessage(sessionID, params = {}, options = {}) { + return this.request("POST", `/session/${encodePathSegment(sessionID)}/message`, { + body: params, + signal: options.signal + }); + } + + promptAsync(sessionID, params = {}, options = {}) { + return this.request("POST", `/session/${encodePathSegment(sessionID)}/prompt_async`, { + body: params, + signal: options.signal + }); + } + + abort(sessionID, options = {}) { + return this.request("POST", `/session/${encodePathSegment(sessionID)}/abort`, { + body: options.body ?? {}, + signal: options.signal + }); + } + + getConfig(options = {}) { + return this.request("GET", "/config", { signal: options.signal }); + } + + getProvider(options = {}) { + return this.request("GET", "/provider", { signal: options.signal }); + } + + listSessions(options = {}) { + return this.request("GET", "/session", { signal: options.signal }); + } + + respondPermission(sessionID, permissionID, response = "always", options = {}) { + return this.request("POST", `/session/${encodePathSegment(sessionID)}/permissions/${encodePathSegment(permissionID)}`, { + body: { response }, + signal: options.signal + }); + } + + health(options = {}) { + return this.request("GET", "/global/health", { signal: options.signal }); + } + + dispose(options = {}) { + return this.request("POST", "/global/dispose", { + body: {}, + signal: options.signal + }); + } + + async subscribeEvents(onEvent, options = {}) { + const response = await this.fetch(this.url("/event"), { + method: "GET", + headers: { + accept: "text/event-stream" + }, + signal: options.signal + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new OpencodeHttpError(`OpenCode GET /event failed with HTTP ${response.status}.`, { + status: response.status, + body, + url: this.url("/event") + }); + } + if (!response.body) { + throw new Error("OpenCode event stream did not include a response body."); + } + + options.onOpen?.(); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + buffer = await dispatchSseBlocks(buffer, onEvent); + } + + buffer += decoder.decode(); + if (buffer.trim()) { + await dispatchSseBlocks(`${buffer}\n\n`, onEvent); + } + } finally { + reader.releaseLock(); + } + } +} diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs new file mode 100644 index 0000000..19ba514 --- /dev/null +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -0,0 +1,920 @@ +import { readJsonFile } from "./fs.mjs"; +import { OpencodeServerClient } from "./opencode-server.mjs"; +import { SERVER_URL_ENV, ensureServer, loadServerSession } from "./server-lifecycle.mjs"; +import { binaryAvailable } from "./process.mjs"; + +const TASK_SESSION_PREFIX = "OpenCode Companion Task"; +const DEFAULT_CONTINUE_PROMPT = + "Continue from the current session state. Pick the next highest-value step and follow through until the task is resolved."; +const MODEL_ALIASES = new Map([["spark", "openai/gpt-5.3-codex-spark"]]); +const WRITE_AGENT = "build"; +const READ_ONLY_AGENT = "plan"; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function shorten(text, limit = 96) { + const normalized = String(text ?? "").trim().replace(/\s+/g, " "); + if (!normalized) { + return ""; + } + if (normalized.length <= limit) { + return normalized; + } + return `${normalized.slice(0, limit - 3)}...`; +} + +function emitProgress(onProgress, message, phase = null, extra = {}) { + if (!onProgress || !message) { + return; + } + if (!phase && Object.keys(extra).length === 0) { + onProgress(message); + return; + } + onProgress({ message, phase, ...extra }); +} + +function emitLogEvent(onProgress, options = {}) { + if (!onProgress) { + return; + } + onProgress({ + message: options.message ?? "", + phase: options.phase ?? null, + stderrMessage: options.stderrMessage ?? null, + logTitle: options.logTitle ?? null, + logBody: options.logBody ?? null + }); +} + +function looksLikeVerificationCommand(command) { + return /\b(test|tests|lint|build|typecheck|type-check|check|verify|validate|pytest|jest|vitest|cargo test|npm test|pnpm test|yarn test|go test|mvn test|gradle test|tsc|eslint|ruff)\b/i.test( + command + ); +} + +function normalizeModelSelection(model) { + if (model == null) { + return null; + } + if (typeof model === "object") { + // A pre-built model object is only valid to OpenCode with both providerID + // and modelID; drop anything partial rather than sending an invalid shape. + return typeof model.providerID === "string" && typeof model.modelID === "string" + ? { providerID: model.providerID, modelID: model.modelID } + : null; + } + + const raw = String(model).trim(); + if (!raw) { + return null; + } + + const normalized = MODEL_ALIASES.get(raw.toLowerCase()) ?? raw; + const slashIndex = normalized.indexOf("/"); + if (slashIndex > 0 && slashIndex < normalized.length - 1) { + return { + providerID: normalized.slice(0, slashIndex), + modelID: normalized.slice(slashIndex + 1) + }; + } + + // OpenCode requires both providerID and modelID. Without a `provider/model` + // form we cannot build a valid selection, so fall back to the server default + // rather than sending an invalid model object the API would reject. + return null; +} + +function buildTaskSessionName(prompt) { + const excerpt = shorten(prompt, 56); + return excerpt ? `${TASK_SESSION_PREFIX}: ${excerpt}` : TASK_SESSION_PREFIX; +} + +function buildWritePermissionRules() { + // OpenCode's PermissionRule.permission is a free-form string but only real + // permission keys take effect, and the built-in `build` agent's own defaults + // still leave some categories on "ask". Use the same wildcard-allow rule the + // `build` agent ships with so headless write turns never stall on approval. + return [{ permission: "*", action: "allow", pattern: "*" }]; +} + +function buildCreateSessionParams(cwd, options = {}) { + const write = Boolean(options.write); + const agent = options.agent ?? (write ? WRITE_AGENT : READ_ONLY_AGENT); + // The create-session body is strict (additionalProperties: false) and, in + // practice, the real server also rejects a `model` object here with + // BadRequest — the model is selected per-message instead (buildMessageParams). + // `directory` is not accepted either: the session inherits it from the + // `opencode serve` working directory, which server-lifecycle spawns with + // `cwd`. `title` must be a string when present. Read-only turns rely on the + // read-only `plan` agent instead of a permission override. + const rawTitle = options.title ?? options.threadName ?? null; + const title = typeof rawTitle === "string" && rawTitle.trim() ? rawTitle : null; + return { + agent, + ...(title ? { title } : {}), + ...(write ? { permission: buildWritePermissionRules() } : {}) + }; +} + +function buildMessageParams(prompt, options = {}) { + const model = normalizeModelSelection(options.model); + return { + parts: [{ type: "text", text: prompt }], + agent: options.agent ?? (options.write ? WRITE_AGENT : READ_ONLY_AGENT), + ...(model ? { model } : {}), + ...(options.variant ? { variant: options.variant } : {}), + ...(options.outputSchema + ? { + format: { + type: "json_schema", + schema: options.outputSchema + } + } + : {}) + }; +} + +function extractEventType(event, meta = {}) { + return ( + event?.type ?? + event?.event ?? + event?.name ?? + event?.properties?.type ?? + event?.properties?.event ?? + meta.eventName ?? + "" + ); +} + +function extractSessionId(event) { + return ( + event?.sessionID ?? + event?.sessionId ?? + event?.session?.id ?? + event?.message?.sessionID ?? + event?.message?.sessionId ?? + event?.info?.sessionID ?? + event?.info?.sessionId ?? + event?.properties?.sessionID ?? + event?.properties?.sessionId ?? + null + ); +} + +function extractParentSessionId(event) { + return ( + event?.parentID ?? + event?.parentId ?? + event?.session?.parentID ?? + event?.session?.parentId ?? + event?.properties?.parentID ?? + event?.properties?.parentId ?? + null + ); +} + +function extractPermissionId(event) { + // Prefer permission-specific fields over the generic envelope `id` (which is + // the `evt_…` event id, not the permission id). The real permission.asked + // event carries the permission id at `properties.id`. + return ( + event?.permissionID ?? + event?.permissionId ?? + event?.permID ?? + event?.permId ?? + event?.permission?.id ?? + event?.properties?.permissionID ?? + event?.properties?.permissionId ?? + event?.properties?.permID ?? + event?.properties?.permId ?? + event?.properties?.permission?.id ?? + event?.properties?.id ?? + event?.id ?? + null + ); +} + +function stringifyError(value) { + if (value == null) { + return "OpenCode session error."; + } + if (value instanceof Error) { + return value.message; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "object") { + const nested = value.message ?? value.data?.message ?? value.error?.message ?? value.name; + if (typeof nested === "string" && nested) { + return nested; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} + +function extractMessageParts(value) { + const parts = value?.parts ?? value?.message?.parts ?? value?.properties?.parts ?? value?.data?.parts ?? null; + return Array.isArray(parts) ? parts : []; +} + +function textFromContent(content) { + if (typeof content === "string") { + return content; + } + if (Array.isArray(content)) { + return content.map(textFromContent).filter(Boolean).join(""); + } + if (content && typeof content === "object") { + return content.text ?? content.content ?? ""; + } + return ""; +} + +function extractTextFromParts(parts) { + return parts + .map((part) => { + if (!part || typeof part !== "object") { + return ""; + } + // Reasoning parts are captured separately; excluding them keeps the final + // assistant message from being prefixed with the model's reasoning text. + if (String(part.type ?? "").includes("reasoning")) { + return ""; + } + if (typeof part.text === "string") { + return part.text; + } + if (typeof part.content === "string" || Array.isArray(part.content)) { + return textFromContent(part.content); + } + return ""; + }) + .filter(Boolean) + .join(""); +} + +function extractReasoningFromParts(parts) { + return parts + .filter((part) => String(part?.type ?? "").includes("reasoning")) + .map((part) => part.text ?? part.summary ?? part.content ?? "") + .flatMap((entry) => (Array.isArray(entry) ? entry : [entry])) + .map((entry) => String(entry ?? "").replace(/\s+/g, " ").trim()) + .filter(Boolean); +} + +function extractFilePath(event) { + return ( + event?.path ?? + event?.file ?? + event?.filename ?? + event?.properties?.path ?? + event?.properties?.file ?? + event?.data?.path ?? + null + ); +} + +function extractCommand(event) { + return ( + event?.command ?? + event?.cmd ?? + event?.tool?.command ?? + event?.properties?.command ?? + event?.properties?.cmd ?? + event?.data?.command ?? + "" + ); +} + +function createTurnCaptureState(sessionID, options = {}) { + let resolveCompletion; + let rejectCompletion; + const completion = new Promise((resolve, reject) => { + resolveCompletion = resolve; + rejectCompletion = reject; + }); + + return { + sessionID, + sessionIDs: new Set([sessionID]), + childLabels: new Map(), + nextChildIndex: 1, + messageID: null, + completed: false, + completion, + resolveCompletion, + rejectCompletion, + finalMessage: "", + reasoningSummary: [], + touchedFiles: new Set(), + commandExecutions: [], + error: null, + response: null, + fallbackTimer: null, + onProgress: options.onProgress ?? null, + write: Boolean(options.write) + }; +} + +function labelForSession(state, sessionID) { + if (!sessionID || sessionID === state.sessionID) { + return null; + } + if (!state.childLabels.has(sessionID)) { + state.childLabels.set(sessionID, String(state.nextChildIndex++)); + } + return state.childLabels.get(sessionID); +} + +function trackSession(state, event) { + const sessionID = extractSessionId(event); + if (!sessionID) { + return; + } + + const parentID = extractParentSessionId(event); + if (sessionID === state.sessionID || state.sessionIDs.has(parentID)) { + state.sessionIDs.add(sessionID); + const label = + event?.session?.title ?? + event?.session?.agent ?? + event?.agent ?? + event?.properties?.title ?? + event?.properties?.agent ?? + null; + if (label && sessionID !== state.sessionID) { + state.childLabels.set(sessionID, label); + } + } +} + +function mergeReasoning(state, sections) { + for (const section of sections) { + const normalized = String(section ?? "").replace(/\s+/g, " ").trim(); + if (normalized && !state.reasoningSummary.includes(normalized)) { + state.reasoningSummary.push(normalized); + } + } +} + +function completeTurn(state, options = {}) { + if (state.completed) { + return; + } + if (state.fallbackTimer) { + clearTimeout(state.fallbackTimer); + state.fallbackTimer = null; + } + state.completed = true; + if (options.inferred) { + emitProgress(state.onProgress, "Turn completion inferred after OpenCode returned the message response.", "finalizing"); + } + state.resolveCompletion(state); +} + +function scheduleResponseFallbackCompletion(state) { + if (state.completed || state.fallbackTimer) { + return; + } + state.fallbackTimer = setTimeout(() => { + state.fallbackTimer = null; + completeTurn(state, { inferred: true }); + }, 250); + state.fallbackTimer.unref?.(); +} + +function applyMessageParts(state, parts, sessionID) { + if (!Array.isArray(parts) || parts.length === 0) { + return; + } + const text = extractTextFromParts(parts); + const reasoning = extractReasoningFromParts(parts); + mergeReasoning(state, reasoning); + + const subagentLabel = labelForSession(state, sessionID); + if (text && !subagentLabel) { + state.finalMessage = text; + emitLogEvent(state.onProgress, { + message: `Assistant message captured: ${shorten(text, 96)}`, + stderrMessage: null, + phase: "finalizing", + logTitle: "Assistant message", + logBody: text + }); + } else if (text && subagentLabel) { + emitLogEvent(state.onProgress, { + message: `Subagent ${subagentLabel}: ${shorten(text, 96)}`, + stderrMessage: null, + logTitle: `Subagent ${subagentLabel} message`, + logBody: text + }); + } + + if (reasoning.length > 0) { + emitLogEvent(state.onProgress, { + message: subagentLabel + ? `Subagent ${subagentLabel} reasoning: ${shorten(reasoning[0], 96)}` + : `Reasoning summary captured: ${shorten(reasoning[0], 96)}`, + stderrMessage: null, + logTitle: subagentLabel ? `Subagent ${subagentLabel} reasoning summary` : "Reasoning summary", + logBody: reasoning.map((section) => `- ${section}`).join("\n") + }); + } +} + +async function respondToPermission(client, state, event, sessionID) { + const permissionID = extractPermissionId(event); + if (!permissionID || !sessionID) { + return; + } + + const response = state.write ? "always" : "reject"; + emitProgress( + state.onProgress, + `${state.write ? "Allowing" : "Denying"} OpenCode permission request ${permissionID}.`, + state.write ? "running" : "investigating" + ); + try { + await client.respondPermission(sessionID, permissionID, response); + } catch (error) { + state.error = error; + emitProgress(state.onProgress, `OpenCode permission response failed: ${error.message}`, "failed"); + } +} + +function describeNextEvent(type, event) { + if (type.startsWith("session.next.tool")) { + const tool = event?.tool ?? event?.name ?? event?.properties?.tool ?? event?.properties?.name ?? "tool"; + return { message: `Running tool: ${tool}.`, phase: "investigating" }; + } + + if (type.startsWith("session.next.shell")) { + const command = extractCommand(event); + return { + message: command ? `Running command: ${shorten(command, 96)}` : "Running shell command.", + phase: looksLikeVerificationCommand(command) ? "verifying" : "running" + }; + } + + if (type.startsWith("session.next.reasoning")) { + return { message: "Reasoning in progress.", phase: "investigating" }; + } + + if (type.startsWith("session.next.text")) { + return { message: "Assistant response in progress.", phase: "running" }; + } + + if (type.startsWith("session.next.step")) { + return { message: "OpenCode advanced to the next step.", phase: "running" }; + } + + return null; +} + +async function applyOpenCodeEvent(client, state, event, meta = {}) { + if (!event || typeof event !== "object") { + return; + } + + const type = extractEventType(event, meta); + trackSession(state, event); + + const sessionID = extractSessionId(event); + if (sessionID && !state.sessionIDs.has(sessionID)) { + return; + } + + if (type === "permission.asked" || type === "permission.v2.asked") { + await respondToPermission(client, state, event, sessionID ?? state.sessionID); + return; + } + + if (type === "message.updated") { + state.messageID = event?.message?.id ?? event?.info?.id ?? event?.id ?? state.messageID; + applyMessageParts(state, extractMessageParts(event), sessionID ?? state.sessionID); + return; + } + + if (type === "file.edited") { + const filePath = extractFilePath(event); + if (filePath) { + state.touchedFiles.add(filePath); + emitProgress(state.onProgress, `Edited ${filePath}.`, "editing"); + } else { + emitProgress(state.onProgress, "File edited.", "editing"); + } + return; + } + + if (type === "session.error") { + const rawError = event?.error ?? event?.properties?.error ?? event?.message ?? "OpenCode session error."; + state.error = rawError instanceof Error ? rawError : new Error(stringifyError(rawError)); + emitProgress(state.onProgress, `OpenCode error: ${state.error.message}`, "failed"); + completeTurn(state); + return; + } + + if (type === "session.idle" && (!sessionID || sessionID === state.sessionID)) { + emitProgress(state.onProgress, "Turn completed.", "finalizing"); + completeTurn(state); + return; + } + + const nextDescription = describeNextEvent(type, event); + if (nextDescription) { + const subagentLabel = labelForSession(state, sessionID); + emitProgress( + state.onProgress, + subagentLabel ? `Subagent ${subagentLabel}: ${nextDescription.message}` : nextDescription.message, + nextDescription.phase + ); + } +} + +async function captureTurn(client, sessionID, startRequest, options = {}) { + const state = createTurnCaptureState(sessionID, options); + const eventAbort = new AbortController(); + let resolveOpen; + let rejectOpen; + const opened = new Promise((resolve, reject) => { + resolveOpen = resolve; + rejectOpen = reject; + }); + + const eventStream = client + .subscribeEvents((event, meta) => applyOpenCodeEvent(client, state, event, meta), { + signal: eventAbort.signal, + onOpen: resolveOpen + }) + .catch((error) => { + if (eventAbort.signal.aborted) { + return; + } + rejectOpen(error); + state.rejectCompletion(error); + }); + + try { + await Promise.race([ + opened, + sleep(options.eventOpenTimeoutMs ?? 3000).then(() => { + throw new Error("Timed out waiting for OpenCode event stream."); + }) + ]); + + const responsePromise = startRequest().then((response) => { + state.response = response; + state.messageID = response?.info?.id ?? response?.id ?? state.messageID; + applyMessageParts(state, response?.parts ?? [], state.sessionID); + scheduleResponseFallbackCompletion(state); + return response; + }); + + await Promise.all([state.completion, responsePromise]); + return state; + } finally { + eventAbort.abort(); + await eventStream.catch(() => {}); + if (state.fallbackTimer) { + clearTimeout(state.fallbackTimer); + } + } +} + +async function withServer(cwd, fn) { + const server = await ensureServer(cwd); + if (!server?.url) { + throw new Error("OpenCode server did not become ready."); + } + return fn(new OpencodeServerClient(server.url), server); +} + +function getSessionsArray(response) { + if (Array.isArray(response)) { + return response; + } + if (Array.isArray(response?.data)) { + return response.data; + } + if (Array.isArray(response?.sessions)) { + return response.sessions; + } + return []; +} + +function sessionTitle(session) { + return session?.title ?? session?.name ?? ""; +} + +function sessionDirectory(session) { + return session?.directory ?? session?.cwd ?? session?.path ?? ""; +} + +function sessionUpdatedAt(session) { + return Date.parse(session?.updatedAt ?? session?.updated_at ?? session?.time?.updated ?? "") || 0; +} + +function buildAuthStatus(fields = {}) { + return { + available: true, + loggedIn: false, + detail: "not authenticated", + source: "unknown", + authMethod: null, + verified: null, + requiresOpenaiAuth: null, + provider: null, + ...fields + }; +} + +export function getAvailability(cwd) { + const versionStatus = binaryAvailable("opencode", ["--version"], { cwd }); + if (!versionStatus.available) { + return versionStatus; + } + + const serveStatus = binaryAvailable("opencode", ["serve", "--help"], { cwd }); + if (!serveStatus.available) { + return { + available: false, + detail: `${versionStatus.detail}; headless server unavailable: ${serveStatus.detail}` + }; + } + + return { + available: true, + detail: `${versionStatus.detail}; headless server available` + }; +} + +export function getSessionRuntimeStatus(env = process.env, cwd = process.cwd()) { + const url = env?.[SERVER_URL_ENV] ?? loadServerSession(cwd)?.url ?? null; + if (url) { + return { + mode: "shared", + label: "shared OpenCode server", + detail: "This Claude session is configured to reuse one shared OpenCode server.", + endpoint: url, + url + }; + } + + return { + mode: "direct", + label: "lazy OpenCode server startup", + detail: "No shared OpenCode server is active yet. The first review or task command will start one on demand.", + endpoint: null, + url: null + }; +} + +export async function getAuthStatus(cwd) { + const availability = getAvailability(cwd); + if (!availability.available) { + return buildAuthStatus({ + available: false, + detail: availability.detail, + source: "availability" + }); + } + + try { + return await withServer(cwd, async (client) => { + const [config, provider] = await Promise.all([ + client.getConfig().catch((error) => ({ error })), + client.getProvider().catch((error) => ({ error })) + ]); + if (config?.error && provider?.error) { + throw config.error; + } + const providerID = + provider?.id ?? + provider?.providerID ?? + provider?.providerId ?? + config?.providerID ?? + config?.providerId ?? + config?.config?.model_provider ?? + null; + return buildAuthStatus({ + loggedIn: true, + detail: providerID ? `OpenCode provider ${providerID} is configured` : "OpenCode server config is readable", + source: "server", + provider: providerID + }); + }); + } catch (error) { + return buildAuthStatus({ + loggedIn: false, + detail: error instanceof Error ? error.message : String(error), + source: "server" + }); + } +} + +export async function interruptServerTurn(cwd, { threadId }) { + if (!threadId) { + return { + attempted: false, + interrupted: false, + transport: null, + detail: "missing OpenCode session id" + }; + } + + const availability = getAvailability(cwd); + if (!availability.available) { + return { + attempted: false, + interrupted: false, + transport: null, + detail: availability.detail + }; + } + + try { + return await withServer(cwd, async (client, server) => { + await client.abort(threadId); + return { + attempted: true, + interrupted: true, + transport: "server", + detail: `Aborted OpenCode session ${threadId}.`, + serverUrl: server.url + }; + }); + } catch (error) { + return { + attempted: true, + interrupted: false, + transport: "server", + detail: error instanceof Error ? error.message : String(error) + }; + } +} + +export async function runServerTurn(cwd, options = {}) { + const availability = getAvailability(cwd); + if (!availability.available) { + throw new Error("OpenCode CLI is not installed or is missing headless server support. Install OpenCode, then rerun `/opencode:setup`."); + } + + const write = Boolean(options.write ?? options.sandbox === "workspace-write"); + const agent = options.agent ?? (write ? WRITE_AGENT : READ_ONLY_AGENT); + const prompt = options.prompt || options.defaultPrompt || ""; + if (!prompt.trim()) { + throw new Error("A prompt is required for this OpenCode run."); + } + + return withServer(cwd, async (client) => { + let sessionID = options.resumeThreadId ?? options.resumeSessionId ?? null; + + if (sessionID) { + emitProgress(options.onProgress, `Resuming OpenCode session ${sessionID}.`, "starting", { + threadId: sessionID + }); + } else { + emitProgress(options.onProgress, "Starting OpenCode task session.", "starting"); + const session = await client.createSession( + buildCreateSessionParams(cwd, { + title: options.threadName ?? options.title ?? (options.persistThread ? buildTaskSessionName(prompt) : null), + model: options.model, + write, + agent + }) + ); + sessionID = session?.id ?? session?.session?.id ?? session?.info?.id ?? null; + if (!sessionID) { + throw new Error("OpenCode did not return a session id."); + } + emitProgress(options.onProgress, `Session ready (${sessionID}).`, "starting", { + threadId: sessionID + }); + } + + const turnState = await captureTurn( + client, + sessionID, + () => + client.sendMessage( + sessionID, + buildMessageParams(prompt, { + model: options.model, + variant: options.variant ?? options.effort ?? null, + outputSchema: options.outputSchema ?? null, + write, + agent + }) + ), + { + onProgress: options.onProgress, + write + } + ); + + return { + status: turnState.error ? 1 : 0, + threadId: sessionID, + turnId: turnState.messageID, + finalMessage: turnState.finalMessage, + reasoningSummary: turnState.reasoningSummary, + turn: { + id: turnState.messageID ?? "opencode-message", + status: turnState.error ? "failed" : "completed" + }, + error: turnState.error, + stderr: "", + fileChanges: [], + touchedFiles: [...turnState.touchedFiles], + commandExecutions: turnState.commandExecutions + }; + }); +} + +export async function runServerReview(cwd, options = {}) { + const result = await runServerTurn(cwd, { + ...options, + agent: READ_ONLY_AGENT, + sandbox: "read-only", + persistThread: false, + threadName: options.threadName ?? "OpenCode Review" + }); + return { + ...result, + reviewText: result.finalMessage, + sourceThreadId: result.threadId + }; +} + +export async function findLatestTaskThread(cwd) { + const availability = getAvailability(cwd); + if (!availability.available) { + throw new Error("OpenCode CLI is not installed or is missing headless server support. Install OpenCode, then rerun `/opencode:setup`."); + } + + return withServer(cwd, async (client) => { + const sessions = getSessionsArray(await client.listSessions()) + .filter((session) => sessionTitle(session).startsWith(TASK_SESSION_PREFIX)) + .filter((session) => { + const directory = sessionDirectory(session); + return !directory || directory === cwd; + }) + .sort((left, right) => sessionUpdatedAt(right) - sessionUpdatedAt(left)); + return sessions[0] ?? null; + }); +} + +export async function importExternalAgentSession() { + throw new Error("OpenCode transfer is not implemented in Phase 1."); +} + +export function buildPersistentTaskThreadName(prompt) { + return buildTaskSessionName(prompt); +} + +export function parseStructuredOutput(rawOutput, fallback = {}) { + if (!rawOutput) { + return { + parsed: null, + parseError: fallback.failureMessage ?? "OpenCode did not return a final structured message.", + rawOutput: rawOutput ?? "", + ...fallback + }; + } + + try { + return { + parsed: JSON.parse(rawOutput), + parseError: null, + rawOutput, + ...fallback + }; + } catch (error) { + return { + parsed: null, + parseError: error.message, + rawOutput, + ...fallback + }; + } +} + +export function readOutputSchema(schemaPath) { + return readJsonFile(schemaPath); +} + +export { + DEFAULT_CONTINUE_PROMPT, + TASK_SESSION_PREFIX, + getAvailability as getOpencodeAvailability, + getAuthStatus as getOpencodeAuthStatus +}; diff --git a/plugins/codex/scripts/lib/process.mjs b/plugins/opencode/scripts/lib/process.mjs similarity index 100% rename from plugins/codex/scripts/lib/process.mjs rename to plugins/opencode/scripts/lib/process.mjs diff --git a/plugins/codex/scripts/lib/prompts.mjs b/plugins/opencode/scripts/lib/prompts.mjs similarity index 100% rename from plugins/codex/scripts/lib/prompts.mjs rename to plugins/opencode/scripts/lib/prompts.mjs diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/opencode/scripts/lib/render.mjs similarity index 85% rename from plugins/codex/scripts/lib/render.mjs rename to plugins/opencode/scripts/lib/render.mjs index 2ec1852..a4c7938 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/opencode/scripts/lib/render.mjs @@ -99,21 +99,21 @@ function escapeMarkdownCell(value) { .trim(); } -function formatCodexResumeCommand(job) { +function formatOpenCodeResumeCommand(job) { if (!job?.threadId) { return null; } - return `codex resume ${job.threadId}`; + return `opencode --session ${job.threadId}`; } function appendActiveJobsTable(lines, jobs) { lines.push("Active jobs:"); - lines.push("| Job | Kind | Status | Phase | Elapsed | Codex Session ID | Summary | Actions |"); + lines.push("| Job | Kind | Status | Phase | Elapsed | OpenCode Session ID | Summary | Actions |"); lines.push("| --- | --- | --- | --- | --- | --- | --- | --- |"); for (const job of jobs) { - const actions = [`/codex:status ${job.id}`]; + const actions = [`/opencode:status ${job.id}`]; if (job.status === "queued" || job.status === "running") { - actions.push(`/codex:cancel ${job.id}`); + actions.push(`/opencode:cancel ${job.id}`); } lines.push( `| ${escapeMarkdownCell(job.id)} | ${escapeMarkdownCell(job.kindLabel)} | ${escapeMarkdownCell(job.status)} | ${escapeMarkdownCell(job.phase ?? "")} | ${escapeMarkdownCell(job.elapsed ?? "")} | ${escapeMarkdownCell(job.threadId ?? "")} | ${escapeMarkdownCell(job.summary ?? "")} | ${actions.map((action) => `\`${action}\``).join("
")} |` @@ -136,24 +136,24 @@ function pushJobDetails(lines, job, options = {}) { lines.push(` Duration: ${job.duration}`); } if (job.threadId) { - lines.push(` Codex session ID: ${job.threadId}`); + lines.push(` OpenCode session ID: ${job.threadId}`); } - const resumeCommand = formatCodexResumeCommand(job); + const resumeCommand = formatOpenCodeResumeCommand(job); if (resumeCommand) { - lines.push(` Resume in Codex: ${resumeCommand}`); + lines.push(` Resume in OpenCode: ${resumeCommand}`); } if (job.logFile && options.showLog) { lines.push(` Log: ${job.logFile}`); } if ((job.status === "queued" || job.status === "running") && options.showCancelHint) { - lines.push(` Cancel: /codex:cancel ${job.id}`); + lines.push(` Cancel: /opencode:cancel ${job.id}`); } if (job.status !== "queued" && job.status !== "running" && options.showResultHint) { - lines.push(` Result: /codex:result ${job.id}`); + lines.push(` Result: /opencode:result ${job.id}`); } if (job.status !== "queued" && job.status !== "running" && job.jobClass === "task" && job.write && options.showReviewHint) { - lines.push(" Review changes: /codex:review --wait"); - lines.push(" Stricter review: /codex:adversarial-review --wait"); + lines.push(" Review changes: /opencode:review --wait"); + lines.push(" Stricter review: /opencode:adversarial-review --wait"); } if (job.progressPreview?.length) { lines.push(" Progress:"); @@ -176,14 +176,14 @@ function appendReasoningSection(lines, reasoningSummary) { export function renderSetupReport(report) { const lines = [ - "# Codex Setup", + "# OpenCode Setup", "", `Status: ${report.ready ? "ready" : "needs attention"}`, "", "Checks:", `- node: ${report.node.detail}`, `- npm: ${report.npm.detail}`, - `- codex: ${report.codex.detail}`, + `- opencode: ${report.opencode.detail}`, `- auth: ${report.auth.detail}`, `- session runtime: ${report.sessionRuntime.label}`, `- review gate: ${report.reviewGateEnabled ? "enabled" : "disabled"}`, @@ -211,9 +211,9 @@ export function renderSetupReport(report) { export function renderReviewResult(parsedResult, meta) { if (!parsedResult.parsed) { const lines = [ - `# Codex ${meta.reviewLabel}`, + `# OpenCode ${meta.reviewLabel}`, "", - "Codex did not return valid structured JSON.", + "OpenCode did not return valid structured JSON.", "", `- Parse error: ${parsedResult.parseError}` ]; @@ -230,10 +230,10 @@ export function renderReviewResult(parsedResult, meta) { const validationError = validateReviewResultShape(parsedResult.parsed); if (validationError) { const lines = [ - `# Codex ${meta.reviewLabel}`, + `# OpenCode ${meta.reviewLabel}`, "", `Target: ${meta.targetLabel}`, - "Codex returned JSON with an unexpected review shape.", + "OpenCode returned JSON with an unexpected review shape.", "", `- Validation error: ${validationError}` ]; @@ -250,7 +250,7 @@ export function renderReviewResult(parsedResult, meta) { const data = normalizeReviewResultData(parsedResult.parsed); const findings = [...data.findings].sort((left, right) => severityRank(left.severity) - severityRank(right.severity)); const lines = [ - `# Codex ${meta.reviewLabel}`, + `# OpenCode ${meta.reviewLabel}`, "", `Target: ${meta.targetLabel}`, `Verdict: ${data.verdict}`, @@ -289,7 +289,7 @@ export function renderNativeReviewResult(result, meta) { const stdout = result.stdout.trim(); const stderr = result.stderr.trim(); const lines = [ - `# Codex ${meta.reviewLabel}`, + `# OpenCode ${meta.reviewLabel}`, "", `Target: ${meta.targetLabel}`, "" @@ -298,9 +298,9 @@ export function renderNativeReviewResult(result, meta) { if (stdout) { lines.push(stdout); } else if (result.status === 0) { - lines.push("Codex review completed without any stdout output."); + lines.push("OpenCode review completed without any stdout output."); } else { - lines.push("Codex review failed."); + lines.push("OpenCode review failed."); } if (stderr) { @@ -318,13 +318,13 @@ export function renderTaskResult(parsedResult, meta) { return rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; } - const message = String(parsedResult?.failureMessage ?? "").trim() || "Codex did not return a final message."; + const message = String(parsedResult?.failureMessage ?? "").trim() || "OpenCode did not return a final message."; return `${message}\n`; } export function renderStatusReport(report) { const lines = [ - "# Codex Status", + "# OpenCode Status", "", `Session runtime: ${report.sessionRuntime.label}`, `Review gate: ${report.config.stopReviewGate ? "enabled" : "disabled"}`, @@ -368,14 +368,14 @@ export function renderStatusReport(report) { if (report.needsReview) { lines.push("The stop-time review gate is enabled."); - lines.push("Ending the session will trigger a fresh Codex adversarial review and block if it finds issues."); + lines.push("Ending the session will trigger a fresh OpenCode adversarial review and block if it finds issues."); } return `${lines.join("\n").trimEnd()}\n`; } export function renderJobStatusReport(job) { - const lines = ["# Codex Job Status", ""]; + const lines = ["# OpenCode Job Status", ""]; pushJobDetails(lines, job, { showElapsed: job.status === "queued" || job.status === "running", showDuration: job.status !== "queued" && job.status !== "running", @@ -389,13 +389,13 @@ export function renderJobStatusReport(job) { export function renderStoredJobResult(job, storedJob) { const threadId = storedJob?.threadId ?? job.threadId ?? null; - const resumeCommand = threadId ? `codex resume ${threadId}` : null; + const resumeCommand = threadId ? `opencode --session ${threadId}` : null; if (isStructuredReviewStoredResult(storedJob) && storedJob?.rendered) { const output = storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`; if (!threadId) { return output; } - return `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`; + return `${output}\nOpenCode session ID: ${threadId}\nResume in OpenCode: ${resumeCommand}\n`; } const rawOutput = @@ -407,7 +407,7 @@ export function renderStoredJobResult(job, storedJob) { if (!threadId) { return output; } - return `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`; + return `${output}\nOpenCode session ID: ${threadId}\nResume in OpenCode: ${resumeCommand}\n`; } if (storedJob?.rendered) { @@ -415,19 +415,19 @@ export function renderStoredJobResult(job, storedJob) { if (!threadId) { return output; } - return `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`; + return `${output}\nOpenCode session ID: ${threadId}\nResume in OpenCode: ${resumeCommand}\n`; } const lines = [ - `# ${job.title ?? "Codex Result"}`, + `# ${job.title ?? "OpenCode Result"}`, "", `Job: ${job.id}`, `Status: ${job.status}` ]; if (threadId) { - lines.push(`Codex session ID: ${threadId}`); - lines.push(`Resume in Codex: ${resumeCommand}`); + lines.push(`OpenCode session ID: ${threadId}`); + lines.push(`Resume in OpenCode: ${resumeCommand}`); } if (job.summary) { @@ -447,7 +447,7 @@ export function renderStoredJobResult(job, storedJob) { export function renderCancelReport(job) { const lines = [ - "# Codex Cancel", + "# OpenCode Cancel", "", `Cancelled ${job.id}.`, "" @@ -459,7 +459,7 @@ export function renderCancelReport(job) { if (job.summary) { lines.push(`- Summary: ${job.summary}`); } - lines.push("- Check `/codex:status` for the updated queue."); + lines.push("- Check `/opencode:status` for the updated queue."); return `${lines.join("\n").trimEnd()}\n`; } diff --git a/plugins/opencode/scripts/lib/server-lifecycle.mjs b/plugins/opencode/scripts/lib/server-lifecycle.mjs new file mode 100644 index 0000000..774e270 --- /dev/null +++ b/plugins/opencode/scripts/lib/server-lifecycle.mjs @@ -0,0 +1,266 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { spawn } from "node:child_process"; + +import { OpencodeServerClient } from "./opencode-server.mjs"; +import { resolveStateDir } from "./state.mjs"; + +export const SERVER_URL_ENV = "OPENCODE_COMPANION_SERVER_URL"; +export const PID_FILE_ENV = "OPENCODE_COMPANION_SERVER_PID_FILE"; +export const LOG_FILE_ENV = "OPENCODE_COMPANION_SERVER_LOG_FILE"; + +const SERVER_STATE_FILE = "server.json"; +const DEFAULT_HOSTNAME = "127.0.0.1"; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function normalizeUrl(url) { + const normalized = String(url ?? "").trim().replace(/\/+$/, ""); + return normalized || null; +} + +export function createServerSessionDir(prefix = "occ-") { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function resolveServerStateFile(cwd) { + return path.join(resolveStateDir(cwd), SERVER_STATE_FILE); +} + +export function loadServerSession(cwd) { + const stateFile = resolveServerStateFile(cwd); + if (!fs.existsSync(stateFile)) { + return null; + } + + try { + return JSON.parse(fs.readFileSync(stateFile, "utf8")); + } catch { + return null; + } +} + +export function saveServerSession(cwd, session) { + const stateDir = resolveStateDir(cwd); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(resolveServerStateFile(cwd), `${JSON.stringify(session, null, 2)}\n`, "utf8"); +} + +export function clearServerSession(cwd) { + const stateFile = resolveServerStateFile(cwd); + if (fs.existsSync(stateFile)) { + fs.unlinkSync(stateFile); + } +} + +async function withTimeout(fn, timeoutMs) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fn(controller.signal); + } finally { + clearTimeout(timeout); + } +} + +export async function isServerHealthy(url, timeoutMs = 500) { + const normalized = normalizeUrl(url); + if (!normalized) { + return false; + } + + try { + const client = new OpencodeServerClient(normalized); + await withTimeout((signal) => client.health({ signal }), timeoutMs); + return true; + } catch { + return false; + } +} + +async function waitForServerHealth(url, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await isServerHealthy(url, 500)) { + return true; + } + await sleep(100); + } + return false; +} + +function findOpenPort(hostname = DEFAULT_HOSTNAME) { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.on("error", reject); + server.listen(0, hostname, () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : null; + server.close(() => { + if (port) { + resolve(port); + } else { + reject(new Error("Could not allocate an OpenCode server port.")); + } + }); + }); + }); +} + +export function spawnServerProcess({ cwd, port, hostname = DEFAULT_HOSTNAME, pidFile, logFile, env = process.env }) { + const logFd = fs.openSync(logFile, "a"); + const child = spawn("opencode", ["serve", "--hostname", hostname, "--port", String(port)], { + cwd, + env, + detached: true, + stdio: ["ignore", logFd, logFd], + windowsHide: true, + shell: process.platform === "win32" ? process.env.SHELL || true : false + }); + child.unref(); + fs.closeSync(logFd); + + if (pidFile && child.pid) { + fs.writeFileSync(pidFile, `${child.pid}\n`, "utf8"); + } + + return child; +} + +function killServerPid(pid, killProcess = null) { + if (!Number.isFinite(pid)) { + return; + } + + if (killProcess) { + killProcess(pid); + return; + } + + try { + process.kill(pid, "SIGTERM"); + } catch { + // Ignore already-exited processes. + } + if (process.platform !== "win32") { + try { + process.kill(-pid, "SIGTERM"); + } catch { + // Ignore missing process groups. + } + } +} + +export async function ensureServer(cwd, options = {}) { + const overrideUrl = normalizeUrl(options.serverUrl ?? options.env?.[SERVER_URL_ENV] ?? process.env[SERVER_URL_ENV]); + if (overrideUrl) { + if (!(await isServerHealthy(overrideUrl, options.healthTimeoutMs ?? 1000))) { + throw new Error(`Configured OpenCode server is not healthy: ${overrideUrl}`); + } + return { + url: overrideUrl, + pid: null, + external: true + }; + } + + const existing = loadServerSession(cwd); + if (existing?.url && (await isServerHealthy(existing.url, options.healthTimeoutMs ?? 500))) { + return existing; + } + + if (existing) { + await teardownServerSession({ + ...existing, + killProcess: options.killProcess ?? null + }); + clearServerSession(cwd); + } + + const hostname = options.hostname ?? DEFAULT_HOSTNAME; + const port = options.port ?? (await findOpenPort(hostname)); + const url = `http://${hostname}:${port}`; + const sessionDir = createServerSessionDir(); + const pidFile = path.join(sessionDir, "opencode-server.pid"); + const logFile = path.join(sessionDir, "opencode-server.log"); + const child = spawnServerProcess({ + cwd, + hostname, + port, + pidFile, + logFile, + env: options.env ?? process.env + }); + + const ready = await waitForServerHealth(url, options.timeoutMs ?? 10000); + if (!ready) { + await teardownServerSession({ + url, + pidFile, + logFile, + sessionDir, + pid: child.pid ?? null, + killProcess: options.killProcess ?? null + }); + return null; + } + + const session = { + url, + pid: child.pid ?? null, + pidFile, + logFile, + sessionDir, + external: false + }; + saveServerSession(cwd, session); + return session; +} + +export async function teardownServerSession({ + url = null, + pidFile = null, + logFile = null, + sessionDir = null, + pid = null, + external = false, + killProcess = null +} = {}) { + if (url && !external) { + try { + const client = new OpencodeServerClient(url); + await withTimeout((signal) => client.dispose({ signal }), 1000); + } catch { + // Fall back to process termination below. + } + } + + if (!external && Number.isFinite(pid)) { + try { + killServerPid(pid, killProcess); + } catch { + // Ignore teardown failures during Claude session shutdown. + } + } + + if (pidFile && fs.existsSync(pidFile)) { + fs.unlinkSync(pidFile); + } + if (logFile && fs.existsSync(logFile)) { + fs.unlinkSync(logFile); + } + + const resolvedSessionDir = sessionDir ?? (pidFile ? path.dirname(pidFile) : logFile ? path.dirname(logFile) : null); + if (resolvedSessionDir && fs.existsSync(resolvedSessionDir)) { + try { + fs.rmdirSync(resolvedSessionDir); + } catch { + // Ignore non-empty or missing directories. + } + } +} diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/opencode/scripts/lib/state.mjs similarity index 98% rename from plugins/codex/scripts/lib/state.mjs rename to plugins/opencode/scripts/lib/state.mjs index 2da2349..11fb00a 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/opencode/scripts/lib/state.mjs @@ -7,7 +7,7 @@ import { resolveWorkspaceRoot } from "./workspace.mjs"; const STATE_VERSION = 1; const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; -const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "codex-companion"); +const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "opencode-companion"); const STATE_FILE_NAME = "state.json"; const JOBS_DIR_NAME = "jobs"; const MAX_JOBS = 50; diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/opencode/scripts/lib/tracked-jobs.mjs similarity index 97% rename from plugins/codex/scripts/lib/tracked-jobs.mjs rename to plugins/opencode/scripts/lib/tracked-jobs.mjs index 9028690..a3e5e70 100644 --- a/plugins/codex/scripts/lib/tracked-jobs.mjs +++ b/plugins/opencode/scripts/lib/tracked-jobs.mjs @@ -3,7 +3,7 @@ import process from "node:process"; import { readJobFile, resolveJobFile, resolveJobLogFile, upsertJob, writeJobFile } from "./state.mjs"; -export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID"; +export const SESSION_ID_ENV = "OPENCODE_COMPANION_SESSION_ID"; export function nowIso() { return new Date().toISOString(); @@ -123,7 +123,7 @@ export function createProgressReporter({ stderr = false, logFile = null, onEvent const event = normalizeProgressEvent(eventOrMessage); const stderrMessage = event.stderrMessage ?? event.message; if (stderr && stderrMessage) { - process.stderr.write(`[codex] ${stderrMessage}\n`); + process.stderr.write(`[opencode] ${stderrMessage}\n`); } appendLogLine(logFile, event.message); appendLogBlock(logFile, event.logTitle, event.logBody); diff --git a/plugins/codex/scripts/lib/workspace.mjs b/plugins/opencode/scripts/lib/workspace.mjs similarity index 100% rename from plugins/codex/scripts/lib/workspace.mjs rename to plugins/opencode/scripts/lib/workspace.mjs diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs similarity index 81% rename from plugins/codex/scripts/codex-companion.mjs rename to plugins/opencode/scripts/opencode-companion.mjs index 83df468..7b2dd6b 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -8,19 +8,19 @@ import { fileURLToPath } from "node:url"; import { parseArgs, splitRawArgumentString } from "./lib/args.mjs"; import { - buildPersistentTaskThreadName, - DEFAULT_CONTINUE_PROMPT, - findLatestTaskThread, - getCodexAuthStatus, - getCodexAvailability, - getSessionRuntimeStatus, - importExternalAgentSession, - interruptAppServerTurn, - parseStructuredOutput, - readOutputSchema, - runAppServerReview, - runAppServerTurn - } from "./lib/codex.mjs"; + buildPersistentTaskThreadName, + DEFAULT_CONTINUE_PROMPT, + findLatestTaskThread, + getAuthStatus, + getAvailability, + getSessionRuntimeStatus, + importExternalAgentSession, + interruptServerTurn, + parseStructuredOutput, + readOutputSchema, + runServerReview, + runServerTurn +} from "./lib/opencode.mjs"; import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs"; import { readStdinIfPiped } from "./lib/fs.mjs"; import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs"; @@ -54,7 +54,6 @@ import { } from "./lib/tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; import { - renderNativeReviewResult, renderReviewResult, renderStoredJobResult, renderCancelReport, @@ -68,22 +67,21 @@ const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url))); const REVIEW_SCHEMA = path.join(ROOT_DIR, "schemas", "review-output.schema.json"); const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000; const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000; -const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]); -const MODEL_ALIASES = new Map([["spark", "gpt-5.3-codex-spark"]]); +const MODEL_ALIASES = new Map([["spark", "openai/gpt-5.3-codex-spark"]]); const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; function printUsage() { console.log( [ "Usage:", - " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", - " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", - " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", - " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", - " node scripts/codex-companion.mjs transfer [--source ] [--json]", - " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", - " node scripts/codex-companion.mjs result [job-id] [--json]", - " node scripts/codex-companion.mjs cancel [job-id] [--json]" + " node scripts/opencode-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", + " node scripts/opencode-companion.mjs review [--wait|--background] [--base ] [--scope ]", + " node scripts/opencode-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", + " node scripts/opencode-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/opencode-companion.mjs transfer [--source ] [--json]", + " node scripts/opencode-companion.mjs status [job-id] [--all] [--json]", + " node scripts/opencode-companion.mjs result [job-id] [--json]", + " node scripts/opencode-companion.mjs cancel [job-id] [--json]" ].join("\n") ); } @@ -115,15 +113,10 @@ function normalizeReasoningEffort(effort) { if (effort == null) { return null; } - const normalized = String(effort).trim().toLowerCase(); + const normalized = String(effort).trim(); if (!normalized) { return null; } - if (!VALID_REASONING_EFFORTS.has(normalized)) { - throw new Error( - `Unsupported reasoning effort "${effort}". Use one of: none, minimal, low, medium, high, xhigh.` - ); - } return normalized; } @@ -183,27 +176,26 @@ async function buildSetupReport(cwd, actionsTaken = []) { const workspaceRoot = resolveWorkspaceRoot(cwd); const nodeStatus = binaryAvailable("node", ["--version"], { cwd }); const npmStatus = binaryAvailable("npm", ["--version"], { cwd }); - const codexStatus = getCodexAvailability(cwd); - const authStatus = await getCodexAuthStatus(cwd); + const opencodeStatus = getAvailability(cwd); + const authStatus = await getAuthStatus(cwd); const config = getConfig(workspaceRoot); const nextSteps = []; - if (!codexStatus.available) { - nextSteps.push("Install Codex with `npm install -g @openai/codex`."); + if (!opencodeStatus.available) { + nextSteps.push("Install OpenCode and ensure `opencode --version` works."); } - if (codexStatus.available && !authStatus.loggedIn && authStatus.requiresOpenaiAuth) { - nextSteps.push("Run `!codex login`."); - nextSteps.push("If browser login is blocked, retry with `!codex login --device-auth` or `!codex login --with-api-key`."); + if (opencodeStatus.available && !authStatus.loggedIn) { + nextSteps.push("Configure an OpenCode provider, then rerun `/opencode:setup`."); } if (!config.stopReviewGate) { - nextSteps.push("Optional: run `/codex:setup --enable-review-gate` to require a fresh review before stop."); + nextSteps.push("Optional: run `/opencode:setup --enable-review-gate` to require a fresh review before stop."); } return { - ready: nodeStatus.available && codexStatus.available && authStatus.loggedIn, + ready: nodeStatus.available && opencodeStatus.available && authStatus.loggedIn, node: nodeStatus, npm: npmStatus, - codex: codexStatus, + opencode: opencodeStatus, auth: authStatus, sessionRuntime: getSessionRuntimeStatus(process.env, workspaceRoot), reviewGateEnabled: Boolean(config.stopReviewGate), @@ -249,38 +241,11 @@ function buildAdversarialReviewPrompt(context, focusText) { }); } -function ensureCodexAvailable(cwd) { - const availability = getCodexAvailability(cwd); +function ensureOpenCodeAvailable(cwd) { + const availability = getAvailability(cwd); if (!availability.available) { - throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`."); - } -} - -function buildNativeReviewTarget(target) { - if (target.mode === "working-tree") { - return { type: "uncommittedChanges" }; - } - - if (target.mode === "branch") { - return { type: "baseBranch", branch: target.baseRef }; - } - - return null; -} - -function validateNativeReviewRequest(target, focusText) { - if (focusText.trim()) { - throw new Error( - `\`/codex:review\` now maps directly to the built-in reviewer and does not support custom focus text. Retry with \`/codex:adversarial-review ${focusText.trim()}\` for focused review instructions.` - ); - } - - const nativeTarget = buildNativeReviewTarget(target); - if (!nativeTarget) { - throw new Error("This `/codex:review` target is not supported by the built-in reviewer. Retry with `/codex:adversarial-review` for custom targeting."); + throw new Error("OpenCode CLI is not installed or is missing headless server support. Install OpenCode, then rerun `/opencode:setup`."); } - - return nativeTarget; } function renderStatusPayload(report, asJson) { @@ -340,7 +305,7 @@ async function resolveLatestTrackedTaskThread(cwd, options = {}) { const visibleJobs = filterJobsForCurrentClaudeSession(jobs); const activeTask = visibleJobs.find((job) => job.jobClass === "task" && (job.status === "queued" || job.status === "running")); if (activeTask) { - throw new Error(`Task ${activeTask.id} is still running. Use /codex:status before continuing it.`); + throw new Error(`Task ${activeTask.id} is still running. Use /opencode:status before continuing it.`); } const trackedTask = findLatestResumableTaskJob(visibleJobs); @@ -356,7 +321,7 @@ async function resolveLatestTrackedTaskThread(cwd, options = {}) { } async function executeReviewRun(request) { - ensureCodexAvailable(request.cwd); + ensureOpenCodeAvailable(request.cwd); ensureGitRepository(request.cwd); const target = resolveReviewTarget(request.cwd, { @@ -365,53 +330,12 @@ async function executeReviewRun(request) { }); const focusText = request.focusText?.trim() ?? ""; const reviewName = request.reviewName ?? "Review"; - if (reviewName === "Review") { - const reviewTarget = validateNativeReviewRequest(target, focusText); - const result = await runAppServerReview(request.cwd, { - target: reviewTarget, - model: request.model, - onProgress: request.onProgress - }); - const payload = { - review: reviewName, - target, - threadId: result.threadId, - sourceThreadId: result.sourceThreadId, - codex: { - status: result.status, - stderr: result.stderr, - stdout: result.reviewText, - reasoning: result.reasoningSummary - } - }; - const rendered = renderNativeReviewResult( - { - status: result.status, - stdout: result.reviewText, - stderr: result.stderr - }, - { reviewLabel: reviewName, targetLabel: target.label, reasoningSummary: result.reasoningSummary } - ); - - return { - exitStatus: result.status, - threadId: result.threadId, - turnId: result.turnId, - payload, - rendered, - summary: firstMeaningfulLine(result.reviewText, `${reviewName} completed.`), - jobTitle: `Codex ${reviewName}`, - jobClass: "review", - targetLabel: target.label - }; - } const context = collectReviewContext(request.cwd, target); const prompt = buildAdversarialReviewPrompt(context, focusText); - const result = await runAppServerTurn(context.repoRoot, { + const result = await runServerReview(context.repoRoot, { prompt, model: request.model, - sandbox: "read-only", outputSchema: readOutputSchema(REVIEW_SCHEMA), onProgress: request.onProgress }); @@ -428,7 +352,7 @@ async function executeReviewRun(request) { branch: context.branch, summary: context.summary }, - codex: { + opencode: { status: result.status, stderr: result.stderr, stdout: result.finalMessage, @@ -451,7 +375,7 @@ async function executeReviewRun(request) { reasoningSummary: result.reasoningSummary }), summary: parsed.parsed?.summary ?? parsed.parseError ?? firstMeaningfulLine(result.finalMessage, `${reviewName} finished.`), - jobTitle: `Codex ${reviewName}`, + jobTitle: `OpenCode ${reviewName}`, jobClass: "review", targetLabel: context.target.label }; @@ -460,7 +384,7 @@ async function executeReviewRun(request) { async function executeTaskRun(request) { const workspaceRoot = resolveWorkspaceRoot(request.cwd); - ensureCodexAvailable(request.cwd); + ensureOpenCodeAvailable(request.cwd); const taskMetadata = buildTaskRunMetadata({ prompt: request.prompt, @@ -473,7 +397,7 @@ async function executeTaskRun(request) { excludeJobId: request.jobId }); if (!latestThread) { - throw new Error("No previous Codex task thread was found for this repository."); + throw new Error("No previous OpenCode task session was found for this repository."); } resumeThreadId = latestThread.id; } @@ -482,12 +406,13 @@ async function executeTaskRun(request) { throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last."); } - const result = await runAppServerTurn(workspaceRoot, { + const result = await runServerTurn(workspaceRoot, { resumeThreadId, prompt: request.prompt, defaultPrompt: resumeThreadId ? DEFAULT_CONTINUE_PROMPT : "", model: request.model, - effort: request.effort, + variant: request.effort, + write: request.write, sandbox: request.write ? "workspace-write" : "read-only", onProgress: request.onProgress, persistThread: true, @@ -532,7 +457,7 @@ async function executeTaskRun(request) { function buildReviewJobMetadata(reviewName, target) { return { kind: reviewName === "Adversarial Review" ? "adversarial-review" : "review", - title: reviewName === "Review" ? "Codex Review" : `Codex ${reviewName}`, + title: reviewName === "Review" ? "OpenCode Review" : `OpenCode ${reviewName}`, summary: `${reviewName} ${target.label}` }; } @@ -540,12 +465,12 @@ function buildReviewJobMetadata(reviewName, target) { function buildTaskRunMetadata({ prompt, resumeLast = false }) { if (!resumeLast && String(prompt ?? "").includes(STOP_REVIEW_TASK_MARKER)) { return { - title: "Codex Stop Gate Review", + title: "OpenCode Stop Gate Review", summary: "Stop-gate review of previous Claude turn" }; } - const title = resumeLast ? "Codex Resume" : "Codex Task"; + const title = resumeLast ? "OpenCode Resume" : "OpenCode Task"; const fallbackSummary = resumeLast ? DEFAULT_CONTINUE_PROMPT : "Task"; return { title, @@ -554,7 +479,7 @@ function buildTaskRunMetadata({ prompt, resumeLast = false }) { } function renderQueuedTaskLaunch(payload) { - return `${payload.title} started in the background as ${payload.jobId}. Check /codex:status ${payload.jobId} for progress.\n`; + return `${payload.title} started in the background as ${payload.jobId}. Check /opencode:status ${payload.jobId} for progress.\n`; } function getJobKindLabel(kind, jobClass) { @@ -615,9 +540,9 @@ function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId function renderTransferResult(payload) { const lines = [ - "Transferred the Claude session into a Codex thread with visible turn history.", - `Codex session ID: ${payload.threadId}`, - `Resume in Codex: ${payload.resumeCommand}` + "Transferred the Claude session into an OpenCode session with visible turn history.", + `OpenCode session ID: ${payload.threadId}`, + `Resume in OpenCode: ${payload.resumeCommand}` ]; return `${lines.join("\n")}\n`; } @@ -629,7 +554,7 @@ async function executeTransfer(cwd, options = {}) { const result = await importExternalAgentSession(cwd, { sourcePath }); const payload = { threadId: result.threadId, - resumeCommand: `codex resume ${result.threadId}`, + resumeCommand: `opencode --session ${result.threadId}`, sourcePath, sessionId: path.basename(sourcePath, ".jsonl") }; @@ -669,7 +594,7 @@ async function runForegroundCommand(job, runner, options = {}) { } function spawnDetachedTaskWorker(cwd, jobId) { - const scriptPath = path.join(ROOT_DIR, "scripts", "codex-companion.mjs"); + const scriptPath = path.join(ROOT_DIR, "scripts", "opencode-companion.mjs"); const child = spawn(process.execPath, [scriptPath, "task-worker", "--cwd", cwd, "--job-id", jobId], { cwd, env: process.env, @@ -754,8 +679,7 @@ async function handleReviewCommand(argv, config) { async function handleReview(argv) { return handleReviewCommand(argv, { - reviewName: "Review", - validateRequest: validateNativeReviewRequest + reviewName: "Review" }); } @@ -786,7 +710,7 @@ async function handleTask(argv) { }); if (options.background) { - ensureCodexAvailable(cwd); + ensureOpenCodeAvailable(cwd); requireTaskRequest(prompt, resumeLast); const job = buildTaskJob(workspaceRoot, taskMetadata, write); @@ -973,13 +897,13 @@ async function handleCancel(argv) { const threadId = existing.threadId ?? job.threadId ?? null; const turnId = existing.turnId ?? job.turnId ?? null; - const interrupt = await interruptAppServerTurn(cwd, { threadId, turnId }); + const interrupt = await interruptServerTurn(cwd, { threadId, turnId }); if (interrupt.attempted) { appendLogLine( job.logFile, interrupt.interrupted - ? `Requested Codex turn interrupt for ${turnId} on ${threadId}.` - : `Codex turn interrupt failed${interrupt.detail ? `: ${interrupt.detail}` : "."}` + ? `Requested OpenCode session abort for ${threadId}.` + : `OpenCode session abort failed${interrupt.detail ? `: ${interrupt.detail}` : "."}` ); } diff --git a/plugins/codex/scripts/session-lifecycle-hook.mjs b/plugins/opencode/scripts/session-lifecycle-hook.mjs similarity index 74% rename from plugins/codex/scripts/session-lifecycle-hook.mjs rename to plugins/opencode/scripts/session-lifecycle-hook.mjs index 778571e..814cf48 100644 --- a/plugins/codex/scripts/session-lifecycle-hook.mjs +++ b/plugins/opencode/scripts/session-lifecycle-hook.mjs @@ -4,20 +4,19 @@ import fs from "node:fs"; import process from "node:process"; import { terminateProcessTree } from "./lib/process.mjs"; -import { BROKER_ENDPOINT_ENV } from "./lib/app-server.mjs"; import { - clearBrokerSession, + clearServerSession, LOG_FILE_ENV, - loadBrokerSession, PID_FILE_ENV, - sendBrokerShutdown, - teardownBrokerSession -} from "./lib/broker-lifecycle.mjs"; + SERVER_URL_ENV, + loadServerSession, + teardownServerSession +} from "./lib/server-lifecycle.mjs"; import { loadState, resolveStateFile, saveState } from "./lib/state.mjs"; import { TRANSCRIPT_PATH_ENV } from "./lib/claude-session-transfer.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; -export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID"; +export const SESSION_ID_ENV = "OPENCODE_COMPANION_SESSION_ID"; const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; function readHookInput() { @@ -82,35 +81,28 @@ function handleSessionStart(input) { async function handleSessionEnd(input) { const cwd = input.cwd || process.cwd(); - const brokerSession = - loadBrokerSession(cwd) ?? - (process.env[BROKER_ENDPOINT_ENV] + const serverSession = + loadServerSession(cwd) ?? + (process.env[SERVER_URL_ENV] ? { - endpoint: process.env[BROKER_ENDPOINT_ENV], + url: process.env[SERVER_URL_ENV], pidFile: process.env[PID_FILE_ENV] ?? null, - logFile: process.env[LOG_FILE_ENV] ?? null + logFile: process.env[LOG_FILE_ENV] ?? null, + external: true } : null); - const brokerEndpoint = brokerSession?.endpoint ?? null; - const pidFile = brokerSession?.pidFile ?? null; - const logFile = brokerSession?.logFile ?? null; - const sessionDir = brokerSession?.sessionDir ?? null; - const pid = brokerSession?.pid ?? null; - - if (brokerEndpoint) { - await sendBrokerShutdown(brokerEndpoint); - } cleanupSessionJobs(cwd, input.session_id || process.env[SESSION_ID_ENV]); - teardownBrokerSession({ - endpoint: brokerEndpoint, - pidFile, - logFile, - sessionDir, - pid, + await teardownServerSession({ + url: serverSession?.url ?? null, + pidFile: serverSession?.pidFile ?? null, + logFile: serverSession?.logFile ?? null, + sessionDir: serverSession?.sessionDir ?? null, + pid: serverSession?.pid ?? null, + external: Boolean(serverSession?.external), killProcess: terminateProcessTree }); - clearBrokerSession(cwd); + clearServerSession(cwd); } async function main() { diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/opencode/scripts/stop-review-gate-hook.mjs similarity index 77% rename from plugins/codex/scripts/stop-review-gate-hook.mjs rename to plugins/opencode/scripts/stop-review-gate-hook.mjs index 2346bdc..9c3343f 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/opencode/scripts/stop-review-gate-hook.mjs @@ -6,7 +6,7 @@ import path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { getCodexAvailability } from "./lib/codex.mjs"; +import { getAvailability } from "./lib/opencode.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; import { getConfig, listJobs } from "./lib/state.mjs"; import { sortJobsNewestFirst } from "./lib/job-control.mjs"; @@ -57,13 +57,13 @@ function buildStopReviewPrompt(input = {}) { } function buildSetupNote(cwd) { - const availability = getCodexAvailability(cwd); + const availability = getAvailability(cwd); if (availability.available) { return null; } const detail = availability.detail ? ` ${availability.detail}.` : ""; - return `Codex is not set up for the review gate.${detail} Run /codex:setup.`; + return `OpenCode is not set up for the review gate.${detail} Run /opencode:setup.`; } function parseStopReviewOutput(rawOutput) { @@ -72,7 +72,7 @@ function parseStopReviewOutput(rawOutput) { return { ok: false, reason: - "The stop-time Codex review task returned no final output. Run /codex:review --wait manually or bypass the gate." + "The stop-time OpenCode review task returned no final output. Run /opencode:review --wait manually or bypass the gate." }; } @@ -84,19 +84,19 @@ function parseStopReviewOutput(rawOutput) { const reason = firstLine.slice("BLOCK:".length).trim() || text; return { ok: false, - reason: `Codex stop-time review found issues that still need fixes before ending the session: ${reason}` + reason: `OpenCode stop-time review found issues that still need fixes before ending the session: ${reason}` }; } return { ok: false, reason: - "The stop-time Codex review task returned an unexpected answer. Run /codex:review --wait manually or bypass the gate." + "The stop-time OpenCode review task returned an unexpected answer. Run /opencode:review --wait manually or bypass the gate." }; } function runStopReview(cwd, input = {}) { - const scriptPath = path.join(SCRIPT_DIR, "codex-companion.mjs"); + const scriptPath = path.join(SCRIPT_DIR, "opencode-companion.mjs"); const prompt = buildStopReviewPrompt(input); const childEnv = { ...process.env, @@ -113,7 +113,7 @@ function runStopReview(cwd, input = {}) { return { ok: false, reason: - "The stop-time Codex review task timed out after 15 minutes. Run /codex:review --wait manually or bypass the gate." + "The stop-time OpenCode review task timed out after 15 minutes. Run /opencode:review --wait manually or bypass the gate." }; } @@ -122,8 +122,8 @@ function runStopReview(cwd, input = {}) { return { ok: false, reason: detail - ? `The stop-time Codex review task failed: ${detail}` - : "The stop-time Codex review task failed. Run /codex:review --wait manually or bypass the gate." + ? `The stop-time OpenCode review task failed: ${detail}` + : "The stop-time OpenCode review task failed. Run /opencode:review --wait manually or bypass the gate." }; } @@ -134,7 +134,7 @@ function runStopReview(cwd, input = {}) { return { ok: false, reason: - "The stop-time Codex review task returned invalid JSON. Run /codex:review --wait manually or bypass the gate." + "The stop-time OpenCode review task returned invalid JSON. Run /opencode:review --wait manually or bypass the gate." }; } } @@ -148,7 +148,7 @@ function main() { const jobs = sortJobsNewestFirst(filterJobsForCurrentSession(listJobs(workspaceRoot), input)); const runningJob = jobs.find((job) => job.status === "queued" || job.status === "running"); const runningTaskNote = runningJob - ? `Codex task ${runningJob.id} is still running. Check /codex:status and use /codex:cancel ${runningJob.id} if you want to stop it before ending the session.` + ? `OpenCode task ${runningJob.id} is still running. Check /opencode:status and use /opencode:cancel ${runningJob.id} if you want to stop it before ending the session.` : null; if (!config.stopReviewGate) { diff --git a/plugins/codex/skills/gpt-5-4-prompting/SKILL.md b/plugins/opencode/skills/gpt-5-4-prompting/SKILL.md similarity index 66% rename from plugins/codex/skills/gpt-5-4-prompting/SKILL.md rename to plugins/opencode/skills/gpt-5-4-prompting/SKILL.md index 16669d9..47ade35 100644 --- a/plugins/codex/skills/gpt-5-4-prompting/SKILL.md +++ b/plugins/opencode/skills/gpt-5-4-prompting/SKILL.md @@ -1,18 +1,18 @@ --- name: gpt-5-4-prompting -description: Internal guidance for composing Codex and GPT-5.4 prompts for coding, review, diagnosis, and research tasks inside the Codex Claude Code plugin +description: Internal guidance for composing OpenCode and GPT-5.4 prompts for coding, review, diagnosis, and research tasks inside the OpenCode Claude Code plugin user-invocable: false --- # GPT-5.4 Prompting -Use this skill when `codex:codex-rescue` needs to ask Codex or another GPT-5.4-based workflow for help. +Use this skill when `opencode:opencode-rescue` needs to ask OpenCode or another GPT-5.4-based workflow for help. -Prompt Codex like an operator, not a collaborator. Keep prompts compact and block-structured with XML tags. State the task, the output contract, the follow-through defaults, and the small set of extra constraints that matter. +Prompt OpenCode like an operator, not a collaborator. Keep prompts compact and block-structured with XML tags. State the task, the output contract, the follow-through defaults, and the small set of extra constraints that matter. Core rules: -- Prefer one clear task per Codex run. Split unrelated asks into separate runs. -- Tell Codex what done looks like. Do not assume it will infer the desired end state. +- Prefer one clear task per OpenCode run. Split unrelated asks into separate runs. +- Tell OpenCode what done looks like. Do not assume it will infer the desired end state. - Add explicit grounding and verification rules for any task where unsupported guesses would hurt quality. - Prefer better prompt contracts over raising reasoning or adding long natural-language explanations. - Use XML tags consistently so the prompt has stable internal structure. @@ -20,7 +20,7 @@ Core rules: Default prompt recipe: - ``: the concrete job and the relevant repository or failure context. - `` or ``: exact shape, ordering, and brevity requirements. -- ``: what Codex should do by default instead of asking routine questions. +- ``: what OpenCode should do by default instead of asking routine questions. - `` or ``: required for debugging, implementation, or risky fixes. - `` or ``: required for review, research, or anything that could drift into unsupported claims. @@ -28,24 +28,24 @@ When to add blocks: - Coding or debugging: add `completeness_contract`, `verification_loop`, and `missing_context_gating`. - Review or adversarial review: add `grounding_rules`, `structured_output_contract`, and `dig_deeper_nudge`. - Research or recommendation tasks: add `research_mode` and `citation_rules`. -- Write-capable tasks: add `action_safety` so Codex stays narrow and avoids unrelated refactors. +- Write-capable tasks: add `action_safety` so OpenCode stays narrow and avoids unrelated refactors. How to choose prompt shape: - Use built-in `review` or `adversarial-review` commands when the job is reviewing local git changes. Those prompts already carry the review contract. - Use `task` when the task is diagnosis, planning, research, or implementation and you need to control the prompt more directly. -- Use `task --resume-last` for follow-up instructions on the same Codex thread. Send only the delta instruction instead of restating the whole prompt unless the direction changed materially. +- Use `task --resume-last` for follow-up instructions on the same OpenCode thread. Send only the delta instruction instead of restating the whole prompt unless the direction changed materially. Working rules: - Prefer explicit prompt contracts over vague nudges. - Use stable XML tag names that match the block names from the reference file. - Do not raise reasoning or complexity first. Tighten the prompt and verification rules before escalating. -- Ask Codex for brief, outcome-based progress updates only when the task is long-running or tool-heavy. +- Ask OpenCode for brief, outcome-based progress updates only when the task is long-running or tool-heavy. - Keep claims anchored to observed evidence. If something is a hypothesis, say so. Prompt assembly checklist: 1. Define the exact task and scope in ``. 2. Choose the smallest output contract that still makes the answer easy to use. -3. Decide whether Codex should keep going by default or stop for missing high-risk details. +3. Decide whether OpenCode should keep going by default or stop for missing high-risk details. 4. Add verification, grounding, and safety tags only where the task needs them. 5. Remove redundant instructions before sending the prompt. diff --git a/plugins/codex/skills/gpt-5-4-prompting/references/codex-prompt-antipatterns.md b/plugins/opencode/skills/gpt-5-4-prompting/references/codex-prompt-antipatterns.md similarity index 94% rename from plugins/codex/skills/gpt-5-4-prompting/references/codex-prompt-antipatterns.md rename to plugins/opencode/skills/gpt-5-4-prompting/references/codex-prompt-antipatterns.md index 10a44d6..7366592 100644 --- a/plugins/codex/skills/gpt-5-4-prompting/references/codex-prompt-antipatterns.md +++ b/plugins/opencode/skills/gpt-5-4-prompting/references/codex-prompt-antipatterns.md @@ -1,6 +1,6 @@ -# Codex Prompt Anti-Patterns +# OpenCode Prompt Anti-Patterns -Avoid these when prompting Codex or GPT-5.4. +Avoid these when prompting OpenCode or GPT-5.4. ## Vague task framing diff --git a/plugins/codex/skills/gpt-5-4-prompting/references/codex-prompt-recipes.md b/plugins/opencode/skills/gpt-5-4-prompting/references/codex-prompt-recipes.md similarity index 91% rename from plugins/codex/skills/gpt-5-4-prompting/references/codex-prompt-recipes.md rename to plugins/opencode/skills/gpt-5-4-prompting/references/codex-prompt-recipes.md index 7711de2..81bee53 100644 --- a/plugins/codex/skills/gpt-5-4-prompting/references/codex-prompt-recipes.md +++ b/plugins/opencode/skills/gpt-5-4-prompting/references/codex-prompt-recipes.md @@ -1,8 +1,8 @@ -# Codex Prompt Recipes +# OpenCode Prompt Recipes -Use these as starting templates for Codex task prompts or other Codex/GPT-5.4 prompt construction. +Use these as starting templates for OpenCode task prompts or other OpenCode/GPT-5.4 prompt construction. Copy the smallest recipe that fits the task, then trim anything you do not need. -In `codex:codex-rescue`, run diagnosis and fix-oriented recipes in write mode by default unless the user explicitly asked for read-only behavior. +In `opencode:opencode-rescue`, run diagnosis and fix-oriented recipes in write mode by default unless the user explicitly asked for read-only behavior. ## Diagnosis @@ -128,7 +128,7 @@ Prefer primary sources. ```xml -Diagnose why this existing prompt is underperforming and propose the smallest high-leverage changes to improve it for Codex or GPT-5.4. +Diagnose why this existing prompt is underperforming and propose the smallest high-leverage changes to improve it for OpenCode or GPT-5.4. diff --git a/plugins/codex/skills/gpt-5-4-prompting/references/prompt-blocks.md b/plugins/opencode/skills/gpt-5-4-prompting/references/prompt-blocks.md similarity index 95% rename from plugins/codex/skills/gpt-5-4-prompting/references/prompt-blocks.md rename to plugins/opencode/skills/gpt-5-4-prompting/references/prompt-blocks.md index cbf6694..776dce2 100644 --- a/plugins/codex/skills/gpt-5-4-prompting/references/prompt-blocks.md +++ b/plugins/opencode/skills/gpt-5-4-prompting/references/prompt-blocks.md @@ -1,6 +1,6 @@ # Prompt Blocks -Use these blocks selectively when composing Codex or GPT-5.4 prompts. +Use these blocks selectively when composing OpenCode or GPT-5.4 prompts. Wrap each block in the XML tag shown in its heading. ## Core Wrapper @@ -44,7 +44,7 @@ Do not include long scene-setting or repeated recap. ### `default_follow_through_policy` -Use when Codex should act without asking routine questions. +Use when OpenCode should act without asking routine questions. ```xml @@ -80,7 +80,7 @@ If a check fails, revise the answer instead of reporting the first draft. ### `missing_context_gating` -Use when Codex might otherwise guess. +Use when OpenCode might otherwise guess. ```xml diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/opencode/skills/opencode-cli-runtime/SKILL.md similarity index 64% rename from plugins/codex/skills/codex-cli-runtime/SKILL.md rename to plugins/opencode/skills/opencode-cli-runtime/SKILL.md index 0e91bfb..89e4e1e 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/opencode/skills/opencode-cli-runtime/SKILL.md @@ -1,43 +1,43 @@ --- -name: codex-cli-runtime -description: Internal helper contract for calling the codex-companion runtime from Claude Code +name: opencode-cli-runtime +description: Internal helper contract for calling the opencode-companion runtime from Claude Code user-invocable: false --- -# Codex Runtime +# OpenCode Runtime -Use this skill only inside the `codex:codex-rescue` subagent. +Use this skill only inside the `opencode:opencode-rescue` subagent. Primary helper: -- `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task ""` +- `node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" task ""` Execution rules: - The rescue subagent is a forwarder, not an orchestrator. Its only job is to invoke `task` once and return that stdout unchanged. -- Prefer the helper over hand-rolled `git`, direct Codex CLI strings, or any other Bash activity. -- Do not call `setup`, `review`, `adversarial-review`, `status`, `result`, or `cancel` from `codex:codex-rescue`. +- Prefer the helper over hand-rolled `git`, direct OpenCode CLI strings, or any other Bash activity. +- Do not call `setup`, `review`, `adversarial-review`, `status`, `result`, or `cancel` from `opencode:opencode-rescue`. - Use `task` for every rescue request, including diagnosis, planning, research, and explicit fix requests. -- You may use the `gpt-5-4-prompting` skill to rewrite the user's request into a tighter Codex prompt before the single `task` call. +- You may use the `gpt-5-4-prompting` skill to rewrite the user's request into a tighter OpenCode prompt before the single `task` call. - That prompt drafting is the only Claude-side work allowed. Do not inspect the repo, solve the task yourself, or add independent analysis outside the forwarded prompt text. - Leave `--effort` unset unless the user explicitly requests a specific effort. - Leave model unset by default. Add `--model` only when the user explicitly asks for one. -- Map `spark` to `--model gpt-5.3-codex-spark`. -- Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. +- Map `spark` to `--model openai/gpt-5.3-codex-spark`. +- Default to a write-capable OpenCode run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. Command selection: - Use exactly one `task` invocation per rescue handoff. - If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text. -- If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`. +- If the forwarded request includes `--model`, normalize `spark` to `openai/gpt-5.3-codex-spark` and pass it through to `task`. - If the forwarded request includes `--effort`, pass it through to `task`. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. - If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`. - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. - `--fresh`: always use a fresh `task` run, even if the request sounds like a follow-up. -- `--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. +- `--effort`: pass the value through as OpenCode's provider-specific `variant`. - `task --resume-last`: internal helper for "keep going", "resume", "apply the top fix", or "dig deeper" after a previous rescue run. Safety rules: -- Default to write-capable Codex work in `codex:codex-rescue` unless the user explicitly asks for read-only behavior. +- Default to write-capable OpenCode work in `opencode:opencode-rescue` unless the user explicitly asks for read-only behavior. - Preserve the user's task text as-is apart from stripping routing flags. - Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own. - Return the stdout of the `task` command exactly as-is. -- If the Bash call fails or Codex cannot be invoked, return nothing. +- If the Bash call fails or OpenCode cannot be invoked, return nothing. diff --git a/plugins/opencode/skills/opencode-result-handling/SKILL.md b/plugins/opencode/skills/opencode-result-handling/SKILL.md new file mode 100644 index 0000000..d4fb51b --- /dev/null +++ b/plugins/opencode/skills/opencode-result-handling/SKILL.md @@ -0,0 +1,21 @@ +--- +name: opencode-result-handling +description: Internal guidance for presenting OpenCode helper output back to the user +user-invocable: false +--- + +# OpenCode Result Handling + +When the helper returns OpenCode output: +- Preserve the helper's verdict, summary, findings, and next steps structure. +- For review output, present findings first and keep them ordered by severity. +- Use the file paths and line numbers exactly as the helper reports them. +- Preserve evidence boundaries. If OpenCode marked something as an inference, uncertainty, or follow-up question, keep that distinction. +- Preserve output sections when the prompt asked for them, such as observed facts, inferences, open questions, touched files, or next steps. +- If there are no findings, say that explicitly and keep the residual-risk note brief. +- If OpenCode made edits, say so explicitly and list the touched files when the helper provides them. +- For `opencode:opencode-rescue`, do not turn a failed or incomplete OpenCode run into a Claude-side implementation attempt. Report the failure and stop. +- For `opencode:opencode-rescue`, if OpenCode was never successfully invoked, do not generate a substitute answer at all. +- CRITICAL: After presenting review findings, STOP. Do not make any code changes. Do not fix any issues. You MUST explicitly ask the user which issues, if any, they want fixed before touching a single file. Auto-applying fixes from a review is strictly forbidden, even if the fix is obvious. +- If the helper reports malformed output or a failed OpenCode run, include the most actionable stderr lines and stop there instead of guessing. +- If the helper reports that setup or authentication is required, direct the user to `/opencode:setup` and do not improvise alternate auth flows. diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs index 19b9888..4e06ce2 100644 --- a/scripts/bump-version.mjs +++ b/scripts/bump-version.mjs @@ -39,7 +39,7 @@ const TARGETS = [ ] }, { - file: "plugins/codex/.claude-plugin/plugin.json", + file: "plugins/opencode/.claude-plugin/plugin.json", values: [ { label: "version", @@ -62,7 +62,7 @@ const TARGETS = [ } }, { - label: "plugins[codex].version", + label: "plugins[opencode].version", get: (json) => findMarketplacePlugin(json).version, set: (json, version) => { findMarketplacePlugin(json).version = version; @@ -132,8 +132,8 @@ function requireObject(value, label) { } function findMarketplacePlugin(json) { - const plugin = json.plugins?.find((entry) => entry?.name === "codex"); - requireObject(plugin, ".claude-plugin/marketplace.json plugins[codex]"); + const plugin = json.plugins?.find((entry) => entry?.name === "opencode"); + requireObject(plugin, ".claude-plugin/marketplace.json plugins[opencode]"); return plugin; } diff --git a/tests/broker-endpoint.test.mjs b/tests/broker-endpoint.test.mjs deleted file mode 100644 index b3fc114..0000000 --- a/tests/broker-endpoint.test.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { createBrokerEndpoint, parseBrokerEndpoint } from "../plugins/codex/scripts/lib/broker-endpoint.mjs"; - -test("createBrokerEndpoint uses Unix sockets on non-Windows platforms", () => { - const endpoint = createBrokerEndpoint("/tmp/cxc-12345", "darwin"); - assert.equal(endpoint, "unix:/tmp/cxc-12345/broker.sock"); - assert.deepEqual(parseBrokerEndpoint(endpoint), { - kind: "unix", - path: "/tmp/cxc-12345/broker.sock" - }); -}); - -test("createBrokerEndpoint uses named pipes on Windows", () => { - const endpoint = createBrokerEndpoint("C:\\\\Temp\\\\cxc-12345", "win32"); - assert.equal(endpoint, "pipe:\\\\.\\pipe\\cxc-12345-codex-app-server"); - assert.deepEqual(parseBrokerEndpoint(endpoint), { - kind: "pipe", - path: "\\\\.\\pipe\\cxc-12345-codex-app-server" - }); -}); diff --git a/tests/bump-version.test.mjs b/tests/bump-version.test.mjs index 205b0e9..7c45734 100644 --- a/tests/bump-version.test.mjs +++ b/tests/bump-version.test.mjs @@ -22,22 +22,22 @@ function makeVersionFixture() { const root = makeTempDir(); writeJson(path.join(root, "package.json"), { - name: "@openai/codex-plugin-cc", + name: "@opencode/claude-code-plugin", version: "1.0.2" }); writeJson(path.join(root, "package-lock.json"), { - name: "@openai/codex-plugin-cc", + name: "@opencode/claude-code-plugin", version: "1.0.2", lockfileVersion: 3, packages: { "": { - name: "@openai/codex-plugin-cc", + name: "@opencode/claude-code-plugin", version: "1.0.2" } } }); - writeJson(path.join(root, "plugins", "codex", ".claude-plugin", "plugin.json"), { - name: "codex", + writeJson(path.join(root, "plugins", "opencode", ".claude-plugin", "plugin.json"), { + name: "opencode", version: "1.0.2" }); writeJson(path.join(root, ".claude-plugin", "marketplace.json"), { @@ -46,7 +46,7 @@ function makeVersionFixture() { }, plugins: [ { - name: "codex", + name: "opencode", version: "1.0.2" } ] @@ -66,7 +66,7 @@ test("bump-version updates every release manifest", () => { assert.equal(readJson(path.join(root, "package.json")).version, "1.2.3"); assert.equal(readJson(path.join(root, "package-lock.json")).version, "1.2.3"); assert.equal(readJson(path.join(root, "package-lock.json")).packages[""].version, "1.2.3"); - assert.equal(readJson(path.join(root, "plugins", "codex", ".claude-plugin", "plugin.json")).version, "1.2.3"); + assert.equal(readJson(path.join(root, "plugins", "opencode", ".claude-plugin", "plugin.json")).version, "1.2.3"); assert.equal(readJson(path.join(root, ".claude-plugin", "marketplace.json")).metadata.version, "1.2.3"); assert.equal(readJson(path.join(root, ".claude-plugin", "marketplace.json")).plugins[0].version, "1.2.3"); }); @@ -74,7 +74,7 @@ test("bump-version updates every release manifest", () => { test("bump-version check mode reports stale metadata", () => { const root = makeVersionFixture(); writeJson(path.join(root, "package.json"), { - name: "@openai/codex-plugin-cc", + name: "@opencode/claude-code-plugin", version: "1.0.3" }); @@ -83,6 +83,6 @@ test("bump-version check mode reports stale metadata", () => { }); assert.notEqual(result.status, 0); - assert.match(result.stderr, /plugins\/codex\/\.claude-plugin\/plugin\.json version/); + assert.match(result.stderr, /plugins\/opencode\/\.claude-plugin\/plugin\.json version/); assert.match(result.stderr, /\.claude-plugin\/marketplace\.json metadata\.version/); }); diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index c34b060..6008233 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -5,74 +5,48 @@ import assert from "node:assert/strict"; import { fileURLToPath } from "node:url"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const PLUGIN_ROOT = path.join(ROOT, "plugins", "codex"); +const PLUGIN_ROOT = path.join(ROOT, "plugins", "opencode"); function read(relativePath) { return fs.readFileSync(path.join(PLUGIN_ROOT, relativePath), "utf8"); } -test("review command uses AskUserQuestion and background Bash while staying review-only", () => { - const source = read("commands/review.md"); - assert.match(source, /AskUserQuestion/); - assert.match(source, /\bBash\(/); - assert.match(source, /Do not fix issues/i); - assert.match(source, /review-only/i); - assert.match(source, /return Codex's output verbatim to the user/i); - assert.match(source, /```bash/); - assert.match(source, /```typescript/); - assert.match(source, /review "\$ARGUMENTS"/); - assert.match(source, /\[--scope auto\|working-tree\|branch\]/); - assert.match(source, /run_in_background:\s*true/); - assert.match(source, /command:\s*`node "\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/codex-companion\.mjs" review "\$ARGUMENTS"`/); - assert.match(source, /description:\s*"Codex review"/); - assert.match(source, /Do not call `BashOutput`/); - assert.match(source, /Return the command stdout verbatim, exactly as-is/i); - assert.match(source, /git status --short --untracked-files=all/); - assert.match(source, /git diff --shortstat/); - assert.match(source, /Treat untracked files or directories as reviewable work/i); - assert.match(source, /Recommend waiting only when the review is clearly tiny, roughly 1-2 files total/i); - assert.match(source, /In every other case, including unclear size, recommend background/i); - assert.match(source, /The companion script parses `--wait` and `--background`/i); - assert.match(source, /Claude Code's `Bash\(..., run_in_background: true\)` is what actually detaches the run/i); - assert.match(source, /When in doubt, run the review/i); - assert.match(source, /\(Recommended\)/); - assert.match(source, /does not support staged-only review, unstaged-only review, or extra focus text/i); +test("commands invoke the opencode companion entrypoint", () => { + const expectations = new Map([ + ["commands/review.md", /opencode-companion\.mjs" review "\$ARGUMENTS"/], + ["commands/adversarial-review.md", /opencode-companion\.mjs" adversarial-review "\$ARGUMENTS"/], + ["commands/setup.md", /opencode-companion\.mjs" setup --json \$ARGUMENTS/], + ["commands/transfer.md", /opencode-companion\.mjs" transfer "\$ARGUMENTS"/], + ["commands/status.md", /opencode-companion\.mjs" status "\$ARGUMENTS"/], + ["commands/result.md", /opencode-companion\.mjs" result "\$ARGUMENTS"/], + ["commands/cancel.md", /opencode-companion\.mjs" cancel "\$ARGUMENTS"/] + ]); + + for (const [file, pattern] of expectations) { + const source = read(file); + assert.match(source, pattern, file); + assert.doesNotMatch(source, /codex-companion\.mjs/, file); + } }); -test("adversarial review command uses AskUserQuestion and background Bash while staying review-only", () => { - const source = read("commands/adversarial-review.md"); - assert.match(source, /AskUserQuestion/); - assert.match(source, /\bBash\(/); - assert.match(source, /Do not fix issues/i); - assert.match(source, /review-only/i); - assert.match(source, /return Codex's output verbatim to the user/i); - assert.match(source, /```bash/); - assert.match(source, /```typescript/); - assert.match(source, /adversarial-review "\$ARGUMENTS"/); - assert.match(source, /\[--scope auto\|working-tree\|branch\] \[focus \.\.\.\]/); - assert.match(source, /run_in_background:\s*true/); - assert.match(source, /command:\s*`node "\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/codex-companion\.mjs" adversarial-review "\$ARGUMENTS"`/); - assert.match(source, /description:\s*"Codex adversarial review"/); - assert.match(source, /Do not call `BashOutput`/); - assert.match(source, /Return the command stdout verbatim, exactly as-is/i); - assert.match(source, /git status --short --untracked-files=all/); - assert.match(source, /git diff --shortstat/); - assert.match(source, /Treat untracked files or directories as reviewable work/i); - assert.match(source, /Recommend waiting only when the scoped review is clearly tiny, roughly 1-2 files total/i); - assert.match(source, /In every other case, including unclear size, recommend background/i); - assert.match(source, /The companion script parses `--wait` and `--background`/i); - assert.match(source, /Claude Code's `Bash\(..., run_in_background: true\)` is what actually detaches the run/i); - assert.match(source, /When in doubt, run the review/i); - assert.match(source, /\(Recommended\)/); - assert.match(source, /uses the same review target selection as `\/codex:review`/i); - assert.match(source, /supports working-tree review, branch review, and `--base `/i); - assert.match(source, /does not support `--scope staged` or `--scope unstaged`/i); - assert.match(source, /can still take extra focus text after the flags/i); +test("rescue command routes through the renamed OpenCode subagent", () => { + const rescue = read("commands/rescue.md"); + const agent = read("agents/opencode-rescue.md"); + const runtimeSkill = read("skills/opencode-cli-runtime/SKILL.md"); + + assert.match(rescue, /subagent_type: "opencode:opencode-rescue"/); + assert.match(rescue, /do not call `Skill\(opencode:opencode-rescue\)`/i); + assert.match(rescue, /task-resume-candidate --json/); + assert.match(rescue, /openai\/gpt-5\.3-codex-spark/); + assert.match(agent, /name: opencode-rescue/); + assert.match(agent, /opencode-companion\.mjs" task/); + assert.match(agent, /thin forwarding wrapper/i); + assert.match(runtimeSkill, /name: opencode-cli-runtime/); + assert.match(runtimeSkill, /opencode-companion\.mjs" task ""/); }); -test("continue is not exposed as a user-facing command", () => { - const commandFiles = fs.readdirSync(path.join(PLUGIN_ROOT, "commands")).sort(); - assert.deepEqual(commandFiles, [ +test("command and skill filenames use OpenCode identity", () => { + assert.deepEqual(fs.readdirSync(path.join(PLUGIN_ROOT, "commands")).sort(), [ "adversarial-review.md", "cancel.md", "rescue.md", @@ -82,124 +56,16 @@ test("continue is not exposed as a user-facing command", () => { "status.md", "transfer.md" ]); + assert.equal(fs.existsSync(path.join(PLUGIN_ROOT, "agents", "opencode-rescue.md")), true); + assert.equal(fs.existsSync(path.join(PLUGIN_ROOT, "skills", "opencode-cli-runtime", "SKILL.md")), true); + assert.equal(fs.existsSync(path.join(PLUGIN_ROOT, "skills", "opencode-result-handling", "SKILL.md")), true); }); -test("rescue command absorbs continue semantics", () => { - const rescue = read("commands/rescue.md"); - const agent = read("agents/codex-rescue.md"); - const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8"); - const runtimeSkill = read("skills/codex-cli-runtime/SKILL.md"); - - assert.match(rescue, /The final user-visible response must be Codex's output verbatim/i); - assert.match(rescue, /allowed-tools:\s*Bash\(node:\*\),\s*AskUserQuestion,\s*Agent/); - // Regression for #234: `Skill(codex:rescue)` from the main agent recursed - // because rescue.md named the routing with ambiguous prose ("Route this - // request to the `codex:codex-rescue` subagent") while running under - // `context: fork` — forked general-purpose subagents do not expose the - // `Agent` tool, so the fork fell back to `Skill` and re-entered this - // command. Pin the explicit transport and the inline (no-fork) execution. - assert.match(rescue, /subagent_type: "codex:codex-rescue"/); - assert.match(rescue, /do not call `Skill\(codex:codex-rescue\)`/i); - assert.doesNotMatch(rescue, /^context:\s*fork\b/m); - assert.match(rescue, /--background\|--wait/); - assert.match(rescue, /--resume\|--fresh/); - assert.match(rescue, /--model /); - assert.match(rescue, /--effort /); - assert.match(rescue, /task-resume-candidate --json/); - assert.match(rescue, /AskUserQuestion/); - assert.match(rescue, /Continue current Codex thread/); - assert.match(rescue, /Start a new Codex thread/); - assert.match(rescue, /run the `codex:codex-rescue` subagent in the background/i); - assert.match(rescue, /default to foreground/i); - assert.match(rescue, /Do not forward them to `task`/i); - assert.match(rescue, /`--model` and `--effort` are runtime-selection flags/i); - assert.match(rescue, /Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort/i); - assert.match(rescue, /If they ask for `spark`, map it to `gpt-5\.3-codex-spark`/i); - assert.match(rescue, /If the request includes `--resume`, do not ask whether to continue/i); - assert.match(rescue, /If the request includes `--fresh`, do not ask whether to continue/i); - assert.match(rescue, /If the user chooses continue, add `--resume`/i); - assert.match(rescue, /If the user chooses a new thread, add `--fresh`/i); - assert.match(rescue, /thin forwarder only/i); - assert.match(rescue, /Return the Codex companion stdout verbatim to the user/i); - assert.match(rescue, /Do not paraphrase, summarize, rewrite, or add commentary before or after it/i); - assert.match(rescue, /return that command's stdout as-is/i); - assert.match(rescue, /Leave `--resume` and `--fresh` in the forwarded request/i); - assert.match(agent, /--resume/); - assert.match(agent, /--fresh/); - assert.match(agent, /thin forwarding wrapper/i); - assert.match(agent, /prefer foreground for a small, clearly bounded rescue request/i); - assert.match(agent, /If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution/i); - assert.match(agent, /Use exactly one `Bash` call/i); - assert.match(agent, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); - assert.match(agent, /Do not call `review`, `adversarial-review`, `status`, `result`, or `cancel`/i); - assert.match(agent, /Leave `--effort` unset unless the user explicitly requests a specific reasoning effort/i); - assert.match(agent, /Leave model unset by default/i); - assert.match(agent, /If the user asks for `spark`, map that to `--model gpt-5\.3-codex-spark`/i); - assert.match(agent, /If the user asks for a concrete model name such as `gpt-5\.4-mini`, pass it through with `--model`/i); - assert.match(agent, /Return the stdout of the `codex-companion` command exactly as-is/i); - assert.match(agent, /If the Bash call fails or Codex cannot be invoked, return nothing/i); - assert.match(agent, /gpt-5-4-prompting/); - assert.match(agent, /only to tighten the user's request into a better Codex prompt/i); - assert.match(agent, /Do not use that skill to inspect the repository, reason through the problem yourself, draft a solution, or do any independent work/i); - assert.match(runtimeSkill, /only job is to invoke `task` once and return that stdout unchanged/i); - assert.match(runtimeSkill, /Do not call `setup`, `review`, `adversarial-review`, `status`, `result`, or `cancel`/i); - assert.match(runtimeSkill, /use the `gpt-5-4-prompting` skill to rewrite the user's request into a tighter Codex prompt/i); - assert.match(runtimeSkill, /That prompt drafting is the only Claude-side work allowed/i); - assert.match(runtimeSkill, /Leave `--effort` unset unless the user explicitly requests a specific effort/i); - assert.match(runtimeSkill, /Leave model unset by default/i); - assert.match(runtimeSkill, /Map `spark` to `--model gpt-5\.3-codex-spark`/i); - assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i); - assert.match(runtimeSkill, /Strip it before calling `task`/i); - assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); - assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); - assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i); - assert.match(readme, /`codex:codex-rescue` subagent/i); - assert.match(readme, /if you do not pass `--model` or `--effort`, Codex chooses its own defaults/i); - assert.match(readme, /--model gpt-5\.4-mini --effort medium/i); - assert.match(readme, /`spark`, the plugin maps that to `gpt-5\.3-codex-spark`/i); - assert.match(readme, /continue a previous Codex task/i); - assert.match(readme, /### `\/codex:setup`/); - assert.match(readme, /### `\/codex:review`/); - assert.match(readme, /### `\/codex:adversarial-review`/); - assert.match(readme, /uses the same review target selection as `\/codex:review`/i); - assert.match(readme, /--base main challenge whether this was the right caching and retry design/); - assert.match(readme, /### `\/codex:rescue`/); - assert.match(readme, /### `\/codex:transfer`/); - assert.match(readme, /### `\/codex:status`/); - assert.match(readme, /### `\/codex:result`/); - assert.match(readme, /### `\/codex:cancel`/); -}); - -test("transfer, result, and cancel commands are exposed as deterministic runtime entrypoints", () => { - const transfer = read("commands/transfer.md"); - const result = read("commands/result.md"); - const cancel = read("commands/cancel.md"); - const resultHandling = read("skills/codex-result-handling/SKILL.md"); - - assert.match(transfer, /disable-model-invocation:\s*true/); - assert.match(transfer, /codex-companion\.mjs" transfer "\$ARGUMENTS"/); - assert.match(transfer, /codex resume /); - assert.match(result, /disable-model-invocation:\s*true/); - assert.match(result, /codex-companion\.mjs" result "\$ARGUMENTS"/); - assert.match(cancel, /disable-model-invocation:\s*true/); - assert.match(cancel, /codex-companion\.mjs" cancel "\$ARGUMENTS"/); - assert.match(resultHandling, /do not turn a failed or incomplete Codex run into a Claude-side implementation attempt/i); - assert.match(resultHandling, /if Codex was never successfully invoked, do not generate a substitute answer at all/i); -}); - -test("internal docs use task terminology for rescue runs", () => { - const runtimeSkill = read("skills/codex-cli-runtime/SKILL.md"); - const promptingSkill = read("skills/gpt-5-4-prompting/SKILL.md"); - const promptRecipes = read("skills/gpt-5-4-prompting/references/codex-prompt-recipes.md"); - - assert.match(runtimeSkill, /codex-companion\.mjs" task ""/); - assert.match(runtimeSkill, /Use `task` for every rescue request/i); - assert.match(runtimeSkill, /task --resume-last/i); - assert.match(promptingSkill, /Use `task` when the task is diagnosis/i); - assert.match(promptRecipes, /Codex task prompts/i); - assert.match(promptRecipes, /Use these as starting templates for Codex task prompts/i); - assert.match(promptRecipes, /## Diagnosis/); - assert.match(promptRecipes, /## Narrow Fix/); +test("setup command offers OpenCode installation guidance", () => { + const setup = read("commands/setup.md"); + assert.match(setup, /description: Check whether the local OpenCode CLI is ready/); + assert.match(setup, /npm install -g opencode-ai/); + assert.doesNotMatch(setup, /@openai\/codex/); }); test("hooks keep session-end cleanup and stop gating enabled", () => { @@ -208,18 +74,5 @@ test("hooks keep session-end cleanup and stop gating enabled", () => { assert.match(source, /SessionEnd/); assert.match(source, /stop-review-gate-hook\.mjs/); assert.match(source, /session-lifecycle-hook\.mjs/); -}); - -test("setup command can offer Codex install and still points users to codex login", () => { - const setup = read("commands/setup.md"); - const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8"); - - assert.match(setup, /argument-hint:\s*'\[--enable-review-gate\|--disable-review-gate\]'/); - assert.match(setup, /AskUserQuestion/); - assert.match(setup, /npm install -g @openai\/codex/); - assert.match(setup, /codex-companion\.mjs" setup --json \$ARGUMENTS/); - assert.match(readme, /!codex login/); - assert.match(readme, /offer to install Codex for you/i); - assert.match(readme, /\/codex:setup --enable-review-gate/); - assert.match(readme, /\/codex:setup --disable-review-gate/); + assert.match(source, /OpenCode Companion/); }); diff --git a/tests/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs new file mode 100644 index 0000000..3566f9c --- /dev/null +++ b/tests/fake-opencode-fixture.mjs @@ -0,0 +1,283 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { writeExecutable } from "./helpers.mjs"; + +export function buildEnv(binDir, extra = {}) { + return { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + FAKE_OPENCODE_STATE_PATH: path.join(binDir, "fake-opencode-state.json"), + ...extra + }; +} + +export function readFakeState(binDir) { + const statePath = path.join(binDir, "fake-opencode-state.json"); + return fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, "utf8")) : null; +} + +export function installFakeOpencode(binDir) { + const statePath = path.join(binDir, "fake-opencode-state.json"); + const scriptPath = path.join(binDir, "opencode"); + const source = `#!/usr/bin/env node +const fs = require("node:fs"); +const http = require("node:http"); +const path = require("node:path"); + +const STATE_PATH = process.env.FAKE_OPENCODE_STATE_PATH || ${JSON.stringify(statePath)}; +const clients = new Set(); +const pendingPermissions = new Map(); + +function loadState() { + if (!fs.existsSync(STATE_PATH)) { + return { + serverStarts: 0, + nextSessionId: 1, + nextMessageId: 1, + sessions: [], + messages: [], + permissions: [], + lastAbort: null + }; + } + return JSON.parse(fs.readFileSync(STATE_PATH, "utf8")); +} + +function saveState(state) { + fs.mkdirSync(path.dirname(STATE_PATH), { recursive: true }); + fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); +} + +function readJson(req) { + return new Promise((resolve) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + if (!body.trim()) { + resolve({}); + return; + } + try { + resolve(JSON.parse(body)); + } catch { + resolve({}); + } + }); + }); +} + +function sendJson(res, value, status = 200) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(value)); +} + +function emit(event) { + const line = "data: " + JSON.stringify(event) + "\\n\\n"; + for (const client of clients) { + client.write(line); + } +} + +function textFromMessage(body) { + return (body.parts || []) + .map((part) => typeof part.text === "string" ? part.text : "") + .join("") + .trim(); +} + +async function waitForPermission(permissionID) { + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 2000); + pendingPermissions.set(permissionID, () => { + clearTimeout(timeout); + resolve(); + }); + }); +} + +async function handleMessage(req, res, sessionID) { + const body = await readJson(req); + const state = loadState(); + const session = state.sessions.find((candidate) => candidate.id === sessionID); + if (!session) { + sendJson(res, { error: "unknown session" }, 404); + return; + } + + const messageID = "msg_" + state.nextMessageId++; + const prompt = textFromMessage(body); + state.messages.push({ sessionID, messageID, body, prompt }); + state.lastMessage = { sessionID, messageID, body, prompt }; + saveState(state); + + emit({ type: "session.next.step.started", sessionID }); + if (session.agent === "build" || body.agent === "build") { + const permissionID = "perm_" + messageID; + emit({ type: "permission.asked", sessionID, permissionID, permission: { id: permissionID, tool: "edit" } }); + await waitForPermission(permissionID); + emit({ type: "file.edited", sessionID, path: "generated.txt" }); + } + + const finalText = prompt.includes("follow up") + ? "Resumed the prior OpenCode run.\\nFollow-up prompt accepted." + : "Handled the requested task.\\nTask prompt accepted."; + const parts = [{ type: "text", text: finalText }]; + emit({ type: "message.updated", sessionID, message: { id: messageID, parts } }); + emit({ type: "session.idle", sessionID }); + sendJson(res, { info: { id: messageID, sessionID }, parts }); +} + +function handleSessionListCli() { + const state = loadState(); + for (const session of state.sessions) { + console.log([session.id, session.title || "", session.directory || ""].filter(Boolean).join(" ")); + } +} + +const args = process.argv.slice(2); +if (args[0] === "--version") { + console.log("opencode 1.17.10-test"); + process.exit(0); +} +if (args[0] === "serve" && args.includes("--help")) { + console.log("fake opencode serve help"); + process.exit(0); +} +if (args[0] === "session" && args[1] === "list") { + handleSessionListCli(); + process.exit(0); +} +if (args[0] !== "serve") { + process.exit(1); +} + +const hostname = args[args.indexOf("--hostname") + 1] || "127.0.0.1"; +const port = Number(args[args.indexOf("--port") + 1] || 0); +const bootState = loadState(); +bootState.serverStarts = (bootState.serverStarts || 0) + 1; +saveState(bootState); + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://127.0.0.1"); + + if (req.method === "GET" && url.pathname === "/global/health") { + sendJson(res, { ok: true }); + return; + } + + if (req.method === "POST" && url.pathname === "/global/dispose") { + sendJson(res, { ok: true }); + setTimeout(() => server.close(() => process.exit(0)), 10); + return; + } + + if (req.method === "GET" && url.pathname === "/config") { + sendJson(res, { providerID: "openai", config: { model_provider: "openai" } }); + return; + } + + if (req.method === "GET" && url.pathname === "/provider") { + sendJson(res, { id: "openai", name: "OpenAI" }); + return; + } + + if (req.method === "GET" && url.pathname === "/event") { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive" + }); + res.flushHeaders(); + res.write(":ok\\n\\n"); + clients.add(res); + req.on("close", () => clients.delete(res)); + return; + } + + if (req.method === "GET" && url.pathname === "/session") { + sendJson(res, loadState().sessions); + return; + } + + if (req.method === "POST" && url.pathname === "/session") { + const body = await readJson(req); + // Mirror the real server: create-session rejects \`directory\` and \`model\` + // (additionalProperties:false / model belongs on the message) with 400. + if ("directory" in body || "model" in body) { + sendJson(res, { _tag: "BadRequest" }, 400); + return; + } + const state = loadState(); + const session = { + id: "ses_" + state.nextSessionId++, + directory: body.directory || process.cwd(), + title: body.title || null, + agent: body.agent || null, + model: body.model || null, + permission: body.permission || [] + }; + state.sessions.unshift(session); + state.lastCreateSession = body; + saveState(state); + emit({ type: "session.created", sessionID: session.id, session }); + sendJson(res, session); + return; + } + + const messageMatch = url.pathname.match(/^\\/session\\/([^/]+)\\/message$/); + if (req.method === "POST" && messageMatch) { + await handleMessage(req, res, decodeURIComponent(messageMatch[1])); + return; + } + + const abortMatch = url.pathname.match(/^\\/session\\/([^/]+)\\/abort$/); + if (req.method === "POST" && abortMatch) { + const state = loadState(); + state.lastAbort = decodeURIComponent(abortMatch[1]); + saveState(state); + sendJson(res, { ok: true }); + return; + } + + const permissionMatch = url.pathname.match(/^\\/session\\/([^/]+)\\/permissions\\/([^/]+)$/); + if (req.method === "POST" && permissionMatch) { + const body = await readJson(req); + // Mirror the real endpoint: body must be exactly { response: once|always|reject }. + const keys = Object.keys(body); + if (keys.length !== 1 || keys[0] !== "response" || !["once", "always", "reject"].includes(body.response)) { + sendJson(res, { _tag: "BadRequest" }, 400); + return; + } + const sessionID = decodeURIComponent(permissionMatch[1]); + const permissionID = decodeURIComponent(permissionMatch[2]); + const state = loadState(); + state.permissions.push({ sessionID, permissionID, body }); + saveState(state); + const resolve = pendingPermissions.get(permissionID); + pendingPermissions.delete(permissionID); + resolve?.(); + sendJson(res, { ok: true }); + return; + } + + sendJson(res, { error: "not found" }, 404); +}); + +server.listen(port, hostname, () => { + const address = server.address(); + console.log("opencode server listening on http://" + hostname + ":" + address.port); +}); +`; + + writeExecutable(scriptPath, source); + if (process.platform === "win32") { + fs.writeFileSync(path.join(binDir, "opencode.cmd"), `@echo off\r\nnode "%~dp0opencode" %*\r\n`, { + encoding: "utf8" + }); + } +} diff --git a/tests/git.test.mjs b/tests/git.test.mjs index 14ff257..5bd6c23 100644 --- a/tests/git.test.mjs +++ b/tests/git.test.mjs @@ -3,7 +3,7 @@ import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; -import { collectReviewContext, resolveReviewTarget } from "../plugins/codex/scripts/lib/git.mjs"; +import { collectReviewContext, resolveReviewTarget } from "../plugins/opencode/scripts/lib/git.mjs"; import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; test("resolveReviewTarget prefers working tree when repo is dirty", () => { diff --git a/tests/helpers.mjs b/tests/helpers.mjs index 945ae0e..4f41db4 100644 --- a/tests/helpers.mjs +++ b/tests/helpers.mjs @@ -4,7 +4,7 @@ import path from "node:path"; import process from "node:process"; import { spawnSync } from "node:child_process"; -export function makeTempDir(prefix = "codex-plugin-test-") { +export function makeTempDir(prefix = "opencode-plugin-test-") { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } @@ -25,7 +25,7 @@ export function run(command, args, options = {}) { export function initGitRepo(cwd) { run("git", ["init", "-b", "main"], { cwd }); - run("git", ["config", "user.name", "Codex Plugin Tests"], { cwd }); + run("git", ["config", "user.name", "OpenCode Plugin Tests"], { cwd }); run("git", ["config", "user.email", "tests@example.com"], { cwd }); run("git", ["config", "commit.gpgsign", "false"], { cwd }); run("git", ["config", "tag.gpgsign", "false"], { cwd }); diff --git a/tests/process.test.mjs b/tests/process.test.mjs index 80e0715..c489177 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { terminateProcessTree } from "../plugins/codex/scripts/lib/process.mjs"; +import { terminateProcessTree } from "../plugins/opencode/scripts/lib/process.mjs"; test("terminateProcessTree uses taskkill on Windows", () => { let captured = null; diff --git a/tests/render.test.mjs b/tests/render.test.mjs index ab68038..a7ee6e7 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { renderReviewResult, renderStoredJobResult } from "../plugins/codex/scripts/lib/render.mjs"; +import { renderReviewResult, renderStoredJobResult } from "../plugins/opencode/scripts/lib/render.mjs"; test("renderReviewResult degrades gracefully when JSON is missing required review fields", () => { const output = renderReviewResult( @@ -22,7 +22,7 @@ test("renderReviewResult degrades gracefully when JSON is missing required revie } ); - assert.match(output, /Codex returned JSON with an unexpected review shape\./); + assert.match(output, /OpenCode returned JSON with an unexpected review shape\./); assert.match(output, /Missing array `findings`\./); assert.match(output, /Raw final message:/); }); @@ -32,13 +32,13 @@ test("renderStoredJobResult prefers rendered output for structured review jobs", { id: "review-123", status: "completed", - title: "Codex Adversarial Review", + title: "OpenCode Adversarial Review", jobClass: "review", threadId: "thr_123" }, { threadId: "thr_123", - rendered: "# Codex Adversarial Review\n\nTarget: working tree diff\nVerdict: needs-attention\n", + rendered: "# OpenCode Adversarial Review\n\nTarget: working tree diff\nVerdict: needs-attention\n", result: { result: { verdict: "needs-attention", @@ -52,8 +52,8 @@ test("renderStoredJobResult prefers rendered output for structured review jobs", } ); - assert.match(output, /^# Codex Adversarial Review/); + assert.match(output, /^# OpenCode Adversarial Review/); assert.doesNotMatch(output, /^\{/); - assert.match(output, /Codex session ID: thr_123/); - assert.match(output, /Resume in Codex: codex resume thr_123/); + assert.match(output, /OpenCode session ID: thr_123/); + assert.match(output, /Resume in OpenCode: opencode --session thr_123/); }); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f27683..4841750 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1,2259 +1,192 @@ import fs from "node:fs"; +import net from "node:net"; import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; +import { buildEnv, installFakeOpencode, readFakeState } from "./fake-opencode-fixture.mjs"; import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; -import { loadBrokerSession, saveBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; -import { resolveStateDir } from "../plugins/codex/scripts/lib/state.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const PLUGIN_ROOT = path.join(ROOT, "plugins", "codex"); -const SCRIPT = path.join(PLUGIN_ROOT, "scripts", "codex-companion.mjs"); -const STOP_HOOK = path.join(PLUGIN_ROOT, "scripts", "stop-review-gate-hook.mjs"); +const PLUGIN_ROOT = path.join(ROOT, "plugins", "opencode"); +const SCRIPT = path.join(PLUGIN_ROOT, "scripts", "opencode-companion.mjs"); const SESSION_HOOK = path.join(PLUGIN_ROOT, "scripts", "session-lifecycle-hook.mjs"); -async function waitFor(predicate, { timeoutMs = 5000, intervalMs = 50 } = {}) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const value = await predicate(); - if (value) { - return value; - } - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - throw new Error("Timed out waiting for condition."); -} - -test("setup reports ready when fake codex is installed and authenticated", () => { - const binDir = makeTempDir(); - installFakeCodex(binDir); - - const result = run("node", [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0); - const payload = JSON.parse(result.stdout); - assert.equal(payload.ready, true); - assert.match(payload.codex.detail, /advanced runtime available/); - assert.equal(payload.sessionRuntime.mode, "direct"); -}); - -test("setup is ready without npm when Codex is already installed and authenticated", () => { - const binDir = makeTempDir(); - installFakeCodex(binDir); - fs.symlinkSync(process.execPath, path.join(binDir, "node")); - - const result = run("node", [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: { - ...process.env, - PATH: binDir - } - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.ready, true); - assert.equal(payload.npm.available, false); - assert.equal(payload.codex.available, true); - assert.equal(payload.auth.loggedIn, true); -}); - -test("setup trusts app-server API key auth even when login status alone would fail", () => { - const binDir = makeTempDir(); - installFakeCodex(binDir, "api-key-account-only"); - - const result = run("node", [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.ready, true); - assert.equal(payload.auth.loggedIn, true); - assert.equal(payload.auth.authMethod, "apiKey"); - assert.equal(payload.auth.source, "app-server"); - assert.match(payload.auth.detail, /API key configured \(unverified\)/); -}); - -test("setup is ready when the active provider does not require OpenAI login", () => { - const binDir = makeTempDir(); - installFakeCodex(binDir, "provider-no-auth"); - - const result = run("node", [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.ready, true); - assert.equal(payload.auth.loggedIn, true); - assert.equal(payload.auth.authMethod, null); - assert.equal(payload.auth.source, "app-server"); - assert.match(payload.auth.detail, /configured and does not require OpenAI authentication/i); -}); - -test("setup treats custom providers with app-server-ready config as ready", () => { - const binDir = makeTempDir(); - installFakeCodex(binDir, "env-key-provider"); - - const result = run("node", [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.ready, true); - assert.equal(payload.auth.loggedIn, true); - assert.equal(payload.auth.authMethod, null); - assert.equal(payload.auth.source, "app-server"); - assert.match(payload.auth.detail, /configured and does not require OpenAI authentication/i); -}); - -test("setup reports not ready when app-server config read fails", () => { - const binDir = makeTempDir(); - installFakeCodex(binDir, "config-read-fails"); - - const result = run("node", [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.ready, false); - assert.equal(payload.auth.loggedIn, false); - assert.equal(payload.auth.source, "app-server"); - assert.match(payload.auth.detail, /config\/read failed for cwd/); -}); - -test("review renders a no-findings result from app-server review/start", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.mkdirSync(path.join(repo, "src")); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 1;\n"); - run("git", ["add", "src/app.js"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 2;\n"); - - const result = run("node", [SCRIPT, "review"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0); - assert.match(result.stdout, /Reviewed uncommitted changes/); - assert.match(result.stdout, /No material issues found/); -}); - -test("task runs when the active provider does not require OpenAI login", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "provider-no-auth"); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const result = run("node", [SCRIPT, "task", "check auth preflight"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Handled the requested task/); -}); - -test("task runs without auth preflight so Codex can refresh an expired session", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "refreshable-auth"); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const result = run("node", [SCRIPT, "task", "check refreshable auth"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Handled the requested task/); -}); - -test("transfer delegates the current Claude session directly to native import", () => { - const home = makeTempDir(); - const repo = path.join(home, "repo"); - const binDir = makeTempDir(); - const sessionId = "sess-native-transfer"; - fs.mkdirSync(repo, { recursive: true }); - const projectDir = path.join(home, ".claude", "projects", "-repo"); - const sourcePath = path.join(projectDir, `${sessionId}.jsonl`); - fs.mkdirSync(projectDir, { recursive: true }); - installFakeCodex(binDir); - initGitRepo(repo); - - fs.writeFileSync( - sourcePath, - [ - { type: "custom-title", customTitle: "Native transfer" }, - { type: "user", cwd: repo, message: { role: "user", content: "Initial request" } }, - { type: "assistant", cwd: repo, message: { role: "assistant", content: "Initial answer" } }, - { type: "user", cwd: repo, message: { role: "user", content: "/codex:transfer" } } - ].map((entry) => JSON.stringify(entry)).join("\n") + "\n", - "utf8" - ); - const result = run("node", [SCRIPT, "transfer", "--json"], { - cwd: repo, - env: { - ...buildEnv(binDir), - HOME: home, - CODEX_HOME: path.join(home, ".codex"), - CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath - } - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - const canonicalSourcePath = fs.realpathSync(sourcePath); - assert.equal(payload.threadId, "thr_1"); - assert.equal(payload.resumeCommand, "codex resume thr_1"); - assert.equal(payload.sourcePath, canonicalSourcePath); - assert.equal(payload.sessionId, sessionId); - - const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); - assert.equal(fakeState.threads.length, 1); - assert.equal(fakeState.threads[0].ephemeral, false); - assert.equal(fakeState.threads[0].name, "Native transfer"); - assert.equal(fakeState.lastExternalAgentImport.sourcePath, canonicalSourcePath); - assert.deepEqual( - fakeState.threads[0].visibleMessages.map((message) => message.text), - ["Initial request", "Initial answer", "/codex:transfer"] - ); -}); - -test("transfer reports an actionable upgrade error when native import is unsupported", () => { - const home = makeTempDir(); - const repo = path.join(home, "repo"); - const binDir = makeTempDir(); - const projectDir = path.join(home, ".claude", "projects", "-repo"); - const sourcePath = path.join(projectDir, "session.jsonl"); - fs.mkdirSync(repo, { recursive: true }); - fs.mkdirSync(projectDir, { recursive: true }); - installFakeCodex(binDir, "external-import-unsupported"); - initGitRepo(repo); - fs.writeFileSync( - sourcePath, - `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Continue this work." } })}\n`, - "utf8" - ); - - const result = run("node", [SCRIPT, "transfer", "--source", sourcePath, "--json"], { - cwd: repo, - env: { - ...buildEnv(binDir), - HOME: home, - CODEX_HOME: path.join(home, ".codex") - } - }); - - assert.notEqual(result.status, 0); - assert.match(result.stderr, /does not support Claude session transfer/); - assert.match(result.stderr, /@openai\/codex@latest/); -}); - -test("transfer fails visibly when native import completes without a ledger record", () => { - const home = makeTempDir(); - const repo = path.join(home, "repo"); - const binDir = makeTempDir(); - const projectDir = path.join(home, ".claude", "projects", "-repo"); - const sourcePath = path.join(projectDir, "session.jsonl"); - fs.mkdirSync(repo, { recursive: true }); - fs.mkdirSync(projectDir, { recursive: true }); - installFakeCodex(binDir, "external-import-fails"); - initGitRepo(repo); - fs.writeFileSync( - sourcePath, - `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Do not lose this request." } })}\n`, - "utf8" - ); - - const result = run("node", [SCRIPT, "transfer", "--source", sourcePath], { - cwd: repo, - env: { - ...buildEnv(binDir), - HOME: home, - CODEX_HOME: path.join(home, ".codex") - } - }); - - assert.notEqual(result.status, 0); - assert.match(result.stderr, /did not record an imported thread/); -}); - -test("transfer rejects sources outside the Claude projects directory", () => { - const home = makeTempDir(); - const repo = path.join(home, "repo"); - const binDir = makeTempDir(); - const sourcePath = path.join(home, "session.jsonl"); - fs.mkdirSync(repo, { recursive: true }); - fs.mkdirSync(path.join(home, ".claude", "projects"), { recursive: true }); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync( - sourcePath, - `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Outside source." } })}\n`, - "utf8" - ); - - const result = run("node", [SCRIPT, "transfer", "--source", sourcePath], { - cwd: repo, - env: { ...buildEnv(binDir), HOME: home } - }); - - assert.notEqual(result.status, 0); - assert.match(result.stderr, /only from .*\.claude.*projects/); -}); - -test("task reports the actual Codex auth error when the run is rejected", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "auth-run-fails"); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const result = run("node", [SCRIPT, "task", "check failed auth"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.notEqual(result.status, 0); - assert.match(result.stderr, /authentication expired; run codex login/); -}); - -test("review accepts the quoted raw argument style for built-in base-branch review", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.mkdirSync(path.join(repo, "src")); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 1;\n"); - run("git", ["add", "src/app.js"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 2;\n"); - - const result = run("node", [SCRIPT, "review", "--base main"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0); - assert.match(result.stdout, /Reviewed changes against main/); - assert.match(result.stdout, /No material issues found/); -}); - -test("adversarial review renders structured findings over app-server turn/start", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.mkdirSync(path.join(repo, "src")); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0];\n"); - run("git", ["add", "src/app.js"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0].id;\n"); - - const result = run("node", [SCRIPT, "adversarial-review"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0); - assert.match(result.stdout, /Missing empty-state guard/); -}); - -test("adversarial review accepts the same base-branch targeting as review", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.mkdirSync(path.join(repo, "src")); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0];\n"); - run("git", ["add", "src/app.js"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0].id;\n"); - - const result = run("node", [SCRIPT, "adversarial-review", "--base", "main"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Branch review against main|against main/i); - assert.match(result.stdout, /Missing empty-state guard/); -}); - -test("adversarial review asks Codex to inspect larger diffs itself", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.mkdirSync(path.join(repo, "src")); - for (const name of ["a.js", "b.js", "c.js"]) { - fs.writeFileSync(path.join(repo, "src", name), `export const value = "${name}-v1";\n`); - } - run("git", ["add", "src/a.js", "src/b.js", "src/c.js"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "a.js"), 'export const value = "PROMPT_SELF_COLLECT_A";\n'); - fs.writeFileSync(path.join(repo, "src", "b.js"), 'export const value = "PROMPT_SELF_COLLECT_B";\n'); - fs.writeFileSync(path.join(repo, "src", "c.js"), 'export const value = "PROMPT_SELF_COLLECT_C";\n'); - - const result = run("node", [SCRIPT, "adversarial-review"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); - assert.match(state.lastTurnStart.prompt, /lightweight summary/i); - assert.match(state.lastTurnStart.prompt, /read-only git commands/i); - assert.doesNotMatch(state.lastTurnStart.prompt, /PROMPT_SELF_COLLECT_[ABC]/); -}); - -test("review includes reasoning output when the app server returns it", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-reasoning"); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - const result = run("node", [SCRIPT, "review"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Reasoning:/); - assert.match(result.stdout, /Reviewed the changed files and checked the likely regression paths first|Reviewed the changed files and checked the likely regression paths/i); -}); - -test("review logs reasoning summaries and review output to the job log", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-reasoning"); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - const result = run("node", [SCRIPT, "review"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const stateDir = resolveStateDir(repo); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - const log = fs.readFileSync(state.jobs[0].logFile, "utf8"); - assert.match(log, /Reasoning summary/); - assert.match(log, /Reviewed the changed files and checked the likely regression paths/); - assert.match(log, /Review output/); - assert.match(log, /Reviewed uncommitted changes\./); -}); - -test("task --resume-last resumes the latest persisted task thread", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const firstRun = run("node", [SCRIPT, "task", "initial task"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(firstRun.status, 0, firstRun.stderr); - - const result = run("node", [SCRIPT, "task", "--resume-last", "follow up"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Resumed the prior run.\nFollow-up prompt accepted.\n"); -}); - -test("task-resume-candidate returns the latest rescue thread from the current session", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "task-current", - status: "completed", - title: "Codex Task", - jobClass: "task", - sessionId: "sess-current", - threadId: "thr_current", - summary: "Investigate the flaky test", - updatedAt: "2026-03-24T20:00:00.000Z" - }, - { - id: "task-other-session", - status: "completed", - title: "Codex Task", - jobClass: "task", - sessionId: "sess-other", - threadId: "thr_other", - summary: "Old rescue run", - updatedAt: "2026-03-24T20:05:00.000Z" - }, - { - id: "review-current", - status: "completed", - title: "Codex Review", - jobClass: "review", - sessionId: "sess-current", - threadId: "thr_review", - summary: "Review main...HEAD", - updatedAt: "2026-03-24T20:10:00.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run("node", [SCRIPT, "task-resume-candidate", "--json"], { - cwd: workspace, - env: { - ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - } - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.available, true); - assert.equal(payload.sessionId, "sess-current"); - assert.equal(payload.candidate.id, "task-current"); - assert.equal(payload.candidate.threadId, "thr_current"); -}); - -test("task --resume-last does not resume a task from another Claude session", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const otherEnv = { - ...buildEnv(binDir), - CODEX_COMPANION_SESSION_ID: "sess-other" - }; - const currentEnv = { - ...buildEnv(binDir), - CODEX_COMPANION_SESSION_ID: "sess-current" - }; - - const firstRun = run("node", [SCRIPT, "task", "initial task"], { - cwd: repo, - env: otherEnv - }); - assert.equal(firstRun.status, 0, firstRun.stderr); - - const candidate = run("node", [SCRIPT, "task-resume-candidate", "--json"], { - cwd: repo, - env: currentEnv - }); - assert.equal(candidate.status, 0, candidate.stderr); - assert.equal(JSON.parse(candidate.stdout).available, false); - - const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], { - cwd: repo, - env: currentEnv - }); - assert.equal(resume.status, 1); - assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./); - - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); - assert.equal(fakeState.lastTurnStart.prompt, "initial task"); -}); - -test("task --resume-last ignores running tasks from other Claude sessions", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const stateDir = resolveStateDir(repo); - fs.mkdirSync(path.join(stateDir, "jobs"), { recursive: true }); - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "task-other-running", - status: "running", - title: "Codex Task", - jobClass: "task", - sessionId: "sess-other", - threadId: "thr_other", - summary: "Other session active task", - updatedAt: "2026-03-24T20:05:00.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const env = { - ...buildEnv(binDir), - CODEX_COMPANION_SESSION_ID: "sess-current" - }; - const status = run("node", [SCRIPT, "status", "--json"], { - cwd: repo, - env - }); - assert.equal(status.status, 0, status.stderr); - assert.deepEqual(JSON.parse(status.stdout).running, []); - - const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], { - cwd: repo, - env - }); - assert.equal(resume.status, 1); - assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./); -}); - -test("session start hook exports the Claude session id, transcript path, and plugin data dir", () => { - const repo = makeTempDir(); - const envFile = path.join(makeTempDir(), "claude-env.sh"); - fs.writeFileSync(envFile, "", "utf8"); - const pluginDataDir = makeTempDir(); - const transcriptPath = path.join(repo, "session.jsonl"); - - const result = run("node", [SESSION_HOOK, "SessionStart"], { - cwd: repo, - env: { - ...process.env, - CLAUDE_ENV_FILE: envFile, - CLAUDE_PLUGIN_DATA: pluginDataDir - }, - input: JSON.stringify({ - hook_event_name: "SessionStart", - session_id: "sess-current", - transcript_path: transcriptPath, - cwd: repo - }) - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal( - fs.readFileSync(envFile, "utf8"), - `export CODEX_COMPANION_SESSION_ID='sess-current'\nexport CODEX_COMPANION_TRANSCRIPT_PATH='${transcriptPath}'\nexport CLAUDE_PLUGIN_DATA='${pluginDataDir}'\n` - ); -}); - -test("write task output focuses on the Codex result without generic follow-up hints", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const result = run("node", [SCRIPT, "task", "--write", "fix the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); -}); - -test("task --resume acts like --resume-last without leaking the flag into the prompt", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const firstRun = run("node", [SCRIPT, "task", "initial task"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(firstRun.status, 0, firstRun.stderr); - - const result = run("node", [SCRIPT, "task", "--resume", "follow up"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); - assert.equal(fakeState.lastTurnStart.prompt, "follow up"); -}); - -test("task --fresh is treated as routing control and does not leak into the prompt", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const result = run("node", [SCRIPT, "task", "--fresh", "diagnose the flaky test"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastTurnStart.prompt, "diagnose the flaky test"); -}); - -test("task forwards model selection and reasoning effort to app-server turn/start", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const result = run("node", [SCRIPT, "task", "--model", "spark", "--effort", "low", "diagnose the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastTurnStart.model, "gpt-5.3-codex-spark"); - assert.equal(fakeState.lastTurnStart.effort, "low"); -}); - -test("task logs reasoning summaries and assistant messages to the job log", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-reasoning"); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const result = run("node", [SCRIPT, "task", "investigate the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const stateDir = resolveStateDir(repo); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - const log = fs.readFileSync(state.jobs[0].logFile, "utf8"); - assert.match(log, /Reasoning summary/); - assert.match(log, /Inspected the prompt, gathered evidence, and checked the highest-risk paths first/); - assert.match(log, /Assistant message/); - assert.match(log, /Handled the requested task/); -}); - -test("task logs subagent reasoning and messages with a subagent prefix", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-subagent"); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const result = run("node", [SCRIPT, "task", "challenge the current design"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const stateDir = resolveStateDir(repo); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - const log = fs.readFileSync(state.jobs[0].logFile, "utf8"); - assert.match(log, /Starting subagent design-challenger via collaboration tool: wait\./); - assert.match(log, /Subagent design-challenger reasoning:/); - assert.match(log, /Questioned the retry strategy and the cache invalidation boundaries\./); - assert.match(log, /Subagent design-challenger:/); - assert.match( - log, - /The design assumes retries are harmless, but they can duplicate side effects without stronger idempotency guarantees\./ - ); -}); - -test("task waits for the main thread to complete before returning the final result", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-subagent"); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const result = run("node", [SCRIPT, "task", "challenge the current design"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); -}); - -test("task ignores later subagent messages when choosing the final returned output", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-late-subagent-message"); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const result = run("node", [SCRIPT, "task", "challenge the current design"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); -}); - -test("task can finish after subagent work even if the parent turn/completed event is missing", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-subagent-no-main-turn-completed"); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const result = run("node", [SCRIPT, "task", "challenge the current design"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); -}); - -test("task using the shared broker still completes when Codex spawns subagents", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-subagent"); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - const env = buildEnv(binDir); - const review = run("node", [SCRIPT, "review"], { - cwd: repo, - env - }); - assert.equal(review.status, 0, review.stderr); - - if (!loadBrokerSession(repo)) { - return; - } - - const result = run("node", [SCRIPT, "task", "challenge the current design"], { - cwd: repo, - env - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); -}); - -test("task --background enqueues a detached worker and exposes per-job status", async () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "slow-task"); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const launched = run("node", [SCRIPT, "task", "--background", "--json", "investigate the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(launched.status, 0, launched.stderr); - const launchPayload = JSON.parse(launched.stdout); - assert.equal(launchPayload.status, "queued"); - assert.match(launchPayload.jobId, /^task-/); - - const waitedStatus = run( - "node", - [SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"], - { - cwd: repo, - env: buildEnv(binDir) - } - ); - - assert.equal(waitedStatus.status, 0, waitedStatus.stderr); - const waitedPayload = JSON.parse(waitedStatus.stdout); - assert.equal(waitedPayload.job.id, launchPayload.jobId); - assert.equal(waitedPayload.job.status, "completed"); - - const resultPayload = await waitFor(() => { - const result = run("node", [SCRIPT, "result", launchPayload.jobId, "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); - if (result.status !== 0) { - return null; - } - return JSON.parse(result.stdout); - }); - - assert.equal(resultPayload.job.id, launchPayload.jobId); - assert.equal(resultPayload.job.status, "completed"); - assert.match(resultPayload.storedJob.rendered, /Handled the requested task/); -}); - -test("review rejects focus text because it is native-review only", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - const result = run("node", [SCRIPT, "review", "--scope working-tree focus on auth"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status > 0, true); - assert.match(result.stderr, /does not support custom focus text/i); - assert.match(result.stderr, /\/codex:adversarial-review focus on auth/i); -}); - -test("review rejects staged-only scope because it is native-review only", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - run("git", ["add", "README.md"], { cwd: repo }); - - const result = run("node", [SCRIPT, "review", "--scope", "staged"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status > 0, true); - assert.match(result.stderr, /Unsupported review scope "staged"/i); - assert.match(result.stderr, /Use one of: auto, working-tree, branch, or pass --base /i); -}); - -test("adversarial review rejects staged-only scope to match review target selection", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - run("git", ["add", "README.md"], { cwd: repo }); - - const result = run("node", [SCRIPT, "adversarial-review", "--scope", "staged"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status > 0, true); - assert.match(result.stderr, /Unsupported review scope "staged"/i); - assert.match(result.stderr, /Use one of: auto, working-tree, branch, or pass --base /i); -}); - -test("review accepts --background while still running as a tracked review job", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - const launched = run("node", [SCRIPT, "review", "--background", "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(launched.status, 0, launched.stderr); - const launchPayload = JSON.parse(launched.stdout); - assert.equal(launchPayload.review, "Review"); - assert.match(launchPayload.codex.stdout, /No material issues found/); - - const status = run("node", [SCRIPT, "status"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(status.status, 0, status.stderr); - assert.match(status.stdout, /# Codex Status/); - assert.match(status.stdout, /Codex Review/); - assert.match(status.stdout, /completed/); -}); - -test("status shows phases, hints, and the latest finished job", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const logFile = path.join(jobsDir, "review-live.log"); - fs.writeFileSync( - logFile, - [ - "[2026-03-18T15:30:00.000Z] Starting Codex Review.", - "[2026-03-18T15:30:01.000Z] Thread ready (thr_1).", - "[2026-03-18T15:30:02.000Z] Turn started (turn_1).", - "[2026-03-18T15:30:03.000Z] Reviewer started: current changes" - ].join("\n"), - "utf8" - ); - - const finishedJobFile = path.join(jobsDir, "review-done.json"); - fs.writeFileSync( - finishedJobFile, - JSON.stringify( - { - id: "review-done", - status: "completed", - title: "Codex Review", - rendered: "# Codex Review\n\nReviewed uncommitted changes.\nNo material issues found.\n" - }, - null, - 2 - ), - "utf8" - ); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "review-live", - kind: "review", - kindLabel: "review", - status: "running", - title: "Codex Review", - jobClass: "review", - phase: "reviewing", - threadId: "thr_1", - summary: "Review working tree diff", - logFile, - createdAt: "2026-03-18T15:30:00.000Z", - updatedAt: "2026-03-18T15:30:03.000Z" - }, - { - id: "review-done", - status: "completed", - title: "Codex Review", - jobClass: "review", - threadId: "thr_done", - summary: "Review main...HEAD", - createdAt: "2026-03-18T15:10:00.000Z", - startedAt: "2026-03-18T15:10:05.000Z", - completedAt: "2026-03-18T15:11:10.000Z", - updatedAt: "2026-03-18T15:11:10.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run("node", [SCRIPT, "status"], { - cwd: workspace - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Active jobs:/); - assert.match(result.stdout, /\| Job \| Kind \| Status \| Phase \| Elapsed \| Codex Session ID \| Summary \| Actions \|/); - assert.match(result.stdout, /\| review-live \| review \| running \| reviewing \| .* \| thr_1 \| Review working tree diff \|/); - assert.match(result.stdout, /`\/codex:status review-live`
`\/codex:cancel review-live`/); - assert.match(result.stdout, /Live details:/); - assert.match(result.stdout, /Latest finished:/); - assert.match(result.stdout, /Progress:/); - assert.match(result.stdout, /Session runtime: direct startup/); - assert.match(result.stdout, /Phase: reviewing/); - assert.match(result.stdout, /Codex session ID: thr_1/); - assert.match(result.stdout, /Resume in Codex: codex resume thr_1/); - assert.match(result.stdout, /Thread ready \(thr_1\)\./); - assert.match(result.stdout, /Reviewer started: current changes/); - assert.match(result.stdout, /Duration: 1m 5s/); - assert.match(result.stdout, /Codex session ID: thr_done/); - assert.match(result.stdout, /Resume in Codex: codex resume thr_done/); -}); - -test("status without a job id only shows jobs from the current Claude session", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const currentLog = path.join(jobsDir, "review-current.log"); - const otherLog = path.join(jobsDir, "review-other.log"); - fs.writeFileSync(currentLog, "[2026-03-18T15:30:00.000Z] Reviewer started: current changes\n", "utf8"); - fs.writeFileSync(otherLog, "[2026-03-18T15:31:00.000Z] Reviewer started: old changes\n", "utf8"); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "review-current", - kind: "review", - kindLabel: "review", - status: "running", - title: "Codex Review", - jobClass: "review", - phase: "reviewing", - sessionId: "sess-current", - threadId: "thr_current", - summary: "Current session review", - logFile: currentLog, - createdAt: "2026-03-18T15:30:00.000Z", - updatedAt: "2026-03-18T15:30:00.000Z" - }, - { - id: "review-other", - kind: "review", - kindLabel: "review", - status: "completed", - title: "Codex Review", - jobClass: "review", - sessionId: "sess-other", - threadId: "thr_other", - summary: "Previous session review", - createdAt: "2026-03-18T15:20:00.000Z", - startedAt: "2026-03-18T15:20:05.000Z", - completedAt: "2026-03-18T15:21:00.000Z", - updatedAt: "2026-03-18T15:21:00.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run("node", [SCRIPT, "status"], { - cwd: workspace, - env: { - ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - } - }); - - assert.equal(result.status, 0, result.stderr); - assert.deepEqual( - [...new Set(result.stdout.match(/review-(?:current|other)/g) ?? [])], - ["review-current"] - ); -}); - -test("status preserves adversarial review kind labels", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const logFile = path.join(jobsDir, "review-adv.log"); - fs.writeFileSync(logFile, "[2026-03-18T15:30:00.000Z] Reviewer started: adversarial review\n", "utf8"); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "review-adv-live", - kind: "adversarial-review", - status: "running", - title: "Codex Adversarial Review", - jobClass: "review", - phase: "reviewing", - threadId: "thr_adv_live", - summary: "Adversarial review current changes", - logFile, - createdAt: "2026-03-18T15:30:00.000Z", - updatedAt: "2026-03-18T15:30:00.000Z" - }, - { - id: "review-adv", - kind: "adversarial-review", - status: "completed", - title: "Codex Adversarial Review", - jobClass: "review", - threadId: "thr_adv_done", - summary: "Adversarial review working tree diff", - createdAt: "2026-03-18T15:10:00.000Z", - startedAt: "2026-03-18T15:10:05.000Z", - completedAt: "2026-03-18T15:11:10.000Z", - updatedAt: "2026-03-18T15:11:10.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run("node", [SCRIPT, "status"], { - cwd: workspace - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /\| review-adv-live \| adversarial-review \| running \| reviewing \|/); - assert.match(result.stdout, /- review-adv \| completed \| adversarial-review \| Codex Adversarial Review/); - assert.match(result.stdout, /Codex session ID: thr_adv_live/); - assert.match(result.stdout, /Codex session ID: thr_adv_done/); -}); - -test("status --wait times out cleanly when a job is still active", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const logFile = path.join(jobsDir, "task-live.log"); - fs.writeFileSync(logFile, "[2026-03-18T15:30:00.000Z] Starting Codex Task.\n", "utf8"); - fs.writeFileSync( - path.join(jobsDir, "task-live.json"), - JSON.stringify( - { - id: "task-live", - status: "running", - title: "Codex Task", - logFile - }, - null, - 2 - ), - "utf8" - ); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "task-live", - status: "running", - title: "Codex Task", - jobClass: "task", - summary: "Investigate flaky test", - logFile, - createdAt: "2026-03-18T15:30:00.000Z", - startedAt: "2026-03-18T15:30:01.000Z", - updatedAt: "2026-03-18T15:30:02.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run("node", [SCRIPT, "status", "task-live", "--wait", "--timeout-ms", "25", "--json"], { - cwd: workspace - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.job.id, "task-live"); - assert.equal(payload.job.status, "running"); - assert.equal(payload.waitTimedOut, true); -}); - -test("result returns the stored output for the latest finished job by default", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - fs.writeFileSync( - path.join(jobsDir, "review-finished.json"), - JSON.stringify( - { - id: "review-finished", - status: "completed", - title: "Codex Review", - rendered: "# Codex Review\n\nReviewed uncommitted changes.\nNo material issues found.\n", - result: { - codex: { - stdout: "Reviewed uncommitted changes.\nNo material issues found." - } - }, - threadId: "thr_review_finished" - }, - null, - 2 - ), - "utf8" - ); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "review-finished", - status: "completed", - title: "Codex Review", - jobClass: "review", - threadId: "thr_review_finished", - summary: "Review working tree diff", - createdAt: "2026-03-18T15:00:00.000Z", - updatedAt: "2026-03-18T15:01:00.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run("node", [SCRIPT, "result"], { - cwd: workspace - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal( - result.stdout, - "Reviewed uncommitted changes.\nNo material issues found.\n\nCodex session ID: thr_review_finished\nResume in Codex: codex resume thr_review_finished\n" - ); -}); - -test("result without a job id prefers the latest finished job from the current Claude session", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - fs.writeFileSync( - path.join(jobsDir, "review-current.json"), - JSON.stringify( - { - id: "review-current", - status: "completed", - title: "Codex Review", - threadId: "thr_current", - result: { - codex: { - stdout: "Current session output." - } - } - }, - null, - 2 - ), - "utf8" - ); - - fs.writeFileSync( - path.join(jobsDir, "review-other.json"), - JSON.stringify( - { - id: "review-other", - status: "completed", - title: "Codex Review", - threadId: "thr_other", - result: { - codex: { - stdout: "Old session output." - } - } - }, - null, - 2 - ), - "utf8" - ); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "review-current", - status: "completed", - title: "Codex Review", - jobClass: "review", - sessionId: "sess-current", - threadId: "thr_current", - summary: "Current session review", - createdAt: "2026-03-18T15:10:00.000Z", - updatedAt: "2026-03-18T15:11:00.000Z" - }, - { - id: "review-other", - status: "completed", - title: "Codex Review", - jobClass: "review", - sessionId: "sess-other", - threadId: "thr_other", - summary: "Old session review", - createdAt: "2026-03-18T15:20:00.000Z", - updatedAt: "2026-03-18T15:21:00.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run("node", [SCRIPT, "result"], { - cwd: workspace, - env: { - ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - } - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal( - result.stdout, - "Current session output.\n\nCodex session ID: thr_current\nResume in Codex: codex resume thr_current\n" - ); -}); - -test("result for a finished write-capable task returns the raw Codex final response", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const taskRun = run("node", [SCRIPT, "task", "--write", "fix the flaky integration test"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(taskRun.status, 0, taskRun.stderr); - - const result = run("node", [SCRIPT, "result"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /^Handled the requested task\.\nTask prompt accepted\.\n/); - assert.match(result.stdout, /Codex session ID: thr_[a-z0-9]+/i); - assert.match(result.stdout, /Resume in Codex: codex resume thr_[a-z0-9]+/i); -}); - -test("cancel stops an active background job and marks it cancelled", async (t) => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const sleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { - cwd: workspace, - detached: true, - stdio: "ignore" - }); - sleeper.unref(); - - t.after(() => { - try { - process.kill(-sleeper.pid, "SIGTERM"); - } catch { - try { - process.kill(sleeper.pid, "SIGTERM"); - } catch { - // Ignore missing process. +async function canListenLocalhost() { + return new Promise((resolve) => { + const server = net.createServer(); + let settled = false; + function finish(value) { + if (settled) { + return; } + settled = true; + resolve(value); } + server.once("error", () => finish(false)); + server.listen(0, "127.0.0.1", () => { + server.close(() => finish(true)); + }); }); +} - const logFile = path.join(jobsDir, "task-live.log"); - const jobFile = path.join(jobsDir, "task-live.json"); - fs.writeFileSync(logFile, "[2026-03-18T15:30:00.000Z] Starting Codex Task.\n", "utf8"); - fs.writeFileSync( - jobFile, - JSON.stringify( - { - id: "task-live", - status: "running", - title: "Codex Task", - logFile - }, - null, - 2 - ), - "utf8" - ); - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "task-live", - status: "running", - title: "Codex Task", - jobClass: "task", - summary: "Investigate flaky test", - pid: sleeper.pid, - logFile, - createdAt: "2026-03-18T15:30:00.000Z", - startedAt: "2026-03-18T15:30:01.000Z", - updatedAt: "2026-03-18T15:30:02.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const cancelResult = run("node", [SCRIPT, "cancel", "task-live", "--json"], { - cwd: workspace - }); - - assert.equal(cancelResult.status, 0, cancelResult.stderr); - assert.equal(JSON.parse(cancelResult.stdout).status, "cancelled"); - - await waitFor(() => { - try { - process.kill(sleeper.pid, 0); - return false; - } catch (error) { - return error?.code === "ESRCH"; - } - }); - - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - const cancelled = state.jobs.find((job) => job.id === "task-live"); - assert.equal(cancelled.status, "cancelled"); - assert.equal(cancelled.pid, null); - - const stored = JSON.parse(fs.readFileSync(jobFile, "utf8")); - assert.equal(stored.status, "cancelled"); - assert.match(fs.readFileSync(logFile, "utf8"), /Cancelled by user/); -}); - -test("cancel without a job id ignores active jobs from other Claude sessions", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const logFile = path.join(jobsDir, "task-other.log"); - fs.writeFileSync(logFile, "", "utf8"); - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "task-other", - status: "running", - title: "Codex Task", - jobClass: "task", - sessionId: "sess-other", - summary: "Other session run", - updatedAt: "2026-03-24T20:05:00.000Z", - logFile - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const env = { - ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - }; - const status = run("node", [SCRIPT, "status", "--json"], { - cwd: workspace, - env - }); - assert.equal(status.status, 0, status.stderr); - assert.deepEqual(JSON.parse(status.stdout).running, []); - - const cancel = run("node", [SCRIPT, "cancel", "--json"], { - cwd: workspace, - env - }); - assert.equal(cancel.status, 1); - assert.match(cancel.stderr, /No active Codex jobs to cancel for this session\./); - - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - assert.equal(state.jobs[0].status, "running"); -}); - -test("cancel with a job id can still target an active job from another Claude session", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const logFile = path.join(jobsDir, "task-other.log"); - fs.writeFileSync(logFile, "", "utf8"); - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "task-other", - status: "running", - title: "Codex Task", - jobClass: "task", - sessionId: "sess-other", - summary: "Other session run", - updatedAt: "2026-03-24T20:05:00.000Z", - logFile - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const env = { - ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - }; - const cancel = run("node", [SCRIPT, "cancel", "task-other", "--json"], { - cwd: workspace, - env - }); - assert.equal(cancel.status, 0, cancel.stderr); - assert.equal(JSON.parse(cancel.stdout).jobId, "task-other"); - - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - assert.equal(state.jobs[0].status, "cancelled"); -}); - -test("cancel sends turn interrupt to the shared app-server before killing a brokered task", async () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const fakeStatePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir, "interruptible-slow-task"); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const env = buildEnv(binDir); - const launched = run("node", [SCRIPT, "task", "--background", "--json", "investigate the flaky worker timeout"], { - cwd: repo, - env - }); - - assert.equal(launched.status, 0, launched.stderr); - const launchPayload = JSON.parse(launched.stdout); - const jobId = launchPayload.jobId; - assert.ok(jobId); - - const stateDir = resolveStateDir(repo); - const runningJob = await waitFor(() => { - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - const job = state.jobs.find((candidate) => candidate.id === jobId); - if (job?.status === "running" && job.threadId && job.turnId) { - return job; - } - return null; - }, { timeoutMs: 15000 }); - - const cancelResult = run("node", [SCRIPT, "cancel", jobId, "--json"], { - cwd: repo, - env - }); - - assert.equal(cancelResult.status, 0, cancelResult.stderr); - const cancelPayload = JSON.parse(cancelResult.stdout); - assert.equal(cancelPayload.status, "cancelled"); - assert.equal(cancelPayload.turnInterruptAttempted, true); - assert.equal(cancelPayload.turnInterrupted, true); - - await waitFor(() => { - const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - return fakeState.lastInterrupt ?? null; - }); +const LOCAL_LISTEN_AVAILABLE = await canListenLocalhost(); +const LOCAL_LISTEN_SKIP = LOCAL_LISTEN_AVAILABLE ? false : "local 127.0.0.1 listen is unavailable in this sandbox"; - const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - assert.deepEqual(fakeState.lastInterrupt, { - threadId: runningJob.threadId, - turnId: runningJob.turnId +function buildTestEnv(binDir, extra = {}) { + return buildEnv(binDir, { + CLAUDE_PLUGIN_DATA: makeTempDir("opencode-plugin-data-"), + OPENCODE_COMPANION_SESSION_ID: "sess-current", + ...extra }); +} - const cleanup = run("node", [SESSION_HOOK, "SessionEnd"], { - cwd: repo, +function cleanupServer(cwd, env) { + run("node", [SESSION_HOOK, "SessionEnd"], { + cwd, env, - input: JSON.stringify({ - hook_event_name: "SessionEnd", - cwd: repo - }) + input: JSON.stringify({ cwd, session_id: env.OPENCODE_COMPANION_SESSION_ID ?? "sess-current" }) }); - assert.equal(cleanup.status, 0, cleanup.stderr); -}); - -test("session end fully cleans up jobs for the ending session", async (t) => { - const repo = makeTempDir(); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const stateDir = resolveStateDir(repo); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const completedLog = path.join(jobsDir, "completed.log"); - const runningLog = path.join(jobsDir, "running.log"); - const otherSessionLog = path.join(jobsDir, "other.log"); - const completedJobFile = path.join(jobsDir, "review-completed.json"); - const runningJobFile = path.join(jobsDir, "review-running.json"); - const otherJobFile = path.join(jobsDir, "review-other.json"); - fs.writeFileSync(completedLog, "completed\n", "utf8"); - fs.writeFileSync(runningLog, "running\n", "utf8"); - fs.writeFileSync(otherSessionLog, "other\n", "utf8"); - fs.writeFileSync(completedJobFile, JSON.stringify({ id: "review-completed" }, null, 2), "utf8"); - fs.writeFileSync(otherJobFile, JSON.stringify({ id: "review-other" }, null, 2), "utf8"); - - const sleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { - cwd: repo, - detached: true, - stdio: "ignore" - }); - sleeper.unref(); - fs.writeFileSync(runningJobFile, JSON.stringify({ id: "review-running" }, null, 2), "utf8"); - - t.after(() => { - try { - process.kill(-sleeper.pid, "SIGTERM"); - } catch { - try { - process.kill(sleeper.pid, "SIGTERM"); - } catch { - // Ignore missing process. - } - } - }); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "review-completed", - status: "completed", - title: "Codex Review", - sessionId: "sess-current", - logFile: completedLog, - createdAt: "2026-03-18T15:30:00.000Z", - updatedAt: "2026-03-18T15:31:00.000Z" - }, - { - id: "review-running", - status: "running", - title: "Codex Review", - sessionId: "sess-current", - pid: sleeper.pid, - logFile: runningLog, - createdAt: "2026-03-18T15:32:00.000Z", - updatedAt: "2026-03-18T15:33:00.000Z" - }, - { - id: "review-other", - status: "completed", - title: "Codex Review", - sessionId: "sess-other", - logFile: otherSessionLog, - createdAt: "2026-03-18T15:34:00.000Z", - updatedAt: "2026-03-18T15:35:00.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run("node", [SESSION_HOOK, "SessionEnd"], { - cwd: repo, - env: { - ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - }, - input: JSON.stringify({ - hook_event_name: "SessionEnd", - session_id: "sess-current", - cwd: repo - }) - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal(fs.existsSync(otherSessionLog), true); - assert.equal(fs.existsSync(otherJobFile), true); - assert.deepEqual( - fs.readdirSync(path.dirname(otherJobFile)).sort(), - [path.basename(otherJobFile), path.basename(otherSessionLog)].sort() - ); - - await waitFor(() => { - try { - process.kill(sleeper.pid, 0); - return false; - } catch (error) { - return error?.code === "ESRCH"; - } - }); - - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - assert.deepEqual(state.jobs.map((job) => job.id), ["review-other"]); - const otherJob = state.jobs[0]; - assert.equal(otherJob.logFile, otherSessionLog); -}); +} -test("stop hook runs a stop-time review task and blocks on findings when the review gate is enabled", () => { +test("setup reports ready when fake opencode is installed and configurable", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); - const fakeStatePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const setup = run("node", [SCRIPT, "setup", "--enable-review-gate", "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(setup.status, 0, setup.stderr); - const setupPayload = JSON.parse(setup.stdout); - assert.equal(setupPayload.reviewGateEnabled, true); - - const taskResult = run("node", [SCRIPT, "task", "--write", "fix the issue"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(taskResult.status, 0, taskResult.stderr); + installFakeOpencode(binDir); + const env = buildTestEnv(binDir); - const blocked = run("node", [STOP_HOOK], { - cwd: repo, - env: buildEnv(binDir), - input: JSON.stringify({ + try { + const result = run("node", [SCRIPT, "setup", "--json"], { cwd: repo, - session_id: "sess-stop-review", - last_assistant_message: "I completed the refactor and updated the retry logic." - }) - }); - assert.equal(blocked.status, 0, blocked.stderr); - const blockedPayload = JSON.parse(blocked.stdout); - assert.equal(blockedPayload.decision, "block"); - assert.match(blockedPayload.reason, /Codex stop-time review found issues that still need fixes/i); - assert.match(blockedPayload.reason, /Missing empty-state guard/i); - - const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - assert.match(fakeState.lastTurnStart.prompt, //i); - assert.match(fakeState.lastTurnStart.prompt, //i); - assert.match(fakeState.lastTurnStart.prompt, /Only review the work from the previous Claude turn/i); - assert.match(fakeState.lastTurnStart.prompt, /I completed the refactor and updated the retry logic\./); - - const status = run("node", [SCRIPT, "status"], { - cwd: repo, - env: { - ...buildEnv(binDir), - CODEX_COMPANION_SESSION_ID: "sess-stop-review" - } - }); - assert.equal(status.status, 0, status.stderr); - assert.match(status.stdout, /Codex Stop Gate Review/); -}); - -test("stop hook logs running tasks to stderr without blocking when the review gate is disabled", () => { - const repo = makeTempDir(); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const stateDir = resolveStateDir(repo); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const runningLog = path.join(jobsDir, "task-running.log"); - fs.writeFileSync(runningLog, "running\n", "utf8"); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { - stopReviewGate: false - }, - jobs: [ - { - id: "task-live", - status: "running", - title: "Codex Task", - jobClass: "task", - sessionId: "sess-current", - logFile: runningLog, - createdAt: "2026-03-18T15:32:00.000Z", - updatedAt: "2026-03-18T15:33:00.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const blocked = run("node", [STOP_HOOK], { - cwd: repo, - env: { - ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - }, - input: JSON.stringify({ cwd: repo }) - }); - - assert.equal(blocked.status, 0, blocked.stderr); - assert.equal(blocked.stdout.trim(), ""); - assert.match(blocked.stderr, /Codex task task-live is still running/i); - assert.match(blocked.stderr, /\/codex:status/i); - assert.match(blocked.stderr, /\/codex:cancel task-live/i); -}); - -test("stop hook allows the stop when the review gate is enabled and the stop-time review task is clean", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "adversarial-clean"); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const setup = run("node", [SCRIPT, "setup", "--enable-review-gate", "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(setup.status, 0, setup.stderr); - - const allowed = run("node", [STOP_HOOK], { - cwd: repo, - env: buildEnv(binDir), - input: JSON.stringify({ cwd: repo, session_id: "sess-stop-clean" }) - }); - - assert.equal(allowed.status, 0, allowed.stderr); - assert.equal(allowed.stdout.trim(), ""); -}); - -test("stop hook does not block when Codex is unavailable even if the review gate is enabled", () => { - const repo = makeTempDir(); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { - cwd: repo - }); - assert.equal(setup.status, 0, setup.stderr); - - const allowed = run(process.execPath, [STOP_HOOK], { - cwd: repo, - env: { - ...process.env, - PATH: "" - }, - input: JSON.stringify({ cwd: repo }) - }); + env + }); - assert.equal(allowed.status, 0, allowed.stderr); - assert.equal(allowed.stdout.trim(), ""); - assert.match(allowed.stderr, /Codex is not set up for the review gate/i); - assert.match(allowed.stderr, /Run \/codex:setup/i); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.ready, true); + assert.equal(payload.opencode.available, true); + assert.equal(payload.auth.loggedIn, true); + assert.equal(payload.auth.provider, "openai"); + assert.equal(payload.sessionRuntime.mode, "shared"); + } finally { + cleanupServer(repo, env); + } }); -test("stop hook runs the actual task when auth status looks stale", () => { +test("foreground task runs through opencode serve and stores a visible session", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); - installFakeCodex(binDir, "refreshable-auth"); + installFakeOpencode(binDir); initGitRepo(repo); fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); run("git", ["add", "README.md"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); + const env = buildTestEnv(binDir); - const setup = run("node", [SCRIPT, "setup", "--enable-review-gate", "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(setup.status, 0, setup.stderr); - - const allowed = run("node", [STOP_HOOK], { - cwd: repo, - env: buildEnv(binDir), - input: JSON.stringify({ cwd: repo }) - }); + try { + const result = run("node", [SCRIPT, "task", "--json", "check the fixture"], { + cwd: repo, + env + }); - assert.equal(allowed.status, 0, allowed.stderr); - assert.doesNotMatch(allowed.stderr, /Codex is not set up for the review gate/i); - const payload = JSON.parse(allowed.stdout); - assert.equal(payload.decision, "block"); - assert.match(payload.reason, /Missing empty-state guard/i); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, 0); + assert.match(payload.threadId, /^ses_/); + assert.match(payload.rawOutput, /Handled the requested task/); + // Progress is streamed to stderr only in non-JSON mode (it goes to the job + // log file under --json), so stderr progress is covered by the live smoke + // test rather than asserted here. + + const list = run("opencode", ["session", "list"], { cwd: repo, env }); + assert.equal(list.status, 0, list.stderr); + assert.match(list.stdout, new RegExp(payload.threadId)); + + const fakeState = readFakeState(binDir); + assert.equal(fakeState.serverStarts, 1); + assert.equal(fakeState.sessions.length, 1); + assert.equal(fakeState.sessions[0].id, payload.threadId); + assert.match(fakeState.sessions[0].title, /^OpenCode Companion Task/); + } finally { + cleanupServer(repo, env); + } }); -test("commands lazily start and reuse one shared app-server after first use", async () => { +test("write task auto-allows headless permission prompts and records touched files", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); - const fakeStatePath = path.join(binDir, "fake-codex-state.json"); - - installFakeCodex(binDir); + installFakeOpencode(binDir); initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - const env = buildEnv(binDir); + const env = buildTestEnv(binDir); - const review = run("node", [SCRIPT, "review"], { - cwd: repo, - env - }); - assert.equal(review.status, 0, review.stderr); + try { + const result = run("node", [SCRIPT, "task", "--json", "--write", "create a small file"], { + cwd: repo, + env + }); - const brokerSession = loadBrokerSession(repo); - if (!brokerSession) { - return; + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.deepEqual(payload.touchedFiles, ["generated.txt"]); + + const fakeState = readFakeState(binDir); + assert.equal(fakeState.sessions[0].agent, "build"); + assert.equal(fakeState.permissions.length, 1); + assert.equal(fakeState.permissions[0].body.response, "always"); + assert.equal(fakeState.permissions[0].body.action, undefined); + assert.deepEqual( + fakeState.sessions[0].permission.filter((rule) => rule.action === "allow").map((rule) => rule.permission).sort(), + ["*"] + ); + } finally { + cleanupServer(repo, env); } - - const adversarial = run("node", [SCRIPT, "adversarial-review"], { - cwd: repo, - env - }); - assert.equal(adversarial.status, 0, adversarial.stderr); - - const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - assert.equal(fakeState.appServerStarts, 1); - - const cleanup = run("node", [SESSION_HOOK, "SessionEnd"], { - cwd: repo, - env, - input: JSON.stringify({ - hook_event_name: "SessionEnd", - cwd: repo - }) - }); - assert.equal(cleanup.status, 0, cleanup.stderr); }); -test("setup reuses an existing shared app-server without starting another one", () => { +test("task forwards spark model alias and effort as OpenCode variant", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); - const fakeStatePath = path.join(binDir, "fake-codex-state.json"); - - installFakeCodex(binDir); + installFakeOpencode(binDir); initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - const env = buildEnv(binDir); + const env = buildTestEnv(binDir); - const review = run("node", [SCRIPT, "review"], { - cwd: repo, - env - }); - assert.equal(review.status, 0, review.stderr); + try { + const result = run("node", [SCRIPT, "task", "--json", "--model", "spark", "--effort", "high", "check model"], { + cwd: repo, + env + }); - const brokerSession = loadBrokerSession(repo); - if (!brokerSession) { - return; + assert.equal(result.status, 0, result.stderr); + const fakeState = readFakeState(binDir); + assert.deepEqual(fakeState.lastMessage.body.model, { + providerID: "openai", + modelID: "gpt-5.3-codex-spark" + }); + assert.equal(fakeState.lastMessage.body.variant, "high"); + } finally { + cleanupServer(repo, env); } - - const setup = run("node", [SCRIPT, "setup", "--json"], { - cwd: repo, - env - }); - assert.equal(setup.status, 0, setup.stderr); - - const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - assert.equal(fakeState.appServerStarts, 1); - - const cleanup = run("node", [SESSION_HOOK, "SessionEnd"], { - cwd: repo, - env, - input: JSON.stringify({ - hook_event_name: "SessionEnd", - cwd: repo - }) - }); - assert.equal(cleanup.status, 0, cleanup.stderr); }); -test("status reports shared session runtime when a lazy broker is active", () => { +test("commands reuse one shared opencode serve within the same plugin state", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); - installFakeCodex(binDir); + installFakeOpencode(binDir); initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - const review = run("node", [SCRIPT, "review"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(review.status, 0, review.stderr); - - if (!loadBrokerSession(repo)) { - return; - } - - const result = run("node", [SCRIPT, "status"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Session runtime: shared session/); -}); - -test("setup and status honor --cwd when reading shared session runtime", () => { - const targetWorkspace = makeTempDir(); - const invocationWorkspace = makeTempDir(); + const env = buildTestEnv(binDir); - saveBrokerSession(targetWorkspace, { - endpoint: "unix:/tmp/fake-broker.sock" - }); + try { + const first = run("node", [SCRIPT, "task", "--json", "first task"], { cwd: repo, env }); + const second = run("node", [SCRIPT, "task", "--json", "second task"], { cwd: repo, env }); - const status = run("node", [SCRIPT, "status", "--cwd", targetWorkspace], { - cwd: invocationWorkspace - }); - assert.equal(status.status, 0, status.stderr); - assert.match(status.stdout, /Session runtime: shared session/); + assert.equal(first.status, 0, first.stderr); + assert.equal(second.status, 0, second.stderr); - const setup = run("node", [SCRIPT, "setup", "--cwd", targetWorkspace, "--json"], { - cwd: invocationWorkspace - }); - assert.equal(setup.status, 0, setup.stderr); - const payload = JSON.parse(setup.stdout); - assert.equal(payload.sessionRuntime.mode, "shared"); - assert.equal(payload.sessionRuntime.endpoint, "unix:/tmp/fake-broker.sock"); + const fakeState = readFakeState(binDir); + assert.equal(fakeState.serverStarts, 1); + assert.equal(fakeState.sessions.length, 2); + } finally { + cleanupServer(repo, env); + } }); diff --git a/tests/server-lifecycle.test.mjs b/tests/server-lifecycle.test.mjs new file mode 100644 index 0000000..c483727 --- /dev/null +++ b/tests/server-lifecycle.test.mjs @@ -0,0 +1,53 @@ +import http from "node:http"; +import net from "node:net"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { isServerHealthy } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; + +async function canListenLocalhost() { + return new Promise((resolve) => { + const server = net.createServer(); + let settled = false; + function finish(value) { + if (settled) { + return; + } + settled = true; + resolve(value); + } + server.once("error", () => finish(false)); + server.listen(0, "127.0.0.1", () => { + server.close(() => finish(true)); + }); + }); +} + +const LOCAL_LISTEN_AVAILABLE = await canListenLocalhost(); + +test( + "isServerHealthy checks the OpenCode global health endpoint", + { skip: LOCAL_LISTEN_AVAILABLE ? false : "local 127.0.0.1 listen is unavailable in this sandbox" }, + async () => { + const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/global/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const url = `http://127.0.0.1:${address.port}`; + + try { + assert.equal(await isServerHealthy(url), true); + assert.equal(await isServerHealthy(`${url}/missing`), false); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + } +); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 0f8f57c..03b52ae 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -5,15 +5,31 @@ import test from "node:test"; import assert from "node:assert/strict"; import { makeTempDir } from "./helpers.mjs"; -import { resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState } from "../plugins/codex/scripts/lib/state.mjs"; +import { resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState } from "../plugins/opencode/scripts/lib/state.mjs"; + +function withoutPluginData(fn) { + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + delete process.env.CLAUDE_PLUGIN_DATA; + try { + return fn(); + } finally { + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } +} test("resolveStateDir uses a temp-backed per-workspace directory", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); + withoutPluginData(() => { + const workspace = makeTempDir(); + const stateDir = resolveStateDir(workspace); - assert.equal(stateDir.startsWith(os.tmpdir()), true); - assert.match(path.basename(stateDir), /.+-[a-f0-9]{16}$/); - assert.match(stateDir, new RegExp(`^${os.tmpdir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); + assert.equal(stateDir.startsWith(os.tmpdir()), true); + assert.match(path.basename(stateDir), /.+-[a-f0-9]{16}$/); + assert.match(stateDir, new RegExp(`^${os.tmpdir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); + }); }); test("resolveStateDir uses CLAUDE_PLUGIN_DATA when it is provided", () => { @@ -41,65 +57,67 @@ test("resolveStateDir uses CLAUDE_PLUGIN_DATA when it is provided", () => { }); test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", () => { - const workspace = makeTempDir(); - const stateFile = resolveStateFile(workspace); - fs.mkdirSync(path.dirname(stateFile), { recursive: true }); + withoutPluginData(() => { + const workspace = makeTempDir(); + const stateFile = resolveStateFile(workspace); + fs.mkdirSync(path.dirname(stateFile), { recursive: true }); - const jobs = Array.from({ length: 51 }, (_, index) => { - const jobId = `job-${index}`; - const updatedAt = new Date(Date.UTC(2026, 0, 1, 0, index, 0)).toISOString(); - const logFile = resolveJobLogFile(workspace, jobId); - const jobFile = resolveJobFile(workspace, jobId); - fs.writeFileSync(logFile, `log ${jobId}\n`, "utf8"); - fs.writeFileSync(jobFile, JSON.stringify({ id: jobId, status: "completed" }, null, 2), "utf8"); - return { - id: jobId, - status: "completed", - logFile, - updatedAt, - createdAt: updatedAt - }; - }); + const jobs = Array.from({ length: 51 }, (_, index) => { + const jobId = `job-${index}`; + const updatedAt = new Date(Date.UTC(2026, 0, 1, 0, index, 0)).toISOString(); + const logFile = resolveJobLogFile(workspace, jobId); + const jobFile = resolveJobFile(workspace, jobId); + fs.writeFileSync(logFile, `log ${jobId}\n`, "utf8"); + fs.writeFileSync(jobFile, JSON.stringify({ id: jobId, status: "completed" }, null, 2), "utf8"); + return { + id: jobId, + status: "completed", + logFile, + updatedAt, + createdAt: updatedAt + }; + }); - fs.writeFileSync( - stateFile, - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs - }, - null, - 2 - )}\n`, - "utf8" - ); + fs.writeFileSync( + stateFile, + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs + }, + null, + 2 + )}\n`, + "utf8" + ); - saveState(workspace, { - version: 1, - config: { stopReviewGate: false }, - jobs - }); + saveState(workspace, { + version: 1, + config: { stopReviewGate: false }, + jobs + }); - const prunedJobFile = resolveJobFile(workspace, "job-0"); - const prunedLogFile = resolveJobLogFile(workspace, "job-0"); - const retainedJobFile = resolveJobFile(workspace, "job-50"); - const retainedLogFile = resolveJobLogFile(workspace, "job-50"); - const jobsDir = path.dirname(prunedJobFile); + const prunedJobFile = resolveJobFile(workspace, "job-0"); + const prunedLogFile = resolveJobLogFile(workspace, "job-0"); + const retainedJobFile = resolveJobFile(workspace, "job-50"); + const retainedLogFile = resolveJobLogFile(workspace, "job-50"); + const jobsDir = path.dirname(prunedJobFile); - assert.equal(fs.existsSync(retainedJobFile), true); - assert.equal(fs.existsSync(retainedLogFile), true); + assert.equal(fs.existsSync(retainedJobFile), true); + assert.equal(fs.existsSync(retainedLogFile), true); - const savedState = JSON.parse(fs.readFileSync(stateFile, "utf8")); - assert.equal(savedState.jobs.length, 50); - assert.deepEqual( - savedState.jobs.map((job) => job.id), - Array.from({ length: 50 }, (_, index) => `job-${50 - index}`) - ); - assert.deepEqual( - fs.readdirSync(jobsDir).sort(), - Array.from({ length: 50 }, (_, index) => `job-${index + 1}`) - .flatMap((jobId) => [`${jobId}.json`, `${jobId}.log`]) - .sort() - ); + const savedState = JSON.parse(fs.readFileSync(stateFile, "utf8")); + assert.equal(savedState.jobs.length, 50); + assert.deepEqual( + savedState.jobs.map((job) => job.id), + Array.from({ length: 50 }, (_, index) => `job-${50 - index}`) + ); + assert.deepEqual( + fs.readdirSync(jobsDir).sort(), + Array.from({ length: 50 }, (_, index) => `job-${index + 1}`) + .flatMap((jobId) => [`${jobId}.json`, `${jobId}.log`]) + .sort() + ); + }); }); diff --git a/tsconfig.app-server.json b/tsconfig.app-server.json deleted file mode 100644 index 3f8c11f..0000000 --- a/tsconfig.app-server.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "allowJs": true, - "checkJs": true, - "noEmit": true, - "strict": false, - "noImplicitAny": false, - "useUnknownInCatchVariables": false, - "skipLibCheck": true, - "types": ["node"] - }, - "include": [ - "plugins/codex/scripts/lib/app-server.mjs", - "plugins/codex/scripts/lib/codex.mjs", - "plugins/codex/scripts/lib/fs.mjs", - "plugins/codex/scripts/lib/process.mjs", - "plugins/codex/scripts/lib/app-server-protocol.d.ts", - "plugins/codex/.generated/app-server-types/**/*.ts" - ] -} From a8683ab33b5bf4118c529b583be0a3a31a322704 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Wed, 8 Jul 2026 00:41:25 +0200 Subject: [PATCH 02/32] fix: capture OpenCode structured (json_schema) output for reviews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode returns json_schema output as a synthetic "StructuredOutput" tool call whose completed `state.input` holds the schema-conforming object — there is no text part. `/opencode:review` therefore captured the model's prose and failed JSON parsing. - Extract `state.input` from the StructuredOutput tool part and use it as the final message when an output schema was requested. - Align the result renderer's stale fallback field (`result.opencode.stdout`, was `result.codex.stdout`). Verified live: `/opencode:review` now returns parsed structured findings; `npm test` 30/30. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VT7Nv9u8ckVJ5vRxzvegiw --- plugins/opencode/scripts/lib/opencode.mjs | 36 ++++++++++++++++++++++- plugins/opencode/scripts/lib/render.mjs | 2 +- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index 19ba514..a89ef99 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -271,6 +271,24 @@ function extractReasoningFromParts(parts) { .filter(Boolean); } +function extractStructuredOutput(parts) { + // OpenCode returns json_schema output as a synthetic "StructuredOutput" tool + // call whose completed `state.input` is the schema-conforming object — there + // is no text part, so text-based capture misses it entirely. + for (const part of parts) { + if (part && part.type === "tool" && part.tool === "StructuredOutput") { + const toolState = part.state ?? {}; + if (toolState.status && toolState.status !== "completed") { + continue; + } + if (toolState.input !== undefined) { + return toolState.input; + } + } + } + return undefined; +} + function extractFilePath(event) { return ( event?.path ?? @@ -314,6 +332,7 @@ function createTurnCaptureState(sessionID, options = {}) { resolveCompletion, rejectCompletion, finalMessage: "", + structuredOutput: null, reasoningSummary: [], touchedFiles: new Set(), commandExecutions: [], @@ -396,6 +415,12 @@ function applyMessageParts(state, parts, sessionID) { if (!Array.isArray(parts) || parts.length === 0) { return; } + if (!sessionID || sessionID === state.sessionID) { + const structured = extractStructuredOutput(parts); + if (structured !== undefined) { + state.structuredOutput = structured; + } + } const text = extractTextFromParts(parts); const reasoning = extractReasoningFromParts(parts); mergeReasoning(state, reasoning); @@ -821,11 +846,20 @@ export async function runServerTurn(cwd, options = {}) { } ); + const structured = turnState.structuredOutput; + const finalMessage = + options.outputSchema && structured !== null && structured !== undefined + ? typeof structured === "string" + ? structured + : JSON.stringify(structured) + : turnState.finalMessage; + return { status: turnState.error ? 1 : 0, threadId: sessionID, turnId: turnState.messageID, - finalMessage: turnState.finalMessage, + finalMessage, + structuredOutput: structured ?? null, reasoningSummary: turnState.reasoningSummary, turn: { id: turnState.messageID ?? "opencode-message", diff --git a/plugins/opencode/scripts/lib/render.mjs b/plugins/opencode/scripts/lib/render.mjs index a4c7938..4b3af5a 100644 --- a/plugins/opencode/scripts/lib/render.mjs +++ b/plugins/opencode/scripts/lib/render.mjs @@ -400,7 +400,7 @@ export function renderStoredJobResult(job, storedJob) { const rawOutput = (typeof storedJob?.result?.rawOutput === "string" && storedJob.result.rawOutput) || - (typeof storedJob?.result?.codex?.stdout === "string" && storedJob.result.codex.stdout) || + (typeof storedJob?.result?.opencode?.stdout === "string" && storedJob.result.opencode.stdout) || ""; if (rawOutput) { const output = rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; From b667fb24b46213cbf94f5dffb0635e9b2239c8bb Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Wed, 8 Jul 2026 01:21:21 +0200 Subject: [PATCH 03/32] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20server=20?= =?UTF-8?q?lock,=20targeted=20cancel,=20review=20test=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented by Codex, then a two-way review round (Claude reviewed Codex's work; Codex reviewed the result), with Codex's findings fixed. - server-lifecycle: add an inter-process lock to `ensureServer` so concurrent plugin commands don't spawn duplicate `opencode serve` processes. Atomic mkdir lock, token-matched release, double-checked healthy-session read, dead-PID/age stale detection. Review-round fixes: stale-takeover TOCTOU -> atomic single-winner rename-steal; leaked lock on owner-file write failure -> cleanup on failure. - cancel: record each job's `serverUrl` and abort against that specific server (fallback to `ensureServer`), so cancel reaches the right instance for backgrounded jobs. - tests: StructuredOutput json_schema regression test; concurrent- startup test now uses a race-safe boot-marker counter (the fixture's `serverStarts` read-modify-write could itself miss a double-spawn). - gpt-5-4-prompting skill generalized to be provider-neutral; `codex-prompt-*.md` -> `opencode-prompt-*.md`. npm test 32/32. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VT7Nv9u8ckVJ5vRxzvegiw --- IMPLEMENTATION_NOTES.md | 16 +- plugins/opencode/scripts/lib/opencode.mjs | 51 +++- .../opencode/scripts/lib/server-lifecycle.mjs | 246 +++++++++++++++--- plugins/opencode/scripts/lib/tracked-jobs.mjs | 14 +- .../opencode/scripts/opencode-companion.mjs | 15 +- .../skills/gpt-5-4-prompting/SKILL.md | 16 +- ...rns.md => opencode-prompt-antipatterns.md} | 2 +- ...-recipes.md => opencode-prompt-recipes.md} | 4 +- .../references/prompt-blocks.md | 2 +- tests/fake-opencode-fixture.mjs | 86 +++++- tests/runtime.test.mjs | 94 ++++++- 11 files changed, 477 insertions(+), 69 deletions(-) rename plugins/opencode/skills/gpt-5-4-prompting/references/{codex-prompt-antipatterns.md => opencode-prompt-antipatterns.md} (96%) rename plugins/opencode/skills/gpt-5-4-prompting/references/{codex-prompt-recipes.md => opencode-prompt-recipes.md} (95%) diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md index 0bbf44d..b188e40 100644 --- a/IMPLEMENTATION_NOTES.md +++ b/IMPLEMENTATION_NOTES.md @@ -27,9 +27,15 @@ The first pass shipped unrun (the build sandbox couldn't bind 127.0.0.1). Runnin - `npm test` — 30/30 green. ✓ - Codex read-only review of the fixes: all 7 confirmed correct; LOW findings (title/model guards, fixture schema enforcement) applied. -## Deferred to Phase 2 -- **`ensureServer` has no inter-process lock** — concurrent plugin commands could spawn duplicate `opencode serve` processes and orphan one. Needs a lock file (or single-flight) around load/spawn/save. LOW (commands rarely overlap; tests run sequentially). -- **`transfer`** — still stubbed (`OpenCode transfer is not implemented in Phase 1`). Phase 3: replay a Claude JSONL transcript into an OpenCode session via `noReply`. -- **Background cancel** — `interruptServerTurn` may connect to a different server instance than the one running a backgrounded job's turn; the abort may not land. +## Phase 2 — command surface + hardening (done) +Delegated to Codex, then a two-way review round (Claude reviewed Codex; Codex reviewed the result), then Codex's findings fixed. `npm test` 32/32. +- **`/opencode:review` structured output** — OpenCode returns `json_schema` output as a synthetic `StructuredOutput` tool part (`state.input`), not text; captured it (was returning unparseable prose). Regression test added. +- **`ensureServer` inter-process lock** — atomic `mkdirSync` lock with token-matched release, double-checked healthy-session read, and dead-PID/age stale detection. Codex's review found 3 real bugs, now fixed: stale-takeover TOCTOU → atomic single-winner rename-steal; leaked lock on owner-file write failure → cleanup-on-failure; age note. Race-safe boot-marker test (serverStarts counter was itself racy). +- **Targeted cancel** — per-job `serverUrl` recorded so cancel aborts the exact server that ran the turn (fallback to `ensureServer`). +- **Prompting skill** — `gpt-5-4-prompting` generalized to be provider-neutral; `codex-prompt-*.md` → `opencode-prompt-*.md`. +- **Housekeeping** — dropped unused `typescript` devDep; `.codex/` gitignored; removed stale CI `npm run build`. + +## Deferred to Phase 3 +- **`transfer`** — still stubbed (`OpenCode transfer is not implemented in Phase 1`). Replay a Claude JSONL transcript into an OpenCode session via `noReply`. - **Review sessions persist** — read-only review sessions are not deleted; acceptable, or delete for ephemeral parity. -- **Housekeeping** — `typescript` devDep is now unused (build step removed); add `.codex/` to `.gitignore` (local Codex-subagent config, not part of the plugin). +- **Optional** — regenerate TypeScript types from OpenCode's OpenAPI (`GET /doc`) to restore a type-check build. diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index a89ef99..d4675e7 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -623,6 +623,17 @@ async function withServer(cwd, fn) { return fn(new OpencodeServerClient(server.url), server); } +async function abortSessionAtUrl(serverUrl, threadId, timeoutMs = 1000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const client = new OpencodeServerClient(serverUrl); + await client.abort(threadId, { signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + function getSessionsArray(response) { if (Array.isArray(response)) { return response; @@ -746,7 +757,7 @@ export async function getAuthStatus(cwd) { } } -export async function interruptServerTurn(cwd, { threadId }) { +export async function interruptServerTurn(cwd, { threadId, serverUrl = null }) { if (!threadId) { return { attempted: false, @@ -756,6 +767,27 @@ export async function interruptServerTurn(cwd, { threadId }) { }; } + if (serverUrl) { + try { + await abortSessionAtUrl(serverUrl, threadId); + return { + attempted: true, + interrupted: true, + transport: "server", + detail: `Aborted OpenCode session ${threadId}.`, + serverUrl + }; + } catch (error) { + return { + attempted: true, + interrupted: false, + transport: "server", + detail: error instanceof Error ? error.message : String(error), + serverUrl + }; + } + } + const availability = getAvailability(cwd); if (!availability.available) { return { @@ -800,15 +832,22 @@ export async function runServerTurn(cwd, options = {}) { throw new Error("A prompt is required for this OpenCode run."); } - return withServer(cwd, async (client) => { + return withServer(cwd, async (client, server) => { + emitProgress(options.onProgress, "Using shared OpenCode server.", "starting", { + serverUrl: server.url + }); + let sessionID = options.resumeThreadId ?? options.resumeSessionId ?? null; if (sessionID) { emitProgress(options.onProgress, `Resuming OpenCode session ${sessionID}.`, "starting", { - threadId: sessionID + threadId: sessionID, + serverUrl: server.url }); } else { - emitProgress(options.onProgress, "Starting OpenCode task session.", "starting"); + emitProgress(options.onProgress, "Starting OpenCode task session.", "starting", { + serverUrl: server.url + }); const session = await client.createSession( buildCreateSessionParams(cwd, { title: options.threadName ?? options.title ?? (options.persistThread ? buildTaskSessionName(prompt) : null), @@ -822,7 +861,8 @@ export async function runServerTurn(cwd, options = {}) { throw new Error("OpenCode did not return a session id."); } emitProgress(options.onProgress, `Session ready (${sessionID}).`, "starting", { - threadId: sessionID + threadId: sessionID, + serverUrl: server.url }); } @@ -858,6 +898,7 @@ export async function runServerTurn(cwd, options = {}) { status: turnState.error ? 1 : 0, threadId: sessionID, turnId: turnState.messageID, + serverUrl: server.url, finalMessage, structuredOutput: structured ?? null, reasoningSummary: turnState.reasoningSummary, diff --git a/plugins/opencode/scripts/lib/server-lifecycle.mjs b/plugins/opencode/scripts/lib/server-lifecycle.mjs index 774e270..6e93122 100644 --- a/plugins/opencode/scripts/lib/server-lifecycle.mjs +++ b/plugins/opencode/scripts/lib/server-lifecycle.mjs @@ -13,7 +13,11 @@ export const PID_FILE_ENV = "OPENCODE_COMPANION_SERVER_PID_FILE"; export const LOG_FILE_ENV = "OPENCODE_COMPANION_SERVER_LOG_FILE"; const SERVER_STATE_FILE = "server.json"; +const SERVER_LOCK_DIR = "server.lock"; +const SERVER_LOCK_INFO_FILE = "owner.json"; const DEFAULT_HOSTNAME = "127.0.0.1"; +const DEFAULT_LOCK_STALE_MS = 30000; +const DEFAULT_LOCK_POLL_MS = 100; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -32,6 +36,10 @@ function resolveServerStateFile(cwd) { return path.join(resolveStateDir(cwd), SERVER_STATE_FILE); } +function resolveServerLockDir(cwd) { + return path.join(resolveStateDir(cwd), SERVER_LOCK_DIR); +} + export function loadServerSession(cwd) { const stateFile = resolveServerStateFile(cwd); if (!fs.existsSync(stateFile)) { @@ -94,6 +102,149 @@ async function waitForServerHealth(url, timeoutMs = 10000) { return false; } +function processIsAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) { + return null; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +function readServerLockInfo(lockDir) { + const infoFile = path.join(lockDir, SERVER_LOCK_INFO_FILE); + try { + return JSON.parse(fs.readFileSync(infoFile, "utf8")); + } catch { + return {}; + } +} + +function serverLockAgeMs(lockDir, info) { + const created = Date.parse(info?.createdAt ?? ""); + if (Number.isFinite(created)) { + return Date.now() - created; + } + + try { + return Date.now() - fs.statSync(lockDir).mtimeMs; + } catch { + return 0; + } +} + +function isServerLockStale(lockDir, staleMs) { + if (!fs.existsSync(lockDir)) { + return true; + } + + const info = readServerLockInfo(lockDir); + const ownerPid = Number(info?.pid); + if (processIsAlive(ownerPid) === false) { + return true; + } + + // Age is only a backstop for zombie/reused PIDs where liveness is unreliable; + // staleMs (default 30s) is well past normal startup (timeoutMs default 10s), + // and the atomic steal bounds any misfire against a live owner to one winner. + return serverLockAgeMs(lockDir, info) > staleMs; +} + +function removeServerLock(lockDir) { + try { + fs.rmSync(lockDir, { recursive: true, force: true }); + } catch { + // Another process may have removed or replaced the lock. + } +} + +function stealStaleServerLock(lockDir) { + // Atomically move the stale lock aside instead of removing it in place. + // renameSync has a single winner, so concurrent stealers cannot all clear the + // path — a blind remove could delete a lock another process just created. The + // winner deletes the moved copy; losers get ENOENT and re-race the atomic + // mkdir. Residual: a lock refreshed within the rename window could be moved, + // which is rare and costs at most one orphaned local server. + const stealPath = `${lockDir}.stale-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + try { + fs.renameSync(lockDir, stealPath); + } catch { + return; + } + removeServerLock(stealPath); +} + +function releaseServerLock(lockDir, token) { + const info = readServerLockInfo(lockDir); + if (info?.token !== token) { + return; + } + removeServerLock(lockDir); +} + +async function loadHealthyServerSession(cwd, healthTimeoutMs) { + const existing = loadServerSession(cwd); + if (existing?.url && (await isServerHealthy(existing.url, healthTimeoutMs))) { + return existing; + } + return null; +} + +async function acquireServerLock(cwd, options = {}) { + const stateDir = resolveStateDir(cwd); + fs.mkdirSync(stateDir, { recursive: true }); + + const lockDir = resolveServerLockDir(cwd); + const staleMs = Math.max(1000, Number(options.lockStaleMs) || DEFAULT_LOCK_STALE_MS); + const pollMs = Math.max(25, Number(options.lockPollMs) || DEFAULT_LOCK_POLL_MS); + const healthTimeoutMs = options.healthTimeoutMs ?? 500; + const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + + for (;;) { + try { + fs.mkdirSync(lockDir); + } catch (error) { + if (error?.code !== "EEXIST") { + throw error; + } + + // The lock is held. Reuse an already-started healthy server if one exists. + const existing = await loadHealthyServerSession(cwd, healthTimeoutMs); + if (existing) { + return { session: existing, release: null }; + } + + // If the holder looks stale, clear it via an atomic single-winner steal + // (never a blind remove), then re-race the mkdir. + if (isServerLockStale(lockDir, staleMs)) { + stealStaleServerLock(lockDir); + } + await sleep(pollMs); + continue; + } + + // We own the freshly created lock dir; record ownership. If that write + // fails, remove the dir so we do not leak an unowned lock others wait out. + try { + fs.writeFileSync( + path.join(lockDir, SERVER_LOCK_INFO_FILE), + `${JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }, null, 2)}\n`, + "utf8" + ); + } catch (error) { + removeServerLock(lockDir); + throw error; + } + return { + release: () => releaseServerLock(lockDir, token) + }; + } +} + function findOpenPort(hostname = DEFAULT_HOSTNAME) { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -169,57 +320,72 @@ export async function ensureServer(cwd, options = {}) { }; } - const existing = loadServerSession(cwd); - if (existing?.url && (await isServerHealthy(existing.url, options.healthTimeoutMs ?? 500))) { + const existing = await loadHealthyServerSession(cwd, options.healthTimeoutMs ?? 500); + if (existing) { return existing; } - if (existing) { - await teardownServerSession({ - ...existing, - killProcess: options.killProcess ?? null - }); - clearServerSession(cwd); + const lock = await acquireServerLock(cwd, options); + if (lock.session) { + return lock.session; } - const hostname = options.hostname ?? DEFAULT_HOSTNAME; - const port = options.port ?? (await findOpenPort(hostname)); - const url = `http://${hostname}:${port}`; - const sessionDir = createServerSessionDir(); - const pidFile = path.join(sessionDir, "opencode-server.pid"); - const logFile = path.join(sessionDir, "opencode-server.log"); - const child = spawnServerProcess({ - cwd, - hostname, - port, - pidFile, - logFile, - env: options.env ?? process.env - }); + try { + const lockedExisting = await loadHealthyServerSession(cwd, options.healthTimeoutMs ?? 500); + if (lockedExisting) { + return lockedExisting; + } + + const staleExisting = loadServerSession(cwd); + if (staleExisting) { + await teardownServerSession({ + ...staleExisting, + killProcess: options.killProcess ?? null + }); + clearServerSession(cwd); + } + + const hostname = options.hostname ?? DEFAULT_HOSTNAME; + const port = options.port ?? (await findOpenPort(hostname)); + const url = `http://${hostname}:${port}`; + const sessionDir = createServerSessionDir(); + const pidFile = path.join(sessionDir, "opencode-server.pid"); + const logFile = path.join(sessionDir, "opencode-server.log"); + const child = spawnServerProcess({ + cwd, + hostname, + port, + pidFile, + logFile, + env: options.env ?? process.env + }); + + const ready = await waitForServerHealth(url, options.timeoutMs ?? 10000); + if (!ready) { + await teardownServerSession({ + url, + pidFile, + logFile, + sessionDir, + pid: child.pid ?? null, + killProcess: options.killProcess ?? null + }); + return null; + } - const ready = await waitForServerHealth(url, options.timeoutMs ?? 10000); - if (!ready) { - await teardownServerSession({ + const session = { url, + pid: child.pid ?? null, pidFile, logFile, sessionDir, - pid: child.pid ?? null, - killProcess: options.killProcess ?? null - }); - return null; + external: false + }; + saveServerSession(cwd, session); + return session; + } finally { + lock.release?.(); } - - const session = { - url, - pid: child.pid ?? null, - pidFile, - logFile, - sessionDir, - external: false - }; - saveServerSession(cwd, session); - return session; } export async function teardownServerSession({ diff --git a/plugins/opencode/scripts/lib/tracked-jobs.mjs b/plugins/opencode/scripts/lib/tracked-jobs.mjs index a3e5e70..d828831 100644 --- a/plugins/opencode/scripts/lib/tracked-jobs.mjs +++ b/plugins/opencode/scripts/lib/tracked-jobs.mjs @@ -16,6 +16,7 @@ function normalizeProgressEvent(value) { phase: typeof value.phase === "string" && value.phase.trim() ? value.phase.trim() : null, threadId: typeof value.threadId === "string" && value.threadId.trim() ? value.threadId.trim() : null, turnId: typeof value.turnId === "string" && value.turnId.trim() ? value.turnId.trim() : null, + serverUrl: typeof value.serverUrl === "string" && value.serverUrl.trim() ? value.serverUrl.trim() : null, stderrMessage: value.stderrMessage == null ? null : String(value.stderrMessage).trim(), logTitle: typeof value.logTitle === "string" && value.logTitle.trim() ? value.logTitle.trim() : null, logBody: value.logBody == null ? null : String(value.logBody).trimEnd() @@ -27,6 +28,7 @@ function normalizeProgressEvent(value) { phase: null, threadId: null, turnId: null, + serverUrl: null, stderrMessage: String(value ?? "").trim(), logTitle: null, logBody: null @@ -71,6 +73,7 @@ export function createJobProgressUpdater(workspaceRoot, jobId) { let lastPhase = null; let lastThreadId = null; let lastTurnId = null; + let lastServerUrl = null; return (event) => { const normalized = normalizeProgressEvent(event); @@ -95,6 +98,12 @@ export function createJobProgressUpdater(workspaceRoot, jobId) { changed = true; } + if (normalized.serverUrl && normalized.serverUrl !== lastServerUrl) { + lastServerUrl = normalized.serverUrl; + patch.serverUrl = normalized.serverUrl; + changed = true; + } + if (!changed) { return; } @@ -146,7 +155,8 @@ export async function runTrackedJob(job, runner, options = {}) { startedAt: nowIso(), phase: "starting", pid: process.pid, - logFile: options.logFile ?? job.logFile ?? null + logFile: options.logFile ?? job.logFile ?? null, + serverUrl: options.serverUrl ?? job.serverUrl ?? null }; writeJobFile(job.workspaceRoot, job.id, runningRecord); upsertJob(job.workspaceRoot, runningRecord); @@ -160,6 +170,7 @@ export async function runTrackedJob(job, runner, options = {}) { status: completionStatus, threadId: execution.threadId ?? null, turnId: execution.turnId ?? null, + serverUrl: execution.serverUrl ?? runningRecord.serverUrl ?? null, pid: null, phase: completionStatus === "completed" ? "done" : "failed", completedAt, @@ -171,6 +182,7 @@ export async function runTrackedJob(job, runner, options = {}) { status: completionStatus, threadId: execution.threadId ?? null, turnId: execution.turnId ?? null, + serverUrl: execution.serverUrl ?? runningRecord.serverUrl ?? null, summary: execution.summary, phase: completionStatus === "completed" ? "done" : "failed", pid: null, diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index 7b2dd6b..b620d0d 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -377,7 +377,8 @@ async function executeReviewRun(request) { summary: parsed.parsed?.summary ?? parsed.parseError ?? firstMeaningfulLine(result.finalMessage, `${reviewName} finished.`), jobTitle: `OpenCode ${reviewName}`, jobClass: "review", - targetLabel: context.target.label + targetLabel: context.target.label, + serverUrl: result.serverUrl ?? null }; } @@ -450,7 +451,8 @@ async function executeTaskRun(request) { summary: firstMeaningfulLine(rawOutput, firstMeaningfulLine(failureMessage, `${taskMetadata.title} finished.`)), jobTitle: taskMetadata.title, jobClass: "task", - write: Boolean(request.write) + write: Boolean(request.write), + serverUrl: result.serverUrl ?? null }; } @@ -713,7 +715,11 @@ async function handleTask(argv) { ensureOpenCodeAvailable(cwd); requireTaskRequest(prompt, resumeLast); - const job = buildTaskJob(workspaceRoot, taskMetadata, write); + const currentServerUrl = getSessionRuntimeStatus(process.env, workspaceRoot).url ?? null; + const job = { + ...buildTaskJob(workspaceRoot, taskMetadata, write), + ...(currentServerUrl ? { serverUrl: currentServerUrl } : {}) + }; const request = buildTaskRequest({ cwd, model, @@ -896,8 +902,9 @@ async function handleCancel(argv) { const existing = readStoredJob(workspaceRoot, job.id) ?? {}; const threadId = existing.threadId ?? job.threadId ?? null; const turnId = existing.turnId ?? job.turnId ?? null; + const serverUrl = existing.serverUrl ?? job.serverUrl ?? null; - const interrupt = await interruptServerTurn(cwd, { threadId, turnId }); + const interrupt = await interruptServerTurn(cwd, { threadId, turnId, serverUrl }); if (interrupt.attempted) { appendLogLine( job.logFile, diff --git a/plugins/opencode/skills/gpt-5-4-prompting/SKILL.md b/plugins/opencode/skills/gpt-5-4-prompting/SKILL.md index 47ade35..7b2f7d1 100644 --- a/plugins/opencode/skills/gpt-5-4-prompting/SKILL.md +++ b/plugins/opencode/skills/gpt-5-4-prompting/SKILL.md @@ -1,20 +1,20 @@ --- name: gpt-5-4-prompting -description: Internal guidance for composing OpenCode and GPT-5.4 prompts for coding, review, diagnosis, and research tasks inside the OpenCode Claude Code plugin +description: Internal guidance for composing provider-neutral OpenCode prompts for coding, review, diagnosis, and research tasks inside the OpenCode Claude Code plugin user-invocable: false --- -# GPT-5.4 Prompting +# OpenCode Prompting -Use this skill when `opencode:opencode-rescue` needs to ask OpenCode or another GPT-5.4-based workflow for help. +Use this skill when `opencode:opencode-rescue` needs to tighten a user request before delegating it to OpenCode. -Prompt OpenCode like an operator, not a collaborator. Keep prompts compact and block-structured with XML tags. State the task, the output contract, the follow-through defaults, and the small set of extra constraints that matter. +Prompt OpenCode like an operator, not a collaborator. Keep prompts compact and block-structured with XML tags. State the task, the output contract, the follow-through defaults, and the small set of extra constraints that matter. Do not assume a specific provider or model family; the prompt should work across OpenCode's configured model unless the user explicitly selected one. Core rules: - Prefer one clear task per OpenCode run. Split unrelated asks into separate runs. - Tell OpenCode what done looks like. Do not assume it will infer the desired end state. - Add explicit grounding and verification rules for any task where unsupported guesses would hurt quality. -- Prefer better prompt contracts over raising reasoning or adding long natural-language explanations. +- Prefer better prompt contracts over model-specific tuning or long natural-language explanations. - Use XML tags consistently so the prompt has stable internal structure. Default prompt recipe: @@ -38,7 +38,7 @@ How to choose prompt shape: Working rules: - Prefer explicit prompt contracts over vague nudges. - Use stable XML tag names that match the block names from the reference file. -- Do not raise reasoning or complexity first. Tighten the prompt and verification rules before escalating. +- Do not escalate model, variant, or complexity first. Tighten the prompt and verification rules before changing runtime settings. - Ask OpenCode for brief, outcome-based progress updates only when the task is long-running or tool-heavy. - Keep claims anchored to observed evidence. If something is a hypothesis, say so. @@ -50,5 +50,5 @@ Prompt assembly checklist: 5. Remove redundant instructions before sending the prompt. Reusable blocks live in [references/prompt-blocks.md](references/prompt-blocks.md). -Concrete end-to-end templates live in [references/codex-prompt-recipes.md](references/codex-prompt-recipes.md). -Common failure modes to avoid live in [references/codex-prompt-antipatterns.md](references/codex-prompt-antipatterns.md). +Concrete end-to-end templates live in [references/opencode-prompt-recipes.md](references/opencode-prompt-recipes.md). +Common failure modes to avoid live in [references/opencode-prompt-antipatterns.md](references/opencode-prompt-antipatterns.md). diff --git a/plugins/opencode/skills/gpt-5-4-prompting/references/codex-prompt-antipatterns.md b/plugins/opencode/skills/gpt-5-4-prompting/references/opencode-prompt-antipatterns.md similarity index 96% rename from plugins/opencode/skills/gpt-5-4-prompting/references/codex-prompt-antipatterns.md rename to plugins/opencode/skills/gpt-5-4-prompting/references/opencode-prompt-antipatterns.md index 7366592..c24023d 100644 --- a/plugins/opencode/skills/gpt-5-4-prompting/references/codex-prompt-antipatterns.md +++ b/plugins/opencode/skills/gpt-5-4-prompting/references/opencode-prompt-antipatterns.md @@ -1,6 +1,6 @@ # OpenCode Prompt Anti-Patterns -Avoid these when prompting OpenCode or GPT-5.4. +Avoid these when prompting OpenCode. ## Vague task framing diff --git a/plugins/opencode/skills/gpt-5-4-prompting/references/codex-prompt-recipes.md b/plugins/opencode/skills/gpt-5-4-prompting/references/opencode-prompt-recipes.md similarity index 95% rename from plugins/opencode/skills/gpt-5-4-prompting/references/codex-prompt-recipes.md rename to plugins/opencode/skills/gpt-5-4-prompting/references/opencode-prompt-recipes.md index 81bee53..00085c6 100644 --- a/plugins/opencode/skills/gpt-5-4-prompting/references/codex-prompt-recipes.md +++ b/plugins/opencode/skills/gpt-5-4-prompting/references/opencode-prompt-recipes.md @@ -1,6 +1,6 @@ # OpenCode Prompt Recipes -Use these as starting templates for OpenCode task prompts or other OpenCode/GPT-5.4 prompt construction. +Use these as starting templates for OpenCode task prompts. Copy the smallest recipe that fits the task, then trim anything you do not need. In `opencode:opencode-rescue`, run diagnosis and fix-oriented recipes in write mode by default unless the user explicitly asked for read-only behavior. @@ -128,7 +128,7 @@ Prefer primary sources. ```xml -Diagnose why this existing prompt is underperforming and propose the smallest high-leverage changes to improve it for OpenCode or GPT-5.4. +Diagnose why this existing prompt is underperforming and propose the smallest high-leverage changes to improve it for OpenCode delegation. diff --git a/plugins/opencode/skills/gpt-5-4-prompting/references/prompt-blocks.md b/plugins/opencode/skills/gpt-5-4-prompting/references/prompt-blocks.md index 776dce2..af05c54 100644 --- a/plugins/opencode/skills/gpt-5-4-prompting/references/prompt-blocks.md +++ b/plugins/opencode/skills/gpt-5-4-prompting/references/prompt-blocks.md @@ -1,6 +1,6 @@ # Prompt Blocks -Use these blocks selectively when composing OpenCode or GPT-5.4 prompts. +Use these blocks selectively when composing OpenCode prompts. Wrap each block in the XML tag shown in its heading. ## Core Wrapper diff --git a/tests/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs index 3566f9c..32356ee 100644 --- a/tests/fake-opencode-fixture.mjs +++ b/tests/fake-opencode-fixture.mjs @@ -18,6 +18,15 @@ export function readFakeState(binDir) { return fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, "utf8")) : null; } +export function readServerBootCount(binDir) { + const bootsDir = path.join(binDir, "boots"); + try { + return fs.readdirSync(bootsDir).length; + } catch { + return 0; + } +} + export function installFakeOpencode(binDir) { const statePath = path.join(binDir, "fake-opencode-state.json"); const scriptPath = path.join(binDir, "opencode"); @@ -90,6 +99,64 @@ function textFromMessage(body) { .trim(); } +function schemaType(schema) { + const type = schema && schema.type; + return Array.isArray(type) ? type[0] : type; +} + +function exampleString(key) { + return key ? key.replace(/_/g, " ") + " value" : "structured value"; +} + +function exampleForSchema(schema, key) { + if (!schema || typeof schema !== "object") { + return exampleString(key); + } + if (Array.isArray(schema.enum) && schema.enum.length > 0) { + return schema.enum[0]; + } + + const type = schemaType(schema); + if (type === "object" || schema.properties) { + const properties = schema.properties || {}; + const required = Array.isArray(schema.required) ? schema.required : Object.keys(properties).slice(0, 1); + const result = {}; + for (const property of required) { + result[property] = exampleForSchema(properties[property], property); + } + return result; + } + if (type === "array") { + return []; + } + if (type === "integer") { + return Number.isFinite(schema.minimum) ? schema.minimum : 1; + } + if (type === "number") { + return Number.isFinite(schema.minimum) ? schema.minimum : 1; + } + if (type === "boolean") { + return true; + } + return exampleString(key); +} + +function structuredOutputParts(body) { + if (body && body.format && body.format.type === "json_schema") { + return [ + { + type: "tool", + tool: "StructuredOutput", + state: { + status: "completed", + input: exampleForSchema(body.format.schema, "result") + } + } + ]; + } + return null; +} + async function waitForPermission(permissionID) { await new Promise((resolve) => { const timeout = setTimeout(resolve, 2000); @@ -126,7 +193,10 @@ async function handleMessage(req, res, sessionID) { const finalText = prompt.includes("follow up") ? "Resumed the prior OpenCode run.\\nFollow-up prompt accepted." : "Handled the requested task.\\nTask prompt accepted."; - const parts = [{ type: "text", text: finalText }]; + const parts = structuredOutputParts(body) || [{ type: "text", text: finalText }]; + const finalState = loadState(); + finalState.lastResponseParts = parts; + saveState(finalState); emit({ type: "message.updated", sessionID, message: { id: messageID, parts } }); emit({ type: "session.idle", sessionID }); sendJson(res, { info: { id: messageID, sessionID }, parts }); @@ -161,11 +231,25 @@ const port = Number(args[args.indexOf("--port") + 1] || 0); const bootState = loadState(); bootState.serverStarts = (bootState.serverStarts || 0) + 1; saveState(bootState); +// Race-safe boot marker: each process writes a uniquely-named file, so counting +// files reliably reflects concurrent boots (unlike serverStarts' read-modify- +// write, where two concurrent boots can both persist the same incremented value). +try { + const bootsDir = path.join(path.dirname(STATE_PATH), "boots"); + fs.mkdirSync(bootsDir, { recursive: true }); + fs.writeFileSync(path.join(bootsDir, process.pid + "-" + process.hrtime.bigint().toString()), ""); +} catch {} +const bootStartedAt = Date.now(); +const healthDelayMs = Math.max(0, Number(process.env.FAKE_OPENCODE_HEALTH_DELAY_MS || 0)); const server = http.createServer(async (req, res) => { const url = new URL(req.url, "http://127.0.0.1"); if (req.method === "GET" && url.pathname === "/global/health") { + if (Date.now() - bootStartedAt < healthDelayMs) { + sendJson(res, { ok: false }, 503); + return; + } sendJson(res, { ok: true }); return; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 4841750..e5c6759 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1,11 +1,12 @@ import fs from "node:fs"; import net from "node:net"; import path from "node:path"; +import { spawn } from "node:child_process"; import test from "node:test"; import assert from "node:assert/strict"; import { fileURLToPath } from "node:url"; -import { buildEnv, installFakeOpencode, readFakeState } from "./fake-opencode-fixture.mjs"; +import { buildEnv, installFakeOpencode, readFakeState, readServerBootCount } from "./fake-opencode-fixture.mjs"; import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -50,6 +51,30 @@ function cleanupServer(cwd, env) { }); } +function runAsync(command, args, options = {}) { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + windowsHide: true + }); + let stdout = ""; + let stderr = ""; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("close", (status) => { + resolve({ status, stdout, stderr }); + }); + }); +} + test("setup reports ready when fake opencode is installed and configurable", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); @@ -190,3 +215,70 @@ test("commands reuse one shared opencode serve within the same plugin state", { cleanupServer(repo, env); } }); + +test("concurrent commands share one opencode serve startup", { skip: LOCAL_LISTEN_SKIP }, async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + const env = buildTestEnv(binDir, { + FAKE_OPENCODE_HEALTH_DELAY_MS: "250" + }); + + try { + const [first, second] = await Promise.all([ + runAsync("node", [SCRIPT, "task", "--json", "first concurrent task"], { cwd: repo, env }), + runAsync("node", [SCRIPT, "task", "--json", "second concurrent task"], { cwd: repo, env }) + ]); + + assert.equal(first.status, 0, first.stderr); + assert.equal(second.status, 0, second.stderr); + + // serverStarts is a racy read-modify-write; the boot-marker count is + // race-safe and reliably fails if the lock let a second server start. + assert.equal(readServerBootCount(binDir), 1); + const fakeState = readFakeState(binDir); + assert.equal(fakeState.sessions.length, 2); + } finally { + cleanupServer(repo, env); + } +}); + +test("review captures json_schema output from StructuredOutput tool input", { skip: LOCAL_LISTEN_SKIP }, () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "before\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "README.md"), "after\n"); + const env = buildTestEnv(binDir); + + try { + const result = run("node", [SCRIPT, "review", "--json"], { + cwd: repo, + env + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + const expected = { + verdict: "approve", + summary: "summary value", + findings: [], + next_steps: [] + }; + assert.equal(payload.parseError, null); + assert.deepEqual(payload.result, expected); + assert.equal(payload.opencode.stdout, JSON.stringify(expected)); + + const fakeState = readFakeState(binDir); + assert.equal(fakeState.lastResponseParts.length, 1); + assert.equal(fakeState.lastResponseParts[0].type, "tool"); + assert.equal(fakeState.lastResponseParts[0].tool, "StructuredOutput"); + assert.equal(fakeState.lastResponseParts[0].text, undefined); + } finally { + cleanupServer(repo, env); + } +}); From 06cf42ef024721160e75580fdd51301d07e19af7 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Wed, 8 Jul 2026 03:43:06 +0200 Subject: [PATCH 04/32] =?UTF-8?q?feat:=20Phase=203=20=E2=80=94=20transfer?= =?UTF-8?q?=20Claude=20sessions=20into=20OpenCode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delegated to Codex, then live-verified end-to-end by Claude. - `transfer` converts the current Claude Code JSONL transcript into an OpenCode import document and runs `opencode import `, creating a resumable session with full user/assistant turn history; prints `opencode --session ` to continue. - New pure converter `buildOpenCodeImportDocumentFromClaudeJsonl`: extracts text from user/assistant content (string or parts), skips non-text parts (thinking/tool_use/tool_result/images) and empty messages, normalizes timestamps to strictly increasing (preserving order), and threads assistant `parentID` to the preceding user message. - Edge-case fix from live testing: drop assistant turns before the first user message — `opencode import` rejects a null `parentID` and would otherwise fail the whole import. - Fake CLI fixture handles `import`/`--version`; converter + command tests, including a leading-assistant regression test. Live-verified: real transcripts import with correct roles/order/text and are resumable. npm test 35/35. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VT7Nv9u8ckVJ5vRxzvegiw --- IMPLEMENTATION_NOTES.md | 9 +- README.md | 4 +- .../scripts/lib/claude-session-transfer.mjs | 251 ++++++++++++++++++ plugins/opencode/scripts/lib/opencode.mjs | 57 +++- .../opencode/scripts/opencode-companion.mjs | 2 +- tests/fake-opencode-fixture.mjs | 34 +++ tests/transfer.test.mjs | 166 ++++++++++++ 7 files changed, 513 insertions(+), 10 deletions(-) create mode 100644 tests/transfer.test.mjs diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md index b188e40..c21aea3 100644 --- a/IMPLEMENTATION_NOTES.md +++ b/IMPLEMENTATION_NOTES.md @@ -35,7 +35,12 @@ Delegated to Codex, then a two-way review round (Claude reviewed Codex; Codex re - **Prompting skill** — `gpt-5-4-prompting` generalized to be provider-neutral; `codex-prompt-*.md` → `opencode-prompt-*.md`. - **Housekeeping** — dropped unused `typescript` devDep; `.codex/` gitignored; removed stale CI `npm run build`. -## Deferred to Phase 3 -- **`transfer`** — still stubbed (`OpenCode transfer is not implemented in Phase 1`). Replay a Claude JSONL transcript into an OpenCode session via `noReply`. +## Phase 3 — transfer (done) +Delegated to Codex; live-verified end-to-end by Claude (real transcript → `opencode import` → resumable session with correct roles/order/text). `npm test` 35/35. +- **`transfer`** — converts the Claude JSONL transcript to an OpenCode import document and runs `opencode import `; returns the new `ses_...` id and prints `opencode --session ` for resume. +- Non-text content (thinking/tool_use/tool_result/images) is skipped; timestamps are normalized to strictly increasing so order is preserved. +- Edge-case fix (found via live testing): assistant turns before the first user message are dropped — `opencode import` rejects a null `parentID`. + +## Deferred - **Review sessions persist** — read-only review sessions are not deleted; acceptable, or delete for ephemeral parity. - **Optional** — regenerate TypeScript types from OpenCode's OpenAPI (`GET /doc`) to restore a type-check build. diff --git a/README.md b/README.md index 9d35c3c..1c9b18f 100644 --- a/README.md +++ b/README.md @@ -160,9 +160,7 @@ Ask OpenCode to redesign the database connection to be more resilient. ### `/opencode:transfer` -Transfer is planned for the next phase of the OpenCode port. The command is present but currently reports that transfer is not implemented in Phase 1. - -Once implemented, use it when you started a debugging or implementation conversation in Claude Code and want to continue that same context directly in OpenCode. +Transfer imports the current Claude Code JSONL transcript into a resumable OpenCode session with visible user and assistant turn history. Use it when you started a debugging or implementation conversation in Claude Code and want to continue that same context directly in OpenCode. Examples: diff --git a/plugins/opencode/scripts/lib/claude-session-transfer.mjs b/plugins/opencode/scripts/lib/claude-session-transfer.mjs index 55f7c75..e756dad 100644 --- a/plugins/opencode/scripts/lib/claude-session-transfer.mjs +++ b/plugins/opencode/scripts/lib/claude-session-transfer.mjs @@ -1,10 +1,14 @@ import fs from "node:fs"; +import { randomBytes } from "node:crypto"; import os from "node:os"; import path from "node:path"; import { ensureAbsolutePath } from "./fs.mjs"; export const TRANSCRIPT_PATH_ENV = "OPENCODE_COMPANION_TRANSCRIPT_PATH"; +export const OPENCODE_IMPORT_MODEL_ID = "gpt-5.4-mini"; +export const OPENCODE_IMPORT_PROVIDER_ID = "openai"; +export const OPENCODE_IMPORT_AGENT = "build"; const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects"); function resolveUserPath(cwd, value) { @@ -42,3 +46,250 @@ export function resolveClaudeSessionPath(cwd, options = {}) { } return source; } + +function shorten(text, limit = 80) { + const normalized = String(text ?? "").trim().replace(/\s+/g, " "); + if (!normalized) { + return ""; + } + if (normalized.length <= limit) { + return normalized; + } + return `${normalized.slice(0, limit - 3)}...`; +} + +function kebab(text, limit = 48) { + const value = String(text ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, limit) + .replace(/-+$/g, ""); + return value || "claude-session"; +} + +function randomId(prefix) { + return `${prefix}_${randomBytes(9).toString("hex")}`; +} + +function joinTextParts(content) { + if (!Array.isArray(content)) { + return ""; + } + return content + .filter((part) => part?.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("\n"); +} + +function extractMessageText(role, content) { + if (role === "user") { + if (typeof content === "string") { + return content; + } + return joinTextParts(content); + } + if (role === "assistant") { + if (typeof content === "string") { + return content; + } + return joinTextParts(content); + } + return ""; +} + +function parseTimestamp(value) { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.trunc(value); + } + if (typeof value !== "string" || !value.trim()) { + return null; + } + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return Math.trunc(numeric); + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function timestampFromEntry(entry) { + return ( + parseTimestamp(entry?.timestamp) ?? + parseTimestamp(entry?.created_at) ?? + parseTimestamp(entry?.createdAt) ?? + parseTimestamp(entry?.time?.created) ?? + parseTimestamp(entry?.message?.timestamp) ?? + parseTimestamp(entry?.message?.created_at) ?? + parseTimestamp(entry?.message?.createdAt) ?? + parseTimestamp(entry?.message?.time?.created) ?? + null + ); +} + +function nextTimestamp(entry, state) { + let value = timestampFromEntry(entry); + if (value == null) { + value = state.fallbackTime + state.fallbackOffset; + state.fallbackOffset += 1; + } + if (value <= state.lastTime) { + value = state.lastTime + 1; + } + state.lastTime = value; + return value; +} + +function parseClaudeJsonl(jsonl) { + const entries = []; + const lines = String(jsonl ?? "").split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (!line) { + continue; + } + try { + entries.push(JSON.parse(line)); + } catch (error) { + throw new Error(`Could not parse Claude transcript JSONL line ${index + 1}: ${error.message}`); + } + } + return entries; +} + +function buildTextPart(text, sessionID, messageID, partID) { + return { + type: "text", + text, + id: partID, + sessionID, + messageID + }; +} + +function buildUserMessage(text, time, sessionID, messageID, partID) { + return { + info: { + role: "user", + time: { created: time }, + agent: OPENCODE_IMPORT_AGENT, + model: { providerID: OPENCODE_IMPORT_PROVIDER_ID, modelID: OPENCODE_IMPORT_MODEL_ID }, + summary: { diffs: [] }, + id: messageID, + sessionID + }, + parts: [buildTextPart(text, sessionID, messageID, partID)] + }; +} + +function buildAssistantMessage(text, time, sessionID, messageID, partID, parentID, cwd) { + return { + info: { + parentID, + role: "assistant", + mode: OPENCODE_IMPORT_AGENT, + agent: OPENCODE_IMPORT_AGENT, + path: { cwd, root: cwd }, + cost: 0, + tokens: { + total: 0, + input: 0, + output: 0, + reasoning: 0, + cache: { write: 0, read: 0 } + }, + modelID: OPENCODE_IMPORT_MODEL_ID, + providerID: OPENCODE_IMPORT_PROVIDER_ID, + time: { created: time, completed: time }, + finish: "stop", + id: messageID, + sessionID + }, + parts: [buildTextPart(text, sessionID, messageID, partID)] + }; +} + +export function buildOpenCodeImportDocumentFromClaudeJsonl(jsonl, options = {}) { + const cwd = options.cwd ?? process.cwd(); + const version = options.version ?? "unknown"; + const idFactory = options.idFactory ?? randomId; + const sessionID = options.sessionID ?? idFactory("ses"); + const timeState = { + fallbackTime: Number.isFinite(options.fallbackTime) ? Math.trunc(options.fallbackTime) : Date.now(), + fallbackOffset: 0, + lastTime: -Infinity + }; + const messages = []; + let firstUserText = ""; + let latestUserMessageID = null; + + for (const entry of parseClaudeJsonl(jsonl)) { + const role = entry?.message?.role; + if (role !== "user" && role !== "assistant") { + continue; + } + + const text = extractMessageText(role, entry.message.content); + if (!text.trim()) { + continue; + } + + // `opencode import` requires an assistant `parentID` string; drop assistant + // turns that precede the first user message (a null parentID fails the whole + // import). Real Claude transcripts open with a user turn, so this only skips + // orphaned leading assistant content. + if (role === "assistant" && !latestUserMessageID) { + continue; + } + + const time = nextTimestamp(entry, timeState); + const messageID = idFactory("msg"); + const partID = idFactory("prt"); + if (role === "user") { + if (!firstUserText) { + firstUserText = text; + } + messages.push(buildUserMessage(text, time, sessionID, messageID, partID)); + latestUserMessageID = messageID; + } else { + messages.push(buildAssistantMessage(text, time, sessionID, messageID, partID, latestUserMessageID, cwd)); + } + } + + if (messages.length === 0) { + throw new Error("Claude transcript did not contain any importable user or assistant text messages."); + } + + const title = shorten(firstUserText || messages[0].parts[0].text, 80) || "Claude session"; + return { + info: { + id: sessionID, + slug: kebab(title), + projectID: "global", + directory: cwd, + path: "", + title, + agent: OPENCODE_IMPORT_AGENT, + model: { + id: OPENCODE_IMPORT_MODEL_ID, + providerID: OPENCODE_IMPORT_PROVIDER_ID, + variant: "default" + }, + version, + summary: { additions: 0, deletions: 0, files: 0 }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 } + }, + time: { + created: messages[0].info.time.created, + updated: messages[messages.length - 1].info.time.created + } + }, + messages + }; +} diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index d4675e7..b888660 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -1,7 +1,11 @@ -import { readJsonFile } from "./fs.mjs"; +import fs from "node:fs"; +import path from "node:path"; + +import { buildOpenCodeImportDocumentFromClaudeJsonl } from "./claude-session-transfer.mjs"; +import { createTempDir, readJsonFile, writeJsonFile } from "./fs.mjs"; import { OpencodeServerClient } from "./opencode-server.mjs"; import { SERVER_URL_ENV, ensureServer, loadServerSession } from "./server-lifecycle.mjs"; -import { binaryAvailable } from "./process.mjs"; +import { binaryAvailable, runCommandChecked } from "./process.mjs"; const TASK_SESSION_PREFIX = "OpenCode Companion Task"; const DEFAULT_CONTINUE_PROMPT = @@ -948,8 +952,53 @@ export async function findLatestTaskThread(cwd) { }); } -export async function importExternalAgentSession() { - throw new Error("OpenCode transfer is not implemented in Phase 1."); +function parseOpenCodeVersion(output) { + const tokens = String(output ?? "").trim().split(/\s+/).filter(Boolean); + return tokens[tokens.length - 1] ?? "unknown"; +} + +function parseImportedSessionId(output) { + return String(output ?? "").match(/Imported session:\s*(ses_[A-Za-z0-9]+)/)?.[1] ?? null; +} + +export async function importExternalAgentSession(cwd, options = {}) { + if (!options.sourcePath) { + throw new Error("Missing Claude session source path for OpenCode transfer."); + } + + const versionResult = runCommandChecked("opencode", ["--version"], { + cwd, + env: options.env + }); + const version = parseOpenCodeVersion(versionResult.stdout || versionResult.stderr); + const transcript = fs.readFileSync(options.sourcePath, "utf8"); + const document = buildOpenCodeImportDocumentFromClaudeJsonl(transcript, { + cwd, + version, + idFactory: options.idFactory, + fallbackTime: options.fallbackTime + }); + + const tempDir = createTempDir("opencode-transfer-"); + const importPath = path.join(tempDir, "claude-session-import.json"); + try { + writeJsonFile(importPath, document); + const importResult = runCommandChecked("opencode", ["import", importPath], { + cwd, + env: options.env, + maxBuffer: 1024 * 1024 * 10 + }); + const threadId = parseImportedSessionId(`${importResult.stdout}\n${importResult.stderr}`); + if (!threadId) { + throw new Error("OpenCode import completed without reporting an imported session id."); + } + return { + threadId, + resumeCommand: `opencode --session ${threadId}` + }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } } export function buildPersistentTaskThreadName(prompt) { diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index b620d0d..359d005 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -556,7 +556,7 @@ async function executeTransfer(cwd, options = {}) { const result = await importExternalAgentSession(cwd, { sourcePath }); const payload = { threadId: result.threadId, - resumeCommand: `opencode --session ${result.threadId}`, + resumeCommand: result.resumeCommand ?? `opencode --session ${result.threadId}`, sourcePath, sessionId: path.basename(sourcePath, ".jsonl") }; diff --git a/tests/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs index 32356ee..06ad42a 100644 --- a/tests/fake-opencode-fixture.mjs +++ b/tests/fake-opencode-fixture.mjs @@ -47,6 +47,7 @@ function loadState() { nextMessageId: 1, sessions: [], messages: [], + imports: [], permissions: [], lastAbort: null }; @@ -209,6 +210,35 @@ function handleSessionListCli() { } } +function handleImportCli(filePath) { + if (!filePath) { + console.error("missing import file"); + process.exit(1); + } + const document = JSON.parse(fs.readFileSync(filePath, "utf8")); + const state = loadState(); + const sessionID = "ses_" + state.nextSessionId++; + const session = { + id: sessionID, + directory: document.info && document.info.directory || process.cwd(), + title: document.info && document.info.title || null, + agent: document.info && document.info.agent || null, + model: document.info && document.info.model || null, + imported: true + }; + const record = { + sourcePath: path.resolve(filePath), + sessionID, + document + }; + state.sessions.unshift(session); + state.imports = state.imports || []; + state.imports.push(record); + state.lastImport = record; + saveState(state); + console.log("Imported session: " + sessionID); +} + const args = process.argv.slice(2); if (args[0] === "--version") { console.log("opencode 1.17.10-test"); @@ -222,6 +252,10 @@ if (args[0] === "session" && args[1] === "list") { handleSessionListCli(); process.exit(0); } +if (args[0] === "import") { + handleImportCli(args[1]); + process.exit(0); +} if (args[0] !== "serve") { process.exit(1); } diff --git a/tests/transfer.test.mjs b/tests/transfer.test.mjs new file mode 100644 index 0000000..e094278 --- /dev/null +++ b/tests/transfer.test.mjs @@ -0,0 +1,166 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; + +import { buildOpenCodeImportDocumentFromClaudeJsonl } from "../plugins/opencode/scripts/lib/claude-session-transfer.mjs"; +import { buildEnv, installFakeOpencode, readFakeState } from "./fake-opencode-fixture.mjs"; +import { makeTempDir, run } from "./helpers.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const PLUGIN_ROOT = path.join(ROOT, "plugins", "opencode"); +const SCRIPT = path.join(PLUGIN_ROOT, "scripts", "opencode-companion.mjs"); + +function sequentialIds() { + let next = 0; + return (prefix) => `${prefix}_${++next}`; +} + +function sampleClaudeJsonl() { + return [ + JSON.stringify({ type: "metadata", message: { id: "ignored" } }), + JSON.stringify({ + timestamp: "2026-01-01T00:00:00.000Z", + message: { role: "user", content: "Investigate the failure" } + }), + JSON.stringify({ + timestamp: "2026-01-01T00:00:00.000Z", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "hidden reasoning" }, + { type: "text", text: "The bug is in parser." }, + { type: "tool_use", name: "Read", input: { file_path: "parser.js" } } + ] + } + }), + JSON.stringify({ + message: { + role: "user", + content: [ + { type: "text", text: "Please fix" }, + { type: "image", source: {} }, + { type: "text", text: "and test" } + ] + } + }), + JSON.stringify({ + timestamp: "2025-12-31T23:59:59.000Z", + message: { role: "assistant", content: [{ type: "text", text: "Fixed and tested." }] } + }) + ].join("\n"); +} + +test("Claude JSONL converts to a well-formed OpenCode import document", () => { + const doc = buildOpenCodeImportDocumentFromClaudeJsonl(sampleClaudeJsonl(), { + cwd: "/tmp/project", + version: "1.17.10-test", + idFactory: sequentialIds(), + fallbackTime: 1000 + }); + + assert.equal(doc.info.id, "ses_1"); + assert.equal(doc.info.projectID, "global"); + assert.equal(doc.info.directory, "/tmp/project"); + assert.equal(doc.info.title, "Investigate the failure"); + assert.equal(doc.info.slug, "investigate-the-failure"); + assert.equal(doc.info.agent, "build"); + assert.deepEqual(doc.info.model, { + id: "gpt-5.4-mini", + providerID: "openai", + variant: "default" + }); + assert.equal(doc.info.version, "1.17.10-test"); + + assert.deepEqual(doc.messages.map((message) => message.info.role), [ + "user", + "assistant", + "user", + "assistant" + ]); + assert.deepEqual(doc.messages.map((message) => message.parts[0].text), [ + "Investigate the failure", + "The bug is in parser.", + "Please fix\nand test", + "Fixed and tested." + ]); + + const times = doc.messages.map((message) => message.info.time.created); + assert.ok(times.every((time, index) => index === 0 || time > times[index - 1])); + assert.equal(doc.info.time.created, times[0]); + assert.equal(doc.info.time.updated, times[times.length - 1]); + + assert.equal(doc.messages[1].info.parentID, doc.messages[0].info.id); + assert.equal(doc.messages[3].info.parentID, doc.messages[2].info.id); + for (const message of doc.messages) { + assert.equal(message.info.sessionID, doc.info.id); + assert.equal(message.parts.length, 1); + assert.equal(message.parts[0].sessionID, doc.info.id); + assert.equal(message.parts[0].messageID, message.info.id); + } +}); + +test("converter drops assistant turns before the first user message (no null parentID)", () => { + const jsonl = [ + JSON.stringify({ message: { role: "assistant", content: [{ type: "text", text: "orphan leading reply" }] } }), + JSON.stringify({ message: { role: "user", content: "first user turn" } }), + JSON.stringify({ message: { role: "assistant", content: [{ type: "text", text: "grounded reply" }] } }) + ].join("\n"); + + const doc = buildOpenCodeImportDocumentFromClaudeJsonl(jsonl, { + cwd: "/tmp/project", + version: "t", + idFactory: sequentialIds(), + fallbackTime: 1000 + }); + + // The leading assistant is dropped (opencode import rejects a null parentID). + assert.deepEqual(doc.messages.map((message) => message.info.role), ["user", "assistant"]); + assert.equal(doc.info.title, "first user turn"); + for (const message of doc.messages) { + if (message.info.role === "assistant") { + assert.equal(typeof message.info.parentID, "string"); + assert.ok(message.info.parentID.length > 0); + } + } +}); + +test("transfer imports a Claude transcript and prints an OpenCode resume command", () => { + const repo = makeTempDir(); + const home = makeTempDir("opencode-plugin-home-"); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + + const claudeProjects = path.join(home, ".claude", "projects", "-tmp-project"); + fs.mkdirSync(claudeProjects, { recursive: true }); + const transcriptPath = path.join(claudeProjects, "session-123.jsonl"); + fs.writeFileSync(transcriptPath, sampleClaudeJsonl(), "utf8"); + + const env = buildEnv(binDir, { + HOME: home, + OPENCODE_COMPANION_TRANSCRIPT_PATH: transcriptPath + }); + const result = run("node", [SCRIPT, "transfer"], { + cwd: repo, + env + }); + + assert.equal(result.status, 0, result.stderr); + const match = result.stdout.match(/OpenCode session ID: (ses_[A-Za-z0-9]+)/); + assert.ok(match, result.stdout); + const importedSessionID = match[1]; + assert.match(result.stdout, new RegExp(`Resume in OpenCode: opencode --session ${importedSessionID}`)); + + const fakeState = readFakeState(binDir); + assert.equal(fakeState.imports.length, 1); + assert.equal(fakeState.lastImport.sessionID, importedSessionID); + assert.equal(fakeState.lastImport.document.info.directory, fs.realpathSync(repo)); + assert.equal(fakeState.lastImport.document.info.version, "1.17.10-test"); + assert.deepEqual(fakeState.lastImport.document.messages.map((message) => message.info.role), [ + "user", + "assistant", + "user", + "assistant" + ]); +}); From ab3917dbe4635324e632f1816ea5b675f0de571b Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Wed, 8 Jul 2026 03:58:17 +0200 Subject: [PATCH 05/32] =?UTF-8?q?fix:=20transfer=20=E2=80=94=20skip=20side?= =?UTF-8?q?chain/subagent=20entries;=20harden=20import=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Codex's independent Phase 3 review (second pass): - Skip `isSidechain` transcript entries in the converter — Claude Code interleaves subagent turns into the same JSONL, and importing them would pollute the transferred main conversation and mis-thread assistant `parentID`s. Live-verified: subagent content no longer leaks. - Fake `opencode import` now enforces the real importer's constraints (session `slug` present; assistant `parentID` a string) so a converter schema regression fails the end-to-end test. npm test 36/36. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VT7Nv9u8ckVJ5vRxzvegiw --- .../scripts/lib/claude-session-transfer.mjs | 6 ++++++ tests/fake-opencode-fixture.mjs | 12 +++++++++++ tests/transfer.test.mjs | 20 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/plugins/opencode/scripts/lib/claude-session-transfer.mjs b/plugins/opencode/scripts/lib/claude-session-transfer.mjs index e756dad..d0a4ea4 100644 --- a/plugins/opencode/scripts/lib/claude-session-transfer.mjs +++ b/plugins/opencode/scripts/lib/claude-session-transfer.mjs @@ -225,6 +225,12 @@ export function buildOpenCodeImportDocumentFromClaudeJsonl(jsonl, options = {}) let latestUserMessageID = null; for (const entry of parseClaudeJsonl(jsonl)) { + // Skip sidechain/subagent turns — Claude Code interleaves them into the same + // transcript, and importing them would pollute the main conversation and + // mis-thread assistant `parentID`s onto subagent messages. + if (entry?.isSidechain === true) { + continue; + } const role = entry?.message?.role; if (role !== "user" && role !== "assistant") { continue; diff --git a/tests/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs index 06ad42a..6665142 100644 --- a/tests/fake-opencode-fixture.mjs +++ b/tests/fake-opencode-fixture.mjs @@ -216,6 +216,18 @@ function handleImportCli(filePath) { process.exit(1); } const document = JSON.parse(fs.readFileSync(filePath, "utf8")); + // Mirror the real importer's strict validation for the constraints confirmed + // against a live server, so a converter schema regression fails the e2e test. + if (!document.info || typeof document.info.slug !== "string" || !document.info.slug) { + console.error("import validation failed: missing session slug"); + process.exit(1); + } + for (const message of document.messages || []) { + if (message.info && message.info.role === "assistant" && typeof message.info.parentID !== "string") { + console.error("import validation failed: assistant parentID must be a string"); + process.exit(1); + } + } const state = loadState(); const sessionID = "ses_" + state.nextSessionId++; const session = { diff --git a/tests/transfer.test.mjs b/tests/transfer.test.mjs index e094278..0bf3d9f 100644 --- a/tests/transfer.test.mjs +++ b/tests/transfer.test.mjs @@ -126,6 +126,26 @@ test("converter drops assistant turns before the first user message (no null par } }); +test("converter skips sidechain/subagent entries", () => { + const jsonl = [ + JSON.stringify({ message: { role: "user", content: "main user turn" } }), + JSON.stringify({ isSidechain: true, message: { role: "user", content: "subagent prompt" } }), + JSON.stringify({ isSidechain: true, message: { role: "assistant", content: [{ type: "text", text: "subagent reply" }] } }), + JSON.stringify({ message: { role: "assistant", content: [{ type: "text", text: "main assistant reply" }] } }) + ].join("\n"); + + const doc = buildOpenCodeImportDocumentFromClaudeJsonl(jsonl, { + cwd: "/tmp/project", + version: "t", + idFactory: sequentialIds(), + fallbackTime: 1000 + }); + + assert.deepEqual(doc.messages.map((message) => message.parts[0].text), ["main user turn", "main assistant reply"]); + // The main assistant must thread to the MAIN user, not the skipped sidechain user. + assert.equal(doc.messages[1].info.parentID, doc.messages[0].info.id); +}); + test("transfer imports a Claude transcript and prints an OpenCode resume command", () => { const repo = makeTempDir(); const home = makeTempDir("opencode-plugin-home-"); From 68570a0c810d0ef6e2c6b350e8c5ad76104577d5 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Wed, 8 Jul 2026 12:43:48 +0200 Subject: [PATCH 06/32] fix(#2): recover slow turns instead of failing on transport timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turn was gated on Promise.all([completion, sendMessage]). The /message POST is held open for the whole turn, so on long turns (e.g. a routine 6-min review) it hits undici's ~300s fetch timeout and rejects with `fetch failed` ~before `session.idle` arrives — failing a turn the server actually completed. - Drive completion from the event stream; treat the prompt request's transport failure as non-fatal, but still fail fast on a real HTTP rejection (bad request/model). - Add OpencodeServerClient.listMessages and recover the finished assistant message over HTTP when the turn ends without a captured result. - Cap runaway turns with a generous outer timeout (review finding #17) and clean up the timer. - Fixture: serve GET /session/:id/message and simulate a mid-turn transport drop; add two regression tests that the old Promise.all path fails. Refs #2 Co-Authored-By: Claude Opus 4.8 --- .../opencode/scripts/lib/opencode-server.mjs | 6 + plugins/opencode/scripts/lib/opencode.mjs | 117 ++++++++++++++++-- tests/fake-opencode-fixture.mjs | 21 +++- tests/runtime.test.mjs | 49 ++++++++ 4 files changed, 179 insertions(+), 14 deletions(-) diff --git a/plugins/opencode/scripts/lib/opencode-server.mjs b/plugins/opencode/scripts/lib/opencode-server.mjs index 02e63d0..169c1f9 100644 --- a/plugins/opencode/scripts/lib/opencode-server.mjs +++ b/plugins/opencode/scripts/lib/opencode-server.mjs @@ -182,6 +182,12 @@ export class OpencodeServerClient { return this.request("GET", "/session", { signal: options.signal }); } + listMessages(sessionID, options = {}) { + return this.request("GET", `/session/${encodePathSegment(sessionID)}/message`, { + signal: options.signal + }); + } + respondPermission(sessionID, permissionID, response = "always", options = {}) { return this.request("POST", `/session/${encodePathSegment(sessionID)}/permissions/${encodePathSegment(permissionID)}`, { body: { response }, diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index b888660..bf3aa17 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -3,7 +3,7 @@ import path from "node:path"; import { buildOpenCodeImportDocumentFromClaudeJsonl } from "./claude-session-transfer.mjs"; import { createTempDir, readJsonFile, writeJsonFile } from "./fs.mjs"; -import { OpencodeServerClient } from "./opencode-server.mjs"; +import { OpencodeHttpError, OpencodeServerClient } from "./opencode-server.mjs"; import { SERVER_URL_ENV, ensureServer, loadServerSession } from "./server-lifecycle.mjs"; import { binaryAvailable, runCommandChecked } from "./process.mjs"; @@ -13,6 +13,11 @@ const DEFAULT_CONTINUE_PROMPT = const MODEL_ALIASES = new Map([["spark", "openai/gpt-5.3-codex-spark"]]); const WRITE_AGENT = "build"; const READ_ONLY_AGENT = "plan"; +// A turn is normally completed by a `session.idle` event. This is only a +// last-resort ceiling so a dropped event stream can't hang the turn forever +// (issue #2 / review finding #17). Deep reviews legitimately run many minutes, +// so keep it generous; on expiry we still try to recover the final message. +const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -569,6 +574,34 @@ async function applyOpenCodeEvent(client, state, event, meta = {}) { } } +// When the turn's transport dies or the event stream drops before we captured a +// result, the OpenCode server may still hold the completed assistant message. +// Re-fetch it over HTTP so a slow-but-successful turn isn't reported as failed +// (issue #2). +async function recoverFinalMessageFromServer(client, state, options = {}) { + try { + const raw = await client.listMessages(state.sessionID, { signal: options.signal }); + const messages = Array.isArray(raw) ? raw : raw?.data ?? raw?.messages ?? []; + const assistant = messages + .filter((message) => (message?.info?.role ?? message?.role) === "assistant") + .pop(); + if (!assistant) { + return false; + } + state.messageID = assistant?.info?.id ?? assistant?.id ?? state.messageID; + applyMessageParts(state, assistant?.parts ?? [], state.sessionID); + if (state.finalMessage || state.structuredOutput != null) { + state.recovered = true; + completeTurn(state); + return true; + } + return false; + } catch (error) { + state.recoveryError = error; + return false; + } +} + async function captureTurn(client, sessionID, startRequest, options = {}) { const state = createTurnCaptureState(sessionID, options); const eventAbort = new AbortController(); @@ -579,6 +612,9 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { rejectOpen = reject; }); + // The event stream is the source of truth for turn completion. If it drops we + // record the error but do NOT fail the turn outright — the completed message + // may still be recoverable from the server (issue #2). const eventStream = client .subscribeEvents((event, meta) => applyOpenCodeEvent(client, state, event, meta), { signal: eventAbort.signal, @@ -588,10 +624,14 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { if (eventAbort.signal.aborted) { return; } + state.streamError = error; rejectOpen(error); - state.rejectCompletion(error); }); + const turnTimeoutMs = Math.max(0, Number(options.turnTimeoutMs) || DEFAULT_TURN_TIMEOUT_MS); + let timedOut = false; + let turnTimer = null; + try { await Promise.race([ opened, @@ -600,17 +640,66 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { }) ]); - const responsePromise = startRequest().then((response) => { - state.response = response; - state.messageID = response?.info?.id ?? response?.id ?? state.messageID; - applyMessageParts(state, response?.parts ?? [], state.sessionID); - scheduleResponseFallbackCompletion(state); - return response; - }); + // Send the prompt. The synchronous /message endpoint keeps this request open + // for the whole turn, so on long turns it can hit the client fetch timeout and + // reject with a transport error well before `session.idle` arrives. That + // transport failure must NOT fail the turn — only a real HTTP rejection (bad + // request, bad model, etc.) is fatal (issue #2 / findings #16, #17). + const responsePromise = startRequest(eventAbort.signal) + .then((response) => { + state.response = response; + state.messageID = response?.info?.id ?? response?.id ?? state.messageID; + applyMessageParts(state, response?.parts ?? [], state.sessionID); + scheduleResponseFallbackCompletion(state); + return response; + }) + .catch((error) => { + state.responseError = error; + // A server-side rejection is terminal; a transport error is not. + if (error instanceof OpencodeHttpError) { + state.error = error; + completeTurn(state); + } + return null; + }); + + // Complete on `session.idle` / `session.error` / the response fallback, on the + // stream closing, or on the outer safety timeout — whichever comes first. + const timeoutPromise = + turnTimeoutMs > 0 + ? new Promise((resolve) => { + turnTimer = setTimeout(() => { + timedOut = true; + resolve(); + }, turnTimeoutMs); + turnTimer.unref?.(); + }) + : new Promise(() => {}); + await Promise.race([state.completion, eventStream, timeoutPromise]); + + // If we didn't capture a usable result, pull the finished message straight + // from the server before giving up. + if (!state.error && (!state.completed || (!state.finalMessage && state.structuredOutput == null))) { + await recoverFinalMessageFromServer(client, state, { signal: eventAbort.signal }); + } + + // Nothing captured and no genuine error => surface the underlying cause. + if (!state.error && !state.completed && !state.finalMessage && state.structuredOutput == null) { + state.error = + state.responseError ?? + state.streamError ?? + new Error( + timedOut + ? "OpenCode turn timed out before completion." + : "OpenCode turn ended without a result." + ); + } - await Promise.all([state.completion, responsePromise]); return state; } finally { + if (turnTimer) { + clearTimeout(turnTimer); + } eventAbort.abort(); await eventStream.catch(() => {}); if (state.fallbackTimer) { @@ -873,7 +962,7 @@ export async function runServerTurn(cwd, options = {}) { const turnState = await captureTurn( client, sessionID, - () => + (signal) => client.sendMessage( sessionID, buildMessageParams(prompt, { @@ -882,11 +971,13 @@ export async function runServerTurn(cwd, options = {}) { outputSchema: options.outputSchema ?? null, write, agent - }) + }), + { signal } ), { onProgress: options.onProgress, - write + write, + turnTimeoutMs: options.turnTimeoutMs } ); diff --git a/tests/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs index 6665142..7a1ebb7 100644 --- a/tests/fake-opencode-fixture.mjs +++ b/tests/fake-opencode-fixture.mjs @@ -197,9 +197,22 @@ async function handleMessage(req, res, sessionID) { const parts = structuredOutputParts(body) || [{ type: "text", text: finalText }]; const finalState = loadState(); finalState.lastResponseParts = parts; + finalState.responses = finalState.responses || []; + finalState.responses.push({ sessionID, info: { id: messageID, role: "assistant", sessionID }, parts }); saveState(finalState); - emit({ type: "message.updated", sessionID, message: { id: messageID, parts } }); + // Issue #2 regression hooks. "transport": deliver the turn over the event + // stream but drop the /message HTTP response mid-flight (like undici timing + // out the held-open POST). "recover": additionally withhold message.updated so + // the client must re-fetch the finished message via GET /session/:id/message. + const failMode = process.env.FAKE_OPENCODE_MESSAGE_FAIL; + if (failMode !== "recover") { + emit({ type: "message.updated", sessionID, message: { id: messageID, parts } }); + } emit({ type: "session.idle", sessionID }); + if (failMode === "transport" || failMode === "recover") { + res.destroy(); + return; + } sendJson(res, { info: { id: messageID, sessionID }, parts }); } @@ -360,6 +373,12 @@ const server = http.createServer(async (req, res) => { } const messageMatch = url.pathname.match(/^\\/session\\/([^/]+)\\/message$/); + if (req.method === "GET" && messageMatch) { + const sessionID = decodeURIComponent(messageMatch[1]); + const responses = (loadState().responses || []).filter((entry) => entry.sessionID === sessionID); + sendJson(res, responses); + return; + } if (req.method === "POST" && messageMatch) { await handleMessage(req, res, decodeURIComponent(messageMatch[1])); return; diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index e5c6759..5e98470 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -282,3 +282,52 @@ test("review captures json_schema output from StructuredOutput tool input", { sk cleanupServer(repo, env); } }); + +test("task succeeds when the message transport drops after session.idle (issue #2)", { skip: LOCAL_LISTEN_SKIP }, () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + // Simulates a slow turn where the held-open /message POST dies (client fetch + // timeout) but session.idle still arrives — the exact failure that reported a + // completed review as `fetch failed`. + const env = buildTestEnv(binDir, { FAKE_OPENCODE_MESSAGE_FAIL: "transport" }); + + try { + const result = run("node", [SCRIPT, "task", "--json", "long running task"], { + cwd: repo, + env + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, 0); + assert.match(payload.rawOutput, /Handled the requested task/); + } finally { + cleanupServer(repo, env); + } +}); + +test("task recovers the final message over HTTP when only session.idle arrives (issue #2)", { skip: LOCAL_LISTEN_SKIP }, () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + // No message.updated event and a dropped POST response: the client must + // re-fetch the finished assistant message from the server to complete. + const env = buildTestEnv(binDir, { FAKE_OPENCODE_MESSAGE_FAIL: "recover" }); + + try { + const result = run("node", [SCRIPT, "task", "--json", "long running task"], { + cwd: repo, + env + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, 0); + assert.match(payload.rawOutput, /Handled the requested task/); + } finally { + cleanupServer(repo, env); + } +}); From f69ee419b9214bd0ec1a5dc78dec5227b9a727c9 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Wed, 8 Jul 2026 13:11:38 +0200 Subject: [PATCH 07/32] Fix issue #4 state races --- plugins/opencode/scripts/lib/render.mjs | 3 +- .../opencode/scripts/lib/server-lifecycle.mjs | 126 +++++++++-- plugins/opencode/scripts/lib/state.mjs | 207 ++++++++++++++++-- plugins/opencode/scripts/lib/tracked-jobs.mjs | 185 +++++++++++----- .../opencode/scripts/opencode-companion.mjs | 171 +++++++++++---- .../scripts/session-lifecycle-hook.mjs | 20 +- tests/server-lifecycle.test.mjs | 51 ++++- tests/state-races.test.mjs | 205 +++++++++++++++++ 8 files changed, 822 insertions(+), 146 deletions(-) create mode 100644 tests/state-races.test.mjs diff --git a/plugins/opencode/scripts/lib/render.mjs b/plugins/opencode/scripts/lib/render.mjs index 4b3af5a..e104ea6 100644 --- a/plugins/opencode/scripts/lib/render.mjs +++ b/plugins/opencode/scripts/lib/render.mjs @@ -446,10 +446,11 @@ export function renderStoredJobResult(job, storedJob) { } export function renderCancelReport(job) { + const cancelled = job.status === "cancelled"; const lines = [ "# OpenCode Cancel", "", - `Cancelled ${job.id}.`, + cancelled ? `Cancelled ${job.id}.` : `${job.id} is already ${job.status}; cancel was not applied.`, "" ]; diff --git a/plugins/opencode/scripts/lib/server-lifecycle.mjs b/plugins/opencode/scripts/lib/server-lifecycle.mjs index 6e93122..f8c575c 100644 --- a/plugins/opencode/scripts/lib/server-lifecycle.mjs +++ b/plugins/opencode/scripts/lib/server-lifecycle.mjs @@ -18,6 +18,7 @@ const SERVER_LOCK_INFO_FILE = "owner.json"; const DEFAULT_HOSTNAME = "127.0.0.1"; const DEFAULT_LOCK_STALE_MS = 30000; const DEFAULT_LOCK_POLL_MS = 100; +const DEFAULT_LEASE_TTL_MS = 6 * 60 * 60 * 1000; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -194,6 +195,47 @@ async function loadHealthyServerSession(cwd, healthTimeoutMs) { return null; } +function createServerLease(options = {}) { + const createdAt = new Date(); + const ttlMs = Math.max(1000, Number(options.leaseTtlMs) || DEFAULT_LEASE_TTL_MS); + return { + pid: process.pid, + token: `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`, + createdAt: createdAt.toISOString(), + expiresAt: new Date(createdAt.getTime() + ttlMs).toISOString() + }; +} + +function isLeaseActive(lease, nowMs = Date.now()) { + const expiresAt = Date.parse(lease?.expiresAt ?? ""); + if (Number.isFinite(expiresAt) && expiresAt <= nowMs) { + return false; + } + + const alive = processIsAlive(Number(lease?.pid)); + return alive !== false; +} + +function pruneServerLeases(session) { + const leases = Array.isArray(session?.leases) ? session.leases.filter((lease) => isLeaseActive(lease)) : []; + return { + ...session, + leases + }; +} + +function hasActiveServerLeases(session) { + return Array.isArray(session?.leases) && session.leases.some((lease) => isLeaseActive(lease)); +} + +function addServerLease(session, options = {}) { + const pruned = pruneServerLeases(session); + return { + ...pruned, + leases: [...pruned.leases, createServerLease(options)] + }; +} + async function acquireServerLock(cwd, options = {}) { const stateDir = resolveStateDir(cwd); fs.mkdirSync(stateDir, { recursive: true }); @@ -201,7 +243,6 @@ async function acquireServerLock(cwd, options = {}) { const lockDir = resolveServerLockDir(cwd); const staleMs = Math.max(1000, Number(options.lockStaleMs) || DEFAULT_LOCK_STALE_MS); const pollMs = Math.max(25, Number(options.lockPollMs) || DEFAULT_LOCK_POLL_MS); - const healthTimeoutMs = options.healthTimeoutMs ?? 500; const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; for (;;) { @@ -212,12 +253,6 @@ async function acquireServerLock(cwd, options = {}) { throw error; } - // The lock is held. Reuse an already-started healthy server if one exists. - const existing = await loadHealthyServerSession(cwd, healthTimeoutMs); - if (existing) { - return { session: existing, release: null }; - } - // If the holder looks stale, clear it via an atomic single-winner steal // (never a blind remove), then re-race the mkdir. if (isServerLockStale(lockDir, staleMs)) { @@ -320,20 +355,14 @@ export async function ensureServer(cwd, options = {}) { }; } - const existing = await loadHealthyServerSession(cwd, options.healthTimeoutMs ?? 500); - if (existing) { - return existing; - } - const lock = await acquireServerLock(cwd, options); - if (lock.session) { - return lock.session; - } try { const lockedExisting = await loadHealthyServerSession(cwd, options.healthTimeoutMs ?? 500); if (lockedExisting) { - return lockedExisting; + const leasedExisting = addServerLease(lockedExisting, options); + saveServerSession(cwd, leasedExisting); + return leasedExisting; } const staleExisting = loadServerSession(cwd); @@ -381,14 +410,15 @@ export async function ensureServer(cwd, options = {}) { sessionDir, external: false }; - saveServerSession(cwd, session); - return session; + const leasedSession = addServerLease(session, options); + saveServerSession(cwd, leasedSession); + return leasedSession; } finally { lock.release?.(); } } -export async function teardownServerSession({ +async function teardownServerSessionUnlocked({ url = null, pidFile = null, logFile = null, @@ -429,4 +459,62 @@ export async function teardownServerSession({ // Ignore non-empty or missing directories. } } + + return { skipped: false }; +} + +export async function teardownServerSession({ + cwd = null, + force = false, + url = null, + pidFile = null, + logFile = null, + sessionDir = null, + pid = null, + external = false, + killProcess = null +} = {}) { + if (!cwd) { + return teardownServerSessionUnlocked({ + url, + pidFile, + logFile, + sessionDir, + pid, + external, + killProcess + }); + } + + const lock = await acquireServerLock(cwd); + try { + const current = loadServerSession(cwd); + const currentUrl = normalizeUrl(current?.url); + const requestedUrl = normalizeUrl(url); + const session = current && (!requestedUrl || currentUrl === requestedUrl) ? current : null; + if (session && !force) { + const pruned = pruneServerLeases(session); + if (hasActiveServerLeases(pruned)) { + saveServerSession(cwd, pruned); + return { skipped: true, reason: "active-leases" }; + } + } + + const teardownTarget = { + url: session?.url ?? url, + pidFile: session?.pidFile ?? pidFile, + logFile: session?.logFile ?? logFile, + sessionDir: session?.sessionDir ?? sessionDir, + pid: session?.pid ?? pid, + external: Boolean(session?.external ?? external), + killProcess + }; + const result = await teardownServerSessionUnlocked(teardownTarget); + if (session) { + clearServerSession(cwd); + } + return result; + } finally { + lock.release?.(); + } } diff --git a/plugins/opencode/scripts/lib/state.mjs b/plugins/opencode/scripts/lib/state.mjs index 11fb00a..389a9fc 100644 --- a/plugins/opencode/scripts/lib/state.mjs +++ b/plugins/opencode/scripts/lib/state.mjs @@ -10,7 +10,12 @@ const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "opencode-companion"); const STATE_FILE_NAME = "state.json"; const JOBS_DIR_NAME = "jobs"; +const STATE_LOCK_DIR_NAME = "state.lock"; +const LOCK_INFO_FILE = "owner.json"; const MAX_JOBS = 50; +const DEFAULT_LOCK_STALE_MS = 30000; +const DEFAULT_LOCK_POLL_MS = 25; +const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)); function nowIso() { return new Date().toISOString(); @@ -51,11 +56,136 @@ export function resolveJobsDir(cwd) { return path.join(resolveStateDir(cwd), JOBS_DIR_NAME); } +function resolveStateLockDir(cwd) { + return path.join(resolveStateDir(cwd), STATE_LOCK_DIR_NAME); +} + export function ensureStateDir(cwd) { fs.mkdirSync(resolveJobsDir(cwd), { recursive: true }); } -export function loadState(cwd) { +function processIsAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) { + return null; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +function readLockInfo(lockDir) { + const infoFile = path.join(lockDir, LOCK_INFO_FILE); + try { + return JSON.parse(fs.readFileSync(infoFile, "utf8")); + } catch { + return {}; + } +} + +function lockAgeMs(lockDir, info) { + const created = Date.parse(info?.createdAt ?? ""); + if (Number.isFinite(created)) { + return Date.now() - created; + } + + try { + return Date.now() - fs.statSync(lockDir).mtimeMs; + } catch { + return 0; + } +} + +function isStateLockStale(lockDir, staleMs) { + if (!fs.existsSync(lockDir)) { + return true; + } + + const info = readLockInfo(lockDir); + if (processIsAlive(Number(info?.pid)) === false) { + return true; + } + + return lockAgeMs(lockDir, info) > staleMs; +} + +function removeStateLock(lockDir) { + try { + fs.rmSync(lockDir, { recursive: true, force: true }); + } catch { + // Another process may have removed or replaced the lock. + } +} + +function stealStaleStateLock(lockDir) { + const stalePath = `${lockDir}.stale-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + try { + fs.renameSync(lockDir, stalePath); + } catch { + return; + } + removeStateLock(stalePath); +} + +function releaseStateLock(lockDir, token) { + const info = readLockInfo(lockDir); + if (info?.token !== token) { + return; + } + removeStateLock(lockDir); +} + +function acquireStateLock(cwd, options = {}) { + const stateDir = resolveStateDir(cwd); + fs.mkdirSync(stateDir, { recursive: true }); + + const lockDir = resolveStateLockDir(cwd); + const staleMs = Math.max(1000, Number(options.lockStaleMs) || DEFAULT_LOCK_STALE_MS); + const pollMs = Math.max(10, Number(options.lockPollMs) || DEFAULT_LOCK_POLL_MS); + const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + + for (;;) { + try { + fs.mkdirSync(lockDir); + } catch (error) { + if (error?.code !== "EEXIST") { + throw error; + } + if (isStateLockStale(lockDir, staleMs)) { + stealStaleStateLock(lockDir); + } + Atomics.wait(sleepBuffer, 0, 0, pollMs); + continue; + } + + try { + fs.writeFileSync( + path.join(lockDir, LOCK_INFO_FILE), + `${JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }, null, 2)}\n`, + "utf8" + ); + } catch (error) { + removeStateLock(lockDir); + throw error; + } + + return () => releaseStateLock(lockDir, token); + } +} + +export function withStateLock(cwd, fn) { + const release = acquireStateLock(cwd); + try { + return fn(); + } finally { + release(); + } +} + +function loadStateUnlocked(cwd) { const stateFile = resolveStateFile(cwd); if (!fs.existsSync(stateFile)) { return defaultState(); @@ -77,6 +207,10 @@ export function loadState(cwd) { } } +export function loadState(cwd) { + return loadStateUnlocked(cwd); +} + function pruneJobs(jobs) { return [...jobs] .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""))) @@ -89,8 +223,24 @@ function removeFileIfExists(filePath) { } } -export function saveState(cwd, state) { - const previousJobs = loadState(cwd).jobs; +function atomicWriteFile(filePath, contents) { + const tempFile = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + try { + fs.writeFileSync(tempFile, contents, "utf8"); + fs.renameSync(tempFile, filePath); + } catch (error) { + try { + if (fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile); + } + } catch { + // Ignore cleanup failures for a best-effort temp file. + } + throw error; + } +} + +function saveStateUnlocked(cwd, state, previousJobs = loadStateUnlocked(cwd).jobs) { ensureStateDir(cwd); const nextJobs = pruneJobs(state.jobs ?? []); const nextState = { @@ -102,6 +252,8 @@ export function saveState(cwd, state) { jobs: nextJobs }; + atomicWriteFile(resolveStateFile(cwd), `${JSON.stringify(nextState, null, 2)}\n`); + const retainedIds = new Set(nextJobs.map((job) => job.id)); for (const job of previousJobs) { if (retainedIds.has(job.id)) { @@ -111,14 +263,37 @@ export function saveState(cwd, state) { removeFileIfExists(job.logFile); } - fs.writeFileSync(resolveStateFile(cwd), `${JSON.stringify(nextState, null, 2)}\n`, "utf8"); return nextState; } +export function saveState(cwd, state) { + return withStateLock(cwd, () => saveStateUnlocked(cwd, state)); +} + +export function applyJobPatch(state, jobPatch, timestamp = nowIso()) { + const existingIndex = state.jobs.findIndex((job) => job.id === jobPatch.id); + if (existingIndex === -1) { + state.jobs.unshift({ + createdAt: timestamp, + updatedAt: timestamp, + ...jobPatch + }); + return; + } + state.jobs[existingIndex] = { + ...state.jobs[existingIndex], + ...jobPatch, + updatedAt: timestamp + }; +} + export function updateState(cwd, mutate) { - const state = loadState(cwd); - mutate(state); - return saveState(cwd, state); + return withStateLock(cwd, () => { + const state = loadStateUnlocked(cwd); + const previousJobs = [...state.jobs]; + mutate(state); + return saveStateUnlocked(cwd, state, previousJobs); + }); } export function generateJobId(prefix = "job") { @@ -128,21 +303,7 @@ export function generateJobId(prefix = "job") { export function upsertJob(cwd, jobPatch) { return updateState(cwd, (state) => { - const timestamp = nowIso(); - const existingIndex = state.jobs.findIndex((job) => job.id === jobPatch.id); - if (existingIndex === -1) { - state.jobs.unshift({ - createdAt: timestamp, - updatedAt: timestamp, - ...jobPatch - }); - return; - } - state.jobs[existingIndex] = { - ...state.jobs[existingIndex], - ...jobPatch, - updatedAt: timestamp - }; + applyJobPatch(state, jobPatch); }); } @@ -166,7 +327,7 @@ export function getConfig(cwd) { export function writeJobFile(cwd, jobId, payload) { ensureStateDir(cwd); const jobFile = resolveJobFile(cwd, jobId); - fs.writeFileSync(jobFile, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + atomicWriteFile(jobFile, `${JSON.stringify(payload, null, 2)}\n`); return jobFile; } diff --git a/plugins/opencode/scripts/lib/tracked-jobs.mjs b/plugins/opencode/scripts/lib/tracked-jobs.mjs index d828831..c7dde7c 100644 --- a/plugins/opencode/scripts/lib/tracked-jobs.mjs +++ b/plugins/opencode/scripts/lib/tracked-jobs.mjs @@ -1,7 +1,14 @@ import fs from "node:fs"; import process from "node:process"; -import { readJobFile, resolveJobFile, resolveJobLogFile, upsertJob, writeJobFile } from "./state.mjs"; +import { + applyJobPatch, + readJobFile, + resolveJobFile, + resolveJobLogFile, + updateState, + writeJobFile +} from "./state.mjs"; export const SESSION_ID_ENV = "OPENCODE_COMPANION_SESSION_ID"; @@ -108,18 +115,7 @@ export function createJobProgressUpdater(workspaceRoot, jobId) { return; } - upsertJob(workspaceRoot, patch); - - const jobFile = resolveJobFile(workspaceRoot, jobId); - if (!fs.existsSync(jobFile)) { - return; - } - - const storedJob = readJobFile(jobFile); - writeJobFile(workspaceRoot, jobId, { - ...storedJob, - ...patch - }); + patchIndexedJobFile(workspaceRoot, jobId, patch); }; } @@ -148,6 +144,79 @@ function readStoredJobOrNull(workspaceRoot, jobId) { return readJobFile(jobFile); } +function currentStoredStatus(stateJob, storedJob) { + return storedJob?.status ?? stateJob?.status ?? null; +} + +function isTerminalStatus(status) { + return status === "completed" || status === "failed" || status === "cancelled"; +} + +function patchIndexedJobFile(workspaceRoot, jobId, patch) { + updateState(workspaceRoot, (state) => { + const stateJob = state.jobs.find((candidate) => candidate.id === jobId) ?? null; + const currentJob = readStoredJobOrNull(workspaceRoot, jobId); + if (isTerminalStatus(currentStoredStatus(stateJob, currentJob))) { + return; + } + + applyJobPatch(state, patch); + + const jobFile = resolveJobFile(workspaceRoot, jobId); + if (!fs.existsSync(jobFile)) { + return; + } + + const storedJob = readJobFile(jobFile); + writeJobFile(workspaceRoot, jobId, { + ...storedJob, + ...patch + }); + }); +} + +function writeIndexedJobFile(workspaceRoot, jobId, jobRecord, indexPatch = jobRecord, options = {}) { + let committed = false; + + updateState(workspaceRoot, (state) => { + if (options.skipTerminal) { + const stateJob = state.jobs.find((candidate) => candidate.id === jobId) ?? null; + const storedJob = readStoredJobOrNull(workspaceRoot, jobId); + if (isTerminalStatus(currentStoredStatus(stateJob, storedJob))) { + return; + } + } + + writeJobFile(workspaceRoot, jobId, jobRecord); + applyJobPatch(state, indexPatch); + committed = true; + }); + + return committed; +} + +function commitFinishedJob(workspaceRoot, jobId, buildUpdate) { + let committed = false; + let current = null; + + updateState(workspaceRoot, (state) => { + const stateJob = state.jobs.find((candidate) => candidate.id === jobId) ?? null; + const storedJob = readStoredJobOrNull(workspaceRoot, jobId); + current = storedJob ?? stateJob; + if (isTerminalStatus(currentStoredStatus(stateJob, storedJob))) { + return; + } + + const update = buildUpdate(storedJob, stateJob); + writeJobFile(workspaceRoot, jobId, update.jobRecord); + applyJobPatch(state, update.indexPatch); + current = update.jobRecord; + committed = true; + }); + + return { committed, current }; +} + export async function runTrackedJob(job, runner, options = {}) { const runningRecord = { ...job, @@ -158,58 +227,64 @@ export async function runTrackedJob(job, runner, options = {}) { logFile: options.logFile ?? job.logFile ?? null, serverUrl: options.serverUrl ?? job.serverUrl ?? null }; - writeJobFile(job.workspaceRoot, job.id, runningRecord); - upsertJob(job.workspaceRoot, runningRecord); + const started = writeIndexedJobFile(job.workspaceRoot, job.id, runningRecord, runningRecord, { skipTerminal: true }); + if (!started) { + throw new Error(`Job ${job.id} is already finished.`); + } try { const execution = await runner(); const completionStatus = execution.exitStatus === 0 ? "completed" : "failed"; const completedAt = nowIso(); - writeJobFile(job.workspaceRoot, job.id, { - ...runningRecord, - status: completionStatus, - threadId: execution.threadId ?? null, - turnId: execution.turnId ?? null, - serverUrl: execution.serverUrl ?? runningRecord.serverUrl ?? null, - pid: null, - phase: completionStatus === "completed" ? "done" : "failed", - completedAt, - result: execution.payload, - rendered: execution.rendered + const finished = commitFinishedJob(job.workspaceRoot, job.id, (storedJob) => { + const serverUrl = execution.serverUrl ?? runningRecord.serverUrl ?? storedJob?.serverUrl ?? null; + const indexPatch = { + id: job.id, + status: completionStatus, + threadId: execution.threadId ?? null, + turnId: execution.turnId ?? null, + serverUrl, + summary: execution.summary, + phase: completionStatus === "completed" ? "done" : "failed", + pid: null, + completedAt + }; + return { + jobRecord: { + ...runningRecord, + ...(storedJob ?? {}), + ...indexPatch, + result: execution.payload, + rendered: execution.rendered + }, + indexPatch + }; }); - upsertJob(job.workspaceRoot, { - id: job.id, - status: completionStatus, - threadId: execution.threadId ?? null, - turnId: execution.turnId ?? null, - serverUrl: execution.serverUrl ?? runningRecord.serverUrl ?? null, - summary: execution.summary, - phase: completionStatus === "completed" ? "done" : "failed", - pid: null, - completedAt - }); - appendLogBlock(options.logFile ?? job.logFile ?? null, "Final output", execution.rendered); + if (finished.committed) { + appendLogBlock(options.logFile ?? job.logFile ?? null, "Final output", execution.rendered); + } return execution; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - const existing = readStoredJobOrNull(job.workspaceRoot, job.id) ?? runningRecord; const completedAt = nowIso(); - writeJobFile(job.workspaceRoot, job.id, { - ...existing, - status: "failed", - phase: "failed", - errorMessage, - pid: null, - completedAt, - logFile: options.logFile ?? job.logFile ?? existing.logFile ?? null - }); - upsertJob(job.workspaceRoot, { - id: job.id, - status: "failed", - phase: "failed", - pid: null, - errorMessage, - completedAt + commitFinishedJob(job.workspaceRoot, job.id, (storedJob) => { + const existing = storedJob ?? runningRecord; + const indexPatch = { + id: job.id, + status: "failed", + phase: "failed", + pid: null, + errorMessage, + completedAt + }; + return { + jobRecord: { + ...existing, + ...indexPatch, + logFile: options.logFile ?? job.logFile ?? existing.logFile ?? null + }, + indexPatch + }; }); throw error; } diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index 359d005..854661c 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -27,11 +27,14 @@ import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from " import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; import { + applyJobPatch, generateJobId, getConfig, listJobs, setConfig, + updateState, upsertJob, + withStateLock, writeJobFile } from "./lib/state.mjs"; import { @@ -890,6 +893,100 @@ function handleTaskResumeCandidate(argv) { outputCommandResult(payload, rendered, options.json); } +function isCompletionTerminalStatus(status) { + return status === "completed" || status === "failed"; +} + +function terminalJobIndexPatch(job) { + return Object.fromEntries( + Object.entries({ + id: job.id, + status: job.status, + phase: job.phase ?? (job.status === "completed" ? "done" : "failed"), + pid: null, + threadId: job.threadId, + turnId: job.turnId, + serverUrl: job.serverUrl, + summary: job.summary, + errorMessage: job.errorMessage, + completedAt: job.completedAt + }).filter(([, value]) => value !== undefined) + ); +} + +function syncTerminalJobIndex(workspaceRoot, job) { + updateState(workspaceRoot, (state) => { + applyJobPatch(state, terminalJobIndexPatch(job)); + }); + return { + ...job, + pid: null + }; +} + +function readCurrentCancelJob(workspaceRoot, job) { + let currentJob = job; + withStateLock(workspaceRoot, () => { + const stateJob = listJobs(workspaceRoot).find((candidate) => candidate.id === job.id) ?? null; + const storedJob = readStoredJob(workspaceRoot, job.id); + currentJob = { + ...job, + ...(stateJob ?? {}), + ...(storedJob ?? {}) + }; + }); + return currentJob; +} + +function cancelJobIfStillActive(workspaceRoot, job, completedAt) { + let cancelled = false; + let nextJob = job; + + updateState(workspaceRoot, (state) => { + const stateJob = state.jobs.find((candidate) => candidate.id === job.id) ?? null; + const storedJob = readStoredJob(workspaceRoot, job.id); + const currentJob = { + ...job, + ...(stateJob ?? {}), + ...(storedJob ?? {}) + }; + + if (isCompletionTerminalStatus(currentJob.status)) { + applyJobPatch(state, terminalJobIndexPatch(currentJob)); + nextJob = { + ...currentJob, + pid: null + }; + return; + } + + nextJob = { + ...currentJob, + status: "cancelled", + phase: "cancelled", + pid: null, + completedAt, + errorMessage: "Cancelled by user." + }; + + writeJobFile(workspaceRoot, job.id, { + ...nextJob, + cancelledAt: completedAt + }); + applyJobPatch(state, { + id: job.id, + status: "cancelled", + phase: "cancelled", + pid: null, + errorMessage: "Cancelled by user.", + completedAt + }); + cancelled = true; + }); + + return { cancelled, job: nextJob }; +} + async function handleCancel(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["cwd"], @@ -899,52 +996,50 @@ async function handleCancel(argv) { const cwd = resolveCommandCwd(options); const reference = positionals[0] ?? ""; const { workspaceRoot, job } = resolveCancelableJob(cwd, reference, { env: process.env }); - const existing = readStoredJob(workspaceRoot, job.id) ?? {}; - const threadId = existing.threadId ?? job.threadId ?? null; - const turnId = existing.turnId ?? job.turnId ?? null; - const serverUrl = existing.serverUrl ?? job.serverUrl ?? null; + const currentJob = readCurrentCancelJob(workspaceRoot, job); + + if (isCompletionTerminalStatus(currentJob.status)) { + const syncedJob = syncTerminalJobIndex(workspaceRoot, currentJob); + const payload = { + jobId: syncedJob.id, + status: syncedJob.status, + title: syncedJob.title, + cancelled: false, + turnInterruptAttempted: false, + turnInterrupted: false + }; - const interrupt = await interruptServerTurn(cwd, { threadId, turnId, serverUrl }); - if (interrupt.attempted) { - appendLogLine( - job.logFile, - interrupt.interrupted - ? `Requested OpenCode session abort for ${threadId}.` - : `OpenCode session abort failed${interrupt.detail ? `: ${interrupt.detail}` : "."}` - ); + outputCommandResult(payload, renderCancelReport(syncedJob), options.json); + return; } - terminateProcessTree(job.pid ?? Number.NaN); - appendLogLine(job.logFile, "Cancelled by user."); + const threadId = currentJob.threadId ?? null; + const turnId = currentJob.turnId ?? null; + const serverUrl = currentJob.serverUrl ?? null; + const interrupt = await interruptServerTurn(cwd, { threadId, turnId, serverUrl }); const completedAt = nowIso(); - const nextJob = { - ...job, - status: "cancelled", - phase: "cancelled", - pid: null, - completedAt, - errorMessage: "Cancelled by user." - }; + const cancelResult = cancelJobIfStillActive(workspaceRoot, currentJob, completedAt); + if (cancelResult.cancelled) { + if (interrupt.attempted) { + appendLogLine( + cancelResult.job.logFile, + interrupt.interrupted + ? `Requested OpenCode session abort for ${threadId}.` + : `OpenCode session abort failed${interrupt.detail ? `: ${interrupt.detail}` : "."}` + ); + } + terminateProcessTree(currentJob.pid ?? Number.NaN); + appendLogLine(cancelResult.job.logFile, "Cancelled by user."); + } - writeJobFile(workspaceRoot, job.id, { - ...existing, - ...nextJob, - cancelledAt: completedAt - }); - upsertJob(workspaceRoot, { - id: job.id, - status: "cancelled", - phase: "cancelled", - pid: null, - errorMessage: "Cancelled by user.", - completedAt - }); + const nextJob = cancelResult.job; const payload = { - jobId: job.id, - status: "cancelled", - title: job.title, + jobId: nextJob.id, + status: nextJob.status, + title: nextJob.title, + cancelled: cancelResult.cancelled, turnInterruptAttempted: interrupt.attempted, turnInterrupted: interrupt.interrupted }; diff --git a/plugins/opencode/scripts/session-lifecycle-hook.mjs b/plugins/opencode/scripts/session-lifecycle-hook.mjs index 814cf48..2ab72c0 100644 --- a/plugins/opencode/scripts/session-lifecycle-hook.mjs +++ b/plugins/opencode/scripts/session-lifecycle-hook.mjs @@ -12,7 +12,7 @@ import { loadServerSession, teardownServerSession } from "./lib/server-lifecycle.mjs"; -import { loadState, resolveStateFile, saveState } from "./lib/state.mjs"; +import { resolveStateFile, updateState } from "./lib/state.mjs"; import { TRANSCRIPT_PATH_ENV } from "./lib/claude-session-transfer.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; @@ -49,8 +49,11 @@ function cleanupSessionJobs(cwd, sessionId) { return; } - const state = loadState(workspaceRoot); - const removedJobs = state.jobs.filter((job) => job.sessionId === sessionId); + let removedJobs = []; + updateState(workspaceRoot, (state) => { + removedJobs = state.jobs.filter((job) => job.sessionId === sessionId); + state.jobs = state.jobs.filter((job) => job.sessionId !== sessionId); + }); if (removedJobs.length === 0) { return; } @@ -67,10 +70,6 @@ function cleanupSessionJobs(cwd, sessionId) { } } - saveState(workspaceRoot, { - ...state, - jobs: state.jobs.filter((job) => job.sessionId !== sessionId) - }); } function handleSessionStart(input) { @@ -93,7 +92,8 @@ async function handleSessionEnd(input) { : null); cleanupSessionJobs(cwd, input.session_id || process.env[SESSION_ID_ENV]); - await teardownServerSession({ + const teardown = await teardownServerSession({ + cwd, url: serverSession?.url ?? null, pidFile: serverSession?.pidFile ?? null, logFile: serverSession?.logFile ?? null, @@ -102,7 +102,9 @@ async function handleSessionEnd(input) { external: Boolean(serverSession?.external), killProcess: terminateProcessTree }); - clearServerSession(cwd); + if (!teardown?.skipped) { + clearServerSession(cwd); + } } async function main() { diff --git a/tests/server-lifecycle.test.mjs b/tests/server-lifecycle.test.mjs index c483727..56f9f2e 100644 --- a/tests/server-lifecycle.test.mjs +++ b/tests/server-lifecycle.test.mjs @@ -3,7 +3,8 @@ import net from "node:net"; import test from "node:test"; import assert from "node:assert/strict"; -import { isServerHealthy } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; +import { makeTempDir } from "./helpers.mjs"; +import { isServerHealthy, loadServerSession, saveServerSession, teardownServerSession } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; async function canListenLocalhost() { return new Promise((resolve) => { @@ -51,3 +52,51 @@ test( } } ); + +test("teardownServerSession skips local teardown while a server lease is active", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + + try { + const session = { + url: "http://127.0.0.1:1", + pid: 123456, + pidFile: null, + logFile: null, + sessionDir: null, + external: false, + leases: [ + { + pid: process.pid, + token: "test-lease", + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60000).toISOString() + } + ] + }; + saveServerSession(workspace, session); + + let killedPid = null; + const result = await teardownServerSession({ + cwd: workspace, + url: session.url, + pid: session.pid, + killProcess: (pid) => { + killedPid = pid; + } + }); + + assert.equal(result.skipped, true); + assert.equal(result.reason, "active-leases"); + assert.equal(killedPid, null); + assert.equal(loadServerSession(workspace).url, session.url); + } finally { + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } +}); diff --git a/tests/state-races.test.mjs b/tests/state-races.test.mjs new file mode 100644 index 0000000..0fc88a0 --- /dev/null +++ b/tests/state-races.test.mjs @@ -0,0 +1,205 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { makeTempDir, run } from "./helpers.mjs"; +import { runTrackedJob } from "../plugins/opencode/scripts/lib/tracked-jobs.mjs"; +import { + applyJobPatch, + loadState, + readJobFile, + resolveJobFile, + resolveJobLogFile, + saveState, + updateState, + writeJobFile +} from "../plugins/opencode/scripts/lib/state.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const STATE_MODULE_URL = pathToFileURL(path.join(ROOT, "plugins", "opencode", "scripts", "lib", "state.mjs")).href; +const COMPANION = path.join(ROOT, "plugins", "opencode", "scripts", "opencode-companion.mjs"); + +async function withPluginData(pluginDataDir, fn) { + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + try { + return await fn(); + } finally { + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } +} + +function spawnUpsert({ workspace, pluginDataDir, jobId }) { + const childScript = ` + import { upsertJob } from ${JSON.stringify(STATE_MODULE_URL)}; + upsertJob(process.env.TEST_WORKSPACE, { + id: process.env.TEST_JOB_ID, + status: "running", + title: process.env.TEST_JOB_ID + }); + `; + const child = spawn(process.execPath, ["--input-type=module", "-e", childScript], { + env: { + ...process.env, + CLAUDE_PLUGIN_DATA: pluginDataDir, + TEST_WORKSPACE: workspace, + TEST_JOB_ID: jobId + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("close", (status) => { + resolve({ status, stdout, stderr }); + }); + }); +} + +test("concurrent upsertJob calls preserve independent job records", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const jobIds = Array.from({ length: 12 }, (_, index) => `job-${index}`); + + const results = await Promise.all(jobIds.map((jobId) => spawnUpsert({ workspace, pluginDataDir, jobId }))); + assert.deepEqual( + results.map((result) => result.status), + jobIds.map(() => 0), + results.map((result) => result.stderr).join("\n") + ); + + await withPluginData(pluginDataDir, async () => { + const indexedIds = loadState(workspace) + .jobs.map((job) => job.id) + .sort(); + assert.deepEqual(indexedIds, [...jobIds].sort()); + }); +}); + +test("runTrackedJob completion does not overwrite a cancelled job", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + + await withPluginData(pluginDataDir, async () => { + const jobId = "job-cancelled-before-completion"; + const logFile = resolveJobLogFile(workspace, jobId); + const job = { + id: jobId, + workspaceRoot: workspace, + title: "Race task", + status: "queued", + logFile + }; + + await runTrackedJob( + job, + async () => { + const completedAt = new Date().toISOString(); + const cancelledRecord = { + ...job, + status: "cancelled", + phase: "cancelled", + pid: null, + completedAt, + errorMessage: "Cancelled by user." + }; + updateState(workspace, (state) => { + writeJobFile(workspace, jobId, cancelledRecord); + applyJobPatch(state, { + id: jobId, + status: "cancelled", + phase: "cancelled", + pid: null, + errorMessage: "Cancelled by user.", + completedAt + }); + }); + return { + exitStatus: 0, + payload: { late: true }, + rendered: "late result\n", + summary: "late result" + }; + }, + { logFile } + ); + + const storedJob = readJobFile(resolveJobFile(workspace, jobId)); + const indexedJob = loadState(workspace).jobs.find((candidate) => candidate.id === jobId); + assert.equal(storedJob.status, "cancelled"); + assert.equal(storedJob.result, undefined); + assert.equal(indexedJob.status, "cancelled"); + assert.equal(indexedJob.summary, undefined); + }); +}); + +test("cancel does not clobber a completed stored job when the state index is stale active", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + + await withPluginData(pluginDataDir, async () => { + const jobId = "job-completed-on-disk"; + const timestamp = new Date().toISOString(); + const logFile = resolveJobLogFile(workspace, jobId); + saveState(workspace, { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: jobId, + status: "running", + phase: "running", + pid: process.pid, + title: "Completed on disk", + logFile, + createdAt: timestamp, + updatedAt: timestamp + } + ] + }); + writeJobFile(workspace, jobId, { + id: jobId, + workspaceRoot: workspace, + status: "completed", + phase: "done", + pid: null, + title: "Completed on disk", + logFile, + completedAt: timestamp, + result: { ok: true }, + rendered: "done\n" + }); + + const result = run(process.execPath, [COMPANION, "cancel", jobId, "--cwd", workspace, "--json"], { + env: { + ...process.env, + CLAUDE_PLUGIN_DATA: pluginDataDir + } + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, "completed"); + assert.equal(payload.cancelled, false); + + const storedJob = readJobFile(resolveJobFile(workspace, jobId)); + const indexedJob = loadState(workspace).jobs.find((candidate) => candidate.id === jobId); + assert.equal(storedJob.status, "completed"); + assert.deepEqual(storedJob.result, { ok: true }); + assert.equal(indexedJob.status, "completed"); + assert.equal(indexedJob.phase, "done"); + }); +}); From e1315b4c688e8646c54abeb82d1e657063e8aefb Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Wed, 8 Jul 2026 13:24:01 +0200 Subject: [PATCH 08/32] fix: address issue #2 transport review fixes --- plugins/opencode/scripts/lib/opencode.mjs | 61 +++++++++++++++-- tests/fake-opencode-fixture.mjs | 18 ++++- tests/runtime.test.mjs | 82 +++++++++++++++++++++++ 3 files changed, 153 insertions(+), 8 deletions(-) diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index bf3aa17..c01955b 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -235,6 +235,27 @@ function extractMessageParts(value) { return Array.isArray(parts) ? parts : []; } +function getMessagesArray(response) { + if (Array.isArray(response)) { + return response; + } + if (Array.isArray(response?.data)) { + return response.data; + } + if (Array.isArray(response?.messages)) { + return response.messages; + } + return []; +} + +function isAssistantMessage(message) { + return (message?.info?.role ?? message?.role) === "assistant"; +} + +function extractMessageId(message) { + return message?.info?.id ?? message?.id ?? null; +} + function textFromContent(content) { if (typeof content === "string") { return content; @@ -340,12 +361,16 @@ function createTurnCaptureState(sessionID, options = {}) { completion, resolveCompletion, rejectCompletion, + priorAssistantIds: new Set(options.priorAssistantIds ?? []), finalMessage: "", structuredOutput: null, reasoningSummary: [], touchedFiles: new Set(), commandExecutions: [], error: null, + streamError: null, + responseError: null, + recoveryError: null, response: null, fallbackTimer: null, onProgress: options.onProgress ?? null, @@ -581,15 +606,20 @@ async function applyOpenCodeEvent(client, state, event, meta = {}) { async function recoverFinalMessageFromServer(client, state, options = {}) { try { const raw = await client.listMessages(state.sessionID, { signal: options.signal }); - const messages = Array.isArray(raw) ? raw : raw?.data ?? raw?.messages ?? []; - const assistant = messages - .filter((message) => (message?.info?.role ?? message?.role) === "assistant") - .pop(); + const messages = getMessagesArray(raw).filter(isAssistantMessage); + const assistant = state.messageID + ? messages.find((message) => extractMessageId(message) === state.messageID) + : messages + .filter((message) => { + const messageID = extractMessageId(message); + return messageID && !state.priorAssistantIds.has(messageID); + }) + .pop(); if (!assistant) { return false; } - state.messageID = assistant?.info?.id ?? assistant?.id ?? state.messageID; - applyMessageParts(state, assistant?.parts ?? [], state.sessionID); + state.messageID = extractMessageId(assistant) ?? state.messageID; + applyMessageParts(state, extractMessageParts(assistant), state.sessionID); if (state.finalMessage || state.structuredOutput != null) { state.recovered = true; completeTurn(state); @@ -602,6 +632,20 @@ async function recoverFinalMessageFromServer(client, state, options = {}) { } } +async function snapshotPriorAssistantIds(client, state, options = {}) { + try { + const raw = await client.listMessages(state.sessionID, { signal: options.signal }); + state.priorAssistantIds = new Set( + getMessagesArray(raw) + .filter(isAssistantMessage) + .map(extractMessageId) + .filter(Boolean) + ); + } catch { + state.priorAssistantIds = new Set(); + } +} + async function captureTurn(client, sessionID, startRequest, options = {}) { const state = createTurnCaptureState(sessionID, options); const eventAbort = new AbortController(); @@ -640,6 +684,8 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { }) ]); + await snapshotPriorAssistantIds(client, state, { signal: eventAbort.signal }); + // Send the prompt. The synchronous /message endpoint keeps this request open // for the whole turn, so on long turns it can hit the client fetch timeout and // reject with a transport error well before `session.idle` arrives. That @@ -684,8 +730,9 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { } // Nothing captured and no genuine error => surface the underlying cause. - if (!state.error && !state.completed && !state.finalMessage && state.structuredOutput == null) { + if (!state.error && !state.finalMessage && state.structuredOutput == null) { state.error = + state.recoveryError ?? state.responseError ?? state.streamError ?? new Error( diff --git a/tests/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs index 7a1ebb7..03619fe 100644 --- a/tests/fake-opencode-fixture.mjs +++ b/tests/fake-opencode-fixture.mjs @@ -195,6 +195,13 @@ async function handleMessage(req, res, sessionID) { ? "Resumed the prior OpenCode run.\\nFollow-up prompt accepted." : "Handled the requested task.\\nTask prompt accepted."; const parts = structuredOutputParts(body) || [{ type: "text", text: finalText }]; + const failMode = process.env.FAKE_OPENCODE_MESSAGE_FAIL; + if (failMode === "empty-recovery") { + emit({ type: "session.idle", sessionID }); + res.destroy(); + return; + } + const finalState = loadState(); finalState.lastResponseParts = parts; finalState.responses = finalState.responses || []; @@ -204,7 +211,16 @@ async function handleMessage(req, res, sessionID) { // stream but drop the /message HTTP response mid-flight (like undici timing // out the held-open POST). "recover": additionally withhold message.updated so // the client must re-fetch the finished message via GET /session/:id/message. - const failMode = process.env.FAKE_OPENCODE_MESSAGE_FAIL; + // "delayed-events" drops the POST before completion events arrive, matching + // the real failure ordering seen in issue #2 review. + if (failMode === "delayed-events") { + res.destroy(); + setTimeout(() => { + emit({ type: "message.updated", sessionID, message: { id: messageID, parts } }); + emit({ type: "session.idle", sessionID }); + }, 25); + return; + } if (failMode !== "recover") { emit({ type: "message.updated", sessionID, message: { id: messageID, parts } }); } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 5e98470..df620ff 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -308,6 +308,30 @@ test("task succeeds when the message transport drops after session.idle (issue # } }); +test("task succeeds when the message transport drops before completion events (issue #2)", { skip: LOCAL_LISTEN_SKIP }, () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + // Reproduces the real ordering where the held-open /message POST fails first, + // then the event stream later delivers the final message and session.idle. + const env = buildTestEnv(binDir, { FAKE_OPENCODE_MESSAGE_FAIL: "delayed-events" }); + + try { + const result = run("node", [SCRIPT, "task", "--json", "long running task"], { + cwd: repo, + env + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, 0); + assert.match(payload.rawOutput, /Handled the requested task/); + } finally { + cleanupServer(repo, env); + } +}); + test("task recovers the final message over HTTP when only session.idle arrives (issue #2)", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); @@ -331,3 +355,61 @@ test("task recovers the final message over HTTP when only session.idle arrives ( cleanupServer(repo, env); } }); + +test("task fails when completion has no recoverable current-turn message (issue #2)", { skip: LOCAL_LISTEN_SKIP }, () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + fs.writeFileSync( + path.join(binDir, "fake-opencode-state.json"), + JSON.stringify( + { + serverStarts: 0, + nextSessionId: 2, + nextMessageId: 2, + sessions: [ + { + id: "ses_existing", + directory: repo, + title: "OpenCode Companion Task: prior fixture task", + agent: "plan", + model: null, + permission: [] + } + ], + messages: [], + responses: [ + { + sessionID: "ses_existing", + info: { id: "msg_1", role: "assistant", sessionID: "ses_existing" }, + parts: [{ type: "text", text: "Prior stale assistant message." }] + } + ], + imports: [], + permissions: [], + lastAbort: null + }, + null, + 2 + ) + ); + const env = buildTestEnv(binDir, { + FAKE_OPENCODE_MESSAGE_FAIL: "empty-recovery", + OPENCODE_COMPANION_SESSION_ID: "" + }); + + try { + const result = run("node", [SCRIPT, "task", "--json", "--resume-last", "follow up with no output"], { + cwd: repo, + env + }); + + assert.notEqual(result.status, 0, result.stdout); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, 1); + assert.equal(payload.rawOutput, ""); + } finally { + cleanupServer(repo, env); + } +}); From 8ff877b752a0db92ed8c92611a025078c1691791 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Wed, 8 Jul 2026 13:36:24 +0200 Subject: [PATCH 09/32] test(#2): fix resume regression test to match realpath'd workspace The "no recoverable current-turn message" test seeded the server session's directory with the raw makeTempDir() path. On macOS that is a /var symlink, but findLatestTaskThread matches against the realpath'd workspace root, so --resume-last threw "No previous session" before the turn ran (empty stdout), masking the actual Fix-1/Fix-2 behavior. Seed the realpath instead. Co-Authored-By: Claude Opus 4.8 --- tests/runtime.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index df620ff..a25fb60 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -371,7 +371,9 @@ test("task fails when completion has no recoverable current-turn message (issue sessions: [ { id: "ses_existing", - directory: repo, + // realpath: makeTempDir returns a symlinked /var path on macOS, but + // findLatestTaskThread matches against the realpath'd workspace root. + directory: fs.realpathSync(repo), title: "OpenCode Companion Task: prior fixture task", agent: "plan", model: null, From 38bc9f7c26b88fac735de7b55d639ded6e1cc35a Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Wed, 8 Jul 2026 13:39:55 +0200 Subject: [PATCH 10/32] Fix issue #4 lease dedupe review --- .../opencode/scripts/lib/server-lifecycle.mjs | 3 +- tests/server-lifecycle.test.mjs | 45 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/plugins/opencode/scripts/lib/server-lifecycle.mjs b/plugins/opencode/scripts/lib/server-lifecycle.mjs index f8c575c..779720d 100644 --- a/plugins/opencode/scripts/lib/server-lifecycle.mjs +++ b/plugins/opencode/scripts/lib/server-lifecycle.mjs @@ -230,9 +230,10 @@ function hasActiveServerLeases(session) { function addServerLease(session, options = {}) { const pruned = pruneServerLeases(session); + const withoutSelf = pruned.leases.filter((lease) => Number(lease?.pid) !== process.pid); return { ...pruned, - leases: [...pruned.leases, createServerLease(options)] + leases: [...withoutSelf, createServerLease(options)] }; } diff --git a/tests/server-lifecycle.test.mjs b/tests/server-lifecycle.test.mjs index 56f9f2e..6619ba4 100644 --- a/tests/server-lifecycle.test.mjs +++ b/tests/server-lifecycle.test.mjs @@ -4,7 +4,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { makeTempDir } from "./helpers.mjs"; -import { isServerHealthy, loadServerSession, saveServerSession, teardownServerSession } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; +import { ensureServer, isServerHealthy, loadServerSession, saveServerSession, teardownServerSession } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; async function canListenLocalhost() { return new Promise((resolve) => { @@ -53,6 +53,49 @@ test( } ); +test("ensureServer keeps a single lease for repeated calls from the same process", async () => { + const url = "http://127.0.0.1:1"; + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + const previousFetch = globalThis.fetch; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + globalThis.fetch = async (requestUrl) => { + assert.equal(String(requestUrl), `${url}/global/health`); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" } + }); + }; + + try { + saveServerSession(workspace, { + url, + pid: 123456, + pidFile: null, + logFile: null, + sessionDir: null, + external: false + }); + + const first = await ensureServer(workspace); + const second = await ensureServer(workspace); + const stored = loadServerSession(workspace); + + assert.equal(first.leases.length, 1); + assert.equal(second.leases.length, 1); + assert.equal(stored.leases.length, 1); + assert.equal(stored.leases[0].pid, process.pid); + } finally { + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + globalThis.fetch = previousFetch; + } +}); + test("teardownServerSession skips local teardown while a server lease is active", async () => { const workspace = makeTempDir(); const pluginDataDir = makeTempDir(); From 322429f7a14f9bed6456239a3a4d5303f066221e Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Wed, 8 Jul 2026 14:58:04 +0200 Subject: [PATCH 11/32] Fix correctness bugs from issue #3 --- .../scripts/lib/claude-session-transfer.mjs | 9 +- .../opencode/scripts/opencode-companion.mjs | 88 +++++++++-- .../scripts/stop-review-gate-hook.mjs | 2 +- tests/commands.test.mjs | 5 + tests/companion-correctness.test.mjs | 147 ++++++++++++++++++ tests/transfer.test.mjs | 20 +++ 6 files changed, 255 insertions(+), 16 deletions(-) create mode 100644 tests/companion-correctness.test.mjs diff --git a/plugins/opencode/scripts/lib/claude-session-transfer.mjs b/plugins/opencode/scripts/lib/claude-session-transfer.mjs index d0a4ea4..77f3edb 100644 --- a/plugins/opencode/scripts/lib/claude-session-transfer.mjs +++ b/plugins/opencode/scripts/lib/claude-session-transfer.mjs @@ -222,7 +222,7 @@ export function buildOpenCodeImportDocumentFromClaudeJsonl(jsonl, options = {}) }; const messages = []; let firstUserText = ""; - let latestUserMessageID = null; + let lastMessageID = null; for (const entry of parseClaudeJsonl(jsonl)) { // Skip sidechain/subagent turns — Claude Code interleaves them into the same @@ -245,7 +245,7 @@ export function buildOpenCodeImportDocumentFromClaudeJsonl(jsonl, options = {}) // turns that precede the first user message (a null parentID fails the whole // import). Real Claude transcripts open with a user turn, so this only skips // orphaned leading assistant content. - if (role === "assistant" && !latestUserMessageID) { + if (role === "assistant" && !lastMessageID) { continue; } @@ -257,9 +257,10 @@ export function buildOpenCodeImportDocumentFromClaudeJsonl(jsonl, options = {}) firstUserText = text; } messages.push(buildUserMessage(text, time, sessionID, messageID, partID)); - latestUserMessageID = messageID; + lastMessageID = messageID; } else { - messages.push(buildAssistantMessage(text, time, sessionID, messageID, partID, latestUserMessageID, cwd)); + messages.push(buildAssistantMessage(text, time, sessionID, messageID, partID, lastMessageID, cwd)); + lastMessageID = messageID; } } diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index 854661c..1fc01fd 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -71,7 +71,6 @@ const REVIEW_SCHEMA = path.join(ROOT_DIR, "schemas", "review-output.schema.json" const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000; const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000; const MODEL_ALIASES = new Map([["spark", "openai/gpt-5.3-codex-spark"]]); -const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; function printUsage() { console.log( @@ -277,8 +276,7 @@ function findLatestResumableTaskJob(jobs) { (job) => job.jobClass === "task" && job.threadId && - job.status !== "queued" && - job.status !== "running" + job.status === "completed" ) ?? null ); } @@ -392,7 +390,8 @@ async function executeTaskRun(request) { const taskMetadata = buildTaskRunMetadata({ prompt: request.prompt, - resumeLast: request.resumeLast + resumeLast: request.resumeLast, + stopReview: request.stopReview }); let resumeThreadId = null; @@ -467,8 +466,8 @@ function buildReviewJobMetadata(reviewName, target) { }; } -function buildTaskRunMetadata({ prompt, resumeLast = false }) { - if (!resumeLast && String(prompt ?? "").includes(STOP_REVIEW_TASK_MARKER)) { +function buildTaskRunMetadata({ prompt, resumeLast = false, stopReview = false }) { + if (!resumeLast && stopReview) { return { title: "OpenCode Stop Gate Review", summary: "Stop-gate review of previous Claude turn" @@ -531,7 +530,7 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) { }); } -function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }) { +function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, stopReview, jobId }) { return { cwd, model, @@ -539,6 +538,7 @@ function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId prompt, write, resumeLast, + stopReview, jobId }; } @@ -611,22 +611,84 @@ function spawnDetachedTaskWorker(cwd, jobId) { return child; } +function markQueuedTaskSpawned(workspaceRoot, jobId, pid) { + if (pid == null) { + return; + } + + updateState(workspaceRoot, (state) => { + const storedJob = readStoredJob(workspaceRoot, jobId); + if (storedJob?.status === "completed" || storedJob?.status === "failed" || storedJob?.status === "cancelled") { + return; + } + + if (storedJob) { + writeJobFile(workspaceRoot, jobId, { + ...storedJob, + pid + }); + } + applyJobPatch(state, { + id: jobId, + pid + }); + }); +} + +function markQueuedTaskSpawnFailed(workspaceRoot, jobId, logFile, error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const completedAt = nowIso(); + appendLogLine(logFile, `Failed to start background task worker: ${errorMessage}`); + + updateState(workspaceRoot, (state) => { + const storedJob = readStoredJob(workspaceRoot, jobId); + const failedJob = { + ...(storedJob ?? { id: jobId }), + status: "failed", + phase: "failed", + pid: null, + completedAt, + errorMessage + }; + writeJobFile(workspaceRoot, jobId, failedJob); + applyJobPatch(state, { + id: jobId, + status: "failed", + phase: "failed", + pid: null, + completedAt, + errorMessage + }); + }); +} + function enqueueBackgroundTask(cwd, job, request) { const { logFile } = createTrackedProgress(job); appendLogLine(logFile, "Queued for background execution."); - const child = spawnDetachedTaskWorker(cwd, job.id); const queuedRecord = { ...job, status: "queued", phase: "queued", - pid: child.pid ?? null, + pid: null, logFile, request }; writeJobFile(job.workspaceRoot, job.id, queuedRecord); upsertJob(job.workspaceRoot, queuedRecord); + let child; + try { + child = spawnDetachedTaskWorker(cwd, job.id); + } catch (error) { + markQueuedTaskSpawnFailed(job.workspaceRoot, job.id, logFile, error); + throw error; + } + child.once("error", (error) => { + markQueuedTaskSpawnFailed(job.workspaceRoot, job.id, logFile, error); + }); + markQueuedTaskSpawned(job.workspaceRoot, job.id, child.pid ?? null); + return { payload: { jobId: job.id, @@ -691,7 +753,7 @@ async function handleReview(argv) { async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["model", "effort", "cwd", "prompt-file"], - booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], + booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background", "stop-review"], aliasMap: { m: "model" } @@ -709,9 +771,11 @@ async function handleTask(argv) { throw new Error("Choose either --resume/--resume-last or --fresh."); } const write = Boolean(options.write); + const stopReview = Boolean(options["stop-review"]); const taskMetadata = buildTaskRunMetadata({ prompt, - resumeLast + resumeLast, + stopReview }); if (options.background) { @@ -730,6 +794,7 @@ async function handleTask(argv) { prompt, write, resumeLast, + stopReview, jobId: job.id }); const { payload } = enqueueBackgroundTask(cwd, job, request); @@ -748,6 +813,7 @@ async function handleTask(argv) { prompt, write, resumeLast, + stopReview, jobId: job.id, onProgress: progress }), diff --git a/plugins/opencode/scripts/stop-review-gate-hook.mjs b/plugins/opencode/scripts/stop-review-gate-hook.mjs index 9c3343f..24ced33 100644 --- a/plugins/opencode/scripts/stop-review-gate-hook.mjs +++ b/plugins/opencode/scripts/stop-review-gate-hook.mjs @@ -102,7 +102,7 @@ function runStopReview(cwd, input = {}) { ...process.env, ...(input.session_id ? { [SESSION_ID_ENV]: input.session_id } : {}) }; - const result = spawnSync(process.execPath, [scriptPath, "task", "--json", prompt], { + const result = spawnSync(process.execPath, [scriptPath, "task", "--json", "--stop-review", prompt], { cwd, env: childEnv, encoding: "utf8", diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 6008233..ffb5b09 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -76,3 +76,8 @@ test("hooks keep session-end cleanup and stop gating enabled", () => { assert.match(source, /session-lifecycle-hook\.mjs/); assert.match(source, /OpenCode Companion/); }); + +test("stop review hook passes an explicit task classification flag", () => { + const source = read("scripts/stop-review-gate-hook.mjs"); + assert.match(source, /"task", "--json", "--stop-review", prompt/); +}); diff --git a/tests/companion-correctness.test.mjs b/tests/companion-correctness.test.mjs new file mode 100644 index 0000000..a0483cd --- /dev/null +++ b/tests/companion-correctness.test.mjs @@ -0,0 +1,147 @@ +import path from "node:path"; +import process from "node:process"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; + +import { listJobs, saveState } from "../plugins/opencode/scripts/lib/state.mjs"; +import { makeTempDir, run } from "./helpers.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SCRIPT = path.join(ROOT, "plugins", "opencode", "scripts", "opencode-companion.mjs"); +const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; + +function withPluginData(pluginData, fn) { + const previousPluginData = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginData; + try { + return fn(); + } finally { + if (previousPluginData == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginData; + } + } +} + +function writeJobState(cwd, pluginData, jobs) { + withPluginData(pluginData, () => { + saveState(cwd, { + version: 1, + config: { stopReviewGate: false }, + jobs + }); + }); +} + +function readJobState(cwd, pluginData) { + return withPluginData(pluginData, () => listJobs(cwd)); +} + +function runTaskWithoutOpenCode(args) { + const cwd = makeTempDir(); + const pluginData = makeTempDir("opencode-plugin-data-"); + const emptyPath = makeTempDir("opencode-plugin-empty-path-"); + const env = { + ...process.env, + PATH: emptyPath, + CLAUDE_PLUGIN_DATA: pluginData, + OPENCODE_COMPANION_SESSION_ID: "" + }; + const result = run(process.execPath, [SCRIPT, "task", "--json", ...args], { + cwd, + env + }); + + return { + result, + jobs: readJobState(cwd, pluginData) + }; +} + +test("task resume candidate ignores failed and cancelled task jobs", () => { + const cwd = makeTempDir(); + const pluginData = makeTempDir("opencode-plugin-data-"); + const env = { + ...process.env, + CLAUDE_PLUGIN_DATA: pluginData, + OPENCODE_COMPANION_SESSION_ID: "" + }; + const jobs = [ + { + id: "task-cancelled", + jobClass: "task", + status: "cancelled", + title: "Cancelled task", + threadId: "ses_cancelled", + createdAt: "2026-01-03T00:00:00.000Z", + updatedAt: "2026-01-03T00:00:00.000Z" + }, + { + id: "task-failed", + jobClass: "task", + status: "failed", + title: "Failed task", + threadId: "ses_failed", + createdAt: "2026-01-02T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z" + }, + { + id: "task-completed", + jobClass: "task", + status: "completed", + title: "Completed task", + threadId: "ses_completed", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z" + } + ]; + + writeJobState(cwd, pluginData, jobs); + const result = run(process.execPath, [SCRIPT, "task-resume-candidate", "--json"], { + cwd, + env + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.available, true); + assert.equal(payload.candidate.id, "task-completed"); + assert.equal(payload.candidate.threadId, "ses_completed"); + + writeJobState( + cwd, + pluginData, + jobs.filter((job) => job.status !== "completed") + ); + const noCandidate = run(process.execPath, [SCRIPT, "task-resume-candidate", "--json"], { + cwd, + env + }); + + assert.equal(noCandidate.status, 0, noCandidate.stderr); + assert.equal(JSON.parse(noCandidate.stdout).available, false); +}); + +test("task prompt marker text does not classify as stop review without the explicit flag", () => { + const { result, jobs } = runTaskWithoutOpenCode([`${STOP_REVIEW_TASK_MARKER} Please handle this normal task.`]); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /OpenCode CLI is not installed/); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].status, "failed"); + assert.equal(jobs[0].title, "OpenCode Task"); + assert.match(jobs[0].summary, /Run a stop-gate review/); +}); + +test("task stop review classification comes from the explicit flag", () => { + const { result, jobs } = runTaskWithoutOpenCode(["--stop-review", STOP_REVIEW_TASK_MARKER]); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /OpenCode CLI is not installed/); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].status, "failed"); + assert.equal(jobs[0].title, "OpenCode Stop Gate Review"); + assert.equal(jobs[0].summary, "Stop-gate review of previous Claude turn"); +}); diff --git a/tests/transfer.test.mjs b/tests/transfer.test.mjs index 0bf3d9f..6fb0d4c 100644 --- a/tests/transfer.test.mjs +++ b/tests/transfer.test.mjs @@ -146,6 +146,26 @@ test("converter skips sidechain/subagent entries", () => { assert.equal(doc.messages[1].info.parentID, doc.messages[0].info.id); }); +test("converter threads consecutive assistant messages after the previous message", () => { + const jsonl = [ + JSON.stringify({ message: { role: "user", content: "main user turn" } }), + JSON.stringify({ message: { role: "assistant", content: [{ type: "text", text: "first assistant reply" }] } }), + JSON.stringify({ message: { role: "assistant", content: [{ type: "text", text: "second assistant reply" }] } }) + ].join("\n"); + + const doc = buildOpenCodeImportDocumentFromClaudeJsonl(jsonl, { + cwd: "/tmp/project", + version: "t", + idFactory: sequentialIds(), + fallbackTime: 1000 + }); + + assert.deepEqual(doc.messages.map((message) => message.info.role), ["user", "assistant", "assistant"]); + assert.equal(doc.messages[1].info.parentID, doc.messages[0].info.id); + assert.equal(doc.messages[2].info.parentID, doc.messages[1].info.id); + assert.equal(typeof doc.messages[2].info.parentID, "string"); +}); + test("transfer imports a Claude transcript and prints an OpenCode resume command", () => { const repo = makeTempDir(); const home = makeTempDir("opencode-plugin-home-"); From 7ac899a2bb6b5a84d43f848eb9cd633f7e5a8110 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Wed, 8 Jul 2026 15:15:19 +0200 Subject: [PATCH 12/32] Fix resource leaks (#5) --- plugins/opencode/scripts/lib/opencode.mjs | 60 ++++--- .../opencode/scripts/opencode-companion.mjs | 12 ++ tests/runtime.test.mjs | 149 ++++++++++++++++++ tests/state-races.test.mjs | 64 ++++++++ tests/transfer.test.mjs | 36 +++++ 5 files changed, 300 insertions(+), 21 deletions(-) diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index c01955b..1014963 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -978,6 +978,7 @@ export async function runServerTurn(cwd, options = {}) { }); let sessionID = options.resumeThreadId ?? options.resumeSessionId ?? null; + let createdSessionID = null; if (sessionID) { emitProgress(options.onProgress, `Resuming OpenCode session ${sessionID}.`, "starting", { @@ -1000,33 +1001,46 @@ export async function runServerTurn(cwd, options = {}) { if (!sessionID) { throw new Error("OpenCode did not return a session id."); } + createdSessionID = sessionID; emitProgress(options.onProgress, `Session ready (${sessionID}).`, "starting", { threadId: sessionID, serverUrl: server.url }); } - const turnState = await captureTurn( - client, - sessionID, - (signal) => - client.sendMessage( - sessionID, - buildMessageParams(prompt, { - model: options.model, - variant: options.variant ?? options.effort ?? null, - outputSchema: options.outputSchema ?? null, - write, - agent - }), - { signal } - ), - { - onProgress: options.onProgress, - write, - turnTimeoutMs: options.turnTimeoutMs + let turnState; + try { + turnState = await captureTurn( + client, + sessionID, + (signal) => + client.sendMessage( + sessionID, + buildMessageParams(prompt, { + model: options.model, + variant: options.variant ?? options.effort ?? null, + outputSchema: options.outputSchema ?? null, + write, + agent + }), + { signal } + ), + { + onProgress: options.onProgress, + write, + turnTimeoutMs: options.turnTimeoutMs + } + ); + } catch (error) { + if (createdSessionID) { + try { + await client.abort(createdSessionID); + } catch { + // Preserve the turn failure; abort is best-effort cleanup. + } } - ); + throw error; + } const structured = turnState.structuredOutput; const finalMessage = @@ -1135,7 +1149,11 @@ export async function importExternalAgentSession(cwd, options = {}) { resumeCommand: `opencode --session ${threadId}` }; } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Preserve the import result/error; temp-dir cleanup is best effort. + } } } diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index 1fc01fd..c0a5a21 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -45,6 +45,7 @@ import { resolveResultJob, sortJobsNewestFirst } from "./lib/job-control.mjs"; +import { teardownServerSession } from "./lib/server-lifecycle.mjs"; import { appendLogLine, createJobLogFile, @@ -1096,6 +1097,17 @@ async function handleCancel(argv) { ); } terminateProcessTree(currentJob.pid ?? Number.NaN); + try { + await teardownServerSession({ + cwd: workspaceRoot, + ...(serverUrl ? { url: serverUrl } : {}) + }); + } catch (error) { + appendLogLine( + cancelResult.job.logFile, + `OpenCode server teardown failed: ${error instanceof Error ? error.message : String(error)}` + ); + } appendLogLine(cancelResult.job.logFile, "Cancelled by user."); } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index a25fb60..f6198a5 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -6,6 +6,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { fileURLToPath } from "node:url"; +import { runServerTurn } from "../plugins/opencode/scripts/lib/opencode.mjs"; +import { saveServerSession } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; import { buildEnv, installFakeOpencode, readFakeState, readServerBootCount } from "./fake-opencode-fixture.mjs"; import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; @@ -51,6 +53,78 @@ function cleanupServer(cwd, env) { }); } +async function withProcessEnv(patch, fn) { + const previous = new Map(); + for (const key of Object.keys(patch)) { + previous.set(key, process.env[key]); + if (patch[key] == null) { + delete process.env[key]; + } else { + process.env[key] = patch[key]; + } + } + + try { + return await fn(); + } finally { + for (const [key, value] of previous) { + if (value == null) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +function runServerTurnEnv(env) { + return { + PATH: env.PATH, + CLAUDE_PLUGIN_DATA: env.CLAUDE_PLUGIN_DATA, + FAKE_OPENCODE_STATE_PATH: env.FAKE_OPENCODE_STATE_PATH, + OPENCODE_COMPANION_SESSION_ID: env.OPENCODE_COMPANION_SESSION_ID + }; +} + +function jsonResponse(value, status = 200) { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" } + }); +} + +function installFailingCaptureFetch(createdSessionId = "ses_created") { + const previousFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (requestUrl, options = {}) => { + const url = new URL(String(requestUrl)); + const method = options.method ?? "GET"; + calls.push({ method, pathname: url.pathname }); + + if (method === "GET" && url.pathname === "/global/health") { + return jsonResponse({ ok: true }); + } + if (method === "POST" && url.pathname === "/session") { + return jsonResponse({ id: createdSessionId }); + } + if (method === "GET" && url.pathname === "/event") { + return jsonResponse({ error: "event stream failed" }, 500); + } + if (method === "POST" && url.pathname === `/session/${createdSessionId}/abort`) { + return jsonResponse({ ok: true }); + } + + return jsonResponse({ error: "not found" }, 404); + }; + + return { + calls, + restore: () => { + globalThis.fetch = previousFetch; + } + }; +} + function runAsync(command, args, options = {}) { return new Promise((resolve) => { const child = spawn(command, args, { @@ -415,3 +489,78 @@ test("task fails when completion has no recoverable current-turn message (issue cleanupServer(repo, env); } }); + +test("runServerTurn aborts only sessions created by a failed captureTurn", async () => { + const createdRepo = makeTempDir(); + const createdBinDir = makeTempDir(); + installFakeOpencode(createdBinDir); + const createdEnv = buildTestEnv(createdBinDir); + const createdFetch = installFailingCaptureFetch("ses_created"); + + try { + await withProcessEnv(runServerTurnEnv(createdEnv), async () => { + saveServerSession(createdRepo, { + url: "http://opencode.test", + pid: null, + pidFile: null, + logFile: null, + sessionDir: null, + external: false, + leases: [] + }); + }); + + await withProcessEnv(runServerTurnEnv(createdEnv), async () => { + await assert.rejects( + runServerTurn(createdRepo, { prompt: "fail during event open" }), + /OpenCode GET \/event failed with HTTP 500/ + ); + }); + + assert.equal(createdFetch.calls.filter((call) => call.method === "POST" && call.pathname === "/session").length, 1); + assert.equal( + createdFetch.calls.filter((call) => call.method === "POST" && call.pathname === "/session/ses_created/abort").length, + 1 + ); + } finally { + createdFetch.restore(); + } + + const resumedRepo = makeTempDir(); + const resumedBinDir = makeTempDir(); + installFakeOpencode(resumedBinDir); + const resumedEnv = buildTestEnv(resumedBinDir); + const resumedFetch = installFailingCaptureFetch("ses_created"); + + try { + await withProcessEnv(runServerTurnEnv(resumedEnv), async () => { + saveServerSession(resumedRepo, { + url: "http://opencode.test", + pid: null, + pidFile: null, + logFile: null, + sessionDir: null, + external: false, + leases: [] + }); + }); + + await withProcessEnv(runServerTurnEnv(resumedEnv), async () => { + await assert.rejects( + runServerTurn(resumedRepo, { + prompt: "fail resumed event open", + resumeThreadId: "ses_existing" + }), + /OpenCode GET \/event failed with HTTP 500/ + ); + }); + + assert.equal(resumedFetch.calls.filter((call) => call.method === "POST" && call.pathname === "/session").length, 0); + assert.equal( + resumedFetch.calls.filter((call) => call.method === "POST" && call.pathname.endsWith("/abort")).length, + 0 + ); + } finally { + resumedFetch.restore(); + } +}); diff --git a/tests/state-races.test.mjs b/tests/state-races.test.mjs index 0fc88a0..3f05a3d 100644 --- a/tests/state-races.test.mjs +++ b/tests/state-races.test.mjs @@ -6,6 +6,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { makeTempDir, run } from "./helpers.mjs"; import { runTrackedJob } from "../plugins/opencode/scripts/lib/tracked-jobs.mjs"; +import { loadServerSession, saveServerSession } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; import { applyJobPatch, loadState, @@ -203,3 +204,66 @@ test("cancel does not clobber a completed stored job when the state index is sta assert.equal(indexedJob.phase, "done"); }); }); + +test("cancel tears down the shared server session when only dead leases remain", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + + await withPluginData(pluginDataDir, async () => { + const jobId = "job-cancel-teardown"; + const timestamp = new Date().toISOString(); + const logFile = resolveJobLogFile(workspace, jobId); + const runningJob = { + id: jobId, + workspaceRoot: workspace, + jobClass: "task", + kind: "task", + status: "running", + phase: "running", + pid: null, + title: "Running task", + logFile, + createdAt: timestamp, + updatedAt: timestamp + }; + saveState(workspace, { + version: 1, + config: { stopReviewGate: false }, + jobs: [runningJob] + }); + writeJobFile(workspace, jobId, runningJob); + saveServerSession(workspace, { + url: "http://127.0.0.1:1", + pid: null, + pidFile: null, + logFile: null, + sessionDir: null, + external: false, + leases: [ + { + pid: 999999999, + token: "dead-worker", + createdAt: timestamp, + expiresAt: new Date(Date.now() + 60000).toISOString() + } + ] + }); + + const result = run(process.execPath, [COMPANION, "cancel", jobId, "--cwd", workspace, "--json"], { + env: { + ...process.env, + CLAUDE_PLUGIN_DATA: pluginDataDir, + OPENCODE_COMPANION_SESSION_ID: "" + } + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.cancelled, true); + assert.equal(payload.status, "cancelled"); + assert.equal(loadServerSession(workspace), null); + + const storedJob = readJobFile(resolveJobFile(workspace, jobId)); + assert.equal(storedJob.status, "cancelled"); + }); +}); diff --git a/tests/transfer.test.mjs b/tests/transfer.test.mjs index 6fb0d4c..4bf1def 100644 --- a/tests/transfer.test.mjs +++ b/tests/transfer.test.mjs @@ -5,6 +5,7 @@ import assert from "node:assert/strict"; import { fileURLToPath } from "node:url"; import { buildOpenCodeImportDocumentFromClaudeJsonl } from "../plugins/opencode/scripts/lib/claude-session-transfer.mjs"; +import { importExternalAgentSession } from "../plugins/opencode/scripts/lib/opencode.mjs"; import { buildEnv, installFakeOpencode, readFakeState } from "./fake-opencode-fixture.mjs"; import { makeTempDir, run } from "./helpers.mjs"; @@ -204,3 +205,38 @@ test("transfer imports a Claude transcript and prints an OpenCode resume command "assistant" ]); }); + +test("importExternalAgentSession returns the imported session when temp cleanup fails", async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + + const transcriptPath = path.join(repo, "session.jsonl"); + fs.writeFileSync(transcriptPath, sampleClaudeJsonl(), "utf8"); + const env = buildEnv(binDir); + + const originalRmSync = fs.rmSync; + let cleanupTarget = null; + fs.rmSync = (target) => { + cleanupTarget = target; + throw new Error("simulated cleanup failure"); + }; + + try { + const result = await importExternalAgentSession(repo, { + sourcePath: transcriptPath, + env, + idFactory: sequentialIds(), + fallbackTime: 1000 + }); + + assert.match(result.threadId, /^ses_/); + assert.equal(result.resumeCommand, `opencode --session ${result.threadId}`); + assert.match(String(cleanupTarget), /opencode-transfer-/); + } finally { + fs.rmSync = originalRmSync; + if (cleanupTarget) { + fs.rmSync(cleanupTarget, { recursive: true, force: true }); + } + } +}); From cdbbc5c0a05ff22c4966b4c1743e31e0be4ea94e Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Wed, 8 Jul 2026 15:26:43 +0200 Subject: [PATCH 13/32] Fix issue #5 finding 3 session cleanup Replace best-effort abort cleanup with DELETE session cleanup when captureTurn fails before a prompt is sent. --- plugins/opencode/scripts/lib/opencode-server.mjs | 4 ++++ plugins/opencode/scripts/lib/opencode.mjs | 4 ++-- tests/runtime.test.mjs | 8 ++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/plugins/opencode/scripts/lib/opencode-server.mjs b/plugins/opencode/scripts/lib/opencode-server.mjs index 169c1f9..52846c8 100644 --- a/plugins/opencode/scripts/lib/opencode-server.mjs +++ b/plugins/opencode/scripts/lib/opencode-server.mjs @@ -170,6 +170,10 @@ export class OpencodeServerClient { }); } + deleteSession(sessionID, options = {}) { + return this.request("DELETE", `/session/${encodePathSegment(sessionID)}`, { signal: options.signal }); + } + getConfig(options = {}) { return this.request("GET", "/config", { signal: options.signal }); } diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index 1014963..addb840 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -1034,9 +1034,9 @@ export async function runServerTurn(cwd, options = {}) { } catch (error) { if (createdSessionID) { try { - await client.abort(createdSessionID); + await client.deleteSession(createdSessionID); } catch { - // Preserve the turn failure; abort is best-effort cleanup. + // Preserve the turn failure; session deletion is best-effort cleanup. } } throw error; diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index f6198a5..e004d45 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -110,7 +110,7 @@ function installFailingCaptureFetch(createdSessionId = "ses_created") { if (method === "GET" && url.pathname === "/event") { return jsonResponse({ error: "event stream failed" }, 500); } - if (method === "POST" && url.pathname === `/session/${createdSessionId}/abort`) { + if (method === "DELETE" && url.pathname === `/session/${createdSessionId}`) { return jsonResponse({ ok: true }); } @@ -490,7 +490,7 @@ test("task fails when completion has no recoverable current-turn message (issue } }); -test("runServerTurn aborts only sessions created by a failed captureTurn", async () => { +test("runServerTurn deletes only sessions created by a failed captureTurn", async () => { const createdRepo = makeTempDir(); const createdBinDir = makeTempDir(); installFakeOpencode(createdBinDir); @@ -519,7 +519,7 @@ test("runServerTurn aborts only sessions created by a failed captureTurn", async assert.equal(createdFetch.calls.filter((call) => call.method === "POST" && call.pathname === "/session").length, 1); assert.equal( - createdFetch.calls.filter((call) => call.method === "POST" && call.pathname === "/session/ses_created/abort").length, + createdFetch.calls.filter((call) => call.method === "DELETE" && call.pathname === "/session/ses_created").length, 1 ); } finally { @@ -557,7 +557,7 @@ test("runServerTurn aborts only sessions created by a failed captureTurn", async assert.equal(resumedFetch.calls.filter((call) => call.method === "POST" && call.pathname === "/session").length, 0); assert.equal( - resumedFetch.calls.filter((call) => call.method === "POST" && call.pathname.endsWith("/abort")).length, + resumedFetch.calls.filter((call) => call.method === "DELETE" && call.pathname.startsWith("/session/")).length, 0 ); } finally { From f03d874db8b2a579220a3134f448174220a5d1c5 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Wed, 8 Jul 2026 23:33:10 +0200 Subject: [PATCH 14/32] Fix error-handling gaps (#6) --- .../opencode/scripts/lib/opencode-server.mjs | 20 +++- .../opencode/scripts/opencode-companion.mjs | 9 +- .../scripts/session-lifecycle-hook.mjs | 8 +- .../scripts/stop-review-gate-hook.mjs | 21 +++- tests/opencode-server.test.mjs | 31 +++++ tests/runtime.test.mjs | 106 +++++++++++++++++- tests/state-races.test.mjs | 41 +++++++ 7 files changed, 223 insertions(+), 13 deletions(-) create mode 100644 tests/opencode-server.test.mjs diff --git a/plugins/opencode/scripts/lib/opencode-server.mjs b/plugins/opencode/scripts/lib/opencode-server.mjs index 52846c8..5bb3004 100644 --- a/plugins/opencode/scripts/lib/opencode-server.mjs +++ b/plugins/opencode/scripts/lib/opencode-server.mjs @@ -231,9 +231,23 @@ export class OpencodeServerClient { throw new Error("OpenCode event stream did not include a response body."); } - options.onOpen?.(); - - const reader = response.body.getReader(); + let reader; + try { + options.onOpen?.(); + reader = response.body.getReader(); + } catch (error) { + if (reader) { + await reader.cancel().catch(() => {}); + try { + reader.releaseLock(); + } catch { + // Preserve the original onOpen/getReader failure. + } + } else { + await response.body.cancel().catch(() => {}); + } + throw error; + } const decoder = new TextDecoder(); let buffer = ""; diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index c0a5a21..1ac04d6 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -31,6 +31,7 @@ import { generateJobId, getConfig, listJobs, + resolveJobLogFile, setConfig, updateState, upsertJob, @@ -1088,9 +1089,11 @@ async function handleCancel(argv) { const completedAt = nowIso(); const cancelResult = cancelJobIfStillActive(workspaceRoot, currentJob, completedAt); if (cancelResult.cancelled) { + const cancelLogFile = + cancelResult.job.logFile ?? currentJob.logFile ?? job.logFile ?? resolveJobLogFile(workspaceRoot, cancelResult.job.id); if (interrupt.attempted) { appendLogLine( - cancelResult.job.logFile, + cancelLogFile, interrupt.interrupted ? `Requested OpenCode session abort for ${threadId}.` : `OpenCode session abort failed${interrupt.detail ? `: ${interrupt.detail}` : "."}` @@ -1104,11 +1107,11 @@ async function handleCancel(argv) { }); } catch (error) { appendLogLine( - cancelResult.job.logFile, + cancelLogFile, `OpenCode server teardown failed: ${error instanceof Error ? error.message : String(error)}` ); } - appendLogLine(cancelResult.job.logFile, "Cancelled by user."); + appendLogLine(cancelLogFile, "Cancelled by user."); } const nextJob = cancelResult.job; diff --git a/plugins/opencode/scripts/session-lifecycle-hook.mjs b/plugins/opencode/scripts/session-lifecycle-hook.mjs index 2ab72c0..869dd7f 100644 --- a/plugins/opencode/scripts/session-lifecycle-hook.mjs +++ b/plugins/opencode/scripts/session-lifecycle-hook.mjs @@ -91,7 +91,13 @@ async function handleSessionEnd(input) { } : null); - cleanupSessionJobs(cwd, input.session_id || process.env[SESSION_ID_ENV]); + try { + cleanupSessionJobs(cwd, input.session_id || process.env[SESSION_ID_ENV]); + } catch (error) { + process.stderr.write( + `OpenCode session job cleanup failed: ${error instanceof Error ? error.message : String(error)}\n` + ); + } const teardown = await teardownServerSession({ cwd, url: serverSession?.url ?? null, diff --git a/plugins/opencode/scripts/stop-review-gate-hook.mjs b/plugins/opencode/scripts/stop-review-gate-hook.mjs index 24ced33..b8e72c6 100644 --- a/plugins/opencode/scripts/stop-review-gate-hook.mjs +++ b/plugins/opencode/scripts/stop-review-gate-hook.mjs @@ -11,6 +11,7 @@ import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; import { getConfig, listJobs } from "./lib/state.mjs"; import { sortJobsNewestFirst } from "./lib/job-control.mjs"; import { SESSION_ID_ENV } from "./lib/tracked-jobs.mjs"; +import { teardownServerSession } from "./lib/server-lifecycle.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; const STOP_REVIEW_TIMEOUT_MS = 15 * 60 * 1000; @@ -95,7 +96,15 @@ function parseStopReviewOutput(rawOutput) { }; } -function runStopReview(cwd, input = {}) { +async function cleanupStopReviewServer(cwd) { + try { + await teardownServerSession({ cwd }); + } catch { + // Best-effort cleanup after a killed stop-review task. + } +} + +async function runStopReview(cwd, input = {}) { const scriptPath = path.join(SCRIPT_DIR, "opencode-companion.mjs"); const prompt = buildStopReviewPrompt(input); const childEnv = { @@ -110,6 +119,7 @@ function runStopReview(cwd, input = {}) { }); if (result.error?.code === "ETIMEDOUT") { + await cleanupStopReviewServer(cwd); return { ok: false, reason: @@ -117,7 +127,8 @@ function runStopReview(cwd, input = {}) { }; } - if (result.status !== 0) { + if (result.status !== 0 || result.signal) { + await cleanupStopReviewServer(cwd); const detail = String(result.stderr || result.stdout || "").trim(); return { ok: false, @@ -139,7 +150,7 @@ function runStopReview(cwd, input = {}) { } } -function main() { +async function main() { const input = readHookInput(); const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd(); const workspaceRoot = resolveWorkspaceRoot(cwd); @@ -163,7 +174,7 @@ function main() { return; } - const review = runStopReview(cwd, input); + const review = await runStopReview(cwd, input); if (!review.ok) { emitDecision({ decision: "block", @@ -176,7 +187,7 @@ function main() { } try { - main(); + await main(); } catch (error) { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${message}\n`); diff --git a/tests/opencode-server.test.mjs b/tests/opencode-server.test.mjs new file mode 100644 index 0000000..f22cce7 --- /dev/null +++ b/tests/opencode-server.test.mjs @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { OpencodeServerClient } from "../plugins/opencode/scripts/lib/opencode-server.mjs"; + +test("subscribeEvents cancels the response body if onOpen throws", async () => { + let cancelled = false; + const expectedError = new Error("open failed"); + const body = new ReadableStream({ + cancel() { + cancelled = true; + } + }); + const client = new OpencodeServerClient("http://opencode.test", { + fetch: async () => + new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" } + }) + }); + + await assert.rejects( + client.subscribeEvents(() => {}, { + onOpen() { + throw expectedError; + } + }), + (error) => error === expectedError + ); + assert.equal(cancelled, true); +}); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index e004d45..6796daf 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -7,7 +7,8 @@ import assert from "node:assert/strict"; import { fileURLToPath } from "node:url"; import { runServerTurn } from "../plugins/opencode/scripts/lib/opencode.mjs"; -import { saveServerSession } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; +import { loadServerSession, saveServerSession } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; +import { resolveStateFile, saveState } from "../plugins/opencode/scripts/lib/state.mjs"; import { buildEnv, installFakeOpencode, readFakeState, readServerBootCount } from "./fake-opencode-fixture.mjs"; import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; @@ -15,6 +16,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const PLUGIN_ROOT = path.join(ROOT, "plugins", "opencode"); const SCRIPT = path.join(PLUGIN_ROOT, "scripts", "opencode-companion.mjs"); const SESSION_HOOK = path.join(PLUGIN_ROOT, "scripts", "session-lifecycle-hook.mjs"); +const STOP_HOOK = path.join(PLUGIN_ROOT, "scripts", "stop-review-gate-hook.mjs"); async function canListenLocalhost() { return new Promise((resolve) => { @@ -93,6 +95,108 @@ function jsonResponse(value, status = 200) { }); } +test("session end clears server session when job cleanup fails but teardown succeeds", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir("opencode-plugin-data-"); + const sessionId = "sess-cleanup-throws"; + + await withProcessEnv({ CLAUDE_PLUGIN_DATA: pluginDataDir }, async () => { + saveServerSession(workspace, { + url: "http://127.0.0.1:1", + pid: null, + pidFile: null, + logFile: null, + sessionDir: null, + external: false, + leases: [] + }); + fs.mkdirSync(resolveStateFile(workspace), { recursive: true }); + + const result = run("node", [SESSION_HOOK, "SessionEnd"], { + cwd: workspace, + env: { + ...process.env, + CLAUDE_PLUGIN_DATA: pluginDataDir, + OPENCODE_COMPANION_SESSION_ID: sessionId + }, + input: JSON.stringify({ cwd: workspace, session_id: sessionId }) + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /OpenCode session job cleanup failed/); + assert.equal(loadServerSession(workspace), null); + }); +}); + +test("session end leaves server session when teardown is skipped for active leases", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir("opencode-plugin-data-"); + const timestamp = new Date().toISOString(); + + await withProcessEnv({ CLAUDE_PLUGIN_DATA: pluginDataDir }, async () => { + saveServerSession(workspace, { + url: "http://127.0.0.1:1", + pid: null, + pidFile: null, + logFile: null, + sessionDir: null, + external: false, + leases: [ + { + pid: process.pid, + token: "parent-test-lease", + createdAt: timestamp, + expiresAt: new Date(Date.now() + 60000).toISOString() + } + ] + }); + + const result = run("node", [SESSION_HOOK, "SessionEnd"], { + cwd: workspace, + env: { + ...process.env, + CLAUDE_PLUGIN_DATA: pluginDataDir, + OPENCODE_COMPANION_SESSION_ID: "sess-active-lease" + }, + input: JSON.stringify({ cwd: workspace, session_id: "sess-active-lease" }) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(loadServerSession(workspace).url, "http://127.0.0.1:1"); + }); +}); + +test("stop review gate tears down a server left by a failed stop review task", { skip: LOCAL_LISTEN_SKIP }, async () => { + const workspace = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + const env = buildTestEnv(binDir, { + FAKE_OPENCODE_MESSAGE_FAIL: "empty-recovery" + }); + + await withProcessEnv({ CLAUDE_PLUGIN_DATA: env.CLAUDE_PLUGIN_DATA }, async () => { + saveState(workspace, { + version: 1, + config: { stopReviewGate: true }, + jobs: [] + }); + + const result = run("node", [STOP_HOOK], { + cwd: workspace, + env, + input: JSON.stringify({ + cwd: workspace, + session_id: env.OPENCODE_COMPANION_SESSION_ID, + last_assistant_message: "Previous turn output." + }) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).decision, "block"); + assert.equal(loadServerSession(workspace), null); + }); +}); + function installFailingCaptureFetch(createdSessionId = "ses_created") { const previousFetch = globalThis.fetch; const calls = []; diff --git a/tests/state-races.test.mjs b/tests/state-races.test.mjs index 3f05a3d..b3d811c 100644 --- a/tests/state-races.test.mjs +++ b/tests/state-races.test.mjs @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; @@ -267,3 +268,43 @@ test("cancel tears down the shared server session when only dead leases remain", assert.equal(storedJob.status, "cancelled"); }); }); + +test("cancel writes a cancellation log when the job record has no logFile", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + + await withPluginData(pluginDataDir, async () => { + const jobId = "job-cancel-missing-log"; + const timestamp = new Date().toISOString(); + const runningJob = { + id: jobId, + workspaceRoot: workspace, + jobClass: "task", + kind: "task", + status: "running", + phase: "running", + pid: null, + title: "Running task without log", + createdAt: timestamp, + updatedAt: timestamp + }; + saveState(workspace, { + version: 1, + config: { stopReviewGate: false }, + jobs: [runningJob] + }); + writeJobFile(workspace, jobId, runningJob); + + const result = run(process.execPath, [COMPANION, "cancel", jobId, "--cwd", workspace, "--json"], { + env: { + ...process.env, + CLAUDE_PLUGIN_DATA: pluginDataDir, + OPENCODE_COMPANION_SESSION_ID: "" + } + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).cancelled, true); + assert.match(fs.readFileSync(resolveJobLogFile(workspace, jobId), "utf8"), /Cancelled by user\./); + }); +}); From e4a5a09ecfa562b719375802c76f1715efa4e8f5 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Thu, 9 Jul 2026 04:07:33 +0200 Subject: [PATCH 15/32] Fix issue #12 async OpenCode turn transport --- .../opencode/scripts/lib/opencode-server.mjs | 72 ++++++++++++++- plugins/opencode/scripts/lib/opencode.mjs | 43 +++------ tests/fake-opencode-fixture.mjs | 90 +++++++++++++------ tests/runtime.test.mjs | 51 +++++++++-- 4 files changed, 189 insertions(+), 67 deletions(-) diff --git a/plugins/opencode/scripts/lib/opencode-server.mjs b/plugins/opencode/scripts/lib/opencode-server.mjs index 5bb3004..cf522e9 100644 --- a/plugins/opencode/scripts/lib/opencode-server.mjs +++ b/plugins/opencode/scripts/lib/opencode-server.mjs @@ -1,3 +1,6 @@ +import http from "node:http"; +import https from "node:https"; + export class OpencodeHttpError extends Error { constructor(message, options = {}) { super(message); @@ -18,11 +21,14 @@ function encodePathSegment(value) { async function parseResponseBody(response) { const text = await response.text(); + return parseBodyText(text, response.headers.get("content-type") ?? ""); +} + +function parseBodyText(text, contentType = "") { if (!text) { return {}; } - const contentType = response.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { return JSON.parse(text); } @@ -34,6 +40,57 @@ async function parseResponseBody(response) { } } +function requestWithFreshConnection(url, options = {}) { + const transport = url.protocol === "https:" ? https : http; + const body = options.body == null ? null : JSON.stringify(options.body); + const headers = { + ...(body == null ? {} : { "content-type": "application/json", "content-length": Buffer.byteLength(body) }), + ...(options.headers ?? {}), + connection: "close" + }; + + return new Promise((resolve, reject) => { + const req = transport.request( + url, + { + method: options.method, + headers, + agent: false, + signal: options.signal + }, + (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + if (res.statusCode < 200 || res.statusCode >= 300) { + reject( + new OpencodeHttpError(`OpenCode ${options.method} ${options.path} failed with HTTP ${res.statusCode}.`, { + status: res.statusCode, + body: text, + url: url.href + }) + ); + return; + } + + try { + const contentType = res.headers["content-type"]; + resolve(parseBodyText(text, Array.isArray(contentType) ? contentType.join(";") : contentType ?? "")); + } catch (error) { + reject(error); + } + }); + } + ); + req.on("error", reject); + if (body != null) { + req.write(body); + } + req.end(); + }); +} + function parseSseBlock(block) { const event = { eventName: "message", @@ -119,6 +176,16 @@ export class OpencodeServerClient { async request(method, path, options = {}) { const url = this.url(path); + if (options.freshConnection) { + return requestWithFreshConnection(new URL(url), { + method, + path, + headers: options.headers, + body: options.body, + signal: options.signal + }); + } + const headers = { ...(options.body == null ? {} : { "content-type": "application/json" }), ...(options.headers ?? {}) @@ -188,7 +255,8 @@ export class OpencodeServerClient { listMessages(sessionID, options = {}) { return this.request("GET", `/session/${encodePathSegment(sessionID)}/message`, { - signal: options.signal + signal: options.signal, + freshConnection: options.freshConnection }); } diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index addb840..bb3863b 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -372,7 +372,6 @@ function createTurnCaptureState(sessionID, options = {}) { responseError: null, recoveryError: null, response: null, - fallbackTimer: null, onProgress: options.onProgress ?? null, write: Boolean(options.write) }; @@ -423,10 +422,6 @@ function completeTurn(state, options = {}) { if (state.completed) { return; } - if (state.fallbackTimer) { - clearTimeout(state.fallbackTimer); - state.fallbackTimer = null; - } state.completed = true; if (options.inferred) { emitProgress(state.onProgress, "Turn completion inferred after OpenCode returned the message response.", "finalizing"); @@ -434,17 +429,6 @@ function completeTurn(state, options = {}) { state.resolveCompletion(state); } -function scheduleResponseFallbackCompletion(state) { - if (state.completed || state.fallbackTimer) { - return; - } - state.fallbackTimer = setTimeout(() => { - state.fallbackTimer = null; - completeTurn(state, { inferred: true }); - }, 250); - state.fallbackTimer.unref?.(); -} - function applyMessageParts(state, parts, sessionID) { if (!Array.isArray(parts) || parts.length === 0) { return; @@ -605,7 +589,7 @@ async function applyOpenCodeEvent(client, state, event, meta = {}) { // (issue #2). async function recoverFinalMessageFromServer(client, state, options = {}) { try { - const raw = await client.listMessages(state.sessionID, { signal: options.signal }); + const raw = await client.listMessages(state.sessionID, { signal: options.signal, freshConnection: true }); const messages = getMessagesArray(raw).filter(isAssistantMessage); const assistant = state.messageID ? messages.find((message) => extractMessageId(message) === state.messageID) @@ -675,6 +659,7 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { const turnTimeoutMs = Math.max(0, Number(options.turnTimeoutMs) || DEFAULT_TURN_TIMEOUT_MS); let timedOut = false; let turnTimer = null; + let responsePromise = Promise.resolve(null); try { await Promise.race([ @@ -686,31 +671,25 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { await snapshotPriorAssistantIds(client, state, { signal: eventAbort.signal }); - // Send the prompt. The synchronous /message endpoint keeps this request open - // for the whole turn, so on long turns it can hit the client fetch timeout and - // reject with a transport error well before `session.idle` arrives. That - // transport failure must NOT fail the turn — only a real HTTP rejection (bad - // request, bad model, etc.) is fatal (issue #2 / findings #16, #17). - const responsePromise = startRequest(eventAbort.signal) + // Send the prompt asynchronously. The ack is only delivery confirmation; the + // assistant result and message id must come from events or HTTP recovery. + responsePromise = startRequest(eventAbort.signal) .then((response) => { state.response = response; - state.messageID = response?.info?.id ?? response?.id ?? state.messageID; - applyMessageParts(state, response?.parts ?? [], state.sessionID); - scheduleResponseFallbackCompletion(state); return response; }) .catch((error) => { state.responseError = error; // A server-side rejection is terminal; a transport error is not. - if (error instanceof OpencodeHttpError) { + if (error instanceof OpencodeHttpError && !state.error) { state.error = error; completeTurn(state); } return null; }); - // Complete on `session.idle` / `session.error` / the response fallback, on the - // stream closing, or on the outer safety timeout — whichever comes first. + // Complete on `session.idle` / `session.error`, on the stream closing, or on + // the outer safety timeout — whichever comes first. const timeoutPromise = turnTimeoutMs > 0 ? new Promise((resolve) => { @@ -749,9 +728,7 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { } eventAbort.abort(); await eventStream.catch(() => {}); - if (state.fallbackTimer) { - clearTimeout(state.fallbackTimer); - } + await responsePromise.catch(() => {}); } } @@ -1014,7 +991,7 @@ export async function runServerTurn(cwd, options = {}) { client, sessionID, (signal) => - client.sendMessage( + client.promptAsync( sessionID, buildMessageParams(prompt, { model: options.model, diff --git a/tests/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs index 03619fe..c2832d3 100644 --- a/tests/fake-opencode-fixture.mjs +++ b/tests/fake-opencode-fixture.mjs @@ -168,20 +168,9 @@ async function waitForPermission(permissionID) { }); } -async function handleMessage(req, res, sessionID) { - const body = await readJson(req); - const state = loadState(); - const session = state.sessions.find((candidate) => candidate.id === sessionID); - if (!session) { - sendJson(res, { error: "unknown session" }, 404); - return; - } - - const messageID = "msg_" + state.nextMessageId++; - const prompt = textFromMessage(body); - state.messages.push({ sessionID, messageID, body, prompt }); - state.lastMessage = { sessionID, messageID, body, prompt }; - saveState(state); +async function completeMessageTurn(res, sessionID, messageID, body, prompt, session, options = {}) { + const asyncDelivery = Boolean(options.asyncDelivery); + const failMode = process.env.FAKE_OPENCODE_MESSAGE_FAIL; emit({ type: "session.next.step.started", sessionID }); if (session.agent === "build" || body.agent === "build") { @@ -191,14 +180,20 @@ async function handleMessage(req, res, sessionID) { emit({ type: "file.edited", sessionID, path: "generated.txt" }); } + const turnDelayMs = Math.max(0, Number(process.env.FAKE_OPENCODE_TURN_DELAY_MS || 0)); + if (turnDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, turnDelayMs)); + } + const finalText = prompt.includes("follow up") ? "Resumed the prior OpenCode run.\\nFollow-up prompt accepted." : "Handled the requested task.\\nTask prompt accepted."; const parts = structuredOutputParts(body) || [{ type: "text", text: finalText }]; - const failMode = process.env.FAKE_OPENCODE_MESSAGE_FAIL; if (failMode === "empty-recovery") { emit({ type: "session.idle", sessionID }); - res.destroy(); + if (!asyncDelivery) { + res.destroy(); + } return; } @@ -207,14 +202,12 @@ async function handleMessage(req, res, sessionID) { finalState.responses = finalState.responses || []; finalState.responses.push({ sessionID, info: { id: messageID, role: "assistant", sessionID }, parts }); saveState(finalState); - // Issue #2 regression hooks. "transport": deliver the turn over the event - // stream but drop the /message HTTP response mid-flight (like undici timing - // out the held-open POST). "recover": additionally withhold message.updated so - // the client must re-fetch the finished message via GET /session/:id/message. - // "delayed-events" drops the POST before completion events arrive, matching - // the real failure ordering seen in issue #2 review. + // Issue #2/#12 regression hooks. In async mode the prompt ack has already + // returned, so transport failures only affect the event/recovery path. if (failMode === "delayed-events") { - res.destroy(); + if (!asyncDelivery) { + res.destroy(); + } setTimeout(() => { emit({ type: "message.updated", sessionID, message: { id: messageID, parts } }); emit({ type: "session.idle", sessionID }); @@ -225,11 +218,40 @@ async function handleMessage(req, res, sessionID) { emit({ type: "message.updated", sessionID, message: { id: messageID, parts } }); } emit({ type: "session.idle", sessionID }); - if (failMode === "transport" || failMode === "recover") { + if (!asyncDelivery && (failMode === "transport" || failMode === "recover")) { res.destroy(); return; } - sendJson(res, { info: { id: messageID, sessionID }, parts }); + if (!asyncDelivery) { + sendJson(res, { info: { id: messageID, sessionID }, parts }); + } +} + +async function handleMessage(req, res, sessionID, options = {}) { + const body = await readJson(req); + const state = loadState(); + const session = state.sessions.find((candidate) => candidate.id === sessionID); + if (!session) { + sendJson(res, { error: "unknown session" }, 404); + return; + } + + const messageID = "msg_" + state.nextMessageId++; + const prompt = textFromMessage(body); + const delivery = options.asyncDelivery ? "async" : "sync"; + const endpoint = options.endpoint || "message"; + state.messages.push({ sessionID, messageID, body, prompt, delivery, endpoint }); + state.lastMessage = { sessionID, messageID, body, prompt, delivery, endpoint }; + saveState(state); + + if (options.asyncDelivery) { + sendJson(res, { ok: true, info: { id: messageID, sessionID } }); + completeMessageTurn(res, sessionID, messageID, body, prompt, session, options).catch((error) => { + emit({ type: "session.error", sessionID, error: { message: error.message } }); + }); + return; + } + await completeMessageTurn(res, sessionID, messageID, body, prompt, session, options); } function handleSessionListCli() { @@ -400,6 +422,24 @@ const server = http.createServer(async (req, res) => { return; } + const promptAsyncMatch = url.pathname.match(/^\\/session\\/([^/]+)\\/prompt_async$/); + if (req.method === "POST" && promptAsyncMatch) { + await handleMessage(req, res, decodeURIComponent(promptAsyncMatch[1]), { + asyncDelivery: true, + endpoint: "prompt_async" + }); + return; + } + + const backgroundMatch = url.pathname.match(/^\\/experimental\\/session\\/([^/]+)\\/background$/); + if (req.method === "POST" && backgroundMatch) { + await handleMessage(req, res, decodeURIComponent(backgroundMatch[1]), { + asyncDelivery: true, + endpoint: "background" + }); + return; + } + const abortMatch = url.pathname.match(/^\\/session\\/([^/]+)\\/abort$/); if (req.method === "POST" && abortMatch) { const state = loadState(); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 6796daf..0d5bf72 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -367,6 +367,8 @@ test("task forwards spark model alias and effort as OpenCode variant", { skip: L modelID: "gpt-5.3-codex-spark" }); assert.equal(fakeState.lastMessage.body.variant, "high"); + assert.equal(fakeState.lastMessage.delivery, "async"); + assert.equal(fakeState.lastMessage.endpoint, "prompt_async"); } finally { cleanupServer(repo, env); } @@ -466,9 +468,8 @@ test("task succeeds when the message transport drops after session.idle (issue # const binDir = makeTempDir(); installFakeOpencode(binDir); initGitRepo(repo); - // Simulates a slow turn where the held-open /message POST dies (client fetch - // timeout) but session.idle still arrives — the exact failure that reported a - // completed review as `fetch failed`. + // The async prompt ack has already returned; this keeps the event-delivered + // success path covered for the historical transport mode. const env = buildTestEnv(binDir, { FAKE_OPENCODE_MESSAGE_FAIL: "transport" }); try { @@ -481,6 +482,34 @@ test("task succeeds when the message transport drops after session.idle (issue # const payload = JSON.parse(result.stdout); assert.equal(payload.status, 0); assert.match(payload.rawOutput, /Handled the requested task/); + const fakeState = readFakeState(binDir); + assert.equal(fakeState.lastMessage.delivery, "async"); + assert.equal(fakeState.lastMessage.endpoint, "prompt_async"); + } finally { + cleanupServer(repo, env); + } +}); + +test("task returns a delayed turn result through async prompt delivery (issue #12)", { skip: LOCAL_LISTEN_SKIP }, () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + const env = buildTestEnv(binDir, { FAKE_OPENCODE_TURN_DELAY_MS: "350" }); + + try { + const result = run("node", [SCRIPT, "task", "--json", "slow async task"], { + cwd: repo, + env + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, 0); + assert.match(payload.rawOutput, /Handled the requested task/); + const fakeState = readFakeState(binDir); + assert.equal(fakeState.lastMessage.delivery, "async"); + assert.equal(fakeState.lastMessage.endpoint, "prompt_async"); } finally { cleanupServer(repo, env); } @@ -491,8 +520,7 @@ test("task succeeds when the message transport drops before completion events (i const binDir = makeTempDir(); installFakeOpencode(binDir); initGitRepo(repo); - // Reproduces the real ordering where the held-open /message POST fails first, - // then the event stream later delivers the final message and session.idle. + // Reproduces delayed completion events after an already-acknowledged prompt. const env = buildTestEnv(binDir, { FAKE_OPENCODE_MESSAGE_FAIL: "delayed-events" }); try { @@ -505,6 +533,9 @@ test("task succeeds when the message transport drops before completion events (i const payload = JSON.parse(result.stdout); assert.equal(payload.status, 0); assert.match(payload.rawOutput, /Handled the requested task/); + const fakeState = readFakeState(binDir); + assert.equal(fakeState.lastMessage.delivery, "async"); + assert.equal(fakeState.lastMessage.endpoint, "prompt_async"); } finally { cleanupServer(repo, env); } @@ -515,8 +546,8 @@ test("task recovers the final message over HTTP when only session.idle arrives ( const binDir = makeTempDir(); installFakeOpencode(binDir); initGitRepo(repo); - // No message.updated event and a dropped POST response: the client must - // re-fetch the finished assistant message from the server to complete. + // No message.updated event: the client must re-fetch the finished assistant + // message from the server to complete. const env = buildTestEnv(binDir, { FAKE_OPENCODE_MESSAGE_FAIL: "recover" }); try { @@ -529,6 +560,9 @@ test("task recovers the final message over HTTP when only session.idle arrives ( const payload = JSON.parse(result.stdout); assert.equal(payload.status, 0); assert.match(payload.rawOutput, /Handled the requested task/); + const fakeState = readFakeState(binDir); + assert.equal(fakeState.lastMessage.delivery, "async"); + assert.equal(fakeState.lastMessage.endpoint, "prompt_async"); } finally { cleanupServer(repo, env); } @@ -589,6 +623,9 @@ test("task fails when completion has no recoverable current-turn message (issue const payload = JSON.parse(result.stdout); assert.equal(payload.status, 1); assert.equal(payload.rawOutput, ""); + const fakeState = readFakeState(binDir); + assert.equal(fakeState.lastMessage.delivery, "async"); + assert.equal(fakeState.lastMessage.endpoint, "prompt_async"); } finally { cleanupServer(repo, env); } From d0cf2cca7d00c5a37fc18e2f352989774130548c Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Thu, 9 Jul 2026 04:45:11 +0200 Subject: [PATCH 16/32] Fix OpenCode sync transport recovery for issue #12 --- plugins/opencode/scripts/lib/opencode.mjs | 58 ++++++++++---- tests/fake-opencode-fixture.mjs | 98 ++++++++--------------- tests/runtime.test.mjs | 43 ++++------ 3 files changed, 90 insertions(+), 109 deletions(-) diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index bb3863b..2f92226 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -372,6 +372,7 @@ function createTurnCaptureState(sessionID, options = {}) { responseError: null, recoveryError: null, response: null, + fallbackTimer: null, onProgress: options.onProgress ?? null, write: Boolean(options.write) }; @@ -422,6 +423,10 @@ function completeTurn(state, options = {}) { if (state.completed) { return; } + if (state.fallbackTimer) { + clearTimeout(state.fallbackTimer); + state.fallbackTimer = null; + } state.completed = true; if (options.inferred) { emitProgress(state.onProgress, "Turn completion inferred after OpenCode returned the message response.", "finalizing"); @@ -429,6 +434,17 @@ function completeTurn(state, options = {}) { state.resolveCompletion(state); } +function scheduleResponseFallbackCompletion(state) { + if (state.completed || state.fallbackTimer) { + return; + } + state.fallbackTimer = setTimeout(() => { + state.fallbackTimer = null; + completeTurn(state, { inferred: true }); + }, 250); + state.fallbackTimer.unref?.(); +} + function applyMessageParts(state, parts, sessionID) { if (!Array.isArray(parts) || parts.length === 0) { return; @@ -591,14 +607,17 @@ async function recoverFinalMessageFromServer(client, state, options = {}) { try { const raw = await client.listMessages(state.sessionID, { signal: options.signal, freshConnection: true }); const messages = getMessagesArray(raw).filter(isAssistantMessage); - const assistant = state.messageID + let assistant = state.messageID ? messages.find((message) => extractMessageId(message) === state.messageID) - : messages - .filter((message) => { - const messageID = extractMessageId(message); - return messageID && !state.priorAssistantIds.has(messageID); - }) - .pop(); + : null; + if (!assistant) { + assistant = messages + .filter((message) => { + const messageID = extractMessageId(message); + return messageID && !state.priorAssistantIds.has(messageID); + }) + .pop(); + } if (!assistant) { return false; } @@ -659,7 +678,6 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { const turnTimeoutMs = Math.max(0, Number(options.turnTimeoutMs) || DEFAULT_TURN_TIMEOUT_MS); let timedOut = false; let turnTimer = null; - let responsePromise = Promise.resolve(null); try { await Promise.race([ @@ -671,25 +689,31 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { await snapshotPriorAssistantIds(client, state, { signal: eventAbort.signal }); - // Send the prompt asynchronously. The ack is only delivery confirmation; the - // assistant result and message id must come from events or HTTP recovery. - responsePromise = startRequest(eventAbort.signal) + // Send the prompt. The synchronous /message endpoint keeps this request open + // for the whole turn, so on long turns it can hit the client fetch timeout and + // reject with a transport error well before `session.idle` arrives. That + // transport failure must NOT fail the turn — only a real HTTP rejection (bad + // request, bad model, etc.) is fatal (issue #2 / findings #16, #17). + const responsePromise = startRequest(eventAbort.signal) .then((response) => { state.response = response; + state.messageID = response?.info?.id ?? response?.id ?? state.messageID; + applyMessageParts(state, response?.parts ?? [], state.sessionID); + scheduleResponseFallbackCompletion(state); return response; }) .catch((error) => { state.responseError = error; // A server-side rejection is terminal; a transport error is not. - if (error instanceof OpencodeHttpError && !state.error) { + if (error instanceof OpencodeHttpError) { state.error = error; completeTurn(state); } return null; }); - // Complete on `session.idle` / `session.error`, on the stream closing, or on - // the outer safety timeout — whichever comes first. + // Complete on `session.idle` / `session.error` / the response fallback, on the + // stream closing, or on the outer safety timeout — whichever comes first. const timeoutPromise = turnTimeoutMs > 0 ? new Promise((resolve) => { @@ -728,7 +752,9 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { } eventAbort.abort(); await eventStream.catch(() => {}); - await responsePromise.catch(() => {}); + if (state.fallbackTimer) { + clearTimeout(state.fallbackTimer); + } } } @@ -991,7 +1017,7 @@ export async function runServerTurn(cwd, options = {}) { client, sessionID, (signal) => - client.promptAsync( + client.sendMessage( sessionID, buildMessageParams(prompt, { model: options.model, diff --git a/tests/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs index c2832d3..e5ffceb 100644 --- a/tests/fake-opencode-fixture.mjs +++ b/tests/fake-opencode-fixture.mjs @@ -168,9 +168,20 @@ async function waitForPermission(permissionID) { }); } -async function completeMessageTurn(res, sessionID, messageID, body, prompt, session, options = {}) { - const asyncDelivery = Boolean(options.asyncDelivery); - const failMode = process.env.FAKE_OPENCODE_MESSAGE_FAIL; +async function handleMessage(req, res, sessionID) { + const body = await readJson(req); + const state = loadState(); + const session = state.sessions.find((candidate) => candidate.id === sessionID); + if (!session) { + sendJson(res, { error: "unknown session" }, 404); + return; + } + + const messageID = "msg_" + state.nextMessageId++; + const prompt = textFromMessage(body); + state.messages.push({ sessionID, messageID, body, prompt }); + state.lastMessage = { sessionID, messageID, body, prompt }; + saveState(state); emit({ type: "session.next.step.started", sessionID }); if (session.agent === "build" || body.agent === "build") { @@ -180,20 +191,14 @@ async function completeMessageTurn(res, sessionID, messageID, body, prompt, sess emit({ type: "file.edited", sessionID, path: "generated.txt" }); } - const turnDelayMs = Math.max(0, Number(process.env.FAKE_OPENCODE_TURN_DELAY_MS || 0)); - if (turnDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, turnDelayMs)); - } - const finalText = prompt.includes("follow up") ? "Resumed the prior OpenCode run.\\nFollow-up prompt accepted." : "Handled the requested task.\\nTask prompt accepted."; const parts = structuredOutputParts(body) || [{ type: "text", text: finalText }]; + const failMode = process.env.FAKE_OPENCODE_MESSAGE_FAIL; if (failMode === "empty-recovery") { emit({ type: "session.idle", sessionID }); - if (!asyncDelivery) { - res.destroy(); - } + res.destroy(); return; } @@ -202,56 +207,37 @@ async function completeMessageTurn(res, sessionID, messageID, body, prompt, sess finalState.responses = finalState.responses || []; finalState.responses.push({ sessionID, info: { id: messageID, role: "assistant", sessionID }, parts }); saveState(finalState); - // Issue #2/#12 regression hooks. In async mode the prompt ack has already - // returned, so transport failures only affect the event/recovery path. + // Issue #2 regression hooks. "transport": deliver the turn over the event + // stream but drop the /message HTTP response mid-flight (like undici timing + // out the held-open POST). "recover": additionally withhold message.updated so + // the client must re-fetch the finished message via GET /session/:id/message. + // "delayed-events" drops the POST before completion events arrive, matching + // the real failure ordering seen in issue #2 review. "mismatched-recover" + // gives recovery a stale event-derived message id, then expects fallback to + // the newest assistant message returned by GET /session/:id/message. if (failMode === "delayed-events") { - if (!asyncDelivery) { - res.destroy(); - } + res.destroy(); setTimeout(() => { emit({ type: "message.updated", sessionID, message: { id: messageID, parts } }); emit({ type: "session.idle", sessionID }); }, 25); return; } + if (failMode === "mismatched-recover") { + emit({ type: "message.updated", sessionID, message: { id: messageID + "_event_only" } }); + emit({ type: "session.idle", sessionID }); + res.destroy(); + return; + } if (failMode !== "recover") { emit({ type: "message.updated", sessionID, message: { id: messageID, parts } }); } emit({ type: "session.idle", sessionID }); - if (!asyncDelivery && (failMode === "transport" || failMode === "recover")) { + if (failMode === "transport" || failMode === "recover") { res.destroy(); return; } - if (!asyncDelivery) { - sendJson(res, { info: { id: messageID, sessionID }, parts }); - } -} - -async function handleMessage(req, res, sessionID, options = {}) { - const body = await readJson(req); - const state = loadState(); - const session = state.sessions.find((candidate) => candidate.id === sessionID); - if (!session) { - sendJson(res, { error: "unknown session" }, 404); - return; - } - - const messageID = "msg_" + state.nextMessageId++; - const prompt = textFromMessage(body); - const delivery = options.asyncDelivery ? "async" : "sync"; - const endpoint = options.endpoint || "message"; - state.messages.push({ sessionID, messageID, body, prompt, delivery, endpoint }); - state.lastMessage = { sessionID, messageID, body, prompt, delivery, endpoint }; - saveState(state); - - if (options.asyncDelivery) { - sendJson(res, { ok: true, info: { id: messageID, sessionID } }); - completeMessageTurn(res, sessionID, messageID, body, prompt, session, options).catch((error) => { - emit({ type: "session.error", sessionID, error: { message: error.message } }); - }); - return; - } - await completeMessageTurn(res, sessionID, messageID, body, prompt, session, options); + sendJson(res, { info: { id: messageID, sessionID }, parts }); } function handleSessionListCli() { @@ -422,24 +408,6 @@ const server = http.createServer(async (req, res) => { return; } - const promptAsyncMatch = url.pathname.match(/^\\/session\\/([^/]+)\\/prompt_async$/); - if (req.method === "POST" && promptAsyncMatch) { - await handleMessage(req, res, decodeURIComponent(promptAsyncMatch[1]), { - asyncDelivery: true, - endpoint: "prompt_async" - }); - return; - } - - const backgroundMatch = url.pathname.match(/^\\/experimental\\/session\\/([^/]+)\\/background$/); - if (req.method === "POST" && backgroundMatch) { - await handleMessage(req, res, decodeURIComponent(backgroundMatch[1]), { - asyncDelivery: true, - endpoint: "background" - }); - return; - } - const abortMatch = url.pathname.match(/^\\/session\\/([^/]+)\\/abort$/); if (req.method === "POST" && abortMatch) { const state = loadState(); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 0d5bf72..5bb2ec9 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -367,8 +367,6 @@ test("task forwards spark model alias and effort as OpenCode variant", { skip: L modelID: "gpt-5.3-codex-spark" }); assert.equal(fakeState.lastMessage.body.variant, "high"); - assert.equal(fakeState.lastMessage.delivery, "async"); - assert.equal(fakeState.lastMessage.endpoint, "prompt_async"); } finally { cleanupServer(repo, env); } @@ -468,8 +466,9 @@ test("task succeeds when the message transport drops after session.idle (issue # const binDir = makeTempDir(); installFakeOpencode(binDir); initGitRepo(repo); - // The async prompt ack has already returned; this keeps the event-delivered - // success path covered for the historical transport mode. + // Simulates a slow turn where the held-open /message POST dies (client fetch + // timeout) but session.idle still arrives — the exact failure that reported a + // completed review as `fetch failed`. const env = buildTestEnv(binDir, { FAKE_OPENCODE_MESSAGE_FAIL: "transport" }); try { @@ -482,23 +481,22 @@ test("task succeeds when the message transport drops after session.idle (issue # const payload = JSON.parse(result.stdout); assert.equal(payload.status, 0); assert.match(payload.rawOutput, /Handled the requested task/); - const fakeState = readFakeState(binDir); - assert.equal(fakeState.lastMessage.delivery, "async"); - assert.equal(fakeState.lastMessage.endpoint, "prompt_async"); } finally { cleanupServer(repo, env); } }); -test("task returns a delayed turn result through async prompt delivery (issue #12)", { skip: LOCAL_LISTEN_SKIP }, () => { +test("task succeeds when the message transport drops before completion events (issue #2)", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); installFakeOpencode(binDir); initGitRepo(repo); - const env = buildTestEnv(binDir, { FAKE_OPENCODE_TURN_DELAY_MS: "350" }); + // Reproduces the real ordering where the held-open /message POST fails first, + // then the event stream later delivers the final message and session.idle. + const env = buildTestEnv(binDir, { FAKE_OPENCODE_MESSAGE_FAIL: "delayed-events" }); try { - const result = run("node", [SCRIPT, "task", "--json", "slow async task"], { + const result = run("node", [SCRIPT, "task", "--json", "long running task"], { cwd: repo, env }); @@ -507,21 +505,19 @@ test("task returns a delayed turn result through async prompt delivery (issue #1 const payload = JSON.parse(result.stdout); assert.equal(payload.status, 0); assert.match(payload.rawOutput, /Handled the requested task/); - const fakeState = readFakeState(binDir); - assert.equal(fakeState.lastMessage.delivery, "async"); - assert.equal(fakeState.lastMessage.endpoint, "prompt_async"); } finally { cleanupServer(repo, env); } }); -test("task succeeds when the message transport drops before completion events (issue #2)", { skip: LOCAL_LISTEN_SKIP }, () => { +test("task recovers the final message over HTTP when only session.idle arrives (issue #2)", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); installFakeOpencode(binDir); initGitRepo(repo); - // Reproduces delayed completion events after an already-acknowledged prompt. - const env = buildTestEnv(binDir, { FAKE_OPENCODE_MESSAGE_FAIL: "delayed-events" }); + // No message.updated event and a dropped POST response: the client must + // re-fetch the finished assistant message from the server to complete. + const env = buildTestEnv(binDir, { FAKE_OPENCODE_MESSAGE_FAIL: "recover" }); try { const result = run("node", [SCRIPT, "task", "--json", "long running task"], { @@ -533,22 +529,17 @@ test("task succeeds when the message transport drops before completion events (i const payload = JSON.parse(result.stdout); assert.equal(payload.status, 0); assert.match(payload.rawOutput, /Handled the requested task/); - const fakeState = readFakeState(binDir); - assert.equal(fakeState.lastMessage.delivery, "async"); - assert.equal(fakeState.lastMessage.endpoint, "prompt_async"); } finally { cleanupServer(repo, env); } }); -test("task recovers the final message over HTTP when only session.idle arrives (issue #2)", { skip: LOCAL_LISTEN_SKIP }, () => { +test("task recovery falls back to the newest assistant message when event message id is stale (issue #12)", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); installFakeOpencode(binDir); initGitRepo(repo); - // No message.updated event: the client must re-fetch the finished assistant - // message from the server to complete. - const env = buildTestEnv(binDir, { FAKE_OPENCODE_MESSAGE_FAIL: "recover" }); + const env = buildTestEnv(binDir, { FAKE_OPENCODE_MESSAGE_FAIL: "mismatched-recover" }); try { const result = run("node", [SCRIPT, "task", "--json", "long running task"], { @@ -561,8 +552,7 @@ test("task recovers the final message over HTTP when only session.idle arrives ( assert.equal(payload.status, 0); assert.match(payload.rawOutput, /Handled the requested task/); const fakeState = readFakeState(binDir); - assert.equal(fakeState.lastMessage.delivery, "async"); - assert.equal(fakeState.lastMessage.endpoint, "prompt_async"); + assert.equal(payload.turnId, fakeState.responses[fakeState.responses.length - 1].info.id); } finally { cleanupServer(repo, env); } @@ -623,9 +613,6 @@ test("task fails when completion has no recoverable current-turn message (issue const payload = JSON.parse(result.stdout); assert.equal(payload.status, 1); assert.equal(payload.rawOutput, ""); - const fakeState = readFakeState(binDir); - assert.equal(fakeState.lastMessage.delivery, "async"); - assert.equal(fakeState.lastMessage.endpoint, "prompt_async"); } finally { cleanupServer(repo, env); } From 9103beadbfaf94acc07ef570cbf4352e27cbd244 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Thu, 9 Jul 2026 21:22:20 +0200 Subject: [PATCH 17/32] test(#12): drop bogus turnId assertion in pop-fallback recovery test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test asserted payload.turnId, but the task --json payload exposes only {status, threadId, rawOutput, touchedFiles, reasoningSummary} — no turnId — so it was always undefined. status 0 + the recovered rawOutput already prove the .pop() fallback selected the right message despite a stale event id. Co-Authored-By: Claude Opus 4.8 --- tests/runtime.test.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 5bb2ec9..cf6b483 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -550,9 +550,11 @@ test("task recovery falls back to the newest assistant message when event messag assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.equal(payload.status, 0); + // The recovered text ("Handled the requested task") comes from the stored + // assistant message, so status 0 + this rawOutput already prove the .pop() + // fallback selected the right message despite the stale event id. (The + // --json payload intentionally does not expose turnId.) assert.match(payload.rawOutput, /Handled the requested task/); - const fakeState = readFakeState(binDir); - assert.equal(payload.turnId, fakeState.responses[fakeState.responses.length - 1].info.id); } finally { cleanupServer(repo, env); } From 7137dab8af0bf6d5ee534a32453a349510e70dec Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Thu, 9 Jul 2026 23:22:23 +0200 Subject: [PATCH 18/32] fix: avoid reading untracked symlink targets (#16) --- plugins/opencode/scripts/lib/git.mjs | 12 +++++++++++- tests/git.test.mjs | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/plugins/opencode/scripts/lib/git.mjs b/plugins/opencode/scripts/lib/git.mjs index 1749cfc..e60cb2f 100644 --- a/plugins/opencode/scripts/lib/git.mjs +++ b/plugins/opencode/scripts/lib/git.mjs @@ -197,10 +197,20 @@ function formatUntrackedFile(cwd, relativePath) { const absolutePath = path.join(cwd, relativePath); let stat; try { - stat = fs.statSync(absolutePath); + stat = fs.lstatSync(absolutePath); } catch { return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`; } + if (stat.isSymbolicLink()) { + let linkTarget; + try { + linkTarget = fs.readlinkSync(absolutePath); + fs.lstatSync(path.resolve(path.dirname(absolutePath), linkTarget)); + } catch { + return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`; + } + return `### ${relativePath}\n${relativePath} -> ${linkTarget}`; + } if (stat.isDirectory()) { return `### ${relativePath}\n(skipped: directory)`; } diff --git a/tests/git.test.mjs b/tests/git.test.mjs index 5bd6c23..0bc6674 100644 --- a/tests/git.test.mjs +++ b/tests/git.test.mjs @@ -119,6 +119,25 @@ test("collectReviewContext skips broken untracked symlinks instead of crashing", assert.match(context.content, /skipped: broken symlink or unreadable file/i); }); +test("collectReviewContext does not read external untracked symlink targets", () => { + const cwd = makeTempDir(); + const externalDir = makeTempDir("opencode-plugin-external-"); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + const externalFile = path.join(externalDir, "outside-secret.txt"); + fs.writeFileSync(externalFile, "EXTERNAL_SYMLINK_SECRET_MARKER\n"); + fs.symlinkSync(externalFile, path.join(cwd, "external-link.txt")); + + const target = resolveReviewTarget(cwd, {}); + const context = collectReviewContext(cwd, target); + + assert.equal(target.mode, "working-tree"); + assert.match(context.content, /### external-link\.txt/); + assert.doesNotMatch(context.content, /EXTERNAL_SYMLINK_SECRET_MARKER/); +}); + test("collectReviewContext falls back to lightweight context for larger adversarial reviews", () => { const cwd = makeTempDir(); initGitRepo(cwd); From c07fc6f2436348c737654a897bcf883f246b4e71 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Thu, 9 Jul 2026 23:51:12 +0200 Subject: [PATCH 19/32] fix: harden cancel teardown leases (#13) --- plugins/opencode/scripts/lib/opencode.mjs | 51 ++-- plugins/opencode/scripts/lib/process.mjs | 119 +++++++++ .../opencode/scripts/lib/server-lifecycle.mjs | 25 +- plugins/opencode/scripts/lib/tracked-jobs.mjs | 2 +- .../opencode/scripts/opencode-companion.mjs | 36 +-- .../scripts/stop-review-gate-hook.mjs | 1 - tests/companion-correctness.test.mjs | 6 +- tests/process.test.mjs | 57 ++++- tests/runtime.test.mjs | 236 +++++++++++++++++- tests/server-lifecycle.test.mjs | 113 +++++++++ tests/state-races.test.mjs | 58 +++++ 11 files changed, 662 insertions(+), 42 deletions(-) diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index 2f92226..98f0670 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -777,6 +777,11 @@ async function abortSessionAtUrl(serverUrl, threadId, timeoutMs = 1000) { } } +function normalizeServerUrlForCompare(url) { + const normalized = String(url ?? "").trim().replace(/\/+$/, ""); + return normalized || null; +} + function getSessionsArray(response) { if (Array.isArray(response)) { return response; @@ -902,31 +907,39 @@ export async function getAuthStatus(cwd) { export async function interruptServerTurn(cwd, { threadId, serverUrl = null }) { if (!threadId) { + const serverExternal = + serverUrl && normalizeServerUrlForCompare(serverUrl) === normalizeServerUrlForCompare(process.env[SERVER_URL_ENV]); return { attempted: false, interrupted: false, transport: null, - detail: "missing OpenCode session id" + detail: "missing OpenCode session id", + ...(serverUrl ? { serverUrl } : {}), + ...(serverExternal ? { serverExternal: true } : {}) }; } if (serverUrl) { try { await abortSessionAtUrl(serverUrl, threadId); + const serverExternal = normalizeServerUrlForCompare(serverUrl) === normalizeServerUrlForCompare(process.env[SERVER_URL_ENV]); return { attempted: true, interrupted: true, transport: "server", detail: `Aborted OpenCode session ${threadId}.`, - serverUrl + serverUrl, + ...(serverExternal ? { serverExternal: true } : {}) }; } catch (error) { + const serverExternal = normalizeServerUrlForCompare(serverUrl) === normalizeServerUrlForCompare(process.env[SERVER_URL_ENV]); return { attempted: true, interrupted: false, transport: "server", detail: error instanceof Error ? error.message : String(error), - serverUrl + serverUrl, + ...(serverExternal ? { serverExternal: true } : {}) }; } } @@ -941,23 +954,33 @@ export async function interruptServerTurn(cwd, { threadId, serverUrl = null }) { }; } + let usedServerUrl = null; + let usedServerExternal = false; try { - return await withServer(cwd, async (client, server) => { - await client.abort(threadId); - return { - attempted: true, - interrupted: true, - transport: "server", - detail: `Aborted OpenCode session ${threadId}.`, - serverUrl: server.url - }; - }); + const server = await ensureServer(cwd); + usedServerUrl = server?.url ?? null; + usedServerExternal = Boolean(server?.external); + if (!usedServerUrl) { + throw new Error("OpenCode server did not become ready."); + } + const client = new OpencodeServerClient(usedServerUrl); + await client.abort(threadId); + return { + attempted: true, + interrupted: true, + transport: "server", + detail: `Aborted OpenCode session ${threadId}.`, + serverUrl: usedServerUrl, + serverExternal: usedServerExternal + }; } catch (error) { return { attempted: true, interrupted: false, transport: "server", - detail: error instanceof Error ? error.message : String(error) + detail: error instanceof Error ? error.message : String(error), + ...(usedServerUrl ? { serverUrl: usedServerUrl } : {}), + ...(usedServerUrl ? { serverExternal: usedServerExternal } : {}) }; } } diff --git a/plugins/opencode/scripts/lib/process.mjs b/plugins/opencode/scripts/lib/process.mjs index af28d1c..f04a1b5 100644 --- a/plugins/opencode/scripts/lib/process.mjs +++ b/plugins/opencode/scripts/lib/process.mjs @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import path from "node:path"; import process from "node:process"; export function runCommand(command, args = [], options = {}) { @@ -117,6 +118,124 @@ export function terminateProcessTree(pid, options = {}) { } } +function commandLineTokens(commandLine) { + const tokens = []; + let current = ""; + let quote = null; + + for (const char of String(commandLine ?? "")) { + if ((char === '"' || char === "'") && (!quote || quote === char)) { + quote = quote === char ? null : char; + continue; + } + if (!quote && /\s/.test(char)) { + if (current) { + tokens.push(current); + current = ""; + } + continue; + } + current += char; + } + + if (current) { + tokens.push(current); + } + return tokens; +} + +function tokenLooksLikeCompanionScript(token) { + return path.basename(token) === "opencode-companion.mjs"; +} + +function tokensContainJobId(tokens, jobId) { + const expected = String(jobId ?? ""); + if (!expected) { + return false; + } + + for (let index = 0; index < tokens.length; index += 1) { + if (tokens[index] === "--job-id" && tokens[index + 1] === expected) { + return true; + } + if (tokens[index] === `--job-id=${expected}`) { + return true; + } + } + return false; +} + +export function commandLineLooksLikeTaskWorker(commandLine, { jobId } = {}) { + const tokens = commandLineTokens(commandLine); + return ( + tokens.some(tokenLooksLikeCompanionScript) && + tokens.includes("task-worker") && + tokensContainJobId(tokens, jobId) + ); +} + +export function readProcessCommandLine(pid, options = {}) { + if (!Number.isFinite(pid)) { + return null; + } + + const platform = options.platform ?? process.platform; + const runCommandImpl = options.runCommandImpl ?? runCommand; + const result = + platform === "win32" + ? runCommandImpl( + "powershell.exe", + [ + "-NoProfile", + "-Command", + `(Get-CimInstance Win32_Process -Filter "ProcessId = ${Number(pid)}").CommandLine` + ], + { + cwd: options.cwd, + env: options.env + } + ) + : runCommandImpl("ps", ["-ww", "-p", String(pid), "-o", "args="], { + cwd: options.cwd, + env: options.env + }); + + if (result.error || result.status !== 0) { + return null; + } + + return String(result.stdout ?? "").trim() || null; +} + +export function terminateTaskWorkerProcessTree(pid, options = {}) { + if (!Number.isFinite(pid)) { + return { attempted: false, delivered: false, method: null }; + } + + const commandLine = readProcessCommandLine(pid, options); + if (!commandLine) { + return { + attempted: false, + delivered: false, + method: null, + reason: "identity-unverified", + commandLine: null + }; + } + + if (!commandLineLooksLikeTaskWorker(commandLine, { jobId: options.jobId })) { + return { + attempted: false, + delivered: false, + method: null, + reason: "identity-mismatch", + commandLine + }; + } + + return terminateProcessTree(pid, options); +} + export function formatCommandFailure(result) { const parts = [`${result.command} ${result.args.join(" ")}`.trim()]; if (result.signal) { diff --git a/plugins/opencode/scripts/lib/server-lifecycle.mjs b/plugins/opencode/scripts/lib/server-lifecycle.mjs index 779720d..e072b99 100644 --- a/plugins/opencode/scripts/lib/server-lifecycle.mjs +++ b/plugins/opencode/scripts/lib/server-lifecycle.mjs @@ -228,6 +228,15 @@ function hasActiveServerLeases(session) { return Array.isArray(session?.leases) && session.leases.some((lease) => isLeaseActive(lease)); } +function removeServerLeaseForPid(session, pid) { + return { + ...session, + leases: Array.isArray(session?.leases) + ? session.leases.filter((lease) => Number(lease?.pid) !== pid) + : [] + }; +} + function addServerLease(session, options = {}) { const pruned = pruneServerLeases(session); const withoutSelf = pruned.leases.filter((lease) => Number(lease?.pid) !== process.pid); @@ -368,8 +377,16 @@ export async function ensureServer(cwd, options = {}) { const staleExisting = loadServerSession(cwd); if (staleExisting) { + const { url, pidFile, logFile, sessionDir, pid, external } = staleExisting; + // The server lock is already held here; intentionally omit cwd so teardown + // uses the unlocked path even if the persisted session schema grows. await teardownServerSession({ - ...staleExisting, + url, + pidFile, + logFile, + sessionDir, + pid, + external, killProcess: options.killProcess ?? null }); clearServerSession(cwd); @@ -467,6 +484,7 @@ async function teardownServerSessionUnlocked({ export async function teardownServerSession({ cwd = null, force = false, + ignoreCurrentProcessLease = false, url = null, pidFile = null, logFile = null, @@ -495,8 +513,9 @@ export async function teardownServerSession({ const session = current && (!requestedUrl || currentUrl === requestedUrl) ? current : null; if (session && !force) { const pruned = pruneServerLeases(session); - if (hasActiveServerLeases(pruned)) { - saveServerSession(cwd, pruned); + const leaseChecked = ignoreCurrentProcessLease ? removeServerLeaseForPid(pruned, process.pid) : pruned; + if (hasActiveServerLeases(leaseChecked)) { + saveServerSession(cwd, leaseChecked); return { skipped: true, reason: "active-leases" }; } } diff --git a/plugins/opencode/scripts/lib/tracked-jobs.mjs b/plugins/opencode/scripts/lib/tracked-jobs.mjs index c7dde7c..065ead3 100644 --- a/plugins/opencode/scripts/lib/tracked-jobs.mjs +++ b/plugins/opencode/scripts/lib/tracked-jobs.mjs @@ -148,7 +148,7 @@ function currentStoredStatus(stateJob, storedJob) { return storedJob?.status ?? stateJob?.status ?? null; } -function isTerminalStatus(status) { +export function isTerminalStatus(status) { return status === "completed" || status === "failed" || status === "cancelled"; } diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index 1ac04d6..311bdd7 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -24,7 +24,7 @@ import { import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs"; import { readStdinIfPiped } from "./lib/fs.mjs"; import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs"; -import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs"; +import { binaryAvailable, terminateTaskWorkerProcessTree } from "./lib/process.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; import { applyJobPatch, @@ -53,6 +53,7 @@ import { createJobProgressUpdater, createJobRecord, createProgressReporter, + isTerminalStatus, nowIso, runTrackedJob, SESSION_ID_ENV @@ -961,16 +962,13 @@ function handleTaskResumeCandidate(argv) { outputCommandResult(payload, rendered, options.json); } -function isCompletionTerminalStatus(status) { - return status === "completed" || status === "failed"; -} - function terminalJobIndexPatch(job) { + const fallbackPhase = job.status === "completed" ? "done" : job.status === "cancelled" ? "cancelled" : "failed"; return Object.fromEntries( Object.entries({ id: job.id, status: job.status, - phase: job.phase ?? (job.status === "completed" ? "done" : "failed"), + phase: job.phase ?? fallbackPhase, pid: null, threadId: job.threadId, turnId: job.turnId, @@ -1019,7 +1017,7 @@ function cancelJobIfStillActive(workspaceRoot, job, completedAt) { ...(storedJob ?? {}) }; - if (isCompletionTerminalStatus(currentJob.status)) { + if (isTerminalStatus(currentJob.status)) { applyJobPatch(state, terminalJobIndexPatch(currentJob)); nextJob = { ...currentJob, @@ -1066,7 +1064,7 @@ async function handleCancel(argv) { const { workspaceRoot, job } = resolveCancelableJob(cwd, reference, { env: process.env }); const currentJob = readCurrentCancelJob(workspaceRoot, job); - if (isCompletionTerminalStatus(currentJob.status)) { + if (isTerminalStatus(currentJob.status)) { const syncedJob = syncTerminalJobIndex(workspaceRoot, currentJob); const payload = { jobId: syncedJob.id, @@ -1082,10 +1080,9 @@ async function handleCancel(argv) { } const threadId = currentJob.threadId ?? null; - const turnId = currentJob.turnId ?? null; const serverUrl = currentJob.serverUrl ?? null; - const interrupt = await interruptServerTurn(cwd, { threadId, turnId, serverUrl }); + const interrupt = await interruptServerTurn(cwd, { threadId, serverUrl }); const completedAt = nowIso(); const cancelResult = cancelJobIfStillActive(workspaceRoot, currentJob, completedAt); if (cancelResult.cancelled) { @@ -1099,12 +1096,21 @@ async function handleCancel(argv) { : `OpenCode session abort failed${interrupt.detail ? `: ${interrupt.detail}` : "."}` ); } - terminateProcessTree(currentJob.pid ?? Number.NaN); + const termination = terminateTaskWorkerProcessTree(currentJob.pid ?? Number.NaN, { + jobId: currentJob.id + }); + if (termination.reason === "identity-mismatch" || termination.reason === "identity-unverified") { + appendLogLine(cancelLogFile, `Skipped worker process kill: ${termination.reason}.`); + } try { - await teardownServerSession({ - cwd: workspaceRoot, - ...(serverUrl ? { url: serverUrl } : {}) - }); + const teardownServerUrl = interrupt.serverUrl ?? serverUrl; + if (!interrupt.serverExternal) { + await teardownServerSession({ + cwd: workspaceRoot, + ignoreCurrentProcessLease: Boolean(interrupt.serverUrl && !serverUrl), + ...(teardownServerUrl ? { url: teardownServerUrl } : {}) + }); + } } catch (error) { appendLogLine( cancelLogFile, diff --git a/plugins/opencode/scripts/stop-review-gate-hook.mjs b/plugins/opencode/scripts/stop-review-gate-hook.mjs index b8e72c6..a0ba77e 100644 --- a/plugins/opencode/scripts/stop-review-gate-hook.mjs +++ b/plugins/opencode/scripts/stop-review-gate-hook.mjs @@ -17,7 +17,6 @@ import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; const STOP_REVIEW_TIMEOUT_MS = 15 * 60 * 1000; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = path.resolve(SCRIPT_DIR, ".."); -const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; function readHookInput() { const raw = fs.readFileSync(0, "utf8").trim(); diff --git a/tests/companion-correctness.test.mjs b/tests/companion-correctness.test.mjs index a0483cd..1e59dcc 100644 --- a/tests/companion-correctness.test.mjs +++ b/tests/companion-correctness.test.mjs @@ -9,7 +9,7 @@ import { makeTempDir, run } from "./helpers.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const SCRIPT = path.join(ROOT, "plugins", "opencode", "scripts", "opencode-companion.mjs"); -const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; +const STOP_REVIEW_PROMPT_TEXT = "Run a stop-gate review of the previous Claude turn."; function withPluginData(pluginData, fn) { const previousPluginData = process.env.CLAUDE_PLUGIN_DATA; @@ -125,7 +125,7 @@ test("task resume candidate ignores failed and cancelled task jobs", () => { }); test("task prompt marker text does not classify as stop review without the explicit flag", () => { - const { result, jobs } = runTaskWithoutOpenCode([`${STOP_REVIEW_TASK_MARKER} Please handle this normal task.`]); + const { result, jobs } = runTaskWithoutOpenCode([`${STOP_REVIEW_PROMPT_TEXT} Please handle this normal task.`]); assert.notEqual(result.status, 0); assert.match(result.stderr, /OpenCode CLI is not installed/); @@ -136,7 +136,7 @@ test("task prompt marker text does not classify as stop review without the expli }); test("task stop review classification comes from the explicit flag", () => { - const { result, jobs } = runTaskWithoutOpenCode(["--stop-review", STOP_REVIEW_TASK_MARKER]); + const { result, jobs } = runTaskWithoutOpenCode(["--stop-review", STOP_REVIEW_PROMPT_TEXT]); assert.notEqual(result.status, 0); assert.match(result.stderr, /OpenCode CLI is not installed/); diff --git a/tests/process.test.mjs b/tests/process.test.mjs index c489177..e218e54 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { terminateProcessTree } from "../plugins/opencode/scripts/lib/process.mjs"; +import { terminateProcessTree, terminateTaskWorkerProcessTree } from "../plugins/opencode/scripts/lib/process.mjs"; test("terminateProcessTree uses taskkill on Windows", () => { let captured = null; @@ -53,3 +53,58 @@ test("terminateProcessTree treats missing Windows processes as already stopped", assert.equal(outcome.result.status, 128); assert.match(outcome.result.stdout, /not found/i); }); + +test("terminateTaskWorkerProcessTree skips pid when command line is not the expected worker", () => { + const outcome = terminateTaskWorkerProcessTree(1234, { + jobId: "job-expected", + platform: "linux", + runCommandImpl(command, args) { + assert.equal(command, "ps"); + assert.deepEqual(args, ["-ww", "-p", "1234", "-o", "args="]); + return { + command, + args, + status: 0, + signal: null, + stdout: "node unrelated-script.mjs --job-id job-expected\n", + stderr: "", + error: null + }; + }, + killImpl() { + throw new Error("kill should not run for a mismatched pid"); + } + }); + + assert.equal(outcome.attempted, false); + assert.equal(outcome.delivered, false); + assert.equal(outcome.reason, "identity-mismatch"); +}); + +test("terminateTaskWorkerProcessTree keeps process-group termination for matching worker", () => { + const kills = []; + const outcome = terminateTaskWorkerProcessTree(1234, { + jobId: "job-expected", + platform: "linux", + runCommandImpl(command, args) { + return { + command, + args, + status: 0, + signal: null, + stdout: + 'node /repo/plugins/opencode/scripts/opencode-companion.mjs task-worker --cwd /repo --job-id "job-expected"\n', + stderr: "", + error: null + }; + }, + killImpl(pid, signal) { + kills.push({ pid, signal }); + } + }); + + assert.deepEqual(kills, [{ pid: -1234, signal: "SIGTERM" }]); + assert.equal(outcome.attempted, true); + assert.equal(outcome.delivered, true); + assert.equal(outcome.method, "process-group"); +}); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index cf6b483..9784b26 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -6,11 +6,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import { fileURLToPath } from "node:url"; -import { runServerTurn } from "../plugins/opencode/scripts/lib/opencode.mjs"; -import { loadServerSession, saveServerSession } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; -import { resolveStateFile, saveState } from "../plugins/opencode/scripts/lib/state.mjs"; +import { interruptServerTurn, runServerTurn } from "../plugins/opencode/scripts/lib/opencode.mjs"; +import { loadServerSession, saveServerSession, SERVER_URL_ENV } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; +import { resolveJobLogFile, resolveStateFile, saveState, writeJobFile } from "../plugins/opencode/scripts/lib/state.mjs"; import { buildEnv, installFakeOpencode, readFakeState, readServerBootCount } from "./fake-opencode-fixture.mjs"; -import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; +import { initGitRepo, makeTempDir, run, writeExecutable } from "./helpers.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const PLUGIN_ROOT = path.join(ROOT, "plugins", "opencode"); @@ -95,6 +95,70 @@ function jsonResponse(value, status = 200) { }); } +test("interruptServerTurn marks env-provided server urls as external", async () => { + const workspace = makeTempDir(); + const binDir = makeTempDir(); + writeExecutable( + path.join(binDir, "opencode"), + `#!/usr/bin/env node +if (process.argv[2] === "--version") { + console.log("opencode test"); + process.exit(0); +} +if (process.argv[2] === "serve" && process.argv.includes("--help")) { + console.log("serve help"); + process.exit(0); +} +process.exit(1); +` + ); + const previousFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (requestUrl, options = {}) => { + const url = new URL(String(requestUrl)); + calls.push({ method: options.method ?? "GET", pathname: url.pathname }); + if (url.pathname === "/global/health") { + return jsonResponse({ ok: true }); + } + if (url.pathname === "/session/ses_external/abort") { + return jsonResponse({ ok: true }); + } + if (url.pathname === "/global/dispose") { + throw new Error("external server should not be disposed"); + } + return jsonResponse({ error: "not found" }, 404); + }; + + try { + const result = await withProcessEnv( + { + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + [SERVER_URL_ENV]: "http://opencode.test" + }, + () => interruptServerTurn(workspace, { threadId: "ses_external" }) + ); + + assert.equal(result.interrupted, true); + assert.equal(result.serverUrl, "http://opencode.test"); + assert.equal(result.serverExternal, true); + assert.deepEqual(calls, [ + { method: "GET", pathname: "/global/health" }, + { method: "POST", pathname: "/session/ses_external/abort" } + ]); + + const missingThreadResult = await withProcessEnv( + { + [SERVER_URL_ENV]: "http://opencode.test/" + }, + () => interruptServerTurn(workspace, { threadId: null, serverUrl: "http://opencode.test" }) + ); + assert.equal(missingThreadResult.attempted, false); + assert.equal(missingThreadResult.serverExternal, true); + } finally { + globalThis.fetch = previousFetch; + } +}); + test("session end clears server session when job cleanup fails but teardown succeeds", async () => { const workspace = makeTempDir(); const pluginDataDir = makeTempDir("opencode-plugin-data-"); @@ -197,6 +261,170 @@ test("stop review gate tears down a server left by a failed stop review task", { }); }); +test("cancel tears down a server it starts to abort a job without a recorded server url", { skip: LOCAL_LISTEN_SKIP }, async () => { + const workspace = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + const env = buildTestEnv(binDir); + + await withProcessEnv({ CLAUDE_PLUGIN_DATA: env.CLAUDE_PLUGIN_DATA }, async () => { + const jobId = "job-cancel-starts-server"; + const timestamp = new Date().toISOString(); + const logFile = resolveJobLogFile(workspace, jobId); + const runningJob = { + id: jobId, + workspaceRoot: workspace, + jobClass: "task", + kind: "task", + status: "running", + phase: "running", + pid: null, + title: "Running task", + threadId: "ses_cancel_without_server_url", + logFile, + createdAt: timestamp, + updatedAt: timestamp + }; + saveState(workspace, { + version: 1, + config: { stopReviewGate: false }, + jobs: [runningJob] + }); + writeJobFile(workspace, jobId, runningJob); + + try { + const result = run("node", [SCRIPT, "cancel", jobId, "--cwd", workspace, "--json"], { + cwd: workspace, + env + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.cancelled, true); + assert.equal(payload.turnInterruptAttempted, true); + assert.equal(payload.turnInterrupted, true); + assert.equal(readFakeState(binDir).lastAbort, "ses_cancel_without_server_url"); + assert.equal(loadServerSession(workspace), null); + } finally { + cleanupServer(workspace, env); + } + }); +}); + +test("cancel aborts but does not dispose an env-provided external server", async () => { + const workspace = makeTempDir(); + const binDir = makeTempDir(); + const pluginDataDir = makeTempDir("opencode-plugin-data-"); + const fetchLog = path.join(pluginDataDir, "external-fetch.jsonl"); + const fetchPreload = path.join(pluginDataDir, "external-fetch-preload.mjs"); + fs.writeFileSync( + fetchPreload, + ` +import fs from "node:fs"; + +const logFile = process.env.TEST_FETCH_LOG; +globalThis.fetch = async (requestUrl, options = {}) => { + const url = new URL(String(requestUrl)); + const method = options.method ?? "GET"; + fs.appendFileSync(logFile, JSON.stringify({ method, pathname: url.pathname }) + "\\n", "utf8"); + if (method === "GET" && url.pathname === "/global/health") { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" } + }); + } + if (method === "POST" && url.pathname === "/session/ses_external_cancel/abort") { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" } + }); + } + if (method === "POST" && url.pathname === "/global/dispose") { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" } + }); + } + return new Response(JSON.stringify({ error: "not found" }), { + status: 404, + headers: { "content-type": "application/json" } + }); +}; +`, + "utf8" + ); + writeExecutable( + path.join(binDir, "opencode"), + `#!/usr/bin/env node +if (process.argv[2] === "--version") { + console.log("opencode test"); + process.exit(0); +} +if (process.argv[2] === "serve" && process.argv.includes("--help")) { + console.log("serve help"); + process.exit(0); +} +process.exit(1); +` + ); + + await withProcessEnv({ CLAUDE_PLUGIN_DATA: pluginDataDir }, async () => { + const jobId = "job-cancel-external-server"; + const timestamp = new Date().toISOString(); + const logFile = resolveJobLogFile(workspace, jobId); + const runningJob = { + id: jobId, + workspaceRoot: workspace, + jobClass: "task", + kind: "task", + status: "running", + phase: "running", + pid: null, + title: "Running external task", + threadId: "ses_external_cancel", + logFile, + createdAt: timestamp, + updatedAt: timestamp + }; + saveState(workspace, { + version: 1, + config: { stopReviewGate: false }, + jobs: [runningJob] + }); + writeJobFile(workspace, jobId, runningJob); + + const result = run(process.execPath, ["--import", fetchPreload, SCRIPT, "cancel", jobId, "--cwd", workspace, "--json"], { + cwd: workspace, + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + CLAUDE_PLUGIN_DATA: pluginDataDir, + OPENCODE_COMPANION_SESSION_ID: "sess-current", + TEST_FETCH_LOG: fetchLog, + [SERVER_URL_ENV]: "http://opencode.test" + } + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.cancelled, true); + assert.equal(payload.turnInterruptAttempted, true); + assert.equal(payload.turnInterrupted, true); + + const calls = fs + .readFileSync(fetchLog, "utf8") + .trim() + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)); + assert.deepEqual(calls, [ + { method: "GET", pathname: "/global/health" }, + { method: "POST", pathname: "/session/ses_external_cancel/abort" } + ]); + assert.equal(loadServerSession(workspace), null); + }); +}); + function installFailingCaptureFetch(createdSessionId = "ses_created") { const previousFetch = globalThis.fetch; const calls = []; diff --git a/tests/server-lifecycle.test.mjs b/tests/server-lifecycle.test.mjs index 6619ba4..a519b62 100644 --- a/tests/server-lifecycle.test.mjs +++ b/tests/server-lifecycle.test.mjs @@ -1,5 +1,6 @@ import http from "node:http"; import net from "node:net"; +import { spawn } from "node:child_process"; import test from "node:test"; import assert from "node:assert/strict"; @@ -143,3 +144,115 @@ test("teardownServerSession skips local teardown while a server lease is active" } } }); + +test("teardownServerSession can ignore only this process lease", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + + try { + const session = { + url: "http://127.0.0.1:1", + pid: 123456, + pidFile: null, + logFile: null, + sessionDir: null, + external: false, + leases: [ + { + pid: process.pid, + token: "cancel-process-lease", + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60000).toISOString() + } + ] + }; + saveServerSession(workspace, session); + + let killedPid = null; + const result = await teardownServerSession({ + cwd: workspace, + url: session.url, + pid: session.pid, + ignoreCurrentProcessLease: true, + killProcess: (pid) => { + killedPid = pid; + } + }); + + assert.equal(result.skipped, false); + assert.equal(killedPid, session.pid); + assert.equal(loadServerSession(workspace), null); + } finally { + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } +}); + +test("teardownServerSession still skips ignored self lease when another process has a lease", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + const otherLeaseHolder = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], { + stdio: "ignore", + windowsHide: true + }); + + try { + const session = { + url: "http://127.0.0.1:1", + pid: 123456, + pidFile: null, + logFile: null, + sessionDir: null, + external: false, + leases: [ + { + pid: process.pid, + token: "cancel-process-lease", + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60000).toISOString() + }, + { + pid: otherLeaseHolder.pid, + token: "other-live-lease", + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60000).toISOString() + } + ] + }; + saveServerSession(workspace, session); + + let killedPid = null; + const result = await teardownServerSession({ + cwd: workspace, + url: session.url, + pid: session.pid, + ignoreCurrentProcessLease: true, + killProcess: (pid) => { + killedPid = pid; + } + }); + const stored = loadServerSession(workspace); + + assert.equal(result.skipped, true); + assert.equal(result.reason, "active-leases"); + assert.equal(killedPid, null); + assert.deepEqual( + stored.leases.map((lease) => lease.token), + ["other-live-lease"] + ); + } finally { + otherLeaseHolder.kill(); + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } +}); diff --git a/tests/state-races.test.mjs b/tests/state-races.test.mjs index b3d811c..bb9e526 100644 --- a/tests/state-races.test.mjs +++ b/tests/state-races.test.mjs @@ -206,6 +206,64 @@ test("cancel does not clobber a completed stored job when the state index is sta }); }); +test("cancel treats a cancelled stored job as terminal when the state index is stale active", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + + await withPluginData(pluginDataDir, async () => { + const jobId = "job-cancelled-on-disk"; + const timestamp = new Date().toISOString(); + const logFile = resolveJobLogFile(workspace, jobId); + saveState(workspace, { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: jobId, + status: "running", + phase: "running", + pid: null, + title: "Cancelled on disk", + logFile, + createdAt: timestamp, + updatedAt: timestamp + } + ] + }); + writeJobFile(workspace, jobId, { + id: jobId, + workspaceRoot: workspace, + status: "cancelled", + phase: "cancelled", + pid: null, + title: "Cancelled on disk", + logFile, + completedAt: timestamp, + cancelledAt: timestamp, + errorMessage: "Already cancelled." + }); + + const result = run(process.execPath, [COMPANION, "cancel", jobId, "--cwd", workspace, "--json"], { + env: { + ...process.env, + CLAUDE_PLUGIN_DATA: pluginDataDir + } + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, "cancelled"); + assert.equal(payload.cancelled, false); + assert.equal(payload.turnInterruptAttempted, false); + + const storedJob = readJobFile(resolveJobFile(workspace, jobId)); + const indexedJob = loadState(workspace).jobs.find((candidate) => candidate.id === jobId); + assert.equal(storedJob.errorMessage, "Already cancelled."); + assert.equal(indexedJob.status, "cancelled"); + assert.equal(indexedJob.phase, "cancelled"); + }); +}); + test("cancel tears down the shared server session when only dead leases remain", async () => { const workspace = makeTempDir(); const pluginDataDir = makeTempDir(); From 94edd96a8b3f2b10b8addae25a47b0a71e99d6c6 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Fri, 10 Jul 2026 05:21:19 +0200 Subject: [PATCH 20/32] Harden OpenCode turn transport and recovery (#15) Fixes three transport-layer issues surfaced by external review: - SSE-drop race: a normal/handled event-stream termination no longer counts as turn completion while the /message POST is still outstanding. On a stream drop, the response is given a bounded grace window before recovery, so a slow-but-successful turn is not failed. - Unbounded recovery: requestWithFreshConnection now has its own request timeout and settles on all terminal socket events (error/close/aborted/ timeout); recovery runs under its own AbortController deadline instead of relying on the turn's finally-time abort. - Stale-snapshot recovery: a resumed session whose prior-assistant snapshot failed no longer falls back to popping the newest assistant message (which could be from an earlier turn); it requires an exact current-message id match, failing closed. Review rework: removed the responseSuccessPromise that completed the turn the instant the sync /message response resolved. It cut the issue #12 scheduleResponseFallbackCompletion 250ms grace short, dropping trailing SSE events (e.g. file.edited) and forcing a redundant recovery GET on successful turns. Completion now flows through session.idle / the response fallback / timeout, and recovery keys off "we lack a result" rather than !completed. npm test: 63 passed, 0 failed, 0 skipped (localhost-binding tests included). Co-Authored-By: Claude Opus 4.8 --- .../opencode/scripts/lib/opencode-server.mjs | 63 ++++++++++- plugins/opencode/scripts/lib/opencode.mjs | 84 ++++++++++++-- tests/fake-opencode-fixture.mjs | 19 +++- tests/opencode-server.test.mjs | 105 ++++++++++++++++++ tests/runtime.test.mjs | 82 ++++++++++++++ 5 files changed, 336 insertions(+), 17 deletions(-) diff --git a/plugins/opencode/scripts/lib/opencode-server.mjs b/plugins/opencode/scripts/lib/opencode-server.mjs index cf522e9..ab80d0d 100644 --- a/plugins/opencode/scripts/lib/opencode-server.mjs +++ b/plugins/opencode/scripts/lib/opencode-server.mjs @@ -1,6 +1,8 @@ import http from "node:http"; import https from "node:https"; +const DEFAULT_FRESH_CONNECTION_TIMEOUT_MS = 30_000; + export class OpencodeHttpError extends Error { constructor(message, options = {}) { super(message); @@ -43,6 +45,10 @@ function parseBodyText(text, contentType = "") { function requestWithFreshConnection(url, options = {}) { const transport = url.protocol === "https:" ? https : http; const body = options.body == null ? null : JSON.stringify(options.body); + const requestTimeoutMs = Math.max( + 0, + Number(options.requestTimeoutMs ?? DEFAULT_FRESH_CONNECTION_TIMEOUT_MS) || DEFAULT_FRESH_CONNECTION_TIMEOUT_MS + ); const headers = { ...(body == null ? {} : { "content-type": "application/json", "content-length": Buffer.byteLength(body) }), ...(options.headers ?? {}), @@ -50,6 +56,29 @@ function requestWithFreshConnection(url, options = {}) { }; return new Promise((resolve, reject) => { + let settled = false; + let responseEnded = false; + + function settle(fn, value) { + if (settled) { + return; + } + settled = true; + fn(value); + } + + function resolveOnce(value) { + settle(resolve, value); + } + + function rejectOnce(error) { + settle(reject, error); + } + + function transportError(message) { + return new Error(`OpenCode ${options.method} ${options.path} ${message}.`); + } + const req = transport.request( url, { @@ -62,9 +91,10 @@ function requestWithFreshConnection(url, options = {}) { const chunks = []; res.on("data", (chunk) => chunks.push(chunk)); res.on("end", () => { + responseEnded = true; const text = Buffer.concat(chunks).toString("utf8"); if (res.statusCode < 200 || res.statusCode >= 300) { - reject( + rejectOnce( new OpencodeHttpError(`OpenCode ${options.method} ${options.path} failed with HTTP ${res.statusCode}.`, { status: res.statusCode, body: text, @@ -76,14 +106,33 @@ function requestWithFreshConnection(url, options = {}) { try { const contentType = res.headers["content-type"]; - resolve(parseBodyText(text, Array.isArray(contentType) ? contentType.join(";") : contentType ?? "")); + resolveOnce(parseBodyText(text, Array.isArray(contentType) ? contentType.join(";") : contentType ?? "")); } catch (error) { - reject(error); + rejectOnce(error); + } + }); + res.on("aborted", () => rejectOnce(transportError("response aborted before completion"))); + res.on("error", rejectOnce); + res.on("close", () => { + if (!responseEnded) { + rejectOnce(transportError("response closed before completion")); } }); } ); - req.on("error", reject); + req.on("error", rejectOnce); + req.on("close", () => { + if (!responseEnded) { + rejectOnce(transportError("request closed before response completion")); + } + }); + if (requestTimeoutMs > 0) { + req.setTimeout(requestTimeoutMs, () => { + const error = transportError(`timed out after ${requestTimeoutMs}ms`); + rejectOnce(error); + req.destroy(error); + }); + } if (body != null) { req.write(body); } @@ -182,7 +231,8 @@ export class OpencodeServerClient { path, headers: options.headers, body: options.body, - signal: options.signal + signal: options.signal, + requestTimeoutMs: options.requestTimeoutMs }); } @@ -256,7 +306,8 @@ export class OpencodeServerClient { listMessages(sessionID, options = {}) { return this.request("GET", `/session/${encodePathSegment(sessionID)}/message`, { signal: options.signal, - freshConnection: options.freshConnection + freshConnection: options.freshConnection, + requestTimeoutMs: options.requestTimeoutMs }); } diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index 2f92226..06e8eda 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -18,6 +18,8 @@ const READ_ONLY_AGENT = "plan"; // (issue #2 / review finding #17). Deep reviews legitimately run many minutes, // so keep it generous; on expiry we still try to recover the final message. const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000; +const DEFAULT_STREAM_DROP_GRACE_MS = 5000; +const DEFAULT_RECOVERY_TIMEOUT_MS = 5000; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -362,6 +364,9 @@ function createTurnCaptureState(sessionID, options = {}) { resolveCompletion, rejectCompletion, priorAssistantIds: new Set(options.priorAssistantIds ?? []), + priorAssistantSnapshotSucceeded: false, + priorAssistantSnapshotError: null, + resumed: Boolean(options.resumed), finalMessage: "", structuredOutput: null, reasoningSummary: [], @@ -604,13 +609,30 @@ async function applyOpenCodeEvent(client, state, event, meta = {}) { // Re-fetch it over HTTP so a slow-but-successful turn isn't reported as failed // (issue #2). async function recoverFinalMessageFromServer(client, state, options = {}) { + const recoveryTimeoutMs = Math.max( + 0, + Number(options.recoveryTimeoutMs ?? DEFAULT_RECOVERY_TIMEOUT_MS) || DEFAULT_RECOVERY_TIMEOUT_MS + ); + const controller = new AbortController(); + let timeout = null; + if (recoveryTimeoutMs > 0) { + timeout = setTimeout(() => { + controller.abort(new Error(`OpenCode recovery timed out after ${recoveryTimeoutMs}ms.`)); + }, recoveryTimeoutMs); + timeout.unref?.(); + } + try { - const raw = await client.listMessages(state.sessionID, { signal: options.signal, freshConnection: true }); + const raw = await client.listMessages(state.sessionID, { + signal: controller.signal, + freshConnection: true, + requestTimeoutMs: recoveryTimeoutMs + }); const messages = getMessagesArray(raw).filter(isAssistantMessage); let assistant = state.messageID ? messages.find((message) => extractMessageId(message) === state.messageID) : null; - if (!assistant) { + if (!assistant && (state.priorAssistantSnapshotSucceeded || !state.resumed)) { assistant = messages .filter((message) => { const messageID = extractMessageId(message); @@ -632,6 +654,10 @@ async function recoverFinalMessageFromServer(client, state, options = {}) { } catch (error) { state.recoveryError = error; return false; + } finally { + if (timeout) { + clearTimeout(timeout); + } } } @@ -644,8 +670,12 @@ async function snapshotPriorAssistantIds(client, state, options = {}) { .map(extractMessageId) .filter(Boolean) ); - } catch { + state.priorAssistantSnapshotSucceeded = true; + state.priorAssistantSnapshotError = null; + } catch (error) { state.priorAssistantIds = new Set(); + state.priorAssistantSnapshotSucceeded = false; + state.priorAssistantSnapshotError = error; } } @@ -694,6 +724,12 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { // reject with a transport error well before `session.idle` arrives. That // transport failure must NOT fail the turn — only a real HTTP rejection (bad // request, bad model, etc.) is fatal (issue #2 / findings #16, #17). + let responseSettled = false; + let resolveResponseSettled; + const responseSettledPromise = new Promise((resolve) => { + resolveResponseSettled = resolve; + }); + const responsePromise = startRequest(eventAbort.signal) .then((response) => { state.response = response; @@ -710,10 +746,19 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { completeTurn(state); } return null; + }) + .finally(() => { + responseSettled = true; + resolveResponseSettled(state); }); - // Complete on `session.idle` / `session.error` / the response fallback, on the - // stream closing, or on the outer safety timeout — whichever comes first. + // Complete on `session.idle` / `session.error`, the response fallback + // (`scheduleResponseFallbackCompletion`, whose 250ms grace also lets trailing + // events like file edits drain before we finalize), or the outer safety + // timeout. A bare event-stream close is NOT completion while /message is still + // pending; on a stream drop, give the response a short grace window before + // trying recovery. We deliberately do NOT complete the race the instant the + // response resolves — that would cut the trailing-event grace short. const timeoutPromise = turnTimeoutMs > 0 ? new Promise((resolve) => { @@ -724,12 +769,31 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { turnTimer.unref?.(); }) : new Promise(() => {}); - await Promise.race([state.completion, eventStream, timeoutPromise]); + const streamDropGraceMs = Math.max( + 0, + Number(options.streamDropGraceMs ?? DEFAULT_STREAM_DROP_GRACE_MS) || DEFAULT_STREAM_DROP_GRACE_MS + ); + const eventStreamDropPromise = eventStream.then(async () => { + if (state.completed || responseSettled) { + return state; + } + if (streamDropGraceMs > 0) { + await Promise.race([state.completion, responseSettledPromise, sleep(streamDropGraceMs)]); + } + return state; + }); + + await Promise.race([state.completion, eventStreamDropPromise, timeoutPromise]); // If we didn't capture a usable result, pull the finished message straight - // from the server before giving up. - if (!state.error && (!state.completed || (!state.finalMessage && state.structuredOutput == null))) { - await recoverFinalMessageFromServer(client, state, { signal: eventAbort.signal }); + // from the server before giving up. Key this off whether we actually lack a + // result — NOT off `state.completed`. The race can resolve while a result is + // already captured but the completion flag hasn't flipped yet (e.g. within the + // response fallback's 250ms grace); re-fetching a message we already have is a + // redundant GET that undoes the issue #12 sync-capture optimization. This + // mirrors the "lack a result" check used just below for the error path. + if (!state.error && !state.finalMessage && state.structuredOutput == null) { + await recoverFinalMessageFromServer(client, state, { recoveryTimeoutMs: options.recoveryTimeoutMs }); } // Nothing captured and no genuine error => surface the underlying cause. @@ -982,6 +1046,7 @@ export async function runServerTurn(cwd, options = {}) { let sessionID = options.resumeThreadId ?? options.resumeSessionId ?? null; let createdSessionID = null; + const resumedSession = Boolean(sessionID); if (sessionID) { emitProgress(options.onProgress, `Resuming OpenCode session ${sessionID}.`, "starting", { @@ -1031,6 +1096,7 @@ export async function runServerTurn(cwd, options = {}) { { onProgress: options.onProgress, write, + resumed: resumedSession, turnTimeoutMs: options.turnTimeoutMs } ); diff --git a/tests/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs index e5ffceb..24cee8e 100644 --- a/tests/fake-opencode-fixture.mjs +++ b/tests/fake-opencode-fixture.mjs @@ -196,11 +196,14 @@ async function handleMessage(req, res, sessionID) { : "Handled the requested task.\\nTask prompt accepted."; const parts = structuredOutputParts(body) || [{ type: "text", text: finalText }]; const failMode = process.env.FAKE_OPENCODE_MESSAGE_FAIL; - if (failMode === "empty-recovery") { + if (failMode === "empty-recovery" || failMode === "snapshot-fails-empty-recovery") { emit({ type: "session.idle", sessionID }); res.destroy(); return; } + if (failMode === "event-drop-before-message-response") { + await new Promise((resolve) => setTimeout(resolve, 300)); + } const finalState = loadState(); finalState.lastResponseParts = parts; @@ -363,6 +366,9 @@ const server = http.createServer(async (req, res) => { res.write(":ok\\n\\n"); clients.add(res); req.on("close", () => clients.delete(res)); + if (process.env.FAKE_OPENCODE_MESSAGE_FAIL === "event-drop-before-message-response") { + setTimeout(() => res.destroy(), 30); + } return; } @@ -399,7 +405,16 @@ const server = http.createServer(async (req, res) => { const messageMatch = url.pathname.match(/^\\/session\\/([^/]+)\\/message$/); if (req.method === "GET" && messageMatch) { const sessionID = decodeURIComponent(messageMatch[1]); - const responses = (loadState().responses || []).filter((entry) => entry.sessionID === sessionID); + const state = loadState(); + if (process.env.FAKE_OPENCODE_MESSAGE_FAIL === "snapshot-fails-empty-recovery") { + state.messageListCalls = (state.messageListCalls || 0) + 1; + saveState(state); + if (state.messageListCalls === 1) { + sendJson(res, { error: "snapshot failed" }, 503); + return; + } + } + const responses = (state.responses || []).filter((entry) => entry.sessionID === sessionID); sendJson(res, responses); return; } diff --git a/tests/opencode-server.test.mjs b/tests/opencode-server.test.mjs index f22cce7..1da8636 100644 --- a/tests/opencode-server.test.mjs +++ b/tests/opencode-server.test.mjs @@ -1,8 +1,41 @@ +import http from "node:http"; +import { EventEmitter } from "node:events"; import test from "node:test"; import assert from "node:assert/strict"; import { OpencodeServerClient } from "../plugins/opencode/scripts/lib/opencode-server.mjs"; +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function createRequestStub(onRequest) { + const req = new EventEmitter(); + req.write = () => {}; + req.end = () => {}; + req.destroy = (error) => { + if (error) { + process.nextTick(() => req.emit("error", error)); + } + process.nextTick(() => req.emit("close")); + }; + req.setTimeout = (ms, onTimeout) => { + req.timeoutMs = ms; + req.timeout = setTimeout(onTimeout, ms); + return req; + }; + onRequest?.(req); + return req; +} + +function installHttpRequestStub(handler) { + const original = http.request; + http.request = handler; + return () => { + http.request = original; + }; +} + test("subscribeEvents cancels the response body if onOpen throws", async () => { let cancelled = false; const expectedError = new Error("open failed"); @@ -29,3 +62,75 @@ test("subscribeEvents cancels the response body if onOpen throws", async () => { ); assert.equal(cancelled, true); }); + +test("fresh-connection requests reject when the response never ends", async () => { + const restore = installHttpRequestStub((url, options, callback) => + createRequestStub(() => { + assert.equal(url.pathname, "/session/ses_timeout/message"); + assert.equal(options.method, "GET"); + process.nextTick(() => { + const res = new EventEmitter(); + res.statusCode = 200; + res.headers = { "content-type": "application/json" }; + callback(res); + res.emit("data", Buffer.from('{"messages":[')); + }); + }) + ); + + try { + const client = new OpencodeServerClient("http://opencode.test"); + const result = await Promise.race([ + client + .listMessages("ses_timeout", { freshConnection: true, requestTimeoutMs: 50 }) + .then( + () => ({ status: "resolved" }), + (error) => ({ status: "rejected", error }) + ), + sleep(250).then(() => ({ status: "pending" })) + ]); + + assert.equal(result.status, "rejected"); + assert.match(result.error.message, /timed out/i); + } finally { + restore(); + } +}); + +test("fresh-connection requests reject when the response aborts mid-body", async () => { + const restore = installHttpRequestStub((url, options, callback) => + createRequestStub(() => { + assert.equal(url.pathname, "/session/ses_aborted/message"); + assert.equal(options.method, "GET"); + process.nextTick(() => { + const res = new EventEmitter(); + res.statusCode = 200; + res.headers = { "content-type": "application/json" }; + callback(res); + res.emit("data", Buffer.from('{"messages":[')); + setTimeout(() => { + res.emit("aborted"); + res.emit("close"); + }, 10); + }); + }) + ); + + try { + const client = new OpencodeServerClient("http://opencode.test"); + const result = await Promise.race([ + client + .listMessages("ses_aborted", { freshConnection: true, requestTimeoutMs: 500 }) + .then( + () => ({ status: "resolved" }), + (error) => ({ status: "rejected", error }) + ), + sleep(250).then(() => ({ status: "pending" })) + ]); + + assert.equal(result.status, "rejected"); + assert.match(result.error.message, /aborted|closed/i); + } finally { + restore(); + } +}); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index cf6b483..16b86ed 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -510,6 +510,30 @@ test("task succeeds when the message transport drops before completion events (i } }); +test("task succeeds when the event stream drops before a successful message response (issue #15)", { skip: LOCAL_LISTEN_SKIP }, () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + const env = buildTestEnv(binDir, { + FAKE_OPENCODE_MESSAGE_FAIL: "event-drop-before-message-response" + }); + + try { + const result = run("node", [SCRIPT, "task", "--json", "long running task"], { + cwd: repo, + env + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, 0); + assert.match(payload.rawOutput, /Handled the requested task/); + } finally { + cleanupServer(repo, env); + } +}); + test("task recovers the final message over HTTP when only session.idle arrives (issue #2)", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); @@ -534,6 +558,64 @@ test("task recovers the final message over HTTP when only session.idle arrives ( } }); +test("task fails closed when a resumed-session snapshot fails and only stale messages are recoverable (issue #15)", { skip: LOCAL_LISTEN_SKIP }, () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + fs.writeFileSync( + path.join(binDir, "fake-opencode-state.json"), + JSON.stringify( + { + serverStarts: 0, + nextSessionId: 2, + nextMessageId: 2, + sessions: [ + { + id: "ses_existing", + directory: fs.realpathSync(repo), + title: "OpenCode Companion Task: prior fixture task", + agent: "plan", + model: null, + permission: [] + } + ], + messages: [], + responses: [ + { + sessionID: "ses_existing", + info: { id: "msg_1", role: "assistant", sessionID: "ses_existing" }, + parts: [{ type: "text", text: "Prior stale assistant message." }] + } + ], + imports: [], + permissions: [], + lastAbort: null + }, + null, + 2 + ) + ); + const env = buildTestEnv(binDir, { + FAKE_OPENCODE_MESSAGE_FAIL: "snapshot-fails-empty-recovery", + OPENCODE_COMPANION_SESSION_ID: "" + }); + + try { + const result = run("node", [SCRIPT, "task", "--json", "--resume-last", "follow up with no output"], { + cwd: repo, + env + }); + + assert.notEqual(result.status, 0, result.stdout); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, 1); + assert.equal(payload.rawOutput, ""); + } finally { + cleanupServer(repo, env); + } +}); + test("task recovery falls back to the newest assistant message when event message id is stale (issue #12)", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); From 47d35990ee1ceb25805f14d0e284686bd8764d7e Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Fri, 10 Jul 2026 05:51:32 +0200 Subject: [PATCH 21/32] Fail closed when the stop-review gate's reviewer is unavailable (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stop-review gate previously returned no decision when OpenCode was unavailable, which Claude Code treats as allow — so a missing reviewer (PATH/CI misconfig) silently bypassed a gate whose own setup text promises it can "require" a fresh review. The gate is opt-in, so when enabled and the reviewer is unavailable it now emits an explicit block decision with an actionable reason (restore OpenCode, or disable via /opencode:setup --disable-review-gate). Available-reviewer and disabled-gate paths are unchanged. Setup/README wording updated to match. Regression test covers the unavailable-while-enabled block. npm test: 73 passed, 0 failed, 0 skipped (localhost tests included). Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- plugins/opencode/commands/setup.md | 2 +- .../opencode/scripts/opencode-companion.mjs | 8 +++- .../scripts/stop-review-gate-hook.mjs | 16 +++++--- tests/commands.test.mjs | 4 ++ tests/runtime.test.mjs | 38 +++++++++++++++++++ 6 files changed, 60 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1c9b18f..876662f 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ You can also use `/opencode:setup` to manage the optional review gate. /opencode:setup --disable-review-gate ``` -When the review gate is enabled, the plugin uses a `Stop` hook to run a targeted OpenCode review based on Claude's response. If that review finds issues, the stop is blocked so Claude can address them first. +When the review gate is enabled, the plugin uses a `Stop` hook to run a targeted OpenCode review based on Claude's response. If that review finds issues, the stop is blocked so Claude can address them first. The gate also blocks stopping when the OpenCode reviewer is unavailable; restore OpenCode and rerun `/opencode:setup`, or disable the gate with `/opencode:setup --disable-review-gate`. > [!WARNING] > The review gate can create a long-running Claude/OpenCode loop and may drain usage limits quickly. Only enable it when you plan to actively monitor the session. diff --git a/plugins/opencode/commands/setup.md b/plugins/opencode/commands/setup.md index 1b92abf..48385cf 100644 --- a/plugins/opencode/commands/setup.md +++ b/plugins/opencode/commands/setup.md @@ -1,5 +1,5 @@ --- -description: Check whether the local OpenCode CLI is ready and optionally toggle the stop-time review gate +description: Check whether the local OpenCode CLI is ready and optionally toggle the fail-closed stop-time review gate argument-hint: '[--enable-review-gate|--disable-review-gate]' allowed-tools: Bash(node:*), Bash(npm:*), AskUserQuestion --- diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index 311bdd7..e0daa50 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -193,7 +193,9 @@ async function buildSetupReport(cwd, actionsTaken = []) { nextSteps.push("Configure an OpenCode provider, then rerun `/opencode:setup`."); } if (!config.stopReviewGate) { - nextSteps.push("Optional: run `/opencode:setup --enable-review-gate` to require a fresh review before stop."); + nextSteps.push( + "Optional: run `/opencode:setup --enable-review-gate` to require a fresh review before stop. If OpenCode becomes unavailable, the enabled gate blocks stopping; disable it with `/opencode:setup --disable-review-gate`." + ); } return { @@ -225,7 +227,9 @@ async function handleSetup(argv) { if (options["enable-review-gate"]) { setConfig(workspaceRoot, "stopReviewGate", true); - actionsTaken.push(`Enabled the stop-time review gate for ${workspaceRoot}.`); + actionsTaken.push( + `Enabled the stop-time review gate for ${workspaceRoot}. If the OpenCode reviewer is unavailable, stopping is blocked; disable the gate with /opencode:setup --disable-review-gate.` + ); } else if (options["disable-review-gate"]) { setConfig(workspaceRoot, "stopReviewGate", false); actionsTaken.push(`Disabled the stop-time review gate for ${workspaceRoot}.`); diff --git a/plugins/opencode/scripts/stop-review-gate-hook.mjs b/plugins/opencode/scripts/stop-review-gate-hook.mjs index a0ba77e..c24d341 100644 --- a/plugins/opencode/scripts/stop-review-gate-hook.mjs +++ b/plugins/opencode/scripts/stop-review-gate-hook.mjs @@ -56,14 +56,14 @@ function buildStopReviewPrompt(input = {}) { }); } -function buildSetupNote(cwd) { +function buildUnavailableReviewerReason(cwd) { const availability = getAvailability(cwd); if (availability.available) { return null; } - const detail = availability.detail ? ` ${availability.detail}.` : ""; - return `OpenCode is not set up for the review gate.${detail} Run /opencode:setup.`; + const detail = availability.detail ? ` Details: ${availability.detail}.` : ""; + return `OpenCode reviewer is unavailable, so the enabled stop-time review gate is blocking this stop.${detail} Restore OpenCode (ensure \`opencode --version\` works) and rerun \`/opencode:setup\`, or disable the gate with \`/opencode:setup --disable-review-gate\`.`; } function parseStopReviewOutput(rawOutput) { @@ -166,10 +166,14 @@ async function main() { return; } - const setupNote = buildSetupNote(cwd); - if (setupNote) { - logNote(setupNote); + const unavailableReviewerReason = buildUnavailableReviewerReason(cwd); + if (unavailableReviewerReason) { + logNote(unavailableReviewerReason); logNote(runningTaskNote); + emitDecision({ + decision: "block", + reason: runningTaskNote ? `${runningTaskNote} ${unavailableReviewerReason}` : unavailableReviewerReason + }); return; } diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index ffb5b09..cb34764 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -63,9 +63,13 @@ test("command and skill filenames use OpenCode identity", () => { test("setup command offers OpenCode installation guidance", () => { const setup = read("commands/setup.md"); + const companion = read("scripts/opencode-companion.mjs"); assert.match(setup, /description: Check whether the local OpenCode CLI is ready/); + assert.match(setup, /fail-closed stop-time review gate/); assert.match(setup, /npm install -g opencode-ai/); assert.doesNotMatch(setup, /@openai\/codex/); + assert.match(companion, /OpenCode reviewer is unavailable/); + assert.match(companion, /--disable-review-gate/); }); test("hooks keep session-end cleanup and stop gating enabled", () => { diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index d05fb7a..3107146 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -230,6 +230,43 @@ test("session end leaves server session when teardown is skipped for active leas }); }); +test("stop review gate blocks when the enabled OpenCode reviewer is unavailable", async () => { + const workspace = makeTempDir(); + const binDir = makeTempDir(); + const pluginDataDir = makeTempDir("opencode-plugin-data-"); + const env = { + ...process.env, + PATH: binDir, + CLAUDE_PLUGIN_DATA: pluginDataDir, + OPENCODE_COMPANION_SESSION_ID: "sess-current" + }; + + await withProcessEnv({ CLAUDE_PLUGIN_DATA: pluginDataDir }, () => { + saveState(workspace, { + version: 1, + config: { stopReviewGate: true }, + jobs: [] + }); + + const result = run(process.execPath, [STOP_HOOK], { + cwd: workspace, + env, + input: JSON.stringify({ + cwd: workspace, + session_id: env.OPENCODE_COMPANION_SESSION_ID + }) + }); + + assert.equal(result.status, 0, result.stderr); + const decision = JSON.parse(result.stdout); + assert.equal(decision.decision, "block"); + assert.match(decision.reason, /OpenCode reviewer is unavailable/); + assert.match(decision.reason, /\/opencode:setup/); + assert.match(decision.reason, /--disable-review-gate/); + assert.match(result.stderr, /blocking this stop/); + }); +}); + test("stop review gate tears down a server left by a failed stop review task", { skip: LOCAL_LISTEN_SKIP }, async () => { const workspace = makeTempDir(); const binDir = makeTempDir(); @@ -257,6 +294,7 @@ test("stop review gate tears down a server left by a failed stop review task", { assert.equal(result.status, 0, result.stderr); assert.equal(JSON.parse(result.stdout).decision, "block"); + assert.match(readFakeState(binDir).lastMessage.prompt, /Run a stop-gate review of the previous Claude turn/); assert.equal(loadServerSession(workspace), null); }); }); From a5abc058ab1e945adf1e09979054960caffa70cb Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Fri, 10 Jul 2026 05:55:14 +0200 Subject: [PATCH 22/32] Tighten npm-pack surface; drop Codex conversion residue (#17) package.json had no files allowlist and no .npmignore, so `npm pack` archived the whole tree including .claude/settings.local.json (user-local shell allowlists, machine paths, PIDs). Add a `files: ["plugins/opencode/"]` allowlist scoped to the shipped plugin artifact (manifest, hooks, commands, agents, prompts, schemas, scripts all confirmed present in the pack; settings + tests excluded). New hermetic tests/packaging.test.mjs plants a sentinel settings.local.json and asserts `npm pack --dry-run` excludes it and the test tree while including the plugin manifest/entrypoint. Also drop conversion residue: remove the unused `@openai/codex` install from CI (the suite uses the fake OpenCode fixture) and delete the unreferenced tests/fake-codex-fixture.mjs. npm test: 73 passed, 0 failed, 0 skipped (localhost tests included). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/pull-request-ci.yml | 3 - package.json | 3 + tests/fake-codex-fixture.mjs | 658 -------------------------- tests/packaging.test.mjs | 63 +++ 4 files changed, 66 insertions(+), 661 deletions(-) delete mode 100644 tests/fake-codex-fixture.mjs create mode 100644 tests/packaging.test.mjs diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 8a36be1..eedb466 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -25,8 +25,5 @@ jobs: - name: Install dependencies run: npm ci - - name: Install Codex CLI - run: npm install -g @openai/codex - - name: Run test suite run: npm test diff --git a/package.json b/package.json index 9f9cac1..2641c12 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "@opencode/claude-code-plugin", "version": "1.0.5", "private": true, + "files": [ + "plugins/opencode/" + ], "type": "module", "description": "Use OpenCode from Claude Code to review code or delegate tasks.", "license": "Apache-2.0", diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs deleted file mode 100644 index f83c96a..0000000 --- a/tests/fake-codex-fixture.mjs +++ /dev/null @@ -1,658 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import process from "node:process"; - -import { writeExecutable } from "./helpers.mjs"; - -export function installFakeCodex(binDir, behavior = "review-ok") { - const statePath = path.join(binDir, "fake-codex-state.json"); - const scriptPath = path.join(binDir, "codex"); - const source = `#!/usr/bin/env node -const fs = require("node:fs"); -const crypto = require("node:crypto"); -const path = require("node:path"); -const readline = require("node:readline"); - - const STATE_PATH = ${JSON.stringify(statePath)}; - const BEHAVIOR = ${JSON.stringify(behavior)}; - const interruptibleTurns = new Map(); - - function loadState() { - if (!fs.existsSync(STATE_PATH)) { - return { nextThreadId: 1, nextTurnId: 1, appServerStarts: 0, threads: [], capabilities: null, lastInterrupt: null }; - } - return JSON.parse(fs.readFileSync(STATE_PATH, "utf8")); - } - -function saveState(state) { - fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); -} - -function requiresExperimental(field, message, state) { - if (!(field in (message.params || {}))) { - return false; - } - return !state.capabilities || state.capabilities.experimentalApi !== true; -} - -function now() { - return Math.floor(Date.now() / 1000); -} - -function buildThread(thread) { - return { - id: thread.id, - preview: thread.preview || "", - ephemeral: Boolean(thread.ephemeral), - modelProvider: "openai", - createdAt: thread.createdAt, - updatedAt: thread.updatedAt, - status: { type: "idle" }, - path: null, - cwd: thread.cwd, - cliVersion: "fake-codex", - source: "appServer", - agentNickname: null, - agentRole: null, - gitInfo: null, - name: thread.name || null, - turns: [] - }; -} - -function buildTurn(id, status = "inProgress", error = null) { - return { id, status, items: [], error }; -} - -function buildAccountReadResult() { - switch (BEHAVIOR) { - case "logged-out": - case "refreshable-auth": - case "auth-run-fails": - return { account: null, requiresOpenaiAuth: true }; - case "provider-no-auth": - case "env-key-provider": - return { account: null, requiresOpenaiAuth: false }; - case "api-key-account-only": - return { account: { type: "apiKey" }, requiresOpenaiAuth: true }; - default: - return { - account: { type: "chatgpt", email: "test@example.com", planType: "plus" }, - requiresOpenaiAuth: true - }; - } -} - -function buildConfigReadResult() { - switch (BEHAVIOR) { - case "provider-no-auth": - return { - config: { model_provider: "ollama" }, - origins: {} - }; - case "env-key-provider": - return { - config: { - model_provider: "openai-custom", - model_providers: { - "openai-custom": { - name: "OpenAI custom", - env_key: "OPENAI_API_KEY", - requires_openai_auth: false - } - } - }, - origins: {} - }; - default: - return { - config: { model_provider: "openai" }, - origins: {} - }; - } -} - -function send(message) { - process.stdout.write(JSON.stringify(message) + "\\n"); -} - -function nextThread(state, cwd, ephemeral) { - const thread = { - id: "thr_" + state.nextThreadId++, - cwd: cwd || process.cwd(), - name: null, - preview: "", - ephemeral: Boolean(ephemeral), - createdAt: now(), - updatedAt: now() - }; - state.threads.unshift(thread); - saveState(state); - return thread; -} - -function ensureThread(state, threadId) { - const thread = state.threads.find((candidate) => candidate.id === threadId); - if (!thread) { - throw new Error("unknown thread " + threadId); - } - return thread; -} - -function nextTurnId(state) { - const turnId = "turn_" + state.nextTurnId++; - saveState(state); - return turnId; -} - -function importLedgerPath() { - return path.join(process.env.CODEX_HOME || path.join(process.env.HOME, ".codex"), "external_agent_session_imports.json"); -} - -function loadImportLedger() { - const ledgerPath = importLedgerPath(); - return fs.existsSync(ledgerPath) ? JSON.parse(fs.readFileSync(ledgerPath, "utf8")) : { records: [] }; -} - -function saveImportLedger(ledger) { - const ledgerPath = importLedgerPath(); - fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); - fs.writeFileSync(ledgerPath, JSON.stringify(ledger, null, 2)); -} - -function emitTurnCompleted(threadId, turnId, item) { - const items = Array.isArray(item) ? item : [item]; - send({ method: "turn/started", params: { threadId, turn: buildTurn(turnId) } }); - for (const entry of items) { - if (entry && entry.started) { - send({ method: "item/started", params: { threadId, turnId, item: entry.started } }); - } - if (entry && entry.completed) { - send({ method: "item/completed", params: { threadId, turnId, item: entry.completed } }); - } - } - send({ method: "turn/completed", params: { threadId, turn: buildTurn(turnId, "completed") } }); -} - -function emitTurnCompletedLater(threadId, turnId, item, delayMs) { - setTimeout(() => { - emitTurnCompleted(threadId, turnId, item); - }, delayMs); -} - -function nativeReviewText(target) { - if (target.type === "baseBranch") { - return "Reviewed changes against " + target.branch + ".\\nNo material issues found."; - } - if (target.type === "custom") { - return "Reviewed custom target.\\nNo material issues found."; - } - return "Reviewed uncommitted changes.\\nNo material issues found."; -} - -function structuredReviewPayload(prompt) { - if (prompt.includes("adversarial software review")) { - if (BEHAVIOR === "adversarial-clean") { - return JSON.stringify({ - verdict: "approve", - summary: "No material issues found.", - findings: [], - next_steps: [] - }); - } - - return JSON.stringify({ - verdict: "needs-attention", - summary: "One adversarial concern surfaced.", - findings: [ - { - severity: "high", - title: "Missing empty-state guard", - body: "The change assumes data is always present.", - file: "src/app.js", - line_start: 4, - line_end: 6, - confidence: 0.87, - recommendation: "Handle empty collections before indexing." - } - ], - next_steps: ["Add an empty-state test."] - }); - } - - if (BEHAVIOR === "invalid-json") { - return "not valid json"; - } - - return JSON.stringify({ - verdict: "approve", - summary: "No material issues found.", - findings: [], - next_steps: [] - }); -} - -function taskPayload(prompt, resume) { - if (prompt.includes("") && prompt.includes("Only review the work from the previous Claude turn.")) { - if (BEHAVIOR === "adversarial-clean") { - return "ALLOW: No blocking issues found in the previous turn."; - } - return "BLOCK: Missing empty-state guard in src/app.js:4-6."; - } - - if (resume || prompt.includes("Continue from the current thread state") || prompt.includes("follow up")) { - return "Resumed the prior run.\\nFollow-up prompt accepted."; - } - - return "Handled the requested task.\\nTask prompt accepted."; -} - -const args = process.argv.slice(2); -if (args[0] === "--version") { - console.log("codex-cli test"); - process.exit(0); -} -if (args[0] === "app-server" && args[1] === "--help") { - console.log("fake app-server help"); - process.exit(0); -} -if (args[0] === "login" && args[1] === "status") { - if (BEHAVIOR === "logged-out" || BEHAVIOR === "refreshable-auth" || BEHAVIOR === "auth-run-fails" || BEHAVIOR === "provider-no-auth" || BEHAVIOR === "env-key-provider" || BEHAVIOR === "api-key-account-only") { - console.error("not authenticated"); - process.exit(1); - } - console.log("logged in"); - process.exit(0); -} -if (args[0] === "login") { - process.exit(0); -} -if (args[0] !== "app-server") { - process.exit(1); -} -const bootState = loadState(); -bootState.appServerStarts = (bootState.appServerStarts || 0) + 1; -saveState(bootState); - -const rl = readline.createInterface({ input: process.stdin }); -rl.on("line", (line) => { - if (!line.trim()) { - return; - } - - const message = JSON.parse(line); - const state = loadState(); - - try { - switch (message.method) { - case "initialize": - state.capabilities = message.params.capabilities || null; - saveState(state); - send({ id: message.id, result: { userAgent: "fake-codex-app-server" } }); - break; - - case "initialized": - break; - - case "account/read": - send({ id: message.id, result: buildAccountReadResult() }); - break; - - case "config/read": - if (BEHAVIOR === "config-read-fails") { - throw new Error("config/read failed for cwd"); - } - send({ id: message.id, result: buildConfigReadResult() }); - break; - - case "thread/start": { - if (BEHAVIOR === "auth-run-fails") { - throw new Error("authentication expired; run codex login"); - } - if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) { - throw new Error("thread/start.persistFullHistory requires experimentalApi capability"); - } - const thread = nextThread(state, message.params.cwd, message.params.ephemeral); - send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); - send({ method: "thread/started", params: { thread: { id: thread.id } } }); - break; - } - - case "thread/name/set": { - const thread = ensureThread(state, message.params.threadId); - thread.name = message.params.name; - thread.updatedAt = now(); - saveState(state); - send({ id: message.id, result: {} }); - break; - } - - case "thread/list": { - let threads = state.threads.slice(); - if (message.params.cwd) { - threads = threads.filter((thread) => thread.cwd === message.params.cwd); - } - if (message.params.searchTerm) { - threads = threads.filter((thread) => (thread.name || "").includes(message.params.searchTerm)); - } - threads.sort((left, right) => right.updatedAt - left.updatedAt); - send({ id: message.id, result: { data: threads.map(buildThread), nextCursor: null } }); - break; - } - - case "thread/resume": { - if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) { - throw new Error("thread/resume.persistFullHistory requires experimentalApi capability"); - } - const thread = ensureThread(state, message.params.threadId); - thread.updatedAt = now(); - saveState(state); - send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); - break; - } - - case "externalAgentConfig/import": { - if (BEHAVIOR === "external-import-unsupported") { - send({ id: message.id, error: { code: -32601, message: "Unsupported method: externalAgentConfig/import" } }); - break; - } - if (BEHAVIOR === "external-import-fails") { - send({ id: message.id, result: {} }); - send({ method: "externalAgentConfig/import/completed", params: {} }); - break; - } - const sessions = (message.params.migrationItems || []) - .flatMap((item) => item.details && Array.isArray(item.details.sessions) ? item.details.sessions : []); - const session = sessions[0]; - if (!session) { - throw new Error("missing external session migration"); - } - const sourcePath = fs.realpathSync(session.path); - const contents = fs.readFileSync(sourcePath, "utf8"); - const contentSha256 = crypto.createHash("sha256").update(contents).digest("hex"); - const ledger = loadImportLedger(); - let record = ledger.records.find( - (candidate) => candidate.source_path === sourcePath && candidate.content_sha256 === contentSha256 - ); - let thread; - if (record) { - thread = ensureThread(state, record.imported_thread_id); - } else { - const records = contents.split(/\\r?\\n/).filter(Boolean).map((line) => JSON.parse(line)); - const title = records.find((entry) => entry.type === "custom-title")?.customTitle || null; - const messages = records - .filter((entry) => entry.type === "user" || entry.type === "assistant") - .map((entry) => ({ role: entry.type, text: entry.message?.content || "" })); - thread = nextThread(state, session.cwd, false); - thread.name = title; - thread.preview = messages.find((entry) => entry.role === "user")?.text || ""; - thread.visibleMessages = messages; - state.lastExternalAgentImport = { sourcePath, threadId: thread.id, messages }; - record = { - source_path: sourcePath, - content_sha256: contentSha256, - imported_thread_id: thread.id, - imported_at: now(), - source_modified_at: null - }; - ledger.records.push(record); - saveState(state); - saveImportLedger(ledger); - } - send({ id: message.id, result: {} }); - send({ method: "externalAgentConfig/import/completed", params: {} }); - break; - } - - case "review/start": { - const thread = ensureThread(state, message.params.threadId); - let reviewThread = thread; - if (message.params.delivery === "detached") { - reviewThread = nextThread(state, thread.cwd, true); - send({ method: "thread/started", params: { thread: { id: reviewThread.id } } }); - } - const turnId = nextTurnId(state); - send({ id: message.id, result: { turn: buildTurn(turnId), reviewThreadId: reviewThread.id } }); - emitTurnCompleted(reviewThread.id, turnId, [ - { - started: { type: "enteredReviewMode", id: turnId, review: "current changes" } - }, - ...(BEHAVIOR === "with-reasoning" - ? [ - { - completed: { - type: "reasoning", - id: "reasoning_" + turnId, - summary: [{ text: "Reviewed the changed files and checked the likely regression paths." }], - content: [] - } - } - ] - : []), - { - completed: { type: "exitedReviewMode", id: turnId, review: nativeReviewText(message.params.target) } - } - ]); - break; - } - - case "turn/start": { - const thread = ensureThread(state, message.params.threadId); - const prompt = (message.params.input || []) - .filter((item) => item.type === "text") - .map((item) => item.text) - .join("\\n"); - const turnId = nextTurnId(state); - thread.updatedAt = now(); - state.lastTurnStart = { - threadId: message.params.threadId, - turnId, - model: message.params.model ?? null, - effort: message.params.effort ?? null, - prompt - }; - saveState(state); - send({ id: message.id, result: { turn: buildTurn(turnId) } }); - - const payload = message.params.outputSchema && message.params.outputSchema.properties && message.params.outputSchema.properties.verdict - ? structuredReviewPayload(prompt) - : taskPayload(prompt, thread.name && thread.name.startsWith("Codex Companion Task") && prompt.includes("Continue from the current thread state")); - - if ( - BEHAVIOR === "with-subagent" || - BEHAVIOR === "with-late-subagent-message" || - BEHAVIOR === "with-subagent-no-main-turn-completed" - ) { - const subThread = nextThread(state, thread.cwd, true); - const subThreadRecord = ensureThread(state, subThread.id); - subThreadRecord.name = "design-challenger"; - saveState(state); - const subTurnId = nextTurnId(state); - - send({ method: "thread/started", params: { thread: { ...buildThread(subThreadRecord), name: "design-challenger", agentNickname: "design-challenger" } } }); - send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); - send({ - method: "item/started", - params: { - threadId: thread.id, - turnId, - item: { - type: "collabAgentToolCall", - id: "collab_" + turnId, - tool: "wait", - status: "inProgress", - senderThreadId: thread.id, - receiverThreadIds: [subThread.id], - prompt: "Challenge the implementation approach", - model: null, - reasoningEffort: null, - agentsStates: { - [subThread.id]: { status: "inProgress", message: "Investigating design tradeoffs" } - } - } - } - }); - if (BEHAVIOR === "with-late-subagent-message") { - send({ - method: "item/completed", - params: { - threadId: thread.id, - turnId, - item: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" } - } - }); - } - send({ method: "turn/started", params: { threadId: subThread.id, turn: buildTurn(subTurnId) } }); - send({ - method: "item/completed", - params: { - threadId: subThread.id, - turnId: subTurnId, - item: { - type: "reasoning", - id: "reasoning_" + subTurnId, - summary: [{ text: "Questioned the retry strategy and the cache invalidation boundaries." }], - content: [] - } - } - }); - send({ - method: "item/completed", - params: { - threadId: subThread.id, - turnId: subTurnId, - item: { - type: "agentMessage", - id: "msg_" + subTurnId, - text: "The design assumes retries are harmless, but they can duplicate side effects without stronger idempotency guarantees.", - phase: "analysis" - } - } - }); - send({ method: "turn/completed", params: { threadId: subThread.id, turn: buildTurn(subTurnId, "completed") } }); - send({ - method: "item/completed", - params: { - threadId: thread.id, - turnId, - item: { - type: "collabAgentToolCall", - id: "collab_" + turnId, - tool: "wait", - status: "completed", - senderThreadId: thread.id, - receiverThreadIds: [subThread.id], - prompt: "Challenge the implementation approach", - model: null, - reasoningEffort: null, - agentsStates: { - [subThread.id]: { status: "completed", message: "Finished" } - } - } - } - }); - if (BEHAVIOR !== "with-late-subagent-message") { - send({ - method: "item/completed", - params: { - threadId: thread.id, - turnId, - item: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" } - } - }); - } - if (BEHAVIOR !== "with-subagent-no-main-turn-completed") { - send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "completed") } }); - } - break; - } - - const items = [ - ...(BEHAVIOR === "with-reasoning" - ? [ - { - completed: { - type: "reasoning", - id: "reasoning_" + turnId, - summary: [{ text: "Inspected the prompt, gathered evidence, and checked the highest-risk paths first." }], - content: [] - } - } - ] - : []), - { - completed: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" } - } - ]; - - if (BEHAVIOR === "interruptible-slow-task") { - send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); - const timer = setTimeout(() => { - if (!interruptibleTurns.has(turnId)) { - return; - } - interruptibleTurns.delete(turnId); - for (const entry of items) { - if (entry && entry.completed) { - send({ method: "item/completed", params: { threadId: thread.id, turnId, item: entry.completed } }); - } - } - send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "completed") } }); - }, 5000); - interruptibleTurns.set(turnId, { threadId: thread.id, timer }); - } else if (BEHAVIOR === "slow-task") { - emitTurnCompletedLater(thread.id, turnId, items, 400); - } else { - emitTurnCompleted(thread.id, turnId, items); - } - break; - } - - case "turn/interrupt": { - state.lastInterrupt = { - threadId: message.params.threadId, - turnId: message.params.turnId - }; - saveState(state); - const pending = interruptibleTurns.get(message.params.turnId); - if (pending) { - clearTimeout(pending.timer); - interruptibleTurns.delete(message.params.turnId); - send({ - method: "turn/completed", - params: { - threadId: pending.threadId, - turn: buildTurn(message.params.turnId, "interrupted") - } - }); - } - send({ id: message.id, result: {} }); - break; - } - - default: - send({ id: message.id, error: { code: -32601, message: "Unsupported method: " + message.method } }); - break; - } - } catch (error) { - send({ id: message.id, error: { code: -32000, message: error.message } }); - } -}); -`; - writeExecutable(scriptPath, source); - - // On Windows, npm global binaries are invoked via .cmd wrappers. - // Create a codex.cmd so the fake binary is discoverable by spawn with shell: true. - if (process.platform === "win32") { - const cmdWrapper = `@echo off\r\nnode "%~dp0codex" %*\r\n`; - fs.writeFileSync(path.join(binDir, "codex.cmd"), cmdWrapper, { encoding: "utf8" }); - } -} - -export function buildEnv(binDir) { - const sep = process.platform === "win32" ? ";" : ":"; - return { - ...process.env, - PATH: `${binDir}${sep}${process.env.PATH}` - }; -} diff --git a/tests/packaging.test.mjs b/tests/packaging.test.mjs new file mode 100644 index 0000000..5e187c8 --- /dev/null +++ b/tests/packaging.test.mjs @@ -0,0 +1,63 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; + +import { makeTempDir, run } from "./helpers.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function packFiles() { + const tempDir = makeTempDir("opencode-plugin-pack-"); + const packageDir = path.join(tempDir, "package"); + const cacheDir = path.join(tempDir, "npm-cache"); + + try { + fs.cpSync(ROOT, packageDir, { + recursive: true, + filter(source) { + const relativePath = path.relative(ROOT, source); + return ![".claude", ".git", "node_modules"].includes(relativePath); + } + }); + fs.mkdirSync(path.join(packageDir, ".claude"), { recursive: true }); + fs.writeFileSync(path.join(packageDir, ".claude", "settings.local.json"), "{}\n"); + + const result = run("npm", ["pack", "--dry-run", "--json"], { + cwd: packageDir, + env: { + ...process.env, + NPM_CONFIG_CACHE: cacheDir + } + }); + + if (result.error?.code === "ENOENT") { + return null; + } + + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout)[0].files.map((file) => file.path); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +test("npm pack ships only the plugin artifact", (t) => { + const files = packFiles(); + + if (!files) { + t.skip("npm pack is unavailable"); + return; + } + + assert.equal(files.includes(".claude/settings.local.json"), false); + assert.equal(files.some((file) => file.startsWith("tests/")), false); + assert.equal(files.includes("plugins/opencode/.claude-plugin/plugin.json"), true); + assert.equal(files.includes("plugins/opencode/hooks/hooks.json"), true); + assert.equal(files.includes("plugins/opencode/prompts/stop-review-gate.md"), true); + assert.equal(files.includes("plugins/opencode/schemas/review-output.schema.json"), true); + assert.equal(files.includes("plugins/opencode/scripts/opencode-companion.mjs"), true); +}); From 153f9ba1f6b2650f8795c68bf17184e22c6339c6 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Fri, 10 Jul 2026 06:26:06 +0200 Subject: [PATCH 23/32] Harden server lifecycle and state-lock robustness (#19) - SessionEnd teardown is now bounded: teardownServerSession takes a lock-acquire timeout and returns {skipped, reason:"lock-timeout", diagnostic} on contention; the SessionEnd hook passes a 3s server-lock + 500ms state-lock budget, logs the diagnostic, and skips clearing state on timeout so it can't be killed mid-teardown by the hook host. ensureServer also fails safely when a bounded acquisition times out. - State lock adds async APIs (withStateLockAsync / updateStateAsync) that yield the event loop via awaitable polling instead of Atomics.wait. The async companion paths (enqueue/spawn bookkeeping, terminal-index sync, cancel read, in-flight cancellation) and the SessionEnd job cleanup now use them. The synchronous lock keeps stale-takeover recovery (no premature default timeout; lockAcquireTimeoutMs is opt-in). - EPERM is treated as "unconfirmed" (not alive) for lease liveness, so a reused foreign pid can no longer hold server teardown hostage for the 6h TTL; the TTL still covers legitimate long sessions. - server.json is written atomically (temp-file + rename), reusing the state writer. npm test: 79 passed, 0 failed, 0 skipped (localhost tests included). Co-Authored-By: Claude Opus 4.8 --- .../opencode/scripts/lib/server-lifecycle.mjs | 34 +++- plugins/opencode/scripts/lib/state.mjs | 128 +++++++++--- .../opencode/scripts/opencode-companion.mjs | 45 +++-- .../scripts/session-lifecycle-hook.mjs | 26 ++- tests/runtime.test.mjs | 79 +++++++- tests/server-lifecycle.test.mjs | 188 ++++++++++++++++++ tests/state.test.mjs | 71 ++++++- 7 files changed, 502 insertions(+), 69 deletions(-) diff --git a/plugins/opencode/scripts/lib/server-lifecycle.mjs b/plugins/opencode/scripts/lib/server-lifecycle.mjs index e072b99..c730a3f 100644 --- a/plugins/opencode/scripts/lib/server-lifecycle.mjs +++ b/plugins/opencode/scripts/lib/server-lifecycle.mjs @@ -6,7 +6,7 @@ import process from "node:process"; import { spawn } from "node:child_process"; import { OpencodeServerClient } from "./opencode-server.mjs"; -import { resolveStateDir } from "./state.mjs"; +import { atomicWriteFile, resolveStateDir } from "./state.mjs"; export const SERVER_URL_ENV = "OPENCODE_COMPANION_SERVER_URL"; export const PID_FILE_ENV = "OPENCODE_COMPANION_SERVER_PID_FILE"; @@ -57,7 +57,7 @@ export function loadServerSession(cwd) { export function saveServerSession(cwd, session) { const stateDir = resolveStateDir(cwd); fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(resolveServerStateFile(cwd), `${JSON.stringify(session, null, 2)}\n`, "utf8"); + atomicWriteFile(resolveServerStateFile(cwd), `${JSON.stringify(session, null, 2)}\n`); } export function clearServerSession(cwd) { @@ -112,7 +112,7 @@ function processIsAlive(pid) { process.kill(pid, 0); return true; } catch (error) { - return error?.code === "EPERM"; + return error?.code === "EPERM" ? null : false; } } @@ -212,8 +212,10 @@ function isLeaseActive(lease, nowMs = Date.now()) { return false; } - const alive = processIsAlive(Number(lease?.pid)); - return alive !== false; + // Leases are created by this plugin, so an EPERM response cannot prove that + // the pid still belongs to the lease owner. Keep the long TTL for legitimate + // sessions, but do not let an inaccessible, reused foreign pid hold teardown. + return processIsAlive(Number(lease?.pid)) === true; } function pruneServerLeases(session) { @@ -253,6 +255,8 @@ async function acquireServerLock(cwd, options = {}) { const lockDir = resolveServerLockDir(cwd); const staleMs = Math.max(1000, Number(options.lockStaleMs) || DEFAULT_LOCK_STALE_MS); const pollMs = Math.max(25, Number(options.lockPollMs) || DEFAULT_LOCK_POLL_MS); + const requestedTimeoutMs = options.lockAcquireTimeoutMs == null ? Number.NaN : Number(options.lockAcquireTimeoutMs); + const deadline = Number.isFinite(requestedTimeoutMs) && requestedTimeoutMs >= 0 ? Date.now() + requestedTimeoutMs : null; const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; for (;;) { @@ -268,7 +272,10 @@ async function acquireServerLock(cwd, options = {}) { if (isServerLockStale(lockDir, staleMs)) { stealStaleServerLock(lockDir); } - await sleep(pollMs); + if (deadline != null && Date.now() >= deadline) { + return null; + } + await sleep(deadline == null ? pollMs : Math.min(pollMs, Math.max(1, deadline - Date.now()))); continue; } @@ -366,6 +373,9 @@ export async function ensureServer(cwd, options = {}) { } const lock = await acquireServerLock(cwd, options); + if (!lock) { + throw new Error("Timed out acquiring the OpenCode server lock."); + } try { const lockedExisting = await loadHealthyServerSession(cwd, options.healthTimeoutMs ?? 500); @@ -485,6 +495,9 @@ export async function teardownServerSession({ cwd = null, force = false, ignoreCurrentProcessLease = false, + lockAcquireTimeoutMs = null, + lockPollMs = null, + lockStaleMs = null, url = null, pidFile = null, logFile = null, @@ -505,7 +518,14 @@ export async function teardownServerSession({ }); } - const lock = await acquireServerLock(cwd); + const lock = await acquireServerLock(cwd, { lockAcquireTimeoutMs, lockPollMs, lockStaleMs }); + if (!lock) { + return { + skipped: true, + reason: "lock-timeout", + diagnostic: "Timed out acquiring the OpenCode server lock for teardown." + }; + } try { const current = loadServerSession(cwd); const currentUrl = normalizeUrl(current?.url); diff --git a/plugins/opencode/scripts/lib/state.mjs b/plugins/opencode/scripts/lib/state.mjs index 389a9fc..e7484cc 100644 --- a/plugins/opencode/scripts/lib/state.mjs +++ b/plugins/opencode/scripts/lib/state.mjs @@ -17,6 +17,10 @@ const DEFAULT_LOCK_STALE_MS = 30000; const DEFAULT_LOCK_POLL_MS = 25; const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)); +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function nowIso() { return new Date().toISOString(); } @@ -138,46 +142,92 @@ function releaseStateLock(lockDir, token) { removeStateLock(lockDir); } -function acquireStateLock(cwd, options = {}) { +function stateLockOptions(cwd, options = {}) { const stateDir = resolveStateDir(cwd); fs.mkdirSync(stateDir, { recursive: true }); - const lockDir = resolveStateLockDir(cwd); - const staleMs = Math.max(1000, Number(options.lockStaleMs) || DEFAULT_LOCK_STALE_MS); - const pollMs = Math.max(10, Number(options.lockPollMs) || DEFAULT_LOCK_POLL_MS); - const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + return { + lockDir: resolveStateLockDir(cwd), + staleMs: Math.max(1000, Number(options.lockStaleMs) || DEFAULT_LOCK_STALE_MS), + pollMs: Math.max(10, Number(options.lockPollMs) || DEFAULT_LOCK_POLL_MS), + token: `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}` + }; +} - for (;;) { - try { - fs.mkdirSync(lockDir); - } catch (error) { - if (error?.code !== "EEXIST") { - throw error; - } - if (isStateLockStale(lockDir, staleMs)) { - stealStaleStateLock(lockDir); - } - Atomics.wait(sleepBuffer, 0, 0, pollMs); - continue; - } +function lockAcquisitionDeadline(options) { + if (options.lockAcquireTimeoutMs == null) { + return null; + } - try { - fs.writeFileSync( - path.join(lockDir, LOCK_INFO_FILE), - `${JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }, null, 2)}\n`, - "utf8" - ); - } catch (error) { - removeStateLock(lockDir); + const timeoutMs = Number(options.lockAcquireTimeoutMs); + return Number.isFinite(timeoutMs) && timeoutMs >= 0 ? Date.now() + timeoutMs : null; +} + +function tryAcquireStateLock(lockDir, staleMs, token) { + try { + fs.mkdirSync(lockDir); + } catch (error) { + if (error?.code !== "EEXIST") { throw error; } + if (isStateLockStale(lockDir, staleMs)) { + stealStaleStateLock(lockDir); + } + return null; + } - return () => releaseStateLock(lockDir, token); + try { + fs.writeFileSync( + path.join(lockDir, LOCK_INFO_FILE), + `${JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }, null, 2)}\n`, + "utf8" + ); + } catch (error) { + removeStateLock(lockDir); + throw error; } + + return () => releaseStateLock(lockDir, token); } -export function withStateLock(cwd, fn) { - const release = acquireStateLock(cwd); +function acquireStateLock(cwd, options = {}) { + const { lockDir, staleMs, pollMs, token } = stateLockOptions(cwd, options); + const deadline = lockAcquisitionDeadline(options); + + for (;;) { + const release = tryAcquireStateLock(lockDir, staleMs, token); + if (release) { + return release; + } + if (deadline != null && Date.now() >= deadline) { + throw new Error("Timed out acquiring the OpenCode state lock."); + } + Atomics.wait(sleepBuffer, 0, 0, deadline == null ? pollMs : Math.min(pollMs, Math.max(1, deadline - Date.now()))); + } +} + +async function acquireStateLockAsync(cwd, options = {}) { + const { lockDir, staleMs, pollMs, token } = stateLockOptions(cwd, options); + const deadline = lockAcquisitionDeadline(options); + + for (;;) { + const release = tryAcquireStateLock(lockDir, staleMs, token); + if (release) { + return release; + } + if (deadline != null && Date.now() >= deadline) { + throw new Error("Timed out acquiring the OpenCode state lock."); + } + await sleep(deadline == null ? pollMs : Math.min(pollMs, Math.max(1, deadline - Date.now()))); + } +} + +// The synchronous API remains for callers that need a synchronous return value. +// It waits for acquisition or stale-lock recovery unless callers opt into a +// lockAcquireTimeoutMs deadline. Async command and hook paths should use +// withStateLockAsync so lock contention yields the Node event loop. +export function withStateLock(cwd, fn, options = {}) { + const release = acquireStateLock(cwd, options); try { return fn(); } finally { @@ -185,6 +235,15 @@ export function withStateLock(cwd, fn) { } } +export async function withStateLockAsync(cwd, fn, options = {}) { + const release = await acquireStateLockAsync(cwd, options); + try { + return await fn(); + } finally { + release(); + } +} + function loadStateUnlocked(cwd) { const stateFile = resolveStateFile(cwd); if (!fs.existsSync(stateFile)) { @@ -223,7 +282,7 @@ function removeFileIfExists(filePath) { } } -function atomicWriteFile(filePath, contents) { +export function atomicWriteFile(filePath, contents) { const tempFile = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; try { fs.writeFileSync(tempFile, contents, "utf8"); @@ -296,6 +355,15 @@ export function updateState(cwd, mutate) { }); } +export function updateStateAsync(cwd, mutate, options = {}) { + return withStateLockAsync(cwd, () => { + const state = loadStateUnlocked(cwd); + const previousJobs = [...state.jobs]; + mutate(state); + return saveStateUnlocked(cwd, state, previousJobs); + }, options); +} + export function generateJobId(prefix = "job") { const random = Math.random().toString(36).slice(2, 8); return `${prefix}-${Date.now().toString(36)}-${random}`; diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index 311bdd7..abde145 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -33,9 +33,8 @@ import { listJobs, resolveJobLogFile, setConfig, - updateState, - upsertJob, - withStateLock, + updateStateAsync, + withStateLockAsync, writeJobFile } from "./lib/state.mjs"; import { @@ -614,12 +613,12 @@ function spawnDetachedTaskWorker(cwd, jobId) { return child; } -function markQueuedTaskSpawned(workspaceRoot, jobId, pid) { +async function markQueuedTaskSpawned(workspaceRoot, jobId, pid) { if (pid == null) { return; } - updateState(workspaceRoot, (state) => { + await updateStateAsync(workspaceRoot, (state) => { const storedJob = readStoredJob(workspaceRoot, jobId); if (storedJob?.status === "completed" || storedJob?.status === "failed" || storedJob?.status === "cancelled") { return; @@ -638,12 +637,12 @@ function markQueuedTaskSpawned(workspaceRoot, jobId, pid) { }); } -function markQueuedTaskSpawnFailed(workspaceRoot, jobId, logFile, error) { +async function markQueuedTaskSpawnFailed(workspaceRoot, jobId, logFile, error) { const errorMessage = error instanceof Error ? error.message : String(error); const completedAt = nowIso(); appendLogLine(logFile, `Failed to start background task worker: ${errorMessage}`); - updateState(workspaceRoot, (state) => { + await updateStateAsync(workspaceRoot, (state) => { const storedJob = readStoredJob(workspaceRoot, jobId); const failedJob = { ...(storedJob ?? { id: jobId }), @@ -665,7 +664,7 @@ function markQueuedTaskSpawnFailed(workspaceRoot, jobId, logFile, error) { }); } -function enqueueBackgroundTask(cwd, job, request) { +async function enqueueBackgroundTask(cwd, job, request) { const { logFile } = createTrackedProgress(job); appendLogLine(logFile, "Queued for background execution."); @@ -678,19 +677,21 @@ function enqueueBackgroundTask(cwd, job, request) { request }; writeJobFile(job.workspaceRoot, job.id, queuedRecord); - upsertJob(job.workspaceRoot, queuedRecord); + await updateStateAsync(job.workspaceRoot, (state) => { + applyJobPatch(state, queuedRecord); + }); let child; try { child = spawnDetachedTaskWorker(cwd, job.id); } catch (error) { - markQueuedTaskSpawnFailed(job.workspaceRoot, job.id, logFile, error); + await markQueuedTaskSpawnFailed(job.workspaceRoot, job.id, logFile, error); throw error; } child.once("error", (error) => { - markQueuedTaskSpawnFailed(job.workspaceRoot, job.id, logFile, error); + void markQueuedTaskSpawnFailed(job.workspaceRoot, job.id, logFile, error); }); - markQueuedTaskSpawned(job.workspaceRoot, job.id, child.pid ?? null); + await markQueuedTaskSpawned(job.workspaceRoot, job.id, child.pid ?? null); return { payload: { @@ -800,7 +801,7 @@ async function handleTask(argv) { stopReview, jobId: job.id }); - const { payload } = enqueueBackgroundTask(cwd, job, request); + const { payload } = await enqueueBackgroundTask(cwd, job, request); outputCommandResult(payload, renderQueuedTaskLaunch(payload), options.json); return; } @@ -980,8 +981,8 @@ function terminalJobIndexPatch(job) { ); } -function syncTerminalJobIndex(workspaceRoot, job) { - updateState(workspaceRoot, (state) => { +async function syncTerminalJobIndex(workspaceRoot, job) { + await updateStateAsync(workspaceRoot, (state) => { applyJobPatch(state, terminalJobIndexPatch(job)); }); return { @@ -990,9 +991,9 @@ function syncTerminalJobIndex(workspaceRoot, job) { }; } -function readCurrentCancelJob(workspaceRoot, job) { +async function readCurrentCancelJob(workspaceRoot, job) { let currentJob = job; - withStateLock(workspaceRoot, () => { + await withStateLockAsync(workspaceRoot, () => { const stateJob = listJobs(workspaceRoot).find((candidate) => candidate.id === job.id) ?? null; const storedJob = readStoredJob(workspaceRoot, job.id); currentJob = { @@ -1004,11 +1005,11 @@ function readCurrentCancelJob(workspaceRoot, job) { return currentJob; } -function cancelJobIfStillActive(workspaceRoot, job, completedAt) { +async function cancelJobIfStillActive(workspaceRoot, job, completedAt) { let cancelled = false; let nextJob = job; - updateState(workspaceRoot, (state) => { + await updateStateAsync(workspaceRoot, (state) => { const stateJob = state.jobs.find((candidate) => candidate.id === job.id) ?? null; const storedJob = readStoredJob(workspaceRoot, job.id); const currentJob = { @@ -1062,10 +1063,10 @@ async function handleCancel(argv) { const cwd = resolveCommandCwd(options); const reference = positionals[0] ?? ""; const { workspaceRoot, job } = resolveCancelableJob(cwd, reference, { env: process.env }); - const currentJob = readCurrentCancelJob(workspaceRoot, job); + const currentJob = await readCurrentCancelJob(workspaceRoot, job); if (isTerminalStatus(currentJob.status)) { - const syncedJob = syncTerminalJobIndex(workspaceRoot, currentJob); + const syncedJob = await syncTerminalJobIndex(workspaceRoot, currentJob); const payload = { jobId: syncedJob.id, status: syncedJob.status, @@ -1084,7 +1085,7 @@ async function handleCancel(argv) { const interrupt = await interruptServerTurn(cwd, { threadId, serverUrl }); const completedAt = nowIso(); - const cancelResult = cancelJobIfStillActive(workspaceRoot, currentJob, completedAt); + const cancelResult = await cancelJobIfStillActive(workspaceRoot, currentJob, completedAt); if (cancelResult.cancelled) { const cancelLogFile = cancelResult.job.logFile ?? currentJob.logFile ?? job.logFile ?? resolveJobLogFile(workspaceRoot, cancelResult.job.id); diff --git a/plugins/opencode/scripts/session-lifecycle-hook.mjs b/plugins/opencode/scripts/session-lifecycle-hook.mjs index 869dd7f..42882e1 100644 --- a/plugins/opencode/scripts/session-lifecycle-hook.mjs +++ b/plugins/opencode/scripts/session-lifecycle-hook.mjs @@ -12,12 +12,14 @@ import { loadServerSession, teardownServerSession } from "./lib/server-lifecycle.mjs"; -import { resolveStateFile, updateState } from "./lib/state.mjs"; +import { resolveStateFile, updateStateAsync } from "./lib/state.mjs"; import { TRANSCRIPT_PATH_ENV } from "./lib/claude-session-transfer.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; export const SESSION_ID_ENV = "OPENCODE_COMPANION_SESSION_ID"; const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; +const SESSION_END_STATE_LOCK_ACQUIRE_TIMEOUT_MS = 500; +const SESSION_END_SERVER_LOCK_ACQUIRE_TIMEOUT_MS = 3000; function readHookInput() { const raw = fs.readFileSync(0, "utf8").trim(); @@ -38,7 +40,7 @@ function appendEnvVar(name, value) { fs.appendFileSync(process.env.CLAUDE_ENV_FILE, `export ${name}=${shellEscape(value)}\n`, "utf8"); } -function cleanupSessionJobs(cwd, sessionId) { +async function cleanupSessionJobs(cwd, sessionId) { if (!cwd || !sessionId) { return; } @@ -50,10 +52,14 @@ function cleanupSessionJobs(cwd, sessionId) { } let removedJobs = []; - updateState(workspaceRoot, (state) => { - removedJobs = state.jobs.filter((job) => job.sessionId === sessionId); - state.jobs = state.jobs.filter((job) => job.sessionId !== sessionId); - }); + await updateStateAsync( + workspaceRoot, + (state) => { + removedJobs = state.jobs.filter((job) => job.sessionId === sessionId); + state.jobs = state.jobs.filter((job) => job.sessionId !== sessionId); + }, + { lockAcquireTimeoutMs: SESSION_END_STATE_LOCK_ACQUIRE_TIMEOUT_MS } + ); if (removedJobs.length === 0) { return; } @@ -92,7 +98,7 @@ async function handleSessionEnd(input) { : null); try { - cleanupSessionJobs(cwd, input.session_id || process.env[SESSION_ID_ENV]); + await cleanupSessionJobs(cwd, input.session_id || process.env[SESSION_ID_ENV]); } catch (error) { process.stderr.write( `OpenCode session job cleanup failed: ${error instanceof Error ? error.message : String(error)}\n` @@ -106,8 +112,12 @@ async function handleSessionEnd(input) { sessionDir: serverSession?.sessionDir ?? null, pid: serverSession?.pid ?? null, external: Boolean(serverSession?.external), - killProcess: terminateProcessTree + killProcess: terminateProcessTree, + lockAcquireTimeoutMs: SESSION_END_SERVER_LOCK_ACQUIRE_TIMEOUT_MS }); + if (teardown?.diagnostic) { + process.stderr.write(`${teardown.diagnostic}\n`); + } if (!teardown?.skipped) { clearServerSession(cwd); } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index d05fb7a..587e7b6 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; import { interruptServerTurn, runServerTurn } from "../plugins/opencode/scripts/lib/opencode.mjs"; import { loadServerSession, saveServerSession, SERVER_URL_ENV } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; -import { resolveJobLogFile, resolveStateFile, saveState, writeJobFile } from "../plugins/opencode/scripts/lib/state.mjs"; +import { resolveJobLogFile, resolveStateDir, resolveStateFile, saveState, writeJobFile } from "../plugins/opencode/scripts/lib/state.mjs"; import { buildEnv, installFakeOpencode, readFakeState, readServerBootCount } from "./fake-opencode-fixture.mjs"; import { initGitRepo, makeTempDir, run, writeExecutable } from "./helpers.mjs"; @@ -88,6 +88,35 @@ function runServerTurnEnv(env) { }; } +function runWithTimeout(command, args, options = {}, timeoutMs) { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const timeout = setTimeout(() => { + timedOut = true; + child.kill(); + }, timeoutMs); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolve({ status, signal, stdout, stderr, timedOut }); + }); + child.stdin.end(options.input ?? ""); + }); +} + function jsonResponse(value, status = 200) { return new Response(JSON.stringify(value), { status, @@ -230,6 +259,54 @@ test("session end leaves server session when teardown is skipped for active leas }); }); +test("session end bounds a contended server teardown lock and reports a diagnostic", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir("opencode-plugin-data-"); + + await withProcessEnv({ CLAUDE_PLUGIN_DATA: pluginDataDir }, async () => { + saveServerSession(workspace, { + url: "http://127.0.0.1:1", + pid: null, + pidFile: null, + logFile: null, + sessionDir: null, + external: false, + leases: [] + }); + const lockDir = path.join(resolveStateDir(workspace), "server.lock"); + fs.mkdirSync(lockDir, { recursive: true }); + fs.writeFileSync( + path.join(lockDir, "owner.json"), + `${JSON.stringify({ pid: process.pid, token: "other-holder", createdAt: new Date().toISOString() })}\n`, + "utf8" + ); + + try { + const result = await runWithTimeout( + "node", + [SESSION_HOOK, "SessionEnd"], + { + cwd: workspace, + env: { + ...process.env, + CLAUDE_PLUGIN_DATA: pluginDataDir, + OPENCODE_COMPANION_SESSION_ID: "sess-server-lock-contention" + }, + input: JSON.stringify({ cwd: workspace, session_id: "sess-server-lock-contention" }) + }, + 4200 + ); + + assert.equal(result.timedOut, false, "SessionEnd should finish within its five-second hook budget"); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Timed out acquiring the OpenCode server lock for teardown/); + assert.equal(loadServerSession(workspace).url, "http://127.0.0.1:1"); + } finally { + fs.rmSync(lockDir, { recursive: true, force: true }); + } + }); +}); + test("stop review gate tears down a server left by a failed stop review task", { skip: LOCAL_LISTEN_SKIP }, async () => { const workspace = makeTempDir(); const binDir = makeTempDir(); diff --git a/tests/server-lifecycle.test.mjs b/tests/server-lifecycle.test.mjs index a519b62..41e0e42 100644 --- a/tests/server-lifecycle.test.mjs +++ b/tests/server-lifecycle.test.mjs @@ -1,11 +1,14 @@ import http from "node:http"; import net from "node:net"; import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; import { makeTempDir } from "./helpers.mjs"; import { ensureServer, isServerHealthy, loadServerSession, saveServerSession, teardownServerSession } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; +import { resolveStateDir } from "../plugins/opencode/scripts/lib/state.mjs"; async function canListenLocalhost() { return new Promise((resolve) => { @@ -256,3 +259,188 @@ test("teardownServerSession still skips ignored self lease when another process } } }); + +test("teardownServerSession returns a bounded diagnostic when the server lock is contended", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + const lockDir = path.join(resolveStateDir(workspace), "server.lock"); + fs.mkdirSync(lockDir, { recursive: true }); + fs.writeFileSync( + path.join(lockDir, "owner.json"), + `${JSON.stringify({ pid: process.pid, token: "other-holder", createdAt: new Date().toISOString() })}\n`, + "utf8" + ); + + let teardownPromise; + let testTimeout; + try { + const startedAt = Date.now(); + teardownPromise = teardownServerSession({ + cwd: workspace, + lockAcquireTimeoutMs: 100, + lockPollMs: 25 + }); + const result = await Promise.race([ + teardownPromise, + new Promise((resolve) => { + testTimeout = setTimeout(() => resolve({ testTimeout: true }), 500); + }) + ]); + + assert.equal(result.testTimeout, undefined, "teardown should not wait indefinitely for a live lock holder"); + assert.equal(result.skipped, true); + assert.equal(result.reason, "lock-timeout"); + assert.match(result.diagnostic, /Timed out acquiring the OpenCode server lock/); + assert.ok(Date.now() - startedAt < 500, "teardown should honor its acquisition deadline"); + } finally { + clearTimeout(testTimeout); + fs.rmSync(lockDir, { recursive: true, force: true }); + await teardownPromise?.catch(() => {}); + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } +}); + +test("ensureServer rejects when its bounded server lock acquisition times out", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + const previousFetch = globalThis.fetch; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + const url = "http://127.0.0.1:1"; + const lockDir = path.join(resolveStateDir(workspace), "server.lock"); + saveServerSession(workspace, { + url, + pid: 123456, + pidFile: null, + logFile: null, + sessionDir: null, + external: false + }); + fs.mkdirSync(lockDir, { recursive: true }); + fs.writeFileSync( + path.join(lockDir, "owner.json"), + `${JSON.stringify({ pid: process.pid, token: "other-holder", createdAt: new Date().toISOString() })}\n`, + "utf8" + ); + globalThis.fetch = async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" } + }); + + try { + await assert.rejects( + ensureServer(workspace, { lockAcquireTimeoutMs: 100, lockPollMs: 25 }), + /Timed out acquiring the OpenCode server lock/ + ); + assert.equal(loadServerSession(workspace).url, url); + } finally { + fs.rmSync(lockDir, { recursive: true, force: true }); + globalThis.fetch = previousFetch; + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } +}); + +test("teardownServerSession expires a lease whose pid reports EPERM", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + const originalKill = process.kill; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + const session = { + url: "http://127.0.0.1:1", + pid: 123456, + pidFile: null, + logFile: null, + sessionDir: null, + external: false, + leases: [ + { + pid: process.pid, + token: "foreign-owner-pid", + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() + } + ] + }; + saveServerSession(workspace, session); + process.kill = (pid, signal) => { + if (pid === process.pid && signal === 0) { + const error = new Error("operation not permitted"); + error.code = "EPERM"; + throw error; + } + return originalKill(pid, signal); + }; + + try { + let killedPid = null; + const result = await teardownServerSession({ + cwd: workspace, + url: session.url, + pid: session.pid, + killProcess: (pid) => { + killedPid = pid; + } + }); + + assert.equal(result.skipped, false); + assert.equal(killedPid, session.pid); + assert.equal(loadServerSession(workspace), null); + } finally { + process.kill = originalKill; + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } +}); + +test("saveServerSession preserves the existing session when its atomic rename fails", () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + const originalSession = { + url: "http://127.0.0.1:1", + pid: 123456, + pidFile: null, + logFile: null, + sessionDir: null, + external: false + }; + const replacementSession = { ...originalSession, url: "http://127.0.0.1:2" }; + const originalRename = fs.renameSync; + + try { + saveServerSession(workspace, originalSession); + fs.renameSync = () => { + throw new Error("simulated rename failure"); + }; + + assert.throws(() => saveServerSession(workspace, replacementSession), /simulated rename failure/); + assert.deepEqual(loadServerSession(workspace), originalSession); + assert.equal( + fs.readdirSync(resolveStateDir(workspace)).some((name) => name.startsWith("server.json.tmp-")), + false + ); + } finally { + fs.renameSync = originalRename; + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } +}); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 03b52ae..9abd1aa 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -5,7 +5,15 @@ import test from "node:test"; import assert from "node:assert/strict"; import { makeTempDir } from "./helpers.mjs"; -import { resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState } from "../plugins/opencode/scripts/lib/state.mjs"; +import { + resolveJobFile, + resolveJobLogFile, + resolveStateDir, + resolveStateFile, + saveState, + withStateLock, + withStateLockAsync +} from "../plugins/opencode/scripts/lib/state.mjs"; function withoutPluginData(fn) { const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; @@ -121,3 +129,64 @@ test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", ); }); }); + +test("withStateLockAsync yields to timers while another process holds the lock", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + const lockDir = path.join(resolveStateDir(workspace), "state.lock"); + fs.mkdirSync(lockDir, { recursive: true }); + fs.writeFileSync( + path.join(lockDir, "owner.json"), + `${JSON.stringify({ pid: process.pid, token: "other-holder", createdAt: new Date().toISOString() })}\n`, + "utf8" + ); + + const startedAt = Date.now(); + const timerDelay = new Promise((resolve) => setTimeout(() => resolve(Date.now() - startedAt), 25)); + const releaseTimer = setTimeout(() => fs.rmSync(lockDir, { recursive: true, force: true }), 100); + + try { + const lockPromise = withStateLockAsync(workspace, () => {}, { lockPollMs: 1000 }); + assert.ok((await timerDelay) < 500, "the event loop should process timers before the next lock poll"); + await lockPromise; + } finally { + clearTimeout(releaseTimer); + fs.rmSync(lockDir, { recursive: true, force: true }); + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } +}); + +test("withStateLock waits for stale-lock takeover by default", () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + const lockDir = path.join(resolveStateDir(workspace), "state.lock"); + fs.mkdirSync(lockDir, { recursive: true }); + fs.writeFileSync( + path.join(lockDir, "owner.json"), + `${JSON.stringify({ pid: process.pid, token: "stuck-holder", createdAt: new Date().toISOString() })}\n`, + "utf8" + ); + + try { + const startedAt = Date.now(); + const acquired = withStateLock(workspace, () => true, { lockPollMs: 25, lockStaleMs: 5600 }); + + assert.equal(acquired, true); + assert.ok(Date.now() - startedAt >= 5500, "the lock should be acquired by stale takeover after the old five-second deadline"); + } finally { + fs.rmSync(lockDir, { recursive: true, force: true }); + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } +}); From aa766f0292e3f953355ee41055082a707f1070b4 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Fri, 10 Jul 2026 08:38:16 +0200 Subject: [PATCH 24/32] Deny gated permissions in headless runs; drop the session wildcard (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write turns previously created sessions with a bare `{permission: "*", action: "allow", pattern: "*"}` rule and answered every permission.asked with "always". OpenCode merges session rules AFTER the agent ruleset and resolves with the LAST matching rule, so the wildcard silently stripped the stock build agent's safety guards (external_directory/.env reads/doom_loop stay on "ask"), and the auto-"always" responder would have approved any that survived. Send no session-level permission rules at all — the stock build agent's ruleset (wildcard allow + ask-guards, plus its own tool-output allowances) is exactly the intended headless write profile — and reject every headless permission ask with a diagnostic naming the gated category. Under the stock agents an ask only fires when a guard trips, so routine write turns stay approval-free. Also documents that OpenCode permissions are approval controls rather than an OS sandbox, updates the fake fixture so workspace edits are ungated while a gated ask must be denied, and marks the original auto-approve design in the adaptation plan as superseded. Co-Authored-By: Claude Fable 5 --- README.md | 3 ++ docs/opencode-adaptation-plan.md | 1 + plugins/opencode/scripts/lib/opencode.mjs | 54 +++++++++++++++-------- tests/fake-opencode-fixture.mjs | 29 ++++++++---- tests/runtime.test.mjs | 13 +++--- 5 files changed, 67 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 876662f..2f0f09d 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,9 @@ Use it when you want OpenCode to: > [!NOTE] > Depending on the task and the model you choose these tasks might take a long time and it's generally recommended to force the task to be in the background or move the agent to the background. +> [!IMPORTANT] +> Rescue tasks default to a write-capable run using OpenCode's stock `build` agent under your own OpenCode permission configuration. Permission categories OpenCode gates behind an approval prompt (external-directory access, `.env` reads, doom-loop protection) are automatically **denied** in these headless runs — the plugin never approves a gated request on your behalf. Note that OpenCode permissions are approval-level controls, not an operating-system sandbox; run the OpenCode server in a container or as a restricted user if you need a hard filesystem boundary. + It supports `--background`, `--wait`, `--resume`, and `--fresh`. If you omit `--resume` and `--fresh`, the plugin can offer to continue the latest rescue thread for this repo. Examples: diff --git a/docs/opencode-adaptation-plan.md b/docs/opencode-adaptation-plan.md index 46ebd18..0e4f03a 100644 --- a/docs/opencode-adaptation-plan.md +++ b/docs/opencode-adaptation-plan.md @@ -108,6 +108,7 @@ Claude Code ### Read-only vs write (the `sandbox` analog) - **Review / read-only task:** send the turn with `agent: "plan"` (OpenCode's read-only agent) and/or create the session with `permission` rules denying edits. - **`--write` task:** `agent: "build"` and create the session with `permission` rules set to `allow` for the edit/shell tools (equivalent to Codex `approvalPolicy: "never"` + `workspace-write`). In headless mode there's no human to approve, so the client must **auto-approve**: either pre-set `permission` on `POST /session`, or reply `allow` to `permission.asked` / `permission.v2.asked` events via `POST /session/{id}/permissions/{permID}`. Design the client to auto-approve in write mode and auto-deny-edits in read-only mode. **This is the single most important implementation detail to get right.** + - **Superseded (issue #26):** the auto-approve design above disables OpenCode's own safety guards. Session-level rules are merged *after* the agent ruleset with last-match-wins, so a broad session allow strips the stock `build` agent's `external_directory`/`.env`/`doom_loop` guards. The implementation now sends **no** session `permission` rules and **rejects** every headless `permission.asked` — under the stock agents an ask only fires when a guard trips. ### Model / effort mapping - `--model spark` → `openai/gpt-5.3-codex-spark`; `--model openai/gpt-5.4` → `{providerID:"openai", modelID:"gpt-5.4"}`; unset → OpenCode default. Add an alias map + a `provider/model` splitter. diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index 7af1f15..e80a337 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -103,14 +103,6 @@ function buildTaskSessionName(prompt) { return excerpt ? `${TASK_SESSION_PREFIX}: ${excerpt}` : TASK_SESSION_PREFIX; } -function buildWritePermissionRules() { - // OpenCode's PermissionRule.permission is a free-form string but only real - // permission keys take effect, and the built-in `build` agent's own defaults - // still leave some categories on "ask". Use the same wildcard-allow rule the - // `build` agent ships with so headless write turns never stall on approval. - return [{ permission: "*", action: "allow", pattern: "*" }]; -} - function buildCreateSessionParams(cwd, options = {}) { const write = Boolean(options.write); const agent = options.agent ?? (write ? WRITE_AGENT : READ_ONLY_AGENT); @@ -119,14 +111,22 @@ function buildCreateSessionParams(cwd, options = {}) { // BadRequest — the model is selected per-message instead (buildMessageParams). // `directory` is not accepted either: the session inherits it from the // `opencode serve` working directory, which server-lifecycle spawns with - // `cwd`. `title` must be a string when present. Read-only turns rely on the - // read-only `plan` agent instead of a permission override. + // `cwd`. `title` must be a string when present. + // + // Deliberately no session-level `permission` rules for either mode. OpenCode + // appends session rules AFTER the agent's ruleset and resolves each request + // with the LAST matching rule, so any broad session rule silently overrides + // the stock agents' safety guards (`external_directory`, `.env` reads, and + // `doom_loop` stay on "ask") — and a session-level guard would likewise + // clobber the agent's allowances for OpenCode's own tool-output directories. + // Write turns rely on the stock `build` agent, read-only turns on the + // read-only `plan` agent; guard categories that reach "ask" are denied + // headlessly in respondToPermission (issue #26). const rawTitle = options.title ?? options.threadName ?? null; const title = typeof rawTitle === "string" && rawTitle.trim() ? rawTitle : null; return { agent, - ...(title ? { title } : {}), - ...(write ? { permission: buildWritePermissionRules() } : {}) + ...(title ? { title } : {}) }; } @@ -378,8 +378,7 @@ function createTurnCaptureState(sessionID, options = {}) { recoveryError: null, response: null, fallbackTimer: null, - onProgress: options.onProgress ?? null, - write: Boolean(options.write) + onProgress: options.onProgress ?? null }; } @@ -495,20 +494,38 @@ function applyMessageParts(state, parts, sessionID) { } } +function describePermissionRequest(event) { + const category = + (typeof event?.permission === "string" ? event.permission : null) ?? + (typeof event?.properties?.permission === "string" ? event.properties.permission : null); + const rawPatterns = event?.patterns ?? event?.properties?.patterns ?? null; + const patterns = Array.isArray(rawPatterns) ? rawPatterns.filter((value) => typeof value === "string") : []; + if (!category) { + return null; + } + return patterns.length > 0 ? `${category}: ${patterns.join(", ")}` : category; +} + async function respondToPermission(client, state, event, sessionID) { const permissionID = extractPermissionId(event); if (!permissionID || !sessionID) { return; } - const response = state.write ? "always" : "reject"; + // Never self-approve, in write mode included. Headless turns have no human + // to ask, and under the stock agents a request only reaches "ask" when a + // safety guard trips (external-directory access, `.env` reads, doom-loop + // protection) or the user configured a category as interactive. Rejecting is + // the only answer that preserves those guards; the model sees the rejection + // and adapts (issue #26). + const described = describePermissionRequest(event); emitProgress( state.onProgress, - `${state.write ? "Allowing" : "Denying"} OpenCode permission request ${permissionID}.`, - state.write ? "running" : "investigating" + `Denying OpenCode permission request ${permissionID}${described ? ` (${described})` : ""}: headless runs never self-approve gated permissions.`, + "running" ); try { - await client.respondPermission(sessionID, permissionID, response); + await client.respondPermission(sessionID, permissionID, "reject"); } catch (error) { state.error = error; emitProgress(state.onProgress, `OpenCode permission response failed: ${error.message}`, "failed"); @@ -1118,7 +1135,6 @@ export async function runServerTurn(cwd, options = {}) { ), { onProgress: options.onProgress, - write, resumed: resumedSession, turnTimeoutMs: options.turnTimeoutMs } diff --git a/tests/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs index 24cee8e..d4d8af6 100644 --- a/tests/fake-opencode-fixture.mjs +++ b/tests/fake-opencode-fixture.mjs @@ -159,11 +159,11 @@ function structuredOutputParts(body) { } async function waitForPermission(permissionID) { - await new Promise((resolve) => { - const timeout = setTimeout(resolve, 2000); - pendingPermissions.set(permissionID, () => { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(null), 2000); + pendingPermissions.set(permissionID, (reply) => { clearTimeout(timeout); - resolve(); + resolve(reply ?? null); }); }); } @@ -185,10 +185,23 @@ async function handleMessage(req, res, sessionID) { emit({ type: "session.next.step.started", sessionID }); if (session.agent === "build" || body.agent === "build") { - const permissionID = "perm_" + messageID; - emit({ type: "permission.asked", sessionID, permissionID, permission: { id: permissionID, tool: "edit" } }); - await waitForPermission(permissionID); + // Workspace edits are covered by the stock build agent's wildcard allow + // and never produce a permission round-trip. emit({ type: "file.edited", sessionID, path: "generated.txt" }); + // A guard category (e.g. external_directory) reaches "ask". The companion + // must deny it; only an (incorrect) approval lets the gated edit proceed. + const permissionID = "perm_" + messageID; + emit({ + type: "permission.asked", + sessionID, + permissionID, + permission: { id: permissionID, tool: "edit" }, + patterns: ["/outside/workspace/secret.txt"] + }); + const reply = await waitForPermission(permissionID); + if (reply && reply.response !== "reject") { + emit({ type: "file.edited", sessionID, path: "/outside/workspace/secret.txt" }); + } } const finalText = prompt.includes("follow up") @@ -448,7 +461,7 @@ const server = http.createServer(async (req, res) => { saveState(state); const resolve = pendingPermissions.get(permissionID); pendingPermissions.delete(permissionID); - resolve?.(); + resolve?.(body); sendJson(res, { ok: true }); return; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index c98e185..12fc5ad 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -659,7 +659,7 @@ test("foreground task runs through opencode serve and stores a visible session", } }); -test("write task auto-allows headless permission prompts and records touched files", { skip: LOCAL_LISTEN_SKIP }, () => { +test("write task denies gated permission asks and keeps stock agent guards (issue #26)", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); installFakeOpencode(binDir); @@ -674,17 +674,18 @@ test("write task auto-allows headless permission prompts and records touched fil assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); + // Only the ungated workspace edit lands; the gated out-of-workspace edit + // stays denied, so it never shows up in touched files. assert.deepEqual(payload.touchedFiles, ["generated.txt"]); const fakeState = readFakeState(binDir); assert.equal(fakeState.sessions[0].agent, "build"); + // No session-level permission override: the stock build agent's ask-guards + // (external_directory, .env reads, doom_loop) must stay in effect. + assert.deepEqual(fakeState.sessions[0].permission, []); assert.equal(fakeState.permissions.length, 1); - assert.equal(fakeState.permissions[0].body.response, "always"); + assert.equal(fakeState.permissions[0].body.response, "reject"); assert.equal(fakeState.permissions[0].body.action, undefined); - assert.deepEqual( - fakeState.sessions[0].permission.filter((rule) => rule.action === "allow").map((rule) => rule.permission).sort(), - ["*"] - ); } finally { cleanupServer(repo, env); } From c73978a090d6e2ad5a0a91c0295076ca15f2313e Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Fri, 10 Jul 2026 09:05:30 +0200 Subject: [PATCH 25/32] Authenticate plugin-owned OpenCode servers; support secured external servers (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin-owned `opencode serve` processes ran unsecured: any local process that could reach the loopback port had full API access (file content, sessions, lifecycle). Conversely, a user who exported OPENCODE_SERVER_PASSWORD got a server the plugin's header-less client could no longer talk to. Every owned server now gets a per-server random password, passed only via the child environment (never argv) with the username pinned, and recorded in server.json written owner-only (0600). The client sends HTTP Basic auth — matching OpenCode's own convention, username defaulting to "opencode" — on all four paths: normal requests, fresh-connection requests, health checks, and the SSE event stream. On 1.17.15 every route including /global/health and /event requires the header, verified live. External servers configured via OPENCODE_COMPANION_SERVER_URL read the same OPENCODE_SERVER_PASSWORD/OPENCODE_SERVER_USERNAME variables the user already exports for a secured server; an unauthenticated 401 now produces a precise diagnostic instead of a generic health failure. Cancel processes re-derive credentials from the persisted server session or the ambient variables, and teardown authenticates its dispose call (dispose only cleans instance state — the PID termination remains the actual shutdown). The fake fixture now enforces Basic auth on every route whenever a password is present in its environment, so the whole existing suite exercises authenticated paths; new tests cover unauthenticated HTTP/SSE rejection, owner-only server.json, credential redaction in status output, and password-protected external servers (missing, wrong, and correct credentials). Co-Authored-By: Claude Fable 5 --- README.md | 2 + .../opencode/scripts/lib/opencode-server.mjs | 28 ++++- plugins/opencode/scripts/lib/opencode.mjs | 37 +++++-- .../opencode/scripts/lib/server-lifecycle.mjs | 102 +++++++++++++++--- plugins/opencode/scripts/lib/state.mjs | 8 +- tests/fake-opencode-fixture.mjs | 12 +++ tests/opencode-server.test.mjs | 64 ++++++++++- tests/runtime.test.mjs | 50 +++++++++ tests/server-lifecycle.test.mjs | 52 +++++++++ 9 files changed, 327 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 2f0f09d..c5be33e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ they already have. - Usage is billed by whichever OpenCode provider/model you select. - **Node.js 18.18 or later** +The plugin starts its own local `opencode serve` on demand, bound to `127.0.0.1` and protected with a per-server random password (HTTP Basic auth), so other local processes cannot reach its API. If you point the plugin at your own server via `OPENCODE_COMPANION_SERVER_URL` and that server is password-protected, also export `OPENCODE_SERVER_PASSWORD` (and `OPENCODE_SERVER_USERNAME` if you customized it). + ## Install Add the marketplace in Claude Code: diff --git a/plugins/opencode/scripts/lib/opencode-server.mjs b/plugins/opencode/scripts/lib/opencode-server.mjs index ab80d0d..fa4dece 100644 --- a/plugins/opencode/scripts/lib/opencode-server.mjs +++ b/plugins/opencode/scripts/lib/opencode-server.mjs @@ -17,6 +17,20 @@ function trimBaseUrl(url) { return String(url ?? "").replace(/\/+$/, ""); } +// Matches OpenCode's own client convention: HTTP Basic with the password from +// OPENCODE_SERVER_PASSWORD and a username defaulting to "opencode". On a +// password-protected server (v1.17.15) every route requires this header, +// including /global/health and the /event stream. +export function buildBasicAuthHeader(credentials = {}) { + const password = typeof credentials.password === "string" && credentials.password ? credentials.password : null; + if (!password) { + return null; + } + const username = + typeof credentials.username === "string" && credentials.username ? credentials.username : "opencode"; + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; +} + function encodePathSegment(value) { return encodeURIComponent(String(value)); } @@ -210,6 +224,7 @@ export class OpencodeServerClient { constructor(baseUrl, options = {}) { this.baseUrl = trimBaseUrl(baseUrl); this.fetch = options.fetch ?? globalThis.fetch; + this.authorization = buildBasicAuthHeader(options); if (!this.baseUrl) { throw new Error("OpenCode server URL is required."); } @@ -223,13 +238,20 @@ export class OpencodeServerClient { return `${this.baseUrl}${suffix}`; } + authHeaders() { + return this.authorization ? { authorization: this.authorization } : {}; + } + async request(method, path, options = {}) { const url = this.url(path); if (options.freshConnection) { return requestWithFreshConnection(new URL(url), { method, path, - headers: options.headers, + headers: { + ...this.authHeaders(), + ...(options.headers ?? {}) + }, body: options.body, signal: options.signal, requestTimeoutMs: options.requestTimeoutMs @@ -238,6 +260,7 @@ export class OpencodeServerClient { const headers = { ...(options.body == null ? {} : { "content-type": "application/json" }), + ...this.authHeaders(), ...(options.headers ?? {}) }; const response = await this.fetch(url, { @@ -333,7 +356,8 @@ export class OpencodeServerClient { const response = await this.fetch(this.url("/event"), { method: "GET", headers: { - accept: "text/event-stream" + accept: "text/event-stream", + ...this.authHeaders() }, signal: options.signal }); diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index e80a337..659c24e 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -4,7 +4,14 @@ import path from "node:path"; import { buildOpenCodeImportDocumentFromClaudeJsonl } from "./claude-session-transfer.mjs"; import { createTempDir, readJsonFile, writeJsonFile } from "./fs.mjs"; import { OpencodeHttpError, OpencodeServerClient } from "./opencode-server.mjs"; -import { SERVER_URL_ENV, ensureServer, loadServerSession } from "./server-lifecycle.mjs"; +import { + SERVER_PASSWORD_ENV, + SERVER_URL_ENV, + SERVER_USERNAME_ENV, + ensureServer, + loadServerSession, + serverSessionCredentials +} from "./server-lifecycle.mjs"; import { binaryAvailable, runCommandChecked } from "./process.mjs"; const TASK_SESSION_PREFIX = "OpenCode Companion Task"; @@ -844,14 +851,32 @@ async function withServer(cwd, fn) { if (!server?.url) { throw new Error("OpenCode server did not become ready."); } - return fn(new OpencodeServerClient(server.url), server); + return fn(new OpencodeServerClient(server.url, serverSessionCredentials(server)), server); } -async function abortSessionAtUrl(serverUrl, threadId, timeoutMs = 1000) { +// A job record only stores the server URL, so a later cancel process must +// re-derive credentials: the workspace's persisted owned-server session first, +// then the ambient external-server variables. +function resolveCredentialsForServerUrl(cwd, serverUrl) { + const normalized = normalizeServerUrlForCompare(serverUrl); + const session = loadServerSession(cwd); + if (session?.url && normalizeServerUrlForCompare(session.url) === normalized) { + return serverSessionCredentials(session); + } + if (normalizeServerUrlForCompare(process.env[SERVER_URL_ENV]) === normalized) { + return { + password: process.env[SERVER_PASSWORD_ENV] || null, + username: process.env[SERVER_USERNAME_ENV] || undefined + }; + } + return { password: null, username: undefined }; +} + +async function abortSessionAtUrl(serverUrl, threadId, timeoutMs = 1000, credentials = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - const client = new OpencodeServerClient(serverUrl); + const client = new OpencodeServerClient(serverUrl, credentials); await client.abort(threadId, { signal: controller.signal }); } finally { clearTimeout(timeout); @@ -1002,7 +1027,7 @@ export async function interruptServerTurn(cwd, { threadId, serverUrl = null }) { if (serverUrl) { try { - await abortSessionAtUrl(serverUrl, threadId); + await abortSessionAtUrl(serverUrl, threadId, 1000, resolveCredentialsForServerUrl(cwd, serverUrl)); const serverExternal = normalizeServerUrlForCompare(serverUrl) === normalizeServerUrlForCompare(process.env[SERVER_URL_ENV]); return { attempted: true, @@ -1044,7 +1069,7 @@ export async function interruptServerTurn(cwd, { threadId, serverUrl = null }) { if (!usedServerUrl) { throw new Error("OpenCode server did not become ready."); } - const client = new OpencodeServerClient(usedServerUrl); + const client = new OpencodeServerClient(usedServerUrl, serverSessionCredentials(server)); await client.abort(threadId); return { attempted: true, diff --git a/plugins/opencode/scripts/lib/server-lifecycle.mjs b/plugins/opencode/scripts/lib/server-lifecycle.mjs index c730a3f..e01992b 100644 --- a/plugins/opencode/scripts/lib/server-lifecycle.mjs +++ b/plugins/opencode/scripts/lib/server-lifecycle.mjs @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import fs from "node:fs"; import net from "node:net"; import os from "node:os"; @@ -11,6 +12,12 @@ import { atomicWriteFile, resolveStateDir } from "./state.mjs"; export const SERVER_URL_ENV = "OPENCODE_COMPANION_SERVER_URL"; export const PID_FILE_ENV = "OPENCODE_COMPANION_SERVER_PID_FILE"; export const LOG_FILE_ENV = "OPENCODE_COMPANION_SERVER_LOG_FILE"; +// OpenCode's own server-auth variables (not plugin-specific): when the +// password is set, `opencode serve` requires HTTP Basic auth on every route. +export const SERVER_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD"; +export const SERVER_USERNAME_ENV = "OPENCODE_SERVER_USERNAME"; + +const OWNED_SERVER_USERNAME = "opencode"; const SERVER_STATE_FILE = "server.json"; const SERVER_LOCK_DIR = "server.lock"; @@ -57,7 +64,19 @@ export function loadServerSession(cwd) { export function saveServerSession(cwd, session) { const stateDir = resolveStateDir(cwd); fs.mkdirSync(stateDir, { recursive: true }); - atomicWriteFile(resolveServerStateFile(cwd), `${JSON.stringify(session, null, 2)}\n`); + // server.json carries the owned server's password; keep it owner-only. + atomicWriteFile(resolveServerStateFile(cwd), `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 }); +} + +export function serverSessionCredentials(session) { + return { + password: typeof session?.password === "string" && session.password ? session.password : null, + username: typeof session?.username === "string" && session.username ? session.username : undefined + }; +} + +function generateServerPassword() { + return crypto.randomBytes(24).toString("base64url"); } export function clearServerSession(cwd) { @@ -77,14 +96,14 @@ async function withTimeout(fn, timeoutMs) { } } -export async function isServerHealthy(url, timeoutMs = 500) { +export async function isServerHealthy(url, timeoutMs = 500, credentials = {}) { const normalized = normalizeUrl(url); if (!normalized) { return false; } try { - const client = new OpencodeServerClient(normalized); + const client = new OpencodeServerClient(normalized, credentials); await withTimeout((signal) => client.health({ signal }), timeoutMs); return true; } catch { @@ -92,10 +111,10 @@ export async function isServerHealthy(url, timeoutMs = 500) { } } -async function waitForServerHealth(url, timeoutMs = 10000) { +async function waitForServerHealth(url, timeoutMs = 10000, credentials = {}) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - if (await isServerHealthy(url, 500)) { + if (await isServerHealthy(url, 500, credentials)) { return true; } await sleep(100); @@ -189,7 +208,7 @@ function releaseServerLock(lockDir, token) { async function loadHealthyServerSession(cwd, healthTimeoutMs) { const existing = loadServerSession(cwd); - if (existing?.url && (await isServerHealthy(existing.url, healthTimeoutMs))) { + if (existing?.url && (await isServerHealthy(existing.url, healthTimeoutMs, serverSessionCredentials(existing)))) { return existing; } return null; @@ -315,11 +334,17 @@ function findOpenPort(hostname = DEFAULT_HOSTNAME) { }); } -export function spawnServerProcess({ cwd, port, hostname = DEFAULT_HOSTNAME, pidFile, logFile, env = process.env }) { +export function spawnServerProcess({ cwd, port, hostname = DEFAULT_HOSTNAME, pidFile, logFile, env = process.env, password = null }) { + // Pass the generated password via the child environment only — never argv, + // which any local user could read from the process list. Pin the username so + // an ambient OPENCODE_SERVER_USERNAME cannot desynchronize server and client. + const childEnv = password + ? { ...env, [SERVER_PASSWORD_ENV]: password, [SERVER_USERNAME_ENV]: OWNED_SERVER_USERNAME } + : env; const logFd = fs.openSync(logFile, "a"); const child = spawn("opencode", ["serve", "--hostname", hostname, "--port", String(port)], { cwd, - env, + env: childEnv, detached: true, stdio: ["ignore", logFd, logFd], windowsHide: true, @@ -360,15 +385,35 @@ function killServerPid(pid, killProcess = null) { } export async function ensureServer(cwd, options = {}) { - const overrideUrl = normalizeUrl(options.serverUrl ?? options.env?.[SERVER_URL_ENV] ?? process.env[SERVER_URL_ENV]); + const envSource = options.env ?? process.env; + const overrideUrl = normalizeUrl(options.serverUrl ?? envSource[SERVER_URL_ENV] ?? process.env[SERVER_URL_ENV]); if (overrideUrl) { - if (!(await isServerHealthy(overrideUrl, options.healthTimeoutMs ?? 1000))) { + // A password-protected external server uses the same variables OpenCode's + // own tooling reads, so a user who secured their server has already + // exported them. + const credentials = { + password: envSource[SERVER_PASSWORD_ENV] || null, + username: envSource[SERVER_USERNAME_ENV] || undefined + }; + try { + const client = new OpencodeServerClient(overrideUrl, credentials); + await withTimeout((signal) => client.health({ signal }), options.healthTimeoutMs ?? 1000); + } catch (error) { + if (error?.status === 401) { + throw new Error( + credentials.password + ? `Configured OpenCode server rejected the provided credentials (HTTP 401): ${overrideUrl}. Check ${SERVER_PASSWORD_ENV} and ${SERVER_USERNAME_ENV}.` + : `Configured OpenCode server requires authentication: ${overrideUrl}. Export ${SERVER_PASSWORD_ENV} (and ${SERVER_USERNAME_ENV} unless it is "opencode") so the plugin can connect.` + ); + } throw new Error(`Configured OpenCode server is not healthy: ${overrideUrl}`); } return { url: overrideUrl, pid: null, - external: true + external: true, + password: credentials.password, + username: credentials.username ?? null }; } @@ -387,7 +432,7 @@ export async function ensureServer(cwd, options = {}) { const staleExisting = loadServerSession(cwd); if (staleExisting) { - const { url, pidFile, logFile, sessionDir, pid, external } = staleExisting; + const { url, pidFile, logFile, sessionDir, pid, external, password, username } = staleExisting; // The server lock is already held here; intentionally omit cwd so teardown // uses the unlocked path even if the persisted session schema grows. await teardownServerSession({ @@ -397,6 +442,8 @@ export async function ensureServer(cwd, options = {}) { sessionDir, pid, external, + password, + username, killProcess: options.killProcess ?? null }); clearServerSession(cwd); @@ -408,16 +455,23 @@ export async function ensureServer(cwd, options = {}) { const sessionDir = createServerSessionDir(); const pidFile = path.join(sessionDir, "opencode-server.pid"); const logFile = path.join(sessionDir, "opencode-server.log"); + // Every plugin-owned server gets its own random password so no other local + // process can reach the API on the loopback port (issue #27). + const password = generateServerPassword(); const child = spawnServerProcess({ cwd, hostname, port, pidFile, logFile, - env: options.env ?? process.env + env: options.env ?? process.env, + password }); - const ready = await waitForServerHealth(url, options.timeoutMs ?? 10000); + const ready = await waitForServerHealth(url, options.timeoutMs ?? 10000, { + password, + username: OWNED_SERVER_USERNAME + }); if (!ready) { await teardownServerSession({ url, @@ -425,6 +479,8 @@ export async function ensureServer(cwd, options = {}) { logFile, sessionDir, pid: child.pid ?? null, + password, + username: OWNED_SERVER_USERNAME, killProcess: options.killProcess ?? null }); return null; @@ -436,7 +492,9 @@ export async function ensureServer(cwd, options = {}) { pidFile, logFile, sessionDir, - external: false + external: false, + password, + username: OWNED_SERVER_USERNAME }; const leasedSession = addServerLease(session, options); saveServerSession(cwd, leasedSession); @@ -453,14 +511,18 @@ async function teardownServerSessionUnlocked({ sessionDir = null, pid = null, external = false, + password = null, + username = null, killProcess = null } = {}) { if (url && !external) { try { - const client = new OpencodeServerClient(url); + const client = new OpencodeServerClient(url, { password, username: username ?? undefined }); + // Dispose only cleans up instance state; on 1.17.15 it does NOT stop the + // HTTP listener, so the PID termination below is the actual shutdown. await withTimeout((signal) => client.dispose({ signal }), 1000); } catch { - // Fall back to process termination below. + // Instance cleanup is best-effort; process termination below still runs. } } @@ -504,6 +566,8 @@ export async function teardownServerSession({ sessionDir = null, pid = null, external = false, + password = null, + username = null, killProcess = null } = {}) { if (!cwd) { @@ -514,6 +578,8 @@ export async function teardownServerSession({ sessionDir, pid, external, + password, + username, killProcess }); } @@ -547,6 +613,8 @@ export async function teardownServerSession({ sessionDir: session?.sessionDir ?? sessionDir, pid: session?.pid ?? pid, external: Boolean(session?.external ?? external), + password: session?.password ?? password, + username: session?.username ?? username, killProcess }; const result = await teardownServerSessionUnlocked(teardownTarget); diff --git a/plugins/opencode/scripts/lib/state.mjs b/plugins/opencode/scripts/lib/state.mjs index e7484cc..b0bf492 100644 --- a/plugins/opencode/scripts/lib/state.mjs +++ b/plugins/opencode/scripts/lib/state.mjs @@ -282,10 +282,14 @@ function removeFileIfExists(filePath) { } } -export function atomicWriteFile(filePath, contents) { +export function atomicWriteFile(filePath, contents, options = {}) { const tempFile = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; try { - fs.writeFileSync(tempFile, contents, "utf8"); + fs.writeFileSync( + tempFile, + contents, + options.mode == null ? "utf8" : { encoding: "utf8", mode: options.mode } + ); fs.renameSync(tempFile, filePath); } catch (error) { try { diff --git a/tests/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs index d4d8af6..38294bf 100644 --- a/tests/fake-opencode-fixture.mjs +++ b/tests/fake-opencode-fixture.mjs @@ -340,8 +340,20 @@ try { } catch {} const bootStartedAt = Date.now(); const healthDelayMs = Math.max(0, Number(process.env.FAKE_OPENCODE_HEALTH_DELAY_MS || 0)); +// Mirror the real server's auth: when OPENCODE_SERVER_PASSWORD is set, every +// route (including /global/health and /event) requires HTTP Basic auth. +const serverPassword = process.env.OPENCODE_SERVER_PASSWORD || ""; +const serverUsername = process.env.OPENCODE_SERVER_USERNAME || "opencode"; +const expectedAuthorization = serverPassword + ? "Basic " + Buffer.from(serverUsername + ":" + serverPassword).toString("base64") + : null; const server = http.createServer(async (req, res) => { + if (expectedAuthorization && req.headers.authorization !== expectedAuthorization) { + res.writeHead(401, { "www-authenticate": 'Basic realm="opencode"' }); + res.end(); + return; + } const url = new URL(req.url, "http://127.0.0.1"); if (req.method === "GET" && url.pathname === "/global/health") { diff --git a/tests/opencode-server.test.mjs b/tests/opencode-server.test.mjs index 1da8636..29693d6 100644 --- a/tests/opencode-server.test.mjs +++ b/tests/opencode-server.test.mjs @@ -1,14 +1,76 @@ import http from "node:http"; +import net from "node:net"; import { EventEmitter } from "node:events"; import test from "node:test"; import assert from "node:assert/strict"; -import { OpencodeServerClient } from "../plugins/opencode/scripts/lib/opencode-server.mjs"; +import { buildBasicAuthHeader, OpencodeServerClient } from "../plugins/opencode/scripts/lib/opencode-server.mjs"; + +async function canListenLocalhost() { + return new Promise((resolve) => { + const server = net.createServer(); + let settled = false; + function finish(value) { + if (settled) { + return; + } + settled = true; + resolve(value); + } + server.once("error", () => finish(false)); + server.listen(0, "127.0.0.1", () => { + server.close(() => finish(true)); + }); + }); +} + +const LOCAL_LISTEN_AVAILABLE = await canListenLocalhost(); +const LOCAL_LISTEN_SKIP = LOCAL_LISTEN_AVAILABLE ? false : "local 127.0.0.1 listen is unavailable in this sandbox"; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +test("buildBasicAuthHeader follows OpenCode's basic-auth convention", () => { + assert.equal(buildBasicAuthHeader({ password: "pw" }), `Basic ${Buffer.from("opencode:pw").toString("base64")}`); + assert.equal( + buildBasicAuthHeader({ username: "custom", password: "pw" }), + `Basic ${Buffer.from("custom:pw").toString("base64")}` + ); + assert.equal(buildBasicAuthHeader({}), null); + assert.equal(buildBasicAuthHeader({ password: "" }), null); +}); + +test("client sends Basic auth on requests, fresh connections, and the event stream", { skip: LOCAL_LISTEN_SKIP }, async () => { + const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}`; + const seen = []; + const server = http.createServer((req, res) => { + seen.push({ url: req.url, authorization: req.headers.authorization }); + if (req.url === "/event") { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end("data: {\"type\":\"noop\"}\n\n"); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end("{}"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const client = new OpencodeServerClient(`http://127.0.0.1:${server.address().port}`, { password: "secret" }); + + try { + await client.health(); + await client.listMessages("ses_auth", { freshConnection: true, requestTimeoutMs: 2000 }); + await client.subscribeEvents(() => {}); + + assert.equal(seen.length, 3); + for (const request of seen) { + assert.equal(request.authorization, authorization, `missing auth on ${request.url}`); + } + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); + function createRequestStub(onRequest) { const req = new EventEmitter(); req.write = () => {}; diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 12fc5ad..d7a6f4d 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -691,6 +691,56 @@ test("write task denies gated permission asks and keeps stock agent guards (issu } }); +test("plugin-owned server requires auth for HTTP and SSE and never leaks the password (issue #27)", { skip: LOCAL_LISTEN_SKIP }, async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + const pluginDataDir = makeTempDir("opencode-plugin-data-"); + const env = buildTestEnv(binDir, { CLAUDE_PLUGIN_DATA: pluginDataDir }); + + try { + // The task completing at all proves the authenticated health, HTTP, and + // SSE paths work: the fixture 401s every unauthenticated route once the + // lifecycle passes it a generated password. + const result = run("node", [SCRIPT, "task", "--json", "auth roundtrip"], { cwd: repo, env }); + assert.equal(result.status, 0, result.stderr); + + await withProcessEnv({ CLAUDE_PLUGIN_DATA: pluginDataDir }, async () => { + const session = loadServerSession(repo); + assert.ok(session?.url, "server session persisted"); + assert.ok( + typeof session.password === "string" && session.password.length >= 24, + "owned server records a generated password" + ); + assert.equal(session.username, "opencode"); + + if (process.platform !== "win32") { + const stateFile = path.join(resolveStateDir(repo), "server.json"); + assert.equal(fs.statSync(stateFile).mode & 0o777, 0o600, "server.json must be owner-only"); + } + + const unauthorizedHttp = await fetch(`${session.url}/session`); + assert.equal(unauthorizedHttp.status, 401); + await unauthorizedHttp.text().catch(() => {}); + const unauthorizedSse = await fetch(`${session.url}/event`, { headers: { accept: "text/event-stream" } }); + assert.equal(unauthorizedSse.status, 401); + await unauthorizedSse.body?.cancel().catch(() => {}); + + const authorization = `Basic ${Buffer.from(`${session.username}:${session.password}`).toString("base64")}`; + const authorized = await fetch(`${session.url}/session`, { headers: { authorization } }); + assert.equal(authorized.status, 200); + await authorized.text().catch(() => {}); + + const status = run("node", [SCRIPT, "status", "--json"], { cwd: repo, env }); + assert.equal(status.status, 0, status.stderr); + assert.ok(!status.stdout.includes(session.password), "status output must not contain the server password"); + }); + } finally { + cleanupServer(repo, env); + } +}); + test("task forwards spark model alias and effort as OpenCode variant", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); diff --git a/tests/server-lifecycle.test.mjs b/tests/server-lifecycle.test.mjs index 41e0e42..1d77341 100644 --- a/tests/server-lifecycle.test.mjs +++ b/tests/server-lifecycle.test.mjs @@ -57,6 +57,58 @@ test( } ); +test( + "ensureServer handles password-protected external servers via OpenCode's auth variables (issue #27)", + { skip: LOCAL_LISTEN_AVAILABLE ? false : "local 127.0.0.1 listen is unavailable in this sandbox" }, + async () => { + const password = "external-secret"; + const expectedAuthorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`; + let authorizedRequests = 0; + const server = http.createServer((req, res) => { + if (req.headers.authorization !== expectedAuthorization) { + res.writeHead(401, { "www-authenticate": 'Basic realm="opencode"' }); + res.end(); + return; + } + authorizedRequests += 1; + if (req.method === "GET" && req.url === "/global/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const url = `http://127.0.0.1:${server.address().port}`; + const workspace = makeTempDir(); + + try { + await assert.rejects( + () => ensureServer(workspace, { env: { OPENCODE_COMPANION_SERVER_URL: url } }), + /requires authentication.*OPENCODE_SERVER_PASSWORD/s + ); + await assert.rejects( + () => + ensureServer(workspace, { + env: { OPENCODE_COMPANION_SERVER_URL: url, OPENCODE_SERVER_PASSWORD: "wrong" } + }), + /rejected the provided credentials/ + ); + + const result = await ensureServer(workspace, { + env: { OPENCODE_COMPANION_SERVER_URL: url, OPENCODE_SERVER_PASSWORD: password } + }); + assert.equal(result.external, true); + assert.equal(result.password, password); + assert.ok(authorizedRequests >= 1, "the health check must send Basic auth"); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + } +); + test("ensureServer keeps a single lease for repeated calls from the same process", async () => { const url = "http://127.0.0.1:1"; const workspace = makeTempDir(); From 531edc76b0f919454b10b890dcb41f457e69ba5d Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Fri, 10 Jul 2026 12:11:42 +0200 Subject: [PATCH 26/32] Implement the real OpenCode event contract; reject headless questions (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SSE client parsed an invented event shape: it looked for parts on message.updated and never handled message.part.updated, so against a real server (1.17.15 OpenAPI verified) no streamed text, reasoning, or structured-output part ever arrived — a valid answer could be reported as failed whenever the held-open /message response dropped and HTTP recovery was unavailable. Question events had no handler at all, so a plan-agent question stalled the turn for the full 30-minute ceiling. Client changes: - message.part.updated: accumulate part snapshots by part id (insertion order preserved) and assemble the main-session final message from the turn's assistant message text parts. - message.part.delta: append field deltas to known parts; the next snapshot stays authoritative. - message.updated is metadata-only (properties.info): assistant message id (fixes turnId picking up evt_ envelope ids) and json_schema output via info.structured. - session.created/session.updated register child sessions from properties.info (id/parentID/title/agent), so subagent output is labeled instead of polluting the final message. - question.asked / question.v2.asked are rejected immediately via POST /question/{requestID}/reject with a progress diagnostic — the model must proceed autonomously instead of stalling headlessly. The fake fixture now emits the real envelope ({id: evt_*, type, properties}) with contract-complete Session/AssistantMessage/Part objects, and a new contract test drives it over raw HTTP and validates every emitted event against tests/opencode-event-contract.json, pinned from a real 1.17.15 /doc — the fixture can no longer drift back to invented shapes. New regression tests cover question rejection, delta-only final-message assembly (dropped POST, recovery withheld), and subagent isolation. Co-Authored-By: Claude Fable 5 --- .../opencode/scripts/lib/opencode-server.mjs | 7 + plugins/opencode/scripts/lib/opencode.mjs | 206 +++++++++- tests/event-contract.test.mjs | 239 ++++++++++++ tests/fake-opencode-fixture.mjs | 197 ++++++++-- tests/opencode-event-contract.json | 364 ++++++++++++++++++ tests/runtime.test.mjs | 74 ++++ 6 files changed, 1055 insertions(+), 32 deletions(-) create mode 100644 tests/event-contract.test.mjs create mode 100644 tests/opencode-event-contract.json diff --git a/plugins/opencode/scripts/lib/opencode-server.mjs b/plugins/opencode/scripts/lib/opencode-server.mjs index fa4dece..786f492 100644 --- a/plugins/opencode/scripts/lib/opencode-server.mjs +++ b/plugins/opencode/scripts/lib/opencode-server.mjs @@ -341,6 +341,13 @@ export class OpencodeServerClient { }); } + rejectQuestion(requestID, options = {}) { + // The reject route takes no request body. + return this.request("POST", `/question/${encodePathSegment(requestID)}/reject`, { + signal: options.signal + }); + } + health(options = {}) { return this.request("GET", "/global/health", { signal: options.signal }); } diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index 659c24e..a43b0de 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -377,6 +377,10 @@ function createTurnCaptureState(sessionID, options = {}) { finalMessage: "", structuredOutput: null, reasoningSummary: [], + // Latest snapshot of every streamed part, keyed by part id. Insertion + // order is preserved across snapshot replacements, which keeps text + // assembly stable while parts stream in (issue #28). + parts: new Map(), touchedFiles: new Set(), commandExecutions: [], error: null, @@ -400,17 +404,21 @@ function labelForSession(state, sessionID) { } function trackSession(state, event) { - const sessionID = extractSessionId(event); + // Real session.created/session.updated events carry the Session object in + // `properties.info` (id, parentID, title, agent); other events only carry + // `properties.sessionID`. + const info = event?.properties?.info ?? event?.info ?? event?.session ?? null; + const sessionID = extractSessionId(event) ?? info?.id ?? null; if (!sessionID) { return; } - const parentID = extractParentSessionId(event); + const parentID = info?.parentID ?? extractParentSessionId(event); if (sessionID === state.sessionID || state.sessionIDs.has(parentID)) { state.sessionIDs.add(sessionID); const label = - event?.session?.title ?? - event?.session?.agent ?? + info?.title ?? + info?.agent ?? event?.agent ?? event?.properties?.title ?? event?.properties?.agent ?? @@ -501,6 +509,160 @@ function applyMessageParts(state, parts, sessionID) { } } +function partIsAssembledText(part) { + return String(part?.type ?? "") === "text" && typeof part?.text === "string" && !part.synthetic && !part.ignored; +} + +// Rebuild the main-session final message from streamed text-part snapshots. +// Parts belong to messages, so assemble the text of one target message: the +// turn's assistant message when known, otherwise the message of the newest +// streamed text part (its parts still arrive in order). +function rebuildMainSessionText(state, hintMessageID = null) { + const textParts = []; + for (const part of state.parts.values()) { + if ((part.sessionID ?? state.sessionID) !== state.sessionID || !partIsAssembledText(part)) { + continue; + } + textParts.push(part); + } + if (textParts.length === 0) { + return; + } + + const candidates = [state.messageID, hintMessageID, textParts[textParts.length - 1].messageID]; + const target = candidates.find((id) => id && textParts.some((part) => part.messageID === id)); + const text = textParts + .filter((part) => (part.messageID ?? target) === target) + .map((part) => part.text) + .join(""); + if (text) { + state.finalMessage = text; + } +} + +// One streamed part snapshot (message.part.updated => properties.part). The +// event carries the part's full current state, so replace by part id. +function applyPartSnapshot(state, part) { + if (!part || typeof part !== "object" || !part.id) { + return; + } + const stored = { ...(state.parts.get(part.id) ?? {}), ...part }; + state.parts.set(part.id, stored); + + const sessionID = stored.sessionID ?? state.sessionID; + if (sessionID && !state.sessionIDs.has(sessionID)) { + return; + } + const subagentLabel = labelForSession(state, sessionID); + const type = String(stored.type ?? ""); + const completed = Boolean(stored.time?.end) || stored.state?.status === "completed"; + + if (!subagentLabel) { + const structured = extractStructuredOutput([stored]); + if (structured !== undefined) { + state.structuredOutput = structured; + } + } + + if (partIsAssembledText(stored)) { + if (!subagentLabel) { + rebuildMainSessionText(state, stored.messageID ?? null); + if (completed && stored.text) { + emitLogEvent(state.onProgress, { + message: `Assistant message captured: ${shorten(stored.text, 96)}`, + stderrMessage: null, + phase: "finalizing", + logTitle: "Assistant message", + logBody: stored.text + }); + } + } else if (completed && stored.text) { + emitLogEvent(state.onProgress, { + message: `Subagent ${subagentLabel}: ${shorten(stored.text, 96)}`, + stderrMessage: null, + logTitle: `Subagent ${subagentLabel} message`, + logBody: stored.text + }); + } + return; + } + + if (type === "reasoning" && completed && stored.text) { + const reasoning = extractReasoningFromParts([stored]); + mergeReasoning(state, reasoning); + if (reasoning.length > 0) { + emitLogEvent(state.onProgress, { + message: subagentLabel + ? `Subagent ${subagentLabel} reasoning: ${shorten(reasoning[0], 96)}` + : `Reasoning summary captured: ${shorten(reasoning[0], 96)}`, + stderrMessage: null, + logTitle: subagentLabel ? `Subagent ${subagentLabel} reasoning summary` : "Reasoning summary", + logBody: reasoning.map((section) => `- ${section}`).join("\n") + }); + } + } +} + +// Incremental text (message.part.delta => {sessionID, messageID, partID, +// field, delta}). Deltas only ever extend a part we already saw a snapshot +// for; the next snapshot is authoritative and replaces the accumulated value. +function applyPartDelta(state, props) { + const partID = typeof props?.partID === "string" ? props.partID : null; + const field = typeof props?.field === "string" ? props.field : null; + const delta = typeof props?.delta === "string" ? props.delta : null; + if (!partID || !field || delta == null) { + return; + } + const existing = state.parts.get(partID); + if (!existing) { + return; + } + existing[field] = (typeof existing[field] === "string" ? existing[field] : "") + delta; + if (partIsAssembledText(existing) && (existing.sessionID ?? state.sessionID) === state.sessionID) { + rebuildMainSessionText(state, existing.messageID ?? null); + } +} + +function extractQuestionRequestId(event) { + // The question request id lives at properties.id (que_...); the envelope's + // own id is the evt_... event id and must not be used. + const id = event?.properties?.id; + return typeof id === "string" && id ? id : null; +} + +function describeQuestions(event) { + const questions = Array.isArray(event?.properties?.questions) ? event.properties.questions : []; + const summary = questions + .map((question) => question?.header ?? question?.question) + .filter((value) => typeof value === "string" && value) + .slice(0, 2) + .join("; "); + return summary || null; +} + +// OpenCode's question tool blocks its session on a deferred reply. Headless +// runs have nobody to answer, so reject immediately — the model receives the +// rejection and must proceed autonomously — instead of stalling the turn +// until the outer timeout (issue #28 / review M-02). +async function respondToQuestion(client, state, event) { + const requestID = extractQuestionRequestId(event); + if (!requestID) { + return; + } + const described = describeQuestions(event); + emitProgress( + state.onProgress, + `Rejecting OpenCode question ${requestID}${described ? ` (${shorten(described, 96)})` : ""}: headless runs cannot answer interactive questions.`, + "running" + ); + try { + await client.rejectQuestion(requestID); + } catch (error) { + state.error = error; + emitProgress(state.onProgress, `OpenCode question rejection failed: ${error.message}`, "failed"); + } +} + function describePermissionRequest(event) { const category = (typeof event?.permission === "string" ? event.permission : null) ?? @@ -581,14 +743,46 @@ async function applyOpenCodeEvent(client, state, event, meta = {}) { return; } + if (type === "session.created" || type === "session.updated") { + // Child registration already happened in trackSession above. + return; + } + if (type === "permission.asked" || type === "permission.v2.asked") { await respondToPermission(client, state, event, sessionID ?? state.sessionID); return; } + if (type === "question.asked" || type === "question.v2.asked") { + await respondToQuestion(client, state, event); + return; + } + if (type === "message.updated") { - state.messageID = event?.message?.id ?? event?.info?.id ?? event?.id ?? state.messageID; - applyMessageParts(state, extractMessageParts(event), sessionID ?? state.sessionID); + // Metadata only in the real contract: properties.info carries the Message. + // Parts arrive separately via message.part.updated / message.part.delta. + const info = event?.properties?.info ?? event?.info ?? event?.message ?? null; + const infoSessionID = info?.sessionID ?? sessionID ?? state.sessionID; + if (info?.role === "assistant" && infoSessionID === state.sessionID) { + if (typeof info.id === "string" && info.id) { + state.messageID = info.id; + rebuildMainSessionText(state); + } + // json_schema output also lands on the assistant message itself. + if (info.structured !== undefined) { + state.structuredOutput = info.structured; + } + } + return; + } + + if (type === "message.part.updated") { + applyPartSnapshot(state, event?.properties?.part ?? event?.part ?? null); + return; + } + + if (type === "message.part.delta") { + applyPartDelta(state, event?.properties ?? null); return; } diff --git a/tests/event-contract.test.mjs b/tests/event-contract.test.mjs new file mode 100644 index 0000000..7c1e0dc --- /dev/null +++ b/tests/event-contract.test.mjs @@ -0,0 +1,239 @@ +import net from "node:net"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +import { installFakeOpencode } from "./fake-opencode-fixture.mjs"; +import { makeTempDir } from "./helpers.mjs"; + +// Pinned from a real `opencode serve` /doc (see contract.version). The fake +// fixture must emit events that satisfy this contract so the suite cannot +// drift back to invented event shapes (issue #28 / review H-03). +const require = createRequire(import.meta.url); +const contract = require("./opencode-event-contract.json"); + +async function canListenLocalhost() { + return new Promise((resolve) => { + const server = net.createServer(); + let settled = false; + function finish(value) { + if (settled) { + return; + } + settled = true; + resolve(value); + } + server.once("error", () => finish(false)); + server.listen(0, "127.0.0.1", () => { + server.close(() => finish(true)); + }); + }); +} + +const LOCAL_LISTEN_AVAILABLE = await canListenLocalhost(); +const LOCAL_LISTEN_SKIP = LOCAL_LISTEN_AVAILABLE ? false : "local 127.0.0.1 listen is unavailable in this sandbox"; + +function startFixtureServer(binDir, env = {}) { + const child = spawn("node", [path.join(binDir, "opencode"), "serve", "--hostname", "127.0.0.1", "--port", "0"], { + env: { + ...process.env, + FAKE_OPENCODE_STATE_PATH: path.join(binDir, "fake-opencode-state.json"), + // The raw-HTTP contract run talks to the fixture without credentials. + OPENCODE_SERVER_PASSWORD: "", + ...env + }, + windowsHide: true + }); + + const url = new Promise((resolve, reject) => { + let output = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + output += chunk; + const match = output.match(/listening on (http:\/\/[^\s]+)/); + if (match) { + resolve(match[1]); + } + }); + child.once("error", reject); + child.once("exit", () => reject(new Error("fixture server exited before listening"))); + setTimeout(() => reject(new Error("fixture server did not start")), 5000).unref(); + }); + + return { child, url }; +} + +function collectSseEvents(baseUrl, onEvent) { + const controller = new AbortController(); + const done = (async () => { + const response = await fetch(`${baseUrl}/event`, { + headers: { accept: "text/event-stream" }, + signal: controller.signal + }); + assert.equal(response.status, 200); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const { done: finished, value } = await reader.read(); + if (finished) { + break; + } + buffer += decoder.decode(value, { stream: true }); + for (;;) { + const blockEnd = buffer.indexOf("\n\n"); + if (blockEnd === -1) { + break; + } + const block = buffer.slice(0, blockEnd); + buffer = buffer.slice(blockEnd + 2); + const data = block + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"); + if (data) { + await onEvent(JSON.parse(data)); + } + } + } + })().catch((error) => { + if (!controller.signal.aborted) { + throw error; + } + }); + return { stop: () => controller.abort(), done }; +} + +function validatePinnedEvent(event, errors) { + const spec = contract.events[event?.type]; + if (!spec) { + errors.push(`unpinned event type emitted by fixture: ${event?.type}`); + return; + } + + for (const key of spec.required) { + if (!(key in event)) { + errors.push(`${event.type}: missing envelope key "${key}"`); + } + } + if (typeof event.id !== "string" || !event.id.startsWith("evt_")) { + errors.push(`${event.type}: envelope id must match ^evt_ (got ${event.id})`); + } + const properties = event.properties ?? {}; + for (const key of spec.properties.required) { + if (!(key in properties)) { + errors.push(`${event.type}: missing properties key "${key}"`); + } + } + for (const key of Object.keys(properties)) { + if (!spec.properties.keys.includes(key)) { + errors.push(`${event.type}: unexpected properties key "${key}" (additionalProperties: false upstream)`); + } + } + + const objectErrors = (objectName, value, label) => { + for (const key of contract.objects[objectName].required) { + if (!value || !(key in value)) { + errors.push(`${event.type}: ${label} missing required "${key}" (${objectName})`); + } + } + }; + + if (event.type === "session.created" || event.type === "session.updated") { + objectErrors("Session", properties.info, "properties.info"); + } + if (event.type === "message.updated") { + const role = properties.info?.role; + objectErrors(role === "user" ? "UserMessage" : "AssistantMessage", properties.info, "properties.info"); + } + if (event.type === "message.part.updated") { + const type = properties.part?.type; + const objectName = type === "reasoning" ? "ReasoningPart" : type === "tool" ? "ToolPart" : "TextPart"; + objectErrors(objectName, properties.part, "properties.part"); + } +} + +test("fake fixture events satisfy the pinned OpenCode event contract", { skip: LOCAL_LISTEN_SKIP }, async () => { + const binDir = makeTempDir(); + installFakeOpencode(binDir); + const { child, url } = startFixtureServer(binDir, { + FAKE_OPENCODE_ASK_QUESTION: "1", + FAKE_OPENCODE_SUBAGENT: "1", + FAKE_OPENCODE_STREAM_DELTAS: "1" + }); + + const events = []; + const errors = []; + let sawIdle; + const idle = new Promise((resolve) => { + sawIdle = resolve; + }); + let stream = null; + + try { + const baseUrl = await url; + stream = collectSseEvents(baseUrl, async (event) => { + events.push(event); + validatePinnedEvent(event, errors); + // Answer the interactive flows the way the real client must: deny the + // gated permission, reject the question. + if (event.type === "permission.asked") { + await fetch(`${baseUrl}/session/${event.properties.sessionID}/permissions/${event.properties.id}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ response: "reject" }) + }); + } + if (event.type === "question.asked") { + await fetch(`${baseUrl}/question/${event.properties.id}/reject`, { method: "POST" }); + } + if (event.type === "session.idle") { + sawIdle(); + } + }); + + const session = await fetch(`${baseUrl}/session`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agent: "build" }) + }).then((response) => response.json()); + assert.ok(session.id, "fixture session created"); + + const message = fetch(`${baseUrl}/session/${session.id}/message`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agent: "build", parts: [{ type: "text", text: "contract run" }] }) + }).catch(() => null); + + await Promise.race([ + idle, + new Promise((_, reject) => setTimeout(() => reject(new Error("session.idle never arrived")), 8000).unref()) + ]); + await message; + + assert.deepEqual(errors, [], errors.join("\n")); + const seenTypes = new Set(events.map((event) => event.type)); + for (const expected of [ + "session.created", + "message.updated", + "session.next.step.started", + "file.edited", + "permission.asked", + "question.asked", + "message.part.updated", + "message.part.delta", + "session.idle" + ]) { + assert.ok(seenTypes.has(expected), `expected fixture to emit ${expected}; saw ${[...seenTypes].join(", ")}`); + } + assert.equal(contract.version, "1.17.15"); + } finally { + stream?.stop(); + await stream?.done.catch(() => {}); + child.kill(); + } +}); diff --git a/tests/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs index 38294bf..f13fd94 100644 --- a/tests/fake-opencode-fixture.mjs +++ b/tests/fake-opencode-fixture.mjs @@ -38,6 +38,7 @@ const path = require("node:path"); const STATE_PATH = process.env.FAKE_OPENCODE_STATE_PATH || ${JSON.stringify(statePath)}; const clients = new Set(); const pendingPermissions = new Map(); +const pendingQuestions = new Map(); function loadState() { if (!fs.existsSync(STATE_PATH)) { @@ -86,13 +87,47 @@ function sendJson(res, value, status = 200) { res.end(JSON.stringify(value)); } -function emit(event) { - const line = "data: " + JSON.stringify(event) + "\\n\\n"; +// Real event envelope (pinned in tests/opencode-event-contract.json): +// { id: "evt_...", type, properties }. +let nextEventId = 1; +function emit(type, properties) { + const line = "data: " + JSON.stringify({ id: "evt_" + nextEventId++, type, properties }) + "\\n\\n"; for (const client of clients) { client.write(line); } } +function sessionInfo(session, parentID) { + return { + id: session.id, + slug: "fake-" + session.id, + projectID: "prj_fake", + directory: session.directory || process.cwd(), + title: session.title || "", + version: "1.17.15", + time: { created: Date.now(), updated: Date.now() }, + ...(session.agent ? { agent: session.agent } : {}), + ...(parentID ? { parentID } : {}) + }; +} + +function assistantInfo(sessionID, messageID, agent) { + return { + id: messageID, + sessionID, + role: "assistant", + time: { created: Date.now() }, + parentID: messageID + "_user", + modelID: "fake-model", + providerID: "fake", + mode: "normal", + agent: agent || "build", + path: { cwd: process.cwd(), root: process.cwd() }, + cost: 0, + tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } + }; +} + function textFromMessage(body) { return (body.parts || []) .map((part) => typeof part.text === "string" ? part.text : "") @@ -148,6 +183,7 @@ function structuredOutputParts(body) { { type: "tool", tool: "StructuredOutput", + callID: "call_structured_1", state: { status: "completed", input: exampleForSchema(body.format.schema, "result") @@ -168,6 +204,16 @@ async function waitForPermission(permissionID) { }); } +async function waitForQuestion(requestID) { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), 2000); + pendingQuestions.set(requestID, () => { + clearTimeout(timeout); + resolve(true); + }); + }); +} + async function handleMessage(req, res, sessionID) { const body = await readJson(req); const state = loadState(); @@ -183,34 +229,94 @@ async function handleMessage(req, res, sessionID) { state.lastMessage = { sessionID, messageID, body, prompt }; saveState(state); - emit({ type: "session.next.step.started", sessionID }); - if (session.agent === "build" || body.agent === "build") { + const agent = session.agent || body.agent || "build"; + const info = assistantInfo(sessionID, messageID, agent); + emit("message.updated", { sessionID, info }); + emit("session.next.step.started", { + timestamp: Date.now(), + sessionID, + assistantMessageID: messageID, + agent, + model: { providerID: "fake", modelID: "fake-model" } + }); + + if (agent === "build") { // Workspace edits are covered by the stock build agent's wildcard allow // and never produce a permission round-trip. - emit({ type: "file.edited", sessionID, path: "generated.txt" }); - // A guard category (e.g. external_directory) reaches "ask". The companion - // must deny it; only an (incorrect) approval lets the gated edit proceed. + emit("file.edited", { file: "generated.txt" }); + // A guard category (external_directory) reaches "ask". The companion must + // deny it; only an (incorrect) approval lets the gated edit proceed. const permissionID = "perm_" + messageID; - emit({ - type: "permission.asked", + emit("permission.asked", { + id: permissionID, sessionID, - permissionID, - permission: { id: permissionID, tool: "edit" }, - patterns: ["/outside/workspace/secret.txt"] + permission: "external_directory", + patterns: ["/outside/workspace/secret.txt"], + metadata: {}, + always: ["/outside/workspace/secret.txt"], + tool: { messageID, callID: "call_edit_1" } }); const reply = await waitForPermission(permissionID); if (reply && reply.response !== "reject") { - emit({ type: "file.edited", sessionID, path: "/outside/workspace/secret.txt" }); + emit("file.edited", { file: "/outside/workspace/secret.txt" }); } } + if (process.env.FAKE_OPENCODE_ASK_QUESTION === "1") { + // The question tool blocks the session on a deferred reply; a headless + // client must reject it via POST /question/{requestID}/reject. + const questionID = "que_" + messageID; + emit("question.asked", { + id: questionID, + sessionID, + questions: [ + { + question: "Which approach should I take?", + header: "Approach", + options: [{ label: "Option A" }, { label: "Option B" }] + } + ] + }); + await waitForQuestion(questionID); + } + + if (process.env.FAKE_OPENCODE_SUBAGENT === "1") { + // A child session announces itself via session.created with parentID in + // properties.info; its output must not pollute the parent final message. + const childID = sessionID + "_child"; + const childMessageID = "msg_child_" + messageID; + emit("session.created", { + sessionID: childID, + info: sessionInfo({ id: childID, directory: process.cwd(), title: "Subtask: explore", agent: "explore" }, sessionID) + }); + emit("message.updated", { sessionID: childID, info: assistantInfo(childID, childMessageID, "explore") }); + emit("message.part.updated", { + sessionID: childID, + part: { + id: "prt_" + childMessageID, + sessionID: childID, + messageID: childMessageID, + type: "text", + text: "Child exploration output.", + time: { start: Date.now(), end: Date.now() } + }, + time: Date.now() + }); + } + const finalText = prompt.includes("follow up") ? "Resumed the prior OpenCode run.\\nFollow-up prompt accepted." : "Handled the requested task.\\nTask prompt accepted."; - const parts = structuredOutputParts(body) || [{ type: "text", text: finalText }]; + const parts = (structuredOutputParts(body) || [{ type: "text", text: finalText }]).map((part, index) => ({ + id: "prt_" + messageID + "_" + index, + sessionID, + messageID, + ...(part.type === "text" ? { time: { start: Date.now(), end: Date.now() } } : {}), + ...part + })); const failMode = process.env.FAKE_OPENCODE_MESSAGE_FAIL; if (failMode === "empty-recovery" || failMode === "snapshot-fails-empty-recovery") { - emit({ type: "session.idle", sessionID }); + emit("session.idle", { sessionID }); res.destroy(); return; } @@ -221,12 +327,33 @@ async function handleMessage(req, res, sessionID) { const finalState = loadState(); finalState.lastResponseParts = parts; finalState.responses = finalState.responses || []; - finalState.responses.push({ sessionID, info: { id: messageID, role: "assistant", sessionID }, parts }); + finalState.responses.push({ sessionID, info, parts }); saveState(finalState); + + if (process.env.FAKE_OPENCODE_STREAM_DELTAS === "1" && parts[0].type === "text") { + // Stream the text purely as an initial empty snapshot plus deltas, then + // drop the POST and withhold recovery data: the client must assemble the + // final message from the delta stream alone. + const full = parts[0].text; + emit("message.part.updated", { + sessionID, + part: Object.assign({}, parts[0], { text: "", time: { start: Date.now() } }), + time: Date.now() + }); + emit("message.part.delta", { sessionID, messageID, partID: parts[0].id, field: "text", delta: full.slice(0, 8) }); + emit("message.part.delta", { sessionID, messageID, partID: parts[0].id, field: "text", delta: full.slice(8) }); + const cleanState = loadState(); + cleanState.responses = (cleanState.responses || []).filter((entry) => entry.info.id !== messageID); + saveState(cleanState); + emit("session.idle", { sessionID }); + res.destroy(); + return; + } + // Issue #2 regression hooks. "transport": deliver the turn over the event // stream but drop the /message HTTP response mid-flight (like undici timing - // out the held-open POST). "recover": additionally withhold message.updated so - // the client must re-fetch the finished message via GET /session/:id/message. + // out the held-open POST). "recover": additionally withhold the part events + // so the client must re-fetch the finished message via GET /session/:id/message. // "delayed-events" drops the POST before completion events arrive, matching // the real failure ordering seen in issue #2 review. "mismatched-recover" // gives recovery a stale event-derived message id, then expects fallback to @@ -234,26 +361,30 @@ async function handleMessage(req, res, sessionID) { if (failMode === "delayed-events") { res.destroy(); setTimeout(() => { - emit({ type: "message.updated", sessionID, message: { id: messageID, parts } }); - emit({ type: "session.idle", sessionID }); + for (const part of parts) { + emit("message.part.updated", { sessionID, part, time: Date.now() }); + } + emit("session.idle", { sessionID }); }, 25); return; } if (failMode === "mismatched-recover") { - emit({ type: "message.updated", sessionID, message: { id: messageID + "_event_only" } }); - emit({ type: "session.idle", sessionID }); + emit("message.updated", { sessionID, info: assistantInfo(sessionID, messageID + "_event_only", agent) }); + emit("session.idle", { sessionID }); res.destroy(); return; } if (failMode !== "recover") { - emit({ type: "message.updated", sessionID, message: { id: messageID, parts } }); + for (const part of parts) { + emit("message.part.updated", { sessionID, part, time: Date.now() }); + } } - emit({ type: "session.idle", sessionID }); + emit("session.idle", { sessionID }); if (failMode === "transport" || failMode === "recover") { res.destroy(); return; } - sendJson(res, { info: { id: messageID, sessionID }, parts }); + sendJson(res, { info, parts }); } function handleSessionListCli() { @@ -422,7 +553,7 @@ const server = http.createServer(async (req, res) => { state.sessions.unshift(session); state.lastCreateSession = body; saveState(state); - emit({ type: "session.created", sessionID: session.id, session }); + emit("session.created", { sessionID: session.id, info: sessionInfo(session, body.parentID) }); sendJson(res, session); return; } @@ -457,6 +588,20 @@ const server = http.createServer(async (req, res) => { return; } + const questionRejectMatch = url.pathname.match(/^\\/question\\/([^/]+)\\/reject$/); + if (req.method === "POST" && questionRejectMatch) { + const requestID = decodeURIComponent(questionRejectMatch[1]); + const state = loadState(); + state.questionRejections = state.questionRejections || []; + state.questionRejections.push({ requestID }); + saveState(state); + const resolve = pendingQuestions.get(requestID); + pendingQuestions.delete(requestID); + resolve?.(); + sendJson(res, true); + return; + } + const permissionMatch = url.pathname.match(/^\\/session\\/([^/]+)\\/permissions\\/([^/]+)$/); if (req.method === "POST" && permissionMatch) { const body = await readJson(req); diff --git a/tests/opencode-event-contract.json b/tests/opencode-event-contract.json new file mode 100644 index 0000000..93b7c0c --- /dev/null +++ b/tests/opencode-event-contract.json @@ -0,0 +1,364 @@ +{ + "source": "opencode /doc", + "version": "1.17.15", + "events": { + "session.created": { + "required": [ + "id", + "type", + "properties" + ], + "properties": { + "required": [ + "sessionID", + "info" + ], + "keys": [ + "sessionID", + "info" + ] + } + }, + "session.updated": { + "required": [ + "id", + "type", + "properties" + ], + "properties": { + "required": [ + "sessionID", + "info" + ], + "keys": [ + "sessionID", + "info" + ] + } + }, + "session.idle": { + "required": [ + "id", + "type", + "properties" + ], + "properties": { + "required": [ + "sessionID" + ], + "keys": [ + "sessionID" + ] + } + }, + "session.error": { + "required": [ + "id", + "type", + "properties" + ], + "properties": { + "required": [], + "keys": [ + "sessionID", + "error" + ] + } + }, + "message.updated": { + "required": [ + "id", + "type", + "properties" + ], + "properties": { + "required": [ + "sessionID", + "info" + ], + "keys": [ + "sessionID", + "info" + ] + } + }, + "message.part.updated": { + "required": [ + "id", + "type", + "properties" + ], + "properties": { + "required": [ + "sessionID", + "part", + "time" + ], + "keys": [ + "sessionID", + "part", + "time" + ] + } + }, + "message.part.delta": { + "required": [ + "id", + "type", + "properties" + ], + "properties": { + "required": [ + "sessionID", + "messageID", + "partID", + "field", + "delta" + ], + "keys": [ + "sessionID", + "messageID", + "partID", + "field", + "delta" + ] + } + }, + "file.edited": { + "required": [ + "id", + "type", + "properties" + ], + "properties": { + "required": [ + "file" + ], + "keys": [ + "file" + ] + } + }, + "permission.asked": { + "required": [ + "id", + "type", + "properties" + ], + "properties": { + "required": [ + "id", + "sessionID", + "permission", + "patterns", + "metadata", + "always" + ], + "keys": [ + "id", + "sessionID", + "permission", + "patterns", + "metadata", + "always", + "tool" + ] + } + }, + "question.asked": { + "required": [ + "id", + "type", + "properties" + ], + "properties": { + "required": [ + "id", + "sessionID", + "questions" + ], + "keys": [ + "id", + "sessionID", + "questions", + "tool" + ] + } + }, + "session.next.step.started": { + "required": [ + "id", + "type", + "properties" + ], + "properties": { + "required": [ + "timestamp", + "sessionID", + "assistantMessageID", + "agent", + "model" + ], + "keys": [ + "timestamp", + "sessionID", + "assistantMessageID", + "agent", + "model", + "snapshot" + ] + } + } + }, + "objects": { + "TextPart": { + "required": [ + "id", + "sessionID", + "messageID", + "type", + "text" + ], + "keys": [ + "id", + "sessionID", + "messageID", + "type", + "text", + "synthetic", + "ignored", + "time", + "metadata" + ] + }, + "ReasoningPart": { + "required": [ + "id", + "sessionID", + "messageID", + "type", + "text", + "time" + ], + "keys": [ + "id", + "sessionID", + "messageID", + "type", + "text", + "metadata", + "time" + ] + }, + "ToolPart": { + "required": [ + "id", + "sessionID", + "messageID", + "type", + "callID", + "tool", + "state" + ], + "keys": [ + "id", + "sessionID", + "messageID", + "type", + "callID", + "tool", + "state", + "metadata" + ] + }, + "AssistantMessage": { + "required": [ + "id", + "sessionID", + "role", + "time", + "parentID", + "modelID", + "providerID", + "mode", + "agent", + "path", + "cost", + "tokens" + ], + "keys": [ + "id", + "sessionID", + "role", + "time", + "error", + "parentID", + "modelID", + "providerID", + "mode", + "agent", + "path", + "summary", + "cost", + "tokens", + "structured", + "variant", + "finish" + ] + }, + "UserMessage": { + "required": [ + "id", + "sessionID", + "role", + "time", + "agent", + "model" + ], + "keys": [ + "id", + "sessionID", + "role", + "time", + "format", + "summary", + "agent", + "model", + "system", + "tools" + ] + }, + "Session": { + "required": [ + "id", + "slug", + "projectID", + "directory", + "title", + "version", + "time" + ], + "keys": [ + "id", + "slug", + "projectID", + "workspaceID", + "directory", + "path", + "parentID", + "summary", + "cost", + "tokens", + "share", + "title", + "agent", + "model", + "version", + "metadata", + "time", + "permission", + "revert" + ] + } + } +} diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index d7a6f4d..f3a9606 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1036,6 +1036,80 @@ test("task recovery falls back to the newest assistant message when event messag } }); +test("task rejects headless question asks instead of stalling (issue #28)", { skip: LOCAL_LISTEN_SKIP }, () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + const env = buildTestEnv(binDir, { FAKE_OPENCODE_ASK_QUESTION: "1" }); + + try { + const result = run("node", [SCRIPT, "task", "--json", "task that provokes a question"], { + cwd: repo, + env + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, 0); + assert.match(payload.rawOutput, /Handled the requested task/); + + const fakeState = readFakeState(binDir); + assert.equal(fakeState.questionRejections.length, 1); + assert.match(fakeState.questionRejections[0].requestID, /^que_/); + } finally { + cleanupServer(repo, env); + } +}); + +test("task assembles the final message from part deltas without transport or recovery (issue #28)", { skip: LOCAL_LISTEN_SKIP }, () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + // The POST response is dropped and the message list withheld, so the final + // text can only come from the message.part.updated + message.part.delta + // stream — the exact channel the pre-#28 client ignored. + const env = buildTestEnv(binDir, { FAKE_OPENCODE_STREAM_DELTAS: "1" }); + + try { + const result = run("node", [SCRIPT, "task", "--json", "stream this answer"], { + cwd: repo, + env + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, 0); + assert.match(payload.rawOutput, /^Handled the requested task/); + } finally { + cleanupServer(repo, env); + } +}); + +test("subagent session output does not pollute the main final message (issue #28)", { skip: LOCAL_LISTEN_SKIP }, () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + const env = buildTestEnv(binDir, { FAKE_OPENCODE_SUBAGENT: "1" }); + + try { + const result = run("node", [SCRIPT, "task", "--json", "task with a subagent"], { + cwd: repo, + env + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, 0); + assert.match(payload.rawOutput, /Handled the requested task/); + assert.doesNotMatch(payload.rawOutput, /Child exploration output/); + } finally { + cleanupServer(repo, env); + } +}); + test("task fails when completion has no recoverable current-turn message (issue #2)", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); From 0508cc4f1eddf823fd535dd041255a592c865592 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Fri, 10 Jul 2026 12:36:27 +0200 Subject: [PATCH 27/32] Bind requests to the invoking workspace; persist server ownership (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External-server mode had two workspace-safety gaps. First, the client never sent the `directory` query, so every project-scoped request (and the /event subscription) resolved against the server process's launch directory — a review could inspect, and a write task could edit, the wrong repository. Second, job records stored only serverUrl; cancel recomputed "external" by comparing against the CURRENT process's OPENCODE_COMPANION_SERVER_URL, so a cancel process without the job-start environment would send /global/dispose to a user-managed server. The client now appends the canonical (realpath) workspace directory to every non-/global/ route — all of them accept it per the 1.17.15 OpenAPI document, and live checks confirm session creation and listing scope to the query value. Job records persist `serverExternal` at job start (and via progress updates for foreground runs); cancel passes the persisted flag through interruptServerTurn, which no longer infers ownership from the environment. Teardown runs only when the job explicitly recorded plugin ownership; a recorded URL with unknown ownership (legacy records) fails safe and leaves the server running. The fixture now honors the directory query on session create/list and records the scope of event subscriptions. New tests: two workspaces sharing one external server get correctly-scoped sessions and event streams; cancelling a job whose environment differs from the job-start environment aborts the session but leaves the external server alive (the fixture exits on dispose, so a regression fails the health check); client unit coverage that project routes carry the directory and /global routes never do. Co-Authored-By: Claude Fable 5 --- .../opencode/scripts/lib/opencode-server.mjs | 12 +- plugins/opencode/scripts/lib/opencode.mjs | 69 ++++++--- plugins/opencode/scripts/lib/tracked-jobs.mjs | 10 ++ .../opencode/scripts/opencode-companion.mjs | 20 ++- tests/fake-opencode-fixture.mjs | 20 ++- tests/opencode-server.test.mjs | 35 +++++ tests/runtime.test.mjs | 143 +++++++++++++++++- 7 files changed, 283 insertions(+), 26 deletions(-) diff --git a/plugins/opencode/scripts/lib/opencode-server.mjs b/plugins/opencode/scripts/lib/opencode-server.mjs index 786f492..182f68f 100644 --- a/plugins/opencode/scripts/lib/opencode-server.mjs +++ b/plugins/opencode/scripts/lib/opencode-server.mjs @@ -225,6 +225,7 @@ export class OpencodeServerClient { this.baseUrl = trimBaseUrl(baseUrl); this.fetch = options.fetch ?? globalThis.fetch; this.authorization = buildBasicAuthHeader(options); + this.directory = typeof options.directory === "string" && options.directory ? options.directory : null; if (!this.baseUrl) { throw new Error("OpenCode server URL is required."); } @@ -235,7 +236,16 @@ export class OpencodeServerClient { url(path) { const suffix = String(path ?? "").startsWith("/") ? path : `/${path}`; - return `${this.baseUrl}${suffix}`; + const base = `${this.baseUrl}${suffix}`; + // Bind project-scoped requests to the invoking workspace. Without the + // `directory` query an external server resolves them against its own + // launch directory (issue #29). Every non-/global/ route the client uses + // accepts it (verified against the 1.17.15 OpenAPI document). + if (!this.directory || suffix.startsWith("/global/")) { + return base; + } + const separator = suffix.includes("?") ? "&" : "?"; + return `${base}${separator}directory=${encodeURIComponent(this.directory)}`; } authHeaders() { diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index a43b0de..93a8066 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -1040,12 +1040,29 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { } } +// The canonical (symlink-resolved) workspace path is what OpenCode records as +// a session's directory, so use it for the client's directory scope. +function canonicalWorkspaceDirectory(cwd) { + try { + return fs.realpathSync(cwd); + } catch { + return cwd; + } +} + +function buildServerClient(cwd, server) { + return new OpencodeServerClient(server.url, { + ...serverSessionCredentials(server), + directory: canonicalWorkspaceDirectory(cwd) + }); +} + async function withServer(cwd, fn) { const server = await ensureServer(cwd); if (!server?.url) { throw new Error("OpenCode server did not become ready."); } - return fn(new OpencodeServerClient(server.url, serverSessionCredentials(server)), server); + return fn(buildServerClient(cwd, server), server); } // A job record only stores the server URL, so a later cancel process must @@ -1066,11 +1083,14 @@ function resolveCredentialsForServerUrl(cwd, serverUrl) { return { password: null, username: undefined }; } -async function abortSessionAtUrl(serverUrl, threadId, timeoutMs = 1000, credentials = {}) { +async function abortSessionAtUrl(serverUrl, threadId, timeoutMs = 1000, credentials = {}, directory = null) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - const client = new OpencodeServerClient(serverUrl, credentials); + const client = new OpencodeServerClient(serverUrl, { + ...credentials, + ...(directory ? { directory } : {}) + }); await client.abort(threadId, { signal: controller.signal }); } finally { clearTimeout(timeout); @@ -1142,14 +1162,18 @@ export function getAvailability(cwd) { } export function getSessionRuntimeStatus(env = process.env, cwd = process.cwd()) { - const url = env?.[SERVER_URL_ENV] ?? loadServerSession(cwd)?.url ?? null; + const envUrl = env?.[SERVER_URL_ENV] ?? null; + const url = envUrl ?? loadServerSession(cwd)?.url ?? null; if (url) { return { mode: "shared", label: "shared OpenCode server", detail: "This Claude session is configured to reuse one shared OpenCode server.", endpoint: url, - url + url, + // Env-configured servers are user-managed; a persisted server session is + // one the plugin spawned and owns. + external: Boolean(envUrl) }; } @@ -1158,7 +1182,8 @@ export function getSessionRuntimeStatus(env = process.env, cwd = process.cwd()) label: "lazy OpenCode server startup", detail: "No shared OpenCode server is active yet. The first review or task command will start one on demand.", endpoint: null, - url: null + url: null, + external: false }; } @@ -1205,41 +1230,48 @@ export async function getAuthStatus(cwd) { } } -export async function interruptServerTurn(cwd, { threadId, serverUrl = null }) { +// `serverExternal` is the ownership flag persisted in the job record when the +// job started. Historical ownership must never be inferred from the current +// process environment — a cancel process without the job-start env would +// otherwise mistake a user-managed server for a plugin-owned one (issue #29). +export async function interruptServerTurn(cwd, { threadId, serverUrl = null, serverExternal = null }) { + const ownership = typeof serverExternal === "boolean" ? { serverExternal } : {}; if (!threadId) { - const serverExternal = - serverUrl && normalizeServerUrlForCompare(serverUrl) === normalizeServerUrlForCompare(process.env[SERVER_URL_ENV]); return { attempted: false, interrupted: false, transport: null, detail: "missing OpenCode session id", ...(serverUrl ? { serverUrl } : {}), - ...(serverExternal ? { serverExternal: true } : {}) + ...ownership }; } if (serverUrl) { try { - await abortSessionAtUrl(serverUrl, threadId, 1000, resolveCredentialsForServerUrl(cwd, serverUrl)); - const serverExternal = normalizeServerUrlForCompare(serverUrl) === normalizeServerUrlForCompare(process.env[SERVER_URL_ENV]); + await abortSessionAtUrl( + serverUrl, + threadId, + 1000, + resolveCredentialsForServerUrl(cwd, serverUrl), + canonicalWorkspaceDirectory(cwd) + ); return { attempted: true, interrupted: true, transport: "server", detail: `Aborted OpenCode session ${threadId}.`, serverUrl, - ...(serverExternal ? { serverExternal: true } : {}) + ...ownership }; } catch (error) { - const serverExternal = normalizeServerUrlForCompare(serverUrl) === normalizeServerUrlForCompare(process.env[SERVER_URL_ENV]); return { attempted: true, interrupted: false, transport: "server", detail: error instanceof Error ? error.message : String(error), serverUrl, - ...(serverExternal ? { serverExternal: true } : {}) + ...ownership }; } } @@ -1263,7 +1295,7 @@ export async function interruptServerTurn(cwd, { threadId, serverUrl = null }) { if (!usedServerUrl) { throw new Error("OpenCode server did not become ready."); } - const client = new OpencodeServerClient(usedServerUrl, serverSessionCredentials(server)); + const client = buildServerClient(cwd, server); await client.abort(threadId); return { attempted: true, @@ -1300,7 +1332,10 @@ export async function runServerTurn(cwd, options = {}) { return withServer(cwd, async (client, server) => { emitProgress(options.onProgress, "Using shared OpenCode server.", "starting", { - serverUrl: server.url + serverUrl: server.url, + // Recorded into the job so a later cancel process knows whether this + // server is plugin-owned without consulting its own environment. + serverExternal: Boolean(server.external) }); let sessionID = options.resumeThreadId ?? options.resumeSessionId ?? null; diff --git a/plugins/opencode/scripts/lib/tracked-jobs.mjs b/plugins/opencode/scripts/lib/tracked-jobs.mjs index 065ead3..3b2ea8c 100644 --- a/plugins/opencode/scripts/lib/tracked-jobs.mjs +++ b/plugins/opencode/scripts/lib/tracked-jobs.mjs @@ -24,6 +24,8 @@ function normalizeProgressEvent(value) { threadId: typeof value.threadId === "string" && value.threadId.trim() ? value.threadId.trim() : null, turnId: typeof value.turnId === "string" && value.turnId.trim() ? value.turnId.trim() : null, serverUrl: typeof value.serverUrl === "string" && value.serverUrl.trim() ? value.serverUrl.trim() : null, + // Tri-state: only an explicit boolean is persisted into the job record. + serverExternal: typeof value.serverExternal === "boolean" ? value.serverExternal : null, stderrMessage: value.stderrMessage == null ? null : String(value.stderrMessage).trim(), logTitle: typeof value.logTitle === "string" && value.logTitle.trim() ? value.logTitle.trim() : null, logBody: value.logBody == null ? null : String(value.logBody).trimEnd() @@ -36,6 +38,7 @@ function normalizeProgressEvent(value) { threadId: null, turnId: null, serverUrl: null, + serverExternal: null, stderrMessage: String(value ?? "").trim(), logTitle: null, logBody: null @@ -81,6 +84,7 @@ export function createJobProgressUpdater(workspaceRoot, jobId) { let lastThreadId = null; let lastTurnId = null; let lastServerUrl = null; + let lastServerExternal = null; return (event) => { const normalized = normalizeProgressEvent(event); @@ -111,6 +115,12 @@ export function createJobProgressUpdater(workspaceRoot, jobId) { changed = true; } + if (normalized.serverExternal != null && normalized.serverExternal !== lastServerExternal) { + lastServerExternal = normalized.serverExternal; + patch.serverExternal = normalized.serverExternal; + changed = true; + } + if (!changed) { return; } diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index 8067227..fb4be68 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -790,10 +790,12 @@ async function handleTask(argv) { ensureOpenCodeAvailable(cwd); requireTaskRequest(prompt, resumeLast); - const currentServerUrl = getSessionRuntimeStatus(process.env, workspaceRoot).url ?? null; + const runtime = getSessionRuntimeStatus(process.env, workspaceRoot); const job = { ...buildTaskJob(workspaceRoot, taskMetadata, write), - ...(currentServerUrl ? { serverUrl: currentServerUrl } : {}) + // Persist ownership with the URL at job start; a later cancel process + // must not re-derive it from its own environment (issue #29). + ...(runtime.url ? { serverUrl: runtime.url, serverExternal: Boolean(runtime.external) } : {}) }; const request = buildTaskRequest({ cwd, @@ -1086,8 +1088,9 @@ async function handleCancel(argv) { const threadId = currentJob.threadId ?? null; const serverUrl = currentJob.serverUrl ?? null; + const serverExternal = typeof currentJob.serverExternal === "boolean" ? currentJob.serverExternal : null; - const interrupt = await interruptServerTurn(cwd, { threadId, serverUrl }); + const interrupt = await interruptServerTurn(cwd, { threadId, serverUrl, serverExternal }); const completedAt = nowIso(); const cancelResult = await cancelJobIfStillActive(workspaceRoot, currentJob, completedAt); if (cancelResult.cancelled) { @@ -1109,7 +1112,16 @@ async function handleCancel(argv) { } try { const teardownServerUrl = interrupt.serverUrl ?? serverUrl; - if (!interrupt.serverExternal) { + // Tear down only a server the job explicitly recorded as plugin-owned + // (serverExternal === false). When the job recorded a URL but no + // ownership (legacy records), fail safe and leave the server running — + // it may be a shared, user-managed service (issue #29). Jobs without a + // recorded URL fall back to the workspace's own persisted server unless + // the interrupt just connected to an env-configured external one. + const ownedServerTeardown = serverUrl + ? serverExternal === false + : interrupt.serverExternal !== true; + if (ownedServerTeardown) { await teardownServerSession({ cwd: workspaceRoot, ignoreCurrentProcessLease: Boolean(interrupt.serverUrl && !serverUrl), diff --git a/tests/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs index f13fd94..17ea743 100644 --- a/tests/fake-opencode-fixture.mjs +++ b/tests/fake-opencode-fixture.mjs @@ -513,6 +513,12 @@ const server = http.createServer(async (req, res) => { } if (req.method === "GET" && url.pathname === "/event") { + // Record the workspace scope of each event subscription so tests can + // assert the client binds its stream to the invoking directory. + const eventState = loadState(); + eventState.eventDirectories = eventState.eventDirectories || []; + eventState.eventDirectories.push(url.searchParams.get("directory")); + saveState(eventState); res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", @@ -529,14 +535,22 @@ const server = http.createServer(async (req, res) => { } if (req.method === "GET" && url.pathname === "/session") { - sendJson(res, loadState().sessions); + // Mirror the real server: a directory query scopes the listing to that + // workspace. + const directory = url.searchParams.get("directory"); + const sessions = loadState().sessions.filter( + (session) => !directory || session.directory === directory + ); + sendJson(res, sessions); return; } if (req.method === "POST" && url.pathname === "/session") { const body = await readJson(req); // Mirror the real server: create-session rejects \`directory\` and \`model\` - // (additionalProperties:false / model belongs on the message) with 400. + // in the BODY (additionalProperties:false / model belongs on the message) + // with 400; the workspace is scoped via the \`directory\` QUERY parameter + // and otherwise inherits the server process launch directory. if ("directory" in body || "model" in body) { sendJson(res, { _tag: "BadRequest" }, 400); return; @@ -544,7 +558,7 @@ const server = http.createServer(async (req, res) => { const state = loadState(); const session = { id: "ses_" + state.nextSessionId++, - directory: body.directory || process.cwd(), + directory: url.searchParams.get("directory") || process.cwd(), title: body.title || null, agent: body.agent || null, model: body.model || null, diff --git a/tests/opencode-server.test.mjs b/tests/opencode-server.test.mjs index 29693d6..ce8a6c2 100644 --- a/tests/opencode-server.test.mjs +++ b/tests/opencode-server.test.mjs @@ -41,6 +41,41 @@ test("buildBasicAuthHeader follows OpenCode's basic-auth convention", () => { assert.equal(buildBasicAuthHeader({ password: "" }), null); }); +test("client scopes project routes to the configured directory but never /global routes", async () => { + const seen = []; + const client = new OpencodeServerClient("http://opencode.test", { + directory: "/work/repo a", + fetch: async (requestUrl) => { + const url = new URL(String(requestUrl)); + seen.push(url); + if (url.pathname === "/event") { + return new Response(":ok\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" } + }); + } + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + } + }); + + await client.listSessions(); + await client.createSession({ agent: "plan" }); + await client.subscribeEvents(() => {}); + await client.health(); + await client.dispose(); + + assert.equal(seen[0].pathname, "/session"); + assert.equal(seen[0].searchParams.get("directory"), "/work/repo a"); + assert.equal(seen[1].pathname, "/session"); + assert.equal(seen[1].searchParams.get("directory"), "/work/repo a"); + assert.equal(seen[2].pathname, "/event"); + assert.equal(seen[2].searchParams.get("directory"), "/work/repo a"); + assert.equal(seen[3].pathname, "/global/health"); + assert.equal(seen[3].searchParams.get("directory"), null); + assert.equal(seen[4].pathname, "/global/dispose"); + assert.equal(seen[4].searchParams.get("directory"), null); +}); + test("client sends Basic auth on requests, fresh connections, and the event stream", { skip: LOCAL_LISTEN_SKIP }, async () => { const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}`; const seen = []; diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index f3a9606..770d4b3 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -124,6 +124,36 @@ function jsonResponse(value, status = 200) { }); } +// Spawns the fake fixture directly as a user-managed external server (no +// plugin lifecycle, no password) and resolves its base URL from stdout. +function startExternalFixtureServer(binDir) { + const child = spawn("node", [path.join(binDir, "opencode"), "serve", "--hostname", "127.0.0.1", "--port", "0"], { + env: { + ...process.env, + FAKE_OPENCODE_STATE_PATH: path.join(binDir, "fake-opencode-state.json"), + OPENCODE_SERVER_PASSWORD: "" + }, + windowsHide: true + }); + + const url = new Promise((resolve, reject) => { + let output = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + output += chunk; + const match = output.match(/listening on (http:\/\/[^\s]+)/); + if (match) { + resolve(match[1]); + } + }); + child.once("error", reject); + child.once("exit", () => reject(new Error("external fixture server exited before listening"))); + setTimeout(() => reject(new Error("external fixture server did not start")), 5000).unref(); + }); + + return { child, url }; +} + test("interruptServerTurn marks env-provided server urls as external", async () => { const workspace = makeTempDir(); const binDir = makeTempDir(); @@ -175,6 +205,8 @@ process.exit(1); { method: "POST", pathname: "/session/ses_external/abort" } ]); + // Ownership comes from the job record, never from the current environment + // (issue #29): without a persisted flag the result must not claim one. const missingThreadResult = await withProcessEnv( { [SERVER_URL_ENV]: "http://opencode.test/" @@ -182,7 +214,15 @@ process.exit(1); () => interruptServerTurn(workspace, { threadId: null, serverUrl: "http://opencode.test" }) ); assert.equal(missingThreadResult.attempted, false); - assert.equal(missingThreadResult.serverExternal, true); + assert.equal("serverExternal" in missingThreadResult, false); + + const persistedExternalResult = await interruptServerTurn(workspace, { + threadId: null, + serverUrl: "http://opencode.test", + serverExternal: true + }); + assert.equal(persistedExternalResult.attempted, false); + assert.equal(persistedExternalResult.serverExternal, true); } finally { globalThis.fetch = previousFetch; } @@ -426,6 +466,107 @@ test("cancel tears down a server it starts to abort a job without a recorded ser }); }); +test("external server requests are bound to each invoking workspace (issue #29)", { skip: LOCAL_LISTEN_SKIP }, async () => { + const repoA = makeTempDir(); + const repoB = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repoA); + initGitRepo(repoB); + const { child, url } = startExternalFixtureServer(binDir); + + try { + const serverUrl = await url; + const resultA = run("node", [SCRIPT, "task", "--json", "task in workspace A"], { + cwd: repoA, + env: buildTestEnv(binDir, { [SERVER_URL_ENV]: serverUrl }) + }); + assert.equal(resultA.status, 0, resultA.stderr); + const resultB = run("node", [SCRIPT, "task", "--json", "task in workspace B"], { + cwd: repoB, + env: buildTestEnv(binDir, { [SERVER_URL_ENV]: serverUrl }) + }); + assert.equal(resultB.status, 0, resultB.stderr); + + // One shared external server, two workspaces: each session must be scoped + // to the invoking repo, not the server process's launch directory. + const fakeState = readFakeState(binDir); + const directories = fakeState.sessions.map((session) => session.directory).sort(); + assert.deepEqual(directories, [fs.realpathSync(repoA), fs.realpathSync(repoB)].sort()); + + // The event subscriptions carried the workspace scope too. + const eventDirectories = (fakeState.eventDirectories || []).filter(Boolean); + assert.ok(eventDirectories.includes(fs.realpathSync(repoA)), "event stream scoped to workspace A"); + assert.ok(eventDirectories.includes(fs.realpathSync(repoB)), "event stream scoped to workspace B"); + } finally { + child.kill(); + } +}); + +test("cancel without the job-start environment leaves an external server running (issue #29)", { skip: LOCAL_LISTEN_SKIP }, async () => { + const workspace = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + const pluginDataDir = makeTempDir("opencode-plugin-data-"); + const { child, url } = startExternalFixtureServer(binDir); + + try { + const serverUrl = await url; + await withProcessEnv({ CLAUDE_PLUGIN_DATA: pluginDataDir }, async () => { + const jobId = "job-cancel-env-divergent"; + const timestamp = new Date().toISOString(); + const logFile = resolveJobLogFile(workspace, jobId); + const runningJob = { + id: jobId, + workspaceRoot: workspace, + jobClass: "task", + kind: "task", + status: "running", + phase: "running", + pid: null, + title: "Running external task", + threadId: "ses_env_divergent", + // Persisted at job start; the cancel environment below deliberately + // lacks OPENCODE_COMPANION_SERVER_URL (the review's H-04 scenario). + serverUrl, + serverExternal: true, + logFile, + createdAt: timestamp, + updatedAt: timestamp + }; + saveState(workspace, { + version: 1, + config: { stopReviewGate: false }, + jobs: [runningJob] + }); + writeJobFile(workspace, jobId, runningJob); + + const result = run("node", [SCRIPT, "cancel", jobId, "--cwd", workspace, "--json"], { + cwd: workspace, + env: buildEnv(binDir, { + CLAUDE_PLUGIN_DATA: pluginDataDir, + OPENCODE_COMPANION_SESSION_ID: "sess-current" + }) + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.cancelled, true); + assert.equal(payload.turnInterruptAttempted, true); + assert.equal(payload.turnInterrupted, true); + assert.equal(readFakeState(binDir).lastAbort, "ses_env_divergent"); + + // The user-managed server must survive the cancel: no dispose (the + // fixture exits on dispose), no process kill. + const health = await fetch(`${serverUrl}/global/health`); + assert.equal(health.status, 200); + await health.text(); + }); + } finally { + child.kill(); + } +}); + test("cancel aborts but does not dispose an env-provided external server", async () => { const workspace = makeTempDir(); const binDir = makeTempDir(); From 2f7d100d910dabcf5b18da482ff08c4bc00cf815 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Fri, 10 Jul 2026 12:54:29 +0200 Subject: [PATCH 28/32] Poll for recovery after a stream drop instead of failing at the grace (#30) A closed event stream raced the whole turn: after a 5s grace, captureTurn made one recovery attempt and could fail immediately even while the held-open /message request was still running and about to succeed (the review reproduced a turn failing at ~5.8s while the response completed at 6.2s). Treat stream closure as loss of one observation channel, not turn completion. After the stream drops, wait the grace for the response and trailing events to land, then actively poll the server for the finished message on an interval until the turn completes another way (response fallback, HTTP recovery, a reconnected session.idle) or the OUTER turn timeout fires. The turn is never failed merely because the stream ended. The grace is now a delay before polling starts rather than a deadline, and a bounded poll interval (default 2s) governs subsequent attempts. The loop is drained in finally so it cannot poll past return, and it still honors the outer timeout so a genuinely stalled turn ends promptly instead of spinning. captureTurn is exported for tests, which drive it against a localhost server across three timings: stream drop + slow-but-successful response (previously failed at the grace), stream drop + lost POST recoverable only via GET, and stream drop + response that never lands (must end near the outer timeout). Co-Authored-By: Claude Fable 5 --- plugins/opencode/scripts/lib/opencode.mjs | 48 +++++- tests/turn-capture.test.mjs | 175 ++++++++++++++++++++++ 2 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 tests/turn-capture.test.mjs diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index 93a8066..d6b898f 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -25,7 +25,13 @@ const READ_ONLY_AGENT = "plan"; // (issue #2 / review finding #17). Deep reviews legitimately run many minutes, // so keep it generous; on expiry we still try to recover the final message. const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000; +// After the event stream drops, wait this long for the held-open /message +// response and any trailing events to land before the first HTTP recovery +// poll. This is a grace before polling STARTS — not a deadline (issue #30). const DEFAULT_STREAM_DROP_GRACE_MS = 5000; +// Interval between HTTP recovery polls while the stream is down and the turn +// has not otherwise completed. Polling continues until the outer turn timeout. +const DEFAULT_STREAM_DROP_POLL_INTERVAL_MS = 2000; const DEFAULT_RECOVERY_TIMEOUT_MS = 5000; function sleep(ms) { @@ -926,6 +932,8 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { const turnTimeoutMs = Math.max(0, Number(options.turnTimeoutMs) || DEFAULT_TURN_TIMEOUT_MS); let timedOut = false; let turnTimer = null; + // Hoisted so the finally block can drain it even if the try throws early. + let streamDropRecovery = Promise.resolve(); try { await Promise.race([ @@ -991,17 +999,38 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { 0, Number(options.streamDropGraceMs ?? DEFAULT_STREAM_DROP_GRACE_MS) || DEFAULT_STREAM_DROP_GRACE_MS ); - const eventStreamDropPromise = eventStream.then(async () => { - if (state.completed || responseSettled) { - return state; - } - if (streamDropGraceMs > 0) { + const streamDropPollIntervalMs = Math.max( + 50, + Number(options.streamDropPollIntervalMs ?? DEFAULT_STREAM_DROP_POLL_INTERVAL_MS) || + DEFAULT_STREAM_DROP_POLL_INTERVAL_MS + ); + + // A dropped event stream is loss of one observation channel, NOT turn + // completion. The held-open /message response can still be running and + // succeed well after the drop, and the finished message is fetchable over + // HTTP. So once the stream closes: wait a short grace for the response and + // trailing events to land, then actively poll the server for the finished + // message until the turn completes another way (response fallback, + // recovery, session.idle over a reconnect) or the OUTER turn timeout fires. + // We never fail the turn merely because the stream ended (issue #30). + streamDropRecovery = eventStream.then(async () => { + if (streamDropGraceMs > 0 && !state.completed && !responseSettled) { await Promise.race([state.completion, responseSettledPromise, sleep(streamDropGraceMs)]); } - return state; + while (!state.completed && !timedOut) { + // Only fetch when we actually lack a result; skip the redundant GET if + // parts already streamed in before the drop (issue #12). + if (!state.error && !state.finalMessage && state.structuredOutput == null) { + await recoverFinalMessageFromServer(client, state, { recoveryTimeoutMs: options.recoveryTimeoutMs }); + } + if (state.completed || timedOut) { + break; + } + await Promise.race([state.completion, timeoutPromise, sleep(streamDropPollIntervalMs)]); + } }); - await Promise.race([state.completion, eventStreamDropPromise, timeoutPromise]); + await Promise.race([state.completion, streamDropRecovery, timeoutPromise]); // If we didn't capture a usable result, pull the finished message straight // from the server before giving up. Key this off whether we actually lack a @@ -1034,6 +1063,10 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { } eventAbort.abort(); await eventStream.catch(() => {}); + // Drain the stream-drop recovery loop so it cannot poll past return. Its + // guard (state.completed || timedOut) is already satisfied once the outer + // race resolved, so this settles promptly. + await streamDropRecovery.catch(() => {}); if (state.fallbackTimer) { clearTimeout(state.fallbackTimer); } @@ -1557,6 +1590,7 @@ export function readOutputSchema(schemaPath) { export { DEFAULT_CONTINUE_PROMPT, TASK_SESSION_PREFIX, + captureTurn as captureTurnForTest, getAvailability as getOpencodeAvailability, getAuthStatus as getOpencodeAuthStatus }; diff --git a/tests/turn-capture.test.mjs b/tests/turn-capture.test.mjs new file mode 100644 index 0000000..34c072a --- /dev/null +++ b/tests/turn-capture.test.mjs @@ -0,0 +1,175 @@ +import http from "node:http"; +import net from "node:net"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { OpencodeServerClient } from "../plugins/opencode/scripts/lib/opencode-server.mjs"; +import { captureTurnForTest } from "../plugins/opencode/scripts/lib/opencode.mjs"; + +async function canListenLocalhost() { + return new Promise((resolve) => { + const server = net.createServer(); + let settled = false; + function finish(value) { + if (settled) { + return; + } + settled = true; + resolve(value); + } + server.once("error", () => finish(false)); + server.listen(0, "127.0.0.1", () => { + server.close(() => finish(true)); + }); + }); +} + +const LOCAL_LISTEN_AVAILABLE = await canListenLocalhost(); +const LOCAL_LISTEN_SKIP = LOCAL_LISTEN_AVAILABLE ? false : "local 127.0.0.1 listen is unavailable in this sandbox"; + +// A tiny OpenCode-shaped server whose event stream drops early while the +// held-open /message response resolves later. Behavior is tuned per test to +// reproduce the issue #30 timing precisely. +function startTurnServer({ eventDropMs, responseDelayMs, dropResponse = false }) { + const sessionID = "ses_capture"; + const messageID = "msg_capture"; + const finalText = "Recovered final answer."; + let stored = null; + + const server = http.createServer((req, res) => { + const url = new URL(req.url, "http://127.0.0.1"); + + if (req.method === "GET" && url.pathname === "/event") { + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); + res.write(":ok\n\n"); + setTimeout(() => res.destroy(), eventDropMs); + return; + } + + if (req.method === "GET" && url.pathname === `/session/${sessionID}/message`) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(stored ? [stored] : [])); + return; + } + + if (req.method === "POST" && url.pathname === `/session/${sessionID}/message`) { + setTimeout(() => { + stored = { + info: { id: messageID, role: "assistant", sessionID }, + parts: [{ id: "prt_capture", sessionID, messageID, type: "text", text: finalText }] + }; + if (dropResponse) { + // The completed turn is only recoverable over HTTP GET now. + res.destroy(); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(stored)); + }, responseDelayMs); + return; + } + + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + }); + + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + resolve({ + server, + sessionID, + finalText, + baseUrl: `http://127.0.0.1:${server.address().port}`, + close: () => new Promise((done) => server.close(done)) + }); + }); + }); +} + +const FAST_CAPTURE_OPTIONS = { + streamDropGraceMs: 100, + streamDropPollIntervalMs: 80, + recoveryTimeoutMs: 500, + turnTimeoutMs: 4000, + eventOpenTimeoutMs: 2000 +}; + +test( + "captureTurn keeps waiting when the stream drops before a slow but successful response (issue #30)", + { skip: LOCAL_LISTEN_SKIP }, + async () => { + // Stream drops at 40ms; the /message response succeeds at 500ms — well past + // the 100ms stream-drop grace. Pre-#30 this failed at ~grace with an empty + // recovery instead of waiting for the response. + const fixture = await startTurnServer({ eventDropMs: 40, responseDelayMs: 500 }); + const client = new OpencodeServerClient(fixture.baseUrl); + + try { + const state = await captureTurnForTest( + client, + fixture.sessionID, + (signal) => client.sendMessage(fixture.sessionID, { parts: [{ type: "text", text: "go" }] }, { signal }), + FAST_CAPTURE_OPTIONS + ); + + assert.equal(state.error, null, state.error?.message); + assert.equal(state.finalMessage, fixture.finalText); + } finally { + await fixture.close(); + } + } +); + +test( + "captureTurn recovers over HTTP when the stream drops and the response POST is lost (issue #30)", + { skip: LOCAL_LISTEN_SKIP }, + async () => { + // Stream drops at 40ms and the held-open POST is destroyed after the turn + // completes server-side, so the answer is only reachable via GET. The poll + // loop must keep trying until the message is stored and recover it. + const fixture = await startTurnServer({ eventDropMs: 40, responseDelayMs: 400, dropResponse: true }); + const client = new OpencodeServerClient(fixture.baseUrl); + + try { + const state = await captureTurnForTest( + client, + fixture.sessionID, + (signal) => client.sendMessage(fixture.sessionID, { parts: [{ type: "text", text: "go" }] }, { signal }), + FAST_CAPTURE_OPTIONS + ); + + assert.equal(state.error, null, state.error?.message); + assert.equal(state.finalMessage, fixture.finalText); + assert.equal(state.recovered, true); + } finally { + await fixture.close(); + } + } +); + +test( + "captureTurn still times out when the stream drops and no result ever appears (issue #30)", + { skip: LOCAL_LISTEN_SKIP }, + async () => { + // Stream drops and the response never completes: the poll loop must be + // bounded by the outer turn timeout, not spin forever. + const fixture = await startTurnServer({ eventDropMs: 40, responseDelayMs: 60_000 }); + const client = new OpencodeServerClient(fixture.baseUrl); + + try { + const startedAt = Date.now(); + const state = await captureTurnForTest( + client, + fixture.sessionID, + (signal) => client.sendMessage(fixture.sessionID, { parts: [{ type: "text", text: "go" }] }, { signal }), + { ...FAST_CAPTURE_OPTIONS, turnTimeoutMs: 700 } + ); + + assert.ok(state.error, "a stalled turn must surface an error"); + assert.equal(state.finalMessage, ""); + assert.ok(Date.now() - startedAt < 3000, "the turn must end near its timeout, not spin"); + } finally { + await fixture.close(); + } + } +); From 67429a4c2c26e49d5c2c188a9261a96a346f68b9 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Fri, 10 Jul 2026 21:52:30 +0200 Subject: [PATCH 29/32] Verify server PID identity before teardown kills (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stale persisted server records were torn down by signalling the stored PID and its process group with no check that the PID still belonged to the plugin's `opencode serve` — after OS PID reuse this could SIGTERM an unrelated process. Teardown now re-reads the PID's live command line (cross-platform, via the same helper the task-worker guard uses) and signals only when it still looks like the plugin-owned `opencode serve` for the session's port. The port is derived from the persisted URL when the explicit field is absent, so records written before this change remain verifiable instead of falling open. Anything unverifiable is left untouched; only the stale metadata is cleared, reported as { killSkipped: true, reason, diagnostic } — `skipped` keeps its existing "teardown did not run" meaning (leases/lock timeouts), so the SessionEnd hook still clears the record correctly. Cancel logs the skip. The matcher requires an opencode-ish executable token plus `serve --port `, and re-splits shell-wrapped Windows command lines (cmd.exe /c "opencode serve ...") so win32 servers do not become unkillable orphans. The spawn-time command line is recorded for forensics; verification always matches the live value. Drafted by OpenCode via the plugin's own rescue path (issue #31 task), then reviewed and reworked: fail-closed for legacy records, URL-derived port, de-duplicated cleanup, return-shape fix for the SessionEnd hook, Windows shell-blob matching, and expanded tests. Co-Authored-By: Claude Fable 5 --- plugins/opencode/scripts/lib/process.mjs | 40 ++++ .../opencode/scripts/lib/server-lifecycle.mjs | 92 +++++++- .../opencode/scripts/opencode-companion.mjs | 5 +- tests/server-lifecycle.test.mjs | 223 +++++++++++++++++- 4 files changed, 345 insertions(+), 15 deletions(-) diff --git a/plugins/opencode/scripts/lib/process.mjs b/plugins/opencode/scripts/lib/process.mjs index f04a1b5..32773e9 100644 --- a/plugins/opencode/scripts/lib/process.mjs +++ b/plugins/opencode/scripts/lib/process.mjs @@ -174,6 +174,46 @@ export function commandLineLooksLikeTaskWorker(commandLine, { jobId } = {}) { ); } +function tokensContainPort(tokens, port) { + const expected = String(port ?? ""); + if (!expected) { + return false; + } + + for (let index = 0; index < tokens.length; index += 1) { + if (tokens[index] === "--port" && tokens[index + 1] === expected) { + return true; + } + if (tokens[index] === `--port=${expected}`) { + return true; + } + } + return false; +} + +export function commandLineLooksLikeOpencodeServe(commandLine, { port } = {}) { + // On win32 the server is spawned through a shell, so `ps`/PowerShell can + // report the whole invocation as one quoted blob (`cmd.exe /c "opencode + // serve --port N"`). Re-split compound tokens so the matcher sees the real + // arguments on every platform. + const tokens = commandLineTokens(commandLine).flatMap((token) => + /\s/.test(token) ? token.split(/\s+/).filter(Boolean) : [token] + ); + // Require an opencode-ish executable token in addition to `serve --port N`. + // Matches the real binary (/opt/homebrew/bin/opencode), test fixtures + // (/opencode), and Windows shims (opencode.cmd). + if (!tokens.some((token) => path.basename(token).toLowerCase().startsWith("opencode"))) { + return false; + } + if (!tokens.includes("serve")) { + return false; + } + if (!tokensContainPort(tokens, port)) { + return false; + } + return true; +} + export function readProcessCommandLine(pid, options = {}) { if (!Number.isFinite(pid)) { return null; diff --git a/plugins/opencode/scripts/lib/server-lifecycle.mjs b/plugins/opencode/scripts/lib/server-lifecycle.mjs index e01992b..50be8f5 100644 --- a/plugins/opencode/scripts/lib/server-lifecycle.mjs +++ b/plugins/opencode/scripts/lib/server-lifecycle.mjs @@ -7,6 +7,7 @@ import process from "node:process"; import { spawn } from "node:child_process"; import { OpencodeServerClient } from "./opencode-server.mjs"; +import { commandLineLooksLikeOpencodeServe, readProcessCommandLine } from "./process.mjs"; import { atomicWriteFile, resolveStateDir } from "./state.mjs"; export const SERVER_URL_ENV = "OPENCODE_COMPANION_SERVER_URL"; @@ -432,7 +433,7 @@ export async function ensureServer(cwd, options = {}) { const staleExisting = loadServerSession(cwd); if (staleExisting) { - const { url, pidFile, logFile, sessionDir, pid, external, password, username } = staleExisting; + const { url, pidFile, logFile, sessionDir, pid, external, password, username, port } = staleExisting; // The server lock is already held here; intentionally omit cwd so teardown // uses the unlocked path even if the persisted session schema grows. await teardownServerSession({ @@ -444,7 +445,9 @@ export async function ensureServer(cwd, options = {}) { external, password, username, - killProcess: options.killProcess ?? null + port, + killProcess: options.killProcess ?? null, + readProcessCommandLineImpl: options.readProcessCommandLineImpl ?? null }); clearServerSession(cwd); } @@ -467,6 +470,10 @@ export async function ensureServer(cwd, options = {}) { env: options.env ?? process.env, password }); + // Recorded for forensics (compare against the live command line when an + // identity-mismatch teardown skip is investigated); verification itself + // matches the LIVE command line against `opencode serve --port `. + const pidCommandLine = readProcessCommandLine(child.pid, options); const ready = await waitForServerHealth(url, options.timeoutMs ?? 10000, { password, @@ -481,7 +488,9 @@ export async function ensureServer(cwd, options = {}) { pid: child.pid ?? null, password, username: OWNED_SERVER_USERNAME, - killProcess: options.killProcess ?? null + port, + killProcess: options.killProcess ?? null, + readProcessCommandLineImpl: options.readProcessCommandLineImpl ?? null }); return null; } @@ -494,7 +503,9 @@ export async function ensureServer(cwd, options = {}) { sessionDir, external: false, password, - username: OWNED_SERVER_USERNAME + username: OWNED_SERVER_USERNAME, + port, + pidCommandLine }; const leasedSession = addServerLease(session, options); saveServerSession(cwd, leasedSession); @@ -504,6 +515,37 @@ export async function ensureServer(cwd, options = {}) { } } +function parseServerUrlPort(url) { + try { + const port = Number(new URL(String(url ?? "")).port); + return Number.isInteger(port) && port > 0 ? port : null; + } catch { + return null; + } +} + +// Fail-closed PID identity check (issue #31): only signal a PID when its live +// command line still looks like the plugin-owned `opencode serve` instance for +// this session's port. The port always exists for owned sessions — it is part +// of the persisted URL — so records written before the explicit identity +// fields existed remain verifiable. Anything unverifiable is left untouched; +// only the stale metadata is cleared. +function verifyServerPidIdentity(pid, { url = null, port = null, readProcessCommandLineImpl = null } = {}) { + const expectedPort = Number.isFinite(port) ? Number(port) : parseServerUrlPort(url); + if (!Number.isFinite(expectedPort)) { + return { verified: false, reason: "identity-unverified" }; + } + const readCommandLine = readProcessCommandLineImpl ?? readProcessCommandLine; + const commandLine = readCommandLine(pid); + if (!commandLine) { + return { verified: false, reason: "identity-unverified" }; + } + if (!commandLineLooksLikeOpencodeServe(commandLine, { port: expectedPort })) { + return { verified: false, reason: "identity-mismatch" }; + } + return { verified: true, reason: null }; +} + async function teardownServerSessionUnlocked({ url = null, pidFile = null, @@ -513,7 +555,9 @@ async function teardownServerSessionUnlocked({ external = false, password = null, username = null, - killProcess = null + killProcess = null, + port = null, + readProcessCommandLineImpl = null } = {}) { if (url && !external) { try { @@ -526,11 +570,17 @@ async function teardownServerSessionUnlocked({ } } + let killSkippedReason = null; if (!external && Number.isFinite(pid)) { - try { - killServerPid(pid, killProcess); - } catch { - // Ignore teardown failures during Claude session shutdown. + const identity = verifyServerPidIdentity(pid, { url, port, readProcessCommandLineImpl }); + if (identity.verified) { + try { + killServerPid(pid, killProcess); + } catch { + // Ignore teardown failures during Claude session shutdown. + } + } else { + killSkippedReason = identity.reason; } } @@ -550,6 +600,18 @@ async function teardownServerSessionUnlocked({ } } + if (killSkippedReason) { + // Metadata is cleared (so callers still clear the session record), but the + // process was deliberately left untouched. `skipped` keeps its existing + // meaning of "teardown did not run at all" (leases / lock timeouts). + return { + skipped: false, + killSkipped: true, + reason: killSkippedReason, + diagnostic: `Left PID ${pid} untouched (${killSkippedReason}); cleared stale OpenCode server metadata only.` + }; + } + return { skipped: false }; } @@ -568,7 +630,9 @@ export async function teardownServerSession({ external = false, password = null, username = null, - killProcess = null + killProcess = null, + port = null, + readProcessCommandLineImpl = null } = {}) { if (!cwd) { return teardownServerSessionUnlocked({ @@ -580,7 +644,9 @@ export async function teardownServerSession({ external, password, username, - killProcess + killProcess, + port, + readProcessCommandLineImpl }); } @@ -615,7 +681,9 @@ export async function teardownServerSession({ external: Boolean(session?.external ?? external), password: session?.password ?? password, username: session?.username ?? username, - killProcess + killProcess, + port: session?.port ?? port, + readProcessCommandLineImpl }; const result = await teardownServerSessionUnlocked(teardownTarget); if (session) { diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index fb4be68..4a09b2c 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -1122,11 +1122,14 @@ async function handleCancel(argv) { ? serverExternal === false : interrupt.serverExternal !== true; if (ownedServerTeardown) { - await teardownServerSession({ + const teardownResult = await teardownServerSession({ cwd: workspaceRoot, ignoreCurrentProcessLease: Boolean(interrupt.serverUrl && !serverUrl), ...(teardownServerUrl ? { url: teardownServerUrl } : {}) }); + if (teardownResult?.killSkipped) { + appendLogLine(cancelLogFile, `Skipped OpenCode server kill: ${teardownResult.reason}.`); + } } } catch (error) { appendLogLine( diff --git a/tests/server-lifecycle.test.mjs b/tests/server-lifecycle.test.mjs index 1d77341..0e85419 100644 --- a/tests/server-lifecycle.test.mjs +++ b/tests/server-lifecycle.test.mjs @@ -9,6 +9,7 @@ import assert from "node:assert/strict"; import { makeTempDir } from "./helpers.mjs"; import { ensureServer, isServerHealthy, loadServerSession, saveServerSession, teardownServerSession } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; import { resolveStateDir } from "../plugins/opencode/scripts/lib/state.mjs"; +import { commandLineLooksLikeOpencodeServe } from "../plugins/opencode/scripts/lib/process.mjs"; async function canListenLocalhost() { return new Promise((resolve) => { @@ -233,7 +234,8 @@ test("teardownServerSession can ignore only this process lease", async () => { ignoreCurrentProcessLease: true, killProcess: (pid) => { killedPid = pid; - } + }, + readProcessCommandLineImpl: () => "opencode serve --hostname 127.0.0.1 --port 1" }); assert.equal(result.skipped, false); @@ -443,7 +445,8 @@ test("teardownServerSession expires a lease whose pid reports EPERM", async () = pid: session.pid, killProcess: (pid) => { killedPid = pid; - } + }, + readProcessCommandLineImpl: () => "opencode serve --hostname 127.0.0.1 --port 1" }); assert.equal(result.skipped, false); @@ -459,6 +462,222 @@ test("teardownServerSession expires a lease whose pid reports EPERM", async () = } }); +test("teardownServerSession signals the process when the persisted identity matches the live command line", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + const session = { + url: "http://127.0.0.1:1", + pid: 123456, + pidFile: null, + logFile: null, + sessionDir: null, + external: false, + port: 8080, + pidCommandLine: "opencode serve --hostname 127.0.0.1 --port 8080", + leases: [] + }; + saveServerSession(workspace, session); + + let killedPid = null; + try { + const result = await teardownServerSession({ + cwd: workspace, + url: session.url, + pid: session.pid, + killProcess: (pid) => { + killedPid = pid; + }, + readProcessCommandLineImpl: () => "opencode serve --hostname 127.0.0.1 --port 8080" + }); + + assert.equal(result.skipped, false); + assert.equal(killedPid, session.pid); + assert.equal(loadServerSession(workspace), null); + } finally { + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } +}); + +test("teardownServerSession does NOT signal and clears the record when the live command line differs (PID reused)", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + const session = { + url: "http://127.0.0.1:1", + pid: 123456, + pidFile: null, + logFile: null, + sessionDir: null, + external: false, + port: 8080, + pidCommandLine: "opencode serve --hostname 127.0.0.1 --port 8080", + leases: [] + }; + saveServerSession(workspace, session); + + let killedPid = null; + try { + const result = await teardownServerSession({ + cwd: workspace, + url: session.url, + pid: session.pid, + killProcess: (pid) => { + killedPid = pid; + }, + readProcessCommandLineImpl: () => "node /opt/unrelated/server.js --port 3000" + }); + + // The teardown itself ran (metadata cleared, record removed); only the + // kill was withheld — `skipped` keeps meaning "teardown did not run". + assert.equal(result.skipped, false); + assert.equal(result.killSkipped, true); + assert.equal(result.reason, "identity-mismatch"); + assert.match(result.diagnostic, /Left PID 123456 untouched/); + assert.equal(killedPid, null); + assert.equal(loadServerSession(workspace), null); + } finally { + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } +}); + +test("teardownServerSession does NOT signal when the command line cannot be read", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + const session = { + url: "http://127.0.0.1:1", + pid: 123456, + pidFile: null, + logFile: null, + sessionDir: null, + external: false, + port: 8080, + pidCommandLine: "opencode serve --hostname 127.0.0.1 --port 8080", + leases: [] + }; + saveServerSession(workspace, session); + + let killedPid = null; + try { + const result = await teardownServerSession({ + cwd: workspace, + url: session.url, + pid: session.pid, + killProcess: (pid) => { + killedPid = pid; + }, + readProcessCommandLineImpl: () => null + }); + + assert.equal(result.skipped, false); + assert.equal(result.killSkipped, true); + assert.equal(result.reason, "identity-unverified"); + assert.equal(killedPid, null); + assert.equal(loadServerSession(workspace), null); + } finally { + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } +}); + +test("teardownServerSession verifies legacy records via the port derived from the persisted url", async () => { + const workspace = makeTempDir(); + const pluginDataDir = makeTempDir(); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + // A record written before the identity fields existed: no `port`, no + // `pidCommandLine`. Verification must still work (the URL carries the port) + // instead of falling open and killing blind. + const session = { + url: "http://127.0.0.1:43117", + pid: 123456, + pidFile: null, + logFile: null, + sessionDir: null, + external: false, + leases: [] + }; + saveServerSession(workspace, session); + + try { + let killedPid = null; + const matched = await teardownServerSession({ + cwd: workspace, + url: session.url, + pid: session.pid, + killProcess: (pid) => { + killedPid = pid; + }, + readProcessCommandLineImpl: () => "/usr/local/bin/opencode serve --hostname 127.0.0.1 --port 43117" + }); + assert.equal(matched.skipped, false); + assert.equal(matched.killSkipped, undefined); + assert.equal(killedPid, session.pid); + assert.equal(loadServerSession(workspace), null); + + // Same legacy record, but the PID now belongs to something else: no kill. + saveServerSession(workspace, session); + killedPid = null; + const reused = await teardownServerSession({ + cwd: workspace, + url: session.url, + pid: session.pid, + killProcess: (pid) => { + killedPid = pid; + }, + readProcessCommandLineImpl: () => "postgres: writer process" + }); + assert.equal(reused.killSkipped, true); + assert.equal(reused.reason, "identity-mismatch"); + assert.equal(killedPid, null); + assert.equal(loadServerSession(workspace), null); + } finally { + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } +}); + +test("commandLineLooksLikeOpencodeServe matches shell-wrapped Windows command lines", () => { + assert.equal( + commandLineLooksLikeOpencodeServe('C:\\Windows\\system32\\cmd.exe /c "opencode serve --hostname 127.0.0.1 --port 5150"', { + port: 5150 + }), + true + ); + assert.equal( + commandLineLooksLikeOpencodeServe("/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 5150", { port: 5150 }), + true + ); + // Same port, different program: must not match. + assert.equal( + commandLineLooksLikeOpencodeServe("node /srv/other-tool serve --port 5150", { port: 5150 }), + false + ); + // Right program, wrong port: must not match. + assert.equal( + commandLineLooksLikeOpencodeServe("/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 5151", { port: 5150 }), + false + ); +}); + test("saveServerSession preserves the existing session when its atomic rename fails", () => { const workspace = makeTempDir(); const pluginDataDir = makeTempDir(); From 63ce798ed7152e7fe32e1b8c2c47a0e4e5c55c09 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Fri, 10 Jul 2026 21:54:05 +0200 Subject: [PATCH 30/32] Split normal vs adversarial review prompts; honest setup provider check (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /opencode:review and /opencode:adversarial-review both rendered the adversarial "break confidence" template — only the result label differed — and the normal review silently accepted focus text its docs said it rejects. getAuthStatus reported loggedIn whenever /config or /provider was merely readable, so setup marked the plugin ready with zero connected providers and the first real task failed later. review now renders a neutral high-signal template (prompts/review.md) selected via explicit per-command config (promptTemplate), rejects positional focus text with an error pointing at adversarial-review, and adversarial-review is unchanged. loggedIn requires a non-empty `connected` set from /provider (id strings or {id} objects); a readable endpoint with nothing connected reports the provider-setup next step, and a failing /provider endpoint is loggedIn: false instead of a crash or a false positive. Drafted by OpenCode via the plugin's own rescue path (issue #32 task), then reviewed and polished: template selection moved from display-label string matching to explicit config, and an invented `connections` field alias dropped from the provider parsing. Tests assert the actual generated prompt per command, the focus-text rejection, and connected/disconnected/failing provider setups against the fixture's real /provider shape. Co-Authored-By: Claude Fable 5 --- plugins/opencode/prompts/review.md | 79 +++++++++++ plugins/opencode/scripts/lib/opencode.mjs | 37 ++++++ .../opencode/scripts/opencode-companion.mjs | 25 +++- tests/fake-opencode-fixture.mjs | 10 +- tests/runtime.test.mjs | 123 ++++++++++++++++++ 5 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 plugins/opencode/prompts/review.md diff --git a/plugins/opencode/prompts/review.md b/plugins/opencode/prompts/review.md new file mode 100644 index 0000000..42e154b --- /dev/null +++ b/plugins/opencode/prompts/review.md @@ -0,0 +1,79 @@ + +You are OpenCode performing a balanced, high-signal software review. +Your job is to find real bugs, correctness issues, and regressions in the change. + + + +Review the provided repository context for bugs, correctness issues, regressions, and quality problems. +Target: {{TARGET_LABEL}} +User focus: {{USER_FOCUS}} + + + +Be honest, calibrated, and thorough. +Flag what matters. Acknowledge what looks correct. +Do not manufacture skepticism or invent problems where none exist. +If the change is solid, say so clearly. + + + +Prioritize findings that affect correctness, reliability, or security: +- logic errors, off-by-one mistakes, and incorrect control flow +- broken contracts, type mismatches, and violated invariants +- resource leaks, missing cleanup, and unhandled error paths +- race conditions, ordering assumptions, and stale-state bugs +- data loss, corruption, duplication, or irreversible state changes +- regressions that break existing behavior or compatibility +- missing tests or verification for critical paths +- unclear or misleading code that hides a real defect + + + +Read each changed file carefully. +Trace the data flow and control flow through the change. +Check edge cases: empty input, null values, timeouts, failures in dependencies. +If the user supplied a focus area, weight it, but still review the full change. +{{REVIEW_COLLECTION_GUIDANCE}} + + + +Report findings with severity and concrete evidence. +Each finding must include: +1. What is the defect? +2. Where is it (file and line range)? +3. What is the impact? +4. How can it be fixed? +Skip style nits, naming preferences, and opinion-only feedback. +Do not report issues you cannot support from the code. + + + +Return only valid JSON matching the provided schema. +Keep the output compact and specific. +Use `needs-attention` for any material finding. +Use `approve` only if the change looks correct and safe. +Every finding must include: +- the affected file +- `line_start` and `line_end` +- a confidence score from 0 to 1 +- a concrete recommendation +Write the summary as a terse, actionable assessment. + + + +Report the strongest findings first. +Group related issues rather than splitting them. +If the change is clean, say so directly and return no findings. + + + +Before finalizing, check that each finding is: +- a real defect, not a style preference +- tied to a concrete code location +- supported by evidence in the provided context +- actionable for an engineer fixing the issue + + + +{{REVIEW_INPUT}} + diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index d6b898f..d2654f1 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -1220,6 +1220,29 @@ export function getSessionRuntimeStatus(env = process.env, cwd = process.cwd()) }; } +function extractConnectedProviderIds(provider) { + if (!provider || typeof provider !== "object") { + return []; + } + // The 1.17.15 /provider response carries connected provider ids in + // `connected`; tolerate id-object entries but no invented field aliases. + const connected = provider.connected ?? []; + if (!Array.isArray(connected)) { + return []; + } + return connected + .map((entry) => { + if (typeof entry === "string") { + return entry; + } + if (entry && typeof entry === "object" && typeof entry.id === "string") { + return entry.id; + } + return null; + }) + .filter(Boolean); +} + export async function getAuthStatus(cwd) { const availability = getAvailability(cwd); if (!availability.available) { @@ -1239,6 +1262,20 @@ export async function getAuthStatus(cwd) { if (config?.error && provider?.error) { throw config.error; } + + const connectedProviders = extractConnectedProviderIds(provider); + const isConnected = connectedProviders.length > 0; + + if (!isConnected) { + return buildAuthStatus({ + loggedIn: false, + detail: "No OpenCode provider is connected. Configure and connect an OpenCode provider, then rerun /opencode:setup.", + source: "server", + available: true, + provider: null + }); + } + const providerID = provider?.id ?? provider?.providerID ?? diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index fb4be68..f3e766a 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -238,10 +238,10 @@ async function handleSetup(argv) { outputResult(options.json ? finalReport : renderSetupReport(finalReport), options.json); } -function buildAdversarialReviewPrompt(context, focusText) { - const template = loadPromptTemplate(ROOT_DIR, "adversarial-review"); +function buildReviewPrompt(context, focusText, { templateName, reviewKind }) { + const template = loadPromptTemplate(ROOT_DIR, templateName); return interpolateTemplate(template, { - REVIEW_KIND: "Adversarial Review", + REVIEW_KIND: reviewKind, TARGET_LABEL: context.target.label, USER_FOCUS: focusText || "No extra focus provided.", REVIEW_COLLECTION_GUIDANCE: context.collectionGuidance, @@ -339,7 +339,10 @@ async function executeReviewRun(request) { const reviewName = request.reviewName ?? "Review"; const context = collectReviewContext(request.cwd, target); - const prompt = buildAdversarialReviewPrompt(context, focusText); + const prompt = buildReviewPrompt(context, focusText, { + templateName: request.promptTemplate ?? "adversarial-review", + reviewKind: reviewName + }); const result = await runServerReview(context.repoRoot, { prompt, model: request.model, @@ -746,6 +749,7 @@ async function handleReviewCommand(argv, config) { model: options.model, focusText, reviewName: config.reviewName, + promptTemplate: config.promptTemplate, onProgress: progress }), { json: options.json } @@ -754,7 +758,15 @@ async function handleReviewCommand(argv, config) { async function handleReview(argv) { return handleReviewCommand(argv, { - reviewName: "Review" + reviewName: "Review", + promptTemplate: "review", + validateRequest: (target, focusText) => { + if (focusText) { + throw new Error( + "/opencode:review does not accept positional focus text. Use /opencode:adversarial-review for a steerable challenge review with custom focus." + ); + } + } }); } @@ -1167,7 +1179,8 @@ async function main() { break; case "adversarial-review": await handleReviewCommand(argv, { - reviewName: "Adversarial Review" + reviewName: "Adversarial Review", + promptTemplate: "adversarial-review" }); break; case "task": diff --git a/tests/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs index 17ea743..5e1cd73 100644 --- a/tests/fake-opencode-fixture.mjs +++ b/tests/fake-opencode-fixture.mjs @@ -508,7 +508,15 @@ const server = http.createServer(async (req, res) => { } if (req.method === "GET" && url.pathname === "/provider") { - sendJson(res, { id: "openai", name: "OpenAI" }); + if (process.env.FAKE_OPENCODE_PROVIDER_FAIL === "1") { + sendJson(res, { error: "provider endpoint failed" }, 500); + return; + } + if (process.env.FAKE_OPENCODE_NO_PROVIDER === "1") { + sendJson(res, { all: [], default: {}, connected: [] }); + } else { + sendJson(res, { all: [{ id: "openai" }], default: {}, connected: ["openai"] }); + } return; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 770d4b3..e0b512b 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1385,3 +1385,126 @@ test("runServerTurn deletes only sessions created by a failed captureTurn", asyn resumedFetch.restore(); } }); + +test("adversarial-review prompt uses the adversarial-review.md template", { skip: LOCAL_LISTEN_SKIP }, () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "before\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "README.md"), "after\n"); + const env = buildTestEnv(binDir); + + try { + const result = run("node", [SCRIPT, "adversarial-review", "--json"], { + cwd: repo, + env + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = readFakeState(binDir); + const prompt = fakeState.lastMessage.prompt; + assert.match(prompt, /adversarial software review/); + assert.match(prompt, /break confidence/); + assert.match(prompt, /Default to skepticism/); + } finally { + cleanupServer(repo, env); + } +}); + +test("review prompt uses the neutral review.md template", { skip: LOCAL_LISTEN_SKIP }, () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "before\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "README.md"), "after\n"); + const env = buildTestEnv(binDir); + + try { + const result = run("node", [SCRIPT, "review", "--json"], { + cwd: repo, + env + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = readFakeState(binDir); + const prompt = fakeState.lastMessage.prompt; + assert.doesNotMatch(prompt, /break confidence/); + assert.doesNotMatch(prompt, /Default to skepticism/); + assert.match(prompt, /balanced, high-signal software review/); + assert.match(prompt, /find real bugs, correctness issues/); + } finally { + cleanupServer(repo, env); + } +}); + +test("review rejects positional focus text with a clear error", () => { + const repo = makeTempDir(); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "test\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "review", "check auth paths"], { + cwd: repo, + env: process.env + }); + + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, /does not accept positional focus text/); + assert.match(result.stderr, /adversarial-review/); +}); + +test("setup reports not ready when no provider is connected", { skip: LOCAL_LISTEN_SKIP }, () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + const env = buildTestEnv(binDir, { + FAKE_OPENCODE_NO_PROVIDER: "1" + }); + + try { + const result = run("node", [SCRIPT, "setup", "--json"], { + cwd: repo, + env + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.ready, false); + assert.equal(payload.opencode.available, true); + assert.equal(payload.auth.loggedIn, false); + assert.match(payload.auth.detail, /No OpenCode provider is connected/); + assert.ok(payload.nextSteps.some((step) => /provider/i.test(step)), "nextSteps should include a provider config step"); + } finally { + cleanupServer(repo, env); + } +}); + +test("setup reports loggedIn false when /provider endpoint fails", { skip: LOCAL_LISTEN_SKIP }, () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeOpencode(binDir); + const env = buildTestEnv(binDir, { + FAKE_OPENCODE_PROVIDER_FAIL: "1" + }); + + try { + const result = run("node", [SCRIPT, "setup", "--json"], { + cwd: repo, + env + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.auth.loggedIn, false); + assert.equal(payload.ready, false); + } finally { + cleanupServer(repo, env); + } +}); From 26f313799667850ce829c13e6c33e5621a28d372 Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Fri, 10 Jul 2026 21:54:39 +0200 Subject: [PATCH 31/32] Fix install docs, widen CI matrix, neutral transfer metadata (#33) The README's marketplace command used an invalid source format, so a fresh user failed at the first documented step; CI ran only Node 22 on ubuntu-latest despite declaring Node >=18.18 support and containing Windows-specific process logic; the transfer converter stamped openai/gpt-5.4-mini into every imported session regardless of the user's provider; and the misleading persistThread option suggested review sessions get deleted when it only ever controlled the session title. README now documents `/plugin marketplace add TheRealDinghyDog/opencode-plugin-cc`; CI runs Node 18.18/20/22 on ubuntu-latest plus Node 22 on windows-latest with fail-fast off; imported sessions carry neutral claude-code/imported-transcript metadata (verified against a real OpenCode 1.17.15 import + export); and persistThread is renamed to taskSessionTitle with a comment stating that review sessions intentionally remain reopenable in OpenCode's store. Drafted by OpenCode via the plugin's own rescue path (issue #33 task); review found no defects to correct. Live-verified the neutral metadata against the real importer. Co-Authored-By: Claude Fable 5 --- .github/workflows/pull-request-ci.yml | 14 +++++++++++--- README.md | 2 +- .../scripts/lib/claude-session-transfer.mjs | 4 ++-- plugins/opencode/scripts/lib/opencode.mjs | 6 ++++-- plugins/opencode/scripts/opencode-companion.mjs | 2 +- tests/transfer.test.mjs | 4 ++-- 6 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index eedb466..8b2e1f4 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -8,9 +8,17 @@ permissions: jobs: ci: - name: CI - runs-on: ubuntu-latest + name: CI (Node ${{ matrix.node-version }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node-version: [18.18, 20, 22] + include: + - os: windows-latest + node-version: 22 steps: - name: Check out repository @@ -19,7 +27,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 22 + node-version: ${{ matrix.node-version }} cache: npm - name: Install dependencies diff --git a/README.md b/README.md index c5be33e..492c7df 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The plugin starts its own local `opencode serve` on demand, bound to `127.0.0.1` Add the marketplace in Claude Code: ```bash -/plugin marketplace add opencode +/plugin marketplace add TheRealDinghyDog/opencode-plugin-cc ``` Install the plugin: diff --git a/plugins/opencode/scripts/lib/claude-session-transfer.mjs b/plugins/opencode/scripts/lib/claude-session-transfer.mjs index 77f3edb..46b94e9 100644 --- a/plugins/opencode/scripts/lib/claude-session-transfer.mjs +++ b/plugins/opencode/scripts/lib/claude-session-transfer.mjs @@ -6,8 +6,8 @@ import path from "node:path"; import { ensureAbsolutePath } from "./fs.mjs"; export const TRANSCRIPT_PATH_ENV = "OPENCODE_COMPANION_TRANSCRIPT_PATH"; -export const OPENCODE_IMPORT_MODEL_ID = "gpt-5.4-mini"; -export const OPENCODE_IMPORT_PROVIDER_ID = "openai"; +export const OPENCODE_IMPORT_MODEL_ID = "imported-transcript"; +export const OPENCODE_IMPORT_PROVIDER_ID = "claude-code"; export const OPENCODE_IMPORT_AGENT = "build"; const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects"); diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index d6b898f..5689951 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -1386,7 +1386,7 @@ export async function runServerTurn(cwd, options = {}) { }); const session = await client.createSession( buildCreateSessionParams(cwd, { - title: options.threadName ?? options.title ?? (options.persistThread ? buildTaskSessionName(prompt) : null), + title: options.threadName ?? options.title ?? (options.taskSessionTitle ? buildTaskSessionName(prompt) : null), model: options.model, write, agent @@ -1471,7 +1471,9 @@ export async function runServerReview(cwd, options = {}) { ...options, agent: READ_ONLY_AGENT, sandbox: "read-only", - persistThread: false, + // Review sessions intentionally remain in OpenCode's session store so + // users can reopen them with `opencode --session `. + taskSessionTitle: false, threadName: options.threadName ?? "OpenCode Review" }); return { diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index fb4be68..6d3563d 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -424,7 +424,7 @@ async function executeTaskRun(request) { write: request.write, sandbox: request.write ? "workspace-write" : "read-only", onProgress: request.onProgress, - persistThread: true, + taskSessionTitle: true, threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) }); diff --git a/tests/transfer.test.mjs b/tests/transfer.test.mjs index 4bf1def..0779c17 100644 --- a/tests/transfer.test.mjs +++ b/tests/transfer.test.mjs @@ -68,8 +68,8 @@ test("Claude JSONL converts to a well-formed OpenCode import document", () => { assert.equal(doc.info.slug, "investigate-the-failure"); assert.equal(doc.info.agent, "build"); assert.deepEqual(doc.info.model, { - id: "gpt-5.4-mini", - providerID: "openai", + id: "imported-transcript", + providerID: "claude-code", variant: "default" }); assert.equal(doc.info.version, "1.17.10-test"); From a4d6f8349dc274a8855cee4c951bb6eef57d62dc Mon Sep 17 00:00:00 2001 From: TheRealDinghyDog Date: Sat, 11 Jul 2026 02:37:23 +0200 Subject: [PATCH 32/32] Fix the Windows failures the new CI matrix surfaced (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The windows-latest leg added in this PR ran the suite on Windows for the first time and found six failures, all environmental: - canonicalWorkspaceDirectory and the tests' expected paths disagreed on Windows 8.3 short names (RUNNER~1 vs runneradmin): use fs.realpathSync.native, which expands short names and matches a child process's cwd, consistent with resolveStateDir. - findLatestTaskThread compared stored canonical directories against the raw cwd, which on Windows is git's forward-slash toplevel — resume lookups could never match; compare canonical-to-canonical. - Two tests hand-rolled a shebang opencode stub that Windows cannot execute via PATH; they now install the same .cmd shim the main fixture uses. - node --import was passed a bare absolute path, which Windows parses as an unsupported "d:" URL scheme; pass a file:// URL. - The transfer test faked only HOME; os.homedir() reads USERPROFILE on Windows. Co-Authored-By: Claude Fable 5 --- plugins/opencode/scripts/lib/opencode.mjs | 20 +++++++-- tests/runtime.test.mjs | 49 ++++++++++++----------- tests/transfer.test.mjs | 2 + 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/plugins/opencode/scripts/lib/opencode.mjs b/plugins/opencode/scripts/lib/opencode.mjs index 5689951..a5e19ea 100644 --- a/plugins/opencode/scripts/lib/opencode.mjs +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -1074,12 +1074,20 @@ async function captureTurn(client, sessionID, startRequest, options = {}) { } // The canonical (symlink-resolved) workspace path is what OpenCode records as -// a session's directory, so use it for the client's directory scope. +// a session's directory, so use it for the client's directory scope. Use the +// native resolver: unlike the JS implementation it also expands Windows 8.3 +// short names (RUNNER~1 -> runneradmin) and normalizes separators, so the +// value matches what a child process sees as its cwd. Same choice as +// resolveStateDir in state.mjs. function canonicalWorkspaceDirectory(cwd) { try { - return fs.realpathSync(cwd); + return fs.realpathSync.native(cwd); } catch { - return cwd; + try { + return fs.realpathSync(cwd); + } catch { + return cwd; + } } } @@ -1489,12 +1497,16 @@ export async function findLatestTaskThread(cwd) { throw new Error("OpenCode CLI is not installed or is missing headless server support. Install OpenCode, then rerun `/opencode:setup`."); } + // Compare canonical-to-canonical: stored session directories originate from + // the canonical directory the client sends, while `cwd` here can be git's + // forward-slash toplevel (Windows) or a symlinked path (macOS /var). + const canonicalCwd = canonicalWorkspaceDirectory(cwd); return withServer(cwd, async (client) => { const sessions = getSessionsArray(await client.listSessions()) .filter((session) => sessionTitle(session).startsWith(TASK_SESSION_PREFIX)) .filter((session) => { const directory = sessionDirectory(session); - return !directory || directory === cwd; + return !directory || directory === canonicalCwd; }) .sort((left, right) => sessionUpdatedAt(right) - sessionUpdatedAt(left)); return sessions[0] ?? null; diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 770d4b3..68be56e 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -4,7 +4,7 @@ import path from "node:path"; import { spawn } from "node:child_process"; import test from "node:test"; import assert from "node:assert/strict"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { interruptServerTurn, runServerTurn } from "../plugins/opencode/scripts/lib/opencode.mjs"; import { loadServerSession, saveServerSession, SERVER_URL_ENV } from "../plugins/opencode/scripts/lib/server-lifecycle.mjs"; @@ -154,9 +154,10 @@ function startExternalFixtureServer(binDir) { return { child, url }; } -test("interruptServerTurn marks env-provided server urls as external", async () => { - const workspace = makeTempDir(); - const binDir = makeTempDir(); +// Hand-rolled availability-only opencode stub (no HTTP server). Mirrors +// installFakeOpencode's Windows shim: a bare shebang script is not executable +// via PATH on win32, so binaryAvailable() would report opencode missing there. +function installStubOpencodeBinary(binDir) { writeExecutable( path.join(binDir, "opencode"), `#!/usr/bin/env node @@ -171,6 +172,17 @@ if (process.argv[2] === "serve" && process.argv.includes("--help")) { process.exit(1); ` ); + if (process.platform === "win32") { + fs.writeFileSync(path.join(binDir, "opencode.cmd"), `@echo off\r\nnode "%~dp0opencode" %*\r\n`, { + encoding: "utf8" + }); + } +} + +test("interruptServerTurn marks env-provided server urls as external", async () => { + const workspace = makeTempDir(); + const binDir = makeTempDir(); + installStubOpencodeBinary(binDir); const previousFetch = globalThis.fetch; const calls = []; globalThis.fetch = async (requestUrl, options = {}) => { @@ -492,12 +504,12 @@ test("external server requests are bound to each invoking workspace (issue #29)" // to the invoking repo, not the server process's launch directory. const fakeState = readFakeState(binDir); const directories = fakeState.sessions.map((session) => session.directory).sort(); - assert.deepEqual(directories, [fs.realpathSync(repoA), fs.realpathSync(repoB)].sort()); + assert.deepEqual(directories, [fs.realpathSync.native(repoA), fs.realpathSync.native(repoB)].sort()); // The event subscriptions carried the workspace scope too. const eventDirectories = (fakeState.eventDirectories || []).filter(Boolean); - assert.ok(eventDirectories.includes(fs.realpathSync(repoA)), "event stream scoped to workspace A"); - assert.ok(eventDirectories.includes(fs.realpathSync(repoB)), "event stream scoped to workspace B"); + assert.ok(eventDirectories.includes(fs.realpathSync.native(repoA)), "event stream scoped to workspace A"); + assert.ok(eventDirectories.includes(fs.realpathSync.native(repoB)), "event stream scoped to workspace B"); } finally { child.kill(); } @@ -609,20 +621,7 @@ globalThis.fetch = async (requestUrl, options = {}) => { `, "utf8" ); - writeExecutable( - path.join(binDir, "opencode"), - `#!/usr/bin/env node -if (process.argv[2] === "--version") { - console.log("opencode test"); - process.exit(0); -} -if (process.argv[2] === "serve" && process.argv.includes("--help")) { - console.log("serve help"); - process.exit(0); -} -process.exit(1); -` - ); + installStubOpencodeBinary(binDir); await withProcessEnv({ CLAUDE_PLUGIN_DATA: pluginDataDir }, async () => { const jobId = "job-cancel-external-server"; @@ -649,7 +648,9 @@ process.exit(1); }); writeJobFile(workspace, jobId, runningJob); - const result = run(process.execPath, ["--import", fetchPreload, SCRIPT, "cancel", jobId, "--cwd", workspace, "--json"], { + // --import requires a file:// URL on Windows (a bare D:\... path parses as + // an unsupported "d:" URL scheme). + const result = run(process.execPath, ["--import", pathToFileURL(fetchPreload).href, SCRIPT, "cancel", jobId, "--cwd", workspace, "--json"], { cwd: workspace, env: { ...process.env, @@ -1108,7 +1109,7 @@ test("task fails closed when a resumed-session snapshot fails and only stale mes sessions: [ { id: "ses_existing", - directory: fs.realpathSync(repo), + directory: fs.realpathSync.native(repo), title: "OpenCode Companion Task: prior fixture task", agent: "plan", model: null, @@ -1268,7 +1269,7 @@ test("task fails when completion has no recoverable current-turn message (issue id: "ses_existing", // realpath: makeTempDir returns a symlinked /var path on macOS, but // findLatestTaskThread matches against the realpath'd workspace root. - directory: fs.realpathSync(repo), + directory: fs.realpathSync.native(repo), title: "OpenCode Companion Task: prior fixture task", agent: "plan", model: null, diff --git a/tests/transfer.test.mjs b/tests/transfer.test.mjs index 0779c17..3bfb7ca 100644 --- a/tests/transfer.test.mjs +++ b/tests/transfer.test.mjs @@ -179,7 +179,9 @@ test("transfer imports a Claude transcript and prints an OpenCode resume command fs.writeFileSync(transcriptPath, sampleClaudeJsonl(), "utf8"); const env = buildEnv(binDir, { + // os.homedir() reads USERPROFILE on Windows and HOME elsewhere. HOME: home, + USERPROFILE: home, OPENCODE_COMPANION_TRANSCRIPT_PATH: transcriptPath }); const result = run("node", [SCRIPT, "transfer"], {