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..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,17 +27,11 @@ 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 run: npm ci - - name: Install Codex CLI - run: npm install -g @openai/codex - - 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..c21aea3 --- /dev/null +++ b/IMPLEMENTATION_NOTES.md @@ -0,0 +1,46 @@ +# 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. + +## 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`. + +## 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 937a303..492c7df 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,38 @@ -# 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** +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: ```bash -/plugin marketplace add openai/codex-plugin-cc +/plugin marketplace add TheRealDinghyDog/opencode-plugin-cc ``` Install the plugin: ```bash -/plugin install codex@openai-codex +/plugin install opencode@opencode ``` Reload plugins: @@ -42,41 +44,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 +84,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,76 +114,77 @@ 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] > 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: ```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. - -Use it when you started a debugging or implementation conversation in Claude Code and want to continue that same context directly in Codex. +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: ```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 +193,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. 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/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..0e4f03a --- /dev/null +++ b/docs/opencode-adaptation-plan.md @@ -0,0 +1,182 @@ +# 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.** + - **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. +- `--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..2641c12 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,12 @@ { - "name": "@openai/codex-plugin-cc", + "name": "@opencode/claude-code-plugin", "version": "1.0.5", "private": true, + "files": [ + "plugins/opencode/" + ], "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 +14,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/claude-session-transfer.mjs b/plugins/codex/scripts/lib/claude-session-transfer.mjs deleted file mode 100644 index eea0aeb..0000000 --- a/plugins/codex/scripts/lib/claude-session-transfer.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { ensureAbsolutePath } from "./fs.mjs"; - -export const TRANSCRIPT_PATH_ENV = "CODEX_COMPANION_TRANSCRIPT_PATH"; -const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects"); - -function resolveUserPath(cwd, value) { - if (value === "~") { - return os.homedir(); - } - if (String(value).startsWith("~/")) { - return path.join(os.homedir(), String(value).slice(2)); - } - return ensureAbsolutePath(cwd, value); -} - -export function resolveClaudeSessionPath(cwd, options = {}) { - const requestedPath = options.source || process.env[TRANSCRIPT_PATH_ENV]; - if (!requestedPath) { - throw new Error("Could not identify the current Claude transcript. Retry with --source ."); - } - - const sourcePath = resolveUserPath(cwd, requestedPath); - if (path.extname(sourcePath) !== ".jsonl") { - throw new Error(`Claude session source must be a JSONL file: ${sourcePath}`); - } - - let source; - let projects; - try { - source = fs.realpathSync(sourcePath); - projects = fs.realpathSync(CLAUDE_PROJECTS_DIR); - } catch { - throw new Error(`Claude session file not found: ${sourcePath}`); - } - 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}`); - } - return source; -} 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/scripts/lib/process.mjs b/plugins/codex/scripts/lib/process.mjs deleted file mode 100644 index af28d1c..0000000 --- a/plugins/codex/scripts/lib/process.mjs +++ /dev/null @@ -1,135 +0,0 @@ -import { spawnSync } from "node:child_process"; -import process from "node:process"; - -export function runCommand(command, args = [], options = {}) { - const result = spawnSync(command, args, { - cwd: options.cwd, - env: options.env, - encoding: "utf8", - input: options.input, - maxBuffer: options.maxBuffer, - stdio: options.stdio ?? "pipe", - shell: process.platform === "win32" ? (process.env.SHELL || true) : false, - windowsHide: true - }); - - return { - command, - args, - status: result.status ?? 0, - signal: result.signal ?? null, - stdout: result.stdout ?? "", - stderr: result.stderr ?? "", - error: result.error ?? null - }; -} - -export function runCommandChecked(command, args = [], options = {}) { - const result = runCommand(command, args, options); - if (result.error) { - throw result.error; - } - if (result.status !== 0) { - throw new Error(formatCommandFailure(result)); - } - return result; -} - -export function binaryAvailable(command, versionArgs = ["--version"], options = {}) { - const result = runCommand(command, versionArgs, options); - if (result.error && /** @type {NodeJS.ErrnoException} */ (result.error).code === "ENOENT") { - return { available: false, detail: "not found" }; - } - if (result.error) { - return { available: false, detail: result.error.message }; - } - if (result.status !== 0) { - const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`; - return { available: false, detail }; - } - return { available: true, detail: result.stdout.trim() || result.stderr.trim() || "ok" }; -} - -function looksLikeMissingProcessMessage(text) { - return /not found|no running instance|cannot find|does not exist|no such process/i.test(text); -} - -export function terminateProcessTree(pid, options = {}) { - if (!Number.isFinite(pid)) { - return { attempted: false, delivered: false, method: null }; - } - - const platform = options.platform ?? process.platform; - const runCommandImpl = options.runCommandImpl ?? runCommand; - const killImpl = options.killImpl ?? process.kill.bind(process); - - if (platform === "win32") { - const result = runCommandImpl("taskkill", ["/PID", String(pid), "/T", "/F"], { - cwd: options.cwd, - env: options.env - }); - - if (!result.error && result.status === 0) { - return { attempted: true, delivered: true, method: "taskkill", result }; - } - - const combinedOutput = `${result.stderr}\n${result.stdout}`.trim(); - if (!result.error && looksLikeMissingProcessMessage(combinedOutput)) { - return { attempted: true, delivered: false, method: "taskkill", result }; - } - - if (result.error?.code === "ENOENT") { - try { - killImpl(pid); - return { attempted: true, delivered: true, method: "kill" }; - } catch (error) { - if (error?.code === "ESRCH") { - return { attempted: true, delivered: false, method: "kill" }; - } - throw error; - } - } - - if (result.error) { - throw result.error; - } - - throw new Error(formatCommandFailure(result)); - } - - try { - killImpl(-pid, "SIGTERM"); - return { attempted: true, delivered: true, method: "process-group" }; - } catch (error) { - if (error?.code !== "ESRCH") { - try { - killImpl(pid, "SIGTERM"); - return { attempted: true, delivered: true, method: "process" }; - } catch (innerError) { - if (innerError?.code === "ESRCH") { - return { attempted: true, delivered: false, method: "process" }; - } - throw innerError; - } - } - - return { attempted: true, delivered: false, method: "process-group" }; - } -} - -export function formatCommandFailure(result) { - const parts = [`${result.command} ${result.args.join(" ")}`.trim()]; - if (result.signal) { - parts.push(`signal=${result.signal}`); - } else { - parts.push(`exit=${result.status}`); - } - const stderr = (result.stderr || "").trim(); - const stdout = (result.stdout || "").trim(); - if (stderr) { - parts.push(stderr); - } else if (stdout) { - parts.push(stdout); - } - return parts.join(": "); -} diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs deleted file mode 100644 index 2da2349..0000000 --- a/plugins/codex/scripts/lib/state.mjs +++ /dev/null @@ -1,191 +0,0 @@ -import { createHash } from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -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 STATE_FILE_NAME = "state.json"; -const JOBS_DIR_NAME = "jobs"; -const MAX_JOBS = 50; - -function nowIso() { - return new Date().toISOString(); -} - -function defaultState() { - return { - version: STATE_VERSION, - config: { - stopReviewGate: false - }, - jobs: [] - }; -} - -export function resolveStateDir(cwd) { - const workspaceRoot = resolveWorkspaceRoot(cwd); - let canonicalWorkspaceRoot = workspaceRoot; - try { - canonicalWorkspaceRoot = fs.realpathSync.native(workspaceRoot); - } catch { - canonicalWorkspaceRoot = workspaceRoot; - } - - const slugSource = path.basename(workspaceRoot) || "workspace"; - const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace"; - const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16); - const pluginDataDir = process.env[PLUGIN_DATA_ENV]; - const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR; - return path.join(stateRoot, `${slug}-${hash}`); -} - -export function resolveStateFile(cwd) { - return path.join(resolveStateDir(cwd), STATE_FILE_NAME); -} - -export function resolveJobsDir(cwd) { - return path.join(resolveStateDir(cwd), JOBS_DIR_NAME); -} - -export function ensureStateDir(cwd) { - fs.mkdirSync(resolveJobsDir(cwd), { recursive: true }); -} - -export function loadState(cwd) { - const stateFile = resolveStateFile(cwd); - if (!fs.existsSync(stateFile)) { - return defaultState(); - } - - try { - const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8")); - return { - ...defaultState(), - ...parsed, - config: { - ...defaultState().config, - ...(parsed.config ?? {}) - }, - jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] - }; - } catch { - return defaultState(); - } -} - -function pruneJobs(jobs) { - return [...jobs] - .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""))) - .slice(0, MAX_JOBS); -} - -function removeFileIfExists(filePath) { - if (filePath && fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } -} - -export function saveState(cwd, state) { - const previousJobs = loadState(cwd).jobs; - ensureStateDir(cwd); - const nextJobs = pruneJobs(state.jobs ?? []); - const nextState = { - version: STATE_VERSION, - config: { - ...defaultState().config, - ...(state.config ?? {}) - }, - jobs: nextJobs - }; - - const retainedIds = new Set(nextJobs.map((job) => job.id)); - for (const job of previousJobs) { - if (retainedIds.has(job.id)) { - continue; - } - removeJobFile(resolveJobFile(cwd, job.id)); - removeFileIfExists(job.logFile); - } - - fs.writeFileSync(resolveStateFile(cwd), `${JSON.stringify(nextState, null, 2)}\n`, "utf8"); - return nextState; -} - -export function updateState(cwd, mutate) { - const state = loadState(cwd); - mutate(state); - return saveState(cwd, state); -} - -export function generateJobId(prefix = "job") { - const random = Math.random().toString(36).slice(2, 8); - return `${prefix}-${Date.now().toString(36)}-${random}`; -} - -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 - }; - }); -} - -export function listJobs(cwd) { - return loadState(cwd).jobs; -} - -export function setConfig(cwd, key, value) { - return updateState(cwd, (state) => { - state.config = { - ...state.config, - [key]: value - }; - }); -} - -export function getConfig(cwd) { - return loadState(cwd).config; -} - -export function writeJobFile(cwd, jobId, payload) { - ensureStateDir(cwd); - const jobFile = resolveJobFile(cwd, jobId); - fs.writeFileSync(jobFile, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); - return jobFile; -} - -export function readJobFile(jobFile) { - return JSON.parse(fs.readFileSync(jobFile, "utf8")); -} - -function removeJobFile(jobFile) { - if (fs.existsSync(jobFile)) { - fs.unlinkSync(jobFile); - } -} - -export function resolveJobLogFile(cwd, jobId) { - ensureStateDir(cwd); - return path.join(resolveJobsDir(cwd), `${jobId}.log`); -} - -export function resolveJobFile(cwd, jobId) { - ensureStateDir(cwd); - return path.join(resolveJobsDir(cwd), `${jobId}.json`); -} diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs deleted file mode 100644 index 9028690..0000000 --- a/plugins/codex/scripts/lib/tracked-jobs.mjs +++ /dev/null @@ -1,204 +0,0 @@ -import fs from "node:fs"; -import process from "node:process"; - -import { readJobFile, resolveJobFile, resolveJobLogFile, upsertJob, writeJobFile } from "./state.mjs"; - -export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID"; - -export function nowIso() { - return new Date().toISOString(); -} - -function normalizeProgressEvent(value) { - if (value && typeof value === "object" && !Array.isArray(value)) { - return { - message: String(value.message ?? "").trim(), - 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, - 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() - }; - } - - return { - message: String(value ?? "").trim(), - phase: null, - threadId: null, - turnId: null, - stderrMessage: String(value ?? "").trim(), - logTitle: null, - logBody: null - }; -} - -export function appendLogLine(logFile, message) { - const normalized = String(message ?? "").trim(); - if (!logFile || !normalized) { - return; - } - fs.appendFileSync(logFile, `[${nowIso()}] ${normalized}\n`, "utf8"); -} - -export function appendLogBlock(logFile, title, body) { - if (!logFile || !body) { - return; - } - fs.appendFileSync(logFile, `\n[${nowIso()}] ${title}\n${String(body).trimEnd()}\n`, "utf8"); -} - -export function createJobLogFile(workspaceRoot, jobId, title) { - const logFile = resolveJobLogFile(workspaceRoot, jobId); - fs.writeFileSync(logFile, "", "utf8"); - if (title) { - appendLogLine(logFile, `Starting ${title}.`); - } - return logFile; -} - -export function createJobRecord(base, options = {}) { - const env = options.env ?? process.env; - const sessionId = env[options.sessionIdEnv ?? SESSION_ID_ENV]; - return { - ...base, - createdAt: nowIso(), - ...(sessionId ? { sessionId } : {}) - }; -} - -export function createJobProgressUpdater(workspaceRoot, jobId) { - let lastPhase = null; - let lastThreadId = null; - let lastTurnId = null; - - return (event) => { - const normalized = normalizeProgressEvent(event); - const patch = { id: jobId }; - let changed = false; - - if (normalized.phase && normalized.phase !== lastPhase) { - lastPhase = normalized.phase; - patch.phase = normalized.phase; - changed = true; - } - - if (normalized.threadId && normalized.threadId !== lastThreadId) { - lastThreadId = normalized.threadId; - patch.threadId = normalized.threadId; - changed = true; - } - - if (normalized.turnId && normalized.turnId !== lastTurnId) { - lastTurnId = normalized.turnId; - patch.turnId = normalized.turnId; - changed = true; - } - - if (!changed) { - return; - } - - upsertJob(workspaceRoot, patch); - - const jobFile = resolveJobFile(workspaceRoot, jobId); - if (!fs.existsSync(jobFile)) { - return; - } - - const storedJob = readJobFile(jobFile); - writeJobFile(workspaceRoot, jobId, { - ...storedJob, - ...patch - }); - }; -} - -export function createProgressReporter({ stderr = false, logFile = null, onEvent = null } = {}) { - if (!stderr && !logFile && !onEvent) { - return null; - } - - return (eventOrMessage) => { - const event = normalizeProgressEvent(eventOrMessage); - const stderrMessage = event.stderrMessage ?? event.message; - if (stderr && stderrMessage) { - process.stderr.write(`[codex] ${stderrMessage}\n`); - } - appendLogLine(logFile, event.message); - appendLogBlock(logFile, event.logTitle, event.logBody); - onEvent?.(event); - }; -} - -function readStoredJobOrNull(workspaceRoot, jobId) { - const jobFile = resolveJobFile(workspaceRoot, jobId); - if (!fs.existsSync(jobFile)) { - return null; - } - return readJobFile(jobFile); -} - -export async function runTrackedJob(job, runner, options = {}) { - const runningRecord = { - ...job, - status: "running", - startedAt: nowIso(), - phase: "starting", - pid: process.pid, - logFile: options.logFile ?? job.logFile ?? null - }; - writeJobFile(job.workspaceRoot, job.id, runningRecord); - upsertJob(job.workspaceRoot, runningRecord); - - 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, - pid: null, - phase: completionStatus === "completed" ? "done" : "failed", - completedAt, - result: execution.payload, - rendered: execution.rendered - }); - upsertJob(job.workspaceRoot, { - id: job.id, - status: completionStatus, - threadId: execution.threadId ?? null, - turnId: execution.turnId ?? null, - summary: execution.summary, - phase: completionStatus === "completed" ? "done" : "failed", - pid: null, - completedAt - }); - 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 - }); - throw error; - } -} 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..48385cf --- /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 fail-closed 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/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/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/opencode/scripts/lib/claude-session-transfer.mjs b/plugins/opencode/scripts/lib/claude-session-transfer.mjs new file mode 100644 index 0000000..46b94e9 --- /dev/null +++ b/plugins/opencode/scripts/lib/claude-session-transfer.mjs @@ -0,0 +1,302 @@ +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 = "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"); + +function resolveUserPath(cwd, value) { + if (value === "~") { + return os.homedir(); + } + if (String(value).startsWith("~/")) { + return path.join(os.homedir(), String(value).slice(2)); + } + return ensureAbsolutePath(cwd, value); +} + +export function resolveClaudeSessionPath(cwd, options = {}) { + const requestedPath = options.source || process.env[TRANSCRIPT_PATH_ENV]; + if (!requestedPath) { + throw new Error("Could not identify the current Claude transcript. Retry with --source ."); + } + + const sourcePath = resolveUserPath(cwd, requestedPath); + if (path.extname(sourcePath) !== ".jsonl") { + throw new Error(`Claude session source must be a JSONL file: ${sourcePath}`); + } + + let source; + let projects; + try { + source = fs.realpathSync(sourcePath); + projects = fs.realpathSync(CLAUDE_PROJECTS_DIR); + } catch { + throw new Error(`Claude session file not found: ${sourcePath}`); + } + const relative = path.relative(projects, source); + if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`OpenCode can import Claude sessions only from ${CLAUDE_PROJECTS_DIR}: ${source}`); + } + 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 lastMessageID = 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; + } + + 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" && !lastMessageID) { + 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)); + lastMessageID = messageID; + } else { + messages.push(buildAssistantMessage(text, time, sessionID, messageID, partID, lastMessageID, cwd)); + lastMessageID = messageID; + } + } + + 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/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 96% rename from plugins/codex/scripts/lib/git.mjs rename to plugins/opencode/scripts/lib/git.mjs index 1749cfc..e60cb2f 100644 --- a/plugins/codex/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/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..182f68f --- /dev/null +++ b/plugins/opencode/scripts/lib/opencode-server.mjs @@ -0,0 +1,432 @@ +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); + this.name = "OpencodeHttpError"; + this.status = options.status ?? null; + this.body = options.body ?? ""; + this.url = options.url ?? null; + } +} + +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)); +} + +async function parseResponseBody(response) { + const text = await response.text(); + return parseBodyText(text, response.headers.get("content-type") ?? ""); +} + +function parseBodyText(text, contentType = "") { + if (!text) { + return {}; + } + + if (contentType.includes("application/json")) { + return JSON.parse(text); + } + + try { + return JSON.parse(text); + } catch { + return text; + } +} + +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 ?? {}), + connection: "close" + }; + + 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, + { + method: options.method, + headers, + agent: false, + signal: options.signal + }, + (res) => { + 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) { + rejectOnce( + 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"]; + resolveOnce(parseBodyText(text, Array.isArray(contentType) ? contentType.join(";") : contentType ?? "")); + } catch (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", 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); + } + req.end(); + }); +} + +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; + 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."); + } + 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}`; + 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() { + 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: { + ...this.authHeaders(), + ...(options.headers ?? {}) + }, + body: options.body, + signal: options.signal, + requestTimeoutMs: options.requestTimeoutMs + }); + } + + const headers = { + ...(options.body == null ? {} : { "content-type": "application/json" }), + ...this.authHeaders(), + ...(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 + }); + } + + deleteSession(sessionID, options = {}) { + return this.request("DELETE", `/session/${encodePathSegment(sessionID)}`, { 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 }); + } + + listMessages(sessionID, options = {}) { + return this.request("GET", `/session/${encodePathSegment(sessionID)}/message`, { + signal: options.signal, + freshConnection: options.freshConnection, + requestTimeoutMs: options.requestTimeoutMs + }); + } + + respondPermission(sessionID, permissionID, response = "always", options = {}) { + return this.request("POST", `/session/${encodePathSegment(sessionID)}/permissions/${encodePathSegment(permissionID)}`, { + body: { response }, + signal: options.signal + }); + } + + 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 }); + } + + 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", + ...this.authHeaders() + }, + 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."); + } + + 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 = ""; + + 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..0765744 --- /dev/null +++ b/plugins/opencode/scripts/lib/opencode.mjs @@ -0,0 +1,1647 @@ +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 { OpencodeHttpError, OpencodeServerClient } from "./opencode-server.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"; +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"; +// 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; +// 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) { + 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 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. + // + // 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 } : {}) + }; +} + +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 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; + } + 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 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 ?? + 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, + priorAssistantIds: new Set(options.priorAssistantIds ?? []), + priorAssistantSnapshotSucceeded: false, + priorAssistantSnapshotError: null, + resumed: Boolean(options.resumed), + 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, + streamError: null, + responseError: null, + recoveryError: null, + response: null, + fallbackTimer: null, + onProgress: options.onProgress ?? null + }; +} + +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) { + // 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 = info?.parentID ?? extractParentSessionId(event); + if (sessionID === state.sessionID || state.sessionIDs.has(parentID)) { + state.sessionIDs.add(sessionID); + const label = + info?.title ?? + info?.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; + } + 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); + + 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") + }); + } +} + +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) ?? + (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; + } + + // 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, + `Denying OpenCode permission request ${permissionID}${described ? ` (${described})` : ""}: headless runs never self-approve gated permissions.`, + "running" + ); + try { + await client.respondPermission(sessionID, permissionID, "reject"); + } 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 === "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") { + // 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; + } + + 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 + ); + } +} + +// 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 = {}) { + 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: 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 && (state.priorAssistantSnapshotSucceeded || !state.resumed)) { + assistant = messages + .filter((message) => { + const messageID = extractMessageId(message); + return messageID && !state.priorAssistantIds.has(messageID); + }) + .pop(); + } + if (!assistant) { + return false; + } + state.messageID = extractMessageId(assistant) ?? state.messageID; + applyMessageParts(state, extractMessageParts(assistant), state.sessionID); + if (state.finalMessage || state.structuredOutput != null) { + state.recovered = true; + completeTurn(state); + return true; + } + return false; + } catch (error) { + state.recoveryError = error; + return false; + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +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) + ); + state.priorAssistantSnapshotSucceeded = true; + state.priorAssistantSnapshotError = null; + } catch (error) { + state.priorAssistantIds = new Set(); + state.priorAssistantSnapshotSucceeded = false; + state.priorAssistantSnapshotError = error; + } +} + +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; + }); + + // 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, + onOpen: resolveOpen + }) + .catch((error) => { + if (eventAbort.signal.aborted) { + return; + } + state.streamError = error; + rejectOpen(error); + }); + + 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([ + opened, + sleep(options.eventOpenTimeoutMs ?? 3000).then(() => { + throw new Error("Timed out waiting for OpenCode event stream."); + }) + ]); + + 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). + let responseSettled = false; + let resolveResponseSettled; + const responseSettledPromise = new Promise((resolve) => { + resolveResponseSettled = resolve; + }); + + 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; + }) + .finally(() => { + responseSettled = true; + resolveResponseSettled(state); + }); + + // 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) => { + turnTimer = setTimeout(() => { + timedOut = true; + resolve(); + }, turnTimeoutMs); + turnTimer.unref?.(); + }) + : new Promise(() => {}); + const streamDropGraceMs = Math.max( + 0, + Number(options.streamDropGraceMs ?? DEFAULT_STREAM_DROP_GRACE_MS) || DEFAULT_STREAM_DROP_GRACE_MS + ); + 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)]); + } + 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, 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 + // 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. + if (!state.error && !state.finalMessage && state.structuredOutput == null) { + state.error = + state.recoveryError ?? + state.responseError ?? + state.streamError ?? + new Error( + timedOut + ? "OpenCode turn timed out before completion." + : "OpenCode turn ended without a result." + ); + } + + return state; + } finally { + if (turnTimer) { + clearTimeout(turnTimer); + } + 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); + } + } +} + +// The canonical (symlink-resolved) workspace path is what OpenCode records as +// 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.native(cwd); + } catch { + 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(buildServerClient(cwd, server), server); +} + +// 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 = {}, directory = null) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const client = new OpencodeServerClient(serverUrl, { + ...credentials, + ...(directory ? { directory } : {}) + }); + await client.abort(threadId, { signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +function normalizeServerUrlForCompare(url) { + const normalized = String(url ?? "").trim().replace(/\/+$/, ""); + return normalized || null; +} + +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 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, + // Env-configured servers are user-managed; a persisted server session is + // one the plugin spawned and owns. + external: Boolean(envUrl) + }; + } + + 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, + external: false + }; +} + +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) { + 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 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 ?? + 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" + }); + } +} + +// `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) { + return { + attempted: false, + interrupted: false, + transport: null, + detail: "missing OpenCode session id", + ...(serverUrl ? { serverUrl } : {}), + ...ownership + }; + } + + if (serverUrl) { + try { + await abortSessionAtUrl( + serverUrl, + threadId, + 1000, + resolveCredentialsForServerUrl(cwd, serverUrl), + canonicalWorkspaceDirectory(cwd) + ); + return { + attempted: true, + interrupted: true, + transport: "server", + detail: `Aborted OpenCode session ${threadId}.`, + serverUrl, + ...ownership + }; + } catch (error) { + return { + attempted: true, + interrupted: false, + transport: "server", + detail: error instanceof Error ? error.message : String(error), + serverUrl, + ...ownership + }; + } + } + + const availability = getAvailability(cwd); + if (!availability.available) { + return { + attempted: false, + interrupted: false, + transport: null, + detail: availability.detail + }; + } + + let usedServerUrl = null; + let usedServerExternal = false; + try { + 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 = buildServerClient(cwd, server); + 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), + ...(usedServerUrl ? { serverUrl: usedServerUrl } : {}), + ...(usedServerUrl ? { serverExternal: usedServerExternal } : {}) + }; + } +} + +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, server) => { + emitProgress(options.onProgress, "Using shared OpenCode server.", "starting", { + 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; + let createdSessionID = null; + const resumedSession = Boolean(sessionID); + + if (sessionID) { + emitProgress(options.onProgress, `Resuming OpenCode session ${sessionID}.`, "starting", { + threadId: sessionID, + serverUrl: server.url + }); + } else { + emitProgress(options.onProgress, "Starting OpenCode task session.", "starting", { + serverUrl: server.url + }); + const session = await client.createSession( + buildCreateSessionParams(cwd, { + title: options.threadName ?? options.title ?? (options.taskSessionTitle ? 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."); + } + createdSessionID = sessionID; + emitProgress(options.onProgress, `Session ready (${sessionID}).`, "starting", { + threadId: sessionID, + serverUrl: server.url + }); + } + + 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, + resumed: resumedSession, + turnTimeoutMs: options.turnTimeoutMs + } + ); + } catch (error) { + if (createdSessionID) { + try { + await client.deleteSession(createdSessionID); + } catch { + // Preserve the turn failure; session deletion is best-effort cleanup. + } + } + throw error; + } + + 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, + serverUrl: server.url, + finalMessage, + structuredOutput: structured ?? null, + 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", + // 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 { + ...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`."); + } + + // 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 === canonicalCwd; + }) + .sort((left, right) => sessionUpdatedAt(right) - sessionUpdatedAt(left)); + return sessions[0] ?? null; + }); +} + +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 { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Preserve the import result/error; temp-dir cleanup is best effort. + } + } +} + +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, + captureTurn as captureTurnForTest, + getAvailability as getOpencodeAvailability, + getAuthStatus as getOpencodeAuthStatus +}; diff --git a/plugins/opencode/scripts/lib/process.mjs b/plugins/opencode/scripts/lib/process.mjs new file mode 100644 index 0000000..32773e9 --- /dev/null +++ b/plugins/opencode/scripts/lib/process.mjs @@ -0,0 +1,294 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; + +export function runCommand(command, args = [], options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env, + encoding: "utf8", + input: options.input, + maxBuffer: options.maxBuffer, + stdio: options.stdio ?? "pipe", + shell: process.platform === "win32" ? (process.env.SHELL || true) : false, + windowsHide: true + }); + + return { + command, + args, + status: result.status ?? 0, + signal: result.signal ?? null, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + error: result.error ?? null + }; +} + +export function runCommandChecked(command, args = [], options = {}) { + const result = runCommand(command, args, options); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(formatCommandFailure(result)); + } + return result; +} + +export function binaryAvailable(command, versionArgs = ["--version"], options = {}) { + const result = runCommand(command, versionArgs, options); + if (result.error && /** @type {NodeJS.ErrnoException} */ (result.error).code === "ENOENT") { + return { available: false, detail: "not found" }; + } + if (result.error) { + return { available: false, detail: result.error.message }; + } + if (result.status !== 0) { + const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`; + return { available: false, detail }; + } + return { available: true, detail: result.stdout.trim() || result.stderr.trim() || "ok" }; +} + +function looksLikeMissingProcessMessage(text) { + return /not found|no running instance|cannot find|does not exist|no such process/i.test(text); +} + +export function terminateProcessTree(pid, options = {}) { + if (!Number.isFinite(pid)) { + return { attempted: false, delivered: false, method: null }; + } + + const platform = options.platform ?? process.platform; + const runCommandImpl = options.runCommandImpl ?? runCommand; + const killImpl = options.killImpl ?? process.kill.bind(process); + + if (platform === "win32") { + const result = runCommandImpl("taskkill", ["/PID", String(pid), "/T", "/F"], { + cwd: options.cwd, + env: options.env + }); + + if (!result.error && result.status === 0) { + return { attempted: true, delivered: true, method: "taskkill", result }; + } + + const combinedOutput = `${result.stderr}\n${result.stdout}`.trim(); + if (!result.error && looksLikeMissingProcessMessage(combinedOutput)) { + return { attempted: true, delivered: false, method: "taskkill", result }; + } + + if (result.error?.code === "ENOENT") { + try { + killImpl(pid); + return { attempted: true, delivered: true, method: "kill" }; + } catch (error) { + if (error?.code === "ESRCH") { + return { attempted: true, delivered: false, method: "kill" }; + } + throw error; + } + } + + if (result.error) { + throw result.error; + } + + throw new Error(formatCommandFailure(result)); + } + + try { + killImpl(-pid, "SIGTERM"); + return { attempted: true, delivered: true, method: "process-group" }; + } catch (error) { + if (error?.code !== "ESRCH") { + try { + killImpl(pid, "SIGTERM"); + return { attempted: true, delivered: true, method: "process" }; + } catch (innerError) { + if (innerError?.code === "ESRCH") { + return { attempted: true, delivered: false, method: "process" }; + } + throw innerError; + } + } + + return { attempted: true, delivered: false, method: "process-group" }; + } +} + +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) + ); +} + +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; + } + + 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) { + parts.push(`signal=${result.signal}`); + } else { + parts.push(`exit=${result.status}`); + } + const stderr = (result.stderr || "").trim(); + const stdout = (result.stdout || "").trim(); + if (stderr) { + parts.push(stderr); + } else if (stdout) { + parts.push(stdout); + } + return parts.join(": "); +} 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 83% rename from plugins/codex/scripts/lib/render.mjs rename to plugins/opencode/scripts/lib/render.mjs index 2ec1852..e104ea6 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,25 +389,25 @@ 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 = (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`; 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) { @@ -446,10 +446,11 @@ export function renderStoredJobResult(job, storedJob) { } export function renderCancelReport(job) { + const cancelled = job.status === "cancelled"; const lines = [ - "# Codex Cancel", + "# OpenCode Cancel", "", - `Cancelled ${job.id}.`, + cancelled ? `Cancelled ${job.id}.` : `${job.id} is already ${job.status}; cancel was not applied.`, "" ]; @@ -459,7 +460,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..50be8f5 --- /dev/null +++ b/plugins/opencode/scripts/lib/server-lifecycle.mjs @@ -0,0 +1,696 @@ +import crypto from "node:crypto"; +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 { commandLineLooksLikeOpencodeServe, readProcessCommandLine } from "./process.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"; +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"; +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)); +} + +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); +} + +function resolveServerLockDir(cwd) { + return path.join(resolveStateDir(cwd), SERVER_LOCK_DIR); +} + +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 }); + // 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) { + 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, credentials = {}) { + const normalized = normalizeUrl(url); + if (!normalized) { + return false; + } + + try { + const client = new OpencodeServerClient(normalized, credentials); + await withTimeout((signal) => client.health({ signal }), timeoutMs); + return true; + } catch { + return false; + } +} + +async function waitForServerHealth(url, timeoutMs = 10000, credentials = {}) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await isServerHealthy(url, 500, credentials)) { + return true; + } + await sleep(100); + } + 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" ? null : false; + } +} + +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, serverSessionCredentials(existing)))) { + return existing; + } + 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; + } + + // 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) { + 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 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); + return { + ...pruned, + leases: [...withoutSelf, createServerLease(options)] + }; +} + +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 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 (;;) { + try { + fs.mkdirSync(lockDir); + } catch (error) { + if (error?.code !== "EEXIST") { + throw error; + } + + // 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); + } + if (deadline != null && Date.now() >= deadline) { + return null; + } + await sleep(deadline == null ? pollMs : Math.min(pollMs, Math.max(1, deadline - Date.now()))); + 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(); + 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, 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: childEnv, + 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 envSource = options.env ?? process.env; + const overrideUrl = normalizeUrl(options.serverUrl ?? envSource[SERVER_URL_ENV] ?? process.env[SERVER_URL_ENV]); + if (overrideUrl) { + // 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, + password: credentials.password, + username: credentials.username ?? null + }; + } + + 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); + if (lockedExisting) { + const leasedExisting = addServerLease(lockedExisting, options); + saveServerSession(cwd, leasedExisting); + return leasedExisting; + } + + const staleExisting = loadServerSession(cwd); + if (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({ + url, + pidFile, + logFile, + sessionDir, + pid, + external, + password, + username, + port, + killProcess: options.killProcess ?? null, + readProcessCommandLineImpl: options.readProcessCommandLineImpl ?? 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"); + // 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, + 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, + username: OWNED_SERVER_USERNAME + }); + if (!ready) { + await teardownServerSession({ + url, + pidFile, + logFile, + sessionDir, + pid: child.pid ?? null, + password, + username: OWNED_SERVER_USERNAME, + port, + killProcess: options.killProcess ?? null, + readProcessCommandLineImpl: options.readProcessCommandLineImpl ?? null + }); + return null; + } + + const session = { + url, + pid: child.pid ?? null, + pidFile, + logFile, + sessionDir, + external: false, + password, + username: OWNED_SERVER_USERNAME, + port, + pidCommandLine + }; + const leasedSession = addServerLease(session, options); + saveServerSession(cwd, leasedSession); + return leasedSession; + } finally { + lock.release?.(); + } +} + +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, + logFile = null, + sessionDir = null, + pid = null, + external = false, + password = null, + username = null, + killProcess = null, + port = null, + readProcessCommandLineImpl = null +} = {}) { + if (url && !external) { + try { + 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 { + // Instance cleanup is best-effort; process termination below still runs. + } + } + + let killSkippedReason = null; + if (!external && Number.isFinite(pid)) { + 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; + } + } + + 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. + } + } + + 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 }; +} + +export async function teardownServerSession({ + cwd = null, + force = false, + ignoreCurrentProcessLease = false, + lockAcquireTimeoutMs = null, + lockPollMs = null, + lockStaleMs = null, + url = null, + pidFile = null, + logFile = null, + sessionDir = null, + pid = null, + external = false, + password = null, + username = null, + killProcess = null, + port = null, + readProcessCommandLineImpl = null +} = {}) { + if (!cwd) { + return teardownServerSessionUnlocked({ + url, + pidFile, + logFile, + sessionDir, + pid, + external, + password, + username, + killProcess, + port, + readProcessCommandLineImpl + }); + } + + 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); + const requestedUrl = normalizeUrl(url); + const session = current && (!requestedUrl || currentUrl === requestedUrl) ? current : null; + if (session && !force) { + const pruned = pruneServerLeases(session); + const leaseChecked = ignoreCurrentProcessLease ? removeServerLeaseForPid(pruned, process.pid) : pruned; + if (hasActiveServerLeases(leaseChecked)) { + saveServerSession(cwd, leaseChecked); + 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), + password: session?.password ?? password, + username: session?.username ?? username, + killProcess, + port: session?.port ?? port, + readProcessCommandLineImpl + }; + 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 new file mode 100644 index 0000000..b0bf492 --- /dev/null +++ b/plugins/opencode/scripts/lib/state.mjs @@ -0,0 +1,424 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +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(), "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 sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function nowIso() { + return new Date().toISOString(); +} + +function defaultState() { + return { + version: STATE_VERSION, + config: { + stopReviewGate: false + }, + jobs: [] + }; +} + +export function resolveStateDir(cwd) { + const workspaceRoot = resolveWorkspaceRoot(cwd); + let canonicalWorkspaceRoot = workspaceRoot; + try { + canonicalWorkspaceRoot = fs.realpathSync.native(workspaceRoot); + } catch { + canonicalWorkspaceRoot = workspaceRoot; + } + + const slugSource = path.basename(workspaceRoot) || "workspace"; + const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace"; + const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16); + const pluginDataDir = process.env[PLUGIN_DATA_ENV]; + const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR; + return path.join(stateRoot, `${slug}-${hash}`); +} + +export function resolveStateFile(cwd) { + return path.join(resolveStateDir(cwd), STATE_FILE_NAME); +} + +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 }); +} + +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 stateLockOptions(cwd, options = {}) { + const stateDir = resolveStateDir(cwd); + fs.mkdirSync(stateDir, { recursive: true }); + + 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)}` + }; +} + +function lockAcquisitionDeadline(options) { + if (options.lockAcquireTimeoutMs == null) { + return null; + } + + 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; + } + + 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); +} + +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 { + release(); + } +} + +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)) { + return defaultState(); + } + + try { + const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8")); + return { + ...defaultState(), + ...parsed, + config: { + ...defaultState().config, + ...(parsed.config ?? {}) + }, + jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] + }; + } catch { + return defaultState(); + } +} + +export function loadState(cwd) { + return loadStateUnlocked(cwd); +} + +function pruneJobs(jobs) { + return [...jobs] + .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""))) + .slice(0, MAX_JOBS); +} + +function removeFileIfExists(filePath) { + if (filePath && fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } +} + +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, + options.mode == null ? "utf8" : { encoding: "utf8", mode: options.mode } + ); + 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 = { + version: STATE_VERSION, + config: { + ...defaultState().config, + ...(state.config ?? {}) + }, + 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)) { + continue; + } + removeJobFile(resolveJobFile(cwd, job.id)); + removeFileIfExists(job.logFile); + } + + 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) { + return withStateLock(cwd, () => { + const state = loadStateUnlocked(cwd); + const previousJobs = [...state.jobs]; + mutate(state); + return saveStateUnlocked(cwd, state, previousJobs); + }); +} + +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}`; +} + +export function upsertJob(cwd, jobPatch) { + return updateState(cwd, (state) => { + applyJobPatch(state, jobPatch); + }); +} + +export function listJobs(cwd) { + return loadState(cwd).jobs; +} + +export function setConfig(cwd, key, value) { + return updateState(cwd, (state) => { + state.config = { + ...state.config, + [key]: value + }; + }); +} + +export function getConfig(cwd) { + return loadState(cwd).config; +} + +export function writeJobFile(cwd, jobId, payload) { + ensureStateDir(cwd); + const jobFile = resolveJobFile(cwd, jobId); + atomicWriteFile(jobFile, `${JSON.stringify(payload, null, 2)}\n`); + return jobFile; +} + +export function readJobFile(jobFile) { + return JSON.parse(fs.readFileSync(jobFile, "utf8")); +} + +function removeJobFile(jobFile) { + if (fs.existsSync(jobFile)) { + fs.unlinkSync(jobFile); + } +} + +export function resolveJobLogFile(cwd, jobId) { + ensureStateDir(cwd); + return path.join(resolveJobsDir(cwd), `${jobId}.log`); +} + +export function resolveJobFile(cwd, jobId) { + ensureStateDir(cwd); + return path.join(resolveJobsDir(cwd), `${jobId}.json`); +} diff --git a/plugins/opencode/scripts/lib/tracked-jobs.mjs b/plugins/opencode/scripts/lib/tracked-jobs.mjs new file mode 100644 index 0000000..3b2ea8c --- /dev/null +++ b/plugins/opencode/scripts/lib/tracked-jobs.mjs @@ -0,0 +1,301 @@ +import fs from "node:fs"; +import process from "node:process"; + +import { + applyJobPatch, + readJobFile, + resolveJobFile, + resolveJobLogFile, + updateState, + writeJobFile +} from "./state.mjs"; + +export const SESSION_ID_ENV = "OPENCODE_COMPANION_SESSION_ID"; + +export function nowIso() { + return new Date().toISOString(); +} + +function normalizeProgressEvent(value) { + if (value && typeof value === "object" && !Array.isArray(value)) { + return { + message: String(value.message ?? "").trim(), + 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, + // 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() + }; + } + + return { + message: String(value ?? "").trim(), + phase: null, + threadId: null, + turnId: null, + serverUrl: null, + serverExternal: null, + stderrMessage: String(value ?? "").trim(), + logTitle: null, + logBody: null + }; +} + +export function appendLogLine(logFile, message) { + const normalized = String(message ?? "").trim(); + if (!logFile || !normalized) { + return; + } + fs.appendFileSync(logFile, `[${nowIso()}] ${normalized}\n`, "utf8"); +} + +export function appendLogBlock(logFile, title, body) { + if (!logFile || !body) { + return; + } + fs.appendFileSync(logFile, `\n[${nowIso()}] ${title}\n${String(body).trimEnd()}\n`, "utf8"); +} + +export function createJobLogFile(workspaceRoot, jobId, title) { + const logFile = resolveJobLogFile(workspaceRoot, jobId); + fs.writeFileSync(logFile, "", "utf8"); + if (title) { + appendLogLine(logFile, `Starting ${title}.`); + } + return logFile; +} + +export function createJobRecord(base, options = {}) { + const env = options.env ?? process.env; + const sessionId = env[options.sessionIdEnv ?? SESSION_ID_ENV]; + return { + ...base, + createdAt: nowIso(), + ...(sessionId ? { sessionId } : {}) + }; +} + +export function createJobProgressUpdater(workspaceRoot, jobId) { + let lastPhase = null; + let lastThreadId = null; + let lastTurnId = null; + let lastServerUrl = null; + let lastServerExternal = null; + + return (event) => { + const normalized = normalizeProgressEvent(event); + const patch = { id: jobId }; + let changed = false; + + if (normalized.phase && normalized.phase !== lastPhase) { + lastPhase = normalized.phase; + patch.phase = normalized.phase; + changed = true; + } + + if (normalized.threadId && normalized.threadId !== lastThreadId) { + lastThreadId = normalized.threadId; + patch.threadId = normalized.threadId; + changed = true; + } + + if (normalized.turnId && normalized.turnId !== lastTurnId) { + lastTurnId = normalized.turnId; + patch.turnId = normalized.turnId; + changed = true; + } + + if (normalized.serverUrl && normalized.serverUrl !== lastServerUrl) { + lastServerUrl = normalized.serverUrl; + patch.serverUrl = normalized.serverUrl; + changed = true; + } + + if (normalized.serverExternal != null && normalized.serverExternal !== lastServerExternal) { + lastServerExternal = normalized.serverExternal; + patch.serverExternal = normalized.serverExternal; + changed = true; + } + + if (!changed) { + return; + } + + patchIndexedJobFile(workspaceRoot, jobId, patch); + }; +} + +export function createProgressReporter({ stderr = false, logFile = null, onEvent = null } = {}) { + if (!stderr && !logFile && !onEvent) { + return null; + } + + return (eventOrMessage) => { + const event = normalizeProgressEvent(eventOrMessage); + const stderrMessage = event.stderrMessage ?? event.message; + if (stderr && stderrMessage) { + process.stderr.write(`[opencode] ${stderrMessage}\n`); + } + appendLogLine(logFile, event.message); + appendLogBlock(logFile, event.logTitle, event.logBody); + onEvent?.(event); + }; +} + +function readStoredJobOrNull(workspaceRoot, jobId) { + const jobFile = resolveJobFile(workspaceRoot, jobId); + if (!fs.existsSync(jobFile)) { + return null; + } + return readJobFile(jobFile); +} + +function currentStoredStatus(stateJob, storedJob) { + return storedJob?.status ?? stateJob?.status ?? null; +} + +export 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, + status: "running", + startedAt: nowIso(), + phase: "starting", + pid: process.pid, + logFile: options.logFile ?? job.logFile ?? null, + serverUrl: options.serverUrl ?? job.serverUrl ?? null + }; + 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(); + 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 + }; + }); + 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 completedAt = nowIso(); + 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/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 64% rename from plugins/codex/scripts/codex-companion.mjs rename to plugins/opencode/scripts/opencode-companion.mjs index 83df468..bc5f2f1 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -8,30 +8,33 @@ 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"; -import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs"; +import { binaryAvailable, terminateTaskWorkerProcessTree } from "./lib/process.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; import { + applyJobPatch, generateJobId, getConfig, listJobs, + resolveJobLogFile, setConfig, - upsertJob, + updateStateAsync, + withStateLockAsync, writeJobFile } from "./lib/state.mjs"; import { @@ -42,19 +45,20 @@ import { resolveResultJob, sortJobsNewestFirst } from "./lib/job-control.mjs"; +import { teardownServerSession } from "./lib/server-lifecycle.mjs"; import { appendLogLine, createJobLogFile, createJobProgressUpdater, createJobRecord, createProgressReporter, + isTerminalStatus, nowIso, runTrackedJob, SESSION_ID_ENV } from "./lib/tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; import { - renderNativeReviewResult, renderReviewResult, renderStoredJobResult, renderCancelReport, @@ -68,22 +72,20 @@ 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 STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; +const MODEL_ALIASES = new Map([["spark", "openai/gpt-5.3-codex-spark"]]); 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 +117,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 +180,28 @@ 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. If OpenCode becomes unavailable, the enabled gate blocks stopping; disable it with `/opencode:setup --disable-review-gate`." + ); } 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), @@ -228,7 +226,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}.`); @@ -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, @@ -249,40 +249,13 @@ 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`."); + throw new Error("OpenCode CLI is not installed or is missing headless server support. Install OpenCode, then rerun `/opencode: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."); - } - - return nativeTarget; -} - function renderStatusPayload(report, asJson) { return asJson ? report : renderStatusReport(report); } @@ -309,8 +282,7 @@ function findLatestResumableTaskJob(jobs) { (job) => job.jobClass === "task" && job.threadId && - job.status !== "queued" && - job.status !== "running" + job.status === "completed" ) ?? null ); } @@ -340,7 +312,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 +328,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 +337,15 @@ 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 prompt = buildReviewPrompt(context, focusText, { + templateName: request.promptTemplate ?? "adversarial-review", + reviewKind: reviewName + }); + const result = await runServerReview(context.repoRoot, { prompt, model: request.model, - sandbox: "read-only", outputSchema: readOutputSchema(REVIEW_SCHEMA), onProgress: request.onProgress }); @@ -428,7 +362,7 @@ async function executeReviewRun(request) { branch: context.branch, summary: context.summary }, - codex: { + opencode: { status: result.status, stderr: result.stderr, stdout: result.finalMessage, @@ -451,20 +385,22 @@ 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 + targetLabel: context.target.label, + serverUrl: result.serverUrl ?? null }; } async function executeTaskRun(request) { const workspaceRoot = resolveWorkspaceRoot(request.cwd); - ensureCodexAvailable(request.cwd); + ensureOpenCodeAvailable(request.cwd); const taskMetadata = buildTaskRunMetadata({ prompt: request.prompt, - resumeLast: request.resumeLast + resumeLast: request.resumeLast, + stopReview: request.stopReview }); let resumeThreadId = null; @@ -473,7 +409,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,15 +418,16 @@ 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, + taskSessionTitle: true, threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) }); @@ -525,27 +462,28 @@ 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 }; } 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}` }; } -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: "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 +492,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) { @@ -601,7 +539,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, @@ -609,15 +547,16 @@ function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId prompt, write, resumeLast, + stopReview, 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 +568,7 @@ async function executeTransfer(cwd, options = {}) { const result = await importExternalAgentSession(cwd, { sourcePath }); const payload = { threadId: result.threadId, - resumeCommand: `codex resume ${result.threadId}`, + resumeCommand: result.resumeCommand ?? `opencode --session ${result.threadId}`, sourcePath, sessionId: path.basename(sourcePath, ".jsonl") }; @@ -669,7 +608,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, @@ -681,21 +620,85 @@ function spawnDetachedTaskWorker(cwd, jobId) { return child; } -function enqueueBackgroundTask(cwd, job, request) { +async function markQueuedTaskSpawned(workspaceRoot, jobId, pid) { + if (pid == null) { + return; + } + + await updateStateAsync(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 + }); + }); +} + +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}`); + + await updateStateAsync(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 + }); + }); +} + +async 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); + await updateStateAsync(job.workspaceRoot, (state) => { + applyJobPatch(state, queuedRecord); + }); + + let child; + try { + child = spawnDetachedTaskWorker(cwd, job.id); + } catch (error) { + await markQueuedTaskSpawnFailed(job.workspaceRoot, job.id, logFile, error); + throw error; + } + child.once("error", (error) => { + void markQueuedTaskSpawnFailed(job.workspaceRoot, job.id, logFile, error); + }); + await markQueuedTaskSpawned(job.workspaceRoot, job.id, child.pid ?? null); return { payload: { @@ -746,6 +749,7 @@ async function handleReviewCommand(argv, config) { model: options.model, focusText, reviewName: config.reviewName, + promptTemplate: config.promptTemplate, onProgress: progress }), { json: options.json } @@ -755,14 +759,21 @@ async function handleReviewCommand(argv, config) { async function handleReview(argv) { return handleReviewCommand(argv, { reviewName: "Review", - validateRequest: validateNativeReviewRequest + 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." + ); + } + } }); } 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" } @@ -780,16 +791,24 @@ 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) { - ensureCodexAvailable(cwd); + ensureOpenCodeAvailable(cwd); requireTaskRequest(prompt, resumeLast); - const job = buildTaskJob(workspaceRoot, taskMetadata, write); + const runtime = getSessionRuntimeStatus(process.env, workspaceRoot); + const job = { + ...buildTaskJob(workspaceRoot, taskMetadata, write), + // 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, model, @@ -797,9 +816,10 @@ async function handleTask(argv) { prompt, write, resumeLast, + stopReview, jobId: job.id }); - const { payload } = enqueueBackgroundTask(cwd, job, request); + const { payload } = await enqueueBackgroundTask(cwd, job, request); outputCommandResult(payload, renderQueuedTaskLaunch(payload), options.json); return; } @@ -815,6 +835,7 @@ async function handleTask(argv) { prompt, write, resumeLast, + stopReview, jobId: job.id, onProgress: progress }), @@ -960,6 +981,97 @@ function handleTaskResumeCandidate(argv) { outputCommandResult(payload, rendered, options.json); } +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 ?? fallbackPhase, + pid: null, + threadId: job.threadId, + turnId: job.turnId, + serverUrl: job.serverUrl, + summary: job.summary, + errorMessage: job.errorMessage, + completedAt: job.completedAt + }).filter(([, value]) => value !== undefined) + ); +} + +async function syncTerminalJobIndex(workspaceRoot, job) { + await updateStateAsync(workspaceRoot, (state) => { + applyJobPatch(state, terminalJobIndexPatch(job)); + }); + return { + ...job, + pid: null + }; +} + +async function readCurrentCancelJob(workspaceRoot, job) { + let currentJob = job; + await withStateLockAsync(workspaceRoot, () => { + const stateJob = listJobs(workspaceRoot).find((candidate) => candidate.id === job.id) ?? null; + const storedJob = readStoredJob(workspaceRoot, job.id); + currentJob = { + ...job, + ...(stateJob ?? {}), + ...(storedJob ?? {}) + }; + }); + return currentJob; +} + +async function cancelJobIfStillActive(workspaceRoot, job, completedAt) { + let cancelled = false; + let nextJob = job; + + await updateStateAsync(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 (isTerminalStatus(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"], @@ -969,51 +1081,84 @@ 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 interrupt = await interruptAppServerTurn(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}` : "."}` - ); + const currentJob = await readCurrentCancelJob(workspaceRoot, job); + + if (isTerminalStatus(currentJob.status)) { + const syncedJob = await syncTerminalJobIndex(workspaceRoot, currentJob); + const payload = { + jobId: syncedJob.id, + status: syncedJob.status, + title: syncedJob.title, + cancelled: false, + turnInterruptAttempted: false, + turnInterrupted: false + }; + + outputCommandResult(payload, renderCancelReport(syncedJob), options.json); + return; } - terminateProcessTree(job.pid ?? Number.NaN); - appendLogLine(job.logFile, "Cancelled by user."); + 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, serverExternal }); const completedAt = nowIso(); - const nextJob = { - ...job, - status: "cancelled", - phase: "cancelled", - pid: null, - completedAt, - errorMessage: "Cancelled by user." - }; + const cancelResult = await 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( + cancelLogFile, + interrupt.interrupted + ? `Requested OpenCode session abort for ${threadId}.` + : `OpenCode session abort failed${interrupt.detail ? `: ${interrupt.detail}` : "."}` + ); + } + 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 { + const teardownServerUrl = interrupt.serverUrl ?? serverUrl; + // 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) { + 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( + cancelLogFile, + `OpenCode server teardown failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + appendLogLine(cancelLogFile, "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 }; @@ -1037,7 +1182,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/plugins/codex/scripts/session-lifecycle-hook.mjs b/plugins/opencode/scripts/session-lifecycle-hook.mjs similarity index 55% rename from plugins/codex/scripts/session-lifecycle-hook.mjs rename to plugins/opencode/scripts/session-lifecycle-hook.mjs index 778571e..42882e1 100644 --- a/plugins/codex/scripts/session-lifecycle-hook.mjs +++ b/plugins/opencode/scripts/session-lifecycle-hook.mjs @@ -4,21 +4,22 @@ 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"; -import { loadState, resolveStateFile, saveState } from "./lib/state.mjs"; + SERVER_URL_ENV, + loadServerSession, + teardownServerSession +} from "./lib/server-lifecycle.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 = "CODEX_COMPANION_SESSION_ID"; +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(); @@ -39,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,8 +51,15 @@ function cleanupSessionJobs(cwd, sessionId) { return; } - const state = loadState(workspaceRoot); - const removedJobs = state.jobs.filter((job) => job.sessionId === sessionId); + let removedJobs = []; + 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; } @@ -68,10 +76,6 @@ function cleanupSessionJobs(cwd, sessionId) { } } - saveState(workspaceRoot, { - ...state, - jobs: state.jobs.filter((job) => job.sessionId !== sessionId) - }); } function handleSessionStart(input) { @@ -82,35 +86,41 @@ 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, - killProcess: terminateProcessTree + try { + 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` + ); + } + const teardown = await teardownServerSession({ + cwd, + 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, + lockAcquireTimeoutMs: SESSION_END_SERVER_LOCK_ACQUIRE_TIMEOUT_MS }); - clearBrokerSession(cwd); + if (teardown?.diagnostic) { + process.stderr.write(`${teardown.diagnostic}\n`); + } + if (!teardown?.skipped) { + 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 61% rename from plugins/codex/scripts/stop-review-gate-hook.mjs rename to plugins/opencode/scripts/stop-review-gate-hook.mjs index 2346bdc..c24d341 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/opencode/scripts/stop-review-gate-hook.mjs @@ -6,17 +6,17 @@ 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"; 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; 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(); @@ -56,14 +56,14 @@ function buildStopReviewPrompt(input = {}) { }); } -function buildSetupNote(cwd) { - const availability = getCodexAvailability(cwd); +function buildUnavailableReviewerReason(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.`; + 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) { @@ -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,25 +84,33 @@ 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"); +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 = { ...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", @@ -110,20 +118,22 @@ function runStopReview(cwd, input = {}) { }); if (result.error?.code === "ETIMEDOUT") { + await cleanupStopReviewServer(cwd); 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." }; } - if (result.status !== 0) { + if (result.status !== 0 || result.signal) { + await cleanupStopReviewServer(cwd); const detail = String(result.stderr || result.stdout || "").trim(); 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,12 +144,12 @@ 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." }; } } -function main() { +async function main() { const input = readHookInput(); const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd(); const workspaceRoot = resolveWorkspaceRoot(cwd); @@ -148,7 +158,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) { @@ -156,14 +166,18 @@ 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; } - const review = runStopReview(cwd, input); + const review = await runStopReview(cwd, input); if (!review.ok) { emitDecision({ decision: "block", @@ -176,7 +190,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/plugins/codex/skills/gpt-5-4-prompting/SKILL.md b/plugins/opencode/skills/gpt-5-4-prompting/SKILL.md similarity index 51% rename from plugins/codex/skills/gpt-5-4-prompting/SKILL.md rename to plugins/opencode/skills/gpt-5-4-prompting/SKILL.md index 16669d9..7b2f7d1 100644 --- a/plugins/codex/skills/gpt-5-4-prompting/SKILL.md +++ b/plugins/opencode/skills/gpt-5-4-prompting/SKILL.md @@ -1,26 +1,26 @@ --- 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 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 `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 tighten a user request before delegating it to OpenCode. -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. 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 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. +- 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: - ``: 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,27 +28,27 @@ 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. +- 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. 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. 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/codex/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 95% rename from plugins/codex/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 10a44d6..c24023d 100644 --- a/plugins/codex/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 @@ -# Codex Prompt Anti-Patterns +# OpenCode Prompt Anti-Patterns -Avoid these when prompting Codex or GPT-5.4. +Avoid these when prompting OpenCode. ## 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/opencode-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/opencode-prompt-recipes.md index 7711de2..00085c6 100644 --- a/plugins/codex/skills/gpt-5-4-prompting/references/codex-prompt-recipes.md +++ b/plugins/opencode/skills/gpt-5-4-prompting/references/opencode-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. 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 delegation. 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..af05c54 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 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..cb34764 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,20 @@ 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"); + 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", () => { @@ -208,18 +78,10 @@ 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/); + assert.match(source, /OpenCode Companion/); }); -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/); +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..1e59dcc --- /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_PROMPT_TEXT = "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_PROMPT_TEXT} 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_PROMPT_TEXT]); + + 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/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-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/fake-opencode-fixture.mjs b/tests/fake-opencode-fixture.mjs new file mode 100644 index 0000000..5e1cd73 --- /dev/null +++ b/tests/fake-opencode-fixture.mjs @@ -0,0 +1,663 @@ +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 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"); + 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(); +const pendingQuestions = new Map(); + +function loadState() { + if (!fs.existsSync(STATE_PATH)) { + return { + serverStarts: 0, + nextSessionId: 1, + nextMessageId: 1, + sessions: [], + messages: [], + imports: [], + 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)); +} + +// 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 : "") + .join("") + .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", + callID: "call_structured_1", + state: { + status: "completed", + input: exampleForSchema(body.format.schema, "result") + } + } + ]; + } + return null; +} + +async function waitForPermission(permissionID) { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(null), 2000); + pendingPermissions.set(permissionID, (reply) => { + clearTimeout(timeout); + resolve(reply ?? null); + }); + }); +} + +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(); + 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); + + 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("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("permission.asked", { + id: permissionID, + sessionID, + 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("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 }]).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("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; + finalState.responses = finalState.responses || []; + 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 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 + // the newest assistant message returned by GET /session/:id/message. + if (failMode === "delayed-events") { + res.destroy(); + setTimeout(() => { + 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("message.updated", { sessionID, info: assistantInfo(sessionID, messageID + "_event_only", agent) }); + emit("session.idle", { sessionID }); + res.destroy(); + return; + } + if (failMode !== "recover") { + for (const part of parts) { + emit("message.part.updated", { sessionID, part, time: Date.now() }); + } + } + emit("session.idle", { sessionID }); + if (failMode === "transport" || failMode === "recover") { + res.destroy(); + return; + } + sendJson(res, { info, parts }); +} + +function handleSessionListCli() { + const state = loadState(); + for (const session of state.sessions) { + console.log([session.id, session.title || "", session.directory || ""].filter(Boolean).join(" ")); + } +} + +function handleImportCli(filePath) { + if (!filePath) { + console.error("missing import file"); + 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 = { + 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"); + 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] === "import") { + handleImportCli(args[1]); + 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); +// 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)); +// 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") { + if (Date.now() - bootStartedAt < healthDelayMs) { + sendJson(res, { ok: false }, 503); + return; + } + 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") { + 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; + } + + 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", + connection: "keep-alive" + }); + res.flushHeaders(); + 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; + } + + if (req.method === "GET" && url.pathname === "/session") { + // 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\` + // 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; + } + const state = loadState(); + const session = { + id: "ses_" + state.nextSessionId++, + directory: url.searchParams.get("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("session.created", { sessionID: session.id, info: sessionInfo(session, body.parentID) }); + sendJson(res, session); + return; + } + + const messageMatch = url.pathname.match(/^\\/session\\/([^/]+)\\/message$/); + if (req.method === "GET" && messageMatch) { + const sessionID = decodeURIComponent(messageMatch[1]); + 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; + } + 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 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); + // 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?.(body); + 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..0bc6674 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", () => { @@ -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); 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/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/opencode-server.test.mjs b/tests/opencode-server.test.mjs new file mode 100644 index 0000000..ce8a6c2 --- /dev/null +++ b/tests/opencode-server.test.mjs @@ -0,0 +1,233 @@ +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 { 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 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 = []; + 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 = () => {}; + 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"); + 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); +}); + +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/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); +}); diff --git a/tests/process.test.mjs b/tests/process.test.mjs index 80e0715..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/codex/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/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..57e93c8 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1,2259 +1,1511 @@ 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 { spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; -import { buildEnv, installFakeCodex } from "./fake-codex-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"; +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, 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"; 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"); +const STOP_HOOK = path.join(PLUGIN_ROOT, "scripts", "stop-review-gate-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; +async function canListenLocalhost() { + return new Promise((resolve) => { + const server = net.createServer(); + let settled = false; + function finish(value) { + if (settled) { + return; + } + settled = true; + resolve(value); } - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - throw new Error("Timed out waiting for condition."); + server.once("error", () => finish(false)); + server.listen(0, "127.0.0.1", () => { + server.close(() => finish(true)); + }); + }); } -test("setup reports ready when fake codex is installed and authenticated", () => { - const binDir = makeTempDir(); - installFakeCodex(binDir); +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 result = run("node", [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: buildEnv(binDir) +function buildTestEnv(binDir, extra = {}) { + return buildEnv(binDir, { + CLAUDE_PLUGIN_DATA: makeTempDir("opencode-plugin-data-"), + OPENCODE_COMPANION_SESSION_ID: "sess-current", + ...extra }); +} - 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"); -}); +function cleanupServer(cwd, env) { + run("node", [SESSION_HOOK, "SessionEnd"], { + cwd, + env, + input: JSON.stringify({ cwd, session_id: env.OPENCODE_COMPANION_SESSION_ID ?? "sess-current" }) + }); +} -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")); +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]; + } + } - const result = run("node", [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: { - ...process.env, - PATH: binDir + 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 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 ?? ""); }); +} - 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); -}); +function jsonResponse(value, status = 200) { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" } + }); +} -test("setup trusts app-server API key auth even when login status alone would fail", () => { - const binDir = makeTempDir(); - installFakeCodex(binDir, "api-key-account-only"); +// 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 result = run("node", [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: buildEnv(binDir) + 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(); }); - 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\)/); -}); + return { child, url }; +} -test("setup is ready when the active provider does not require OpenAI login", () => { - const binDir = makeTempDir(); - installFakeCodex(binDir, "provider-no-auth"); +// 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 +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); +` + ); + if (process.platform === "win32") { + fs.writeFileSync(path.join(binDir, "opencode.cmd"), `@echo off\r\nnode "%~dp0opencode" %*\r\n`, { + encoding: "utf8" + }); + } +} - const result = run("node", [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: buildEnv(binDir) - }); +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 = {}) => { + 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); + }; - 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); + 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" } + ]); + + // 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/" + }, + () => interruptServerTurn(workspace, { threadId: null, serverUrl: "http://opencode.test" }) + ); + assert.equal(missingThreadResult.attempted, false); + 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; + } }); -test("setup treats custom providers with app-server-ready config as ready", () => { - const binDir = makeTempDir(); - installFakeCodex(binDir, "env-key-provider"); +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 }) + }); - const result = run("node", [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: buildEnv(binDir) + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /OpenCode session job cleanup failed/); + assert.equal(loadServerSession(workspace), null); }); - - 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"); +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", [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: buildEnv(binDir) - }); + 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); - 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/); + assert.equal(result.status, 0, result.stderr); + assert.equal(loadServerSession(workspace).url, "http://127.0.0.1:1"); + }); }); -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"); +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" + ); - const result = run("node", [SCRIPT, "review"], { - cwd: repo, - env: buildEnv(binDir) + 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 }); + } }); - - 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(); +test("stop review gate blocks when the enabled OpenCode reviewer is unavailable", async () => { + const workspace = 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 pluginDataDir = makeTempDir("opencode-plugin-data-"); + const env = { + ...process.env, + PATH: binDir, + CLAUDE_PLUGIN_DATA: pluginDataDir, + OPENCODE_COMPANION_SESSION_ID: "sess-current" + }; - const result = run("node", [SCRIPT, "task", "check auth preflight"], { - cwd: repo, - env: buildEnv(binDir) - }); + 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); - assert.match(result.stdout, /Handled the requested task/); + 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("task runs without auth preflight so Codex can refresh an expired session", () => { - const repo = makeTempDir(); +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(); - 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) + installFakeOpencode(binDir); + const env = buildTestEnv(binDir, { + FAKE_OPENCODE_MESSAGE_FAIL: "empty-recovery" }); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Handled the requested task/); + 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.match(readFakeState(binDir).lastMessage.prompt, /Run a stop-gate review of the previous Claude turn/); + assert.equal(loadServerSession(workspace), null); + }); }); -test("transfer delegates the current Claude session directly to native import", () => { - const home = makeTempDir(); - const repo = path.join(home, "repo"); +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(); - 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); + 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); - 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 + 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); } }); +}); - 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("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.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.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(); + } }); -test("transfer reports an actionable upgrade error when native import is unsupported", () => { - const home = makeTempDir(); - const repo = path.join(home, "repo"); +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(); - 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); + 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(); + 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( - sourcePath, - `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Continue this work." } })}\n`, + 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" ); + installStubOpencodeBinary(binDir); + + 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); + + // --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, + 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" + } + }); - const result = run("node", [SCRIPT, "transfer", "--source", sourcePath, "--json"], { - cwd: repo, - env: { - ...buildEnv(binDir), - HOME: home, - CODEX_HOME: path.join(home, ".codex") - } + 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); }); - - 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" - ); +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 }); - const result = run("node", [SCRIPT, "transfer", "--source", sourcePath], { - cwd: repo, - env: { - ...buildEnv(binDir), - HOME: home, - CODEX_HOME: path.join(home, ".codex") + 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 === "DELETE" && url.pathname === `/session/${createdSessionId}`) { + return jsonResponse({ ok: true }); } - }); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /did not record an imported thread/); -}); + return jsonResponse({ error: "not found" }, 404); + }; -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" - ); + return { + calls, + restore: () => { + globalThis.fetch = previousFetch; + } + }; +} - const result = run("node", [SCRIPT, "transfer", "--source", sourcePath], { - cwd: repo, - env: { ...buildEnv(binDir), HOME: home } +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(); + installFakeOpencode(binDir); + const env = buildTestEnv(binDir); + + try { + const result = run("node", [SCRIPT, "setup", "--json"], { + cwd: repo, + env + }); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /only from .*\.claude.*projects/); + 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("task reports the actual Codex auth error when the run is rejected", () => { +test("foreground task runs through opencode serve and stores a visible session", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); - installFakeCodex(binDir, "auth-run-fails"); + 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 result = run("node", [SCRIPT, "task", "check failed auth"], { - cwd: repo, - env: buildEnv(binDir) - }); + try { + const result = run("node", [SCRIPT, "task", "--json", "check the fixture"], { + cwd: repo, + env + }); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /authentication expired; run codex login/); + 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("review accepts the quoted raw argument style for built-in base-branch review", () => { +test("write task denies gated permission asks and keeps stock agent guards (issue #26)", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); - installFakeCodex(binDir); + installFakeOpencode(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 env = buildTestEnv(binDir); - const result = run("node", [SCRIPT, "review", "--base main"], { - cwd: repo, - env: buildEnv(binDir) - }); + try { + const result = run("node", [SCRIPT, "task", "--json", "--write", "create a small file"], { + cwd: repo, + env + }); - assert.equal(result.status, 0); - assert.match(result.stdout, /Reviewed changes against main/); - assert.match(result.stdout, /No material issues found/); + 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, "reject"); + assert.equal(fakeState.permissions[0].body.action, undefined); + } finally { + cleanupServer(repo, env); + } }); -test("adversarial review renders structured findings over app-server turn/start", () => { +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(); - installFakeCodex(binDir); + installFakeOpencode(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) - }); + 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"); + } - assert.equal(result.status, 0); - assert.match(result.stdout, /Missing empty-state guard/); + 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("adversarial review accepts the same base-branch targeting as review", () => { +test("task forwards spark model alias and effort as OpenCode variant", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); - installFakeCodex(binDir); + installFakeOpencode(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 env = buildTestEnv(binDir); - const result = run("node", [SCRIPT, "adversarial-review", "--base", "main"], { - cwd: repo, - env: buildEnv(binDir) - }); + try { + const result = run("node", [SCRIPT, "task", "--json", "--model", "spark", "--effort", "high", "check model"], { + cwd: repo, + env + }); - 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/); + 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); + } }); -test("adversarial review asks Codex to inspect larger diffs itself", () => { +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.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 env = buildTestEnv(binDir); - const result = run("node", [SCRIPT, "adversarial-review"], { - cwd: repo, - env: buildEnv(binDir) - }); + 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 }); + + assert.equal(first.status, 0, first.stderr); + assert.equal(second.status, 0, second.stderr); - 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]/); + const fakeState = readFakeState(binDir); + assert.equal(fakeState.serverStarts, 1); + assert.equal(fakeState.sessions.length, 2); + } finally { + cleanupServer(repo, env); + } }); -test("review includes reasoning output when the app server returns it", () => { +test("concurrent commands share one opencode serve startup", { skip: LOCAL_LISTEN_SKIP }, async () => { const repo = makeTempDir(); const binDir = makeTempDir(); - installFakeCodex(binDir, "with-reasoning"); + installFakeOpencode(binDir); initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + 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"), "hello again\n"); + fs.writeFileSync(path.join(repo, "README.md"), "after\n"); + const env = buildTestEnv(binDir); - const result = run("node", [SCRIPT, "review"], { - cwd: repo, - env: buildEnv(binDir) - }); + try { + const result = run("node", [SCRIPT, "review", "--json"], { + cwd: repo, + env + }); - 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); + 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); + } }); -test("review logs reasoning summaries and review output to the job log", () => { +test("task succeeds when the message transport drops after session.idle (issue #2)", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); - installFakeCodex(binDir, "with-reasoning"); + 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"); + // 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" }); - const result = run("node", [SCRIPT, "review"], { - cwd: repo, - env: buildEnv(binDir) - }); + try { + const result = run("node", [SCRIPT, "task", "--json", "long running task"], { + cwd: repo, + env + }); - 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\./); + 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 --resume-last resumes the latest persisted task thread", () => { +test("task succeeds when the message transport drops before completion events (issue #2)", { 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 }); + // 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" }); - 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) - }); + try { + const result = run("node", [SCRIPT, "task", "--json", "long running task"], { + cwd: repo, + env + }); - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Resumed the prior run.\nFollow-up prompt accepted.\n"); + 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-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" - } +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" }); - 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"); + 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 --resume-last does not resume a task from another Claude session", () => { +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(); - const statePath = 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 }); - - 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); + // 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" }); - 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\./); + try { + const result = run("node", [SCRIPT, "task", "--json", "long running task"], { + cwd: repo, + env + }); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); - assert.equal(fakeState.lastTurnStart.prompt, "initial task"); + 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 --resume-last ignores running tasks from other Claude sessions", () => { +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(); - 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 }); - - const stateDir = resolveStateDir(repo); - fs.mkdirSync(path.join(stateDir, "jobs"), { recursive: true }); fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( + path.join(binDir, "fake-opencode-state.json"), + JSON.stringify( { - version: 1, - config: { stopReviewGate: false }, - jobs: [ + serverStarts: 0, + nextSessionId: 2, + nextMessageId: 2, + sessions: [ { - 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" + id: "ses_existing", + directory: fs.realpathSync.native(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 - )}\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 + const env = buildTestEnv(binDir, { + FAKE_OPENCODE_MESSAGE_FAIL: "snapshot-fails-empty-recovery", + OPENCODE_COMPANION_SESSION_ID: "" }); - 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 - }) - }); + try { + const result = run("node", [SCRIPT, "task", "--json", "--resume-last", "follow up with no output"], { + cwd: repo, + env + }); - 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` - ); + 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("write task output focuses on the Codex result without generic follow-up hints", () => { +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(); - 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 }); + const env = buildTestEnv(binDir, { FAKE_OPENCODE_MESSAGE_FAIL: "mismatched-recover" }); - const result = run("node", [SCRIPT, "task", "--write", "fix the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); + try { + const result = run("node", [SCRIPT, "task", "--json", "long running task"], { + cwd: repo, + env + }); - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); + 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/); + } finally { + cleanupServer(repo, env); + } }); -test("task --resume acts like --resume-last without leaking the flag into the prompt", () => { +test("task rejects headless question asks instead of stalling (issue #28)", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); - const statePath = 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 }); - - const firstRun = run("node", [SCRIPT, "task", "initial task"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(firstRun.status, 0, firstRun.stderr); + const env = buildTestEnv(binDir, { FAKE_OPENCODE_ASK_QUESTION: "1" }); - 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 }); + try { + const result = run("node", [SCRIPT, "task", "--json", "task that provokes a question"], { + cwd: repo, + env + }); - const result = run("node", [SCRIPT, "task", "--fresh", "diagnose the flaky test"], { - cwd: repo, - env: buildEnv(binDir) - }); + 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.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastTurnStart.prompt, "diagnose the flaky test"); + const fakeState = readFakeState(binDir); + assert.equal(fakeState.questionRejections.length, 1); + assert.match(fakeState.questionRejections[0].requestID, /^que_/); + } finally { + cleanupServer(repo, env); + } }); -test("task forwards model selection and reasoning effort to app-server turn/start", () => { +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(); - const statePath = 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 }); + // 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" }); - const result = run("node", [SCRIPT, "task", "--model", "spark", "--effort", "low", "diagnose the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); + try { + const result = run("node", [SCRIPT, "task", "--json", "stream this answer"], { + cwd: repo, + env + }); - 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"); + 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 logs reasoning summaries and assistant messages to the job log", () => { +test("subagent session output does not pollute the main final message (issue #28)", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); - installFakeCodex(binDir, "with-reasoning"); + 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, { FAKE_OPENCODE_SUBAGENT: "1" }); - const result = run("node", [SCRIPT, "task", "investigate the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); + try { + const result = run("node", [SCRIPT, "task", "--json", "task with a subagent"], { + cwd: repo, + env + }); - 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/); + 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 logs subagent reasoning and messages with a subagent prefix", () => { +test("task fails when completion has no recoverable current-turn message (issue #2)", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); - installFakeCodex(binDir, "with-subagent"); + 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 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\./ + fs.writeFileSync( + path.join(binDir, "fake-opencode-state.json"), + JSON.stringify( + { + serverStarts: 0, + nextSessionId: 2, + nextMessageId: 2, + sessions: [ + { + id: "ses_existing", + // realpath: makeTempDir returns a symlinked /var path on macOS, but + // findLatestTaskThread matches against the realpath'd workspace root. + directory: fs.realpathSync.native(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 + ) ); -}); - -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) + const env = buildTestEnv(binDir, { + FAKE_OPENCODE_MESSAGE_FAIL: "empty-recovery", + OPENCODE_COMPANION_SESSION_ID: "" }); - 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) - }); + try { + const result = run("node", [SCRIPT, "task", "--json", "--resume-last", "follow up with no output"], { + cwd: repo, + env + }); - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); + 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 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 }); +test("runServerTurn deletes 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: [] + }); + }); - const result = run("node", [SCRIPT, "task", "challenge the current design"], { - cwd: repo, - env: buildEnv(binDir) - }); + await withProcessEnv(runServerTurnEnv(createdEnv), async () => { + await assert.rejects( + runServerTurn(createdRepo, { prompt: "fail during event open" }), + /OpenCode GET \/event failed with HTTP 500/ + ); + }); - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); -}); + assert.equal(createdFetch.calls.filter((call) => call.method === "POST" && call.pathname === "/session").length, 1); + assert.equal( + createdFetch.calls.filter((call) => call.method === "DELETE" && call.pathname === "/session/ses_created").length, + 1 + ); + } finally { + createdFetch.restore(); + } -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 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: [] + }); + }); - const env = buildEnv(binDir); - const review = run("node", [SCRIPT, "review"], { - cwd: repo, - env - }); - assert.equal(review.status, 0, review.stderr); + 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/ + ); + }); - if (!loadBrokerSession(repo)) { - return; + assert.equal(resumedFetch.calls.filter((call) => call.method === "POST" && call.pathname === "/session").length, 0); + assert.equal( + resumedFetch.calls.filter((call) => call.method === "DELETE" && call.pathname.startsWith("/session/")).length, + 0 + ); + } finally { + resumedFetch.restore(); } - - 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 () => { +test("adversarial-review prompt uses the adversarial-review.md template", { skip: LOCAL_LISTEN_SKIP }, () => { const repo = makeTempDir(); const binDir = makeTempDir(); - installFakeCodex(binDir, "slow-task"); + installFakeOpencode(binDir); initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + 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); - 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"], - { + try { + const result = run("node", [SCRIPT, "adversarial-review", "--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) + env }); - 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/); + 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 rejects focus text because it is native-review only", () => { +test("review prompt uses the neutral review.md template", { 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"); + 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"), "hello again\n"); + fs.writeFileSync(path.join(repo, "README.md"), "after\n"); + const env = buildTestEnv(binDir); - const result = run("node", [SCRIPT, "review", "--scope working-tree focus on auth"], { - cwd: repo, - env: buildEnv(binDir) - }); + try { + const result = run("node", [SCRIPT, "review", "--json"], { + cwd: repo, + env + }); - 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); + 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 staged-only scope because it is native-review only", () => { +test("review rejects positional focus text with a clear error", () => { const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + fs.writeFileSync(path.join(repo, "README.md"), "test\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"], { + const result = run("node", [SCRIPT, "review", "check auth paths"], { cwd: repo, - env: buildEnv(binDir) + env: process.env }); - 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); + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, /does not accept positional focus text/); + assert.match(result.stderr, /adversarial-review/); }); -test("adversarial review rejects staged-only scope to match review target selection", () => { +test("setup reports not ready when no provider is connected", { skip: LOCAL_LISTEN_SKIP }, () => { 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) + installFakeOpencode(binDir); + const env = buildTestEnv(binDir, { + FAKE_OPENCODE_NO_PROVIDER: "1" }); - 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); + 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("review accepts --background while still running as a tracked review job", () => { +test("setup reports loggedIn false when /provider endpoint fails", { skip: LOCAL_LISTEN_SKIP }, () => { 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) + installFakeOpencode(binDir); + const env = buildTestEnv(binDir, { + FAKE_OPENCODE_PROVIDER_FAIL: "1" }); - 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" - ); + try { + const result = run("node", [SCRIPT, "setup", "--json"], { + cwd: repo, + env + }); - 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. - } - } - }); - - 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 fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - assert.deepEqual(fakeState.lastInterrupt, { - threadId: runningJob.threadId, - turnId: runningJob.turnId - }); - - 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("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", () => { - 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); - - const blocked = run("node", [STOP_HOOK], { - cwd: repo, - env: buildEnv(binDir), - input: JSON.stringify({ - 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 }) - }); - - 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); -}); - -test("stop hook runs the actual task when auth status looks stale", () => { - 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 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 }) - }); - - 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); -}); - -test("commands lazily start and reuse one shared app-server after first use", async () => { - 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 }); - 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); - - const brokerSession = loadBrokerSession(repo); - if (!brokerSession) { - return; + 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); } - - 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", () => { - 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 }); - 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); - - const brokerSession = loadBrokerSession(repo); - if (!brokerSession) { - return; - } - - 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", () => { - 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 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(); - - saveBrokerSession(targetWorkspace, { - endpoint: "unix:/tmp/fake-broker.sock" - }); - - 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/); - - 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"); }); diff --git a/tests/server-lifecycle.test.mjs b/tests/server-lifecycle.test.mjs new file mode 100644 index 0000000..0e85419 --- /dev/null +++ b/tests/server-lifecycle.test.mjs @@ -0,0 +1,717 @@ +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"; +import { commandLineLooksLikeOpencodeServe } from "../plugins/opencode/scripts/lib/process.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)); + } + } +); + +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(); + 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(); + 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; + } + } +}); + +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; + }, + readProcessCommandLineImpl: () => "opencode serve --hostname 127.0.0.1 --port 1" + }); + + 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; + } + } +}); + +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; + }, + readProcessCommandLineImpl: () => "opencode serve --hostname 127.0.0.1 --port 1" + }); + + 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("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(); + 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-races.test.mjs b/tests/state-races.test.mjs new file mode 100644 index 0000000..bb9e526 --- /dev/null +++ b/tests/state-races.test.mjs @@ -0,0 +1,368 @@ +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 { 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, + 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"); + }); +}); + +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(); + + 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"); + }); +}); + +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\./); + }); +}); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 0f8f57c..9abd1aa 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -5,15 +5,39 @@ 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, + withStateLock, + withStateLockAsync +} 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 +65,128 @@ 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 }); - - 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 - }; + 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 + }; + }); + + fs.writeFileSync( + stateFile, + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs + }, + null, + 2 + )}\n`, + "utf8" + ); + + 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); + + 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() + ); }); +}); +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( - stateFile, - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs - }, - null, - 2 - )}\n`, + path.join(lockDir, "owner.json"), + `${JSON.stringify({ pid: process.pid, token: "other-holder", createdAt: new Date().toISOString() })}\n`, "utf8" ); - 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 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); - assert.equal(fs.existsSync(retainedJobFile), true); - assert.equal(fs.existsSync(retainedLogFile), true); + 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; + } + } +}); - 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() +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; + } + } }); diff --git a/tests/transfer.test.mjs b/tests/transfer.test.mjs new file mode 100644 index 0000000..3bfb7ca --- /dev/null +++ b/tests/transfer.test.mjs @@ -0,0 +1,244 @@ +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 { importExternalAgentSession } from "../plugins/opencode/scripts/lib/opencode.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: "imported-transcript", + providerID: "claude-code", + 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("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("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-"); + 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, { + // os.homedir() reads USERPROFILE on Windows and HOME elsewhere. + HOME: home, + USERPROFILE: 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" + ]); +}); + +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 }); + } + } +}); 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(); + } + } +); 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" - ] -}