diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f910534..b4e6d84 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,10 +12,11 @@ jobs: id-token: write steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 24 registry-url: https://registry.npmjs.org + - run: npm install -g npm@latest - run: npm install - run: npm run build - name: Publish package diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fb2d297 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,243 @@ +# AGENTS.md + +## Project Shape + +- This is an npm package that exposes an opencode provider by wrapping the Claude Code CLI (`claude`), not the Anthropic HTTP API directly. +- Package entrypoint is `src/index.ts`; runtime provider behavior lives mostly in `src/claude-code-language-model.ts`. +- `src/message-builder.ts` owns AI-SDK prompt → Claude CLI stream-json message conversion, including `/compact` transcript rendering. +- `src/session-manager.ts` owns Claude CLI process reuse, session ids, LRU eviction, and CLI arg construction. +- `src/cli-version.ts` gates optional CLI flags. Do not pass newly-added Claude CLI flags unconditionally. +- `src/claude-session-bun.ts` + `src/claude-session-wrapper.ts` own the experimental interactive transport (from PR #10): the interactive `claude` TUI under Bun's native PTY, prompts typed via bracketed paste, output tailed from the session JSONL transcript. Opt-in via `interactive: true` / `CLAUDE_CODE_INTERACTIVE_TRANSPORT=1`; headless `--print` stays the default. +- Build output is `dist/`, is gitignored, and is rebuilt by CI. Do not commit `dist/`. + +## Commands + +- Keep `skills/claude-code-plugin/SKILL.md` current in the same change whenever options, defaults, env vars, model ids, proxy tools, agent configuration or troubleshooting behavior change. This bundled skill is the agent-facing configuration reference, not a generated file. `test-configure-skill.ts` checks identifier coverage against source; reviewers must still verify defaults, precedence, safety and recipes. `test-skill-bridge.ts` covers native delivery and opencode skill-path registration. Keep `skills` in the npm package files list and verify built-package discovery when changing layout. + +- Typecheck: `npm run typecheck` (`tsc --noEmit`). +- Test suite: `npm test`. The script enumerates test files explicitly — when adding a `test-*.ts` file you MUST add it to `package.json`'s `test` script or it silently never runs (this had drifted: `test-config-models.ts` and `test-ask-user-question.ts` were missing until 2026-06-10). +- Single focused test file: `npx tsx --test test-get-claude-user-message.ts` (replace file as needed). +- Build: `npm run build` (`tsup`, emits ESM + d.ts to `dist/`). +- Before release, run: `npm run typecheck && npm test && npm run build`. +- There is no lockfile. CI uses Node 24 and runs `npm install`, then `npm run build`. + +## Release Workflow + +- Never run `npm publish` manually. Tag push triggers `.github/workflows/publish.yml`, which publishes to npm. +- Publishing uses npm **trusted publishing (OIDC)**, not a token (since v0.6.2). The `publish` job has `id-token: write`, upgrades npm (`npm install -g npm@latest`; OIDC needs npm >= 11.5.1), and runs `npm publish --access public` with **no `NODE_AUTH_TOKEN`**. The trusted publisher is configured on npmjs.com and must match repo `khalilgharbaoui/opencode-claude-code-plugin` + workflow filename `publish.yml`. The legacy `NPM_TOKEN` secret is unused (it expired ~2026-05-25, which silently failed the 0.6.0/0.6.1 publishes with `npm error 404` on PUT until the OIDC switch). If a publish fails on auth, check the trusted-publisher config, not a token. +- Release flow: commit code/docs, then `npm version patch` (or minor/major), then `git push origin master --follow-tags`. +- `npm version` creates the version commit and annotated `v*` tag. Prior release commit/tag messages are `v0.x.y`; keep that style. +- After pushing a release tag, confirm the publish workflow with `gh run list --repo khalilgharbaoui/opencode-claude-code-plugin --limit 3`. +- GitHub Releases lapsed after v0.9.2 (tag pushes publish to npm on their own, so notes are optional). They were resumed for **v0.13.2** because it carried a security fix and users need to know why to upgrade. Write notes for anything security-relevant or behaviour-changing; a routine patch does not need them. +- A freshly published version will NOT appear in a local opencode until its frozen plugin cache is cleared. opencode resolves the `@latest` spec once and freezes the concrete version into `~/.cache/opencode/packages/@khalilgharbaoui/opencode-claude-code-plugin@latest/` (its `package.json` + `package-lock.json`); a plain restart never re-resolves the tag. To pick up a new release: `rm -rf ~/.cache/opencode/packages/@khalilgharbaoui/opencode-claude-code-plugin@latest` then fully relaunch opencode. Confirmed 2026-05-29: the cache was frozen at 0.5.1, which is why 0.6.2 (Opus 4.8) did not show in the model picker after a restart until the dir was removed. +- Do not add a Claude co-author trailer to commits. +- Keep `README.md` updated when adding public options, env vars, required CLI versions, or behavior users can observe. + +## High-Signal Runtime Gotchas + +### Question Diagnosis Correction (2026-09-06) + +This correction supersedes the historical claims below that native-provider failures proved an upstream TUI rendering regression, or that enabling the question bridges must wait for PR #36603. The user's clean Omarchy installation reportedly works. On this Mac, global `~/.config/opencode/plugins/notify.ts` awaited `notifyQuestionIfNeeded` inside `tool.execute.before`; its backend awaited `alerter` exit, which defaults to waiting for dismissal indefinitely. A real question notification child started at exactly the failed question's timestamp and remained alive. opencode awaits before-hooks before calling the native question tool, so the request/form could not exist yet. Native providers still run global hooks and were not a plugin-free control. A no-inference test importing the real notification plugin failed with a pending simulated alerter and passed after the hook dispatched notification delivery without awaiting it, with rejection handling and deduplication preserved. Local regression test: `~/.config/opencode/tests/notify-question.test.ts`. For future failures, `GET /question` on the same server/workspace separates pre-tool blocking (absent request) from event/session/rendering issues (present request). #36604 remains open for detach/reattach; #36603 is closed unmerged, not a fix to wait on. Do not remove the user's no-question-tool preference without their approval. + +**Both round-trips are now verified, so every "blocked upstream, leave it off" line in the bullets below is history, not current advice.** After the restart, the maintainer authorized one native `question` call in the Mac TUI and answered it: the form rendered and the answer came back. The `"Question"` proxy was then verified end to end on plugin **0.18.0**, Claude Code **2.1.258**, opencode **1.18.29** through a headless `opencode serve` with a scratch config (`proxyTools: ["Question"]`, `permission.question: "allow"`, account `appical`, haiku): `plugin.log` shows `question proxy version gate {"opencodeHasQuestion":true,"kept":true}` then `proxy-mcp tool call received {"toolName":"question"}`, `GET /question` listed one pending request for the session, `POST /question/{id}/reply` with a random token completed the single `question` tool part, and Claude's final answer was that token, which it could only have obtained through the tool result. Probe script: `/var/folders/.../opencode/verify-provider-question.mjs` (scratch, not in the repo). The headless probes answered over HTTP, not by clicking, so the last join was closed separately: with `"Question"` added to the maintainer's own `proxyTools` and opencode relaunched, a two-question `mcp__opencode_proxy__question` call rendered as a real form in his TUI and both answers came back into the turn. Model to form to answer to model, in the actual terminal. `"Question"` stays out of `DEFAULT_PROXY_TOOL_NAMES` anyway: enabling it disables Claude's `AskUserQuestion` via `--disallowedTools`, and that swap is the operator's call, not a silent upgrade. `planModeQuestion` is still **unverified**, for a different reason than before: its delivery surface now works, but nobody has driven an actual `ExitPlanMode` approval through it. That is the test to run before promoting it. + +- The `chat.params` hook tags opencode's active agent (`default`, `compaction`, `title`, etc.) into provider options. Write to `output.options` at the top level. opencode wraps that bag under the provider id later. Do not pre-nest under `output.options[providerID]`, or the model sees `providerOptions[id][id]`. +- Reasoning effort is a spawn-time env var (`CLAUDE_CODE_EFFORT_LEVEL`, set in `claudeSpawnEnv` and the interactive session's env), not message text. Claude Code 2.1.x only recognises the `ultrathink` keyword, so the old per-level keywords were silently inert. Because the var is fixed per process, effort is part of the session key (`::effort=`); a respawn reads it back from `ActiveProcess.effort`. Compaction spawns never carry it. +- **Per-agent model override (`src/agent-models.ts`) swaps the model NAME only, never the provider.** The account lives in the provider (`claude-code-` → `CLAUDE_CONFIG_DIR`) and in the `@` marker on the id, so the override reattaches that marker: `claude-fable-5-1@work` becomes `claude-opus-5@work`. Dropping the marker would silently move the work to the default account. Three guards keep it from surprising anyone, and none of them are optional: `defaultSubagentModel` is **unset by default**, so an upgrade changes no existing behaviour; only agents the plugin discovered (`config.agent` entries, markdown in `agents/`) are eligible, so opencode's built-ins stay out of the path or `explore` quietly becomes an Opus agent; and an unknown model id is refused rather than spawned. The effective model is part of the session key in BOTH `doGenerate` and `doStream`, otherwise an overridden subagent shares a `claude` process with its caller. The plugin defines **no agents of its own** on purpose: a provider plugin injecting opinionated agents (with their own permission blocks) into every user's `@` menu is not its job. +- **An agent's declared `reasoningEffort` beats the effort the call arrived with** (`resolveAgentEffort`, applied in both `doGenerate` and `doStream`). opencode resolves one effort per session and a subagent inherits it, which fails in the expensive direction: a parent on `max` silently dispatches every worker at `max`, so a four-fix mechanical lane runs at the costliest setting there is and eats a weekly Opus cap. Declaring nothing keeps the inherited value, an unknown level is refused rather than forwarded (the CLI rejects it), and compaction is exempt because its summary needs the whole budget. Effort is part of the session key, so changing it respawns rather than reusing a process started at the old level. +- `/compact` must not fall through the no-tools title stub. It is detected via `opencodeAgent === "compaction"`, runs through `doStream`, uses a fresh short-lived Claude CLI process, skips MCP/proxy/tool wiring, and defaults to `claude-haiku-4-5`. +- Compaction model precedence is: `CLAUDE_CODE_COMPACTION_MODEL` env var, then `compactionModel` provider option, then default `claude-haiku-4-5`. +- Opus 4.7 omits thinking summaries by default. The plugin asks for summaries with `--thinking-display summarized`, but only when `src/cli-version.ts` confirms Claude Code CLI >= 2.1.142. Older CLIs must skip that flag instead of crashing. +- Respect user Claude Code env vars. Do not delete or override `CLAUDE_CODE_DISABLE_THINKING`, `CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING`, or explicit `CLAUDE_CODE_SHOW_THINKING_SUMMARIES` values. The same rule governs the CLI hygiene vars below. +- **Every spawned child gets `DISABLE_AUTOUPDATER=1` and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`, and the reason is the version cache, not tidiness.** `detectCliVersion` resolves once per `cliPath` and caches that answer for the life of the opencode process, and three flag gates read it: `--thinking-display summarized`, `--plugin-dir`, and fast mode via `--settings`. If the CLI autoupdates underneath a long-running opencode, the cached version stops describing the binary actually being spawned, so a gated flag can be passed to a CLI that rejects it or withheld from one that supports it; swapping the binary mid-session is a plain correctness hazard besides. Both names were **read out of the 2.1.263 Mach-O** (`rg -a`, the same technique the CLI stream-event gotcha records), not assumed: `DISABLE_AUTOUPDATER` is parsed by `hQ()` as an update blocker, and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` is read by `I()` as `"essential-traffic"` **and** returned by `dxe()` as a second, independent update blocker, so the two overlap on purpose. Anthropic's own runner sets `DISABLE_AUTOUPDATER:"1"` on the children it spawns, which is the same use this is. `cliHygieneEnv` lives in `src/cli-version.ts` rather than `session-manager.ts` because it exists to protect that module's cache, and because `src/claude-session-bun.ts` needs it too: `cli-version.ts` pulls in only `logger.ts`, so the interactive module stays cheap to import (it is also driven directly by `e2e-claude-session-bun.ts`). It **fills gaps only** and never overwrites, so `DISABLE_AUTOUPDATER=0` in the user's shell keeps the autoupdater and an empty string stays empty, which both vars read as off; that is the escape hatch, and it is why this needs **no provider option**. The interactive env moved out of the `Bun.spawn` literal into `interactiveSpawnEnv` purely so a test can reach it without a PTY. Tests: `test-spawn-env.ts` (7 cases; 4 fail with `cliHygieneEnv` stubbed to `{}`, the other 3 assert the constant or the absence of an override). +- Reasoning stream parts are only started after the first non-empty `thinking_delta`. This prevents empty Thinking rows when the CLI opens a thinking block but streams no text. +- opencode's own reasoning features (e.g. v1.17.0 "Added Claude Fable reasoning support", vLLM interleaved `reasoning` field) live in opencode's **native** Anthropic/vLLM runtime, which this plugin deliberately bypasses by routing through the `claude` CLI. There is nothing to "switch to" — the plugin implements reasoning itself (reasoning variants → thinking keyword + `--thinking`/`--thinking-display` flags → `thinking_delta` forwarding), and any model defined with `reasoning: true` (including `claude-fable-5`) inherits the full path automatically. Do not re-investigate adopting opencode's native reasoning; it would mean abandoning the CLI wrapper. +- Model display names carry a list-price multiplier as a `(N×)` suffix (`src/models.ts` `defineModel`, via the `multiplier` field): haiku 1×, sonnet 3×, opus 5×, fable 10×, mythos 10×. These are exact ratios of published per-token price vs Haiku (input and output ratios coincide), so Fable/Mythos = 2× Opus. opencode has no native multiplier field, so the suffix is the only way it surfaces in the picker; it's display-only and model resolution still keys off `id`. `test-config-models.ts` asserts the suffixed names — update both if the format changes. +- **Fast mode's `-fast` model ids are OURS, not Anthropic's.** `claude-opus-5-fast` / `claude-opus-4-8-fast` are registry entries this plugin invents; `src/models.ts` `parseModelId` strips the marker before `--model` and turns it into `--settings '{"fastMode":true}'`. Do not "fix" this by passing the id through: Anthropic's real `-fast` names are retired (`claude-opus-4-6-fast` silently falls back to standard, `claude-opus-4-7-fast` hard-errors). There is no `--fast` flag. `--settings` is the only headless opt-in because the CLI's SDK gate reads the **flagSettings** layer specifically (`if (le() && Ui() && !flagSettings.fastMode) return "sdk_opt_in_required"`), so a `fastMode` in the user's own settings.json does nothing for a `--print` run. Only Opus 4.8 / Opus 5 qualify (the CLI matches on the name containing `opus-4-8` / `opus-5`); registering a fast entry for any other model would show a 10× price on a standard-speed turn. Verified live against 2.1.245 on 2026-08-30. `--settings` takes one value, so the interactive wrapper merges `permissions` and `fastMode` into a single payload rather than pushing the flag twice. +- **Fast mode fails soft, so the downgrade must warn, not notice.** An ineligible account returns `fast_mode_state: "off"` with a reason and runs at standard speed with no error, while the picker still advertises 10×. `reportFastModeState` uses `log.warn` deliberately: in `src/logger.ts` only warn/error are alwaysStderr, so a NOTICE would be invisible outside debug mode and defeat the point. Deduped per reason per process, because the blockers are account-level and would otherwise fire on every respawn. Maintainer's own account reports `extra_usage_disabled` (fix: `/usage-credits`), so the on-state path is **unverified in production**: only the opt-in plumbing and the downgrade path have live evidence. +- Sonnet 5 is on **standard pricing** ($3/M in, $15/M out, `sonnetCost`, multiplier 3×) as of 2026-09-01, when its introductory $2/$10 period ended. The `sonnet5Cost` constant is gone; do not reintroduce it, and do not "correct" the 3× suffix back to 2× from an older README or screenshot. +- **Costs in `src/models.ts` are dollars per MILLION tokens**, the unit opencode and models.dev use (`~/.cache/opencode/models.json` has `claude-haiku-4-5 -> {"input": 1, ...}`); opencode divides by 1e6 itself. They were per-token until @CNQQC's PR #25 (merged 2026-08-19), which made every reported session cost 1,000,000x too low — do not "restore" the `1e-6` form. `opusCost` is the real Opus 4.5+ standard price ($5/M in, $25/M out — corrected from a stale legacy $15/$75; Opus 5 keeps it). Haiku ($1/$5), Sonnet ($3/$15), and Fable/Mythos ($10/$50) were already correct. Fable/Mythos 5.1 keep those input/output rates but use a separately published $0.25/M cache-read rate, not 5.0's $1/M. If you add a model, set its cost from the published standard (not Fast Mode) pricing so the `(N×)` suffix stays consistent. **Every entry now carries its published `limit`**, audited against the Anthropic models + pricing docs on 2026-07-26 (the placeholder `output: 16_384` is gone; do not reintroduce it). Two classes of drift were corrected: `claude-sonnet-4-5` and `claude-opus-4-5` claimed a **1M context they never had** — the whole 4.5 generation (including Haiku 4.5) is **200k context / 64k output** — while every 4.6-and-later entry is **1M / 128k**. Release dates for the three dated IDs were also wrong and now match the snapshot suffix (haiku `2025-10-01`, sonnet-4-5 `2025-09-29`, opus-4-5 `2025-11-01`). `test-config-models.ts` pins all thirteen non-fast-model limits, with fast-model limits pinned separately, so a regression fails the suite rather than silently misreporting the context gauge. +- **No long-context pricing tier exists — do not add one.** Investigated for issue #24 on 2026-07-26: Anthropic's pricing page has a "Long context pricing" section stating that Claude 4.6 and later include the full 1M window **at standard pricing** ("a 900k-token request is billed at the same per-token rate as a 9k-token request"), with caching and batch discounts unchanged across it. opencode 1.18.5's optional `cost.tiers` / `cost.experimentalOver200K` fields therefore stay unset — populating them would misreport the real price. The premiums that *do* exist are out of scope here: Fast Mode ($10/$50 on Opus 5/4.8, and this plugin never sends `speed: "fast"`), `inference_geo: "us"` (1.1×, not a CLI flag we pass), and partner-cloud regional endpoints (10%, not our path). Re-open only if Anthropic publishes an above-200K rate. A comment above the cost constants in `src/models.ts` records the same finding. +- Billing context (researched 2026-06-10, documented in README "Billing change: June 15, 2026"): from 2026-06-15 Anthropic bills `claude -p` / Agent SDK usage (the plugin's default headless `--print` path) against a separate monthly Agent SDK credit on subscription plans (Pro $20 / Max 5x $100 / Max 20x $200), not normal plan limits; API-key auth is unaffected. Same day, `claude-sonnet-4-20250514` / `claude-opus-4-20250514` retire (not registered here, but pass-through overrides could hit them). Fable 5 is included free on plans only through 2026-06-22; after that it needs usage credits. Confirmed failure mechanism: the 400 `Third-party apps now draw from your extra usage...` corresponds to a `rate_limit_event` with `{rateLimitType:"five_hour", overageStatus:"rejected", overageDisabledReason:"org_level_disabled"}` under OAuth subscription auth, so org-level overage/extra usage being disabled can reject requests that do not fit the remaining rolling window. URL redaction was tested and reverted; the opencode repo URL is not the trigger. Interactive mitigation: live bisection showed this plugin's own CLI/AGENTS/continuation prompt succeeds, while opencode's forwarded system prompt payload can trip the usage gate on constrained subscription accounts. Interactive mode therefore intentionally omits the forwarded opencode system prompt by default. Real account-side fixes remain: enable overage/add extra usage, wait for the 5-hour window reset, switch account/org/plan, or use API-key auth. +- **`AGENTS.md` must not reach the model twice** (`buildAppendedSystemPrompt`, cherry-picked from @HeikoAtGitHub's `25260a4`, absorbed 2026-09-06). opencode forwards `~/.config/opencode/AGENTS.md` inside its own system prompt under an `Instructions from:` header, and this plugin also read it from disk and appended it, so every turn paid for both copies (visible in any plugin-driven session's own system prompt). The disk copy is now pushed only when the forwarded `extraSystemContent` does not already contain it; no match keeps the old behaviour, so the interactive transport (which forwards nothing) never loses it. Live-verified: one copy in a 63 KB appended prompt. Test in `test-compaction-model.ts`. +- **Abort sends the CLI an `interrupt` control request** (`interruptTurn` in `session-manager.ts`, adapted from @broskees' `68ed142`, absorbed 2026-09-06). The CLI runs one turn per process and closing our stream told it nothing: an aborted turn ran to completion, billed, executed tools, and its late output plus stale `result` landed in the next turn (Joseph measured ~7,500 characters generated after abort). `noteTurnStarted` marks the process in flight at every stdin write that asks for work (fresh envelope, auto-continue, watchdog re-send), the terminal `result` line clears it inside the `rl` handler in `spawnClaudeProcess` (**not** a permanent `lineEmitter` listener: `listenerCount("line") === 0` is what routes unattended lines to the buffer and what `/btw` reads as busy, so a permanent listener would break both), the abort handler sends `{type:"control_request", request:{subtype:"interrupt"}}`, and a new turn that finds the previous one in flight interrupts it first with a 5 s cap, except tool-result turns where the CLI is legitimately parked in a proxy call. The interactive transport is never marked in flight (its stdin is a TUI). Live-verified on 2.1.258: abort mid-webfetch, `interrupt sent for aborted turn {idle:true}`, next turn clean in 8.5 s. Tests: `test-session-manager.ts`. +- **`idleProcessTimeoutMs`** (cherry-picked from @bernardofortes' `a5f723a`, absorbed 2026-09-06, resolved by hand onto the current tree because his base predated the `--resume` rename and the respawn rework; the commit is still his). **Off unless set** (`DEFAULT_IDLE_PROCESS_TIMEOUT_MS` is 0, resolved by `resolveIdleProcessTimeoutMs` at the `completeResult` call site so unset and `0` both arm nothing; @broskees' fork-parity PR #36 proposed 30 minutes by default and that was reverted at merge, since it changes when a resumed chat pays for a fresh `--resume` spawn and that is the user's call. The helper stays so a default can be revisited in one line). Timer armed in `completeResult` after `cleanupTurn`, so the clock starts when a turn finishes, not at spawn; cancelled by `getActiveProcess`/`setActiveProcess`/`detachActiveProcess`/spawn/exit, unref'd, and it deletes only if the same process object is still registered so a respawn cannot be killed by its predecessor's timer. A process found `turnInFlight` when it fires (recovered continuation, auto-continue, late tool result) is re-armed, never killed, the same rule the LRU cap follows. Session id survives, so the next turn resumes. Tests: `test-session-manager.ts`, `test-process-lifecycle.ts`. +- **The child's stdin needs its own `error` listener, and `proc.on("error")` is not it.** Every write that asks the CLI for work (fresh envelope, auto-continue, the watchdog re-send, `interruptTurn`) can land after the child died, and an `error` event on a stream with no listener throws inside **opencode's** process, not the child's. `spawnClaudeProcess` attaches a baseline `proc.stdin?.on("error", ...)` next to the process one; it logs at WARN with the errno and calls `settleTurn`, because no terminal `result` is ever coming for a write that never arrived. It deliberately does not end the turn: the child is gone, so the readline `close` follows and the turn's close handler reports it. Note EPIPE is delivered whenever libuv gets round to failing the queued write (measured: hundreds of ms, sometimes only once the child is killed), so the regression test emits the event directly; the contract under test is that something is listening. The interactive shim's `stdin` is a plain object with `write`/`end` and no emitter, so it cannot emit `error` and needs nothing. Test: `test-session-manager.ts`. +- **LRU eviction must never take a process that is mid-turn.** `evictIfNeeded` deleted the oldest of 16 outright, and the evicted turn's close handler then finished with reason `stop` and no error, so a user with many open chats saw an answer silently truncated. It now walks insertion order (which is LRU) for the first process with `turnInFlight !== true`, and when every process is busy it evicts **nothing** and warns, letting the map exceed the cap for a moment rather than killing live work. Do not "restore" the one-liner. The cap is **8** (was 16; the fork's figure, adopted in the fork-parity PR): the idle timer above does the real work and this is the backstop for a burst of chats inside one idle window. Tests: `test-session-manager.ts` (both branches). +- **A deleted opencode session releases everything at once, and host exit kills what is left.** The plugin's `event` hook (`index.ts`) acts on `session.deleted` only, reading the id from `properties.info.id` (`extractDeletedSessionId`), and calls `deleteActiveProcessesForSession`: every process whose `opencodeSessionID` tag or session-key affinity segment (`describeSessionKey(key).session`, which covers effort and compaction keys) matches is killed, its proxy server closed, and, unlike idle eviction, its Claude session id, plan-mode questions, todo ledger and compression summary are dropped, because a deleted session never resumes. The `"default"` affinity is the shared fallback bucket and is never matched. `ensureProcessExitCleanup` arms a single `process.once("exit")` that runs the synchronous `killAllActiveProcesses`, guarded so repeated plugin initialisation never stacks listeners. `detachActiveProcess` also rejects the broker's pending calls for the key once it closed the proxy server: nothing can answer them any more, and a `task` call has no deadline that would otherwise reap the entry. Tests: `test-session-manager.ts`, `test-process-lifecycle.ts`. +- **A child that closes without a `result` is an error, not a `stop`.** The doStream close handler finished the stream with `toFinishReason("stop")` and empty usage, so a crashed CLI read as a short but successful answer. It now emits an `error` part (consistent with the other error paths in that file) plus `finishReason: "error"`, built by `describeChildCrash(exitCode, signal, lastStderr)`. Three things hold it together: stderr was debug-only and clipped to 200 chars, so `retainStderr` keeps a 2 KB tail on the ActiveProcess (`lastStderr`, newest wins) as the only record of why; `proc.exitCode` is usually still `null` when stdout hits EOF, so the crash branch waits up to `CHILD_EXIT_STATUS_GRACE_MS` (250 ms) for the `exit` event rather than reporting a bare "closed its output"; and an abort is exempt (`autoContinueState.aborted`), since the operator asked for it and the CLI may exit before the interrupt's own result lands. The path where a `result` did arrive is untouched, and auto-continue is unaffected because it only runs from `completeResult` (`isError` already returns `{continue:false, reason:"error"}`). Tests: `test-respawn.ts` (fake CLI, crash and abort), `test-session-manager.ts` (retention cap, message shape). +- **Skill bridge is opt-in** (`bridgeOpencodeSkills`, `src/skill-bridge.ts` written by @broskees in `68ed142`, absorbed 2026-09-06; his fork-parity PR #36 proposed on-by-default and that was reverted at merge, see the follow-up commit). opencode and Claude share the `/SKILL.md` format but not the roots, so opencode advertised skills the CLI's `Skill` tool could not find. The bridge stages a throwaway plugin dir (`skills-` under `pluginTmpDir`, linked, copy fallback for Windows) and passes `--plugin-dir`; the flag has no version marker so `detectCliSupportsFlag` probes `claude --help` (cached). **Deliberately off by default**: every bridged skill is also in the system prompt opencode forwards, so a big skill set is paid for twice per turn by every user; `bridgeOpencodeSkills: true` opts the user's skills in, and the bundled skill is staged regardless. Live-verified via `OPENCODE_CONFIG=` on a temp project: 4 skills bridged, `Skill` call rendered as opencode's `skill` tool, token returned. Only `~/.config/opencode/skills` and `.opencode/skills` are roots; `~/.agents/skills` is not opencode's, so those are not bridged. Wired into the headless `doStream` spawn, `doGenerate`'s direct spawn, and the interactive spawn (`pluginDirs` on `spawnInteractiveProcess`, appended by `interactiveExtraArgs`); compaction's lean spawn never stages it, and the `--help` probe keeps the flag off a CLI that does not know it on every path. Tests: `test-skill-bridge.ts` (including the real argv of a spawned fake CLI on both headless paths), `test-claude-session-wrapper.ts`. +- **Two forks independently named the 5-minute proxy wall's timer**, which the 0.15.0 note above says not to claim without evidence: @broskees (`68ed142`) measured a hard 301 s and attributes it to undici's `headersTimeout` and `bodyTimeout` (300 s each) behind Node `fetch` in the CLI's MCP client; @HeikoAtGitHub (`42f426d`) measured 293 to 296 s plus a separate 300 s MCP-idle timer and, like 0.15.0, fixed it with SSE plus progress notifications. Treat 300 s undici as the working explanation; the 0.15.0 fix already covers it. +- **Do not wait for `message_stop` to drain proxy calls.** @broskees' `a44a2dc`: draining only at that boundary deadlocked two ordinary Bash calls until their timeouts fired in succession, because the CLI blocks inside the MCP call before emitting it. Our broker drains as calls arrive; keep it that way. +- **Sweep the forks more often than once a quarter.** @galvani fixed the stale `toolCallMap` re-emission on 2026-05-25 (`2238ed0`) with the same log signature that took until 2026-09-06 to find here. The sweep is cheap: clone, add every fork as a remote, `git cherry origin/master ` per branch (patch-id equivalence, so absorbed cherry-picks do not show), read the bodies of what is left. +- `signature_delta` is expected encrypted thinking metadata. Ignore it quietly; do not treat it as an error. +- `WebSearch` with the default `"claude"` routing must NOT be forwarded as a tool-call part. opencode has no `WebSearch` registry entry, and (at least as of opencode v1.17.0) the AI SDK rejects unknown tool names with "Model tried to call unavailable tool" even when `providerExecuted: true` — users saw `⚙ invalid` rows on every CLI-internal web search (fixed after v0.8.0). `mapTool` returns `skip: true` for it, and both tool_use sites in `claude-code-language-model.ts` render the query as a `> **Web search:** …` text line instead (gated by `isWebSearchTool` + `isWebSearchHandledByCli` from `tool-mapping.ts`). Explicit opencode-tool routing (`webSearch: ""`) still forwards with `executed: false`. Tests in `test-tool-mapping.ts`. +- `tool-input-delta` parts must only be forwarded for tool calls whose `tool-input-start` was actually emitted. opencode's AI SDK bridge (`packages/opencode/src/session/llm/ai-sdk.ts`) resolves delta/end names via `state.toolNames[event.id] ?? "unknown"`; a delta for an unseen id creates a permanently-pending part with `tool: "unknown"` that the TUI renders as `⚙ unknown`. Skipped tools (ToolSearch, TaskCreate/TaskUpdate, CLI-internal WebSearch, AskUserQuestion, ExitPlanMode, proxy tools) stream `input_json_delta` like any other tool_use, so the streaming site in `claude-code-language-model.ts` gates delta forwarding on a `started` flag set only when the input-start part went out (fixed after v0.8.1). Keep accumulating `inputJson` unconditionally — the skip-path text rendering (AskUserQuestion/ExitPlanMode/WebSearch) depends on it. +- Subagent dispatch must be steered at the tool *and* the prompt (absorbed from @jknlsn's `94980a6`, diagnosed on his fork 2026-07-04, re-confirmed live here 2026-07-26). Headless `--print` Claude Code exposes no `Agent`/`Task` dispatch tool of its own (checked through CLI 2.1.211), so the `task` proxy is the only path — but the CLI *does* expose `TaskCreate`, a todo tool, and models resolve opencode's "call the task tool with subagent: X" mention hint straight to it: a todo appears, nothing runs, and the model narrates a successful dispatch. Since Task is proxied by default (v0.10.0) this is reachable without any config. Two spawn-time countermeasures, both required: `overlayTaskProxyDescription` in `proxy-mcp.ts` front-loads opencode's live agent-type list onto the `task` proxy def, and `SUBAGENT_DISPATCH_HINT` goes into the appended system prompt naming `mcp__opencode_proxy__task` as the only dispatch path. **Claude Code truncates long MCP tool descriptions, so position is load-bearing:** jknlsn's original pasted opencode's entire live description (2858 chars) in front of the static def, but opencode puts "Available agent types" at the *end* of it (char 2306), so the only part the model needed was exactly what got cut. Live-verified failure (2026-07-26, haiku): the model asked for `general-purpose`, then `default`, then `code-reviewer` — Claude Code's own agent names — and every dispatch died with `Unknown agent type`, after which it grepped `~/.config/opencode/opencode.json` and answered the question itself. Fix: `extractAgentTypeList` keeps only the list, trims each blurb to 140 chars, drops opencode's generic preamble, and the overlay puts it **first**; total description stays under ~1.4 KB (a test asserts < 1600). Same prompt then dispatched cleanly on the first try (`subagent_type: general`, real child session, `completed`). If you ever grow that description, re-run the live check — a passing unit test will not catch truncation. The hint's ToolSearch line is load-bearing, not padding: harnesses that defer MCP tool schemas (opencode-dcp does) leave `mcp__opencode_proxy__task` invisible while `TaskCreate` stays visible, which is the worst case for this confusion — the maintainer hit exactly that during the v0.10.0 smoke test. `TASK_PROXY_NOTE` must keep describing the real deadline (none by default; a positive `proxyToolTimeoutMs` adds one) and `background` mode; jknlsn's original said 10 minutes, which predated the per-tool timeouts, and a later version said 60. Only wired into `doStream`'s spawn path — `doGenerate` has no proxy wiring at all, so it deliberately has no hint. Tests: `test-subagent-hint.ts`. +- **`task_batch` is the only way to run two subagents at once, because the CLI serialises MCP calls** (from @broskees' `68ed142`, adapted 2026-09-06, his design). Measured before building it, not assumed: haiku asked for two parallel `mcp__opencode_proxy__bash` sleeps emitted **both tool_use blocks in one assistant message** (same `message.id`, 275 ms apart), yet the second MCP request reached the proxy 7 ms **after** the first resolved, 8 s later. So "call task twice" is serial by construction and no amount of prompting fixes it. `task_batch` (`proxy-mcp.ts`) is one MCP call whose `tasks` array `finishWithToolCalls` fans out as N `task` tool-calls in the **same** stream finish, ids `${parent}_task_${i}` (`taskBatchChildToolCallId`), which opencode runs concurrently as one step; `extractPendingProxyResultForCall` gathers the children's results back onto the parent id (`formatTaskBatchResults`, labelled in order) and resolves the one broker call. Invariants: (1) it rides along with `task` in `resolvedProxyTools`, so `proxyTools: ["Task"]` gets both and nobody has to know it exists; it disables the same built-in (`Agent`), deduped. (2) The batch is validated in the `tools/call` handler **before** it is queued (`taskBatchInputError`), as an MCP `isError` result, since a bad batch has nothing to fan out and a broker entry for it would only time out. (3) A partial set of child results still resolves the parent, with the gap written into the text as `[missing]`: returning null there would send the turn down the fresh-envelope path, which rejects the parent as orphaned and renders the children as text, the worst of both. opencode hands all of a step's results to the next call together, so partial is theoretical. (4) The `TASK_PROXY_NOTE`, the batch def's note, and `SUBAGENT_DISPATCH_HINT` all name it, because the model has to be told the serial behaviour exists to prefer the batch. The fork's "unlimited by default" task deadline (`dd494a8`) was first left out as contradicting the then-documented 60-minute contract, and adopted in the fork-parity PR; see the deadline gotcha below for the lifecycle that releases an abandoned call instead. Tests: `test-proxy-mcp.ts` (def, validation, deadline, formatter), `test-subagent-hint.ts`, `test-proxy-task.ts` (fake-CLI fan-out and the two-turn gather). **Live-verified 2026-09-06** on Claude Code 2.1.258 + opencode 1.18.29 (haiku, two `general` subagents): `plugin.log` shows exactly one `proxy-mcp tool call received` with `toolName: task_batch` and zero plain `task` calls, the parent holds two `task` tool parts with ids `_task_0` / `_task_1` that started 13 ms apart and overlapped for their whole 5.6 s / 5.8 s runs, two child sessions exist, and the final answer quoted both subagents' tokens. That overlap is the fingerprint: if the two child intervals ever stop overlapping, the fan-out has silently become serial again. +- **`toolCallMap` is keyed by content-block index and MUST be deleted at `content_block_stop`.** Claude CLI restarts block indices at 0 on every assistant message, and one turn routinely holds several (tool_use -> tool_result -> answer, `numTurns: 2`). The entry was never deleted, unlike its neighbours `reasoningIds` and `textBlockIndices`, so message 2's answer-text block at index 0 hit message 1's stale tool_use entry and re-emitted a `tool-call` for an id opencode had already completed. That second part never receives a `tool-result`, so opencode aborts it at stream end with `Tool execution aborted` / `interrupted: true`, and opencode's `task` tool turns that abort into `Subagent failed (task_id: ...)` **even though the child answered correctly and finished with `stop`**. Diagnosed live 2026-09-06 on 0.15.0: three probes, deterministic — a subagent using any provider-executed tool failed, a subagent using no tools returned fine. The plugin log is the tell: two `tool call complete` lines with the same `id`, the second ~2 ms after the final text ends. This was NOT a 0.15.0 regression (aborted parts go back to at least 2026-08-16) and it silently produced the long-standing background noise of `⚙ aborted` rows in the main lane too; it only became a hard failure through the `task` tool. Do not "tidy" the delete away. Test: `test-tool-block-index.ts`, which fails with `got 2` without it. +- Claude CLI emits internal tools (`Agent`, `ToolSearch`, `AskFollowupQuestion`, `TaskList`, `TaskGet`, `TaskStop`) that have no opencode registry entry. They live in `CLAUDE_INTERNAL_TOOLS` in `src/tool-mapping.ts` and must be skipped, not forwarded. Forwarding them surfaces `⚙ invalid` tool rows in opencode. `TaskOutput` is the exception: it stays mapped to a `bash echo` so the result is visible. `TaskCreate` and `TaskUpdate` are NOT in this set — they route through the todo ledger (see next gotcha). +- proxy-mcp `tools/call` responses MUST be MCP results (`{ result: { content, isError } }`), never JSON-RPC error envelopes. Claude CLI validates every `tools/call` response against the MCP result schema and rejects JSON-RPC errors as a "malformed result that failed schema validation" (seen live 2026-07-04 on broker timeouts/orphans — fixed post-0.9.2). All three error paths in `src/proxy-mcp.ts` now return results with `isError: true`: unknown tool, `result.kind === "error"` (merged into the success path), and the outer `catch` when `requestMethod === "tools/call"`. Non-`tools/call` methods (initialize, tools/list) and unparseable requests still use JSON-RPC errors, which is spec-correct. `requestId`/`requestMethod` are hoisted above the try so the catch can echo them — do not regress to `id: null`. Tests: `test-proxy-mcp.ts`. +- **The proxy MCP endpoint is authenticated.** It executes Bash/Edit/Write through opencode's executor, so before @willmcginnis's PR #28 (fixed in 0.13.2, disclosed as **GHSA-3mxm-w7gf-3c5x**, High/CVSS 7.5 `AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H`, affecting >= 0.1.3 < 0.13.2; a CVE was requested from GitHub's CNA on 2026-08-20 and was still unassigned at that point — check `gh api /repos/khalilgharbaoui/opencode-claude-code-plugin/security-advisories/GHSA-3mxm-w7gf-3c5x --jq .cve_id` and, once it lands, add it to the README security section and the v0.13.2 release notes) any local process could POST to the loopback port and get arbitrary command execution, and a web page could do it blind via a `text/plain` CORS simple request. `createProxyMcpServer` now mints a 256-bit bearer token per server, hands it to Claude in the `headers` block of the generated `--mcp-config` (that file is `0600`, which is now load-bearing), and rejects every request that fails one of four guards, in this order: `Host` must equal the bound `127.0.0.1:` authority (DNS rebinding), `Origin` must be absent, `Content-Type` must be `application/json` (forces a preflight that then fails, closing the simple-request hole), and the bearer token must match under `timingSafeEqual`. All four run **before** `readBody`, so an unauthenticated peer cannot stream a body into memory, and `reject()` sets `Connection: close` and destroys the socket so a slow unauthenticated body cannot hold `server.close()` open. Three consequences to remember: (1) `authToken` must never be logged or put in the URL — the log line in `reject()` deliberately reports only `hasAuthorization`, never values; (2) the Origin and Content-Type guards are **measured properties of the client we spawn**, not spec guarantees, so a future Claude CLI that starts sending an `Origin` would 403 every call — that is exactly why `reject()` logs a reason at NOTICE; (3) anything in-repo that drives the endpoint over HTTP has to authenticate, which is why `test-proxy-mcp.ts` has `authedPost` and `test-compress-tool.ts` threads `srv.authToken`. Live-verified end to end on **Claude Code 2.1.226** (2026-08-20): real CLI, real `--mcp-config`, proxy call received and answered. Do not "simplify" a guard without re-running that check; the unit tests cannot see a client-side header change. **Upgrading does not patch a running opencode**: the plugin is loaded once at process start, so every opencode left open from before the upgrade keeps serving an unauthenticated proxy port until it is restarted. Observed on the maintainer's own machine on 2026-08-20, where three sessions from Aug 5 and Aug 18 still answered `POST /mcp` with 200 and 145-byte MCP configs (no `headers` block) while the freshly started one answered 401 with a 272-byte config. That probe (`lsof -nP -iTCP -sTCP:LISTEN | grep opencode`, then an unauthenticated `initialize`, 401 = patched, 200 = stale) is the check to run after any security release, and it is in the README security section for users. +- **`proxyOpencodeMcpTools` read the wrong registry, and the prefix rule was never the bug.** It defaulted to `true` and routed nothing, because `resolvedProxyMcpTools` discovered candidates from `client.tool.list()` (`GET /experimental/tool`), and **opencode's tool registry does not contain MCP tools**. Measured twice on 1.18.31, once on the maintainer's real config with five servers connected and once on a scratch config with one, both times returning only built-ins plus plugin-declared tools (`invalid, question, bash, read, glob, grep, edit, write, task, webfetch, todowrite, websearch, skill, apply_patch`, plus `gemini_quota, quota_status, compress` where those plugins are loaded); `GET /experimental/tool/ids` documents itself as "all tool IDs (including built-in and dynamically registered)" and is no better. Waiting 25 s changed nothing, so it is not a startup race, and no loose substring of a connected server name matched either, so it was never a naming-scheme problem. The full SDK route list in `dist/gen/sdk.gen.js` has exactly two tool routes and no other surface carries tool names, and neither the v1 `Hooks` surface nor the v2 `PluginContext` (`agent`, `aisdk`, `catalog`, `command`, `integration`, `plugin`, `reference`, `skill`) has an MCP-tool domain. + - **Where they actually are: the model tool set, which is downstream of the registry.** In opencode's own bundle the session assembles `o` from `ToolRegistry.tools` first, then adds the MCP resources tool, then does `for (let [k, W] of Object.entries(yield* d.tools())) { ... o[k] = b } return o`, where `d.tools()` is `MCP.tools()` and `k` is `MCP.toolName(server, tool)`. So MCP tools join the same dict the built-ins are in, after the registry has been read, which is why a registry query cannot see them and why `tool.definition` cannot either. That dict is what reaches the provider, so **the `tools` argument of `doStream` is the only place a provider plugin can discover them**. `resolveMcpProxyToolDefs` in `proxy-mcp.ts` now reads it. The `` / `_` prefix match is unchanged and was always correct: live names look like `codebase-memory-mcp_list_projects`, hyphens in the server name and all. + - **The default went `true` to `false`, and that changed no behaviour.** The option was inert, so leaving it on while repairing discovery would have silently moved every user's MCP traffic off the direct bridge that is carrying it today. Turning it on is now the operator's call, consistent with `proxyOpencodeTools`, the compress tool and the skill bridge. `excludeServers` also narrowed from "every enabled server" to only the servers a def was actually built for: it was safe while the resolution was always null, but excluding a server with no def would drop it from `--mcp-config` without putting it on the proxy, reachable by neither route. + - **Enabling it is not enough on its own, and this cost a probe to find.** Claude Code merges its *own* user-scope MCP config with the `--mcp-config` this plugin writes. With `codebase-memory-mcp` in both, excluding it from our config changed nothing: Claude called `mcp__codebase-memory-mcp__list_projects` through its own child, the log shows `mapping MCP tool` and `executed: true`, and the proxy was never touched. Adding `strictMcpConfig: true` fixed it. Both README and SKILL.md say to pair the two; a user reporting "I enabled it and nothing routes" is almost certainly hitting this. + - **Live-verified 2026-09-19** on opencode 1.18.31 + Claude Code 2.1.263, scratch `XDG_CONFIG_HOME` and scratch cwd (one `plugin ready`, providers `["claude-code"]`): `routing opencode MCP tools through the proxy` listed all 14 `codebase-memory-mcp_*` tools while `GET /experimental/tool/ids` on the same server listed none of them, `proxy-mcp server started` carried them as defs, `proxy-mcp tool call received {"toolName":"codebase-memory-mcp_list_projects"}` fired, and the stored transcript holds a `completed` tool part with 3521 characters of real output followed by the model's answer. One wrinkle to expect and not misread: opencode aborted the provider stream at that tool boundary (`abort between proxy tool boundaries`) and the result reached the model through the issue-#29 text path (`rendering opencode-side tool result as text`), the same shape AGENTS.md already records for dcp's `compress`. The turn completed and the tool part rendered correctly. Probe scripts were scratch under `/tmp/ocprobe`, not in the repo. + - **Re-verified across every connected server before release** (2026-09-19, on a copy of the maintainer's real config so all eight enabled servers were in play, `plugin ready` asserted exactly once). `GET /mcp` reported five connected (`alwasiyyah-errors`, `codebase-memory-mcp`, `figma`, `furno-postgres`, `obsidian`) and three failed (`postgres`, `postgres-alwasiyyah`, `slack`), and the discovery line covered **exactly those five**, 60-odd tools, with no `no MCP tool was found` warning. The two-call structure is worth knowing before reading a log: the `bridged opencode MCP config {"excluded":[]}` line is the hot-reload **probe** (`:2671`), which deliberately passes no exclusions, while the spawn's own call (`:2967`) is the one carrying `coveredServers`. With every bridged server covered, `mcp-bridge.ts:580` returns no path, which is why the argv then holds a single `--mcp-config` pointing at the proxy and no bridged file, and why only one bridge line is ever logged. A partially covered set takes the other branch and still writes a bridged config for the uncovered servers, which is the stranding fix doing its job; that case cannot be produced on demand live, so it is a unit test. + - **The bridged config file is content-addressed, and that is load-bearing.** `finishBridge` writes `mcp-.json` only when the file is absent, and the digest is now taken over the file **body**. It used to be `hash`, which covers the merged opencode config and **not** `excludeServers`, so two calls differing only in exclusions collided on one filename and the first writer won. The hot-reload probe (`:2671`) always runs first with no exclusions, so the spawn's own exclusions never reached disk: a server routed through the proxy stayed in the bridged config as well and Claude could reach it both ways, which is the double execution the option exists to prevent. Only the partially covered case was affected, because full coverage returns early with `path: ""` before touching a file, which is exactly why the live five-server probe looked clean and a test was needed to find it. The returned `hash` is unchanged, since drift detection wants to track the config rather than the exclusions. Test: the wiring test below fails when the filename goes back to `mcp-${hash}.json`. + - **`--mcp-config ` is variadic**, so `buildCliArgs` pushing the flag once followed by every path (`args.push("--mcp-config", ...filtered)`) is correct and must not be "fixed" into a repeated flag. Anything parsing that argv has to read every argument after the flag until the next option; a parser that reads only `argv[i + 1]` silently sees one path and misses the rest. + - This is still why `proxyOpencodeTools` (PR #38) takes an explicit allowlist keyed on registry ids rather than extending the server-prefix rule: a plugin-declared tool belongs to no MCP server, so no prefix rule can ever reach it. +- **A call with no deadline reports itself, because nothing else will** (`PROXY_STALL_WARNING_MS` in `src/proxy-broker.ts`, 5 minutes, repeating). Removing the `task`/`task_batch` deadline was right on correctness and wrong on visibility: a wedged subagent went from "fails after 60 minutes" to "silent forever", with the operator as the only detector. The heartbeat restores the visibility half without restoring a killer: it **never ends a call**, it logs one line naming tool, call id, elapsed, `emitted`, `channelClosed` and what will end it. Four things hold it together. It is armed **only** when `deadlineMs === PROXY_NO_DEADLINE_MS`, since a deadline-bearing call already reports itself and a 5-minute build is not a stall. It is WARN for the same reason `reportFastModeState` is: only warn/error are alwaysStderr in `src/logger.ts`, so a NOTICE would be invisible outside debug mode and the line would exist for nobody. It is `unref`'d, so a heartbeat can never hold opencode's process open. And every removal site now goes through `clearPendingTimers(pending)` rather than clearing `timer` by hand, because a pending call holds **two** timers and an interval left running against a deleted entry is a leak that repeats forever. Deliberately not done: no warning from the proxy-mcp HTTP side, which holds its own timer for the same call and would double every line. **The deadline-bearing half followed immediately, and the reason is worth keeping**: the original claim, that "a deadline already reports the call", was true only in the sense that it reports it *by killing it*, so the first signal is the failure. `PROXY_DEADLINE_WARNING_FRACTION` (0.6) fires one notice at 60% of the deadline with `remainingMs` and the `proxyToolTimeoutMs` hint, one-shot because the rejection speaks next, and `PROXY_DEADLINE_WARNING_MIN_MS` (60 s) skips short deadlines where the notice and the rejection would land together. Found by hitting it: two `write`/`bash` proxy calls were rejected at their 10-minute deadline while the work was actually succeeding, with no prior signal, and the session had to infer it from silence. Tests: five more in `test-broker.ts`; only the substantive one fails when the arm condition is stubbed, since three assert absence. Tests: `test-broker.ts` (repeat, both stop paths, the deadline-bearing case, the `0` seam), three of which fail with the arm condition stubbed to `false`. +- **A proxied call ends on an event, not on a clock, and the tests pin each event.** This is the rationale behind the no-deadline `task` default, not a bigger timer: the plugin listens to the child process, the stdout stream and the control protocol, so it never has to infer from elapsed time that a subagent failed. The events, each with the regression that proves the call is released: opencode's result resolves it (`test-proxy-task.ts` "proxy MCP initializes, lists Task, and resolves it through the broker"); an abort rejects the turn's pending calls at once and interrupts the CLI, whether it lands before content (`test-proxy-task.ts` "immediate abort rejects a buffered Task call"), after content (`test-process-lifecycle.ts` "an abort after content…"), or while opencode is running the tool with the stream already closed on its boundary, where the signal fires on a closed stream and the handler acts only if no later turn has attached to the process (`test-process-lifecycle.ts` "an abort while opencode is running the tool…"; before the fork-parity PR that abort did nothing and the call waited for the next message); the next user message rejects the previous turn's calls as orphaned and the CLI's HTTP request gets the error result (`test-process-lifecycle.ts` "a task call the previous turn left pending…"); the child dying mid-turn ends the turn as an error and rejects its calls, and the child dying between turns rejects them from `spawnClaudeProcess`'s exit handler with no turn attached (`test-process-lifecycle.ts`, both `exit-*` modes; the between-turns case was a real gap before the fork-parity PR, covered only by the 60-min timer); a deleted session and host exit reject them through `detachActiveProcess` (`test-process-lifecycle.ts` event hook test, `test-session-manager.ts` `killAllActiveProcesses`); and a CLI that hung up on its own request keeps its entry for late-result recovery (`test-proxy-task.ts` recovery modes), as does a watchdog respawn (`test-respawn.ts`, completions carried to the replacement). **Every terminal-event test asserts both registries**, the proxy server's open HTTP requests (`ProxyMcpServer.pendingCallIds()`, read-only) and the broker's entries (`getPendingProxyCalls`), not merely that `kill()` ran or one promise rejected: with no deadline, an entry either side forgets to drop is permanent. What no event covers is a child that is alive and silent, which is what the start and inactivity watchdogs are for; they are unchanged and are not proxy deadlines. Keepalives are about the CLI's HTTP client, not the tool. Do not describe this change as "removing timeouts"; describe it as listening. +- Proxy call deadlines are per-tool, not flat. `resolveProxyCallTimeoutMs(toolName, input, overrides)` in `src/proxy-mcp.ts` is the single resolver consumed by BOTH the proxy-mcp HTTP handler (`:478` area) and the broker (`queuePendingProxyCall`); the two layers must never race on different values, so any new timeout site must call it too. Layering: flat 10-min default → per-tool default (`task` and `task_batch` **none**, `PROXY_NO_DEADLINE_MS` = 0; `question` 30 min) → `proxyToolTimeoutMs` config override (case-insensitive; positive replaces, `0` disables, negative/NaN ignored) → for `bash` only, `max(resolved, input.timeout)` so the proxy never undercuts a build the caller explicitly asked to run long (the bash def advertises a `timeout` field; ignoring it forced a model to `nohup` xcodebuild and poll a log file — live ses_0cfc0da6, 2026-07-05). `buildProxyTimeoutError(toolName, ms)` keeps the catch-block substrings (`"timed out after"` + `"waiting for opencode to resolve"`) so the expected-cleanup classifier at the proxy-mcp catch still demotes to NOTICE; the `task` variant appends a "do not schedule a wake-up, that does not apply here" note. That note is load-bearing: when a Task timeout fires the subagent may still be running but its result is unreachable (the late broker resolve finds the entry already deleted), and without the note the model "schedules a wake-up" — a real Claude Code affordance that cannot fire in headless/proxy mode — and ends its turn, so the operator must manually nudge "please check now, it seems the task succeeded" (same live session). The flat `PROXY_CALL_TIMEOUT_MS` constant is gone; do not reintroduce it. The one remaining flat value is `resolveProxyClientCeilingMs(overrides)` — the `timeout` written into Claude's `--mcp-config` entry for the proxy server (without it Claude's remote-HTTP MCP client aborts at its 60-second default, @broskees PR #18); it tracks the max of all effective deadlines (defaults with overrides applied) so the client never gives up before the broker, and it is `MAX_PROXY_TIMEOUT_MS` whenever any tool has no deadline, because the CLI rejects `timeout: 0` in the MCP config (fork measurement, `dd494a8`). **A deadline of 0 means no timer**: both the HTTP handler and the broker guard their `setTimeout` on `deadlineMs > 0` (the broker's `timer` is nullable), since `setTimeout(fn, 0)` would reject the call on the next tick. What releases an unlimited call instead is the existing lifecycle: the next user turn's orphan sweep, an abort before content, the child closing, the process being deleted (which now also rejects the broker's entries for the key, see the deleted-session gotcha), and the late-result recovery path for a client that hung up. That last one is why the fork's immediate client-disconnect cancellation (`CLIENT_GONE_MESSAGE`, `calls.emit("cancel")`) was **not** taken: it deleted the entry the recovery machinery needs to deliver a late `task` result as a continuation. Config is read once at opencode startup like the rest of the proxy block, so `proxyToolTimeoutMs` changes need a full restart. `/claude-code-doctor` prints a 0 deadline as `none`. Tests: `test-proxy-mcp.ts`, `test-broker.ts`, `test-doctor.ts`. +- Reused-process start watchdog. A reused `claude --print` child can go silent on stdout after a fresh-turn envelope write — seen after a very long proxy-blocked `task` call resumed successfully (the per-tool timeout fix let the block return instead of ending the turn, which is what previously masked this). The doStream `armStartWatchdog()` (`src/claude-code-language-model.ts`, fired only on the fresh-turn write path) complements the existing inactivity watchdog, which explicitly skips the pre-content gap (`if (!hasReceivedContent) return`). On first fire (default 90s, env `CLAUDE_CODE_START_WATCHDOG_MS`) it respawns the child via `respawnActiveProcess` (`src/session-manager.ts`) — which kills the wedged child but REUSES its proxy server, system-prompt file, and mcp hash (their handles are baked into the original `cliArgs`) and appends `--resume` so the conversation resumes transparently (`--session-id` would be rejected with "already in use" once a transcript exists — see the `--resume` gotcha; adapted during absorption on top of PR #18). The replacement inherits the old process's in-flight marker (`turnWasInFlight` read before the swap, `noteTurnStarted(replacement)` after; @broskees' `b719497`), and `deliverPendingCompletions` calls `noteTurnStarted` before its own write, so a recovered continuation is busy for abort, LRU eviction, the idle timer and the next turn's quiesce; before that handoff every one of them read the working replacement as idle. Still no permanent `lineEmitter` listener for it: `listenerCount("line") === 0` is load-bearing for the unattended buffer and `/btw`. The old child's exit handler is silenced (`removeAllListeners("exit")`) before kill so it doesn't close the reused proxy. A second fire (respawn also silent) ends the turn with an error + `deleteActiveProcess` so the next opencode turn spawns fresh. `cliArgs` is hoisted to doStream scope so the watchdog (which lives outside the non-interactive `else` spawn block) can see it. The tool-result turn path (`hasMatchedPendingResults`) does NOT arm the watchdog — no envelope is written there (the proxy resolution unblocks claude directly). Tests: `test-respawn.ts`. +- Todo ledger translates Claude CLI's granular `TaskCreate`/`TaskUpdate` family into opencode's full-list `todowrite` so the opencode todo panel populates during multi-step Claude work. State lives in `src/todo-ledger.ts`, keyed by Claude CLI session id, cleared via `clearLedger` from `deleteClaudeSessionId` in `session-manager.ts`. TaskCreate stashes pending by `tool_use_id` on tool_use and commits on tool_result (parsed via `/Task\s*#?\s*(\d+)\s+created/i`); TaskUpdate mutates in place. Without `sessionId` in `MapToolOptions`, both fall back to `{skip: true}` to preserve safety for callers that haven't been threaded. Tests live in `test-todo-ledger.ts` and `test-tool-mapping.ts`; live UI verification requires a fresh opencode session with a multi-step Claude task. +- Subagent todos require `permission: { todowrite: "allow" }` on the subagent definition. opencode's `task.ts:197` injects `todowrite: false` into the tools dict for subagents that don't have the rule, so the ledger's synthetic todowrites surface as `⚙ invalid` in the subagent's stream. Built-in `general` denies todowrite by default (`agent.ts:171`); custom subagents must grant it explicitly. When permission is granted, the data flow is fully verifiable in `~/.local/share/opencode/opencode.db`: rows land in the `todo` table and parts with `tool="todowrite"` appear in the `part` table for the subagent's session id. Todos then render inline in the subagent's session view (navigate via `session.child.next`), not the parent's. Empirically confirmed 2026-05-16 via subagent `ses_1d16d3bb4ffeOI5QUWZzBKDsSL`. +- Verified compatible with **opencode v1.18.31** (re-audited 2026-09-19: `1.18.29 → 1.18.31` is **byte-identical in both packages** apart from the `version` field, 69 plugin files and 79 sdk files compared each side, so every finding below still stands verbatim). Fetch the tarballs from `registry.npmjs.org` with `curl` rather than `npm pack`: on a slow link `npm pack` of four packages exceeded a 300 s timeout twice, while the direct tarball fetch took seconds. Previously audited at v1.18.29 (2026-09-07, by diffing the published packages 1.18.18 → 1.18.29). **`@opencode-ai/plugin` is byte-identical apart from `package.json`**, so every v1 hook we implement is unchanged, including `chat.params`, whose output still carries `options: Record` at the top level (the "do not pre-nest under providerID" gotcha still holds). **SDK v1 (`dist/gen/*`) is byte-identical too**: `McpStatus` is still the same five variants, so `enabled: status === "connected"` in `mcp-bridge.ts` stays correct, and the v1 `Model` type did not move. The entire delta is in **v2**, which we do not use: provider `chunkTimeout` widened to `number | false`, its and `headersTimeout`'s docs now name a 300000 ms default, `GlobalUpgradeData.body.target` became required, and an `upgrade` doc string was reworded. Nothing to change in the plugin; the 1.18.5 audit notes below still stand in full. + - **`src/opencode-types.ts` is not a copy of any single upstream type, so do not "fix" it by pasting one in.** Its `OpenCodeModel` blends two schemas: `release_date`, and the flat models.dev-shaped provider config entry, come from the **v1 config schema**, while nested `capabilities` with `interleaved` matches the **v2 runtime `Model`**. v1's own runtime `Model` has none of `interleaved`, `release_date`, `family`, `variants` or `limit.input`. The blend is what opencode actually accepts from the `provider.models()` hook, confirmed empirically: models resolve and sessions run on 1.18.29 (live probes, 2026-09-06). Non-load-bearing but worth knowing: v2 documenting 300000 ms as the ambient timeout default is consistent with the 300 s proxy wall, though that wall is in the Claude CLI's MCP client, not opencode's fetch, so it is corroboration and not proof. +- Earlier audit, opencode v1.18.18 (2026-08-20, by diffing the published packages: `@opencode-ai/plugin` 1.18.5 vs 1.18.18 is byte-identical apart from `package.json`, and the only `@opencode-ai/sdk` type change is `capabilities.interleaved` widening — `reasoning_details` became `reasoning_text` and bare strings/booleans are accepted. `src/opencode-types.ts` was updated to match; we pass `interleaved: false`, so nothing else moved. The 1.18.5 audit below therefore still stands in full). Original audit 2026-07-26 (audit notes, against the published `@opencode-ai/plugin@1.18.5` + `@opencode-ai/sdk@1.18.5` type surface, plus a live `opencode run` turn on that binary). Nothing we depend on broke, because the plugin does not import opencode's types at all — `src/opencode-types.ts` is a hand-written structural mirror, so drift is silent and has to be audited deliberately. Findings worth remembering: + - The **v1 `Hooks` surface is unchanged** where we touch it: `config`, `provider: { id, models(provider, ctx) }`, `chat.params` (output still has `options: Record` at the top level, so the "do not pre-nest under providerID" gotcha still holds). + - A **v2 plugin API** now ships alongside it (`@opencode-ai/plugin/v2`, effect + promise flavors, `PluginContext` with `aisdk` / `catalog` / `agent` / `skill` / `command` hooks). It is additive; v1 `Plugin` is still the documented entry. Migration is optional — tracked in issue #24, do not start it casually. + - `PluginInput` gained `serverUrl: URL`, `$: BunShell`, `worktree`, `experimental_workspace`. Still **no version field** (see the diagnostics gotcha). + - `McpStatus` is still the same 5 variants, so `enabled: status === "connected"` in `mcp-bridge.ts` remains correct. + - The model schema (`sdk/v2` `Model`) gained optional `cost.tiers` (`{ tier: { type: "context", size } }`) and `cost.experimentalOver200K`, and `capabilities.interleaved` gained a `field: "reasoning"` variant. All optional, so our `defineModel` output still validates. Long-context pricing for the `1_000_000`-context entries is now expressible — issue #24. + - New hooks that overlap features we hand-rolled: `tool.definition` (description/param overlay), `experimental.session.compacting` + `experimental.compaction.autocontinue` (our `/compact` detection and auto-continue nudge), `experimental.chat.system.transform`, `chat.headers`, `permission.ask`. + - CLI flags changed: `opencode run` no longer accepts `-a` as shorthand for `--agent` (spell it out in smoke tests), and gained `--variant`, `--thinking`, `--auto`, `--pure`, `--fork`, `--attach`. + - **Superseded as of PR #39:** this line used to read "opencode's `tools` argument to `doStream` is still intentionally unused". It is read now, and it is the only place a provider plugin can see opencode's MCP tools, because they join the model tool set after `ToolRegistry` has been enumerated (see the `proxyOpencodeMcpTools` gotcha above for the bundle evidence). `resolveMcpProxyToolDefs` reads it for exactly that purpose and nothing else: Claude CLI is still only offered its own built-ins plus whatever reaches it through `--mcp-config` or the proxy, so opencode-native tools like `task_status` still never reach the model and still need no `mapTool` entry. Do not "tidy away" the read on the strength of the old sentence. + - Re-audit at the next opencode minor bump. The `opencode` field in the startup block names the running version, so an audit starts by reading that. +- `cwd` resolution at spawn must stay lazy. `opencodeProjectDirectory` captured from `PluginInput.directory` lives in `runtime-status.ts` and is consumed via `resolveSpawnCwd()` at spawn time only as a fallback when `process.cwd()` is unusable (`/`). Do NOT bake the captured value into `mergedOptions.cwd` during provider registration in `index.ts` — that freezes it at plugin init and breaks workspace switching mid-session. The v0.2.4 fix did exactly this and it shipped as the v0.4.21 regression report on issue #4. Tests live in `test-cwd-resolution.ts`. + - **Serve mode gets a tier between the pin and `process.cwd()`: the session's own `directory`** (`resolveSpawnCwdForSession` in `runtime-status.ts`, cherry-picked from @galvani's `9e02ce4`, absorbed 2026-09-06). In `opencode serve` / web UI / OpenChamber one long-lived server handles many projects and `process.cwd()` is the server's launch dir, which is "usable", so it won and **every** `claude` spawned there. `GET /session/{id}` carries `directory`; it is fetched per call (no cache, a workspace switch can change it) keyed by the affinity id, and any failure falls back to the old resolution so the TUI path is unchanged. Live-verified: server launched from `/tmp`, session created with `?directory=`, spawn log `cwd` = the project's realpath. `describeSpawnCwd` for the startup block still mirrors the synchronous order only; the session tier is per call and cannot be described at init. +- `AskUserQuestion` is auto-denied in `controlRequestBehaviorForTool` (so the headless CLI can't self-answer an empty TTY) and rendered to the operator as markdown via `formatAskUserQuestion`. The deny message (`denyMessageForTool` / `ASK_USER_QUESTION_DENY_MESSAGE` in `claude-code-language-model.ts`) must tell the model to **stop and wait unconditionally** — end the turn, no more tools, no self-answer. Before v0.7.0 it offered an "if non-interactive, proceed with a reasonable guess" escape hatch; the model could not tell interactive opencode from a headless run and routinely took it, so questions appeared skipped (issue #8). Do not re-add a proceed-anyway clause to that message. Behavior is verified via `denyMessageForTool` in `test-ask-user-question.ts`; the full stop-the-turn flow needs a live opencode session where the model calls AskUserQuestion. Two reinforcing guards were added after v0.9.1: (1) the deny message explicitly states it is **not a cancellation** and forbids the model from saying the question was cancelled/skipped/declined — this kills the "the user cancelled, so I'll proceed" rationalization the model otherwise narrates; (2) a turn-local latch `AutoContinueState.sawAskUserQuestion`, set when `formatAskUserQuestion` renders, makes `shouldAutoContinueIncompleteTurn` return `{continue:false, reason:"question"}` for the rest of the turn. Without the latch, a short non-`?` trailing line after the question (e.g. "I'll go with the first option.") looked like an incomplete turn, and the auto-continue nudge made the model proceed with no operator input — the exact "I never interacted and it answered itself" symptom. Latch test in `test-auto-continue.ts`. +- **The `AskUserQuestion` fallback is currently dormant in headless mode.** Probed 2026-07-26 against Claude Code CLI **2.1.211**: the name is still *known* to the CLI (`--disallowedTools AskUserQuestion` validates silently, while a bogus name prints `matches no known tool`), but the tool is **not offered to the model** under `--print` — a direct "list every tool you can call" returns `Agent, Bash, Edit, Read, ReportFindings, Skill, ToolSearch, Workflow, Write`, and `ToolSearch select:AskUserQuestion` returns nothing. It reads as a TUI-only affordance the headless surface no longer presents. Consequence: with `Question` off (the default), the model has **no** question tool at all and can only ask in prose and end the turn — which is what the deny/markdown path produced anyway, so behavior is unchanged, but do not expect `formatAskUserQuestion` or the auto-continue latch to fire on this CLI. Keep the machinery (older/newer CLIs and the interactive transport may still offer it); just do not treat "the fallback did not render" as a plugin bug without re-running the two probes above. Evidence is model self-report plus the ToolSearch miss, both on haiku. +- **Question proxy is blocked upstream — leave it off.** Verified 2026-07-26 on opencode 1.18.5: the proxy delivers correctly but opencode's own `question` TUI form never renders, so an enabled `Question` costs you the working `AskUserQuestion` fallback and gives a silent hang the operator can only escape by interrupting. Proof it is not ours: (a) `github-copilot/gpt-5.5`, a native provider with the plugin nowhere in the path, fails identically (`Tool execution aborted`, `metadata.interrupted: true`, ~27 s); (b) the `part` table shows every `question` call `completed` through 2026-04-25 and every one since 2026-05-18 aborted, i.e. an opencode regression somewhere in v1.14.24…v1.15.5 (note `The user dismissed this question` is a *different*, healthy error — it means the form rendered); (c) a `--pure` (no-plugin) headless `opencode serve` drives the whole server path green — tool blocks, `question.asked` publishes, `GET /question` lists it, `POST /question/{id}/reply` completes the tool with the answer and emits `question.replied`. So the server is fine and only the TUI render is broken. Upstream: anomalyco/opencode issue **#36604** (open) with fix **PR #36603** (`hydratePending()` at TUI bootstrap, open since 2026-07-13, unmerged). Re-test when that merges; until then do not promote `Question` toward the default list, and do not spend time debugging the proxy for this symptom. +- Question proxy (absorbed from @jknlsn's `47501d0`, on master after 0.11.2) is the **opt-in alternative** to the deny/markdown path above, not a replacement for it. `"Question"` is deliberately NOT in `DEFAULT_PROXY_TOOL_NAMES` (`src/index.ts`) — enabling it disables Claude's built-in `AskUserQuestion` via `--disallowedTools` and swaps the unconditional stop-and-wait guarantee for an in-turn blocking form, which is a trade against issue #8. Keep it opt-in until it has Task's mileage; the comment above the constant records why, so do not "tidy" it into the default list. Three invariants: (1) `--disallowedTools` is computed from the **post-filter** proxy list (`enrichedProxy`), never `resolvedProxy` — `filterQuestionProxyByOpencodeSupport` drops the def on opencode builds without a `question` registry entry, and computing from the pre-filter list would disable `AskUserQuestion` while its replacement is absent, leaving the model with no question path at all. (2) `QUESTION_PROXY_HINT` must name the FULL `mcp__opencode_proxy__question`: haiku strips the MCP prefix and calls bare `question`, which opencode renders as `⚙ invalid` (same near-miss family as TaskCreate vs the task proxy). (3) `question` gets a 30-min default in `PROXY_PER_TOOL_DEFAULT_TIMEOUT_MS` because it blocks on a human reading a form; the flat 10-min ceiling rejected calls mid-answer. `fetchLiveToolInfo` does ONE `client.tool.list()` fetch feeding the task overlay, the question gate and the plan-mode gate — do not add a second fetch; `liveToolInfoOnce()` memoizes it per model instance for exactly that reason, and deliberately does **not** memoize an unresolved fetch (`resolved: false`) so a not-yet-ready opencode server cannot disable every overlay for the life of the process. The proxy defs stay spawn-time, so a reused process keeps its defs. Verified live on opencode 1.18.5 (registry has `question`); a build lacking it takes the fallback silently, which the `question proxy version gate` log line makes visible. Tests: `test-proxy-mcp.ts`, `test-cli-args.ts`, `test-subagent-hint.ts`, `test-ask-user-question.ts`. +- **`planModeQuestion` cannot fire on the headless transport, and `permissionMode: "plan"` is inert at the default `skipPermissions`. Both measured on CLI 2.1.258, 2026-09-06.** The bridge keys on an `ExitPlanMode` tool_use, and headless `--print` does not offer that tool: asked for its tool list in plan mode the model returned `Agent, Bash, Edit, ListAgents, Read, ReportFindings, ScheduleWakeup, Skill, ToolSearch, Workflow, Write`, said "I'm unable to exit plan mode from within the tool set available to me" when asked to work, and a full probe through the plugin (`planModeQuestion: true`, `skipPermissions: false`, opencode 1.18.29) logged no `ExitPlanMode` at all while the model asked for approval in prose and its blocked `write` produced no file. `--disallowedTools ExitPlanMode` still validates silently where a bogus name warns, so the name is known and this is headless dormancy, exactly the `AskUserQuestion` shape above — do not read "the bridge did not fire" as a plugin bug without re-running those probes. **The separate trap is now fixed: `buildCliArgs` drops `--dangerously-skip-permissions` when `permissionMode` is `"plan"`.** It used to push both independently and the CLI lets the skip flag win, so `permissionMode: "plan"` at the default `skipPermissions: true` gave no plan mode whatsoever: verified by writing a file, unprompted, in a plan-mode run, where the same request without the skip flag was refused. Plan mode is a capability restriction rather than a prompt policy, so it wins; every other mode governs prompting, which is exactly what the skip flag is for, and still passes both. Live-verified after the fix through a full plugin probe at default settings: `--permission-mode plan` present, skip flag absent, requested file never created. Do not "restore symmetry" by making the flag unconditional again. The honest remainder, which `warnIfPlanModeCannotExit` in `index.ts` states once per process at WARN: nothing releases plan mode mid-session, so an enforced plan mode is a one-way door out of which the only exit is editing config and restarting opencode. The CLI does still write its own plan markdown under `~/.claude*/plans/`, which is its feature and outside the workspace. Probe scripts: `/var/folders/.../opencode/verify-plan-mode-question.mjs` and `verify-plan-enforced.mjs` (scratch, not in the repo). Tests: `test-cli-args.ts`. +- Plan-mode approval bridge (`src/plan-mode-question.ts`, absorbed from @CollieIsCute's `8c5b583` with authorship preserved, issue #21) is **opt-in via `planModeQuestion` and off by default**, for the same reason the question proxy is: it delivers through opencode's `question` form, and that form does not render (see the gotcha above), so an enabled bridge turns a working text prompt into a hang. Do not promote it to a default until #36603 merges and the round-trip is re-tested live. What it does when on: `ExitPlanMode` stops being rendered as `**Do you want to proceed with this plan?** (yes/no)` text and instead ends the turn on `tool-calls` with a synthetic `question` tool-call, then the operator's answer is turned back into a `tool_result` **for the original `ExitPlanMode` tool_use id** and sent as the entire next user message. That last part is the whole point of the port: Claude Code only leaves plan mode when it sees that `tool_result`, so a "yes" typed as ordinary prose never actually unlocks it. Invariants: (1) the gate is `isPlanModeQuestionActive` (config + live registry has `question` + not compaction) and it is resolved in the doStream/doGenerate **prologue**, not inside the stream body: the ExitPlanMode branches run in a synchronous line handler and a reused process never reaches the spawn block where the registry snapshot is otherwise taken. (2) Both transports have two ExitPlanMode sites each (partial-event `content_block_stop` and whole-`assistant`-message), so a change to one needs the same change to its twin; all four keep the legacy text path verbatim in the `else`. (3) `clearExitPlanModeQuestions(sk)` runs wherever `deleteClaudeSessionId`/`deleteActiveProcess` do, or a stale pending id outlives its session and the next answer is routed to a dead tool_use. (4) `finishReason` must be `tool-calls` (not the usual unconditional `stop`) when a question call was emitted, or opencode never runs the tool. Offline tests: `test-exit-plan-mode-question.ts`. The approval round-trip itself needs a live opencode session with `permissionMode: "plan"` and is **not verified**; it cannot be while the form is broken. + +- Compress proxy tool (`src/compression-store.ts` + the `compress` def in `proxy-mcp.ts`, reimplemented from @flupkede's `4ac319f`/`5b4ee5d` on their unmerged `feature/compress-tool` branch, credit theirs). **Opt-in via `proxyTools: [..., "Compress"]`**, deliberately absent from `DEFAULT_PROXY_TOOL_NAMES` — it throws away the model's working context, which is not something to enable behind someone's back. It is the only proxy tool opencode never sees: `createProxyMcpServer`'s third argument is an interceptor map, and an intercepted `tools/call` is answered in-process (no broker entry, no deadline, no permission prompt). Five invariants: + 1. Interceptor results go out through `writeToolCallResult`, the single exit both the broker and interceptor paths share. The fork wrote a JSON-RPC error envelope on interceptor failure, which Claude CLI rejects as a malformed result (same trap as the proxy-mcp gotcha above). + 2. **The summary must survive `deleteClaudeSessionId()`** — the opposite of the plan-mode-question rule, and the fork got this exactly backwards: it cleared the summary there, and the reset path calls it, so the summary was wiped microseconds before the fresh spawn read it and the feature silently did nothing. `clearCompression` is called only from the `!hasPriorConversation` branch (a new opencode conversation), plus a 32-entry cap in the store. Regression test: "summary survives the session reset that the compress call triggers". + 3. The reset runs inside `doStream`'s `start()`, **after** `userMsg` and `includeHistoryContext` were resolved against the still-live session. That ordering is what makes it a real reset: `includeHistoryContext` stays false, so the fresh child gets this turn's message plus the summary in its system prompt and nothing else. Move the reset earlier and `compactConversationHistory` would replay the whole opencode conversation, which is the opposite of compressing. + 4. It is skipped when `hasMatchedPendingResults` — evicting a child whose tool results are arriving this turn would deliver a `tool_result` to a process that never issued the `tool_use`. The mark is not consumed, so it fires on the next turn instead. + 5. `CLAUDE_CLI_COMPRESS_NOTE` replaces `CLAUDE_CLI_CONTEXT_NOTE` only when `compress` is in the **post-overlay** proxy list (`enrichedProxy`), and it spells out the full `mcp__opencode_proxy__compress` for the same reason `QUESTION_PROXY_HINT` does. The default note still tells the model compress does not exist, which stays true for `doGenerate` (no proxy wiring) and the interactive transport (no proxy server). Tests: `test-compress-tool.ts`. The store/interceptor/prompt layers are covered offline; the end-to-end "model calls compress, next turn is fresh" round-trip is **not live-verified**. + +- **Two different tools want the MCP name `compress`, and the precedence is deliberate** (`proxyOpencodeTools` + `resolveProxyOpencodeToolDefs` in `proxy-mcp.ts`). `resolvedProxyMcpTools` forwards an opencode tool only when its id matches an enabled MCP server (`` or `_`), so a tool another opencode **plugin declares directly** belongs to no server and `if (!matchedServer) continue` drops it. opencode-dcp's `compress` is exactly that, which is why dcp's "MAX CONTEXT LIMIT REACHED ... You MUST use the `compress` tool now" reminders were unobeyable under this provider: the tool is in `client.tool.list()` (confirmed on 1.18.31, alongside `question`, `task`, `skill`, `gemini_quota`, `quota_status`) and was simply never offered. `proxyOpencodeTools` is the explicit allowlist that forwards it, **empty by default**, and never automatic because a forwarded tool executes in opencode with the calling agent's permissions. The collision is resolved **twice, at two layers, and both are load-bearing**: (1) at def level, `taken` holds the names already claimed by `enrichedProxy` and the MCP defs, so a forwarded `compress` is dropped with a WARN rather than becoming a second def of the same name; (2) at interceptor level, `ensureProxyServer` takes an explicit `interceptCompress` flag instead of keying on `tools.some(t => t.name === "compress")`. Layer 2 is the one a def-level check cannot see and the one that actually bit: with **only** the forwarded def present there is nothing to collide with, and the old name-keyed condition would have answered opencode's tool with the plugin's in-process reset ("Summary stored...") while opencode never saw the call. The plugin's own tool wins when both are configured, because it is named explicitly in `proxyTools` and it manages the window that overflows here. `buildAppendedSystemPrompt` follows the same precedence and has a **third** note variant (`CLAUDE_CLI_OPENCODE_COMPRESS_NOTE`) that says the forwarded tool compresses **opencode's** transcript and not the Claude session: reusing the plugin's note would tell the model its context had been discarded when it has not. Live-verified 2026-09-19 on CLI 2.1.263 + opencode 1.18.31 with dcp loaded: `forwarding opencode tools through the proxy {"tools":["compress"]}`, proxy started with `tools: ["bash","compress"]`, `proxy-mcp tool call received {"toolName":"compress"}`, queued through the normal broker, and dcp really ran (`Compressed 3 messages into [Compressed conversation section]`). **The wrinkle to expect:** dcp's compress rewrites opencode's message history mid-turn, so opencode aborts the provider stream at that tool boundary (`abort between proxy tool boundaries; releasing pending calls`) and the result reaches the model on the next step through the issue #29 text path (`rendering opencode-side tool result as text`). The turn completes and nothing leaks, but do not read that abort as a regression. Collision verified live in the same session: WARN emitted, exactly one `compress` in the server's tool list, and the interceptor answered. Tests: `test-compress-tool.ts` (forwarding, unknown name, unreachable registry, both collision layers, note selection). + - **Probing any of this live needs a scratch `XDG_CONFIG_HOME`, not just `OPENCODE_CONFIG`.** opencode **merges** the `plugin` array with the user's global config, so a scratch config still loads the parent checkout's copy of this plugin and its provider registration can win. The symptom is silent and cost three paid runs: the option is visibly present in `GET /config` provider options, yet the model behaves like a build without it, because the language model came from the other copy. Assert `plugin ready` appears exactly **once** in `plugin.log`. Two smaller traps in the same family: a leftover `opencode.json` in a **parent directory** of the probe's cwd beats `OPENCODE_CONFIG`, so give each probe its own cwd; and an account provider's model id carries the marker (`claude-haiku-4-5@appical`), where a bare id 500s as an opaque `UnknownError`. + - **`stripContextReminders`** (`message-builder.ts`) is the other half, also **off by default**. dcp anchors its nudges into **message text** (`lib/messages/inject/utils.ts` appends to an existing text part or splices a synthetic one), not into the system prompt, so each is re-sent with every message that carries it; all of them are wrapped in ``. The strip runs once at the top of `getClaudeUserMessage`, which is why the fresh-session rebuild and the `/compact` transcript get it for free instead of each needing a flag. Three rules: it is matched **wherever the block sits**, because dcp appends `` after one and an end-anchored check would miss it (the same trap the `/btw` strip hit in production); emptied parts are kept as empty strings rather than dropped, since a nudge can be a message's only text part and removing it could leave a user message with no content at all; and it must never touch opencode's own `` blocks, which are opencode's instructions to the model. `shouldStripContextReminders` turns it off as soon as `compress` is named in either list, resolved from **config alone** so it is answerable before the spawn block (`userMsg` is built well ahead of it) and so a configured-but-unregistered name errs toward keeping the reminder. Tests: `test-get-claude-user-message.ts`. +- **Account failover is ON by default, and the thing that makes that safe is that the pick is the consent** (`src/account-failover.ts`). With more than one account configured, a usage limit ends the turn on opencode's native `question` form (the same mechanism as the plan-mode bridge: `finishWithQuestionCall` emits `tool-input-start` + `tool-call` and finishes on `tool-calls`, and the answer arrives on the NEXT `doStream` as a `tool-result` with the same id) instead of the rate-limit error. Nothing moves until an account is picked, and an unanswered form waits at zero cost. Seven things hold it together and none is optional: + 1. **Detection is two exact signals, never "an error".** `isAccountLimitError` fires on a `rate_limit_event` that `isRateLimitRejected` accepts, or on one of `ACCOUNT_LIMIT_PATTERNS` (the two texts this file already records). A generic 4xx opening this form would silently move where usage is billed, which is the one failure mode that would be worse than the error it replaces. The rate-limit branch parses the event **separately from `reportRateLimitEvent`**, which dedupes per process and returns null on a repeat: the second rejection in a session is still a rejection this turn must act on. + 2. **The override is keyed on the LIMITED ACCOUNT, not the session.** A rate limit is a property of the account, so one pick covers every session on it and a subagent follows its parent for free. That is also why `isAccountFailoverQuestionActive` refuses child sessions (`fetchSessionParentId` in `runtime-status.ts`, off the same `GET /session/{id}` as `fetchSessionDirectory`): a form in a subagent session is one nobody is looking at. + 3. **`--resume` can never cross accounts**, because transcripts live under the account's own `CLAUDE_CONFIG_DIR`. A switch is therefore always a fresh session with the thread replayed: the prologue drops the active process **and** the Claude session id when `ActiveProcess.cliPath` differs from the resolved one, which is what makes `includeHistoryContext` true, and is equally what switches back once the override expires. The comparison is guarded on `active?.cliPath &&` so the interactive shim, which carries no path, is never dropped by it. + 4. **The `@account` suffix must come off the model id.** `parseModelId` keeps it on purpose (the source account's own wrapper strips it), but a failover spawn goes through a *different* wrapper, or the bare binary for `default`, and `--model claude-opus-5@appical` is rejected outright. `resolveFailoverSpawn` strips it; `parseModelId` is called on `failover.modelId`, not on `effectiveModelId`. + 5. **A reset time that is not in the future degrades to "until opencode restarts".** Found by the fake-CLI test, not by reasoning: with `until` behind `now` (clock skew, a stale `resetsAt`), `resolveAccountOverride` deleted the override on the very next read, so the switch the operator had just authorised was undone before it ran and the turn re-hit the same limit and asked again. `setAccountOverride` clamps it. + 6. **The dialog must never be replayed.** The synthetic `question` tool-call and its `tool-result` carry `account_failover_` ids Claude never issued or saw, so `stripAccountFailoverParts` is called from `filterSideQuestionHistory` (both transcript rebuild paths) and from `buildFailoverContinuationPrompt`. `FAILOVER_MARKER` is registered in `PLUGIN_NOTE_MARKERS` and the note is enqueued as its own text part, the same rule every `▌` line follows. A message left with no content after the strip is dropped rather than replayed empty. + 7. **`doGenerate` takes the override with no dialog of its own.** A title or no-tools call must not ask anything, but it must follow the account the conversation moved to, or it quietly bills the limited one. + Excluded entirely: compaction (its answer would have nowhere to go) and the interactive transport (TUI stdin, no proxy server). `clearAccountFailoverQuestions` is wired into `deleteClaudeSessionId` next to `clearExitPlanModeQuestions`. `ExitPlanModeQuestionCall` is now an alias of the shared `QuestionToolCall`, and `unwrapToolOutput`/`collectAnswerStrings` are exported rather than copied. Tests: `test-account-failover.ts` (23, including the negative detection cases and a fake CLI that answers differently depending on the `CLAUDE_CONFIG_DIR` it was reached through, which is how the routing, the stripped `--model` and the `` replay are asserted). **Not live-verified**: the whole path is offline only, so the first real-account run is the one that proves it. +- `ignoreAnthropicApiKey` (added 0.9.1, issue #9 secondary ask from @Aptul9): a stray `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` in the env makes Claude Code authenticate with the key (pay-as-you-go Console billing) instead of the logged-in subscription, silently bypassing the Agent SDK plan credit. The flag strips both vars from the spawn env. The single strip point is `claudeSpawnEnv({ ignoreAnthropicApiKey })` in `session-manager.ts`; the headless `doGenerate`/`doStream` spawns and the interactive transport (`ClaudeSessionOptions.ignoreAnthropicApiKey` → inline env block in `claude-session-bun.ts`) all thread it through. Default off so deliberate API-key users are unaffected. `warnIfAnthropicApiKey` in `index.ts` logs a one-time startup warning whenever a key is present, regardless of the flag. Tests: `test-spawn-env.ts`. +- Interactive transport (opt-in, `src/claude-session-bun.ts` + `src/claude-session-wrapper.ts`): `spawnInteractiveProcess` returns an `ActiveProcess`-shaped shim so doStream's line handler, session reuse, and eviction work unchanged. Key invariants: (1) doStream writes stream-json user envelopes to `stdin.write`; `decodeUserEnvelope` converts them to typed plain text — text blocks joined, `tool_result` rendered as labeled text, image/other blocks dropped with a logged warning (never paste base64 into a TTY). (2) The wrapper synthesizes the terminal `{type:"result"}` line; a turn with no terminal stop_reason (timeout/exit mid-turn) MUST stay `subtype: "error_during_execution", is_error: true` — do not "clean it up" to `end_turn`, that masks truncation from the user and from auto-continue. (3) The appended prompt reaches the TUI only via `--append-system-prompt-file` (built per spawn, unlinked on kill); interactive mode intentionally appends only this plugin's CLI note, AGENTS.md guidance, and continuation hint by default, not opencode's forwarded system prompt, because live testing showed that forwarded `extra` payload can trigger Claude Code's third-party-app usage gate on subscription accounts. `interactiveSystemPrompt: false` is diagnostic-only and drops even the plugin prompt. (4) There is no `can_use_tool` control channel in the TUI — permissions are pre-allowed via `--settings '{"permissions":{"allow":[...]}}'`: MCP wildcards always derived from the live bridge config, built-ins from `interactiveAllowTools` (default Bash/Edit/Write/Read/WebFetch). Do NOT pass `--permission-mode bypassPermissions` in interactive mode: Claude Code shows a manual safety confirmation and defaults to "No, exit", so pasted prompts can terminate the process. (5) The interactive spawn must use the configured `cliPath`, not plain `claude`; account providers rely on wrapper scripts like `~/.cache/opencode-claude-code-plugin/claude-` to strip `@account` model suffixes and set `CLAUDE_CONFIG_DIR`. The JSONL tail path must use the same `configDir` (`~/.claude-` for account providers), otherwise opencode hangs while Claude writes transcripts elsewhere. (6) The `Bun.Terminal` capability gate falls back to headless silently. (7) Compaction always takes the headless path. Turn timeout default is 30 min (`turnTimeoutMs` in `claude-session-bun.ts`). Offline tests: `test-claude-session-wrapper.ts`; live verification needs a Bun-run opencode with `interactive: true`. + +- Startup diagnostics (`src/startup-diagnostics.ts`, roadmap #3): one `NOTICE: claude-code plugin ready` block emitted once per process from the `config` hook in `index.ts`, replacing the older "registered claude-code provider(s)" notices. Fields: plugin version, opencode version, `claudeCli` path+version, `cwd` **with the branch that won** (`configured` | `process` | `captured` | `unresolved` — `captured` is the issue-#4 macOS-GUI fingerprint), provider ids, accounts, `proxyTools`, enabled MCP servers, interactive-transport flag, `anthropicApiKeyInEnv`. It is fire-and-forget (`claude --version` is async, 5s timeout, cached) and every field is wrapped so diagnostics can never break provider registration. `describeSpawnCwd` intentionally mirrors `resolveSpawnCwd`'s priority order and a test asserts they never disagree — change both together. The MCP list is the **disk-only** merge (`mergeOpencodeMcp`, split out of `bridgeOpencodeMcp` so diagnostics never writes a scratch config): opencode's runtime status isn't settled at plugin init, so the per-turn overlay is deliberately not applied. The `opencode` field is resolved by `detectOpencodeVersion()`: the plugin runs inside opencode's process, so `process.execPath` **is** the opencode binary and ` --version` is the only reliable source (cached, 5s timeout, guarded on the basename containing "opencode" so a `bun run` from source reports "unknown" instead of Bun's version). It is only spawned when the plugin input and `OPENCODE_VERSION` gave us nothing. Do not "fix" this with an SDK call: re-verified on **1.18.5** that nothing on the plugin surface carries the version (`PluginInput` has no version field, the SDK client's `app` namespace is still only `log` + `agents`, and the server exposes no `/version` route — the route list in `sdk.gen.js` has none). To see the block: `OPENCODE_CLAUDE_CODE_LOG_FILE=1 opencode` then read `~/.local/share/opencode-claude-code/plugin.log` (the plugin logger is silent by default and does **not** write to opencode's own log). Tests: `test-startup-diagnostics.ts`. + +### Current plan-mode registry and cleanup semantics + +These rules supersede the older lifetime-cache and process-cleanup wording in the question-proxy and plan-mode notes above: + +- `createLiveToolInfoLoader()` shares one lazy `client.tool.list()` request within a `doStream` turn. A later turn creates a fresh loader, and `doGenerate` fetches per call, so runtime tool changes do not stay cached for the model lifetime. +- `deleteClaudeSessionId()` is the cleanup boundary for pending ExitPlanMode approvals. Process-only deletion or respawn intentionally preserves them because the same Claude session can resume; every destructive session reset clears them centrally through `deleteClaudeSessionId()`. + +- **Auto-continue never fires on current Claude Code CLI.** Measured 2026-08-19 from `~/.local/share/opencode-claude-code/plugin.log`: 53 decisions stopped at `reason: "end-turn"` with `attempts: 0`, 12 at `error`, and nothing else. **Re-measured 2026-09-20 against CLI 2.1.263 and the conclusion holds, but two sentences of the original are wrong and are corrected here.** The window was 2026-09-19T20:13Z to 2026-09-20T01:29Z (the log rotates at ~5 MB, which is what bounds it), 275 decision lines, of which only **47 are production**: the other 228 carry a `/var/folders/` cwd or a `claude-test-*` model id and are the suite's own fake CLIs. Split them before reading a tally or the picture is badly wrong. Production: 34 `end-turn` (`stop_reason: end_turn`), 13 `error` (`stop_reason: stop_sequence`), **zero continuations, every line `attempts: 0`**, and no `max_tokens` anywhere, so the truncation branch still has no production mileage. The two corrections: (1) `stop_sequence` is a stop reason this record never mentioned and it is now a third of production decisions, and it lands on `reason: "error"` because `snapshot.isError` is checked **before** the stop-reason guard. (2) **"The CLI always emits a `stop_reason`" is not strictly true on 2.1.263.** An interrupted turn's `result` carries `stop_reason: null` with `subtype: "error_during_execution"`, measured directly (see the abort probe below). The conclusion survives anyway, and for a reason worth keeping: that same result has `is_error: true`, and the `isError` check short-circuits above the guard, so the heuristic is still never consulted. The only `stopReason: null` lines that reach the heuristic in the whole log come from the fake CLI in `test-unattended-replay.ts`, which is what the fallback is for. Also re-checked on 2.1.263 and **not** found: any sign that the CLI continues itself, which would risk our nudge doubling up on a turn. A plain `-p` turn emits exactly one terminal `result` (`num_turns: 1`), and an interrupted turn emits exactly one error `result` and then stays silent until a new user message. The 515 real `replaying stdout the child emitted between turns` lines are **not** this: that is the known proxy-detach path (PR #35), not unprompted continuation. The stall and server-error cases the backlog claims were not reproducible cheaply and are therefore **not established either way**. Abort was re-verified live on 2.1.263 by driving the real CLI over stream-json stdin with exactly `interruptTurn`'s payload: `control_response` `subtype: "success"` came back in **1 ms** (now carrying `still_queued: []`, matching the new `interrupt_receipt_v1` / `interrupt_cancel_queued_v1` capabilities that `system`/`init` advertises), the turn ended 331 ms later, only **14 characters** streamed after the interrupt, and the same process answered the next turn cleanly with `stop_reason: end_turn`. Unparsed-but-forwarded stream events as of 2.1.263, reported and deliberately **not** implemented in that lane: the CLI's SDK allowlist forwards `tool_progress`, `tool_use_summary`, `prompt_suggestion`, `conversation_reset` and `command_lifecycle`, none of which `src/cli-events.ts` reads; `system` also has `hook_started` / `hook_response` subtypes (seen live), `compact_metadata` gained an optional `cumulative_dropped_tokens`, and `init` now carries `agents`, `capabilities`, `plugins`, `skills` and `slash_commands` among others. `conversation_reset` is the one worth a look first, since a reset would invalidate the `toolCallMap` and pending-proxy bookkeeping. `/goal` and `/loop` **are** advertised in `init.slash_commands` under `--print` on 2.1.263 (159 commands listed), which confirms the backlog's claim that far; neither was executed, deliberately, because `/loop` can loop. The original reasoning, as corrected above: the CLI emits a `stop_reason` on every turn that is not aborted, and `shouldAutoContinueIncompleteTurn` treats any `stop_reason` as authoritative (v0.4.17), so the keyword heuristic below that guard (`looksLikeFinalAnswer` / `looksLikeQuestion` / `looksLikeBlocker` and the whole v0.4.10 to v0.4.15 idiom list) is dead code in practice, and `autoContinueIncompleteTurns: "smart"` behaves as `off`. @JWebCoder's PR #15 diagnosed this correctly; it was closed because the remedy (delete the guard) promotes the regex back to the deciding vote on every turn, which is exactly what v0.4.17 removed, and it also carried a `package-lock.json` this repo deliberately does not have. **The narrow change has since been made, and not the way that sentence originally proposed.** `isTruncationStopReason` (`max_tokens`, plus `max_output_tokens` as a defensive alias) now returns `{continue: true, reason: "truncated"}`, bounded by the attempt and elapsed rails, while every other `stop_reason` stays authoritative. It could not simply "fall through to the heuristic": the common truncation case is one long prose answer with no tool or reasoning activity, which dies at the `no-activity` gate a few lines below, so truncation had to be authoritative in the opposite direction instead. It runs at the default `autoContinueIncompleteTurns: "smart"`, which is what makes it reachable at all given everything else about that setting behaves as off. Do not delete the heuristic either: it is the fallback for CLIs that omit `stop_reason`. Tests: `test-auto-continue.ts` (five cases, all failing with the branch stubbed out). + - **A compaction turn must never be nudged, and truncation-continue is what made that reachable.** `AUTO_CONTINUE_PROMPT` says "Do not summarize; keep working", the exact inverse of a `/compact` turn's job, and continuation reopens the same stream instead of closing it, so the non-summary text would be appended to what opencode stores as the session summary. `doStream` builds `autoContinueState` inline and passed `self.config.autoContinueIncompleteTurns` straight through with no `compactionMode` term, which was harmless only while every `stop_reason` returned `continue:false`. `autoContinueEnabledFor(compactionMode, configured)` now gates it, exported purely so the wiring is testable rather than only the pure decision function. Bounded at 8 attempts either way, so the pre-fix worst case was an inflated and corrupted summary, not a hang. Found by a subagent review of the truncation change, not by the test suite, which had no compaction case at all. +- **opencode's `tool.definition`, `experimental.session.compacting` and `experimental.compaction.autocontinue` hooks were evaluated on 1.18.29 and deliberately NOT adopted** (issue #24). `tool.definition` fires only inside opencode's own `ToolRegistry.tools`, over built-ins plus filesystem/plugin-declared tools; **MCP tools are not in that registry**, and the MCP assembly path triggers only `tool.execute.before`/`after`. So it cannot reach the proxy defs this plugin serves to the Claude CLI, and it could not do the job anyway: opencode appends `describeTask`'s agent list *after* the hook returns, which is the exact ordering `overlayTaskProxyDescription` exists to control against Claude Code's description truncation. Its input is `{toolID}` alone, with no session/provider scope, so any edit would reshape tools for every provider in the user's opencode. The compaction hooks are a prompt-authoring hook and a veto on opencode's post-compaction synthetic turn; neither is registered here, so they cannot interact with this plugin's auto-continue, and they operate on a different boundary regardless (an opencode turn versus a CLI turn inside one opencode turn). `experimental.session.compacting` would also be strictly worse for detection than `opencodeAgent === "compaction"`, which is available synchronously per call and drives the model override, effort exemption, session key and lean spawn. +- **v2 plugin API: do not migrate, and the reload that exists is not the one we want** (tracker is issue **#31**, checked on 1.18.29; #24 is closed and is not the tracker any more). `Reload` is `{ reload: () => Promise }` (`dist/v2/promise/registration.d.ts`), and `catalog`, `agent`, `command`, `integration`, `reference` and `skill` carry it while **`aisdk` does not** (`dist/v2/promise/context.d.ts`). So model *metadata* can be re-transformed at runtime through `CatalogHooks = Hooks<{transform: CatalogDraft}>`, but the model implementation path cannot. That does not touch this plugin's actual pain point: provider options are captured at `createClaudeCode()` and baked into each `ClaudeCodeLanguageModel`, and `catalog.reload()` re-runs a catalog transform rather than re-reading `provider.claude-code.options`. The restart requirement is opencode's config loading, not the plugin API. Even the metadata win is nil here, since `src/models.ts` is a static registry that only changes on package upgrade, which requires a restart anyway. v1 is **not deprecated**: all five `@deprecated` markers in `dist/index.d.ts` are unrelated (auth-prompt `condition` → `when`, and the `AuthOuathResult` typo alias). + +- **Every `▌`-led text part the plugin writes must be registered in `PLUGIN_NOTE_MARKERS`** (`src/message-builder.ts`). The `/btw` aside started this, and there are now five more: the turn-stats footer (`TURN_STATS_MARKER`), the CLI self-compaction note, the rate-limit rejection line, the failed-result-subtype line, and the doctor report. None of them was ever Claude's output or ever in Claude's context, so a transcript rebuilt for a fresh CLI process must not hand any of them back as something the model said. The strip is **part-level and anchored at the start** for the same reason the aside's is: it matches `text.trimStart().startsWith(marker)` on a whole text part, so every one of these has to be enqueued as **its own** text part (`startTextBlock()` before it) rather than appended to the model's block. `filterSideQuestionHistory` also now drops the `/claude-code-doctor` user message and its reply, the same way it drops a `/btw` pair; it kept its `/btw`-era name because it is called from both transcript rebuild paths and renaming it would touch code two other lanes are in. Tests: `test-turn-stats.ts`, `test-doctor.ts`. +- **`turnStats` is off by default and must never fire on a compaction turn or a failed one** (`src/turn-stats.ts`, consumed in `doStream`'s `result` branch). A footer on a `/compact` turn would be appended to what opencode stores as the session summary, the same trap the auto-continue compaction fix documents above; a footer on a failed turn puts the bill where the error belongs. The numbers are the **turn totals** (`msg.usage` directly), deliberately not the last-iteration figures `toUsage` prefers: `toUsage` feeds opencode's context gauge, where summing iterations inflates the window and triggers premature compaction, while a cost line has to match `total_cost_usd`, which is cumulative. The same numbers go to `log.info` whatever the option is set to, because the footer is a display preference and the numbers are diagnostics. `permission_denials` reaches `providerMetadata` as **names and ids only**: a denial carries a `tool_input` on the wire that can be a whole file-write payload, which is why `ClaudeStreamMessage` deliberately does not declare that field. Tests: `test-turn-stats.ts`, `test-cli-events-stream.ts`. +- **The four CLI stream events in `src/cli-events.ts` were read out of the CLI's own zod schemas, not guessed** (2.1.263, `rg -a` over `~/.local/share/claude/versions/`; the binary is a Mach-O bundle and the schemas are in it as plain text). Confirmed shapes: `{type:"rate_limit_event", rate_limit_info:{status:"allowed"|"allowed_warning"|"rejected", rateLimitType?, resetsAt?, utilization?, isUsingOverage?, overageStatus?, overageResetsAt?, overageDisabledReason?}}`; `{type:"system", subtype:"compact_boundary", compact_metadata:{trigger:"manual"|"auto", pre_tokens, post_tokens?}}` (the stream schema says `compact_metadata`, the CLI's own transcript reader says `compactMetadata`, and both are parsed because both exist in the binary); `system`/`init` carrying `apiKeySource`, `permissionMode`, `model`, `tools[]`, `mcp_servers[{name,status}]`, `claude_code_version`; and `result` carrying `modelUsage` (per-model numeric counters) and `permission_denials`. Re-run those greps before changing a parser, and keep the parsing defensive anyway: a diagnostic that throws is worse than one that stays quiet. Every reporter dedupes **once per identity per process** (`_resetRateLimitReports` / `_resetSystemInitReports` are the test seams, same shape as `_resetFastModeWarnings`), because a rejected rate limit and a failed MCP server both repeat on every respawn. Levels follow the `src/logger.ts` rule: WARN for anything the user must act on, since only warn/error are alwaysStderr. +- **`apiKeySource` is the field that tells you pay-as-you-go billing is happening, and `process.env` is not.** `warnIfAnthropicApiKey` in `index.ts` sees only the env-var route; the CLI also takes a key from its own settings scopes (`user`, `project`, `org`) and from an `apiKeyHelper`, and `API_KEY_SOURCES` in `cli-events.ts` treats everything except `oauth` (the subscription) and `none` as a key in effect. That is also why the warning text branches on `ignoreAnthropicApiKey`: with the option already on, the env vars are stripped from the spawn, so a key still in effect did not come from the environment and recommending the option again would be wrong. +- **A failed CLI tool needs `isError: true` on the `tool-result` stream part, not just error text in the output.** Measured in opencode's own bundle: its AI SDK bridge does `if (V.isError) enqueue({type:"tool-error", ..., error: V.result}) else enqueue({type:"tool-result", ...})`, so without the flag a failed `Read` was forwarded as a successful tool result whose output happened to be an error message. AI SDK v3 has no `tool-error` stream part for a provider to emit directly (`LanguageModelV3ToolResult` with `isError` is the only route), so do not go looking for one. The source is `block.is_error` on the CLI's `tool_result` content block, which is why that field is now declared on `ClaudeStreamMessage.message.content[]`. +- **A `result` with a failing subtype finishes as `{unified:"error", raw:}`, and that is a deliberate widening of `toFinishReason`'s two-value vocabulary.** It used to be an unconditional `stop`, so opencode recorded `error_max_turns` as an ordinary reply. Checked against opencode's bundle before shipping: it validates the finish reason against the standard enum and falls back to `"unknown"`, so `"error"` is accepted and nothing branches on it destructively. This is the **with-result** case only; a CLI that dies without emitting a `result` at all is a different failure with its own handling. +- **`/claude-code-doctor` is answered by the plugin with no CLI inference** (`src/doctor.ts`, branch in `doStream` immediately above the `/btw` aside branch, registered by `registerDoctorCommand` in `index.ts`). Four things to keep true: (1) the command name has **no space** in it, because opencode invokes `/` and takes everything after the first space as `$ARGUMENTS`, so `claude-code doctor` would be the command `claude-code` with an argument; (2) it never overwrites a user-defined command of that name, same guard as `/btw`, and unlike `/btw` there is no hook to gate because the language model answers the message the template produces; (3) nothing secret may enter the report, meaning no proxy `authToken`, no `ANTHROPIC_API_KEY` value, no system prompt, and no pending call's `input` (a test asserts the report matches no credential-shaped string); (4) the loopback auth self-check posts **`initialize` only**, never `tools/call`, because a `tools/call` probe would execute something. `formatDoctorReport` is pure and `gatherDoctorReport` is the live half, which is what lets a test pin the whole report against a fixed object. It reads providers through `lastDiagnosticsProviders()` in `startup-diagnostics.ts`, recorded **before** that module's once-per-process log guard so an account expansion's second call wins. +- **`snapshotActiveProcesses` and `snapshotPendingProxyCalls` are read-only views added for the doctor.** Neither touches eviction, the child's `close`/`exit` handler, or stdin. `ActiveProcess.startedAt` is set in `spawnClaudeProcess`'s object literal purely so the report can show an age; `lastStderr` is read through an **optional property access and is never written here**, so the report works whether or not another change adds that field. + +## Running The Suite + +- **Never pipe `npm test` into `grep` inside an `&&` chain.** The pipeline exits with grep's status, not the test runner's, so a red suite reads as green and the chain continues. This is not hypothetical: on 2026-09-19 it carried a `npm version minor` and a tag push through five failing tests, and v0.21.0 published before anyone knew. Redirect and check instead: `npm test > /tmp/run.log 2>&1; echo "EXIT=$?"`, then grep the file. +- **The fake-CLI recovery tests in `test-proxy-task.ts` are timing-sensitive and everything they wait on is derived from `START_WATCHDOG_MS`.** The fixture is a real Node process, so its cold start competes with the machine. The old 500 ms budget (whose comment claimed it was "ample") failed every recovery test at load average 5 with dozens of node processes around, **identically on master and on already-released tags**, which is what proves such a failure is the machine talking and not a regression. Diagnose it that way before touching code: run the same file at the last known-green tag, and if it fails there too, the code is exonerated. Do not raise one of the three waits on its own; the longest path lets **two** consecutive watchdog deadlines elapse, so a hard-coded wait under twice the watchdog fails by construction. That is exactly how the first attempt at this fix broke. + +## Tests To Touch When Editing + +- Version 0.15.0 proxy recovery: SSE `tools/call` replies send headers immediately plus 15-second comments, while preserving the existing authentication guards and per-tool deadlines. A real Claude 2.1.258 call held for 390 seconds completed successfully; the previous single-shot response timed out before delivery. Do not claim a specific underlying timer without fresh evidence. A JSON-only client now gets the same liveness (`openJsonStream`, from @broskees' `68ed142`): headers flushed at once, chunked body, whitespace on the same `PROXY_KEEPALIVE_MS` cadence, envelope last, so the body is still one valid JSON-RPC response on success and on error. Only broker-backed calls stream; `initialize`, `tools/list`, unknown tools, bad batches and interceptors keep the single-shot `Content-Length` reply, and nothing is flushed before the four guards ran. `createProxyMcpServer`'s fourth argument (`keepaliveMs`) is a test seam. `ActiveProcess.pendingProxyCompletions` retains resolved results and shared channel references until continuation settles. Both live and buffered terminal boundaries must consume abandoned completions once, and respawn must preserve the map and original CLI args. Bookkeeping-only stdout must not disarm the start watchdog. Tests: `test-proxy-task.ts`, `test-proxy-mcp.ts`, `test-respawn.ts`. +- Native `/btw` (0.15.0): `src/side-question.ts` uses `control_request.request.subtype: "side_question"`, with the answer at `control_response.response.response.response`. The gate is CLI >= 2.1.258 (oldest measured), idle headless process only. Route matching replies through `dispatchSideQuestionResponse` before ordinary stdout buffering. Never send the aside as a user envelope, spawn a different model, or promise a concurrent opencode overlay. Command registration preserves user definitions. History filtering excludes aside exchanges from fresh-process and compaction transcripts. The CLI response has no usage stats. Tests: `test-side-question.ts`, `test-get-claude-user-message.ts`. `scripts/live-probe.ts` is opt-in paid inference, not part of `npm test`. + - **The aside question must be stripped of opencode's `` blocks** (`SYSTEM_REMINDER_BLOCK` in `src/side-question.ts`). opencode appends them as *extra text parts* on the same user message, and `parseSideQuestionContent` joins every text part, so without the strip the reminder travels with the aside. Measured live on opencode 1.18.29 (2026-09-06): a 35-character question was sent as 1,599 characters, and a bare `/btw` was never empty, so `SIDE_QUESTION_USAGE` was unreachable and the model answered "I don't see a question in your message" instead. The plan-mode reminder is the worst case (1,523 chars of "READ-ONLY phase / STRICTLY FORBIDDEN"), which is exactly the content most likely to steer an aside. Strip **wherever the block sits**, not by matching a whole part or anchoring at the end: a harness may append trailing metadata after the closing tag (opencode-dcp adds ``), and the first attempt at this fix used `endsWith("")`, passed its unit test, and still did nothing in production for exactly that reason. Only this parse strips reminders; normal turns must keep forwarding them, since they are opencode's instructions to the model. Live-verified after the fix by asking the aside its own word count: 17, matching the question alone. + - **`/btw` is asked early and kept in the conversation (`src/btw-command.ts`, after 0.15.1).** Two designs were rejected live before this one. 0.15.x left the aside in the main lane, so a `/btw` typed mid-turn was "Queued" and then refused by the idle guard. The next attempt answered it in a child session with a toast, which the maintainer rejected on UX: the toast vanished before it could be read and the child session was not where anyone looked. What holds now rests on measured facts, re-check them before changing it: (1) opencode's TUI sends `session.command` immediately, busy or not (`packages/tui/src/component/prompt/index.tsx`), so `command.execute.before` fires at once; the resulting user message is what gets queued. (2) opencode's loop exits only when `lastAssistant.parentID === lastUser.id` (`session/prompt.ts` `runLoop`), so **any** message added to a busy session, `noReply` included, becomes the turn's next step, and that step is also the one carrying the results of the tools opencode just ran. Answering the aside there swallowed the turn's own continuation: measured live, turn 2's "finished" never appeared. (3) Claude Code answers `side_question` while the main loop is blocked (2.1.258: 2.3 s into a 35 s held tool call). So the hook finds the process by opencode session id (`findActiveProcessBySessionId`, fed by the `opencodeSessionID`/`asideTransport` tags doStream writes on every non-compaction turn), sends the `side_question` **immediately**, remembers the promise per session (`rememberSideQuestionAnswer`), toasts the answer when it arrives if the session was busy, and then **holds the command until `client.session.status()` reports the session idle** before returning, so opencode creates the `/btw` message only after the turn is completely over and runs it as a fresh turn. That turn hits the aside branch in `claude-code-language-model.ts`, which takes the remembered answer (`takeSideQuestionAnswer`) or asks the now idle process, and emits it as the assistant reply at 0 tokens; `filterSideQuestionHistory` keeps the pair out of Claude's prompt, and `collectSideQuestionHistory` feeds earlier pairs to follow-ups. Three traps: the remembered answer is matched by **prefix**, not equality, because opencode-dcp appends `` to the message text (an exact match missed live and the turn re-asked into the single-flight guard); busy must come from `session.status`, not the process's line-listener count, because the listener is detached while opencode runs a tool; and holding the route is fine because opencode already keeps the command route open for a queued prompt (34 s observed) and the TUI's call is fire-and-forget. The hook only intercepts when `registerSideQuestionCommand` returned true, so a user-defined `btw` command keeps opencode's normal behaviour. A no-process `/btw` answers with `BTW_NO_SESSION_MESSAGE` as text, not an error. Tests: `test-btw-command.ts` (hook incl. the held return and the give-up timeout, answer store, history fetch, fake-CLI end to end), `test-side-question.ts`. + - **Both lookups the hook makes are racy the instant `/btw` is typed, and losing either race puts the "Queued" bubble straight back.** Reported live 2026-09-06 ("if i do the /btw too soo it still gets queued") and confirmed in `plugin.log`: `btw: no live claude process for session` at 14:04:06, then the same question at 14:04:28 found a process and was answered concurrently. Cause: doStream tags the process (`opencodeSessionID`/`asideTransport`) only where it attaches its line listener, which is **after the whole spawn path**, so on a conversation's first turn there is a multi-second window with nothing to ask; the hook fell through, and the message it let past is exactly what opencode queues. The same shape applies to `session.status`, where a session that opencode has not registered yet is **absent from the map and therefore reads as idle**, so a single early read says "not busy" and the hold is skipped. So `waitForAsideProcess` polls for the process while the session is busy (giving up after `SPAWN_WAIT_MAX_MS`, 30 s, because the running turn may belong to another provider and then no process is ever coming), and `settleSessionBusy` keeps re-reading status for `BUSY_SETTLE_MS` (1.5 s) before it will conclude idle. Two ordering rules hold this together: the settle runs **concurrently** with the request, never before it, or an idle `/btw` would wait out the settle window before being asked at all; and `answer.catch(() => undefined)` goes on immediately, because the settle spans timer ticks and a fast failure (dead process, interactive transport) would otherwise surface as an unhandled rejection in opencode's own process before the real handlers are attached. The suite caught that second one, so do not remove it as dead code. + - **The answer is written into the running turn's own reply, and only falls back to the toast plus a held message.** The toast was the delivery while a turn ran, and the maintainer rejected it twice for the same reason ("the notification is too short and is gone right away", then "can you also add it printed to the main thread"): a toast expires, and the held `/btw` pair could not land until the turn was over. So `doStream` registers an `AsideSink` per conversation (`registerAsideSink(affinity, ...)`, unregistered in `cleanupTurn`) that enqueues one finished text block into the live stream, and `deliverAsideInline` uses it; on success the hook throws `BtwHandledError` so opencode never creates the `/btw` message at all, since the answer is already in the transcript. Four things this rests on: (1) the sink is keyed by `affinity`, which **is** the opencode session id, the same key `takeSideQuestionAnswer` uses. (2) `registerAsideSink` returns an unregister that only deletes its own sink, because a turn's cleanup runs after the next turn has already registered. (3) A turn is a **run of streams**, not one: every proxy tool call ends the stream (`finishWithPendingProxyCalls`) and opencode opens the next one with the result, so an answer arriving in that gap has nothing to write to. `deliverAsideInline` therefore retries for `INLINE_WAIT_MAX_MS` (20 s) while the session stays busy and only then falls back to the toast plus the held message, which is still the whole point of keeping that path. (4) The block is its own text part led by `INLINE_ASIDE_MARKER` (`▌ **btw:**`), which is what lets `filterSideQuestionHistory` strip it exactly when a transcript is rebuilt: an aside was never Claude's output and was never in its context. Do not merge it into the model's own text block, and do not match the marker mid-part; the strip is part-level for a reason. Live-verified 2026-09-06 on Claude 2.1.258 + opencode 1.18.29: `/btw` typed 15 s into a 35 s webfetch, block written 1.4 s later inside that turn's assistant message, no `/btw` message in the transcript, turn still delivered its own "finished". Note the command route answers **HTTP 500** on the drop, as it does for every `BtwHandledError`; the TUI's `session.command` call is fire-and-forget and swallows it. Tests: `test-btw-command.ts` (sink ownership, marker strip, and a fake-CLI turn held open by the `SLOW` keyword that the aside is written into). + - **The aside's left bar is a literal `▌` the plugin emits, NOT a markdown blockquote.** Asked for "a subtle green border around the full response", and the blockquote answer shipped first and was wrong; it was replaced after the maintainer reported "the theme is picked but nothing green shows up", which is exactly what the source predicts. Read `@opentui/core`'s `src/renderables/Markdown.ts` before touching this (`npm pack @opentui/core`, the sourcemap carries the TS): a blockquote **does** get a real left border (`createBlockquoteRenderable` → `BoxRenderable` with `border: ["left"]`, `paddingLeft: 1`), but `getBlockquoteBorderColor()` reads the **`conceal`** scope, falling back to `default`, while `theme.markdownBlockQuote` / `markup.quote` colours only the quoted **text**. So the one key a theme could plausibly change is the one that does not paint the bar, there is no per-block override, and a custom theme is the operator's config anyway (opencode resolves `theme.theme` as-is, with no merge over a base, so it means copying a whole theme). `barEveryLine` therefore prefixes **every** line, blank ones as a bare `▌`, so the bar runs the full height. + - **The bar is not coloured, and the search for a green one is closed.** Correction to an earlier note here: assistant text is **not** run through `strip-ansi`. That call sites at `packages/tui/src/routes/session/index.tsx:2051,2349` apply to **tool output**; `TextPart` passes the text straight into ``. ANSI is still useless, for a better reason: OpenTUI renders markdown through tree-sitter into its own buffer with its own colours, so escape bytes print literally and corrupt width measurement. Colour in that renderer comes only from syntax scopes, and there is no scope for a plain character in a paragraph: `` `x` `` is `markup.raw`, `**x**` is `markup.strong`, a blockquote border is `conceal` (`Markdown.ts` `renderInlineToken`). Every one of those is theme-wide, so painting the bar green would repaint all inline code, or all bold, or all blockquotes, across every message, and needs a whole copied theme to do it. The only zero-config green is a colour emoji, which the maintainer refused outright ("no emoji please") and which contradicts the original ask for something *subtle*. So the bar stays a plain `▌`. Do not re-open this without a new opencode rendering feature. Two facts hold the rendering together, both from the same file: OpenTUI renders a paragraph from `token.raw` **verbatim**, so line breaks survive and nothing reflows; and blockquote content goes through `createMarkdownCodeRenderable(token.text, …)` rather than being re-parsed, so the old shape never rendered nested markdown either and dropping it costs nothing. `INLINE_ASIDE_MARKER` must stay the leading characters after `trimStart()` or `filterSideQuestionHistory` stops stripping the block; `LEGACY_INLINE_ASIDE_MARKERS` keeps the old `> **btw:**` blocks strippable in conversations that predate the change, and a test covers it. + - **No toast ever carries the answer** ("so we can get rid of the notification now?", once the inline block worked). Every path that produces an answer now puts it in the conversation, inline or as the held pair, so announcing it as well was duplicate delivery of the *worse* copy: a toast expires, which is the complaint that started this whole redesign. Removed with it: `answerToastMessage` / `answerToastDuration` and the `ANSWER_TOAST_*` sizing constants, `BTW_BUSY_TOAST_MESSAGE` (the "answering alongside the turn" notice, which the inline block obsoletes) and `BTW_IN_FLIGHT_TOAST_MESSAGE` (a second `/btw` still gets asked when the turn ends, so its answer lands too). The **two** that stay are exactly the paths where nothing reaches the conversation because the message is dropped: a bare `/btw` (`SIDE_QUESTION_USAGE`) and `BTW_TURN_TOO_LONG_MESSAGE` after the 30 minute hold gives up. That is the rule to apply to any new toast here: if the conversation gets the content, do not also toast it. `showToast` itself stays, and so does the `tui.showToast` receiver-binding care in it (a detached `const show = client.tui.showToast` throws, since the SDK method reads `this._client`). + - **A receipt block goes into the turn the moment the question is sent** ("there should be some feedback of them actually having sent it also in the main", once the toast was gone). `formatInlineAsideAsk(question)` writes the question on the marker line with the note on its own bar line beneath it, through the same `deliverAsideInline`, and the answer handler **awaits** that promise before writing the answer, so a receipt can never land under the answer it announces. **The receipt quotes the question in full and the answer block repeats it, and that duplication is deliberate.** It went question-less first, on the reasoning that the answer block carries the question anyway; the maintainer asked for it back ("maybe we should see: ▌ btw: <the text you actually sent here> sent to Claude on the side"), and the ask is right: the prompt box clears on submit and the `/btw` message is dropped, so with no question in the receipt **nothing on screen ever says what was sent**. A 240-character `RECEIPT_QUESTION_MAX` was added and then removed for the same reason ("i want the question to hold the full untruncated question"): once the receipt is the only readback, eliding it means a long aside can be read nowhere, and it also produced the odd shape the maintainer spotted, a short copy in the receipt above a full copy in the answer. The reason the answer block must keep its own copy is measured, not stylistic: the model keeps streaming its own text between the two (receipt in the assistant message *before* the tool part, answer in the one after, 13 s later), so a headerless answer arriving after that reads as orphaned. Keeping the answer block's existing shape and marker is also what means nothing new has to be stripped: a continuation marker (`"▌\n"`) was written and then deleted for exactly that reason. + - **Updating the receipt in place when the answer lands is not available, do not try again without new evidence.** Asked for directly ("maybe dont want a new block when answer comes in instead update the original"). Two blockers, both checked rather than assumed on 2026-09-06: opencode's SDK exposes **no** part or message update route (`/session/{id}/message/{messageID}` is read-only, and the full route list has nothing else), and the AI SDK stream has no replace-text event, so the only way to grow a block is to keep its text part open and append deltas to the same id. That is ruled out by stream lifetime: a turn is a run of streams, every proxy tool call ends one, and the receipt lands in the stream *before* the tool part while the answer arrives in the one after, so the part is already closed and drained. What *is* true, and is the part worth keeping if this ever becomes possible: opencode's bridge resolves ids explicitly (`currentTextID(state, event.id)` in `session/llm/ai-sdk.ts`) and every delta this plugin emits carries an explicit id, so two concurrently open text parts are protocol-legal. The blocker is the stream boundary, not the id model. `startTextBlock()` here is single-slot and would also have to stop closing the aside's part. Only sent while `busy`, since an idle `/btw` gets its own message a moment later anyway. Live-verified 2026-09-06 on haiku: receipt 0.3 s after the command, answer 13 s later, `/btw` message dropped, turn still delivered its own reply. + - **Probing this live needs a turn that is genuinely still running**, which took three wasted paid runs to get right. `POST /session/:id/message?async=true` **still blocks** until the turn finishes on opencode 1.18.29, so a `/btw` fired after it "returns" is measured against an idle session and silently exercises the wrong path (`busy:false` in the log is the tell); background the curl instead. opencode's `webfetch` also times out well before 30 s, so a stall server has to sleep under that (12 s works) or the tool errors and the turn ends early. And the provider ids are `claude-code-default` / `claude-code-appical`, never a bare `claude-code`, which fails as an opaque `UnknownError` from the message route. + +- **A `tool_result` may only be sent back for an id THIS CLI process issued** (`cliToolCallIds` on `getClaudeUserMessage`, issue #29 from @nic-lan). opencode runs some tools on its own behalf, notably the `task` call a `subtask: true` command dispatches, and the resumed CLI session never emitted those `tool_use` blocks. Sending a `tool_result` for one is orphaned: Claude cannot resolve the id, so the payload, **which is right there in the envelope**, is unreachable. Reported as "the result is lost"; measured offline on master, the 613-character subagent answer was physically present as `{"type":"tool_result","tool_use_id":"call_X",...}` and simply unusable. Unmatched ids now render as `` text **before** the trailing user message, which is what makes opencode's own synthetic "Summarize the task tool output above" instruction true. Two things hold this together and both are load-bearing: (1) the gate cannot break the proxy round-trip, because the envelope is **not** how proxy results are delivered. `proc.stdin.write(userMsg)` is the fresh-turn path only; when a pending proxy call has a matching tool-result, doStream returns before that write (`hasMatchedPendingResults`) and the broker resolves the call directly, and on the write path any still-pending call is being **rejected as orphaned** a few lines above. So a tool-result reaching a written envelope is an opencode-side one by construction. (2) `cliToolCallIds` is read from `getPendingProxyCalls(sk)` **before** `userMsg` is built, in both `doStream` and `doGenerate`, because there is an `await` in between; `doGenerate` passes an **empty** set, since it has no proxy wiring and therefore issued no calls at all. Omitting the option keeps the old unconditional block, so a forgotten call site degrades to the status quo rather than hanging the CLI. **Live-verified 2026-09-06** on Claude Code 2.1.258 + opencode 1.18.29 through a headless `opencode serve`: a project command with `subtask: true` (`.opencode/command/.md`, `agent: general`) whose subagent answers with a secret token and no tools; the parent's "Summarize the task tool output above" turn quoted the token back, and `plugin.log` showed `rendering opencode-side tool result as text` for the real opencode call id (`tool: task`, 154 chars) at the moment of the command. That log line is the fingerprint to look for if this ever regresses; the unit tests alone had already passed for two fixes that did nothing in production that day. +- **The fresh-session history fallback must render tool content, not count it.** Second half of issue #29, and worse than reported: `compactConversationHistory`'s `fresh-session` mode filtered to `user`/`assistant`, so a `tool`-role message was dropped **entirely** and even `[Received N tool result(s)]` never appeared. All that survived a subagent was `[Called 1 tool(s): task]`. It now includes `tool` roles and uses `renderMessageContentForCompaction`, the same serializer `/compact` already used, so `[tool_use:name(input)]` and `[tool_result:name]` plus the clipped body survive. Do not "simplify" this back to placeholders; the whole point is that this path is what a fresh CLI process gets when the prior session id is gone. +- Prompt/message conversion or compaction transcript behavior: `test-get-claude-user-message.ts`. Also owns issue #29: the orphaned-`tool_use_id` gate (both branches, allowed and degraded) and the fresh-session history keeping tool inputs and result bodies. Each of those three tests fails with the corresponding fix reverted. +- Claude CLI arg construction / version-gated flags: `test-cli-args.ts`. Also owns fast mode: `parseModelId`, `cliSupportsFastMode`, the `--settings` opt-in, and `reportFastModeState`'s log levels. +- Tool name/input mapping (`mapTool`, `CLAUDE_INTERNAL_TOOLS`): `test-tool-mapping.ts`. +- Content-block index reuse across assistant messages within one turn (stale `toolCallMap` entry re-emitting a completed tool call, which breaks subagent `task` results): `test-tool-block-index.ts`. +- Todo ledger (Task* → todowrite translation, TTL pruning, multi-session isolation): `test-todo-ledger.ts`. +- MCP bridge/proxy behavior: `test-bridge.ts`, `test-broker.ts`, `test-proxy-mcp.ts` (HTTP-level JSON-RPC framing incl. error-envelope id echo, `tools/list`, per-tool proxy timeouts + bash `input.timeout` + task-timeout wake-up note). +- MCP tool discovery for `proxyOpencodeMcpTools` (`resolveMcpProxyToolDefs`: the model tool set as the source, the registry-shaped list resolving to nothing, longest-server-prefix, `coveredServers` not stranding an unmatched server, name collisions): `test-proxy-mcp.ts`. Five of those seven fail if discovery is stubbed back to finding nothing; the two that still pass are the two asserting absence. +- The consequence of that discovery, at the argv a real spawn receives (a partially covered server set still bridges the uncovered server; a fully covered one passes only the proxy config): `test-proxy-mcp.ts`. It is one test rather than two because the helper swaps `XDG_CONFIG_HOME`/`HOME` for the duration of a spawn, and it uses a distinct server set per scenario because the bridged file is content-addressed. It fails if the bridged filename goes back to being keyed on the config hash. +- Reused-process respawn (`appendResumeIfNeeded`, `respawnActiveProcess` undefined-branch and in-flight handoff): `test-respawn.ts`; the recovered continuation being marked in flight, through a real turn: `test-proxy-task.ts` (`late` and `swallow` modes). +- Process lifetime as opencode sees it (`session.deleted` through the plugin's `event` hook, `extractDeletedSessionId`, the idle timer armed by a real turn with no option set) and what ends a proxied call (next user message, abort, child exit mid-turn and between turns, with a fake CLI that parks inside a `task` call and records what its HTTP request got): `test-process-lifecycle.ts`. +- Auto-continue / incomplete turn handling: `test-auto-continue.ts`, `test-has-new-user-content.ts`. +- Logger/env behavior: `test-logger.ts`. +- Spawn-time cwd resolution (`resolveSpawnCwd`, captured-directory fallback, session-directory tier): `test-cwd-resolution.ts`. +- Turn lifecycle and abort interrupt (`noteTurnStarted`, `noteTurnLine`, `interruptTurn`), idle eviction (`scheduleIdleProcessEviction`, the 30-minute default, the in-flight re-arm), the 8-process cap, `deleteActiveProcessesForSession`, `killAllActiveProcesses`, `ensureProcessExitCleanup`, broker rejection on detach: `test-session-manager.ts`. +- Skill bridge (`discoverOpencodeSkills`, `buildSkillPluginDir`, `resolveSkillPluginDirs`, `--plugin-dir` in `buildCliArgs`, the default-on `createClaudeCode` wiring, and the argv of a real spawned fake CLI on `doStream` and `doGenerate`): `test-skill-bridge.ts`; the interactive `--plugin-dir` (`interactiveExtraArgs`): `test-claude-session-wrapper.ts`. +- `AGENTS.md` dedup against the forwarded system prompt: `test-compaction-model.ts`. +- AskUserQuestion deny/stop behavior (`denyMessageForTool`, `isAskUserQuestionTool`): `test-ask-user-question.ts`. +- Plan-mode approval bridge (`isPlanModeQuestionActive`, `createExitPlanModeQuestionCall`, `consumeExitPlanModeQuestionResult`): `test-exit-plan-mode-question.ts`. +- Account failover (limit detection incl. its negative cases, the account-scoped override and its expiry clamp, `resolveFailoverSpawn` for a default vs a named target, the form, every answer classification, the transcript strip and the continuation prompt, plus a fake CLI driving a real `doStream` through ask / switch / stop): `test-account-failover.ts`. +- Compress tool (proxy interceptor path, compression store, compress vs default runtime note), plus `resolveProxyOpencodeToolDefs` and both layers of the `compress` name collision: `test-compress-tool.ts`. +- dcp reminder stripping (`stripContextReminderBlocks`, `stripContextReminders`, `shouldStripContextReminders`, and that it is off by default): `test-get-claude-user-message.ts`. +- Config-path model metadata injection (`configModelsForProvider`): `test-config-models.ts`. +- Interactive transport (`decodeUserEnvelope`, `spawnInteractiveProcess` shim shape): `test-claude-session-wrapper.ts`. +- Spawn-env API-key stripping (`claudeSpawnEnv` with/without `ignoreAnthropicApiKey`): `test-spawn-env.ts`. +- Startup diagnostics (`collectStartupDiagnostics`, `describeSpawnCwd`, `detectOpencodeVersion`, `claudeCodeProviders`): `test-startup-diagnostics.ts`. +- Per-turn cost/cache stats (`extractTurnStats`, `formatTurnStatsLine`, the `turnStats` default, the transcript strip): `test-turn-stats.ts`. +- CLI stream-event parsers and their once-per-process dedup (`parseRateLimitEvent`, `describeRateLimit`, `parseSystemInit`, `apiKeySourceWarning`, `parseCompactBoundary`, `describeResultFailure`): `test-cli-events.ts`. +- The same events as opencode sees them, through a fake CLI and a real `doStream` (failed `tool_result` carrying `isError`, failing result subtype finishing as an error, footer gated on `turnStats`, rate-limit and compaction notes): `test-cli-events-stream.ts`. +- The wire-inactivity watchdog's visible note (`formatStreamTimeoutNote`, `CLAUDE_CODE_RESULT_FALLBACK_MS`, its own text part, the transcript strip), through a fake CLI that produces output and then never sends a `result`: `test-result-fallback.ts`. +- `/claude-code-doctor` (report formatter against a fixed report, command-registration guard, `checkProxyAuth`, transcript strip, `describeSessionKey`): `test-doctor.ts`. + +## Roadmap + +Current state (refreshed 2026-07-26 after the fork/PR sweep): + +1. ✅ Per-tool proxy timeouts — absorbed from @jknlsn's fork (`84f3db9`, authorship preserved) in v0.10.0: `proxyToolTimeoutMs` config, per-tool defaults (`task` 60 min then; no deadline since the fork-parity PR, see the deadline gotcha), bash `input.timeout` floor. Contributor-style note: this repo absorbs fork work directly via cherry-pick (authorship preserved) with credit + thanks in release notes; don't wait on inviting a PR first. +2. ✅ Task proxy default-on — resolved by PR #18 (@broskees), absorbed via cherry-pick for v0.10.0 (maintainer live smoke test passed 2026-07-26: subagent dispatch through opencode's TaskTool via `opencode run`). `proxyTools` config remains the escape hatch; subagents need `permission.task`. +3. ✅ Startup diagnostics / doctor log — landed as `src/startup-diagnostics.ts` (`claude-code plugin ready` NOTICE, see the gotcha above). +4. ✅ Subagent todo docs + config example — README "Subagent todos" section: worked `multistep` agent block with `permission.todowrite: allow`, why it is load-bearing, `session.child.next` navigation, and the sqlite queries that prove the todos landed. +5. ✅ Retired 2026-09-06 with issue #4, closed as resolved-pending-feedback (no retest reported in the 2.5 weeks after the ping). The tier-two fix, a per-request/current-project query instead of `process.cwd()`, was never built and should not be unless #4 is reopened with evidence. The startup-diagnostics `cwd` branch is the fingerprint to ask for: `captured` means this bug, `process`/`configured` means it resolved normally. +6. ✅ ExitPlanMode approval bridge, absorbed from @CollieIsCute's `8c5b583` (authorship preserved) behind the opt-in `planModeQuestion` flag (issue #21). @CollieIsCute called their own commits experimental and gave explicit permission to take them (2026-07-31), so this shipped gated rather than blind: the delivery surface (opencode's `question` form) is still broken upstream, so the live approval round-trip is **unverified** and the flag stays off. Re-test when #36603 merges. + +Open work, re-checked 2026-09-19: only **#31**, the v2 plugin API migration tracker, and it is explicitly **not planned** (see the v2 gotcha above for the evidence and the checklist of what would change the answer). **#24** is **closed**: its long-context-cost-tiers item was not-applicable, and `tool.definition` plus both compaction hooks were evaluated on 1.18.29 and skipped, shipped in v0.18.3 via PR #30. #24 had been carrying the v2-migration tracker role, which is why #31 exists; do not reopen #24 for it. **#29** (@nic-lan, subtask/`task` tool results lost across the CLI resume boundary) is **closed**: fixed in `dc3368c`, live-verified, shipped as v0.15.4 on 2026-09-06 (see the `cliToolCallIds` gotcha above). Nothing else is open, and there are **no open PRs**. #22 (Sonnet 5 standard-pricing bump) landed on its 2026-09-01 date. #26 (`proxyTools` allowlist-by-omission) and #27 (`TaskOutput` shell interpolation) are **done** on master, both reported by @tkszeler: #27 became `singleQuoteForShell` + `printf` in `tool-mapping.ts`, #26 became the `extraDisallowedTools` option plus `resolveDisallowedTools` and a warning for unknown `proxyTools` names. #26's other half, a `notebookedit` proxy def, is **deliberately not done**: forwarding it needs a matching opencode registry entry to execute against, and that is unverified, so check `client.tool.list()` on a live server before adding one. #20 (jknlsn absorption) is complete: timeouts + respawn in v0.10.0, task steering in v0.11.2, question proxy in v0.12.0. #21 (CollieIsCute absorption) is complete: flupkede's four items had already landed independently on 2026-05-18, so compare fork *contents*, not commit counts. + +Fork sweep state (2026-09-19, all 20 forks, every branch, by patch-id; `gh api repos///forks` for the list, then one remote per fork and `git cherry origin/master `): three forks had pushed since the previous sweep and both open PRs were theirs. **Merged:** @nic-lan's PR #35 (`fix/replay-single-text-block`, squash `4aad4c7`), unattended stdout replayed as one text block instead of one per delta, with a fake-CLI regression test that fails without the fix; @broskees' PR #36 (`feature/fork-reliability-parity`, merge commit `9866f02` keeping his `ff2edf0`), every terminal event releasing a proxied call on both the broker and the HTTP side, `session.deleted` and host-exit cleanup, respawn keeping `turnInFlight`, idle timer re-arming on a busy process, JSON-only keepalive, skill bridge on `doGenerate` and interactive, and **no default deadline for `task`/`task_batch`**, which was declined in the previous sweep (`dd494a8`) and accepted now because the lifecycle release is what makes a wall clock redundant. Of his four proposed default changes only that one stayed; `dfb82d5` restored `bridgeOpencodeSkills: false`, idle eviction off unless set, and the 16-process cap, and the PR comment says why. `HeikoAtGitHub/master` and `broskees/master` carried nothing new beyond that PR (Heiko's 8 remain the `submit_plan`/workstream product work declined below; broskees' `b796c71` Fable 5.1 and Sonnet 5 pricing had already landed here independently). Every other fork's remaining `git cherry` output is either a stale copy of an origin branch (`disable-thinking`, `feature/claude-code-accounts`, `sonnet-5-standard-pricing`) or was resolved in an earlier sweep. + +Previous sweep (2026-09-06, all 19 forks, every branch, by patch-id): absorbed this round, authorship preserved, credited in the README **Credits** table: @galvani `9e02ce4` (serve-mode cwd), @HeikoAtGitHub `25260a4` (AGENTS.md dedup), @bernardofortes `a5f723a` (idle timeout), and from @broskees' `68ed142` the abort interrupt, the skill bridge, and (after the premise was re-measured live) `task_batch` (three commits under his authorship, adapted). Deliberately **not** taken: @HeikoAtGitHub's other 13 commits (`submit_plan` for Plannotator, a private "workstream" contract system, `repo_policy_scope`: fork-specific product work); @broskees' `ae48773` (commits `dist/`, against policy), his one-turn guard (in via interrupt) and his parallel idle sweep as such (its 30-minute figure and 8-process cap were proposed again as defaults in his fork-parity PR #36 and reverted at merge, see the 2026-09-19 sweep below; his immediate client-disconnect cancellation was not taken either, see the deadline gotcha); @galvani's `7b7841f` (drops `--thinking-display summarized`, which we set on purpose; its other two fixes were already here). Earlier state (2026-08-19): nothing unabsorbed is left on `CollieIsCute/master`, `jknlsn/main`, or `flupkede/feature/compress-tool`. The compress branch's three commits are all resolved: + +- `60a6e9a` (AI-SDK-v4 image parts) **absorbed** by cherry-pick, authorship preserved. `toImageBlock` accepted `type: "image"` parts but never read `part.image`, where v4 puts the binary, so pasted screenshots were dropped with a "file part without data" warning. Two regression tests in `test-get-claude-user-message.ts`; the first fails without the fix (verified, not vacuous). +- `4ac319f` + `5b4ee5d` (compress proxy tool) **reimplemented rather than cherry-picked** — see the compress gotcha below. The design was right, four defects were not. + +Recommendation as of 2026-09-19 (after the PR #35 and #36 merges): **nothing open has a user-visible payoff.** The one follow-up those merges create is the proxy-call stall warning: with no `task` deadline a wedged subagent is silent until something releases it, so a periodic WARN naming tool and call id is the next reliability item, tracked in the maintainer's Future Features note. #29 and #24 both shipped. The only open issue is #31, the v2 migration tracker, which is deliberately parked; pick it up only when one of its checklist triggers fires, not because an opencode bump happened. Note that PR #15's narrow half did eventually land (truncation-continue, v0.18.2), and that it introduced the compaction regression fixed in v0.18.3, which is the argument for a compaction case in any future auto-continue change. The PRs that used to need a decision are all resolved: #25 (@CNQQC, cost units off by 1e6) merged, #23 (own draft) and #15 (@JWebCoder, auto-continue stopReason short-circuit) closed, the latter for the reason in the auto-continue gotcha above. + +## Outward-facing follow-ups (posted 2026-08-19) + +Both deferred items were approved and are done. What they are waiting on now: + +1. **[anomalyco/opencode#36604](https://github.com/anomalyco/opencode/issues/36604)** — our question-form evidence is posted. Two corrections to the older note: **PR #36603 is CLOSED unmerged**, so no fix is landing, and the issue is scoped to *detach + reattach* while our symptom happens with the TUI attached the whole time (the comment says so and offers to file separately if maintainers see it as distinct). Evidence posted: still reproducing on **1.18.18** (2026-08-19); 59 `completed` question parts between 2026-03-31 and 2026-04-25 vs essentially all aborted from 2026-05-18 on, bracketing the regression to v1.14.24…v1.15.5; the single post-boundary `completed` is our own headless `POST /question/{id}/reply` test, which is what isolates the fault to the TUI render step. **Re-test the `question` proxy and `planModeQuestion` when this moves** — both stay off until then. +2. **Issue #4** — @jessielaf pinged for a retest, with the startup-diagnostics `cwd` branch (`captured` is the fingerprint of this bug) as the thing to paste. Stated intent: close as resolved-pending-feedback if there is no reply in about a week, reopening on request. That also retires roadmap item #5. diff --git a/README.md b/README.md index 0d01269..85ac37a 100644 --- a/README.md +++ b/README.md @@ -1,179 +1,1232 @@ -# opencode-claude-code +# @khalilgharbaoui/opencode-claude-code-plugin -A standalone [opencode](https://github.com/opencodeco/opencode) provider plugin that uses [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) as a backend. It spawns `claude` as a subprocess with `--output-format stream-json --input-format stream-json`, implements the AI SDK `LanguageModelV2` interface, and streams responses back to opencode. +[![npm](https://img.shields.io/npm/v/@khalilgharbaoui/opencode-claude-code-plugin.svg)](https://www.npmjs.com/package/@khalilgharbaoui/opencode-claude-code-plugin) -This is a **standalone npm package** that opencode loads dynamically via its external provider system -- no modifications to opencode's source code required. +Use Claude models inside [opencode](https://opencode.ai) by driving the official **Claude Code CLI** (`claude`) as a subprocess. opencode therefore inherits whatever authentication that CLI already holds: a Claude subscription login, an API key, Bedrock, or Vertex. This plugin never reads, stores, or replays an OAuth token of its own. -## Prerequisites +- **Your CLI's auth, untouched.** Because `claude` does the authenticating, there is no subscription token here to lift and replay against the Anthropic API. That replay is what proxy-style opencode plugins do, it is a practice Anthropic has disallowed for third-party tools in 2026, and it is structurally not something this plugin can do. +- **opencode stays in charge of your machine.** Bash, Edit, Write, WebFetch and subagent dispatch are executed by opencode, behind its permission prompts and audit log, rather than by Claude Code. See [Selective tool proxy](#selective-tool-proxy). +- **Headless by default, which has a billing consequence.** `claude --print` usage on a subscription plan draws from the separate Agent SDK / extra-usage allowance rather than from normal plan usage; API-key authentication is unaffected. See [Billing](#billing). -- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated (`claude` available in your PATH) -- [opencode](https://github.com/opencodeco/opencode) installed +> Maintained fork of [`unixfox/opencode-claude-code-plugin`](https://github.com/unixfox/opencode-claude-code-plugin). Published as `@khalilgharbaoui/opencode-claude-code-plugin` on npm. -## Installation +--- + +## Quickstart + +### 1. Install and log in the Claude Code CLI + +The plugin drives an existing [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code); it does not bundle one. Check that `claude` is on your `$PATH` and authenticated: + +```bash +claude --version # e.g. 2.1.263 (Claude Code) +claude auth status # which account you are signed in as +claude auth login # run this if you are not signed in yet +``` + +`login`, `status` and `logout` are the `claude auth` subcommands as of 2.1.263. Run `claude auth --help` if your install differs. + +### 2. Add the plugin to your opencode config + +opencode reads a global config at `~/.config/opencode/opencode.json` (or `$XDG_CONFIG_HOME/opencode/` when that is set). A project-level `opencode.json` in your repo overrides the global one, and `OPENCODE_CONFIG=/path/to/config.json` points opencode at one specific file instead. Put the plugin in the global config so every project gets it: + +```json +{ + "plugin": ["@khalilgharbaoui/opencode-claude-code-plugin"] +} +``` + +That package spec is the whole install. Do **not** `npm install` the package yourself: opencode resolves and caches plugin packages on its own. You do not need a `provider` block either, unless you want to change one of the [options](#options-reference). + +### 3. Restart opencode and verify + +Quit opencode fully and relaunch it: plugins are loaded once, at process start, so a reload is not enough. + +In the model picker you should now see a provider called **Claude Code (Default)** holding entries such as `Claude Haiku 4.5 (1×)`, `Claude Sonnet 5 (3×)` and `Claude Opus 5 (5×)`. The `(N×)` suffix is each model's list price relative to Haiku; see [Models](#models). Pick one and send a message. + +If the provider does not appear, turn on the plugin's log file and look for its one startup line: + +```bash +OPENCODE_CLAUDE_CODE_LOG_FILE=1 opencode +grep "plugin ready" ~/.local/share/opencode-claude-code/plugin.log +``` + +That single `NOTICE: claude-code plugin ready` entry reports the plugin version, the `claude` binary and version it found, the directory it will spawn in, and which providers registered. [Startup diagnostics](#startup-diagnostics) explains every field. + +### Not seeing a version you just upgraded to? + +opencode resolves the `@latest` plugin spec once and freezes the concrete version into its own package cache, so restarting never re-resolves the tag. Delete the cache entry and relaunch: + +```bash +rm -rf ~/.cache/opencode/packages/@khalilgharbaoui/opencode-claude-code-plugin@latest +``` ### Local development ```bash -git clone -cd opencode-claude-code +git clone https://github.com/khalilgharbaoui/opencode-claude-code-plugin +cd opencode-claude-code-plugin bun install bun run build ``` -Then reference it via `file://` in your `opencode.json`. +In your `opencode.json`, point at the local build with a `file://` URL: + +```json +{ + "plugin": ["file:///absolute/path/to/opencode-claude-code-plugin"] +} +``` + +CI installs and builds on **Node 24** (`.github/workflows/publish.yml`), which is the only version this package is built against. `package.json` declares no `engines` range, so older Node versions are untested rather than deliberately unsupported. opencode itself may run under Bun; the [interactive transport](#interactive-transport-experimental) requires that. + +--- + +## Models + +The plugin auto-registers the following, and they appear in the model picker with no extra config: Haiku 4.5, Sonnet 4.5/4.6/5, Opus 4.5/4.6/4.7/4.8/5 (plus two fast-mode Opus entries), Fable 5/5.1 and Mythos 5/5.1, each except Haiku carrying `low` / `medium` / `high` / `xhigh` / `max` reasoning variants. + +| ID | Display name | Context | Output | Reasoning variants | Price × | +|---|---|---|---|---|---| +| `claude-haiku-4-5` | Claude Haiku 4.5 | 200k | 64,000 | – | 1× | +| `claude-sonnet-4-5` | Claude Sonnet 4.5 | 200k | 64,000 | low/medium/high/xhigh/max | 3× | +| `claude-sonnet-4-6` | Claude Sonnet 4.6 | 1M | 128,000 | low/medium/high/xhigh/max | 3× | +| `claude-sonnet-5` | Claude Sonnet 5 | 1M | 128,000 | low/medium/high/xhigh/max | 3× | +| `claude-opus-4-5` | Claude Opus 4.5 | 200k | 64,000 | low/medium/high/xhigh/max | 5× | +| `claude-opus-4-6` | Claude Opus 4.6 | 1M | 128,000 | low/medium/high/xhigh/max | 5× | +| `claude-opus-4-7` | Claude Opus 4.7 | 1M | 128,000 | low/medium/high/xhigh/max | 5× | +| `claude-opus-4-8` | Claude Opus 4.8 | 1M | 128,000 | low/medium/high/xhigh/max | 5× | +| `claude-opus-4-8-fast` | Claude Opus 4.8 Fast | 1M | 128,000 | low/medium/high/xhigh/max | 10× | +| `claude-opus-5` | Claude Opus 5 | 1M | 128,000 | low/medium/high/xhigh/max | 5× | +| `claude-opus-5-fast` | Claude Opus 5 Fast | 1M | 128,000 | low/medium/high/xhigh/max | 10× | +| `claude-fable-5` | Claude Fable 5 | 1M | 128,000 | low/medium/high/xhigh/max | 10× | +| `claude-fable-5-1` | Claude Fable 5.1 | 1M | 128,000 | low/medium/high/xhigh/max | 10× | +| `claude-mythos-5` | Claude Mythos 5 | 1M | 128,000 | low/medium/high/xhigh/max | 10× | +| `claude-mythos-5-1` | Claude Mythos 5.1 | 1M | 128,000 | low/medium/high/xhigh/max | 10× | + +`claude-mythos-5` and `claude-mythos-5-1` are Mythos-class counterparts to the corresponding Fable models, but without safety classifiers, and are **limited availability via [Project Glasswing](https://anthropic.com/glasswing)**. They're registered unconditionally; if your Claude account lacks access, `claude --model` just errors. Use the corresponding generally available `claude-fable-5` or `claude-fable-5-1` otherwise. + +Capabilities for every model: text + image input, text output, tool use, attachments. No temperature control, no PDF/audio/video, no interleaved streaming. + +**Price ×** is each model's per-token list price relative to Haiku, the cheapest model. It's derived exactly from Anthropic's published pricing (input and output ratios both come out the same: Haiku $1/$5 = 1×, Sonnet $3/$15 = 3×, Opus $5/$25 = 5×, Fable/Mythos 5 and 5.1 / Opus fast mode $10/$50 = 10×). So **Fable/Mythos 5 and 5.1, and fast-mode Opus, all cost 2× standard Opus 5**. The same multiplier is shown as a `(N×)` suffix on the display name in opencode's model picker, since opencode has no dedicated multiplier field. On a flat Max/Pro subscription it doubles as a rough guide to how fast each model drains your usage limit. + +Fable 5.1 and Mythos 5.1 keep the same $10/M input and $50/M output rates as 5.0, but cache reads cost $0.25/M instead of $1/M. Their cache-write rate remains $12.50/M. + +The model ID is passed straight through to `claude --model`, so anything Claude Code accepts works. The two `-fast` IDs are the one exception, described below. + +### Fast mode + +`claude-opus-5-fast` and `claude-opus-4-8-fast` run the same models at up to 2.5× the output tokens per second, at 2× the price ($10/M input, $50/M output, the 10× column). Pick them in the model selector like any other model. + +The `-fast` suffix is this plugin's own marker, not a model name Anthropic serves. The plugin strips it and spawns `claude --model claude-opus-5 --settings '{"fastMode":true}'`, because that settings layer is the only way to opt a headless (`--print`) session into fast mode: there is no `--fast` flag, and the old `claude-opus-4-6-fast` style model names are retired. Requires Claude Code 2.1.220+; below that the plugin skips the opt-in and you get standard speed. + +Fast mode is not available everywhere, and it **fails soft**: an ineligible account drops back to standard speed with no error. Known blockers: + +- **Usage credits are off.** The most common one. Run `/usage-credits` in an interactive `claude` session to enable them. +- **Not first-party.** Fast mode is Anthropic-API-only; Bedrock, Vertex, and Foundry are excluded. +- **Free tier**, or an organization that has turned fast mode off. +- **Cooldown.** Fast mode has its own rate limit; after a hit, Claude Code falls back to standard until it clears. +- `CLAUDE_CODE_DISABLE_FAST_MODE=1` in the environment turns it off outright. + +Because a downgrade is otherwise invisible, and because the picker shows these IDs at 10× regardless, the plugin logs a **warning** (once per reason) when a fast turn actually ran at standard speed, naming the reason. If you see it, switch to the non-fast ID so the picker's price matches your bill. + +### Picking a variant + +Variants set the underlying reasoning effort. They're regular opencode model variants — pick them in the model selector. If you'd previously declared variants in your project's `opencode.json`, they're merged on top of the defaults so nothing gets lost. + +--- + +## Billing + +By default this plugin drives Claude Code headlessly (the Agent SDK path, `claude --print`). Since June 2026, headless usage on a Claude subscription plan draws from a separate Agent SDK credit / extra usage rather than from normal plan usage. Authenticating the CLI with an API key is unaffected by that policy and bills as ordinary API usage. + +Anthropic's own page is the authoritative and current source, including the amounts, which change: + +Two things in this plugin interact with the above. [`ignoreAnthropicApiKey`](#options-reference) stops a stray `ANTHROPIC_API_KEY` in your environment from silently redirecting the CLI onto pay-as-you-go API billing. The experimental [interactive transport](#interactive-transport-experimental) drives the real `claude` TUI instead of `--print`, which bills as normal plan usage. + +--- ## Configuration -Add this to your project's `opencode.json`: +The minimum config is just the `plugin` entry above. Everything below is optional override that goes in a `provider.claude-code` block. + +### Multiple Claude Code accounts + +Declare account names once and the plugin expands them into separate opencode providers: + +```json +{ + "plugin": ["@khalilgharbaoui/opencode-claude-code-plugin"], + "provider": { + "claude-code": { + "options": { + "accounts": ["personal", "work"] + } + } + } +} +``` + +`default` is always implicit, so the config above creates: + +| Provider ID | Display name | Claude config dir | +|---|---|---| +| `claude-code-default` | `Claude Code (Default)` | normal `~/.claude` | +| `claude-code-personal` | `Claude Code (Personal)` | `~/.claude-personal` | +| `claude-code-work` | `Claude Code (Work)` | `~/.claude-work` | + +Non-default accounts use `CLAUDE_CONFIG_DIR` through a generated wrapper script, so auth/session state stays isolated per account. Shared capability files and folders are symlinked from `~/.claude` into each account dir when present: + +```text +CLAUDE.md +settings.json +skills/ +agents/ +commands/ +plugins/ +``` + +Identity/session state is not shared. + +Login each account once: + +```bash +CLAUDE_CONFIG_DIR="$HOME/.claude-personal" claude auth login +CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude auth login +``` + +The account model IDs are internally suffixed, for example `claude-sonnet-4-6@work`, so long-lived Claude subprocess sessions do not collide across accounts. The generated wrapper strips the suffix before calling `claude --model`. + +#### Account failover + +With more than one account configured, an account running out of usage mid-task no longer just ends the turn. The plugin asks, using opencode's own `question` form: + +```text +Account limit +The Claude account "work" is out of usage in the five_hour window, which resets at +2026-09-20T18:00:00.000Z. Continue this task on another configured account? +Leaving this unanswered waits, at no cost. + + personal Run on "personal" until 2026-09-20T18:00:00.000Z. … + default Run on "default" until 2026-09-20T18:00:00.000Z. … + stop End this turn now and leave the account as it is. +``` + +Pick an account and the task continues on it **inside the same opencode turn**, with no new message from you. This is on by default because the pick is the consent: nothing moves until you choose, and leaving the form open costs nothing. + +What a pick does, in full: + +- **It is sticky for the limited account, not for the session.** A usage limit belongs to the account, so one pick governs every session running on `work`, and subagents follow their parent for free. It lasts until the limit's reset time, or until opencode restarts when the CLI did not report one. Child sessions never show the form themselves. +- **The conversation is replayed, not resumed.** Claude transcripts live under each account's own `CLAUDE_CONFIG_DIR`, so `--resume` cannot cross accounts. The plugin starts a fresh Claude session on the target and replays the thread from opencode's history, then tells it to carry on. That costs input tokens on the new account, and anything the CLI held but opencode did not is gone. +- **Per-profile MCP servers do not come along.** A server configured only in the limited account's Claude profile is simply absent on the target. +- **`stop`, dismissing the form, or any answer that is not one of the offered accounts** ends the turn exactly the way the rate-limit error ends it today. + +Only two things open the form: a `rate_limit_event` the CLI marked `rejected`, and the two known account-limit error texts (`Third-party apps now draw from your extra usage…`, `You've hit your individual spend limit`). A generic 4xx, a timeout or a bad flag never does, deliberately: a transient failure must not quietly move where your usage is billed. + +Not available on the [interactive transport](#interactive-transport-experimental) (no proxy server, TUI stdin) or on compaction turns. Set `"accountFailover": "off"` to keep the plain error. + +### Subagents: your account, their model + +opencode's agent config cannot express "inherit the account, choose the model". A subagent that omits `model` inherits the invoking agent's whole model string; one that pins `model` inherits neither half, so pinning Opus also pins whichever account was written into it. This plugin closes that gap, because it is the piece that knows the account is the *provider* while the model is only a `--model` flag. + +Write an agent markdown file. Nothing goes in `opencode.json`. + +```markdown +--- +description: Designs and builds UI work +mode: subagent +--- +You are a designer... +``` + +`@designer` now runs on **the account of the session that invoked it**, on whatever model you point it at. Which model comes from one of two places. + +Per agent, in the agent's own file: + +```yaml +forceModel: claude-haiku-4-5 +``` + +Or once, for every subagent that pins nothing, in the provider options: + +```json +{ "provider": { "claude-code": { "options": { "defaultSubagentModel": "claude-opus-5" } } } } +``` + +The rules, in order: + +| The agent | Runs on | +| --- | --- | +| `forceModel: ` | the caller's account, that model | +| `mode: subagent`, no model, `defaultSubagentModel` set | the caller's account, that model | +| `mode: subagent`, no model, no default set | untouched, inherits the caller's model | +| `model: /` | exactly that, account and all (untouched) | +| anything opencode ships (`explore`, `general`, `compaction`) | untouched | + +**`defaultSubagentModel` is unset by default and nothing is overridden without it.** That is deliberate: this feature rewrites what the model picker said would run, so an existing setup that upgrades the plugin has to behave exactly as it did before. Built-ins are excluded for the same reason, since forcing Opus onto a cheap exploration agent would be an expensive surprise nobody asked for. An unknown model id is refused and the original kept, rather than spawning the CLI with a `--model` it will reject. + +Two things worth knowing. The overridden model is part of the Claude session key, so a subagent forced to Opus never shares a `claude` process with a Fable parent in the same directory. And opencode still prices the turn against the model *it* routed, so a cost readout attributes the work to the caller's model, not the one that actually ran. + +### The effort an agent runs at + +The same file can state its own thinking budget: + +```yaml +reasoningEffort: high +``` + +That beats whatever effort the call arrived with. It has to, because opencode resolves one effort for a session and a subagent inherits it, which is wrong in the expensive direction: a caller who picked `max` for their own turn otherwise hands `max` to every worker it dispatches, and a mechanical lane burns a weekly cap at the costliest setting available. Model and effort together are what a turn costs, so both belong with the agent rather than with whoever happened to dispatch it. + +An agent that declares nothing keeps the inherited effort, so this changes nothing until a file asks for it. An unrecognised level is refused and the inherited one kept, since the CLI rejects a level it does not know. Compaction is exempt: its summary always gets the full budget. + +To force an **account** rather than a model, pin the full string. This only applies if you declared [`accounts`](#multiple-claude-code-accounts) in the first place; with the default single-account setup there is nothing to pin. Both halves are needed, because the provider selects the account's config dir and the `@account` marker is what the model was registered under for that provider: + +```yaml +model: claude-code-work/claude-opus-5@work +``` + +### Options reference + +```json +{ + "plugin": ["@khalilgharbaoui/opencode-claude-code-plugin"], + "provider": { + "claude-code": { + "options": { + "cliPath": "claude", + "proxyTools": ["Bash", "Edit", "Write", "WebFetch", "Task"], + "skipPermissions": true, + "permissionMode": "default", + "bridgeOpencodeMcp": true, + "strictMcpConfig": false, + "idleProcessTimeoutMs": 900000 + } + } + } +} +``` + +| Option | Type | Default | Description | +|---|---|---|---| +| `cliPath` | string | `"claude"` | Path to the `claude` executable (a binary, not a shell command with flags). opencode's config hook seeds this with `"claude"`, so under opencode this default always applies; `CLAUDE_CLI_PATH` is only consulted when `createClaudeCode()` is called directly and the option is absent. Account providers wrap it with a generated script; never point it at one of those yourself. | +| `accounts` | string[] | – | **Optional.** Most setups need no accounts at all: with this unset you get a single `Claude Code (Default)` provider on your normal `~/.claude` login. Supply names only to run several Claude logins side by side; `default` stays implicit, so `["work", "personal"]` gives you `Claude Code (Default)`, `Claude Code (Work)` and `Claude Code (Personal)`. See [Multiple Claude Code accounts](#multiple-claude-code-accounts). | +| `accountFailover` | `"ask"` \| `"off"` | `"ask"` | When this account runs out of usage mid-task, show a form listing the other configured accounts and continue on the one you pick, inside the same turn. Only ever fires when more than one account is configured, so a single-account setup is unaffected. `"off"` keeps the plain rate-limit error. See [Account failover](#account-failover). | +| `cwd` | string | see description | Working directory for the spawned CLI. Resolved **lazily per request**, first match winning: this explicit value, then the opencode session's own `directory` (so `opencode serve` and the web UI spawn in the right project even though one server handles many), then `process.cwd()` when it is a real directory, then the project directory captured at plugin init (this rescues macOS GUI launches, where `process.cwd()` is `/`), and finally `process.cwd()` regardless. [Startup diagnostics](#startup-diagnostics) reports which tier won. Session tier contributed by [@galvani](https://github.com/galvani). | +| `skipPermissions` | boolean | `true` | Pass `--dangerously-skip-permissions` to `claude`. It is still passed when `proxyTools` is set: proxied calls go through opencode's permission system regardless, but unproxied CLI built-ins do not. The one case where the flag is dropped is `permissionMode: "plan"`, because the CLI lets the skip flag override plan mode outright. See [Plan mode](#plan-mode). | +| `permissionMode` | `acceptEdits` \| `auto` \| `bypassPermissions` \| `default` \| `dontAsk` \| `plan` | – | Forwarded to headless `claude --permission-mode`. `"plan"` also suppresses `--dangerously-skip-permissions` (see the row above). Not version-gated, so check that your installed CLI accepts the value. The [interactive transport](#interactive-transport-experimental) does not forward it. | +| `defaultSubagentModel` | string | – | Model that plugin-discovered `mode: subagent` agents run on when their own definition pins nothing. The caller's account is kept; only the model name changes. An agent's own `forceModel` wins over it, and an unknown id is refused rather than spawned. Unset means no implicit override at all. See [Subagents: your account, their model](#subagents-your-account-their-model). | +| `proxyTools` | string[] | `["Bash", "Edit", "Write", "WebFetch", "Task"]` | Claude built-in tools to route through opencode's executor + permission UI. Opt-in extras: `"Question"`, `"Compress"`. See [Selective tool proxy](#selective-tool-proxy). | +| `extraDisallowedTools` | string[] | – | Extra Claude built-ins to switch off with `--disallowedTools`, on top of what `proxyTools` implies. Claude's names, e.g. `["NotebookEdit"]`. See [Closing a tool with no proxy](#closing-a-tool-with-no-proxy). | +| `proxyToolTimeoutMs` | `Record` | – | Optional wall-clock backstop per proxy tool, in ms, keyed by proxy tool name (`bash`, `task`, …). A call normally ends on an event the plugin listens for (result, abort, next message, process exit, chat deletion), not on a timer; see [How a proxied call ends](#how-a-proxied-call-ends). Defaults: 10 min flat, `task` / `task_batch` → none, `question` → 30 min. `0` disables a tool's deadline; negative or non-numeric values are ignored. For `bash`, the call's own `input.timeout` is honoured on top (`max(resolved, input.timeout)`). See [Per-tool proxy timeouts](#per-tool-proxy-timeouts). | +| `planModeQuestion` | boolean | `false` | Route `ExitPlanMode` approval through opencode's native `question` tool instead of a text "(yes/no)" prompt. Opt-in, and currently unreachable on the default headless transport, which is not offered an `ExitPlanMode` tool at all. See [Plan mode](#plan-mode). | +| `controlRequestBehavior` | `allow` \| `deny` | `allow` | Default response when `skipPermissions: false` and Claude sends a `can_use_tool` control request. | +| `controlRequestToolBehaviors` | `Record` | – | Per-tool override for `can_use_tool`. Example: `{ "Bash": "deny", "Read": "allow" }`. | +| `controlRequestDenyMessage` | string | built-in message | Message returned to Claude on a deny. | +| `bridgeOpencodeMcp` | boolean | `true` | Auto-translate your opencode `mcp` block into Claude's `--mcp-config`. See [MCP bridge](#mcp-bridge). | +| `mcpConfig` | string \| string[] | – | Extra `--mcp-config` paths/JSON passed alongside the bridged config. | +| `strictMcpConfig` | boolean | `false` | Pass `--strict-mcp-config` so Claude loads **only** the configured servers and ignores `~/.claude/settings.json`. | +| `hotReloadMcp` | boolean | `true` | With MCP bridging on, compare the merged MCP config and runtime status at the start of each turn and respawn the `claude` process when they drifted, so a server you just enabled or disabled becomes visible without restarting opencode or opening a new chat. Eviction waits for pending proxy calls, never happening mid tool-call, and the session id is preserved for `--resume`. Set `false` to keep a cached subprocess until the chat is reset. It does not reload other provider options and does not watch the contents of files named in `mcpConfig`. | +| `proxyOpencodeMcpTools` | boolean | `false` | Route opencode's MCP-backed tools through the in-process `opencode_proxy` server instead of bridging them straight into Claude's `--mcp-config`, so each call executes once, inside opencode, with its permission prompt and its tool row. **The default changed from `true` to `false` in this release, and no behaviour changed with it:** at `true` it used to route nothing at all, because discovery read opencode's tool registry, which contains built-ins and plugin-declared tools and has never contained an MCP tool. Discovery now reads the model tool set opencode passes the provider, which is where MCP tools actually are, so the option works, and turning it on is the operator's decision rather than a silent migration of traffic that the direct bridge is handling today. Two caveats before enabling it: pair it with `strictMcpConfig: true`, because a server also registered in Claude Code's own config is reached directly and bypasses the proxy entirely; and a routed call runs in opencode with the calling agent's permissions, the same trade [`proxyOpencodeTools`](#options-reference) makes. Servers whose tools are not found stay on the direct bridge, and a warning says so, so do not treat this as an exactly-once guarantee for write-capable tools. | +| `proxyOpencodeTools` | string[] | `[]` | Forward named opencode tools through the proxy by their registry id, for tools another opencode plugin declares directly and that therefore belong to no MCP server (opencode-dcp's `compress`). Explicit allowlist; a forwarded tool runs inside opencode with the calling agent's permissions. A name already held by a proxy def is dropped with a warning rather than taking it over. See [Forwarding opencode's own tools](#forwarding-opencode-s-own-tools). | +| `stripContextReminders` | boolean | `false` | Remove opencode-dcp's `` blocks from message text when no `compress` tool is proxied, so an order the model cannot follow stops being re-sent with every message that carries it. Inert as soon as `compress` is reachable. See [Trimming unsatisfiable context reminders](#trimming-unsatisfiable-context-reminders). | +| `webSearch` | `"claude"` \| `"disabled"` \| `` | `"claude"` | Routing for Claude's built-in `WebSearch`. See [WebSearch routing](#websearch-routing). | +| `multiStepContinuation` | boolean | `true` | Append a system-prompt hint nudging Claude to chain tool calls within one turn instead of pausing between subtasks. Each opencode turn boundary requires the user to manually press "continue", so for multi-step tasks this reduces friction. Set `false` to disable. | +| `autoContinueIncompleteTurns` | boolean \| `"smart"` | `"smart"` | Smartly continue incomplete Claude CLI results inside the same opencode turn. Reduces manual "continue" presses when Claude ends after reasoning/tool activity without a useful final answer. Set `false` to disable. | +| `compactionModel` | string | `"claude-haiku-4-5"` | Model used when opencode invokes `/compact`. Override per-process via the `CLAUDE_CODE_COMPACTION_MODEL` env var (env wins over config). See [Compaction](#compaction). | +| `ignoreAnthropicApiKey` | boolean | `false` | Strip `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` from every spawned `claude` process so it authenticates with your logged-in subscription instead of pay-as-you-go API billing. The plugin warns once at startup whenever an API key is detected, regardless of this setting. See [Billing](#billing). | +| `idleProcessTimeoutMs` | number | – | Kill a retained headless Claude worker after this many idle milliseconds following a completed turn. The timer starts when a turn finishes, a new turn cancels it, a worker that is mid-turn when it fires is left alone and re-timed, and the session id is preserved for `--resume`. Values above Node's maximum timer delay (`2147483647`) are ignored. Omit or set `0` to retain workers until LRU eviction (16 processes). Interactive transport is excluded. Contributed by [@bernardofortes](https://github.com/bernardofortes). | +| `bridgeOpencodeSkills` | boolean | `false` | Expose your opencode skills to Claude's native `Skill` tool. Off by default because every bridged skill is also in the system prompt opencode forwards, so a large set is paid for twice per turn; the bundled configuration skill is staged either way. See [Skill bridge](#skill-bridge). Written by [@broskees](https://github.com/broskees). | +| `logging` | object | all defaults | The plugin's own logger, four independent fields: `file` (boolean, default `false`), `dir` (string, default `~/.local/share/opencode-claude-code/`), `mode` (`"silent"` \| `"debug"`, default `"silent"`) and `level` (`"debug"` \| `"info"` \| `"notice"` \| `"warn"` \| `"error"`, default `"info"`). Goes under `provider.claude-code.options` like every other row here. See [Logging](#logging). | +| `turnStats` | boolean | `false` | Append a one-line cost / duration / cache footer to each finished turn. See [Per-turn stats](#per-turn-stats). | +| `interactive` | boolean | `false` | **Experimental.** Drive the interactive `claude` TUI (subscription billing) instead of headless `--print`. Requires opencode running under Bun with PTY support; silently falls back to headless otherwise. The tool proxy, `permissionMode` and `/btw` are all unavailable on it, so read [What it does not support](#what-it-does-not-support) before enabling. Env: `CLAUDE_CODE_INTERACTIVE_TRANSPORT=1`. | +| `interactiveBypass` | boolean | `false` | Deprecated/no-op with `interactive`: Claude Code's TUI shows a manual safety confirmation for `bypassPermissions`, so the plugin intentionally does not pass it. | +| `interactiveAllowTools` | string[] | `["Bash", "Edit", "Write", "Read", "WebFetch"]` | With `interactive`: built-in tools pre-allowed without prompting (replaces the default list). MCP server wildcards (`mcp____*`) are always added from the bridged config. | +| `interactiveSystemPrompt` | boolean | `true` | With `interactive`: append this plugin's CLI/AGENTS/continuation prompt via `--append-system-prompt-file`. The transport intentionally does not forward opencode's own system prompt, because it can trigger Claude Code's third-party-app usage gate on subscription accounts. Set `false` only for diagnostics. | + +### Environment variables + +Every variable the plugin itself reads, in one place. Config is read once at opencode startup, so these are the way to change behaviour for a single run without editing `opencode.json`. Claude Code's own variables (`CLAUDE_CODE_DISABLE_THINKING` and friends) are passed through untouched and are listed under [Extended thinking](#extended-thinking). + +| Variable | Read by | Effect | +|---|---|---| +| `CLAUDE_CLI_PATH` | provider factory | Fallback `claude` path when `cliPath` is absent. Under opencode the config hook always supplies `cliPath`, so this only applies to direct `createClaudeCode()` use. | +| `CLAUDE_CODE_COMPACTION_MODEL` | compaction spawn | Model for `/compact`. Wins over the `compactionModel` option. See [Compaction](#compaction). | +| `CLAUDE_CODE_INTERACTIVE_TRANSPORT` | transport selection | `1` turns on the experimental [interactive transport](#interactive-transport-experimental) for one process, same as `interactive: true`. | +| `CLAUDE_CODE_INTERACTIVE_BYPASS` | transport selection | Requests `bypassPermissions` in interactive mode. Deliberately ignored, with a warning, for the reason in the `interactiveBypass` row above. | +| `CLAUDE_CODE_START_WATCHDOG_MS` | start watchdog | Milliseconds a `claude` process may stay completely silent on stdout after a turn is written, or after a proxy tool result should have resumed it, before the plugin acts. First expiry respawns the process and resumes the session; a second ends the turn with an error rather than hanging. Default `90000`; a positive integer is required and anything else falls back to that. Mainly a knob for reproducing the hang. | +| `CLAUDE_CODE_RESULT_FALLBACK_MS` | wire-inactivity watchdog | Milliseconds a `claude` process that has already produced output may stay silent on stdout before the turn is closed without a `result`. The close is announced in the reply as a `▌ **stream timeout:**` note. Default `60000`; a positive integer is required and anything else falls back to that. Like the start watchdog, mainly a knob for reproducing a hang. | +| `OPENCODE_CLAUDE_CODE_LOG_FILE` | logger | `1` writes the log file, `0` forces it off even when `logging.file` is `true`. See [Logging](#logging). | +| `OPENCODE_CLAUDE_CODE_LOG_DIR` | logger | Directory for the log file, overriding `logging.dir`. | +| `OPENCODE_CLAUDE_CODE_LOG_LEVEL` | logger | Minimum level to emit, overriding `logging.level`. An unrecognised value falls through to config. | +| `DEBUG` | logger | `DEBUG=opencode-claude-code` promotes the logger to `mode: "debug"`, echoing every emitted level to opencode's TUI. | +| `OPENCODE_CLAUDE_CODE_PLUGIN_NO_CLEANUP` | startup cleanup | `1` skips the one-time removal of a stale **unscoped** `opencode-claude-code-plugin` install from opencode's plugin cache. That old package is a different artifact that shadows this scoped one when both are present; set this if you are deliberately keeping it. | +| `OPENCODE_WORKTREE` | MCP bridge | Overrides worktree-root detection, which otherwise walks up from the working directory looking for a `.git` entry. | +| `OPENCODE_CONFIG` / `OPENCODE_CONFIG_DIR` | config discovery | Where the plugin looks for your opencode config when bridging MCP and skills. See [Discovery order](#discovery-order-highest-to-lowest-priority). | +| `OPENCODE_VERSION` | startup diagnostics | Reported as the opencode version when set, sparing the plugin a `--version` spawn. Diagnostics only. | +| `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` | spawn environment | Not set by the plugin: these are yours, and Claude Code authenticates with them in preference to your subscription login when present. `ignoreAnthropicApiKey: true` strips them from the spawn. See [Billing](#billing). | +| `DISABLE_AUTOUPDATER` | spawn environment | Set to `1` on every `claude` the plugin spawns, **only if you have not set it yourself**. The plugin detects your CLI version once and caches it, and gates `--thinking-display summarized`, `--plugin-dir` and fast mode on the answer, so a CLI that updates itself mid-session would leave those gates describing a binary that is no longer running. Export `DISABLE_AUTOUPDATER=0` to keep the autoupdater; your value is never overwritten, and updating the CLI between opencode restarts works normally either way. | +| `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | spawn environment | Set to `1` on every spawned `claude` under the same never-overwrite rule. It suppresses the CLI's non-essential network calls and is a second, independent way Claude Code declines to auto-update. Export it yourself (including as an empty string, which the CLI reads as off) to take control. | + +The plugin also honours the usual path conventions rather than defining its own: `XDG_CONFIG_HOME` and `XDG_CACHE_HOME` (falling back to `~/.config` and `~/.cache`), `HOME` / `USERPROFILE`, and Claude Code's `CLAUDE_CONFIG_DIR` when the interactive transport needs to find the session transcript. Account providers set `CLAUDE_CONFIG_DIR` themselves for the process they spawn. + +### Overriding model metadata + +To rename a model, change a limit, or add a custom one: ```json { + "plugin": ["@khalilgharbaoui/opencode-claude-code-plugin"], "provider": { "claude-code": { - "npm": "opencode-claude-code-plugin", "models": { - "haiku": { - "name": "Claude Code Haiku", - "attachment": false, - "limit": { "context": 200000, "output": 8192 }, - "capabilities": { "reasoning": false, "toolcall": true } - }, - "sonnet": { - "name": "Claude Code Sonnet", - "attachment": false, - "limit": { "context": 1000000, "output": 16384 }, - "capabilities": { "reasoning": true, "toolcall": true } - }, - "opus": { - "name": "Claude Code Opus", - "attachment": false, - "limit": { "context": 1000000, "output": 16384 }, - "capabilities": { "reasoning": true, "toolcall": true } + "claude-sonnet-4-6": { + "name": "Sonnet (custom)", + "limit": { "context": 1000000, "output": 32768 } } - }, - "options": { - "cliPath": "claude" } } } } ``` -Replace `"opencode-claude-code-plugin"` with a `file://` path if you're using a local build. +Anything you supply is merged on top of the defaults; you don't need to redeclare every model. -The model IDs (`haiku`, `sonnet`, `opus`) are passed directly to `claude --model`, which accepts these aliases natively. +--- -## How it works +## Interactive transport (experimental) -### Architecture +By default the plugin spawns `claude --print` (headless). From **June 15, 2026** that usage bills against the separate [Agent SDK credit](#billing) on subscription plans. The interactive transport instead drives the real interactive `claude` TUI — which bills as **normal plan usage** — under a native PTY inside opencode's Bun runtime, types your prompt into it, and streams the session transcript (`~/.claude/projects//.jsonl`) back through the same pipeline the headless transport uses. +```json +"options": { "interactive": true } ``` -opencode --> streamText() --> ClaudeCodeLanguageModel.doStream() - | - v - claude CLI subprocess - (stream-json mode) - | - v - ReadableStream - | - v - opencode processor (UI) + +Or per-process: `CLAUDE_CODE_INTERACTIVE_TRANSPORT=1`. + +### Requirements + +- opencode must be running under **Bun** with `Bun.Terminal` (PTY) support. If it isn't, the flag is ignored and the headless transport is used — nothing breaks. +- A logged-in `claude` (subscription auth). The whole point is plan billing, so API-key auth gains nothing here. + +### What carries over from the headless transport + +- The plugin's appended prompt (Claude CLI context, AGENTS.md guidance, continuation rules). The interactive transport intentionally does not forward opencode's own system prompt, because live testing showed that payload can trigger Claude Code's third-party-app usage gate on subscription accounts. +- The MCP bridge: bridged servers are passed via `--mcp-config` + `--strict-mcp-config`, and every bridged server is pre-allowed as `mcp____*`. +- The [skill bridge](#skill-bridge): the same `--plugin-dir` staging the headless spawn uses, so the TUI's native `Skill` tool can load your opencode skills too. +- Model selection, session reuse, and the whole streaming/usage pipeline. + +Set `interactiveSystemPrompt: false` only for diagnostics. While disabled, the interactive session will not receive the plugin's CLI context, AGENTS.md guidance, or continuation hints. + +### What it does not support + +This is the part to read before turning it on. Three whole features of this plugin are simply absent on the interactive transport: + +- **No tool proxy.** The interactive spawn starts no proxy MCP server at all, so `mcp__opencode_proxy__bash`, `edit`, `write`, `webfetch`, `task`, `task_batch`, `question` and `compress` do not exist for that session. Claude uses its own built-in tools directly, which means opencode does not execute them, does not prompt for them, and does not log them. Everything in [Selective tool proxy](#selective-tool-proxy) applies to the headless transport only. +- **No `permissionMode`.** The interactive spawn never passes your `permissionMode` to the CLI, so `"plan"` and the rest have no effect there. Permission handling is the pre-allow list described below and nothing else. +- **No [`/btw`](#side-questions-with-btw).** Side questions ride Claude Code's `side_question` control protocol over the headless process's stdio. Asking one in an interactive session returns an error telling you so. + +### What else is different + +- **Permissions:** the interactive TUI has no `can_use_tool` control channel, so tools can't be approved per-call through opencode. Built-in tools are pre-allowed via a settings allow list (default `Bash, Edit, Write, Read, WebFetch`; override with `interactiveAllowTools`). `bypassPermissions` is intentionally not used here because Claude Code shows a manual safety confirmation in the TUI and defaults to exit. +- **Input is text-only:** images and other non-text blocks are dropped (with a logged warning); tool results are rendered as labeled text. +- **Output granularity:** text arrives per transcript record, not token-by-token, so it can feel chunkier than headless streaming. +- **Turn timeout:** a turn that produces no terminal stop within 30 minutes is reported honestly as an error result (visible truncation), not silently ended. +- **No idle eviction:** `idleProcessTimeoutMs` does not apply to interactive sessions. +- `/compact` always uses the headless transport regardless of this setting. + +--- + +## Selective tool proxy + +This is the core feature. + +By default, the plugin proxies `Bash`, `Edit`, `Write`, `WebFetch`, and `Task`. It disables Claude's corresponding built-in tool and exposes an equivalent through an in-process MCP server. Claude calls the MCP version, which blocks until opencode runs the tool through its own executor and permission system. + +### Default proxied tools + +| `proxyTools` value | Claude built-ins disabled | Proxy MCP tool exposed | +|---|---|---| +| `"Bash"` | `Bash` | `mcp__opencode_proxy__bash` | +| `"Edit"` | `Edit`, `MultiEdit` | `mcp__opencode_proxy__edit` | +| `"Write"` | `Write` | `mcp__opencode_proxy__write` | +| `"WebFetch"` | `WebFetch` | `mcp__opencode_proxy__webfetch` | +| `"Task"` | `Agent` | `mcp__opencode_proxy__task`, `mcp__opencode_proxy__task_batch` | +| `"Question"` | `AskUserQuestion` | `mcp__opencode_proxy__question` | +| `"Compress"` | none | `mcp__opencode_proxy__compress` | + +### OpenCode-native subagents + +`Task` is proxied by default. The proxy disables Claude CLI's `Agent` tool and emits an unexecuted `task` call; it does not register a replacement task tool. OpenCode's built-in TaskTool remains responsible for permission checks, creating or resuming the child session, selecting the configured subagent, and foreground/background lifecycle. + +- **Permissions:** the calling agent's `permission.task` rule applies to the target `subagent_type`. Grant `task: "allow"` on agents that should delegate without a prompt; an `ask` or `deny` rule remains authoritative. The plugin never bypasses this decision. +- **Resume:** pass the child session ID back as `task_id` to continue that subagent session. Omit it to create a fresh child. +- **Nested tasks:** current opencode defaults `subagent_depth` to `1`, so a first-level child cannot launch another child. Increase top-level `subagent_depth` to permit deeper nesting, and explicitly grant `permission.task` on every subagent that should delegate; opencode otherwise adds a task deny to spawned subagent sessions. +- **Background:** `background: true` returns after starting the child and lets opencode notify the parent when it finishes. Current opencode requires `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true` in the environment of the opencode process. Foreground is the default. +- **Several at once:** `mcp__opencode_proxy__task_batch` takes a `tasks` array of ordinary task inputs and runs them concurrently. It exists because Claude Code sends MCP requests one at a time: when the model emits two `task` calls in one response, the second only leaves the CLI after the first has returned (measured live, 2026-09-06), so "launch two subagents" was always serial. The plugin turns one `task_batch` call into N opencode `task` calls inside a single tool boundary, which opencode executes in parallel, then hands the model every result together, labelled in task order. Same permissions, same no-deadline default, same `subagent_type` list. Enabled whenever `Task` is proxied. Designed and first implemented by [@broskees](https://github.com/broskees) on his fork. + +**Steering models to it.** Headless Claude Code CLIs expose no `Agent`/`Task` +dispatch tool of their own (verified on 2.1.211), while they *do* expose +`TaskCreate` — a todo tool. So "use a subagent" requests get mis-resolved: +a todo appears, nothing runs, and the model may still narrate a successful +dispatch. Two spawn-time countermeasures prevent that. The plugin injects +opencode's live agent-type list into the `task` proxy description (so the model +picks a real `subagent_type` instead of guessing a Claude Code name like +`general-purpose`, and doesn't grep configs to check a subagent exists), and +appends a system-prompt note naming +`mcp__opencode_proxy__task` as the only dispatch path — with the ToolSearch +recovery step for harnesses that defer MCP tool schemas. Both apply per Claude +process at spawn, and provider options are read once at opencode startup, so +`proxyTools` changes need a full opencode restart. + +### Proxy endpoint security + +The proxy is a small HTTP MCP server on an ephemeral loopback port, and calling it runs Bash, Edit and Write through opencode's executor. Since 0.13.2 it requires a 256-bit bearer token, generated per server and handed to Claude in the `headers` block of the `0600` MCP config file the plugin writes. Requests are also rejected unless the `Host` header matches the bound `127.0.0.1:` authority, no `Origin` header is present, and the content type is `application/json`. + +**Upgrade if you are on 0.13.1 or earlier.** Before this, any local process could post to that port and execute commands as you, and a web page you visited could do the same blind, without reading the response. Reported by @willmcginnis in [#28](https://github.com/khalilgharbaoui/opencode-claude-code-plugin/pull/28); tracked as [GHSA-3mxm-w7gf-3c5x](https://github.com/khalilgharbaoui/opencode-claude-code-plugin/security/advisories/GHSA-3mxm-w7gf-3c5x) (High, CVSS 7.5). No exploitation is known: it was found by code audit, not an incident. + +**Restart every opencode you have running.** A plugin is read once, when the process starts, so an opencode you left open keeps the old code and keeps serving an unauthenticated proxy port for as long as it lives, however new the installed version is. Long-lived sessions are the ones to check: + +```sh +lsof -nP -iTCP -sTCP:LISTEN | grep opencode +curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:PORT/mcp \ + -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' ``` -### Session management +A patched process answers `401`. A `200` is a pre-0.13.2 process still running, and restarting it is the fix. + +Nothing to configure. If proxied tools ever stop working after a Claude Code upgrade, check the plugin log for `proxy-mcp rejected a request`, which names which guard failed. + +### Closing a tool with no proxy + +`proxyTools` only reaches built-ins the plugin can replace. A built-in with no opencode equivalent, `NotebookEdit` today and whatever Claude Code ships next, stays enabled and unmediated no matter what you put in that list. `extraDisallowedTools` names them directly: + +```json +"options": { + "extraDisallowedTools": ["NotebookEdit"] +} +``` -Sessions are managed **per working directory + model**. One active Claude CLI process is kept alive per `(cwd, model)` pair and reused across conversation turns. This means: +These go straight to `claude --disallowedTools`, so use Claude's tool names rather than opencode's. There is no replacement: the capability goes away rather than being routed through opencode, which is the point, but the model then has to work without it. -- **Same session, multiple turns**: The CLI process stays alive between messages. Claude retains full native context. -- **New session**: When opencode starts a new session (first message with no history), any existing process for that `(cwd, model)` is killed and a fresh one is spawned. -- **Resumed session after restart**: If opencode restarts, the in-memory session state is lost. A new CLI process is spawned, and the conversation history is summarized and prepended as context. -- **Abort (Ctrl+C)**: The stream closes but the CLI process stays alive for the next message. +Unknown entries in `proxyTools` are logged as a warning at spawn rather than passing silently, so a typo shows up as "ignoring unknown proxyTools entries" in the plugin log instead of quietly leaving the matching built-in unmediated. -### Tool handling +### Context compression -Claude CLI executes all tools internally (Read, Write, Edit, Bash, Glob, Grep, etc.). Tool calls and results are streamed to opencode for UI display with `providerExecuted: true`. +`"Compress"` is off by default. Add it when you run a harness that expects the model to manage its own context (opencode-dcp injects exactly those instructions), and the plugin exposes `mcp__opencode_proxy__compress`: -Tool name mapping: -- **Built-in tools**: `Edit` -> `edit`, `Write` -> `write`, `Bash` -> `bash`, etc. (lowercased) -- **MCP tools**: `mcp__server__tool` -> `server_tool` (Claude CLI format to opencode format) -- **Claude CLI internal tools**: `ToolSearch`, `Agent`, `AskFollowupQuestion` are silently skipped -- **Questions**: `AskUserQuestion` is rendered as text in the stream +```json +"options": { + "proxyTools": ["Bash", "Edit", "Write", "WebFetch", "Task", "Compress"] +} +``` -### Permissions +It is the one proxy tool opencode never sees. The call is answered inside the plugin: the model passes a `summary`, the plugin stores it, and the turn continues normally. At the start of the **next** turn the Claude Code session is discarded and a fresh `claude` starts with that summary prepended to its system prompt, and nothing else. The earlier conversation is not replayed, so a thin summary means real lost context. The reset waits if the incoming turn is carrying tool results for the running process. -The plugin runs with `--dangerously-skip-permissions` by default. Claude CLI handles all tool execution internally. Users control permissions via Claude Code's own `.claude/settings.json` allow/deny lists. +Without it, the appended system prompt tells the model that `compress` is unavailable and to ignore instructions that ask for it, which is the right answer when nothing implements it. -### Stream sequencing +The round trip is verified live (Claude Code 2.1.263, opencode 1.18.31, haiku): the model called `mcp__opencode_proxy__compress` with a build identifier in its summary, the plugin logged `compress stored summary; session resets next turn`, the next turn logged `compress reset: dropped claude process and session id` and spawned a second `claude`, and that fresh process answered with the identifier it could only have read from the summary in its system prompt. -The plugin ensures proper event ordering for opencode's processor: -- `text-start` -> `text-delta`* -> `text-end` -- `reasoning-start` -> `reasoning-delta`* -> `reasoning-end` -- `tool-input-start` -> `tool-input-delta`* -> `tool-call` -> `tool-result` +Only those seven values are actually proxied; anything else you put in `proxyTools` is ignored. Proxying `Edit` also disables `MultiEdit` — opencode has no batched-edit equivalent, so Claude is forced to fan out into single `Edit` calls that each flow through the permission UI. The `"Question"` proxy is version-gated on opencode's built-in `question` tool: on builds that lack the registry entry the def is silently dropped (a forwarded call would otherwise render as `⚙ invalid`), so add it only on opencode versions that ship the `question` tool. -## Package structure +Without `"Task"` in `proxyTools`, Claude's built-in `Agent` tool stays enabled and Claude orchestrates subagents internally with no opencode child-session visibility. To opt out of all proxying, including Task, use an explicit empty list: +```json +"options": { "proxyTools": [] } ``` -src/ - index.ts # Factory: createClaudeCode() - claude-code-language-model.ts # LanguageModelV2 impl (doGenerate + doStream) - types.ts # Type definitions - tool-mapping.ts # Tool name/input conversion - message-builder.ts # AI SDK prompt -> Claude CLI JSON messages - session-manager.ts # CLI process lifecycle (spawn, reuse, cleanup) - logger.ts # Debug logging + +### Forwarding opencode's own tools + +`proxyTools` names the tools this plugin ships defs for, and MCP-backed opencode tools can be routed with [`proxyOpencodeMcpTools`](#options-reference). Neither covers a tool that **another opencode plugin declares directly**: it belongs to no MCP server, so the automatic match (`` or `_`) skips it and the model is never offered it. opencode-dcp's `compress` is the case that matters in practice, because DCP then injects "MAX CONTEXT LIMIT REACHED ... You MUST use the `compress` tool now" reminders that the model has no way to act on. + +`proxyOpencodeTools` is the explicit allowlist. Empty by default: + +```json +"options": { + "proxyTools": ["Bash", "Edit", "Write", "WebFetch", "Task"], + "proxyOpencodeTools": ["compress"] +} ``` -## Development +Names are opencode's tool ids as `client.tool.list()` reports them, matched case-insensitively. An unknown name is skipped with a warning rather than failing the spawn. Forwarded tools use the same broker as every other proxy tool, so [how a proxied call ends](#how-a-proxied-call-ends) applies to them unchanged: abort, orphan sweep, session deletion and child exit all release them. + +This is deliberately not automatic. A forwarded tool executes inside opencode with the calling agent's permissions, so which ones cross over is your decision, not the plugin's. + +**The `compress` name collision.** Two different tools want it: DCP's, which rewrites opencode's transcript, and [this plugin's](#context-compression), which resets the Claude Code session. They compress different windows, and after a DCP compress the live `claude` process still holds its full context until something restarts it. If you enable both, the plugin's own tool keeps the name and the forwarded one is dropped with a warning in the log: + +``` +WARN: proxyOpencodeTools entry dropped: a proxy tool already holds that name, and it keeps it {"collided":["compress"]} +``` + +Pick one. The appended system prompt describes whichever is actually reachable, so the model is told the right semantics either way. + +Verified live on Claude Code 2.1.263 and opencode 1.18.31 with DCP loaded: the plugin logged `forwarding opencode tools through the proxy {"tools":["compress"]}`, started the proxy with `tools: ["bash","compress"]`, received `proxy-mcp tool call received {"toolName":"compress"}`, queued it through the normal broker, and DCP really ran, returning `Compressed 3 messages into [Compressed conversation section]`. One wrinkle worth knowing: DCP's compress rewrites opencode's message history mid-turn, which makes opencode abort the provider stream at that tool boundary. The pending call is released normally and the result still reaches the model on the next step as text, so the turn completes, but you will see one `abort between proxy tool boundaries` line in the log each time. + +### Trimming unsatisfiable context reminders + +DCP anchors its nudges into message text as `` blocks, so each one is re-sent with every message that carries it. If no `compress` tool is reachable they are an order the model cannot follow, and the plugin already tells it to ignore them. `stripContextReminders: true` stops paying for them too: + +```json +"options": { "stripContextReminders": true } +``` + +Off by default. It removes those blocks from user and assistant text before the transcript reaches the CLI, including the fresh-session rebuild, where every anchored reminder would otherwise replay at once. It leaves opencode's own `` blocks alone: those are opencode's instructions to the model, not an unsatisfiable order. + +It switches itself off whenever `compress` is named in `proxyTools` or `proxyOpencodeTools`, since the reminder is then something the model can act on. The check is on configuration, so a name that is configured but missing from opencode's registry still counts as reachable and nothing is stripped, which errs toward keeping the reminder. + +### Subagent todos + +When Claude works through a multi-step task it emits `TaskCreate` / `TaskUpdate` calls. The plugin translates those into opencode's full-list `todowrite` so the todo panel populates. Inside a **subagent** that translation is blocked unless you say otherwise: opencode's task tool injects `todowrite: false` into the tools dict for any subagent without an explicit rule, so the plugin's synthetic emissions surface as `⚙ invalid todowrite` rows instead of todos. The built-in `general` subagent denies it by default. + +Grant it per subagent definition in `opencode.json`: + +```json +{ + "agent": { + "multistep": { + "description": "Multi-step worker whose progress should be visible as todos", + "mode": "subagent", + "model": "claude-code-default/claude-opus-5", + "permission": { + "todowrite": "allow", + "todoread": "allow", + "task": "deny" + } + } + } +} +``` + +Notes on that example: + +- `todowrite: "allow"` is the load-bearing line. Without it you get `⚙ invalid` rows, not a broken run. +- `todoread` is worth allowing too so the subagent can re-read its own list across turns. +- `task: "deny"` is explicit rather than implied. Leave it denied unless this subagent should itself delegate, in which case set `"allow"` and raise the top-level `subagent_depth` (opencode defaults it to `1`, so a child cannot spawn a grandchild). +- Provider and agent config are read at startup, so restart opencode fully after editing. + +The todos render in the **subagent's own session view**, not the parent's panel. Navigate to it in the TUI with `session.child.next` (and back with `session.parent`); run `opencode --print-logs` or check the keybindings if those actions are unbound in your setup. + +To confirm the data actually landed rather than trusting the UI: ```bash -bun install -bun run build # Build with tsup -bun run dev # Build in watch mode -bun run typecheck # Type check without emitting +sqlite3 ~/.local/share/opencode/opencode.db \ + "select id, parent_id from session order by rowid desc limit 5;" +# then, with the child session id: +sqlite3 ~/.local/share/opencode/opencode.db \ + "select tool, state from part where session_id='' and tool='todowrite';" +``` + +### What you get with proxying on + +- opencode's **permission prompts** for every Bash/Edit/Write/WebFetch call. The default `--dangerously-skip-permissions` is still passed to `claude`, but it only governs Claude's own built-in tools; a proxied call is executed by opencode and answers to opencode's rules instead. Built-ins that are neither proxied nor listed in `extraDisallowedTools` do run under that flag. +- opencode's **audit log** captures the calls. +- Per-tool **policy rules** in opencode apply. + +### What you give up + +- A small per-call latency hop through `127.0.0.1:/mcp`. +- Batched-edit ergonomics: with `Edit` proxied, Claude can no longer use `MultiEdit`, so a refactor that would have been one tool call becomes N single `Edit` calls. + +### How a proxied call ends + +A proxied call ends when something happens to it, not when a clock runs out. The plugin holds the CLI's request open and listens to the process, the stream and the protocol for the events that actually decide the call's fate; each one releases the call on the spot, and tells the CLI where there is still a CLI to tell: + +| What happens | What the plugin does | +|---|---| +| opencode returns the tool's result | resolves the call; the CLI gets the result and carries on | +| you abort the turn (Esc / Ctrl+C) | sends the CLI an `interrupt`, which answers with its own result, and rejects every call the turn had pending, whether the abort lands before content, mid-turn, or while opencode is running the tool between two stream boundaries | +| you send the next message in that chat | rejects every call the previous turn left pending as orphaned, so the CLI gets an error result and the new turn starts clean | +| the `claude` process closes its output or exits, mid-turn or between turns | rejects its pending calls; a mid-turn death also ends the turn as a visible error | +| you delete the chat in opencode, or opencode exits | kills the worker and rejects its pending calls | +| the CLI hangs up on its own request | keeps the call so a late result can still be delivered as a plain-text continuation (see below) | + +Because every ending is observed rather than inferred from elapsed time, a `task` can run until it is finished: **`task` and `task_batch` have no deadline by default**. Earlier flat ceilings fired mid-subagent, Claude believed its dispatch had failed, and the eventual result was dropped because the parent turn had already ended on the timeout error; a 60-minute one did the same to anything longer. What the default gives up is only that nothing fires on the clock alone, so a chat parked in a `task` holds its `claude` worker until one of the events above happens. That is the operator's decision to make, so no timer makes it for them. + +So that a call with no deadline is never silent, the plugin says it is still waiting. Five minutes in, and every five minutes after, a call without a deadline logs a warning naming the tool, the call id, how long it has waited, and what will end it. It never ends the call, it only reports one, which is the whole point: the thing a deadline used to provide was visibility, not correctness, and visibility is what is kept. Calls that do have a deadline get one notice rather than a heartbeat, at 60% of the way to it, saying how long is left and which option would extend it. Before this, a deadline reported a call only by killing it: the first thing you heard was the failure, which is no use while there is still time to react. It is one line, never repeated, because the deadline itself is the next thing that will speak, and deadlines under a minute are skipped entirely since the notice and the rejection would arrive together. The line reaches your terminal (warnings always go to stderr), so a subagent that has genuinely wedged shows up on its own instead of waiting to be noticed. `/claude-code-doctor` lists the same calls on demand. + +The same events are also what let a legitimately long call complete, which is the second half of the story: the CLI's own HTTP client used to give up on a silent reply at about five minutes whatever the tool deadline said. Every held call therefore keeps its connection visibly alive. A client that advertises SSE gets immediate headers and a keepalive comment every 15 seconds (since 0.15.0); a client that only accepts JSON gets its headers immediately as well, as a chunked body carrying keepalive whitespace on the same cadence, which is still one valid JSON-RPC response when the result lands, on success and on error. Keepalives are about the connection, not the tool: they never extend or replace a deadline. Claude's MCP client timeout for the proxy server, written into the generated `--mcp-config`, is set to the largest effective deadline, and to the largest value the CLI accepts (Node's timer maximum, about 24.8 days) while any tool has no deadline, because the CLI rejects a `timeout` of `0` outright. + +### Per-tool proxy timeouts + +Deadlines still exist, as an explicit backstop rather than the mechanism that decides when a call is over. If a tool with one has not been resolved within that many milliseconds, the call is rejected and Claude receives a timeout error. Resolved per tool, most-specific layer winning: + +1. flat default — 10 min (matches Claude CLI's own Bash ceiling) +2. per-tool default: **`task` / `task_batch`: none**, **`question`: 30 min**, everything else: 10 min +3. your `proxyToolTimeoutMs` override (case-insensitive key; a positive value replaces the default, `0` removes the deadline, anything else is ignored) +4. for `bash` only, the call's own `input.timeout`: the proxy never undercuts a build the caller explicitly asked to run long (`max(resolved, input.timeout)`), and a positive `input.timeout` restores a deadline that `bash: 0` removed + +`question` keeps 30 minutes because it blocks on a human reading a form, and a form nobody answers is not an event. A positive `task` override restores a wall-clock backstop for operators who want one; if it fires, the error tells Claude not to "schedule a wake-up": that is a Claude Code affordance which cannot fire in this headless/proxy context, so deferring silently loses the work. + +Two watchdogs are a different thing again and are unchanged: the start watchdog (90 s of complete silence after a turn is written, respawn then error, see `CLAUDE_CODE_START_WATCHDOG_MS`) and the wire-inactivity watchdog (60 s of silence after content, see `CLAUDE_CODE_RESULT_FALLBACK_MS`; when it fires the reply gets a `▌ **stream timeout:**` note so the turn does not just stop). Those exist because a process that is alive but wedged emits no event to listen to, and a proxy call is never what they are waiting on: a CLI parked inside a proxied tool is producing nothing on purpose, and both watchdogs know that. + +If Claude nevertheless abandons the HTTP call, the plugin preserves narration emitted while opencode was running the tool, renders it on return, and delivers the late completion as a plain-text continuation naming the original call. It tells Claude not to run the tool again. A silent post-tool continuation gets one resumed-process retry, preserving the original model, account, effort, and proxy configuration; a second failure ends with an error rather than an indefinite hang. Buffered narration is capped at 500 lines and 2 MiB, with a warning if output was dropped. + +```json +"options": { + "proxyTools": ["Bash", "Edit", "Write", "WebFetch", "Task"], + "proxyToolTimeoutMs": { "Task": 5400000, "bash": 1800000 } +} +``` + +--- + +## Side questions with /btw + +After a normal Claude Code turn, at any time, including while Claude is still working: + +```text +/btw Why did you choose that approach? +``` + +The plugin registers the command without replacing an existing user-defined `btw` command. The question goes to Claude Code's native `side_question` control protocol on the conversation's live process, using the same model, account, and context. Claude Code answers it on a separate call, concurrently with whatever the main turn is doing. Claude never sees the aside afterwards: the question never enters Claude Code's own transcript, and the plugin keeps every `/btw` exchange out of the prompt it sends the model. + +Where the answer appears, in the conversation either way: + +- **A receipt, straight away**, when you asked while a turn was running, in the reply you are watching, so a `/btw` typed mid-turn is visibly taken rather than looking swallowed until the answer arrives: + + ```text + ▌ **btw:** + ▌ *sent to Claude on the side* + ``` + + It quotes the question back untruncated because the prompt box clears on submit and no `/btw` message is ever created, so this is the only place you can read back what you sent. If opencode is between two streams at that moment (it was running a tool), the receipt lands when the next one opens. +- **Inside the running turn's own reply**, as soon as the answer arrives, when you asked while Claude was working. It is written into the reply you are already watching as its own block, headed `▌ **btw:** `, so it stays there and is easy to pick out. Every line of the aside, answer included, carries that `▌` bar, so it reads as one block down its whole height. Nothing is queued and the `/btw` message itself is dropped, because the answer is already in the transcript. The turn goes on to deliver its own reply as usual. +- **As its own `/btw` message and answer** when the conversation is idle, or when the turn had no stream open to write into at that moment (opencode was running a tool between two of them). In the second case the pair lands when the turn ends; nothing is announced in the meantime, because the answer itself is what arrives. +- Follow-ups work: earlier asides in the conversation are sent along as the aside's history. + +Notes: + +- Requires Claude Code CLI **2.1.258 or newer**, the oldest verified version. +- Requires a live **headless** process for the conversation. Send a normal message with a Claude Code model first if the process has not started or was evicted; the answer in the transcript tells you when that is the case. Interactive transport is not supported. +- Asking immediately after starting a turn is fine. The conversation's process only exists once that turn reaches the model, so `/btw` waits for it (up to 30 seconds) instead of falling back to being queued. If no Claude Code process turns up in that window, because the running turn belongs to another provider, the question is answered when the turn ends. +- One aside per conversation at a time. A second `/btw` while one is in flight is asked once the turn ends. +- An aside costs nothing in opencode's counters: a `/btw` pair reports 0 tokens and $0, and a block written into a running turn adds nothing to that turn's usage. The control response has no usage fields, so aside usage is not counted anywhere; this does not mean the request is free. +- An aside written into a turn is marked, and the plugin strips it again if the conversation ever has to be replayed into a fresh Claude Code process. It was never Claude's own output. +- A request times out after two minutes. Abort and timeout cancel that side request without killing the main session. If the running turn is still not over after 30 minutes, the plugin gives up on that `/btw`; ask again once the turn ends. +- The answer is never delivered as a notification: it always lands in the conversation, where it stays. The only two toasts left are the cases where nothing reaches the conversation at all, a bare `/btw` (which shows the usage text) and a turn that ran past the 30 minute wait. + +Fully restart opencode after upgrading to load the command and runtime changes. Other providers do not gain Claude's native side-question behavior from this command. + +## Plugin health with /claude-code-doctor + +```text +/claude-code-doctor +``` + +Prints, in the chat, what the plugin currently thinks is happening. The plugin answers it itself: no model is called, nothing is billed, and the reply reports 0 tokens. It is the thing to paste into a bug report. + +It carries the startup-diagnostics fields (plugin version, opencode version, `claude` path and version, the working directory and which resolution tier picked it, providers, accounts, `proxyTools`, the on-disk MCP servers, transport, whether an `ANTHROPIC_API_KEY` is present) plus the live runtime state the startup block cannot know: + +- every live `claude` child, by opencode session id and model, with its pid, whether a turn is in flight, how long it has been up, and the effort it was spawned at, +- every pending proxy call, with the tool, the call id, how long it has waited, and its deadline, +- each proxy server's URL with one unauthenticated `initialize` posted to it: `401, good` is the patched behaviour, and anything else is flagged unsafe with the fix (restart every opencode window, since a window opened before 0.13.2 keeps serving an open port). See [Proxy endpoint security](#proxy-endpoint-security). + +Nothing secret goes in it: not the proxy bearer token, not the value of `ANTHROPIC_API_KEY`, not the system prompt, not a pending call's arguments. A `claude-code-doctor` command you defined yourself is never overwritten. The name has no space in it because opencode reads everything after the first space as the command's arguments. The whole exchange is kept out of any transcript replayed to the CLI, like a `/btw` pair. + +## Per-turn stats + +Off by default. With `turnStats: true`: + +```text +▌ **stats:** $0.0123 · 4.2 s · 2 CLI turns · in 1.2k · out 812 · cache read 45.1k · cache write 2.0k +``` + +One line at the end of a finished turn, from the numbers the CLI already reports on its `result`. Notes: + +- Never on a `/compact` turn (the footer would be appended to what opencode stores as the summary) and never on a turn that ended in error, where the error is the thing to read. +- It is its own text part led by `▌ **stats:**`, and the plugin strips it again if the conversation is ever replayed into a fresh Claude Code process. The model never reads its own accounting. +- Token counts are the turn's totals, which is what matches the cost. They are deliberately not the same numbers opencode's context gauge shows, which use the last tool-use iteration so the window is not inflated. +- The cost is what the CLI reported for the turn, not a billing guarantee. + +The same numbers are logged at INFO whatever this option is set to, and `total_cost_usd`, `duration_ms`, `duration_api_ms`, `num_turns`, `usage`, `modelUsage` and `permission_denials` always reach `providerMetadata` (denials by tool name and id only, never their inputs). + +## Things the CLI says that are no longer silent + +Four Claude Code stream events used to reach nothing but a debug log: + +- **A rate-limit rejection.** When the CLI reports `status: "rejected"` (or a rejected extra-usage state), the turn now carries a `▌ **rate limit:**` line naming the window, the reason extra usage is unavailable, when it resets, and the four things that can be done about it. Warned once per identity per process. See [Billing](#billing-change-june-15-2026-agent-sdk-credit). +- **A context compaction Claude Code did on its own.** A `▌ **context compacted:**` note says so, with the before and after token counts, so an answer that suddenly forgets the start of the conversation has a visible cause. +- **A `result` whose subtype is not `success`** (`error_max_turns`, `error_during_execution`, …). The subtype is named in the transcript and the turn finishes as an error instead of an ordinary reply. +- **A CLI-executed tool that failed.** Its result is forwarded with the AI SDK's error flag, so opencode renders the row as failed rather than as a success whose output happens to be an error message. + +At session start the plugin also warns once per process for each MCP server Claude Code could not connect (its tools are simply absent otherwise) and once when the CLI's own `apiKeySource` says an API key is in effect, which is the field that tells you pay-as-you-go billing is happening. See [`ignoreAnthropicApiKey`](#options-reference). + +## Configuration skill + +The package includes a `claude-code-plugin` skill so your agent can configure it without asking you to navigate all its options. Ask, for example: + +```text +Use the claude-code-plugin skill to configure a work account and idle worker cleanup. +``` + +It covers accounts, models and agent effort, proxy tools, permissions, MCP/skill bridging, timeouts, logging, upgrades and troubleshooting. It directs the agent to preserve JSONC comments, change only requested settings, validate the result, protect credentials and ask before paid probes or broader permissions. + +The plugin registers the bundled directory with opencode's `skills.paths`, making it available to other providers too on supporting opencode versions. For Claude turns it also loads through Claude's native Skill tool as `opencode-skills:claude-code-plugin`, even when `bridgeOpencodeSkills` is `false`. This requires CLI `--plugin-dir` support and applies to the headless, interactive and direct `doGenerate` spawns; compaction never loads the native bridge. + +No separate skill installation or copying is needed. It ships with each package version, so upgrading updates the reference. Fully restart opencode to load it. `test-configure-skill.ts` checks coverage of provider/logging options, model ids, proxy tools and environment variables; maintainers must update behavior and default guidance in the same change as the implementation. + +## Skill bridge + +opencode and Claude Code use the same on-disk skill format, a `/SKILL.md` whose frontmatter carries `name` and `description`, but they read from different directories. opencode looks in `.opencode/skills/` and `~/.config/opencode/skills/`; the Claude CLI looks in `~/.claude/skills/` and its own plugins. So opencode advertises your skills in the system prompt it forwards, the model calls `Skill("browser-automation")`, and Claude answers `Unknown skill`. + +By default the plugin discovers your opencode skills, stages a throwaway Claude Code plugin directory that links them, and passes it as `claude --plugin-dir`. They register natively, prefixed with the plugin name: + +```text +opencode-skills:browser-automation +opencode-skills:rtk +``` + +Claude can invoke them with the Skill tool or as `/opencode-skills:`. `--plugin-dir` is scoped to the spawned session, so nothing is written into your `~/.claude`. + +Discovery order, first match wins: `.opencode/skills/` walking up from the working directory, then `~/.opencode/skills/`, then `$OPENCODE_CONFIG_DIR/skills/`, then `~/.config/opencode/skills/`. A project skill shadows a global one of the same name. If the skill set is unchanged the staged directory is reused between spawns. + +The bridge is **off by default**: every bridged skill's name and description is also in the system prompt opencode already forwards, so a large skill set is paid for twice on every turn. Set `bridgeOpencodeSkills: true` when the model tries `Skill("")` for a skill opencode advertises and gets `Unknown skill`; the bundled configuration skill is staged either way. When on, the bridge applies to the headless, interactive and direct `doGenerate` spawns alike, never to compaction, and it is skipped on a Claude CLI without `--plugin-dir` (the plugin probes `claude --help` and logs a notice). + +This bridge was written by [@broskees](https://github.com/broskees) (Joseph Roberts) on his fork and absorbed here with credit; see [Credits](#credits). + +## WebSearch routing + +Claude Code ships a built-in `WebSearch` tool. The `webSearch` option controls who actually executes those calls: + +| `webSearch` value | Behavior | When to use | +|---|---|---| +| `"claude"` (default) | Claude CLI runs WebSearch internally via Anthropic. Zero setup, no extra cost, no API key. The query is shown in the transcript as a `> Web search:` line (opencode has no `WebSearch` tool registry entry, so a raw tool row would render as `⚙ invalid`). | Most users. | +| `""` (e.g. `"websearch_web_search_exa"`) | Forward to that opencode-side tool with `executed:false`. Requires the corresponding MCP server to be configured in opencode (e.g. [exa-mcp-server](https://github.com/exa-labs/exa-mcp-server)). | You want a specific search backend (Exa, Tavily, Brave) and have the MCP wired up in opencode. | +| `"disabled"` | `WebSearch` is added to `--disallowedTools` so the model can't call it. | Compliance/security scenarios where outbound search isn't allowed. | + +```json +"options": { "webSearch": "websearch_web_search_exa" } +``` + +**Trade-offs** + +- Claude-side execution: free with your Claude usage, no API key, but no opencode visibility into queries/results, no caching/rate-limit hooks. +- opencode-side execution: choose any backend, queries flow through opencode's audit/policy/cache, but costs money (search APIs are paid) and adds a network hop. +- Some Claude-specific tool features stay on the built-in side (notably `MultiEdit` — see the note above). + +--- + +## MCP bridge + +If `bridgeOpencodeMcp` is true (the default), the plugin reads your opencode config's `mcp` block, translates it into Claude's MCP schema, writes it to a temp file, and passes that to `claude --mcp-config`. So whatever MCP servers you've already configured in opencode become available to Claude with no extra setup. + +### Discovery order (highest to lowest priority) + +1. `OPENCODE_CONFIG` env var (file path) +2. `OPENCODE_CONFIG_DIR` env var +3. Walk up from the current `cwd` looking for `opencode.jsonc`, `opencode.json`, `config.json`, or a `.opencode/` directory +4. Global `$XDG_CONFIG_HOME/opencode` or `~/.config/opencode` + +Later sources override earlier ones **by server name**, so a project-level MCP server replaces a global one with the same id. + +### Translation + +| opencode `type` | Claude `type` | +|---|---| +| `local` | `stdio` | +| `remote` | `http` | + +If you want to manage MCP servers only via `~/.claude/settings.json`, set `bridgeOpencodeMcp: false`. + +To replace (rather than augment) bridged MCP with your own: + +```json +"options": { + "bridgeOpencodeMcp": false, + "mcpConfig": "/path/to/your/mcp.json", + "strictMcpConfig": true +} +``` + +--- + +## Sessions + +Each chat keeps a long-lived `claude` subprocess so the model retains its native context across turns. + +- **Session key**: `(cwd, model, tool-scope, opencode-session-id)`. The opencode session id comes from the `x-session-affinity` header opencode sets on third-party provider calls. Two chats in the same project on the same model run in **separate** CLI processes — they don't race. In account mode, model IDs are suffixed per account, so account sessions do not collide. +- **Same chat, multiple turns** → process reused, full Claude context retained. +- **New chat** → fresh process under the new session key. +- **Resumed chat after restart** → in-memory state is gone; a new process spawns and the conversation history is summarized and prepended. +- **Abort (Esc / Ctrl+C)** → the plugin sends the Claude CLI a stream-json `interrupt` control request, so the CLI actually stops generating and running tools instead of finishing the abandoned turn on your bill. The process stays alive for the next message in that chat, and any proxied call the aborted turn left behind is released when that message arrives (see [How a proxied call ends](#how-a-proxied-call-ends)). If a turn is somehow still running when the next one starts, it is interrupted first (5 s cap). Contributed by [@broskees](https://github.com/broskees). +- **Idle timeout** → when `idleProcessTimeoutMs` is set, a completed headless turn arms an eviction timer (unset or `0` keeps workers until LRU eviction). Reuse cancels it, a worker found mid-turn when it fires is left alone and re-timed, and eviction preserves the session id, so the next message resumes the same conversation with `--resume`. An idle `claude --print` holds around 250 MB, which is the reason to set it if you keep many chats open. +- **Cap**: 16 active processes, LRU eviction. A process that is mid-turn is never the victim: eviction takes the oldest **idle** one, and when every process is busy it evicts nothing and warns instead, so a running answer is never truncated to make room. +- **Deleted chat** → deleting a session in opencode kills its `claude` workers at once and forgets their session ids and per-chat state; there is nothing left to resume. Other chats, and the shared fallback bucket used when no session id is known, are untouched. +- **opencode exits** → every retained worker is killed on the way out, so a hard shutdown does not leave `claude` processes reparented to init. +- **Crash** → if the CLI dies mid-turn (no terminal `result` line), the turn ends with a visible error naming the exit code or signal and the last stderr the CLI wrote, not a silent `stop` that reads as a short but finished answer. An abort you asked for is not reported this way. + +--- + +## Plan mode + +Set `permissionMode: "plan"` to forward `--permission-mode plan` to Claude. The plugin handles `ExitPlanMode` specially — instead of forwarding it as a tool call, it converts it to a confirmation prompt that flows through opencode normally. + +> **Plan mode never permits edits, and you do not have to configure anything for that.** The CLI lets `--dangerously-skip-permissions` override `--permission-mode plan` outright, and `skipPermissions` defaults to `true`, so until this was fixed anyone asking for plan mode silently got full write access (measured on CLI 2.1.258: the run wrote a file on request without a prompt). The plugin now drops the skip flag whenever `permissionMode` is `"plan"`; every other mode governs prompting, which is what that flag is for, so those still pass it. +> +> Two things to know. Nothing releases plan mode mid-session: headless Claude Code is not offered an `ExitPlanMode` tool, so approving a plan in chat does not unlock writes, and leaving plan mode means changing the config and restarting opencode. The plugin warns about this once at startup. And the CLI still writes its own plan document under `~/.claude*/plans/`, which is its own feature and outside your workspace; your files and commands are untouched. + +By default that prompt is text: the plan is rendered as markdown, followed by `**Do you want to proceed with this plan?** (yes/no)`, and you answer in your next message. + +### Approval as a real form (`planModeQuestion`, opt-in) + +Set `planModeQuestion: true` to route the approval through opencode's native `question` tool instead: + +```json +"options": { + "permissionMode": "plan", + "planModeQuestion": true +} +``` + +The plan is still rendered, but the turn then ends on `tool-calls` and opencode runs its own `question` tool, so approval is a form rather than prose. Your answer is fed back to the CLI as the `tool_result` for the original `ExitPlanMode` call, which is what actually unlocks plan mode on the Claude side. A "yes" typed as ordinary text never does that. Anything other than picking `yes` (including custom text) comes back as rejection feedback the model is told to act on. + +> **This cannot currently fire on the default headless transport, so leaving it off costs you nothing.** The form it delivers through works (see [AskUserQuestion](#askuserquestion)), but headless `--print` does not offer the model an `ExitPlanMode` tool at all on CLI 2.1.258, and the bridge keys on that tool call. Measured three ways: asked directly for its tool list in plan mode, the CLI returned `Agent, Bash, Edit, ListAgents, Read, ReportFindings, ScheduleWakeup, Skill, ToolSearch, Workflow, Write` and nothing else; asked to do work it said "I'm unable to exit plan mode from within the tool set available to me"; and a full probe through this plugin with `planModeQuestion: true` produced no `ExitPlanMode` anywhere in `plugin.log` while the model asked for approval in prose. The name is still known to the CLI (`--disallowedTools ExitPlanMode` validates silently, where a bogus name warns), so this reads as headless dormancy rather than removal, the same shape as the [`AskUserQuestion` fallback](#askuserquestion). The text path below is what you actually get, and it works. Re-run those probes on a newer CLI before assuming the bridge is reachable. On opencode builds with no `question` registry entry the plugin silently keeps the text path (look for `plan-mode question gate` in the log). + +Approval bridge contributed by [@CollieIsCute](https://github.com/CollieIsCute). + +--- + +## AskUserQuestion + +opencode ships a built-in `question` tool (`packages/opencode/src/tool/question.ts`) that renders a real TUI form with options and a custom-answer field — near-identical to Claude Code's `AskUserQuestion` (`multiSelect` → `multiple`). The plugin can route `AskUserQuestion` through it so the prompt becomes an actual form instead of plain text. Two modes: + +### With `"Question"` in `proxyTools` (opt-in) + +> **Correction, September 6, 2026: this is no longer blocked, and earlier releases of this README were wrong about why.** The missing form was attributed to an upstream TUI regression. The real cause was local: a notification plugin awaited macOS `alerter` dismissal inside `tool.execute.before`, so the question tool never started. Native providers load that same global plugin, which is why their identical failure did not isolate the TUI. With the hook made non-blocking, the form renders, and the full path through this plugin is verified: on plugin 0.18.0 / Claude Code 2.1.258 / opencode 1.18.29, Claude called `mcp__opencode_proxy__question`, the request appeared in `GET /question`, the reply completed the tool, and Claude's answer contained a token it could only have read from the tool result. Confirmed in a real terminal too: with `"Question"` enabled and opencode relaunched, the proxied call rendered as a TUI form and the clicked answers came back into the turn. +> +> `"Question"` is still opt-in, because turning it on disables Claude's own `AskUserQuestion` (see the fallback below) and that trade should be deliberate. If your form does not render, see [question troubleshooting](#question-troubleshooting) before assuming an upstream bug. + +#### Question troubleshooting + +For a stalled call, inspect `GET /question` on the same opencode server and workspace. If no request exists, check awaited `tool.execute.before` hooks and custom tools replacing `question`, especially notification plugins: a hook opencode waits on runs *before* the tool, so the request cannot exist yet. If a request exists but no form appears, check session ownership, pending permissions, and event delivery. The separate detach/reattach issue [anomalyco/opencode#36604](https://github.com/anomalyco/opencode/issues/36604) remains open; [PR #36603](https://github.com/anomalyco/opencode/pull/36603) is closed without merging. Do not infer a universal platform or version failure from either symptom. + +Add `"Question"` to `proxyTools`. Claude's built-in `AskUserQuestion` is disabled via `--disallowedTools`, and the plugin exposes `mcp__opencode_proxy__question` in its place. A primary agent needs no permission entry (verified on opencode 1.18.29 with no `permission` block at all); if a subagent's form is refused, grant it `permission.question: "allow"` on that agent, the same way [subagent todos](#subagent-todos) need `todowrite`. The model calls the proxy, opencode renders the form, and the operator's answers come back as arrays of selected labels. On builds that lack the `question` registry entry the def is silently dropped at spawn (version gate), and the deny/markdown fallback below applies instead. + +`proxyTools` replaces the default list rather than adding to it, so repeat the defaults you still want: + +```json +"options": { + "proxyTools": ["Bash", "Edit", "Write", "WebFetch", "Task", "Question"] +} ``` -### Debug logging +To turn it back off, drop `"Question"` from the list. It is **not** in the default list, so no configuration means the deny/markdown fallback below stays in force. + +The same spawn-time caveat as `"Task"` applies: provider options are read once at opencode startup, so restart opencode fully after adding it. Question calls get a 30-minute proxy deadline (raise it with `proxyToolTimeoutMs` if you expect to be AFK longer; an expired call comes back as an error, not an answer). + +### Without the proxy (default fallback) + +When `"Question"` is not in `proxyTools` (or the opencode version lacks the `question` tool), the plugin handles `AskUserQuestion` as follows: + +1. **It renders the full question.** The tool's payload — every question, header, option label, and option description — is emitted as readable markdown into the assistant stream so the user actually sees the choices (same approach as `ExitPlanMode`). +2. **It is never auto-allowed at the CLI gate.** Allowing it would let the headless Claude CLI resolve its own question (no TTY → fabricated/empty answer) and proceed on a guess. `controlRequestBehaviorForTool` hard-denies `AskUserQuestion` and returns a message telling the model to **stop and wait for the operator's answer** — end the turn, call no further tools, and never self-answer. (Before v0.7.0 this message also offered an "if the run is non-interactive, proceed with a reasonable guess" fallback. The model could not reliably tell interactive opencode from a headless run and routinely took it, so questions appeared to be skipped — [issue #8](https://github.com/khalilgharbaoui/opencode-claude-code-plugin/issues/8). For genuinely unattended runs, use the `controlRequestToolBehaviors` override below instead.) + +This hard-deny sits **below** `controlRequestToolBehaviors` in precedence but **above** the global `controlRequestBehavior`. So: + +- The global `controlRequestBehavior: "allow"` does **not** override it (interactive setups stay correct by default). +- An explicit per-tool entry **does**. For a fully unattended/automated deployment that prefers "guess and continue" over "stop and wait", restore the old auto-allow: + + ```json + "provider": { + "claude-code": { + "options": { + "controlRequestToolBehaviors": { "AskUserQuestion": "allow" } + } + } + } + ``` + + With `"allow"`, the Claude CLI answers its own `AskUserQuestion` internally and the run never blocks — appropriate only when no operator is watching and forward progress matters more than a correct decision. + +--- -Set `DEBUG=opencode-claude-code` to enable verbose logging to stderr: +## Compaction + +When you run `/compact` in opencode, the plugin handles it on a short-lived dedicated Claude CLI spawn instead of routing it through your main conversation process. Three reasons: + +1. **Cost.** The summarizer reads your entire transcript every time. Routing through a smaller model keeps `/compact` from burning your Opus budget. +2. **Latency.** Claude Haiku 4.5 hits ~150 tok/s with a hard 8k output cap, so compaction completes predictably (~30s for a long transcript). +3. **Cleanliness.** The compaction spawn skips MCP servers, the tool proxy, and the multi-step continuation hint. It's a one-shot text-out call; the rest is overhead. + +The transcript itself is serialized rich: tool inputs and tool results are both included (each clipped at 10k chars), with oldest entries dropped first when the aggregate exceeds 180k chars. The summarizer sees actual tool activity rather than placeholders. + +### Picking a different compaction model + +| Source | How | Wins over | +|---|---|---| +| Env var (per-process) | `CLAUDE_CODE_COMPACTION_MODEL=claude-sonnet-4-6 opencode` | config, default | +| `opencode.json` (per-project) | `"compactionModel": "claude-sonnet-4-6"` under `provider.claude-code.options` | default | +| Default | `claude-haiku-4-5` | – | + +Anything Claude Code's `--model` accepts works as a value. + +--- + +## Extended thinking + +The plugin forwards Claude's thinking blocks (`thinking_delta` stream events) to opencode as reasoning parts, so the "Thinking" row in the chat panel shows whenever the model uses extended thinking. This works across every Claude 4 family model the CLI supports. + +What you see is a **summary** of the model's thinking, not the raw chain-of-thought. Anthropic [stopped exposing raw thinking on the Claude 4 family](https://platform.claude.com/docs/en/build-with-claude/extended-thinking#summarized-thinking) and ships a server-generated digest instead. For Claude Opus 4.7 specifically, [thinking content is omitted from responses by default](https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7#thinking-content-omitted-by-default); the plugin opts back in by passing `--thinking-display summarized` on every spawn. Claude Code CLI 2.1.142+ is required for that flag to take effect; older CLIs skip it silently. + +### Reasoning effort + +Each model exposes five picker variants, `low` / `medium` / `high` / `xhigh` / `max`. An agent's own `reasoningEffort` frontmatter accepts six values: those five plus `minimal`, which maps to the CLI's `low`. The plugin hands the level to the CLI as `CLAUDE_CODE_EFFORT_LEVEL` at spawn, which Claude Code treats as the session-wide override: it beats the `effortLevel` in that account's `settings.json` and a shell export of the same variable. Effort is fixed for the life of a `claude` process, so it is part of the session key. Changing effort retires the previous effort's process and remembered transcript ID before replaying the conversation into a fresh process. Switching back cannot resume stale context; same-effort streaming turns still reuse their process. This reset is scoped to the same directory, model, provider/account, agent, and conversation. If the previous effort still has pending work (including tool results, plan approval, recovery, or `/btw`), the switch is rejected: finish that work at its original effort first. Title, compaction, and `/btw` calls do not trigger effort resets. + +Earlier versions injected a thinking keyword such as `(ultrathink)` into the user message instead. Claude Code stopped recognising every keyword except `ultrathink`, so that path is gone and nothing is appended to your messages any more. Compaction skips request and agent effort overrides, but still inherits a shell-level `CLAUDE_CODE_EFFORT_LEVEL` when set. + +### Env-var overrides + +The plugin respects the standard Claude Code thinking env vars. If you set them in your shell, they pass through to the spawned process untouched, with the one exception in the first row. + +| Env var | Effect | +|---|---| +| `CLAUDE_CODE_EFFORT_LEVEL=` | Session effort override. Passes through when no effort was requested; a variant or an agent's `reasoningEffort` replaces it for that spawn. | +| `CLAUDE_CODE_DISABLE_THINKING=1` | Disable thinking entirely. | +| `CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING=1` | Disable adaptive thinking only. | +| `CLAUDE_CODE_SHOW_THINKING_SUMMARIES=0` | Suppress summaries (the plugin sets this to `1` by default when unset). | + +--- + +## Quirks worth knowing + +- **Empty text blocks are dropped.** Claude sometimes opens a `content_block_start` for text but never sends a delta. The plugin no longer emits the empty block (which was triggering Anthropic 400s like `cache_control cannot be set for empty text blocks`). +- **Smart incomplete-turn continuation.** By default, the plugin keeps the current opencode stream open and feeds Claude CLI a small internal continuation message when Claude emits a `result` after reasoning/tool activity without a useful visible answer. It still stops normally on final-looking answers, questions, blockers, errors, aborts, or internal safety-budget exhaustion. It also resumes an answer the model was cut off mid-sentence: a `max_tokens` stop means truncation rather than completion, so the turn continues instead of ending on half a sentence, capped at 8 attempts and 10 minutes. Every other stop reason is taken at face value. Disable with `"autoContinueIncompleteTurns": false`. +- **`AskUserQuestion`** from the CLI is converted into plain text content rather than forwarded as a tool call — unless `"Question"` is in `proxyTools`, in which case it is routed through opencode's native `question` tool (see [AskUserQuestion](#askuserquestion)). +- **Wire-inactivity watchdog.** Once the CLI has produced any content, the stream closes gracefully if stdout goes silent for 60 seconds without a `result` message arriving. Resets on every line received, so long mid-turn pauses (Sonnet between text-end and the next tool_use, for example) are tolerated. On a user-initiated abort, the watchdog shortens to 5 seconds. +- **Per-iteration usage.** When the CLI internally retries with tools, the plugin only counts the last iteration's usage so opencode's context accounting stays accurate. +- **Lazy `cwd`.** The working directory is re-resolved at every request, so opencode's project-aware behavior works without restarting the plugin. +- **Variants survive merge.** opencode recalculates variant lists after the plugin loads; the plugin re-injects defaults into runtime config so your variants don't disappear. + +## Logging + +Configure via `opencode.jsonc` (launch-method-independent) or env vars +(temporary override for a single process). The plugin has four orthogonal +knobs: + +| Field | Values | Default | Effect | +|---|---|---|---| +| `file` | `true \| false` | `false` | Persist log entries to disk | +| `dir` | path string | `~/.local/share/opencode-claude-code/` | Custom file location | +| `mode` | `"silent" \| "debug"` | `"silent"` | TUI policy | +| `level` | `"debug" \| "info" \| "notice" \| "warn" \| "error"` | `"info"` | Minimum level to emit | + +Rails-style threshold: anything below `level` is dropped before either +destination decides what to do. `mode: "silent"` routes DEBUG/INFO/NOTICE +to file only and lets WARN/ERROR bubble in the TUI (they always do). +`mode: "debug"` additionally echoes every emitted level to the TUI (which +opencode surfaces as warning bubbles). + +`logging` is an ordinary provider option, so it goes under `provider.claude-code.options` like every other one. Keying it on the package name instead is the common mistake: opencode accepts that config without complaint and the plugin never reads it, so you get no log and no error. + +**Recommended dev setup** — capture audit trail to disk, keep TUI quiet: + +```jsonc +{ + "provider": { + "claude-code": { + "options": { + "logging": { "file": true } + } + } + } +} +``` + +The snippets below abbreviate to the `logging` value alone; each one belongs at that same path. + +**Full firehose for deep debugging** (every DEBUG stream event captured): + +```jsonc +"logging": { "file": true, "level": "debug" } +``` + +**Live TUI noise** (everything echoes to opencode's stderr → warning bubbles): + +```jsonc +"logging": { "file": true, "mode": "debug" } +``` + +### Env-var overrides + +Set explicitly to override config for one process — useful for one-off +debugging without editing `opencode.jsonc`: ```bash -DEBUG=opencode-claude-code opencode +OPENCODE_CLAUDE_CODE_LOG_FILE=1 opencode # file on +OPENCODE_CLAUDE_CODE_LOG_FILE=0 opencode # file off (overrides config:true) +OPENCODE_CLAUDE_CODE_LOG_DIR=/tmp/cc opencode # custom dir +OPENCODE_CLAUDE_CODE_LOG_LEVEL=debug opencode # capture every level +DEBUG=opencode-claude-code opencode # promote to mode:"debug" ``` -### Running tests +Boolean env vars accept `1/true/on/yes` for on and `0/false/no/off` for +off; empty / unset falls through to config. Invalid `level` values fall +through to config. + +### Startup diagnostics + +Once per process, right after the provider(s) register, the plugin logs a +single `NOTICE: claude-code plugin ready` line summarizing everything worth +knowing before you start debugging anything else: ```bash -bun run test.ts +OPENCODE_CLAUDE_CODE_LOG_FILE=1 opencode +grep "plugin ready" ~/.local/share/opencode-claude-code/plugin.log ``` -Requires the `claude` CLI to be installed and authenticated. +```json +{ + "plugin": "0.11.1", + "opencode": "1.18.5", + "cwd": { "resolved": "/Users/you/code/app", "source": "process" }, + "providers": ["claude-code-default", "claude-code-work"], + "accounts": ["default", "work"], + "proxyTools": ["Bash", "Edit", "Write", "WebFetch", "Task"], + "mcpServers": ["github", "slack"], + "interactiveTransport": false, + "anthropicApiKeyInEnv": false, + "claudeCli": { "path": "claude", "version": "2.1.211 (Claude Code)" } +} +``` -## Plan mode +Reading it: + +- **`cwd.source`** is which rule picked the working directory Claude will be + spawned in: `configured` (you pinned `options.cwd`), `process` (normal), + `captured` (`process.cwd()` was unusable and opencode's project directory + rescued it, the macOS GUI-launch case), or `unresolved` (neither worked). + The per-session tier that `opencode serve` uses is resolved per call and so + cannot appear here; this line mirrors the synchronous order only. +- **`claudeCli.version`** reading `not detected` means the `claude` binary at + that path didn't answer `--version`, which also disables version-gated + flags like `--thinking-display`. +- **`mcpServers`** is the on-disk merge, before opencode's runtime toggles + are applied (those aren't settled yet at startup). +- **`opencode`** is read from the running opencode binary (`--version`), since + opencode still does not hand its version to plugins. It reads `unknown` when + opencode is run from source rather than as the packaged binary. + +This block is logged once, to a file that is off by default. For the same +fields plus live process and proxy state, without enabling logging, run +[`/claude-code-doctor`](#plugin-health-with-claude-code-doctor) in the session. + +### Default behavior (no config, no env) -When Claude finishes planning, the plugin does **not** automatically exit plan mode (since a plugin cannot switch opencode's mode). Instead, the plan is displayed as text with a confirmation prompt. +Nothing persists; only WARN and ERROR bubble in the TUI. The plugin +doesn't accrete a log file on every user's disk by default — opt in when +you need to inspect auto-continue decisions, broker state, or other +plugin internals. -To proceed after reviewing the plan: -1. Switch to **build mode** using `Tab` -2. Enter `yes` (or `no` to reject) into the prompt +## Compatibility with other opencode plugins + +### [opencode-dcp](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning) (Dynamic Context Pruning) + +Partial support since v0.5.1. DCP runs in a useful degraded mode: its automatic strategies and slash commands work, while its own model-facing tools do not reach the model. Model-driven compression is still available, through this plugin's opt-in [`compress` proxy](#context-compression) rather than DCP's tool. + +| DCP feature | Status | Notes | +|---|---|---| +| `experimental.chat.messages.transform` (compression placeholders, dedup, error purge) | ✅ Works | Transforms run inside opencode before reaching this plugin. | +| `experimental.chat.system.transform` (context-limit nudges, iteration reminders) | ✅ Works in headless | Headless spawns forward system-role content via `--append-system-prompt-file`. Interactive mode intentionally omits opencode's forwarded system prompt and keeps only this plugin's CLI/AGENTS/continuation prompt. | +| `/dcp compress`, `/dcp sweep`, `/dcp manual`, `/dcp context`, `/dcp stats` slash commands | ✅ Works | Handled by opencode's `command.execute.before` hook, not the model. | +| Automatic `deduplication` + `purgeErrors` strategies | ✅ Works | Message-transform only, no model tool calls. | +| DCP's own autonomous `compress` / `distill` / `prune` tool calls | ⚠️ Opt-in | DCP registers those as opencode-native tools rather than through an MCP server, so the automatic MCP routing never saw them. Name one in [`proxyOpencodeTools`](#forwarding-opencode-s-own-tools) and it is forwarded: `proxyOpencodeTools: ["compress"]` makes `mcp__opencode_proxy__compress` run DCP's real tool. | +| Model-driven compression through this plugin's `compress` proxy | ⚠️ Opt-in | Add `"Compress"` to `proxyTools` and the plugin exposes `mcp__opencode_proxy__compress`, which gives the model a working way to compress its own context. It is not DCP's tool and does not use DCP's strategies. See [Context compression](#context-compression). | +| DCP's `` context-limit nudges when no compress tool is reachable | ⚠️ Opt-in strip | Those reminders are anchored into messages, so each one is re-sent with every message that carries it. If you run without either compress route, `stripContextReminders: true` removes them. It turns itself off as soon as a `compress` tool is proxied. | + +So autonomous compression is available, and DCP's own implementation is now one of the options. Three routes, all opt-in: + +- `proxyOpencodeTools: ["compress"]` forwards **DCP's** tool, which compresses opencode's transcript using DCP's strategies. +- `proxyTools: [..., "Compress"]` exposes **this plugin's** tool, which resets the Claude Code session and carries a summary into the fresh one. +- Neither, and trigger DCP by hand with `/dcp compress`. + +The two compress different windows, so pick deliberately rather than enabling both; [Forwarding opencode's own tools](#forwarding-opencode-s-own-tools) explains what happens if you do. With neither enabled, the plugin's appended system prompt tells Claude that no such tool exists and to ignore instructions asking for it, which is the correct answer in that case. + +--- ## Known limitations -- **One session per directory per model**: If you run two opencode instances in the same directory with the same model simultaneously, they will share a CLI process and interfere with each other. This is because opencode doesn't expose its session ID to external providers. -- **MCP servers are separate**: Claude CLI uses its own MCP servers (configured in `~/.claude/settings.json`), not the ones configured in opencode. If you need a specific MCP server (e.g., GitHub), add it to your Claude Code settings. -- **No opencode permission UI integration**: Permission prompts go through Claude CLI's own system, not opencode's permission dialog. +- Tool inputs stream as they are constructed (Anthropic's `input_json_delta` is forwarded as `tool-input-delta`), but only for tool calls opencode actually sees. Calls the plugin deliberately does not forward, meaning proxy tools, CLI-internal `WebSearch`, `AskUserQuestion`, `ExitPlanMode`, the todo-ledger `Task*` family and Claude's other internal tools, have their deltas suppressed, because a delta for a tool opencode never saw start renders as a permanently pending `⚙ unknown` row. +- Raw chain-of-thought is not available. Claude 4 family models ship summarized thinking only. See [Extended thinking](#extended-thinking) for the full picture. +- Recommended Claude Code CLI: **2.1.142+**. Older CLIs work for everything else but skip the `--thinking-display` flag, so Claude Opus 4.7 turns may render empty Thinking rows. If something breaks after a Claude Code update, the CLI version is the first thing to check. +- **Foreground Task calls have no proxy deadline by default.** The plugin listens for the events that end a call instead of timing it (see [How a proxied call ends](#how-a-proxied-call-ends)), so a subagent runs to completion and a chat parked in one holds its `claude` worker until you abort, send another message, delete the chat, or the process goes away. Such a call warns that it is still waiting after five minutes and every five minutes after, so it is never silent. Add a wall-clock backstop via [`proxyToolTimeoutMs`](#per-tool-proxy-timeouts) if you want one. For independent work that should not block the turn at all, use `background: true` after enabling opencode's experimental background-subagent flag. +- **Subagent todos require explicit permission.** See [Subagent todos](#subagent-todos) for the rule and a working config. + +--- + +## Development + +```bash +bun install +bun run typecheck # tsc --noEmit +bun run test # tsx --test (unit suite) +bun run build # tsup -> dist/ +``` + +Source layout: -## Publishing +``` +src/ + index.ts # opencode plugin entry, config + provider hooks + models.ts # default models + variants + accounts.ts # multi-account expansion (per-account CLAUDE_CONFIG_DIR + wrapper script) + claude-code-language-model.ts # AI-SDK provider that drives `claude` + message-builder.ts # AI-SDK prompt → Claude CLI user message + tool-mapping.ts # Claude tool name ↔ opencode tool name mapping; internal-tool skip list + proxy-mcp.ts # in-process MCP server for proxied tools + proxy-broker.ts # pending proxy-call broker between proxy-mcp and opencode tool execution + mcp-bridge.ts # opencode → Claude --mcp-config translator + session-manager.ts # LRU cache of CLI subprocesses + cli-version.ts # detect Claude CLI version, gate optional flags + runtime-status.ts # runtime introspection of opencode (MCP status, tool registry) + logger.ts # DEBUG=opencode-claude-code stderr logger + tmp.ts # per-plugin temp directory helper + cleanup-stale.ts # remove legacy unscoped install from opencode's plugin cache + types.ts # public option types + opencode-types.ts # mirrored opencode types +``` + +For runtime gotchas, the release flow, and the compatibility audit (last taken against **opencode 1.18.29**), see [`AGENTS.md`](./AGENTS.md). -To publish a new version to npm, bump the version in `package.json` and push a tag: +## Publishing (maintainers) ```bash -git tag v0.1.1 -git push origin v0.1.1 +npm version patch # or minor/major — bumps package.json + creates the tag +git push origin master --follow-tags ``` -The GitHub Actions workflow will automatically build and publish to npm on any `v*` tag. +The GitHub Actions workflow at `.github/workflows/publish.yml` runs `npm publish --access public` on tag push. Since v0.6.2 it authenticates with **npm trusted publishing (OIDC)**, not a token: the job holds `id-token: write`, upgrades npm first because OIDC needs npm 11.5.1 or newer, and passes no `NODE_AUTH_TOKEN`. The trusted publisher is configured on npmjs.com against this repository and the `publish.yml` workflow filename, so a publish that fails on auth means that configuration, not an expired secret. There is no `NPM_TOKEN` in the workflow. + +## Star History + + + + + + Star History Chart + + + +## Credits + +This plugin absorbs work from its forks directly, cherry-picked with the original authorship preserved or reimplemented with the author named in the commit, rather than waiting on pull requests. The people behind the features you are using: + +| Who | What | Where | +|---|---|---| +| [@galvani](https://github.com/galvani) (Jan Kozak) | Per-session working directory for `opencode serve`, so one server spawns each project's `claude` in the right place. Also found the stale `toolCallMap` re-emission three months before it was fixed here. | `9e02ce4`, `2238ed0` | +| [@HeikoAtGitHub](https://github.com/HeikoAtGitHub) | Stopped sending `AGENTS.md` to the model twice (opencode already forwards it). Independently diagnosed the 5-minute proxy wall. | `25260a4`, `42f426d` | +| [@bernardofortes](https://github.com/bernardofortes) (Bernardo Fortes) | `idleProcessTimeoutMs`, idle eviction of retained `claude` workers. | `a5f723a` | +| [@broskees](https://github.com/broskees) (Joseph Roberts) | Task proxy default-on (PR #18), the abort `interrupt` so Esc really stops the CLI, the skill bridge, `task_batch` for concurrent subagents (and the measurement that the CLI serialises MCP calls), the undici 300 s diagnosis of the proxy wall, and the lifecycle release of proxied calls that made the `task` deadline unnecessary (PR #36). | PR #18, `68ed142`, PR #36 | +| [@jknlsn](https://github.com/jknlsn) (Jake Nelson) | Per-tool proxy timeouts, subagent dispatch steering, the question proxy, the start watchdog respawn. | `84f3db9`, `94980a6`, `47501d0`, `ffefc24` | +| [@CollieIsCute](https://github.com/CollieIsCute) (Collie Tsai) | The plan-mode approval bridge. | `8c5b583` | +| [@flupkede](https://github.com/flupkede) | The compress proxy tool design and the AI-SDK v4 image-part fix. | `4ac319f`, `60a6e9a` | +| [@CNQQC](https://github.com/CNQQC) | Cost units corrected to dollars per million tokens (PR #25). | PR #25 | +| [@willmcginnis](https://github.com/willmcginnis) | The proxy endpoint authentication (PR #28, GHSA-3mxm-w7gf-3c5x). | PR #28 | +| [@nic-lan](https://github.com/nic-lan) | The issue #29 diagnosis of subagent output lost across the CLI resume boundary, and the fix for unattended output replaying as one text block per delta (PR #35). | #29, PR #35 | +| [@JWebCoder](https://github.com/JWebCoder) (joao moura) | Diagnosed that auto-continue never fires on current CLIs (PR #15). | PR #15 | + +Commit hashes are on the contributors' forks where the work was cherry-picked; `git log --author` on this repo shows the preserved authorship. ## License -MIT +MIT. See [LICENSE](./LICENSE). + +Original work © `unixfox`. Fork modifications © Khalil Gharbaoui. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e5aa488 --- /dev/null +++ b/TODO.md @@ -0,0 +1,82 @@ +# Deferred Checks + +## Ideas + +- 2026-09-06, maintainer: "maybe someday we still want to align it with plan mode of opencode maybe". + Deferred, not scheduled. Make `permissionMode: "plan"` follow opencode's own plan/build agent + instead of being a static provider option. + + Cheaper than it looks, and the objection that killed it the first time does not apply: + the opencode agent is already part of the session key + (`...::ses_...::context=["claude-code-appical","build"]`), so plan and build turns already + run as separate `claude` processes. A Tab back to build would spawn one without the flag, + so a coupled design is not a one-way door the way the static option is. + + What still argues against it, and what to re-check before building: + 1. `"plan"` is only a name. Users define their own agents called plan, some of which write + plan documents into the repo, and forcing CLI plan mode would break those silently. + Any implementation needs an explicit opt-in rather than a name match. + 2. The two disagree about how you leave. Claude Code expects an `ExitPlanMode` tool call + that headless `--print` never offers (measured on 2.1.258, probes recorded in AGENTS.md), + so the model searches for a tool it cannot find and narrates confusion. Re-run those + probes first: if a newer CLI offers `ExitPlanMode` headless, this objection dies and the + `planModeQuestion` bridge becomes reachable at the same time. + 3. It buys little for the common config. opencode's plan mode already denies its own tools, + and `Bash`/`Edit`/`Write` are proxied by default, so the only gap it closes is Claude's + unproxied built-ins. + + Shape if built: an explicit option (something like `planModePermission: "follow-agent"`), + never silent coupling. Do not start this without a user asking for it. + +- 2026-09-09, maintainer: "pin to appical but if limits hit switch to default is that possible?" + Asked while designing the `dev-support` agent, which must run on the appical account for + its per-profile MCP servers (Linear, Aikido, Sentry) but should survive that account's + spend limit. Today it is not possible: the account is the provider, it is fixed for the + life of the `claude` process, and a `forceModel` agent inherits whoever invoked it. When + the limit error arrives ("You've hit your individual spend limit", resets at a stated + time) the turn simply fails and the human restarts on the other account. + + Shape if built: an optional `fallbackAccounts: ["default"]` per agent or per provider. + On a recognised limit error the plugin respawns the session on the next account with + the same model, effort and cwd, and says so in the turn. Things to check first: + 1. The failover account may lack the MCP servers the run depends on; the resumed turn + would need to re-announce its tool list, or the option should refuse to fail over when + the tool sets differ. + 2. Session key includes the account, so a failover is a new process and loses in-process + state; opencode's own transcript is what carries over, which is probably enough. + 3. Detection must match the CLI's limit message exactly, not any 4xx, or a transient + error would silently move billing to another account. + +## Dropped + +- Dropped 2026-09-06 at the user's request: live observation of `idleProcessTimeoutMs: 900000`. The 15-minute eviction and subsequent resume remain unverified in the user's window; no test is planned. + +## Backlog + +## Deferred decisions + +- 2026-09-20: The maintainer chose "later" for adding the Appical MCP project block + to `Appical.IaC`, `Cl-nica-Aurora---Player-team`, `Manager-toolkit`, + `NOW-player-web` and `workshop-sep-2026`. +- 2026-09-20: The maintainer chose "later" for choosing a Slack authentication + strategy. The current global server can still pay a 30-second 1Password unlock + timeout on startup. +- 2026-09-20: The maintainer chose "later" for completing opencode's separate, + global Linear OAuth authentication. + +## Open from you + +Questions the maintainer still owes an answer on. Written here the turn they are +raised, so they survive context compaction; removed when answered, done or dropped. + +No pending questions. + +## In progress + +- 2026-09-20: two lanes dispatched after the maintainer said `go` to every recommendation. + Lane 1, account failover: on whenever more than one account is configured; a synthetic + `question` tool-call on a recognised limit rejection; the pick applies inside the same + opencode turn; sticky for the limited account until its reset time; subagents follow + the parent's pick and never ask; a dismissed form ends the turn as the rate-limit error + does today. Lane 2, small cleanup: stale plan-mode comment, visible result-fallback + timeout, bounded serve-mode maps, silent-turn nudge. diff --git a/e2e-claude-session-bun.ts b/e2e-claude-session-bun.ts new file mode 100644 index 0000000..15ee528 --- /dev/null +++ b/e2e-claude-session-bun.ts @@ -0,0 +1,97 @@ +/** + * E2E for src/claude-session-bun.ts against REAL claude over Bun's native + * ConPTY. Plain runnable script (not part of the offline suite; spawns claude, + * needs a logged-in subscription). Run: + * + * bun e2e-claude-session-bun.ts + * + * Milestone proof: multiple messages in one live chat session, context retained + * across turns (subscription interactive path), with prompt-cache reuse. + */ +import { ClaudeSession, askOnce } from "./src/claude-session-bun.js" + +const TERMINAL = new Set(["end_turn", "stop_sequence", "max_tokens"]) +let failures = 0 +function check(cond: boolean, msg: string) { + if (cond) console.log(" PASS:", msg) + else { + failures++ + console.log(" FAIL:", msg) + } +} + +async function main() { + console.log("=== e2e claude-session-bun (Bun native ConPTY) ===") + console.log( + "bun:", + Bun.version, + "| Bun.Terminal:", + typeof (Bun as any).Terminal, + ) + + console.log("\n[A] one-shot 2+2") + const r = await askOnce("What is 2+2? Reply with only the number.", { + settingSources: "", + }) + console.log(" reply:", JSON.stringify(r.text), "stop:", r.stopReason) + check(TERMINAL.has(r.stopReason ?? ""), "one-shot terminal stop") + check(/4/.test(r.text), "one-shot says 4") + + console.log("\n[B] multi-turn: 3 messages, one live process") + const s = new ClaudeSession({ settingSources: "" }) + await s.start() + try { + const t1 = await s.ask( + "Remember two facts for this conversation: my favorite number is 42 and my favorite color is teal. Reply with exactly: OK", + ) + console.log(" turn1:", JSON.stringify(t1.text), "stop:", t1.stopReason) + check(TERMINAL.has(t1.stopReason ?? ""), "turn1 terminal stop") + + const t2 = await s.ask( + "What is my favorite number? Reply with only the number.", + ) + console.log( + " turn2:", + JSON.stringify(t2.text), + "stop:", + t2.stopReason, + "cacheRead:", + t2.cacheReadTokens, + "eph1h:", + t2.ephemeral1hTokens, + ) + check(TERMINAL.has(t2.stopReason ?? ""), "turn2 terminal stop") + check(/42/.test(t2.text), "turn2 recalls 42 (context retained across turns)") + + const t3 = await s.ask( + "What is my favorite color? Reply with only the word.", + ) + console.log( + " turn3:", + JSON.stringify(t3.text), + "stop:", + t3.stopReason, + "cacheRead:", + t3.cacheReadTokens, + ) + check(TERMINAL.has(t3.stopReason ?? ""), "turn3 terminal stop") + check(/teal/i.test(t3.text), "turn3 recalls teal (context retained across turns)") + + check( + t2.cacheReadTokens > 0 || t3.cacheReadTokens > 0, + "prompt-cache reuse on later turns (1h tier)", + ) + } finally { + s.dispose() + } + + console.log( + `\n=== ${failures === 0 ? "ALL PASS" : failures + " FAILURE(S)"} ===`, + ) + process.exit(failures === 0 ? 0 : 1) +} + +main().catch((e) => { + console.error("FATAL:", e?.stack ?? e) + process.exit(2) +}) diff --git a/jsr.json b/jsr.json index 3479fa0..65ca1d7 100644 --- a/jsr.json +++ b/jsr.json @@ -1,5 +1,5 @@ { - "name": "@unixfox/opencode-claude-code-plugin", + "name": "@khalilgharbaoui/opencode-claude-code-plugin", "version": "0.1.0", "license": "MIT", "exports": "./mod.ts" diff --git a/package.json b/package.json index 8282b45..55227a1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { - "name": "opencode-claude-code-plugin", - "version": "0.1.2", + "name": "@khalilgharbaoui/opencode-claude-code-plugin", + "version": "0.24.0", "description": "Claude Code CLI provider plugin for opencode", + "author": "Khalil Gharbaoui", "type": "module", "main": "dist/index.js", "module": "dist/index.js", @@ -13,20 +14,24 @@ } }, "files": [ - "dist" + "dist", + "skills" ], "scripts": { "build": "tsup", "dev": "tsup --watch", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "tsx --test test-bridge.ts test-broker.ts test-proxy-mcp.ts test-proxy-task.ts test-auto-continue.ts test-has-new-user-content.ts test-get-claude-user-message.ts test-logger.ts test-cli-args.ts test-session-manager.ts test-compaction-model.ts test-tool-mapping.ts test-cwd-resolution.ts test-todo-ledger.ts test-session-affinity.ts test-config-models.ts test-ask-user-question.ts test-claude-session-wrapper.ts test-spawn-env.ts test-respawn.ts test-startup-diagnostics.ts test-subagent-hint.ts test-exit-plan-mode-question.ts test-compress-tool.ts test-agent-models.ts test-side-question.ts test-btw-command.ts test-effort-sessions.ts test-tool-block-index.ts test-skill-bridge.ts test-turn-stats.ts test-cli-events.ts test-cli-events-stream.ts test-result-fallback.ts test-doctor.ts test-configure-skill.ts test-unattended-replay.ts test-process-lifecycle.ts test-account-failover.ts" }, "dependencies": { - "@ai-sdk/provider": "^2.0.0", - "@ai-sdk/provider-utils": "^2.0.0" + "@ai-sdk/provider": "^3.0.8", + "@ai-sdk/provider-utils": "^3.0.8", + "jsonc-parser": "3.3.1" }, "devDependencies": { "@types/node": "^25.5.0", "tsup": "^8.0.0", + "tsx": "^4.22.4", "typescript": "^5.7.0" }, "keywords": [ @@ -39,6 +44,9 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/unixfox/opencode-claude-code-plugin" + "url": "git+https://github.com/khalilgharbaoui/opencode-claude-code-plugin.git" + }, + "publishConfig": { + "access": "public" } } diff --git a/scripts/live-probe.ts b/scripts/live-probe.ts new file mode 100644 index 0000000..4b851c1 --- /dev/null +++ b/scripts/live-probe.ts @@ -0,0 +1,205 @@ +// Live probe against the real Claude Code CLI. Not part of the test suite: +// it spends real tokens and needs a logged-in `claude`. Run with +// npx tsx scripts/live-probe.ts +// Modes (env MODE): +// hold HOLD_MS=390000 proxy holds one bash call for HOLD_MS, then resolves. +// Verifies the CLI still receives the result after a +// 6.5-minute hold using SSE. This checks the measured +// stalled HTTP response behavior, not a specific timer. +// btw one normal turn, then a `side_question` control +// request. Verifies the /btw protocol shape. +// Other env: CLI (path to claude), MODEL (default claude-haiku-4-5). +// HOLD_MS must be an integer from 1 to 1800000. Global deadline: hold + 4 min +// in hold mode, 4 min in btw mode, plus at most 5 seconds for cleanup. +import { spawn } from "node:child_process" +import { createInterface } from "node:readline" +import { randomUUID } from "node:crypto" +import { createProxyMcpServer, DEFAULT_PROXY_TOOLS, type ProxyToolCall } from "../src/proxy-mcp.js" + +const mode = process.env.MODE ?? "btw" +const holdMs = Number(process.env.HOLD_MS ?? "390000") +if (mode !== "hold" && mode !== "btw") { + console.error("MODE must be hold or btw") + process.exit(1) +} +if (!Number.isSafeInteger(holdMs) || holdMs < 1 || holdMs > 1_800_000) { + console.error("HOLD_MS must be an integer from 1 to 1800000") + process.exit(1) +} +const cli = process.env.CLI ?? "claude" +const model = process.env.MODEL ?? "claude-haiku-4-5" +const t0 = Date.now() +const stamp = () => `[+${((Date.now() - t0) / 1000).toFixed(1)}s]` +const say = (...a: unknown[]) => console.log(stamp(), ...a) +const marker = `PROBE-RESULT-OK-${randomUUID()}` +const requestId = randomUUID() +const timers = new Set>() +let done = false +let heldResultReturned = false +let proxyCalled = false +let questionSent = false +let srv: Awaited> | undefined +let proc: ReturnType | undefined +let rl: ReturnType | undefined +let cliClosed: Promise | undefined +const deadline = setTimeout(() => void finish(false, "global timeout"), + (mode === "hold" ? holdMs : 0) + 240_000) + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +async function finish(success: boolean, reason: string) { + if (done) return + done = true + process.exitCode = success ? 0 : 1 + say(success ? "VERIFIED" : "FAIL", reason) + clearTimeout(deadline) + for (const timer of timers) clearTimeout(timer) + timers.clear() + if (!srv) process.exit(1) + const cleanupDeadline = setTimeout(() => { + proc?.kill("SIGKILL") + say("FAIL", "cleanup timeout") + process.exit(1) + }, 5_000) + rl?.close() + proc?.stdin?.destroy() + proc?.kill("SIGTERM") + try { + await Promise.all([srv?.close(), cliClosed]) + } catch { + process.exitCode = 1 + say("FAIL", "cleanup error") + } finally { + clearTimeout(cleanupDeadline) + } +} + +try { + const bash = DEFAULT_PROXY_TOOLS.find((tool) => tool.name === "bash")! + srv = await createProxyMcpServer([bash], { bash: holdMs + 120_000 }) + // If setup outlived the deadline, do not spawn a CLI afterwards. + if (done) { + await srv.close() + } else { + srv.calls.on("call", (call: ProxyToolCall) => { + if (done) return + if (mode !== "hold" || proxyCalled || call.toolName !== "bash") { + void finish(false, "unexpected proxy call") + return + } + proxyCalled = true + say("PROXY CALL RECEIVED; holding response", holdMs) + const timer = setTimeout(() => { + timers.delete(timer) + if (call.channel?.closed) { + void finish(false, "proxy HTTP response closed before hold completed") + return + } + heldResultReturned = true + say("PROXY RESOLVING after hold", holdMs) + call.resolve({ kind: "text", text: marker }) + }, holdMs) + timers.add(timer) + }) + + const args = [ + "--print", "--output-format", "stream-json", "--input-format", "stream-json", + "--include-partial-messages", "--verbose", "--model", model, + "--mcp-config", srv.configPath(), "--strict-mcp-config", + "--disallowedTools", "Bash", "--dangerously-skip-permissions", + ] + say("spawning CLI", "mode=", mode) + const child = spawn(cli, args, { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, TERM: "xterm-256color" }, + }) + proc = child + cliClosed = new Promise((resolve) => child.once("close", () => { + resolve() + if (!done) void finish(false, "unexpected CLI close") + })) + child.on("error", () => void finish(false, "CLI process error")) + child.stdin.on("error", () => void finish(false, "CLI stdin error")) + child.stdout.on("error", () => void finish(false, "CLI stdout error")) + child.stderr.on("error", () => void finish(false, "CLI stderr error")) + // Drain diagnostics without exposing auth details, prompts, or thinking. + child.stderr.resume() + rl = createInterface({ input: child.stdout }) + rl.on("close", () => { + if (!done) void finish(false, "unexpected CLI stdout close") + }) + rl.on("line", (line) => { + if (done) return + let msg: unknown + try { + msg = JSON.parse(line) + } catch { + void finish(false, "invalid CLI JSON") + return + } + if (!isRecord(msg)) { + void finish(false, "invalid CLI message") + return + } + if (msg.type === "error" || msg.is_error === true || + (msg.type === "assistant" && msg.error != null)) { + void finish(false, "CLI reported an error") + return + } + if (msg.type === "control_response") { + const response = msg.response + const answer = isRecord(response) ? response.response : undefined + const valid = mode === "btw" && questionSent && isRecord(response) && + response.request_id === requestId && response.subtype === "success" && + isRecord(answer) && answer.response === "pong" && answer.synthetic === false + void finish(valid, valid ? "native /btw returned matching pong" : "unexpected /btw response") + return + } + if (msg.type === "control_request") { + void finish(false, "unexpected CLI control request") + return + } + if (msg.type === "result") { + if (msg.subtype !== "success" || msg.is_error !== false) { + void finish(false, "unsuccessful CLI result") + return + } + if (mode === "hold") { + const valid = heldResultReturned && msg.result === marker + void finish(valid, valid ? "held marker returned" : "held marker missing or mismatched") + } else if (!questionSent && msg.result === "pong") { + questionSent = true + const req = { + type: "control_request", + request_id: requestId, + request: { + subtype: "side_question", + question: + "What single word did I ask you to reply with? Answer with just that word.", + }, + } + say("SENDING side_question") + child.stdin.write(JSON.stringify(req) + "\n") + } else { + void finish(false, "unexpected initial pong result or extra result") + } + } + }) + + const prompt = + mode === "hold" + ? "Use the mcp__opencode_proxy__bash tool to run the command `echo probe`. After it returns, reply with exactly the text the tool returned and nothing else." + : "Reply with the single word: pong" + say("SENDING user message") + child.stdin.write( + JSON.stringify({ + type: "user", + message: { role: "user", content: [{ type: "text", text: prompt }] }, + }) + "\n", + ) + } +} catch { + await finish(false, "probe setup failed") +} diff --git a/sim/eval-candidate.ts b/sim/eval-candidate.ts new file mode 100644 index 0000000..7afedea --- /dev/null +++ b/sim/eval-candidate.ts @@ -0,0 +1,429 @@ +/** + * Candidate heuristic, evaluated against the same corpus as + * `eval-corpus.ts` to compare projected improvement vs shipped behavior. + * + * v0.4.10 SHIPPED changes vs 0.4.9 (all push toward STOP — safe direction): + * Tweak 2 — Question regex extended with indirect-offer phrases + * ("let me know if", "if you'd like", "tell me if", etc.). + * Tweak 3 — Blocker regex extended with intent-equivalents to + * "requires your" ("needs your", "needs you to", "action required"). + * Tweak 4 — Final-answer length floor lowered 40 → 30 so short clean + * completions ("Task is now completely done. Pushed.") match. + * Tweak 5 — '?' anywhere in last block (was: endsWith only) + soft-proceed + * phrases ("say go", "push back", "your call", "if you want to", + * "sounds good", "ready to ship", etc.) treated as questions. + * Catches F02-shape over-eager fires observed in real plugin.log. + * + * v0.4.11 SHIPPED additions (also push toward STOP): + * Tweak 6 — Question regex picks up "ready when/whenever/once/if you" / + * "standing by" / "i'll stand by" / "let me know when". + * Triggered by 04:00:41 real fire on "Ready when you are." + * — and the meta-irony that "standing by" is the exact stub + * commit 49345e3 fought against at the CLI-stub layer. + * + * v0.4.12 SHIPPED additions (defensive — user-requested preemptive): + * Tweak 7 — Question regex picks up "over to you" / "your turn" / + * "all yours" / "let me know how" / "i'm here". + * User-requested defensive coverage of soft-proceed idioms. + * "i'm here" is FP-prone on conversational openers — accepted + * since cost of FP is one extra continue press. + * + * v0.4.15 SHIPPED additions (also push toward STOP): + * Tweak 8 — Final-answer keyword regex picks up "shipped|deployed| + * merged|tagged|live|pinned". Driven by 03:31 real fire on + * "v0.4.13 on npm" — completion verbs the model uses at + * turn end that weren't in the original v0.4.5 keyword list. + * Tweak 9 — Strong-completion phrases ("we're done", "we are done", + * "all done", "all set") bypass the 30-char length floor. + * User-requested. These are unambiguous end-of-turn signals + * at any text length. + * + * EXPERIMENTAL — NOT SHIPPED: + * Tweak 1 — `looksLikeMidTaskContinuation` override of completion-keyword + * detection. Defined below for documentation/future reference + * but its call site in `looksLikeFinalAnswer` is commented out. + * Rationale for not shipping: would widen auto-continue (the + * unsafe direction), and there are zero observed G-class fires + * in real plugin.log. Keep around in case organic G-class fires + * appear later — corpus G01-G04 are the regression bench. + * + * Run: npx tsx sim/eval-candidate.ts + */ + +type State = { + enabled: boolean | "smart" | undefined + attempts: number + startedAt: number + noProgressCount: number + lastSignature?: string + aborted?: boolean +} +type Snapshot = { + text: string + lastVisibleText: string + hadReasoning: boolean + hadToolActivity: boolean + hadProxyActivity: boolean + isError?: boolean + now?: number +} +type Decision = { continue: boolean; reason: string } + +const AUTO_CONTINUE_MAX_ATTEMPTS = 8 +const AUTO_CONTINUE_MAX_ELAPSED_MS = 10 * 60 * 1000 +const AUTO_CONTINUE_NO_PROGRESS_LIMIT = 2 + +function normalize(text: string): string { + return text.replace(/\s+/g, " ").trim() +} + +function looksLikeQuestion(text: string): boolean { + const t = normalize(text).toLowerCase() + if (!t) return false + // Tweak 5a: '?' anywhere in the last block, not just trailing. Catches + // long answers that ask a question mid-text then list options after, + // ending in a period. FP risk on inline code (`result?.value`) — accepted; + // the cost is one extra "continue" press if it hits. + if (t.includes("?")) return true + // v0.4.11: "ready when you are" / "standing by" / "let me know when". + // v0.4.12: "over to you" / "your turn" / "all yours" / "let me know how" / "i'm here". + return /\b(please confirm|can you confirm|should i|would you like|do you want|which option|choose|pick one|need your|need you to|what would you like|let me know if|let me know whether|let me know what|let me know when|let me know how|if you'?d like|if you want to|tell me if|tell me which|tell me whether|say (?:go|yes|no)|push back|sign off|sounds? (?:good|right)|your call|your move|your turn|over to you|all yours|up to you|ready to (?:ship|go|proceed|merge)|ready (?:when|whenever|once|if) you|standing by|i'?ll stand ?by|i'?m here|happy to (?:ship|go|proceed|merge))\b/.test(t) +} + +function looksLikeBlocker(text: string): boolean { + const t = normalize(text).toLowerCase() + if (!t) return false + return /\b(blocked|blocker|cannot proceed|can't proceed|unable to proceed|need clarification|need more information|permission denied|failed and needs|requires your|needs your|needs you to|action required|manual step|required from you)\b/.test(t) +} + +/** + * Candidate addition: detect explicit forward-motion phrases that prove + * the model is mid-task even if a completion verb is in the same sentence. + * If this fires, looksLikeFinalAnswer is suppressed. + */ +function looksLikeMidTaskContinuation(text: string): boolean { + const t = normalize(text).toLowerCase() + if (!t) return false + return /\b(now [a-z]+ing\b|now i'll|now i will|next i'll|next i will|next [a-z]+ing\b|next to (?:confirm|verify|check|test|ensure|validate|run|see)|moving on|moving to|before i\b|then i'll|then i will|after that|let me also|let's also|i'll also|i will now|i'm going to|going to [a-z]+|kicking off|on to (?:file|step|task|the next))\b/.test(t) +} + +function looksLikeFinalAnswer(text: string): boolean { + const t = normalize(text).toLowerCase() + if (looksLikeQuestion(t) || looksLikeBlocker(t)) return false + // v0.4.15 strong-completion phrases (bypass length floor): + if (/\b(we'?re done|we are done|all done|all set)\b/.test(t)) { + return true + } + // Tweak 4: floor lowered 40 → 30. Catches "Task is now completely done. + // Pushed." (36 chars) without going so low that ambiguous short text + // ("Done with phase 1.") could match. + if (t.length < 30) return false + // Tweak 1 (experimental, NOT shipped in v0.4.10): + // if (looksLikeMidTaskContinuation(t)) return false + // The mid-task-continuation override widens auto-continue, opposite of + // safe failure direction. No real-world G-class fires observed. Kept + // available below for future evaluation. + // v0.4.15: keyword list extended with shipped|deployed|merged|tagged| + // live|pinned (deploy/ship verbs at turn end). Also "tests pass" + // present tense (was past-tense-only) — fixes real fire 03:31 that + // ended in "78/78 tests pass". + return /\b(done|completed|fixed|implemented|verified|published|released|sent|delivered|updated|shipped|deployed|merged|tagged|live|pinned)\b/.test(t) || + /\b(checks?|tests?) (?:pass|passes|passed)\b/.test(t) || + /\b(summary|what changed|verification)\b/.test(t) +} + +function continuationSignature(s: Snapshot): string { + const text = normalize(s.text).slice(-500) + return JSON.stringify({ + text, + reasoning: s.hadReasoning, + tools: s.hadToolActivity, + proxy: s.hadProxyActivity, + }) +} + +function shouldAutoContinueCandidate(state: State, snapshot: Snapshot): Decision { + if (state.enabled === false) return { continue: false, reason: "disabled" } + if (snapshot.isError) return { continue: false, reason: "error" } + if (state.aborted) return { continue: false, reason: "aborted" } + if (state.attempts >= AUTO_CONTINUE_MAX_ATTEMPTS) { + return { continue: false, reason: "max-attempts" } + } + const now = snapshot.now ?? Date.now() + if (now - state.startedAt > AUTO_CONTINUE_MAX_ELAPSED_MS) { + return { continue: false, reason: "max-elapsed" } + } + + const text = normalize(snapshot.text) + const lastText = normalize(snapshot.lastVisibleText) + if (looksLikeQuestion(text)) return { continue: false, reason: "question" } + if (looksLikeBlocker(text)) return { continue: false, reason: "blocker" } + if (looksLikeFinalAnswer(lastText)) { + return { continue: false, reason: "final-answer" } + } + + const hadActivity = + snapshot.hadReasoning || snapshot.hadToolActivity || snapshot.hadProxyActivity + if (!hadActivity) return { continue: false, reason: "no-activity" } + + const signature = continuationSignature(snapshot) + const noProgress = signature === state.lastSignature + if (noProgress && state.noProgressCount + 1 >= AUTO_CONTINUE_NO_PROGRESS_LIMIT) { + return { continue: false, reason: "no-progress" } + } + + if (!text) { + return { continue: true, reason: "activity-without-visible-answer" } + } + + return { continue: true, reason: "non-final-progress" } +} + +// ─────────────────────────────────────────────────────────────────────────── +// Re-import the same cases as the baseline corpus and run both. +// ─────────────────────────────────────────────────────────────────────────── + +import { shouldAutoContinueIncompleteTurn as baseline } from "../src/claude-code-language-model.js" + +interface Case { + id: string + category: string + label: string + state?: Partial + snapshot: Partial + expected: "continue" | "stop" + rationale: string +} + +function mkState(o: Partial = {}): State { + return { enabled: "smart", attempts: 0, startedAt: 1_000, noProgressCount: 0, ...o } as State +} +function mkSnap(o: Partial = {}): Snapshot { + const base: any = { + text: "", lastVisibleText: "", + hadReasoning: false, hadToolActivity: false, hadProxyActivity: false, + now: 1_500, ...o, + } + if (o.text !== undefined && o.lastVisibleText === undefined) base.lastVisibleText = o.text + return base +} + +const cases: Case[] = [ + { id: "A01", category: "should-continue", label: "tool activity only, no text", + snapshot: { hadToolActivity: true }, expected: "continue", rationale: "" }, + { id: "A02", category: "should-continue", label: "short mid-task narration", + snapshot: { text: "Let me check the next file.", hadToolActivity: true }, expected: "continue", rationale: "" }, + { id: "A03", category: "should-continue", label: "step announcement", + snapshot: { text: "Running tests now.", hadProxyActivity: true }, expected: "continue", rationale: "" }, + { id: "A04", category: "should-continue", label: "reasoning only, brief text", + snapshot: { text: "Working on it.", hadReasoning: true }, expected: "continue", rationale: "" }, + { id: "A05", category: "should-continue", label: "multi-step plan narration", + snapshot: { text: "Now I'll read the file. Then I'll diff against previous. Then summarize.", hadReasoning: true }, expected: "continue", rationale: "" }, + + { id: "B01", category: "should-stop-final", label: "explicit completion", + snapshot: { text: "Done — published v0.4.9. Restart opencode to verify the new behavior.", hadReasoning: true, hadToolActivity: true }, expected: "stop", rationale: "" }, + { id: "B02", category: "should-stop-final", label: "verification summary", + snapshot: { text: "Verified end-to-end. 63 tests passed. Build clean. Restart to load.", hadToolActivity: true }, expected: "stop", rationale: "" }, + { id: "B03", category: "should-stop-final", label: "markdown summary section", + snapshot: { text: "## Summary\n- Fixed the import bug\n- Tests pass\n- Published 0.4.9", hadReasoning: true, hadToolActivity: true }, expected: "stop", rationale: "" }, + + { id: "C01", category: "should-stop-question", label: "literal question mark", + snapshot: { text: "I see two paths. Should I proceed with option A or option B?", hadReasoning: true }, expected: "stop", rationale: "" }, + { id: "C02", category: "should-stop-question", label: "which/choose phrasing", + snapshot: { text: "Which approach do you prefer: the broker fix or the heuristic fix?", hadReasoning: true }, expected: "stop", rationale: "" }, + { id: "C03", category: "should-stop-question", label: "indirect offer (no '?')", + snapshot: { text: "Let me know if you'd like me to proceed with the cleanup phase or stop here.", hadReasoning: true }, expected: "stop", rationale: "" }, + + { id: "D01", category: "should-stop-blocker", label: "explicit cannot proceed", + snapshot: { text: "I can't proceed without you setting the API key first.", hadReasoning: true }, expected: "stop", rationale: "" }, + { id: "D02", category: "should-stop-blocker", label: "permission + manual step", + snapshot: { text: "Permission denied on /etc/foo. This is a manual step you'll need to handle.", hadToolActivity: true }, expected: "stop", rationale: "" }, + { id: "D03", category: "should-stop-blocker", label: "indirect approval needed", + snapshot: { text: "Needs your approval before I push the tag — auto-push is not enabled.", hadReasoning: true }, expected: "stop", rationale: "" }, + + { id: "E01", category: "should-stop-noactivity", label: "completely empty", + snapshot: {}, expected: "stop", rationale: "" }, + + { id: "F01", category: "real-fire-repro", label: "02:19:14 over-eager continue", + snapshot: { + text: "Let me check the plugin log and opencode log right after the last turn ended to see what warning surfaced. I'll look at the most recent NOTICE events and correlate with timing. After that I'll inspect the logger code path to find where the leak originates. The hypothesis is that log.notice writes to console.error which opencode promotes to a UI warning bubble.", + hadToolActivity: true, + }, + expected: "continue", rationale: "" }, + { id: "F02", category: "real-fire-repro", label: "02:48:11 long answer ending in recommendation", + snapshot: { + text: ("Here's the full picture. DEBUG was introduced by this plugin (initial commit b03fa8e). opencode itself has no logging convention — plugins use raw console.* and opencode promotes any stderr to UI warnings. Three other installed plugins I sampled all log via plain console.error with no gating. We're the only one in your setup with structured logging or a DEBUG flag. Recommendation: leave DEBUG off (current state); ").repeat(3) + + "consider option C if you want to re-enable DEBUG without UI noise.", + hadReasoning: true, hadToolActivity: true, + }, + expected: "stop", rationale: "" }, + { id: "F03", category: "real-fire-repro", label: "01:10:43 long answer that correctly stopped", + snapshot: { + text: "## Diagnosis complete\n\nThe root cause is clear: the proxy broker holds one pending call per session. I've fixed it. Updated `proxy-broker.ts` with a 10-min timeout and changed the rejection direction. Tests added; 51/51 passing. Verified end-to-end with three scenarios.", + hadReasoning: true, hadToolActivity: true, + }, + expected: "stop", rationale: "" }, + { id: "F04", category: "real-fire-repro", label: "03:31:16 'say go or push back' (today's fire)", + snapshot: { + text: "My recommendation is the conservative path. Here's the projected match rate. Want me to proceed with that? Concretely: 1. Apply 3 surgical changes. 2. Add regression tests. 3. Add header note. 4. Commit sim files. 5. Bump 0.4.9 to 0.4.10. 6. Update opencode.jsonc. Say 'go' or push back on any step.", + hadReasoning: true, + }, + expected: "stop", rationale: "Has '?' mid-text + 'say go' + 'push back' — clear awaiting-input signal" }, + { id: "F05", category: "real-fire-repro", label: "02:48:11 'consider if you want to' (no '?')", + snapshot: { + text: ("Here's the picture. DEBUG was introduced by this plugin. opencode itself has no logging convention. Plugins use raw console.* and opencode promotes stderr to UI warnings. We're the only one with structured logging. Recommendation: leave DEBUG off (current state); ").repeat(3) + + "consider option C if you want to re-enable DEBUG without UI noise.", + hadReasoning: true, hadToolActivity: true, + }, + expected: "stop", rationale: "Reconstruction of 02:48:11 over-eager fire — 'if you want to' is the awaiting-input signal" }, + { id: "F06", category: "real-fire-repro", label: "04:00:41 'Ready when you are' (today's v0.4.11 fire)", + snapshot: { + text: "Yes — real idiom, 'ready and waiting.' But you caught the irony. It's the exact stub Claude CLI used to emit on empty turns. The habit lives in training, not just in Claude CLI's empty-turn behavior. Ready when you are.", + hadReasoning: true, + }, + expected: "stop", rationale: "Real fire from 04:00:41 — 'Ready when you are' is the canonical 'your move' phrase; v0.4.11 adds it explicitly" }, + { id: "F07", category: "real-fire-repro", label: "'Standing by' — the meta-irony stub", + snapshot: { + text: "All done on my side; the rest is on you. Standing by.", + hadReasoning: true, hadToolActivity: true, + }, + expected: "stop", rationale: "Self-referential — the exact stub commit 49345e3 was designed to suppress at the CLI layer; v0.4.11 adds it at the model-output layer too" }, + { id: "F08", category: "real-fire-repro", label: "v0.4.12 'over to you'", + snapshot: { + text: "I've prepared the patch and tests are green. Over to you.", + hadReasoning: true, hadToolActivity: true, + }, + expected: "stop", rationale: "Defensive add; canonical handoff phrase" }, + { id: "F09", category: "real-fire-repro", label: "v0.4.12 'your turn'", + snapshot: { + text: "Reviewed the diff and flagged three concerns. Your turn to pick a direction.", + hadReasoning: true, + }, + expected: "stop", rationale: "Defensive add; explicit 'your move' variant" }, + { id: "F10", category: "real-fire-repro", label: "v0.4.12 'all yours'", + snapshot: { + text: "Branch is rebased and the PR template filled. The rest is all yours.", + hadReasoning: true, hadToolActivity: true, + }, + expected: "stop", rationale: "Defensive add; handoff idiom" }, + { id: "F11", category: "real-fire-repro", label: "v0.4.12 'let me know how'", + snapshot: { + text: "Three viable paths surfaced. Let me know how you'd like to proceed.", + hadReasoning: true, + }, + expected: "stop", rationale: "Defensive add; sibling of let-me-know-if/whether/what/when" }, + { id: "F12", category: "real-fire-repro", label: "v0.4.12 'i'm here'", + snapshot: { + text: "All staged for the release. I'm here when you're ready to ship.", + hadReasoning: true, + }, + expected: "stop", rationale: "Defensive add; FP risk on conversational openers — accepted, safe direction" }, + { id: "F13", category: "real-fire-repro", label: "v0.4.15 'shipped' as keyword (real fire 03:31)", + snapshot: { + text: "v0.4.13 on npm, pin matches, 78/78 tests pass, sim corpus + regression bench preserved as future leverage.", + hadReasoning: true, + }, + expected: "stop", rationale: "Real fire shape — 'shipped' completion verb wasn't in v0.4.14 keyword list" }, + { id: "F14", category: "real-fire-repro", label: "v0.4.15 'deployed/merged/tagged'", + snapshot: { + text: "Patch merged to master, tagged v0.4.15, deployed via CI. Restart at your convenience.", + hadReasoning: true, hadToolActivity: true, + }, + expected: "stop", rationale: "Multiple v0.4.15 keywords in one sentence" }, + { id: "F15", category: "real-fire-repro", label: "v0.4.15 'pinned' as keyword", + snapshot: { + text: "Plugin pinned at @0.4.15 in opencode.jsonc. Restart loads it.", + hadReasoning: true, + }, + expected: "stop", rationale: "'pinned' added as completion verb in v0.4.15" }, + { id: "F16", category: "real-fire-repro", label: "v0.4.15 'we're done' short message bypasses length floor", + snapshot: { + text: "We're done.", // 11 chars — below 30-char threshold + hadReasoning: true, + }, + expected: "stop", rationale: "Strong-completion phrase should bypass length floor" }, + { id: "F17", category: "real-fire-repro", label: "v0.4.15 'all set' short message", + snapshot: { + text: "All set.", // 8 chars + hadReasoning: true, hadToolActivity: true, + }, + expected: "stop", rationale: "Strong-completion phrase at minimal length" }, + + { id: "G01", category: "midtask-keyword-fp", label: "'updated' mid-task", + snapshot: { text: "Updated the cache, now checking for stale entries before the next sync.", hadToolActivity: true }, expected: "continue", rationale: "" }, + { id: "G02", category: "midtask-keyword-fp", label: "'implemented' mid-task", + snapshot: { text: "Implemented the new branch logic. Now writing the test cases before committing.", hadReasoning: true, hadToolActivity: true }, expected: "continue", rationale: "" }, + { id: "G03", category: "midtask-keyword-fp", label: "'fixed' mid-task", + snapshot: { text: "Fixed the import path. Running tests next to confirm nothing else broke.", hadToolActivity: true }, expected: "continue", rationale: "" }, + { id: "G04", category: "midtask-keyword-fp", label: "'done' as step marker", + snapshot: { text: "Done with file 1, moving on to file 2 of 5.", hadProxyActivity: true }, expected: "continue", rationale: "" }, + + { id: "H01", category: "state-machine", label: "max attempts", + state: { attempts: 8 }, snapshot: { text: "Still working on it.", hadToolActivity: true }, expected: "stop", rationale: "" }, + { id: "H02", category: "state-machine", label: "max elapsed", + state: { startedAt: 1_000 }, snapshot: { text: "Still working.", hadToolActivity: true, now: 1_000 + 11 * 60 * 1000 }, expected: "stop", rationale: "" }, + { id: "H03", category: "state-machine", label: "aborted", + state: { aborted: true }, snapshot: { text: "Mid-step text", hadToolActivity: true }, expected: "stop", rationale: "" }, + { id: "H04", category: "state-machine", label: "isError", + snapshot: { text: "Working...", hadToolActivity: true, isError: true }, expected: "stop", rationale: "" }, + { id: "H05", category: "state-machine", label: "user-disabled", + state: { enabled: false }, snapshot: { text: "Mid-step.", hadToolActivity: true }, expected: "stop", rationale: "" }, + { id: "H06", category: "state-machine", label: "no-progress loop", + state: { noProgressCount: 1, lastSignature: JSON.stringify({ text: "", reasoning: false, tools: false, proxy: true }) }, + snapshot: { hadToolActivity: false, hadReasoning: false, hadProxyActivity: true }, expected: "stop", rationale: "" }, + + { id: "I01", category: "boundary", label: "39 chars with 'done'", + snapshot: { text: "Task is now completely done. Pushed.", hadToolActivity: true }, expected: "stop", rationale: "" }, + { id: "I02", category: "boundary", label: "last-block clean, accumulated dirty", + snapshot: { + text: "Implemented the change. Now running tests. ... Initial output looks clean.", + lastVisibleText: "Initial output looks clean.", + hadToolActivity: true, + }, + expected: "continue", rationale: "" }, +] + +function runOne(decider: (s: State, ss: Snapshot) => Decision, label: string): { + matched: number; fp: number; fn: number; rows: string[] +} { + let matched = 0, fp = 0, fn = 0 + const rows: string[] = [] + for (const c of cases) { + const decision = decider(mkState(c.state), mkSnap(c.snapshot)) + const actual = decision.continue ? "continue" : "stop" + const ok = actual === c.expected + if (ok) matched++ + else if (c.expected === "stop" && actual === "continue") fp++ + else fn++ + const flag = ok ? "✓" : actual === "continue" ? "FP" : "FN" + rows.push(`${c.id}\t${flag}\t${decision.reason}`) + } + return { matched, fp, fn, rows } +} + +const baselineRun = runOne((s, ss) => baseline(s, ss), "baseline (0.4.9)") +const candidateRun = runOne((s, ss) => shouldAutoContinueCandidate(s, ss), "candidate") + +console.log("\n# Heuristic Comparison: v0.4.9 baseline vs candidate v0.4.10\n") +console.log(`Cases: ${cases.length}\n`) +console.log("## Per-case comparison\n") +console.log("| ID | Expected | Baseline | Cand. | Δ |") +console.log("|---|---|---|---|---|") +for (let i = 0; i < cases.length; i++) { + const [bid, bflag, breason] = baselineRun.rows[i].split("\t") + const [, cflag, creason] = candidateRun.rows[i].split("\t") + const changed = bflag !== cflag ? "**Δ**" : "" + const c = cases.find((x) => x.id === bid)! + console.log(`| ${bid} | ${c.expected} | ${bflag} \`${breason}\` | ${cflag} \`${creason}\` | ${changed} |`) +} +console.log("\n## Summary\n") +console.log("| Heuristic | Matched | FP | FN | Match rate |") +console.log("|---|---|---|---|---|") +for (const [name, r] of [ + ["baseline v0.4.9", baselineRun], + ["candidate v0.4.10", candidateRun], +] as const) { + console.log(`| ${name} | ${r.matched}/${cases.length} | ${r.fp} | ${r.fn} | ${((r.matched / cases.length) * 100).toFixed(0)}% |`) +} +const delta = candidateRun.matched - baselineRun.matched +console.log(`\nNet improvement: **${delta >= 0 ? "+" : ""}${delta}** cases matched.\n`) diff --git a/sim/eval-corpus.ts b/sim/eval-corpus.ts new file mode 100644 index 0000000..24a5c0b --- /dev/null +++ b/sim/eval-corpus.ts @@ -0,0 +1,407 @@ +/** + * Auto-continue heuristic evaluation corpus. + * + * Throws 30 crafted snapshots at `shouldAutoContinueIncompleteTurn` to + * surface false-positive / false-negative patterns before tightening the + * heuristic for v0.4.10. + * + * Run: npx tsx sim/eval-corpus.ts + */ + +import { shouldAutoContinueIncompleteTurn } from "../src/claude-code-language-model.js" + +type State = Parameters[0] +type Snapshot = Parameters[1] +type Decision = ReturnType + +interface Case { + id: string + category: string + label: string + state?: Partial + snapshot: Partial + expected: "continue" | "stop" + rationale: string +} + +function mkState(overrides: Partial = {}): State { + return { + enabled: "smart" as const, + attempts: 0, + startedAt: 1_000, + noProgressCount: 0, + ...overrides, + } as State +} + +function mkSnap(overrides: Partial = {}): Snapshot { + const base: any = { + text: "", + lastVisibleText: "", + hadReasoning: false, + hadToolActivity: false, + hadProxyActivity: false, + now: 1_500, + ...overrides, + } + if (overrides.text !== undefined && overrides.lastVisibleText === undefined) { + base.lastVisibleText = overrides.text + } + return base as Snapshot +} + +const cases: Case[] = [ + // ─── Category A: should CONTINUE (real work in progress) ──────────────── + { + id: "A01", category: "should-continue", label: "tool activity only, no text", + snapshot: { hadToolActivity: true }, + expected: "continue", + rationale: "Pure tool work mid-task; opencode UI shows the call, model just hasn't narrated yet", + }, + { + id: "A02", category: "should-continue", label: "short mid-task narration", + snapshot: { text: "Let me check the next file.", hadToolActivity: true }, + expected: "continue", + rationale: "Sub-40 chars, mid-step intent statement, clearly more work coming", + }, + { + id: "A03", category: "should-continue", label: "step announcement", + snapshot: { text: "Running tests now.", hadProxyActivity: true }, + expected: "continue", + rationale: "Tool just kicked off; next turn should report results", + }, + { + id: "A04", category: "should-continue", label: "reasoning only, brief text", + snapshot: { text: "Working on it.", hadReasoning: true }, + expected: "continue", + rationale: "Reasoning happened but no tool yet; not at a stopping point", + }, + { + id: "A05", category: "should-continue", label: "multi-step plan narration", + snapshot: { + text: "Now I'll read the file. Then I'll diff against previous. Then summarize.", + hadReasoning: true, + }, + expected: "continue", + rationale: "Explicit plan-state; no completion keywords", + }, + + // ─── Category B: should STOP (final answer) ───────────────────────────── + { + id: "B01", category: "should-stop-final", label: "explicit completion", + snapshot: { + text: "Done — published v0.4.9. Restart opencode to verify the new behavior.", + hadReasoning: true, hadToolActivity: true, + }, + expected: "stop", + rationale: "Classic completion phrase + restart instruction = end-of-turn", + }, + { + id: "B02", category: "should-stop-final", label: "verification summary", + snapshot: { + text: "Verified end-to-end. 63 tests passed. Build clean. Restart to load.", + hadToolActivity: true, + }, + expected: "stop", + rationale: "Multiple completion signals: verified + tests passed", + }, + { + id: "B03", category: "should-stop-final", label: "markdown summary section", + snapshot: { + text: "## Summary\n- Fixed the import bug\n- Tests pass\n- Published 0.4.9", + hadReasoning: true, hadToolActivity: true, + }, + expected: "stop", + rationale: "Has 'summary', 'fixed', 'tests pass', 'published' — extremely final-shaped", + }, + + // ─── Category C: should STOP (question) ───────────────────────────────── + { + id: "C01", category: "should-stop-question", label: "literal question mark", + snapshot: { + text: "I see two paths. Should I proceed with option A or option B?", + hadReasoning: true, + }, + expected: "stop", + rationale: "Ends with '?', explicit ask", + }, + { + id: "C02", category: "should-stop-question", label: "which/choose phrasing", + snapshot: { + text: "Which approach do you prefer: the broker fix or the heuristic fix?", + hadReasoning: true, + }, + expected: "stop", + rationale: "'which' + '?' both trip the regex", + }, + { + id: "C03", category: "should-stop-question", label: "indirect offer (no '?')", + snapshot: { + text: "Let me know if you'd like me to proceed with the cleanup phase or stop here.", + hadReasoning: true, + }, + expected: "stop", + rationale: "Optional follow-up phrased as a statement — heuristic likely misses this", + }, + + // ─── Category D: should STOP (blocker) ────────────────────────────────── + { + id: "D01", category: "should-stop-blocker", label: "explicit cannot proceed", + snapshot: { + text: "I can't proceed without you setting the API key first.", + hadReasoning: true, + }, + expected: "stop", + rationale: "'can't proceed' is the canonical blocker phrase", + }, + { + id: "D02", category: "should-stop-blocker", label: "permission + manual step", + snapshot: { + text: "Permission denied on /etc/foo. This is a manual step you'll need to handle.", + hadToolActivity: true, + }, + expected: "stop", + rationale: "Two blocker keywords", + }, + { + id: "D03", category: "should-stop-blocker", label: "indirect approval needed", + snapshot: { + text: "Needs your approval before I push the tag — auto-push is not enabled.", + hadReasoning: true, + }, + expected: "stop", + rationale: "'Needs your' is intent-equivalent to 'requires your', but heuristic looks for the latter literal", + }, + + // ─── Category E: should STOP (no activity) ────────────────────────────── + { + id: "E01", category: "should-stop-noactivity", label: "completely empty", + snapshot: {}, + expected: "stop", + rationale: "Nothing happened; no reason to continue", + }, + + // ─── Category F: real fire reproductions ──────────────────────────────── + { + id: "F01", category: "real-fire-repro", label: "02:19:14 over-eager continue", + snapshot: { + text: "Let me check the plugin log and opencode log right after the last turn ended to see what warning surfaced. " + + "I'll look at the most recent NOTICE events and correlate with timing. " + + "After that I'll inspect the logger code path to find where the leak originates. " + + "The hypothesis is that log.notice writes to console.error which opencode promotes to a UI warning bubble.", + hadToolActivity: true, + }, + expected: "continue", + rationale: "Logged-real fire that was over-eager from user POV; matches 'mid-investigation, more work coming' but no question/blocker — heuristic correctly fires CONTINUE per its design, the question is whether design is right", + }, + { + id: "F02", category: "real-fire-repro", label: "02:48:11 long answer ending in recommendation", + snapshot: { + text: ("Here's the full picture. DEBUG was introduced by this plugin (initial commit b03fa8e). " + + "opencode itself has no logging convention — plugins use raw console.* and opencode promotes any stderr to UI warnings. " + + "Three other installed plugins I sampled all log via plain console.error with no gating. " + + "We're the only one in your setup with structured logging or a DEBUG flag. " + + "Recommendation: leave DEBUG off (current state); ").repeat(3) + + "consider option C if you want to re-enable DEBUG without UI noise.", + hadReasoning: true, hadToolActivity: true, + }, + expected: "stop", + rationale: "Real 02:48:11 over-eager fire; long analysis ending in concrete recommendation = user expected stop", + }, + { + id: "F03", category: "real-fire-repro", label: "01:10:43 long answer that correctly stopped", + snapshot: { + text: "## Diagnosis complete\n\nThe root cause is clear: the proxy broker holds one pending call per session. " + + "I've fixed it. Updated `proxy-broker.ts` with a 10-min timeout and changed the rejection direction. " + + "Tests added; 51/51 passing. Verified end-to-end with three scenarios.", + hadReasoning: true, hadToolActivity: true, + }, + expected: "stop", + rationale: "Real 01:10:43 fire; clear completion narrative — heuristic correctly stopped", + }, + + // ─── Category G: mid-task keyword false-positives (CRITICAL CLASS) ────── + { + id: "G01", category: "midtask-keyword-fp", label: "'updated' mid-task", + snapshot: { + text: "Updated the cache, now checking for stale entries before the next sync.", + hadToolActivity: true, + }, + expected: "continue", + rationale: "'updated' + 'now checking' = mid-task progress, not completion", + }, + { + id: "G02", category: "midtask-keyword-fp", label: "'implemented' mid-task", + snapshot: { + text: "Implemented the new branch logic. Now writing the test cases before committing.", + hadReasoning: true, hadToolActivity: true, + }, + expected: "continue", + rationale: "'implemented' triggers final-answer but 'now writing' clearly signals more work", + }, + { + id: "G03", category: "midtask-keyword-fp", label: "'fixed' mid-task", + snapshot: { + text: "Fixed the import path. Running tests next to confirm nothing else broke.", + hadToolActivity: true, + }, + expected: "continue", + rationale: "'fixed' triggers but 'Running tests next' = more work", + }, + { + id: "G04", category: "midtask-keyword-fp", label: "'done' as step marker", + snapshot: { + text: "Done with file 1, moving on to file 2 of 5.", + hadProxyActivity: true, + }, + expected: "continue", + rationale: "'done' as a progress marker, not a turn-end signal", + }, + + // ─── Category H: state-machine ────────────────────────────────────────── + { + id: "H01", category: "state-machine", label: "max attempts", + state: { attempts: 8 }, + snapshot: { text: "Still working on it.", hadToolActivity: true }, + expected: "stop", + rationale: "Hit AUTO_CONTINUE_MAX_ATTEMPTS=8", + }, + { + id: "H02", category: "state-machine", label: "max elapsed (10 min budget)", + state: { startedAt: 1_000 }, + snapshot: { text: "Still working.", hadToolActivity: true, now: 1_000 + 11 * 60 * 1000 }, + expected: "stop", + rationale: "11 minutes since start; exceeds 10-min budget", + }, + { + id: "H03", category: "state-machine", label: "aborted", + state: { aborted: true }, + snapshot: { text: "Mid-step text", hadToolActivity: true }, + expected: "stop", + rationale: "Abort signal active", + }, + { + id: "H04", category: "state-machine", label: "isError", + snapshot: { text: "Working...", hadToolActivity: true, isError: true }, + expected: "stop", + rationale: "Claude CLI signaled error", + }, + { + id: "H05", category: "state-machine", label: "user-disabled", + state: { enabled: false }, + snapshot: { text: "Mid-step.", hadToolActivity: true }, + expected: "stop", + rationale: "User opted out via config", + }, + { + id: "H06", category: "state-machine", label: "no-progress loop", + // Signature matches the snapshot below (computed from continuationSignature internals) + state: { + noProgressCount: 1, + lastSignature: JSON.stringify({ text: "", reasoning: false, tools: false, proxy: true }), + }, + snapshot: { hadToolActivity: false, hadReasoning: false, hadProxyActivity: true }, + expected: "stop", + rationale: "Same signature as previous attempt; loop detection should fire when noProgressCount+1 >= 2", + }, + + // ─── Category I: boundary cases ───────────────────────────────────────── + { + id: "I01", category: "boundary", label: "39 chars with 'done' (under threshold)", + snapshot: { + text: "Task is now completely done. Pushed.", // 36 chars + hadToolActivity: true, + }, + expected: "stop", + rationale: "Human reads as complete; heuristic's 40-char floor likely says CONTINUE", + }, + { + id: "I02", category: "boundary", label: "last-block has no keyword, accumulated does", + snapshot: { + text: "Implemented the change. Now running tests. (... 1.2k chars of output ...) Initial output looks clean.", + lastVisibleText: "Initial output looks clean.", + hadToolActivity: true, + }, + expected: "continue", + rationale: "v0.4.6 last-block fix should isolate; only last block evaluated for final-answer", + }, +] + +// ─────────────────────────────────────────────────────────────────────────── + +function runCorpus(): void { + let matched = 0 + let falsePositives = 0 // heuristic said continue, expected stop + let falseNegatives = 0 // heuristic said stop, expected continue + const fpCases: Array<{ id: string; reason: string }> = [] + const fnCases: Array<{ id: string; reason: string }> = [] + + const lines: string[] = [] + lines.push("# Auto-Continue Heuristic Eval Report") + lines.push("") + lines.push(`Plugin: opencode-claude-code-plugin@0.4.9`) + lines.push(`Helper: shouldAutoContinueIncompleteTurn`) + lines.push(`Cases: ${cases.length}`) + lines.push("") + lines.push("| ID | Category | Label | Expected | Actual | Reason | Match |") + lines.push("|---|---|---|---|---|---|---|") + + for (const c of cases) { + const state = mkState(c.state) + const snap = mkSnap(c.snapshot) + const decision: Decision = shouldAutoContinueIncompleteTurn(state, snap) + const actual = decision.continue ? "continue" : "stop" + const ok = actual === c.expected + if (ok) matched++ + else if (c.expected === "stop" && actual === "continue") { + falsePositives++ + fpCases.push({ id: c.id, reason: decision.reason }) + } else { + falseNegatives++ + fnCases.push({ id: c.id, reason: decision.reason }) + } + const flag = ok ? "✓" : actual === "continue" ? "**FP**" : "**FN**" + lines.push( + `| ${c.id} | ${c.category} | ${c.label} | ${c.expected} | ${actual} | \`${decision.reason}\` | ${flag} |`, + ) + } + + lines.push("") + lines.push("## Summary") + lines.push("") + lines.push(`- Total cases: **${cases.length}**`) + lines.push(`- Matched expected: **${matched}** (${((matched / cases.length) * 100).toFixed(0)}%)`) + lines.push(`- False positives: **${falsePositives}** (continued when should stop)`) + lines.push(`- False negatives: **${falseNegatives}** (stopped when should continue)`) + lines.push("") + + if (fpCases.length) { + lines.push("## False Positives (over-eager continues)") + lines.push("") + lines.push("These are the cases where users perceive the assistant as not stopping when it should.") + lines.push("") + for (const fp of fpCases) { + const c = cases.find((x) => x.id === fp.id)! + lines.push(`- **${fp.id}** ${c.label} → heuristic continued with reason \`${fp.reason}\``) + lines.push(` - Rationale: ${c.rationale}`) + } + lines.push("") + } + + if (fnCases.length) { + lines.push("## False Negatives (over-eager stops)") + lines.push("") + lines.push("These cases cause unnecessary 'continue' presses by the user — heuristic should have kept going.") + lines.push("") + for (const fn of fnCases) { + const c = cases.find((x) => x.id === fn.id)! + lines.push(`- **${fn.id}** ${c.label} → heuristic stopped with reason \`${fn.reason}\``) + lines.push(` - Rationale: ${c.rationale}`) + } + lines.push("") + } + + console.log(lines.join("\n")) +} + +runCorpus() diff --git a/skills/claude-code-plugin/SKILL.md b/skills/claude-code-plugin/SKILL.md new file mode 100644 index 0000000..6a891d9 --- /dev/null +++ b/skills/claude-code-plugin/SKILL.md @@ -0,0 +1,562 @@ +--- +name: claude-code-plugin +description: Configure and troubleshoot the opencode-claude-code-plugin, the opencode provider that runs Anthropic Claude models through the Claude Code CLI. Use when the user wants to install, set up, change or debug this plugin, meaning anything under provider.claude-code.options in opencode.json (accounts, proxyTools, cwd, permissions, MCP bridging, timeouts, logging), subagent model or effort, model ids and variants, /btw, the skill bridge, upgrades, or reading plugin.log. Not for opencode's own general configuration. +--- + +# Configuring the Claude Code plugin + +This plugin is `@khalilgharbaoui/opencode-claude-code-plugin`. It registers one or more +`claude-code*` providers and routes inference through the `claude` CLI, not opencode's +native Anthropic provider. Headless `--print` is the default. Subscription headless +usage draws on Agent SDK credit/extra usage under Anthropic's billing policy, not a +promise of free or normal interactive-plan usage. API-key/cloud billing depends on +the CLI's authentication. Confirm the user's intended account and billing method. + +This file ships with the package, so upgrading that package updates the bundled +reference without a separate skill install. Do not copy it into a personal skill +directory: a user override can shadow the bundled version. Match guidance to the +version actually loaded, not a newer checkout. `test-configure-skill.ts` checks name +coverage against source declarations; it does not verify defaults or runtime +semantics or regenerate prose. For behavior, inspect the matching version's +`src/types.ts`, consumers in `src/index.ts` / `src/claude-code-language-model.ts`, and +the relevant module. Comments and README can lag the implementation. + +## Ground rules + +1. **Config lives in opencode's config, not in a plugin file.** Start at + `provider.claude-code.options`. Global defaults usually live in + `~/.config/opencode/opencode.json[c]`; project `opencode.json[c]` and `.opencode/` + files can override them. Check `OPENCODE_CONFIG`, `OPENCODE_CONFIG_DIR` and + `XDG_CONFIG_HOME` before selecting a file. With `accounts`, the seed options are + inherited; an existing `provider.claude-code-.options` can override them. +2. **Provider options are read once, at opencode startup.** After any change the user must fully + quit and relaunch opencode. A plain `/new` session is not enough, and every other + opencode window still open keeps running the old configuration and the old plugin + code. Include serve/GUI processes. Say this every time you change something. + Bridged MCP config has a limited next-turn hot reload, not general config reload. +3. **Edit minimally.** Keep the user's comments in `.jsonc`, keep key order, change only + the keys asked for, and re-parse afterwards. Use surgical text edits or a + JSONC-aware edit API. This package already depends on `jsonc-parser`: its `modify` + and `applyEdits` preserve unrelated text; `parse` must be checked for errors + (`allowTrailingComma: true` for JSONC). Never strip comments with regex or round-trip + JSONC through `JSON.stringify`; that can corrupt URLs or erase comments. +4. **Never edit `dist/`, `node_modules/`, or `~/.cache/opencode/packages/`** to change + behavior. Build output and installer caches are not configuration. +5. **No credentials exposure.** Never read or print auth files, tokens, keys, a full + environment dump, or generated MCP configs. Check credential presence only, not + values. Config, diffs and logs can contain secrets or private prompts; inspect only + relevant fields and redact before displaying or sharing. Leave secret references + such as `{env:NAME}` intact. Do not initiate login/account switching without approval. +6. **No paid probes or risky changes without explicit approval.** Do not run inference + (`claude -p`, `opencode run`, `/btw`), enable extra usage, change billing, grant broad + tool permissions, or enable experimental flags as a routine verification step. + Explain consequences first, including `Question`, `planModeQuestion`, `Compress`, + `interactive`, skill/MCP bridging and fast models. Ask in ordinary text if a decision + is needed; do not use the known-broken question form to configure itself. + +## Procedure + +1. Identify install source/version, config scope, account/provider and requested change. + Inspect relevant config layers without exposing secrets. Preserve unrelated work. +2. If installation is requested, add the scoped package to the existing `plugin` array, + not a replacement array. Preserve pins and `file://` installs unless upgrading was + requested. A local checkout entry is `file:///abs/path/to/opencode-claude-code-plugin`. +3. Edit only the needed options/agent keys. Do not populate every default or invent + plugin-level options, `apiKey`, model metadata, or derived account fields. +4. Validate syntax and the opencode schema (`https://opencode.ai/config.json` when + needed). Schema validation alone does not validate this plugin's free-form options; + check this reference and source for names, types, units and enums. +5. Review the minimal, redacted diff. Report what changed and any unverified behavior. +6. Tell the user to fully restart opencode. Prefer offline checks below; get approval + before launching another opencode process, which may also start configured MCPs. + +## Options reference + +Use `provider.claude-code.options` unless intentionally overriding an expanded account. +Defaults below describe normal headless opencode use when the key is absent. + +| Option | Type | Default | What it does | +|---|---|---|---| +| `cliPath` | string | `"claude"` | Executable, not a shell command with flags. Use an absolute path for a non-PATH install. The opencode config hook supplies this default; only direct `createClaudeCode()` use falls back to `CLAUDE_CLI_PATH`. Account providers wrap it; never select a generated wrapper yourself. | +| `accounts` | string[] | unset | Unset keeps provider `claude-code`. Any array, including `[]`, expands to `claude-code-default` plus normalized, deduplicated names. Non-default accounts use `~/.claude-`; default uses the CLI's normal environment/auth. | +| `accountFailover` | `"ask"` / `"off"` | `"ask"` | When the account a conversation runs on is out of usage, end the turn on opencode's native `question` form listing the other configured accounts, and continue the task on the pick inside the same opencode turn. Only ever fires with more than one account configured, so a single-account install is unaffected by the default. The pick is sticky for the LIMITED account until the limit's reset time (or until opencode restarts when the CLI reported none), so it covers every session on that account and subagents follow their parent; child sessions are never shown the form. Leaving it unanswered waits and costs nothing. `stop`, a dismissal, or text that is not one of the offered accounts ends the turn as the rate-limit error does. Triggered only by a rejected `rate_limit_event` or the two known account-limit error texts, never by a generic failure. Never on compaction turns or the interactive transport. A switch cannot resume the Claude session (transcripts live under the account's own config dir), so the conversation is replayed into a fresh one: it costs input tokens on the new account, and MCP servers configured only in the limited account's Claude profile are gone. `"off"` keeps the plain rate-limit error. | +| `failoverAccounts` | string[] | unset/derived | Account expansion supplies the resolved account list so a limited account can offer the others. Do not hand-wire it; set `accounts` instead. | +| `baseCliPath` | string | unset/derived | The `cliPath` before the per-account wrapper substitution, so a failover can build another account's wrapper on the same binary. Supplied by the config hook. Do not hand-wire it. | +| `defaultSubagentModel` | string | unset | Seed-config default for discovered `mode: subagent` agents without a full `provider/model` pin; `forceModel` takes precedence. Keeps the caller's account. Unknown ids warn and keep the inherited model. Not independently read per expanded account. | +| `cwd` | string | automatic | Pin an absolute existing directory. Otherwise: session directory from SDK, usable `process.cwd()`, captured project directory, final `process.cwd()` fallback. Startup diagnostics cannot show the per-call session tier. | +| `skipPermissions` | boolean | `true` | Pass `--dangerously-skip-permissions` to headless Claude, even with proxies enabled. Proxied calls still use opencode permissions, but unproxied CLI tools do not. `false` removes the bypass flag; it does not by itself create human approval prompts. Ignored when `permissionMode` is `"plan"`, which always drops the flag. | +| `permissionMode` | `acceptEdits` / `auto` / `bypassPermissions` / `default` / `dontAsk` / `plan` | unset | Headless `--permission-mode`, not version-gated: verify the installed CLI supports the value. `plan` is enforced: it overrides `skipPermissions: true` and the plugin drops `--dangerously-skip-permissions` for it, so claude cannot edit or run commands. Every other value governs prompting and still passes the skip flag, so `plan` is the only one that makes a run read-only. Nothing releases plan mode mid-session (no headless `ExitPlanMode`), so leaving it means a config change and an opencode restart; the plugin warns once at startup. Not forwarded by the current interactive spawn path. | +| `controlRequestBehavior` | `allow` / `deny` | `allow` | Automatically answer CLI `can_use_tool` requests if emitted. Not an opencode permission prompt or a sandbox; bypass/pre-allowed tools may never ask. `AskUserQuestion` defaults to deny. | +| `controlRequestToolBehaviors` | object of tool name to `allow`/`deny` | unset | Case-insensitive per-tool override of the above (`Bash`, `Read`, `mcp__github__list_prs`). Do not allow `AskUserQuestion`: that can let headless Claude self-answer. | +| `controlRequestDenyMessage` | string | built-in text | Override ordinary deny text. `AskUserQuestion` always uses its own stop-and-wait message. | +| `proxyTools` | string[] | `["Bash", "Edit", "Write", "WebFetch", "Task"]` | Case-insensitive replacement list, not additive and not a capability allowlist. Known entries expose `mcp__opencode_proxy__`; omitted/unknown tools are not disabled. `Task` also brings `task_batch`; `[]` disables this list, not MCP proxying. See the proxy table for exceptions. | +| `extraDisallowedTools` | string[] | unset | Claude built-ins to switch off outright with `--disallowedTools`, for tools that have no proxy (`["NotebookEdit"]`). Removes the capability rather than routing it. | +| `proxyToolTimeoutMs` | object of proxy tool name to ms | unset | Optional wall-clock backstop per tool, in ms, case-insensitive keys. A proxied call normally ends on an event the plugin listens for, not on a timer: opencode's result, an abort (the CLI is interrupted), the next user message (calls the previous turn left pending are rejected as orphaned), the `claude` process exiting, the chat being deleted, or opencode exiting. Fallback 10 min (including dynamic MCP tools); `task` and `task_batch` have no deadline, so a subagent runs to completion and a chat parked in one holds its worker until one of those events; `question` 30 min. Set both task keys to cover both. A positive value replaces the default, `0` removes that tool's deadline, negative or non-numeric values are ignored, and values above 2147483647 are clamped. Bash `input.timeout` raises the resolved deadline (and restores one after `bash: 0`); executor ceilings still apply. The generated MCP client timeout is the largest effective deadline, or the CLI's maximum while any tool has none. `compress` is intercepted without a deadline. | +| `planModeQuestion` | boolean | `false` | Bridge `ExitPlanMode` approval to opencode's `question` and return a real CLI tool result. Requires a live question registry entry; otherwise keeps text fallback. Cannot fire on the headless transport: CLI 2.1.258 does not offer `ExitPlanMode` under `--print`, measured directly and through a full plugin probe, so the text path is what runs. Prose yes/no is not a verified CLI plan-mode unlock. | +| `webSearch` | `"claude"` / `"disabled"` / `""` | `"claude"` | Default: CLI search with the query rendered as text. Custom target forwards a tool call to an existing opencode tool accepting `query`; this is mapping, not the authenticated proxy replacement, so do not assume CLI search is suppressed. `"disabled"` disallows headless `WebSearch`. | +| `bridgeOpencodeMcp` | boolean | `true` | Discover/translate disk MCP config plus runtime enabled status. False stops this bridge, not explicit `mcpConfig`, the built-in-tool proxy, or Claude's own MCP settings. Only bridge trusted servers. | +| `mcpConfig` | string or string[] | unset | Extra `--mcp-config` paths or inline JSON passed alongside the bridged config. | +| `strictMcpConfig` | boolean | `false` | Headless `--strict-mcp-config`: use only explicitly supplied MCP configs, ignoring other MCP sources, not all settings/credentials/hooks. The interactive wrapper adds it whenever it passes MCP paths, independently of this option. | +| `hotReloadMcp` | boolean | `true` | With bridging on, compare merged MCP config/status at turn start and respawn on drift after pending proxy calls resolve. Keeps the session via headless `--resume`. Does not reload arbitrary provider options or watch explicit `mcpConfig` contents. | +| `proxyOpencodeMcpTools` | boolean | `false` | Route opencode's MCP-backed tools through opencode's executor instead of Claude's own `--mcp-config` child, so each call is permission-prompted and rendered as an opencode tool row. Default changed `true` to `false` here, with no behaviour change: at `true` it routed nothing, because discovery read opencode's tool registry, which never contains MCP tools. Discovery now reads the model tool set opencode passes the provider, verified live on opencode 1.18.31 / Claude Code 2.1.263. **Tell the user to set `strictMcpConfig: true` alongside it**: a server also present in Claude Code's own config is reached directly and the proxy is bypassed, which looks exactly like the option doing nothing. A routed call runs with the calling agent's permissions. Servers whose tools are not found stay on the direct bridge and log a warning. Do not promise exactly-once side effects across failures, retries or opencode versions; verify routing before using write-capable tools. | +| `proxyOpencodeTools` | string[] | `[]` | Forward named opencode tools through the proxy by registry id (`client.tool.list()`, matched case-insensitively). Covers tools another opencode plugin declares directly, which belong to no MCP server and so are never matched by `proxyOpencodeMcpTools`: opencode-dcp's `compress` is the motivating case. Same broker as every other proxy tool, so the same events release the call. Unknown name is skipped with a warning; a name a proxy def already holds is dropped with a warning and the existing tool keeps it. Explicit allowlist only, because a forwarded tool runs in opencode with the calling agent's permissions. | +| `stripContextReminders` | boolean | `false` | Strip opencode-dcp `` blocks from user/assistant message text, including the fresh-session rebuild. Only when no `compress` is proxied via `proxyTools` or `proxyOpencodeTools`; reachable compress makes it inert. Resolved from config, so a configured-but-unregistered name still counts as reachable. Leaves opencode's own `` blocks alone. | +| `multiStepContinuation` | boolean | `true` | Append a system-prompt hint to chain tool calls in one turn instead of stopping between subtasks. | +| `autoContinueIncompleteTurns` | boolean or `"smart"` | `"smart"` | `true`/`"smart"` continue a turn truncated at `max_tokens`, bounded by 8 attempts and 10 minutes, and otherwise run the keyword heuristic only when stop reason is missing. Every other stop reason, plus error, abort or latched question, stops it. Current measured CLIs always report a reason, so truncation is the only case that resumes in practice. | +| `compactionModel` | string | `"claude-haiku-4-5"` | `/compact` uses a fresh short-lived headless process without the usual bridge/proxy/skill wiring. Nonblank `CLAUDE_CODE_COMPACTION_MODEL` wins. This is inference and can be billed. | +| `ignoreAnthropicApiKey` | boolean | `false` | Strip `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` from headless/interactive spawn env, allowing stored auth to be used. Does not log in, change the parent env, or guarantee subscription billing if other CLI/cloud auth is configured. Warns at startup when either nonempty variable is present, regardless of the flag. | +| `idleProcessTimeoutMs` | number | unset | Kill a conversation's idle `claude` worker this many ms after a finished turn. The timer starts when a turn completes, reuse cancels it, and a worker found mid-turn when it fires is re-timed rather than killed. The session id is kept, so the next message resumes transparently. Unset or `0` keeps workers until LRU eviction (16 processes, oldest idle first). Values above `2147483647` are ignored. Not applied to the interactive transport. Deleting a chat in opencode releases its workers and session ids immediately regardless. | +| `turnStats` | boolean | `false` | Append one `▌ **stats:**` line to each finished turn: cost, wall duration, CLI turn count, and input/output/cache-read/cache-write tokens, taken from the CLI's own `result`. Never on a compaction turn or a turn that ended in error. Its own text part, stripped from transcripts rebuilt for the CLI, so the model never sees it. The same numbers are logged at INFO regardless, and `modelUsage` plus `permission_denials` always reach `providerMetadata`. Reported cost is the CLI's figure, not a billing guarantee. | +| `bridgeOpencodeSkills` | boolean | `false` | Stage the user's opencode skills for Claude's native Skill tool as `opencode-skills:`, on headless, interactive and direct `doGenerate` spawns (never compaction). Requires the CLI's `--help` to advertise `--plugin-dir`; otherwise no-op. Bridged skills are also listed in opencode's forwarded system prompt, so a large skill set costs prompt tokens twice, which is why it is off by default; `true` opts the user's skills in. Bundled skill staging ignores this option, but still requires flag support and successful discovery/staging. | +| `interactive` | boolean | unset (headless) | Experimental PTY transport; explicit boolean wins over `CLAUDE_CODE_INTERACTIVE_TRANSPORT`. Needs `Bun.Terminal`; otherwise headless fallback. Compaction stays headless. Does not wire the headless proxy server or disallowed-tools controls; no equivalent opencode permission guarantee or `/btw`. The skill bridge does apply. Never enable to bypass a billing/access restriction. | +| `interactiveBypass` | boolean | `false` | Deprecated no-op. The TUI asks for a manual safety confirmation on `bypassPermissions`, so the plugin never passes it. | +| `interactiveAllowTools` | string[] | `["Bash", "Edit", "Write", "Read", "WebFetch"]` | With `interactive`: replaces the built-in pre-allow list. MCP wildcards from discovered bridge names plus `mcp__opencode_proxy__*` are added even with `[]`. Not a capability denylist; review permissions before enabling. | +| `interactiveSystemPrompt` | boolean | `true` | With `interactive`: append the plugin's own prompt. opencode's forwarded system prompt is deliberately not sent on this transport (it can trip Claude's third-party usage gate). `false` is for diagnostics only. | +| `logging` | object | see below | File and TUI logging policy. | +| `name` | string | unset | Low-level `createClaudeCode()` provider identity fallback after `providerID`, not the opencode display-name setting. Display name lives at `provider..name`; account expansion supplies its own label. Leave this option unset. | +| `providerID` | string | derived | Config hook writes the actual provider id (`claude-code` or `claude-code-work`). Do not override manually. | +| `account` | string | unset/derived | Account expansion supplies this to generate its runtime wrapper. Prefer `accounts` over hand-wiring it. | +| `configDir` | string | unset/derived | Generated account directory, also used for interactive env/transcript lookup. Not a standalone headless auth switch: headless account selection comes from the wrapper's env. Do not hand-wire it. | + +### `logging` object + +| Key | Values | Default | Effect | +|---|---|---|---| +| `file` | boolean | `false` | Persist entries that pass `level`. Logs can contain prompts/tool data/CLI arguments; enable temporarily with consent, not as a credential dump. | +| `dir` | path | `~/.local/share/opencode-claude-code/` | Where `plugin.log` goes. | +| `mode` | `"silent"` / `"debug"` | `"silent"` | After level filtering: silent routes lower levels only to the file if enabled; WARN/ERROR go to stderr/TUI too. Debug echoes all emitted levels to stderr, but does not lower the threshold. | +| `level` | `debug` / `info` / `notice` / `warn` / `error` | `"info"` | Minimum level emitted anywhere. | + +## Environment variables + +Set variables in the environment that launches opencode, then fully restart it. +Precedence is per variable, not a blanket env-over-config rule. CLI-owned variables +are passed through; their final effect depends on the installed CLI. Never print +their secret values. Arbitrary MCP `{env:NAME}` placeholders are outside this list. + +| Variable | Effect | +|---|---| +| `CLAUDE_CLI_PATH` | Direct factory fallback for absent `cliPath`. Normal opencode registration supplies `"claude"`; set the option explicitly there. | +| `CLAUDE_CONFIG_DIR` | CLI auth/settings/session directory. Non-default account wrappers override it; default headless account inherits it if set. Login is a user-approved interactive action, never a diagnostic probe. | +| `CLAUDE_CODE_EFFORT_LEVEL` | Shell-level CLI effort. Request variant/agent effort wins on a normal spawn. Compaction omits request/agent effort, but still inherits the shell env. | +| `CLAUDE_CODE_DISABLE_THINKING` | CLI-owned, conventionally `1` to disable thinking. Plugin leaves it intact and suppresses its own thinking flags/summary defaults if enabled. | +| `CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING` | CLI-owned adaptive-thinking control. Either disable variable suppresses the plugin's own thinking flags/summary defaults, not just adaptive flags. Empty/`0`/`false`/`no`/`off` are false, case-insensitive. | +| `CLAUDE_CODE_SHOW_THINKING_SUMMARIES` | Headless spawn fills in `1` only if unset and neither disable flag is enabled. Any explicit value is preserved and suppresses the plugin's `--thinking-display` override; `0` requests suppression from the CLI. | +| `CLAUDE_CODE_COMPACTION_MODEL` | Nonblank, trimmed value wins over `compactionModel`. | +| `CLAUDE_CODE_DISABLE_FAST_MODE` | CLI-owned kill switch, conventionally `1`; plugin does not interpret it or change picker prices. Use the non-fast id if fast mode is disabled. | +| `CLAUDE_CODE_INTERACTIVE_TRANSPORT` | Fallback when `interactive` is absent: `1` enables; empty/`0`/`false`/`no`/`off` disable (case-insensitive). Explicit `interactive: false` wins. | +| `CLAUDE_CODE_INTERACTIVE_BYPASS` | Deprecated no-op, like `interactiveBypass`. | +| `CLAUDE_CODE_START_WATCHDOG_MS` | Positive integer ms before a headless start or proxy-result continuation is considered silent; default 90000 for missing/invalid/nonpositive values. First expiry respawns, second errors. Bookkeeping-only output is not progress. Keep within timer range; do not lower for routine config checks. | +| `CLAUDE_CODE_RESULT_FALLBACK_MS` | Positive integer ms of stdout silence, after the CLI has produced output, before the turn is closed with no `result`; default 60000 for missing/invalid/nonpositive values. The close is announced in the reply as a `▌ **stream timeout:**` note, which is stripped from any rebuilt transcript. An aborted turn gets no note. | +| `OPENCODE_CLAUDE_CODE_LOG_FILE` | Overrides `logging.file`: trimmed `0/false/no/off` are false; any other nonempty value is true; empty falls back to config. Prefer `1` or `0`. | +| `OPENCODE_CLAUDE_CODE_LOG_DIR` | Overrides `logging.dir`. | +| `OPENCODE_CLAUDE_CODE_LOG_LEVEL` | Overrides `logging.level`. Invalid values fall through to config. | +| `DEBUG` | A value containing `opencode-claude-code` promotes `logging.mode` to debug, not `logging.level`. Preserve other debug namespaces. | +| `OPENCODE_CLAUDE_CODE_PLUGIN_NO_CLEANUP=1` | Skip the one-time removal of a stale unscoped `opencode-claude-code-plugin` install from opencode's package cache. | +| `ANTHROPIC_API_KEY` | CLI API authentication input, stripped when `ignoreAnthropicApiKey` is true; otherwise may change billing away from stored subscription auth. Never display it. | +| `ANTHROPIC_AUTH_TOKEN` | CLI auth-token input; same strip/warning rule. Never display it. | +| `DISABLE_AUTOUPDATER` | Set to `1` on every spawned `claude`, and only when the user has not set it. Keeps the CLI from updating mid-session, which would invalidate the cached version that gates `--thinking-display summarized`, `--plugin-dir` and fast mode. Not a provider option: a user-set value (including `0`, meaning keep updating) is never overwritten, which is the intended escape hatch. Tell a user who wants CLI autoupdates to export `DISABLE_AUTOUPDATER=0`, not to look for a config key. | +| `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | Set to `1` on every spawned `claude` under the same never-overwrite rule. Suppresses non-essential CLI network traffic and independently blocks auto-update. An empty string counts as user-set and is left alone; the CLI reads it as off. | +| `OPENCODE_CONFIG` | Explicit config file, also read by the disk MCP bridge before project layers. | +| `OPENCODE_CONFIG_DIR` | Additional `.opencode`-style config/skill root. The plugin's direct agent-file fallback does not use it; agents must reach the config hook or a supported agent directory. | +| `OPENCODE_WORKTREE` | Overrides the disk MCP bridge's project walk-up boundary. | +| `XDG_CONFIG_HOME` | Global MCP/skill/AGENTS discovery root (`/opencode`); defaults to the home `.config`. Direct agent-file fallback still uses `~/.config/opencode/agent(s)`. | +| `XDG_CACHE_HOME` | Account wrapper/cache-cleanup root override; do not assume the default cache path when upgrading. | +| `HOME` | Home expansion and direct agent-file discovery (other paths also use OS homedir). Do not change it to switch accounts. | +| `USERPROFILE` | Home fallback where `HOME` is absent. | +| `OPENCODE_VERSION` | Startup diagnostics version fallback, not a capability override. | + +## Recipes + +### Minimum install + +```json +{ "plugin": ["@khalilgharbaoui/opencode-claude-code-plugin"] } +``` + +Everything else is optional. Models appear in the picker without extra config. + +### Two accounts + +```json +{ + "provider": { "claude-code": { "options": { "accounts": ["personal", "work"] } } } +} +``` + +Creates `claude-code-default`, `claude-code-personal`, `claude-code-work`; default +models have no suffix, other accounts have `@` (`claude-opus-5@work`). Names +normalize to lowercase hyphen-separated ids, so choose distinct simple names. +After the user approves login, they authenticate each non-default account interactively, +for example `CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude auth login`, using the chosen +binary. Never copy credentials between accounts. The generated wrapper strips the model +suffix and sets the config dir. Existing `CLAUDE.md`, `settings.json`, `skills/`, +`agents/`, `commands/`, `plugins/` in `~/.claude` are symlinked only when targets are +missing; existing targets stay untouched. This shares capabilities/settings, not an +isolation boundary. Auth/session files are not part of the shared list. + +### Account failover + +With more than one account configured, `accountFailover` is `"ask"` by default. When a +turn is rejected for usage, the turn ends on opencode's `question` form instead of an +error: one option per other configured account, plus `stop`. Picking an account applies +it inside the same opencode turn, with no new user message, and the task carries on. +Leaving the form unanswered waits and costs nothing. + +Tell the user what a pick actually does before recommending one: + +- It is sticky for the **limited account** until that limit's reset time, or until + opencode restarts when the CLI reported no reset time. Every session on the limited + account follows the same pick, and subagents follow their parent. Child sessions are + never shown the form themselves. +- A switch **cannot resume the Claude session**, because transcripts live under each + account's own `CLAUDE_CONFIG_DIR`. The conversation is replayed into a fresh session + on the target account, which costs input tokens there and loses anything the CLI held + but opencode did not. +- MCP servers configured only in the limited account's Claude profile will be **missing** + on the target account. +- `stop`, dismissing the form, or answering with anything that is not one of the offered + accounts ends the turn exactly as the rate-limit error does today. The limit is + unchanged either way; failover moves the work, it does not create usage. +- Only a rejected `rate_limit_event` or one of the two known account-limit error texts + opens the form. A generic 4xx, a timeout or a bad flag never does. +- Not available on the interactive transport or on compaction turns. + +`{ "accountFailover": "off" }` keeps the plain rate-limit error. + +### Subagents on one model, on the caller's account + +opencode's agent config cannot say "inherit the account, change the model", because the +account is the provider and the model is only a `--model` flag. The plugin closes that gap. + +Per agent, in `~/.config/opencode/agents/.md` or `.opencode/agents/.md` +(`agent/` singular also works), no `model:` key: + +```yaml +--- +description: Designs and builds UI work +mode: subagent +forceModel: claude-haiku-4-5 +reasoningEffort: high +--- +``` + +Or once for every discovered subagent without a full provider/model pin: + +```json +{ "provider": { "claude-code": { "options": { "defaultSubagentModel": "claude-opus-5" } } } } +``` + +Rules, in order: `forceModel` wins; else `mode: subagent` with `defaultSubagentModel` +set; else untouched. An agent with `model: /` is left exactly as written, +account and all (`model: claude-code-work/claude-opus-5@work` pins the account too). +Undeclared built-ins are not discovered; a user definition with a built-in name can +enter the registry and is subject to these rules. This is not a built-in-name denylist. +`reasoningEffort` in the agent file beats the effort the call arrived with; compaction is +exempt. Effort and model are part of the CLI session key, so a changed agent respawns +rather than sharing a process. + +Only grant `permission.task` for approved target agents if delegation is wanted. +`permission.todowrite: "allow"` is needed for subagent todos; opencode otherwise denies +them by default. Ask before broadening permissions. Use the singular `agent` config +object for inline definitions, with `forceModel`/`reasoningEffort` under `options` if +the opencode schema requires it. Markdown fallback reads top-level scalar fields only. + +### Agent keys + +| Key | Behavior | +|---|---| +| `mode` | Only exactly `subagent` qualifies for `defaultSubagentModel`; `primary`/`all` do not. | +| `model` | Full `provider/model` pins bypass plugin model overrides, not the separate effort override. | +| `forceModel` | Registered bare model id, preserving the caller's account even if an account suffix is supplied. Works for any discovered agent mode. | +| `reasoningEffort` | `minimal`, `low`, `medium`, `high`, `xhigh`, `max`; invalid declarations warn and keep inherited effort. `minimal` maps to CLI `low`. Compaction skips this override. | + +### Route a tool through opencode, or switch one off + +```json +{ "proxyTools": ["Bash", "Edit", "Write", "WebFetch", "Task"], "extraDisallowedTools": ["NotebookEdit"] } +``` + +Options fragments in recipes belong inside `provider.claude-code.options`, not at +the config root. Preserve other wanted proxies when changing this replacement list. +`Read`, `Glob` and `Grep` have tool mappings/disallowed-name entries but no selectable +proxy definitions in this version, just like `NotebookEdit` has no proxy. Adding them +to `proxyTools` warns and leaves the built-ins unproxied. Use `extraDisallowedTools` +only to deliberately remove a capability; omission from `proxyTools` is not denial. + +The proxy's loopback endpoint has bearer, Host, Origin and Content-Type guards. +Never weaken them, publish its token or relax the generated MCP file's `0600` mode. +Restart all old processes after a security upgrade; changing files cannot patch them. + +### Let the model satisfy an opencode-dcp compress nudge + +DCP injects "MAX CONTEXT LIMIT REACHED ... You MUST use the `compress` tool now" +reminders. DCP declares `compress` directly rather than through an MCP server, so +automatic MCP routing never offers it and the model cannot obey. Two choices, and +they are different tools, so choose one rather than both: + +```json +{ "proxyOpencodeTools": ["compress"] } +``` + +forwards DCP's real tool, which compresses opencode's transcript with DCP's +strategies. The live `claude` process keeps its own context until it restarts. + +```json +{ "proxyTools": ["Bash", "Edit", "Write", "WebFetch", "Task", "Compress"] } +``` + +uses this plugin's tool instead, which resets the Claude session and carries a +summary forward. Setting both leaves this one holding the `compress` name and logs +`proxyOpencodeTools entry dropped`. If neither is wanted, `stripContextReminders: true` +removes the reminders the model cannot act on. + +### Proxy tool names + +Names below become `mcp__opencode_proxy__`; input config is case-insensitive. + +| Tool | Selection and behavior | +|---|---| +| `bash` | `"Bash"`, default; replaces CLI Bash with opencode execution. | +| `edit` | `"Edit"`, default; replaces CLI Edit. | +| `write` | `"Write"`, default; replaces CLI Write. | +| `webfetch` | `"WebFetch"`, default; replaces CLI WebFetch. | +| `task` | `"Task"`, default; disables CLI Agent and dispatches opencode subagents under its permissions. No proxy deadline by default; a positive `proxyToolTimeoutMs` entry adds one. | +| `task_batch` | Included with Task; one MCP call fans out two or more independent task inputs concurrently. Separate task calls were measured serial on CLI 2.1.258. | +| `question` | `"Question"`, opt-in; replaces AskUserQuestion only if the live opencode registry has question. Round-trip verified on plugin 0.18.0 / CLI 2.1.258 / opencode 1.18.29, headless and as a real TUI form, with no `permission` block; grant `permission.question` only if a subagent's form is refused. Opt-in because it disables Claude's own AskUserQuestion. | +| `compress` | `"Compress"`, opt-in; in-process summary/reset interceptor, no opencode permission prompt and no built-in replacement. Discards prior CLI detail on a later eligible turn, retaining the summary, not the full transcript. Keep off unless explicitly requested. Reset round-trip verified live on CLI 2.1.263 / opencode 1.18.31. Not the same tool as a forwarded opencode `compress` (see `proxyOpencodeTools`): this one resets the Claude session, that one compresses opencode's transcript. Enabling both leaves this one holding the name. | + +A proxied call is held open until an event ends it, and the plugin listens to the +`claude` process, the stream and the control protocol for those events rather than +inferring failure from elapsed time: opencode's result resolves the call; an abort +interrupts the CLI and rejects the turn's pending calls, even when it lands while +opencode is running the tool; the next user message rejects what the previous turn left pending +and tells the CLI; the process exiting, the chat being deleted, or opencode exiting +rejects the rest. That is why `task` and `task_batch` carry no default deadline and a +subagent runs to completion. Three timers remain and are distinct from that: the +optional per-tool deadlines above (a backstop the user chooses), the start and +inactivity watchdogs (for a process that is alive but silent, which emits nothing to +listen to; a CLI parked in a proxied call is exempt), and the connection keepalives +(SSE comments or JSON whitespace every 15 s, so the CLI's HTTP client does not give up +on a long call; they never extend a deadline). Do not present a raised deadline as the +fix for a long subagent; the default already waits for it. A deadline-free call is not +silent while it waits: it logs `proxy call still waiting, no deadline` at WARN after +five minutes and every five minutes after, with tool, call id and elapsed time. That +line is a status report, never a failure; it does not end the call. A call that HAS a +deadline instead logs `proxy call still waiting, deadline approaching` once, at 60% of +that deadline, carrying `remainingMs` and naming `proxyToolTimeoutMs`; deadlines under +a minute are not announced, because there the notice and the rejection would arrive +together. Neither line means something is wrong and neither ends a call. Use them, or +`/claude-code-doctor`, to tell a working subagent from a wedged one before suggesting +any timeout change. + +### Let Claude load the user's opencode skills + +```json +{ "bridgeOpencodeSkills": true } +``` + +The bridge is off by default. With it on, `Skill("")` works for any skill opencode +advertises. Bridged names are `opencode-skills:`, including this bundled skill as +`opencode-skills:claude-code-plugin`. The package also registers its skill directory +with opencode's `skills.paths`; older opencode versions may not support that surface. +The native Claude bridge needs `--plugin-dir` support and is wired into headless +streaming, interactive and direct `doGenerate` spawns, never compaction. Set `true` +only when the user asks for it, since a large skill set costs prompt tokens twice; the +bundled skill is staged either way. Reusing a process does not load a new skill catalog. + +User roots: `.opencode/skills` walking from cwd to filesystem root, home `.opencode/skills`, +`OPENCODE_CONFIG_DIR/skills`, then `XDG_CONFIG_HOME/opencode/skills` (home `.config` +fallback). First name wins; enabled user bridging can shadow bundled names. Only immediate +`/SKILL.md` directories are collected. Arbitrary `skills.paths`, `skills.urls`, +singular `skill/`, `~/.agents/skills` and `~/.claude/skills` are not scanned by this +bridge; Claude can already discover its own skills independently. Broad bridging can +duplicate advertised skill context and exposes every discovered skill, not just one. + +### Change when idle workers are freed + +```json +{ "idleProcessTimeoutMs": 900000 } +``` + +The default is thirty minutes: that long after a turn ends with no new message, the +conversation's `claude` process exits, and the next message resumes the same +conversation. This example shortens it to fifteen; `0` keeps workers until the +8-process LRU cap evicts the oldest idle one. Neither ever kills a worker mid-turn. + +### Different `/compact` model + +```json +{ "compactionModel": "claude-sonnet-5" } +``` + +This is more expensive per token than the Haiku default, not a cost-saving recipe. + +### Debug logging + +```json +{ "logging": { "file": true } } +``` + +Default destination: `~/.local/share/opencode-claude-code/plugin.log` (respect the +configured/env directory). INFO is enough for startup diagnostics. Add +`"level": "debug"` only if needed for lower-level events; `mode: "debug"` alone does +not do that. Capture a bounded, redacted excerpt, then disable temporary logging and +restart. Logs rotate above 5 MB to `plugin.log.1`, which can also contain private data. + +### Upgrade the plugin + +A published version does not reach a running opencode. First distinguish an npm pin, +npm latest resolution, and a local `file://` install. Preserve a pin unless the user +requested changing it. Some opencode versions freeze latest in +`~/.cache/opencode/packages/@khalilgharbaoui/opencode-claude-code-plugin@latest/`. +Inspect the actual cache location/package identity and get approval before removing +only that stale package directory, never the whole cache or auth/session directories. +Respect platform/XDG paths. Then fully relaunch. A `file://` install uses the checkout's +`dist/`: rebuild with `npm run build` and restart after approval, not cache deletion. +No manual skill copy/update is needed. Do not publish or release as part of configuring. + +## Models and variants + +### Registered model ids + +Registered ids: `claude-haiku-4-5`, `claude-sonnet-4-5`, `claude-sonnet-4-6`, +`claude-sonnet-5`, `claude-opus-4-5`, `claude-opus-4-6`, `claude-opus-4-7`, +`claude-opus-4-8`, `claude-opus-4-8-fast`, `claude-opus-5`, `claude-opus-5-fast`, +`claude-fable-5`, `claude-fable-5-1`, `claude-mythos-5`, `claude-mythos-5-1`. + +### Variants and costs + +- Display names end in a `(N×)` list-price multiplier relative to Haiku: 1× haiku, + 3× sonnet, 5× opus, 10× fable, mythos and fast-mode opus. It is display only. +- Every model except Haiku has reasoning variants `low`, `medium`, `high`, `xhigh`, + `max`, picked in opencode's model selector. A variant becomes + `CLAUDE_CODE_EFFORT_LEVEL` on the spawned CLI unless an agent effort wins. For direct + AI-SDK calls, `ClaudeCodeCallOptions.reasoningEffort` supports the same levels plus + `minimal` (mapped to `low`); it is not a provider startup option. +- The `-fast` ids are this plugin's own markers. They spawn the base model with + `--settings '{"fastMode":true}'` (Claude Code 2.1.220+). Fast mode fails soft: an + ineligible account runs at standard speed and the plugin logs a warning naming the + reason. Switch to a non-fast id rather than silently enabling paid usage credits. + Review eligibility/billing with the user; the enabled state needs live verification + on their account. CLI floors are gates, not proof of model access. +- `claude-mythos-5` and `claude-mythos-5-1` are limited availability (Project Glasswing). + Without access `claude --model` errors; use the corresponding `claude-fable-*`. +- Ordinary calls can pass through unregistered ids; availability and opencode model + registration still need checking. `forceModel`/`defaultSubagentModel` reject those ids. +- Registry costs are USD per million tokens, not subscription quota or a billing + guarantee. Fast entries have fast pricing; other entries use standard rates. There + is no above-200K tier in this registry; do not invent `cost.tiers` or + `cost.experimentalOver200K`. 4.5 models have 200K context/64K output, later registered + models have 1M/128K. Recheck vendor pricing/access separately when changing models. + +## Verify and diagnose + +Offline first: validate edited JSON/JSONC without starting opencode; inspect installed +package metadata. `claude --version` / `claude --help` on the trusted configured binary +and `opencode --version` do not request model inference. Do not invoke a model merely +to test configuration. A paid smoke test requires explicit approval and a bounded task. + +If diagnostic logging was approved, find the newest matching +`NOTICE: claude-code plugin ready` entry for the restarted process (INFO threshold +includes NOTICE). Do not paste the entire log or raw spawn arguments. + +Fields: `plugin` (version actually loaded), `opencode`, `cwd.resolved` and `cwd.source` +(`configured`, `process`, `captured`, `unresolved`), `providers`, `accounts`, +`proxyTools`, `mcpServers`, `interactiveTransport`, `planModeQuestion`, +`anthropicApiKeyInEnv`, `claudeCli.path` and `.version` +(`not detected` means the binary did not answer `--version`, which also disables +version-gated flags). Cwd is a startup fallback snapshot, not the per-session spawn +directory. MCP names are disk discovery, not proof of live connectivity. Interactive +status is a preference report, not proof that Bun PTY transport was used. Check a +relevant, redacted spawn/bridge entry for actual routing after an approved normal turn. + +Useful log lines to search for (redact payloads): `spawning new claude process`, +`bridged opencode skills into claude`, `interrupt sent for aborted turn`, `btw:`, +`rendering opencode-side tool result as text`, `proxy-mcp tool call received`, +`evicting idle claude process`, `fast mode` warnings. + +Version requirements: Claude Code CLI 2.1.142+ recommended (thinking summaries), +2.1.220+ for fast mode, 2.1.258+ for `/btw`. Check with `claude --version`. + +Only if a proxy security check is specifically requested: identify the exact local +proxy port first, not every opencode listener. An unauthenticated `initialize` with +the correct `127.0.0.1:` Host, no Origin and JSON Content-Type should get `401`. +`200` on a confirmed proxy endpoint is unsafe; restart/upgrade. Other status codes +alone do not prove it patched. Never call `tools/call` or obtain the bearer to probe. + +`/claude-code-doctor` prints the same fields as the startup block plus live runtime +state, in the chat, with no model inference and at zero tokens: plugin/opencode/CLI +versions, cwd and its resolution tier, providers, accounts, `proxyTools`, disk MCP +servers, transport, whether an `ANTHROPIC_API_KEY` is present (never its value), the +live `claude` processes (opencode session, model, pid, in flight, age, effort), pending +proxy calls with their deadlines, and one unauthenticated `initialize` against each +proxy URL (`401, good`; anything else is flagged unsafe). Prefer it over asking for +`plugin.log` for a first look. It carries no bearer token, no key value and no system +prompt. A user-defined `claude-code-doctor` command is never overwritten. The name has +no space in it: opencode would read the second word as an argument. + +Claude Code stream events the plugin now surfaces without debug logging: a rate-limit +rejection, a context compaction the CLI did on its own, a `result` subtype other than +`success` (which now finishes the turn as an error, not a clean stop), and a failed +CLI-executed tool (forwarded with the error flag, so the row renders as failed). A +failed MCP server at session start and an `apiKeySource` that means API-key billing +each warn once per process. None of these are actions the plugin may take on the user's +behalf; enabling paid usage or changing auth still needs approval. + +`/btw ` needs an existing headless Claude conversation and CLI 2.1.258+. +It asks through the side channel and keeps the answer in the conversation (inline +when possible); it is excluded from Claude's normal turn history. It is still +inference: zero reported usage for the aside does not mean free. User-defined `btw` +commands are preserved. Do not use it as an automatic diagnostic probe. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| A config change did nothing | Options are read at startup; another opencode window is still running the old process | Fully quit every opencode window and relaunch | +| New plugin version or model not in the picker after upgrading | Frozen `@latest` in opencode's package cache | Remove the cache dir (recipe "Upgrade the plugin") and relaunch | +| `/btw` shows "Queued" or "requires an idle Claude Code session" | Plugin older than 0.15.2, or a window started before the current build | Upgrade and restart. `/btw` also needs Claude Code 2.1.258+ | +| Model calls `Skill("x")` and gets `Unknown skill` | Wrong namespace (`opencode-skills:x`), a CLI without `--plugin-dir`, an unscanned root, a compaction turn, or `bridgeOpencodeSkills: false` | Check the namespace, `claude --help` and the skill root; remove the `false` only with approval | +| `Subagent failed (task_id …): Tool execution aborted` while the child finished fine | Bug fixed in 0.15.1 | Upgrade | +| A `subtask: true` command's subagent output is "lost" | Bug fixed in 0.15.4 | Upgrade | +| Two subagents run one after another | The CLI serialises MCP calls | Plugin 0.17.0+; the model must use `mcp__opencode_proxy__task_batch` | +| Esc does not stop Claude; aborted turns keep running | Plugin older than 0.16.0 | Upgrade | +| Under `opencode serve` or the web UI every project spawns Claude in the server's launch dir | Plugin older than 0.16.0 | Upgrade, or pin `cwd` | +| 400 `Third-party apps now draw from your extra usage…` | Subscription/account usage gate, including disabled extra usage or an exhausted window | Explain waiting, account choice and billing options; do not enable paid usage, switch auth or change transport without approval | +| Warning that a fast turn ran at standard speed | Fast mode ineligible (usage credits off, cooldown, not first-party) | Prefer non-fast id; paid usage changes require approval | +| `claude --model claude-mythos-*` errors | Limited-availability model | Use `claude-fable-5` or `claude-fable-5-1` | +| Startup warning about `ANTHROPIC_API_KEY` | CLI may prefer env credentials | Confirm billing intent; strip only with approval, without displaying the key | +| A question form never renders and the turn hangs | Blocking notification/tool hooks, or a pending request not reaching the visible session | Check `GET /question` on the same server/workspace: absent means investigate pre-tool hooks or replacement tools; present means inspect session ownership, permission priority and event delivery. On the maintainer's Mac, awaiting `alerter` dismissal in `tool.execute.before` blocked the tool itself. Native providers load global plugins too. The separate detach/reattach issue #36604 remains open; #36603 closed unmerged. | +| No thinking summary | CLI version, explicit disable/summary env, or no thinking text emitted | Check version and nonsecret flag presence; do not override deliberate user suppression | +| `⚙ invalid` rows for `todowrite` inside a subagent | Subagent lacks `permission.todowrite: "allow"` | Grant it on the agent definition with approval | +| Other `⚙ invalid` or `⚙ unknown` tool rows | A Claude tool the plugin does not map for this version | Note plugin version, CLI version and the tool name; upgrade or report | +| `AGENTS.md` appears twice in Claude's system prompt | Plugin older than 0.16.0 | Upgrade | +| "What does the plugin actually think is going on?" | Startup diagnostics go to a log that is off by default | Run `/claude-code-doctor` in the session; paste that instead of the log | +| A turn ended with no answer and nothing said why | The CLI's `result` carried a failure subtype, or a rate limit was rejected | Both are now written into the transcript as `▌` lines; read the subtype or the limit reason there | +| A CLI tool row looks successful but its output is an error | Plugin older than this release forwarded `is_error` results as successes | Upgrade; failed CLI tools now render as failed | +| Claude "forgot" the earlier part of a long conversation | Claude Code compacted its own context | Look for the `▌ **context compacted:**` note in the transcript | +| Wanting the per-turn cost in the chat | Not shown by default | Set `turnStats: true` and restart opencode | +| Turn ends with an error naming an exit code or signal and a stderr tail | The `claude` child died mid-turn without emitting its terminal `result` | Read the quoted stderr; that is the CLI's own reason. Older builds reported this as a normal stop, so a truncated answer looked finished | +| An answer is cut off with no error, in a window with many open chats | Plugin older than this fix: LRU eviction could kill a process mid-turn | Upgrade. Eviction now takes the oldest idle process and skips the round when all 8 are busy; the 30-minute idle timer spares a busy worker too | +| A `claude` worker lingers after its chat was deleted, or after opencode quit | Plugin older than this release | Upgrade. Deleting a chat now releases its workers; every retained worker is killed when opencode exits | + +## Do not + +- Do not enable `planModeQuestion` or `"Question"` without the user asking. `"Question"` + works (round-trip verified headless and as a real TUI form) but disables Claude's own + AskUserQuestion; `planModeQuestion` cannot fire at all on the headless transport, + because CLI 2.1.258 does not offer `ExitPlanMode` under `--print`. The historical + blanket TUI diagnosis was confounded by a local macOS notification hook; do not + repeat it as established fact. +- Do not make `--dangerously-skip-permissions` unconditional again. The CLI lets it + override plan mode, so the plugin drops it for `permissionMode: "plan"` on purpose; + without that, asking for plan mode silently grants full write access. +- Do not "fix" the `-fast` model ids by passing Anthropic-looking names; the real ones are + retired and the `--settings` opt-in is the only headless path. +- Do not add long-context `cost.tiers` to a model; Claude 4.6+ bills the full 1M window + at standard rates. +- Do not set `name`, `providerID`, `account` or `configDir` by hand when `accounts` is + in use; expansion writes them. +- Do not point `cliPath` at the generated account wrapper in + `~/.cache/opencode-claude-code-plugin/`; the plugin generates and selects it. diff --git a/src/account-failover.ts b/src/account-failover.ts new file mode 100644 index 0000000..808ea54 --- /dev/null +++ b/src/account-failover.ts @@ -0,0 +1,537 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { + DEFAULT_ACCOUNT, + ensureAccountRuntime, + normalizeAccountName, +} from "./accounts.js" +import { + formatResetsAt, + isRateLimitRejected, + resetsAtToMs, + type RateLimitInfo, +} from "./cli-events.js" +import { log } from "./logger.js" +import { + QUESTION_TOOL_NAME, + collectAnswerStrings, + unwrapToolOutput, + type QuestionToolCall, +} from "./plan-mode-question.js" + +/** + * Account failover. + * + * When the account a conversation is running on is out of usage, the turn + * fails and the only remedy is the operator's: wait, pay, or move to another + * account. The last one is the only one the plugin can help with, because the + * accounts are already configured and each is just another `CLAUDE_CONFIG_DIR` + * behind a wrapper script (`src/accounts.ts`). + * + * So the limit ends the turn with a form instead of an error: one option per + * other configured account, plus `stop`. The form is opencode's own `question` + * tool, reached exactly the way the plan-mode bridge reaches it, which means + * the answer arrives on the next `doStream` call as a `tool-result` and the + * switch happens inside the same opencode turn, with no new user message. + * Leaving it unanswered waits, and waiting costs nothing. + * + * Three things about the design are deliberate and load-bearing: + * + * 1. **The override is scoped to the limited ACCOUNT, not the session.** A + * rate limit is a property of the account, so one pick governs every + * session running on it, and a subagent follows its parent for free + * without needing its own form (child sessions are never asked). + * 2. **A switch is always a fresh Claude session with the conversation + * replayed.** Transcripts live under the account's own config dir, so + * `--resume` can never cross accounts. The caller drops the active process + * and the stored Claude session id, which makes `includeHistoryContext` + * true and rebuilds the thread from opencode's prompt. + * 3. **The `@account` suffix must come off the model id.** `parseModelId` + * keeps it on purpose because the source account's own wrapper strips it, + * but a failover spawn goes through a DIFFERENT wrapper (or the bare + * binary for `default`), which would pass `--model claude-opus-5@appical` + * straight to a CLI that rejects it. + */ + +type Prompt = Parameters[0]["prompt"] + +/** Leading text of the `▌` note the plugin writes when a switch happens. */ +export const FAILOVER_MARKER = "▌ **account failover:**" + +/** + * Prefix of every synthetic `question` tool-call id this module mints. The + * transcript rebuild keys on it to drop the dialog, so a replayed history + * never hands Claude a form it never saw. + */ +export const ACCOUNT_FAILOVER_TOOL_CALL_PREFIX = "account_failover_" + +// Escaped rather than a literal NUL byte: one raw \0 anywhere in the file +// makes git treat this TypeScript source as binary, so it has no diff, no +// line-level merge and no review. Same string value, text file. +const KEY_SEPARATOR = "\u0000" + +/** The option that ends the turn instead of switching. */ +export const STOP_ANSWER = "stop" + +/** + * The only error texts that count as "this account is out of usage". + * + * Deliberately not "any 4xx" and not "any error": a transient network failure + * or a bad flag must never open a form that moves where the billing lands. + * Both strings are the ones observed in production and recorded in AGENTS.md; + * the apostrophe class covers the straight and curly forms. + */ +export const ACCOUNT_LIMIT_PATTERNS: RegExp[] = [ + /third-party apps now draw from your extra usage/i, + /you[’'`]?ve hit your individual spend limit/i, +] + +export function isAccountLimitError(input: { + rateLimit?: RateLimitInfo | null + resultText?: string | null +}): boolean { + if (input.rateLimit && isRateLimitRejected(input.rateLimit)) return true + const text = input.resultText + if (typeof text !== "string" || text.length === 0) return false + return ACCOUNT_LIMIT_PATTERNS.some((pattern) => pattern.test(text)) +} + +// --------------------------------------------------------------------------- +// The override store: which account replaces which, and until when +// --------------------------------------------------------------------------- + +interface AccountOverride { + target: string + /** Epoch ms the limit resets at, or undefined for "until opencode restarts". */ + until?: number +} + +const accountOverrides = new Map() + +/** Test-only. */ +export function _resetAccountOverrides(): void { + accountOverrides.clear() +} + +export function setAccountOverride( + source: string, + target: string, + until?: number, + now = Date.now(), +): void { + const from = normalizeAccountName(source || DEFAULT_ACCOUNT) + const to = normalizeAccountName(target) + if (!to || to === from) return + // A reset time that is not in the future would expire the override on the + // very next read, so the switch the operator just asked for would be + // undone before it ran and the turn would re-hit the same limit. Clock + // skew and a stale `resetsAt` both produce that, so anything not ahead of + // now degrades to "until opencode restarts" rather than to nothing. + if (until !== undefined && until <= now) { + log.notice("ignoring a failover reset time that is not in the future", { + source: from, + target: to, + until, + }) + until = undefined + } + accountOverrides.set(from, { target: to, until }) + log.warn( + `Claude account "${from}" is out of usage; this and every other session on it now runs on "${to}"${ + until ? ` until ${new Date(until).toISOString()}` : " until opencode restarts" + }.`, + { source: from, target: to, until: until ?? null }, + ) +} + +/** + * The account to run on instead of `source`, or undefined when there is no + * override. An expired one is deleted here (and logged once, because the + * deletion is what silently sends the next turn back to the original account + * and replays the conversation again). + */ +export function resolveAccountOverride( + source: string, + now = Date.now(), +): string | undefined { + const from = normalizeAccountName(source || DEFAULT_ACCOUNT) + const entry = accountOverrides.get(from) + if (!entry) return undefined + if (entry.until !== undefined && entry.until <= now) { + accountOverrides.delete(from) + log.notice( + `Claude account "${from}" should have usage again; switching back from "${entry.target}".`, + { source: from, target: entry.target, until: entry.until }, + ) + return undefined + } + return entry.target +} + +export function clearAccountOverride(source: string): void { + accountOverrides.delete(normalizeAccountName(source || DEFAULT_ACCOUNT)) +} + +/** Read-only view for `/claude-code-doctor` and tests. */ +export function snapshotAccountOverrides(): Array<{ + source: string + target: string + until?: number +}> { + return [...accountOverrides.entries()].map(([source, entry]) => ({ + source, + target: entry.target, + ...(entry.until === undefined ? {} : { until: entry.until }), + })) +} + +// --------------------------------------------------------------------------- +// Resolving the spawn +// --------------------------------------------------------------------------- + +/** `claude-opus-5@appical` -> `claude-opus-5`. See the module note (3). */ +export function stripAccountSuffix(modelId: string): string { + const at = modelId.indexOf("@") + return at === -1 ? modelId : modelId.slice(0, at) +} + +export interface FailoverSpawn { + cliPath: string + modelId: string + target?: string + failedOver: boolean +} + +/** + * The CLI path and model id this turn should actually spawn with. Without an + * override the inputs come back untouched, which is what keeps every + * single-account install on exactly today's code path. + */ +export async function resolveFailoverSpawn(input: { + account: string | undefined + baseCliPath: string + cliPath: string + modelId: string + now?: number +}): Promise { + const unchanged: FailoverSpawn = { + cliPath: input.cliPath, + modelId: input.modelId, + failedOver: false, + } + const source = normalizeAccountName(input.account || DEFAULT_ACCOUNT) + const target = resolveAccountOverride(source, input.now) + if (!target) return unchanged + + try { + const cliPath = + target === DEFAULT_ACCOUNT + ? input.baseCliPath + : (await ensureAccountRuntime(target, input.baseCliPath)).cliPath + return { + cliPath, + modelId: stripAccountSuffix(input.modelId), + target, + failedOver: true, + } + } catch (err) { + // A wrapper we cannot write is not a reason to spawn nothing: fall back + // to the limited account and let its own error speak, rather than + // spawning a path that does not exist. + log.error("failed to prepare the failover account runtime; staying put", { + source, + target, + error: String(err), + }) + return unchanged + } +} + +// --------------------------------------------------------------------------- +// The form +// --------------------------------------------------------------------------- + +/** + * On by default whenever more than one account is configured: the operator's + * pick is the consent, and with no other account there is nothing to offer. + * Never on a compaction turn (its answer would have nowhere to go), never on + * the interactive transport (a TUI stdin and no proxy server), never in a + * child session (a subagent follows its parent's account for free), and never + * without opencode's `question` entry, where the emitted call renders as + * `⚙ invalid` and wedges the turn. + */ +export function isAccountFailoverQuestionActive(input: { + configured: "ask" | "off" | undefined + candidates: readonly string[] + opencodeHasQuestion: boolean + compactionMode: boolean + interactive: boolean + childSession: boolean +}): boolean { + if (input.compactionMode) return false + if (input.interactive) return false + if (input.childSession) return false + if (input.configured === "off") return false + if (input.candidates.length === 0) return false + return input.opencodeHasQuestion +} + +/** Every configured account except the one that just hit its limit. */ +export function failoverCandidates( + accounts: readonly string[] | undefined, + source: string, +): string[] { + const from = normalizeAccountName(source || DEFAULT_ACCOUNT) + const out: string[] = [] + for (const raw of accounts ?? []) { + const name = normalizeAccountName(String(raw)) + if (!name || name === from || out.includes(name)) continue + out.push(name) + } + return out +} + +interface PendingFailoverQuestion { + sourceAccount: string + candidates: string[] + resetsAt?: number +} + +const pendingQuestions = new Map() + +function pendingKey(sessionKey: string, toolCallId: string): string { + return `${sessionKey}${KEY_SEPARATOR}${toolCallId}` +} + +/** + * Called from `deleteClaudeSessionId`, the one destructive session boundary. + * A pending id that outlives its session would route the next answer at a + * dialog nobody can act on. + */ +export function clearAccountFailoverQuestions(sessionKey: string): void { + const prefix = `${sessionKey}${KEY_SEPARATOR}` + for (const key of pendingQuestions.keys()) { + if (key.startsWith(prefix)) pendingQuestions.delete(key) + } +} + +function describeReset(resetsAt: number | undefined): string | undefined { + return formatResetsAt(resetsAt) +} + +export function createAccountFailoverQuestionCall( + sessionKey: string, + input: { + sourceAccount: string + candidates: readonly string[] + resetsAt?: number + window?: string + }, + toolCallId = `${ACCOUNT_FAILOVER_TOOL_CALL_PREFIX}${Math.random() + .toString(36) + .slice(2, 10)}`, +): QuestionToolCall { + const source = normalizeAccountName(input.sourceAccount || DEFAULT_ACCOUNT) + const candidates = input.candidates.map((c) => normalizeAccountName(c)) + const resets = describeReset(input.resetsAt) + const until = resets ?? "opencode restarts" + + pendingQuestions.set(pendingKey(sessionKey, toolCallId), { + sourceAccount: source, + candidates: [...candidates], + resetsAt: input.resetsAt, + }) + + const question = [ + `The Claude account "${source}" is out of usage`, + input.window ? ` in ${input.window}` : "", + resets ? `, which resets at ${resets}` : "", + ". Continue this task on another configured account? Leaving this unanswered waits, at no cost.", + ].join("") + + return { + toolCallId, + toolName: QUESTION_TOOL_NAME, + input: { + questions: [ + { + header: "Account limit", + question, + options: [ + ...candidates.map((candidate) => ({ + label: candidate, + description: `Run on "${candidate}" until ${until}. The conversation is replayed as a fresh Claude session (a session cannot resume across accounts), and any MCP server configured only in "${source}"'s Claude profile will be missing.`, + })), + { + label: STOP_ANSWER, + description: "End this turn now and leave the account as it is.", + }, + ], + multiple: false, + custom: true, + }, + ], + }, + text: "", + } +} + +export type AccountFailoverAnswer = + | { kind: "switch"; target: string; sourceAccount: string; resetsAt?: number } + | { kind: "stop"; reason: string } + +function isDenied(output: unknown): output is { reason?: unknown } { + return ( + !!output && + typeof output === "object" && + (output as { denied?: unknown }).denied === true + ) +} + +function classify( + pending: PendingFailoverQuestion, + part: any, +): AccountFailoverAnswer { + const output = unwrapToolOutput(part) + if (isDenied(output)) { + return { + kind: "stop", + reason: String((output as { reason?: unknown }).reason ?? "question rejected"), + } + } + + const answers = collectAnswerStrings(output) + .map((answer) => answer.trim()) + .filter(Boolean) + if (answers.length === 0) return { kind: "stop", reason: "no answer" } + + const picked = normalizeAccountName(answers[0]) + if (picked === STOP_ANSWER) { + return { kind: "stop", reason: "the operator chose to stop" } + } + const target = pending.candidates.find((candidate) => candidate === picked) + if (!target) { + return { kind: "stop", reason: `unrecognised answer "${answers[0]}"` } + } + return { + kind: "switch", + target, + sourceAccount: pending.sourceAccount, + resetsAt: pending.resetsAt, + } +} + +/** + * Take the operator's answer to a failover form out of this turn's prompt. + * Anything that is not one of the offered accounts, including a dismissal and + * unrecognised custom text, is a `stop`: the turn then ends the way the + * rate-limit error ends it today. + */ +export function consumeAccountFailoverAnswer( + sessionKey: string, + prompt: Array<{ role: string; content?: unknown }>, +): AccountFailoverAnswer | null { + for (let i = prompt.length - 1; i >= 0; i--) { + const msg = prompt[i] + if (!Array.isArray(msg.content)) continue + + for (const part of msg.content as any[]) { + if (part?.type !== "tool-result" || typeof part.toolCallId !== "string") { + continue + } + const key = pendingKey(sessionKey, part.toolCallId) + const pending = pendingQuestions.get(key) + if (!pending) continue + + pendingQuestions.delete(key) + return classify(pending, part) + } + } + return null +} + +// --------------------------------------------------------------------------- +// Transcript handling +// --------------------------------------------------------------------------- + +export function formatFailoverNote(input: { + sourceAccount: string + target: string + resetsAt?: number +}): string { + const resets = describeReset(input.resetsAt) + return `\n${FAILOVER_MARKER} "${input.sourceAccount}" is out of usage, so this conversation continues on "${ + input.target + }" ${ + resets ? `until ${resets}` : "until opencode restarts" + }. Claude cannot resume a session across accounts, so the thread is being replayed into a fresh one.\n` +} + +export function formatFailoverStopNote(reason: string): string { + return `\n${FAILOVER_MARKER} Staying on this account (${reason}). The turn ends here; the usage limit is unchanged.\n` +} + +function isFailoverPart(part: any): boolean { + if (!part || typeof part.toolCallId !== "string") return false + if (part.type !== "tool-call" && part.type !== "tool-result") return false + return part.toolCallId.startsWith(ACCOUNT_FAILOVER_TOOL_CALL_PREFIX) +} + +/** + * Remove the failover dialog from a transcript: the synthetic `question` + * tool-call and the `tool-result` carrying the answer. Claude never issued + * that call and never saw that result, so replaying either would hand a + * fresh session a conversation it cannot make sense of. Messages left with no + * content at all are dropped rather than replayed empty. + */ +export function stripAccountFailoverParts(prompt: Prompt): Prompt { + let changed = false + const out = [] as unknown as Prompt + + for (const message of prompt) { + const content = (message as { content?: unknown }).content + if (!Array.isArray(content) || !content.some(isFailoverPart)) { + out.push(message) + continue + } + changed = true + const kept = content.filter((part: any) => !isFailoverPart(part)) + if (kept.length === 0) continue + out.push({ ...message, content: kept } as typeof message) + } + + return changed ? out : prompt +} + +export function failoverContinuationText(target: string): string { + return [ + `The Claude account this conversation was running on hit its usage limit, so it has been moved to the "${target}" account and you are now in a fresh Claude session.`, + "The conversation so far is above. Continue the task from where it stopped: do not start over, do not re-plan, and do not repeat work that is already done.", + "Do not mention the account switch unless you are asked about it.", + ].join(" ") +} + +/** + * The prompt to replay into the target account: the conversation with the + * failover dialog removed, plus one user message telling the fresh session + * what happened and to carry on. + */ +export function buildFailoverContinuationPrompt( + prompt: Prompt, + target: string, +): Prompt { + const stripped = stripAccountFailoverParts(prompt) + return [ + ...stripped, + { + role: "user", + content: [{ type: "text", text: failoverContinuationText(target) }], + }, + ] as Prompt +} + +/** Epoch ms a limit resets at, from whichever field the CLI filled in. */ +export function failoverUntil( + resetsAt: number | undefined, +): number | undefined { + return resetsAtToMs(resetsAt) +} diff --git a/src/accounts.ts b/src/accounts.ts new file mode 100644 index 0000000..b86f637 --- /dev/null +++ b/src/accounts.ts @@ -0,0 +1,208 @@ +import { chmod, lstat, mkdir, readlink, symlink, writeFile } from "node:fs/promises" +import path from "node:path" +import { log } from "./logger.js" + +export const BASE_PROVIDER_ID = "claude-code" +export const DEFAULT_ACCOUNT = "default" + +const SHARED_CAPABILITY_ITEMS = [ + "CLAUDE.md", + "settings.json", + "skills", + "agents", + "commands", + "plugins", +] + +export function normalizeAccountName(account: string): string { + return account + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +export function resolveAccounts(value: unknown): string[] | null { + if (!Array.isArray(value)) return null + + const accounts = value + .map((account) => normalizeAccountName(String(account))) + .filter(Boolean) + + return Array.from(new Set([DEFAULT_ACCOUNT, ...accounts])) +} + +export function accountProviderId(account: string): string { + return `${BASE_PROVIDER_ID}-${normalizeAccountName(account)}` +} + +export function accountDisplayName(account: string): string { + return `Claude Code (${titleizeAccount(account)})` +} + +export function accountModelSuffix(account: string): string | undefined { + const normalized = normalizeAccountName(account) + return normalized === DEFAULT_ACCOUNT ? undefined : normalized +} + +export function accountConfigDir(account: string): string | undefined { + const normalized = normalizeAccountName(account) + + if (!normalized || normalized === DEFAULT_ACCOUNT) return undefined + + return `~/.claude-${normalized}` +} + +export function expandHome(value: string): string { + const home = process.env.HOME ?? process.env.USERPROFILE + + if (value === "~") return home ?? value + + if (value.startsWith("~/") || value.startsWith("~\\")) { + return home ? path.join(home, value.slice(2)) : value + } + + return value +} + +export async function ensureAccountRuntime( + account: string, + baseCliPath: string, +): Promise<{ cliPath: string; configDir?: string }> { + const configDir = accountConfigDir(account) + + if (!configDir) return { cliPath: baseCliPath } + + const expandedConfigDir = expandHome(configDir) + await mkdir(expandedConfigDir, { recursive: true }) + + try { + await ensureSharedCapabilities(expandedConfigDir) + } catch (err) { + log.warn("failed to symlink shared capabilities; continuing anyway", { + account, + configDir: expandedConfigDir, + error: String(err), + }) + } + + const cliPath = await writeAccountWrapper( + normalizeAccountName(account), + baseCliPath, + expandedConfigDir, + ) + + return { cliPath, configDir: expandedConfigDir } +} + +async function ensureSharedCapabilities(targetRoot: string): Promise { + const sourceRoot = expandHome("~/.claude") + + for (const item of SHARED_CAPABILITY_ITEMS) { + await ensureSharedCapabilityItem(sourceRoot, targetRoot, item) + } +} + +async function ensureSharedCapabilityItem( + sourceRoot: string, + targetRoot: string, + item: string, +): Promise { + const source = path.join(sourceRoot, item) + const target = path.join(targetRoot, item) + + let sourceStat + try { + sourceStat = await lstat(source) + } catch { + return + } + + try { + const targetStat = await lstat(target) + + if (targetStat.isSymbolicLink()) { + const current = await readlink(target) + const resolvedCurrent = path.resolve(path.dirname(target), current) + const resolvedSource = path.resolve(source) + + if (resolvedCurrent === resolvedSource) return + } + + log.warn("shared Claude capability already exists; leaving untouched", { + item, + target, + source, + }) + + return + } catch { + // Missing target is expected. + } + + const type = sourceStat.isDirectory() + ? process.platform === "win32" + ? "junction" + : "dir" + : "file" + + await symlink(source, target, type) +} + +async function writeAccountWrapper( + account: string, + baseCliPath: string, + configDir: string, +): Promise { + const cacheRoot = path.join( + process.env.XDG_CACHE_HOME ?? expandHome("~/.cache"), + "opencode-claude-code-plugin", + ) + const wrapperPath = path.join(cacheRoot, `claude-${account}`) + const suffix = `@${account}` + + await mkdir(cacheRoot, { recursive: true }) + + const script = `#!/usr/bin/env bash +set -euo pipefail + +args=() +while [[ $# -gt 0 ]]; do + if [[ "$1" == "--model" && $# -ge 2 ]]; then + model="$2" + if [[ "$model" == *${shellDoubleQuote(suffix)} ]]; then + model="\${model%${shellDoubleQuote(suffix)}}" + fi + args+=("$1" "$model") + shift 2 + else + args+=("$1") + shift + fi +done + +export CLAUDE_CONFIG_DIR=${shellSingleQuote(configDir)} +exec ${shellSingleQuote(baseCliPath)} "\${args[@]}" +` + + await writeFile(wrapperPath, script, "utf8") + await chmod(wrapperPath, 0o755) + + return wrapperPath +} + +function shellSingleQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'` +} + +function shellDoubleQuote(value: string): string { + return value.replace(/[$`"\\]/g, "\\$&") +} + +function titleizeAccount(account: string): string { + return normalizeAccountName(account) + .split("-") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" ") +} diff --git a/src/agent-models.ts b/src/agent-models.ts new file mode 100644 index 0000000..c01e055 --- /dev/null +++ b/src/agent-models.ts @@ -0,0 +1,299 @@ +/** + * Per-agent model resolution. + * + * opencode's agent config cannot express "inherit the account, choose the + * model". A subagent that omits `model` inherits the invoking agent's WHOLE + * model string, and one that pins `model` inherits neither half, so pinning + * Opus also pins the account it was written with. That is the wrong trade on a + * machine with more than one Claude account: the worker should follow whoever + * invoked it and still run on the model the job needs. + * + * The account is not part of the model id this class sees. It lives in the + * provider (`claude-code-`), which selects CLAUDE_CONFIG_DIR at spawn + * time, and in an `@` marker riding on the id for non-default + * accounts (see `parseModelId` in models.ts). So swapping the model NAME while + * preserving that marker changes the model and nothing else, which is exactly + * the gap in the config schema. + * + * Declaring it: an agent markdown file says `forceModel: `, or the + * `defaultSubagentModel` provider option covers every subagent at once. + * Nothing needs a per-agent entry in opencode.json. + * + * The same file can state `reasoningEffort:`, which beats the effort opencode + * inherited from the caller's picker (see `resolveAgentEffort`). Model and + * effort together are what a turn costs, so both belong with the agent. + * + * Two deliberate silences, because this rewrites what a user's model picker + * said it would run: + * + * - With `defaultSubagentModel` unset there is NO implicit override. An + * existing setup upgrading the plugin behaves exactly as before, instead + * of quietly moving somebody's cheap subagent onto an expensive model. + * - Only agents this plugin discovered are eligible. opencode's built-ins + * (`explore`, `general`, `compaction`, ...) are never in the registry, so + * they are never rewritten. + */ +import { readFile, readdir } from "node:fs/promises" +import path from "node:path" +import { log } from "./logger.js" +import { defaultModels } from "./models.js" + +/** Directory names opencode reads agent markdown from, current form first. */ +export const AGENT_DIR_NAMES = ["agents", "agent"] + +/** Levels the Claude CLI accepts; anything else is refused, not forwarded. */ +const REASONING_EFFORTS = [ + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] + +export type AgentRecord = { + mode?: string + /** A fully-qualified `provider/model` the agent pinned for itself. */ + model?: string + /** Model NAME this agent wants, on whatever account the caller is using. */ + forceModel?: string + /** Thinking budget this agent wants, whatever the caller's picker says. */ + reasoningEffort?: string +} + +let registry: Record = {} +let defaultSubagentModel: string | undefined + +export function setAgentRegistry(records: Record): void { + registry = records +} + +export function getAgentRegistry(): Record { + return registry +} + +/** `undefined` (the default) means no implicit override for any agent. */ +export function setDefaultSubagentModel(model: string | undefined): void { + defaultSubagentModel = model?.trim() || undefined +} + +export function getDefaultSubagentModel(): string | undefined { + return defaultSubagentModel +} + +export function _resetAgentRegistryForTests(): void { + registry = {} + defaultSubagentModel = undefined +} + +/** `claude-opus-5-fast@work` -> `@work`; a default-account id has none. */ +function accountMarker(modelId: string): string { + const at = modelId.indexOf("@") + return at === -1 ? "" : modelId.slice(at) +} + +function withoutAccountMarker(modelId: string): string { + const at = modelId.indexOf("@") + return at === -1 ? modelId : modelId.slice(0, at) +} + +/** + * The model a request should actually spawn with. + * + * Order, first match wins: + * 1. The agent declared `forceModel`. + * 2. The agent is a discovered subagent and `defaultSubagentModel` is set. + * 3. Anything else: the id opencode asked for, untouched. + * + * An agent that pinned a full `provider/model` is out of scope entirely: + * opencode already routed the call to that provider, and second-guessing it + * here would silently undo a choice the user made explicitly. + * + * Fails closed. An id that is not in the model registry is refused and the + * original kept, because the alternative is spawning the CLI with a `--model` + * it will reject, on a turn someone is waiting for. + */ +export function resolveAgentModel( + agent: string | undefined, + modelId: string, + overrides?: { + records?: Record + defaultSubagentModel?: string + }, +): string { + if (!agent) return modelId + + const record = (overrides?.records ?? registry)[agent] + if (!record) return modelId + if (record.model?.includes("/")) return modelId + + const fallback = overrides + ? overrides.defaultSubagentModel + : defaultSubagentModel + const declared = record.forceModel?.trim() + const wanted = + declared || (record.mode === "subagent" ? fallback : undefined) + if (!wanted) return modelId + + // A `forceModel` carrying its own `@account` would be forcing an account, + // which is the thing this exists to avoid. Keep the caller's. + const base = withoutAccountMarker(wanted) + if (!Object.hasOwn(defaultModels, base)) { + log.warn("agent model override refused: unknown model", { + agent, + wanted: base, + keeping: modelId, + }) + return modelId + } + + const resolved = `${base}${accountMarker(modelId)}` + if (resolved !== modelId) { + log.debug("agent model override", { agent, from: modelId, to: resolved }) + } + return resolved +} + +/** + * The thinking budget a request should actually spawn with. + * + * opencode resolves one effort for the whole session (the model picker's + * selector, or a variant), and a subagent inherits it. That inheritance is + * wrong in the expensive direction: a caller who picked `max` for their own + * turn silently hands `max` to every worker it dispatches, so a mechanical + * lane runs at the most costly setting available and burns a weekly cap that + * the caller never spent on the work in front of them. + * + * An agent that states its own budget wins. Same reasoning as `forceModel`: + * the declaration lives with the agent, so a file on disk is the whole + * configuration and the caller's picker stays a choice about the caller. + * + * Unknown values are ignored rather than passed on, since the CLI refuses a + * level it does not recognise and the turn would die at spawn. + */ +export function resolveAgentEffort( + agent: string | undefined, + inherited: string | undefined, + overrides?: { records?: Record }, +): string | undefined { + if (!agent) return inherited + + const record = (overrides?.records ?? registry)[agent] + const declared = record?.reasoningEffort?.trim() + if (!declared) return inherited + + if (!REASONING_EFFORTS.includes(declared)) { + log.warn("agent effort override refused: unknown level", { + agent, + wanted: declared, + keeping: inherited, + }) + return inherited + } + + if (declared !== inherited) { + log.debug("agent effort override", { + agent, + from: inherited, + to: declared, + }) + } + return declared +} + +/** + * Read the four fields that matter out of an agent markdown file's YAML + * frontmatter. Hand-parsed rather than pulling a YAML dependency in for four + * scalars, and deliberately top-level only: `permission:` has nested keys + * (`bash:`, `edit:`) that must not be mistaken for agent fields. + */ +export function parseAgentFrontmatter(text: string): AgentRecord { + const record: AgentRecord = {} + if (!text.startsWith("---")) return record + + const lines = text.split(/\r?\n/) + for (let i = 1; i < lines.length; i++) { + const line = lines[i] + if (line.trim() === "---") break + + const match = /^([A-Za-z_][A-Za-z0-9_-]*):[ \t]*(.*)$/.exec(line) + if (!match) continue + + const key = match[1] + if ( + key !== "mode" && + key !== "model" && + key !== "forceModel" && + key !== "reasoningEffort" + ) + continue + + const value = match[2].trim().replace(/^["']|["']$/g, "") + if (value) record[key] = value + } + + return record +} + +/** + * Discover agents from markdown on disk. opencode merges these into its own + * registry, but whether they reach a plugin's config hook is not documented, + * so they are read directly rather than assumed. + */ +export async function readAgentMarkdownRecords( + directories: string[], +): Promise> { + const records: Record = {} + + for (const directory of directories) { + let entries: string[] + try { + entries = await readdir(directory) + } catch { + continue + } + + for (const entry of entries) { + if (!entry.endsWith(".md")) continue + + const name = entry.slice(0, -3) + if (records[name]) continue + + try { + const text = await readFile(path.join(directory, entry), "utf8") + records[name] = parseAgentFrontmatter(text) + } catch (err) { + log.debug("failed to read agent markdown", { + file: path.join(directory, entry), + error: String(err), + }) + } + } + } + + return records +} + +/** + * Every directory opencode would read agent markdown from, project before + * global so a project agent of the same name wins, as opencode resolves them. + */ +export function agentDirectories( + home: string | undefined, + projectDirectory: string | undefined, +): string[] { + const directories: string[] = [] + + if (projectDirectory) { + for (const name of AGENT_DIR_NAMES) { + directories.push(path.join(projectDirectory, ".opencode", name)) + } + } + if (home) { + for (const name of AGENT_DIR_NAMES) { + directories.push(path.join(home, ".config", "opencode", name)) + } + } + + return directories +} diff --git a/src/btw-command.ts b/src/btw-command.ts new file mode 100644 index 0000000..a255367 --- /dev/null +++ b/src/btw-command.ts @@ -0,0 +1,653 @@ +import { detectCliVersion } from "./cli-version.js" +import { log } from "./logger.js" +import { getOpencodeClient } from "./runtime-status.js" +import { findActiveProcessBySessionId, type ActiveProcess } from "./session-manager.js" +import { + collectSideQuestionHistory, + isSideQuestionPending, + requestSideQuestion, + SIDE_QUESTION_USAGE, + type SideQuestionExchange, + type SideQuestionResult, +} from "./side-question.js" + +/** + * `/btw`: a side question that is answered while the main turn keeps running, + * and whose exchange is kept in the conversation where it was asked. + * + * opencode's TUI sends every slash command to the server the moment it is + * typed, busy or not (`tui/component/prompt/index.tsx`), so the + * `command.execute.before` hook fires immediately. The user message the + * command produces is what gets held back ("Queued") until the running turn + * ends, and opencode's loop then runs it as a step of its own: the loop only + * exits when the newest assistant message answers the newest user message + * (`session/prompt.ts`, `lastAssistant.parentID === lastUser.id`). + * + * So the hook sends the question to the conversation's live `claude` process + * as a `side_question` control request right away (Claude Code answers those + * on a separate advisor call, concurrently with a running turn, from the + * conversation's context) and remembers the pending answer per session. Where + * the answer lands then depends on what is open when it arrives: + * 1. a turn is streaming, so the answer is written into that turn's own + * reply as its own text block and the `/btw` message is dropped. The + * operator reads it in place, the moment it is ready, and it stays; + * 2. nothing is open to write to, so the `/btw` message is held until the + * turn ends. It then reaches the aside branch in + * `claude-code-language-model.ts`, which takes the remembered answer and + * emits it as that message's reply, at no cost; + * 3. the conversation was idle all along, so the message runs at once and + * case 2 is all that happens. + * Every one of those lands in the conversation, so none of them toasts: a + * toast expires and the operator asked for the answer to stay. The two that + * remain are the paths where nothing reaches the conversation at all, a bare + * `/btw` and a turn that never ended, where a toast is the only feedback left. + * `filterSideQuestionHistory` keeps every `/btw` pair out of Claude's prompt, + * `INLINE_ASIDE_MARKER` does the same for case 1's block, and the control + * request never touches Claude's own transcript, so an aside is persisted for + * the operator only. + */ + +type SdkResult = Promise<{ data?: T; error?: unknown }> + +export interface BtwToast { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number +} + +export interface BtwSdkMessage { + info?: { role?: string } + parts?: unknown[] +} + +export interface BtwSdkClient { + session?: { + messages?: (options: { path: { id: string } }) => SdkResult + /** `GET /session/status`: sessions missing from the map are idle. */ + status?: () => SdkResult> + } + tui?: { + showToast?: (options: { body: BtwToast }) => SdkResult + } +} + +export interface BtwCommandInput { + command: string + sessionID: string + arguments: string +} + +/** Every wait the hook can make, so tests do not have to sit through them. */ +export interface BtwWaitOptions { + /** How often to re-read opencode's session status. */ + pollMs?: number + /** Cap on holding the `/btw` message back while a turn runs. */ + timeoutMs?: number + /** Cap on treating an idle-looking status as not yet registered. */ + settleMs?: number + /** Cap on waiting for the running turn's `claude` process to be tagged. */ + spawnWaitMs?: number + /** How often to retry writing the answer into the running turn. */ + inlinePollMs?: number + /** Cap on waiting for a stream to write the answer into. */ + inlineWaitMs?: number +} + +/** Thrown to make opencode drop the prompt when there is nothing worth keeping. */ +export class BtwHandledError extends Error { + override readonly name = "BtwHandledError" + constructor(message = "/btw was handled by the claude-code plugin; nothing to add to this conversation.") { + super(message) + } +} + +export const BTW_NO_SESSION_MESSAGE = + "/btw needs a live Claude Code session in this conversation. Send a normal message with a Claude Code model first, then ask again." + +export const BTW_INLINE_HANDLED_MESSAGE = + "/btw was answered inside the running turn; nothing to add to this conversation." + +export const BTW_TURN_TOO_LONG_MESSAGE = + "/btw gave up waiting for this turn to end. Ask again once it is over." + +const IDLE_POLL_MS = 500 +const IDLE_WAIT_MAX_MS = 30 * 60_000 +/** + * How long a single status read is allowed to be wrong. opencode registers + * the turn a moment after the TUI sends the command, and a session missing + * from `GET /session/status` reads as idle, so a `/btw` typed inside that gap + * would decide the conversation is free and let its message queue. + */ +const BUSY_SETTLE_MS = 1_500 +/** + * How long to wait for the turn's `claude` process to appear. doStream tags + * the process only once it attaches its line listener, which is after the + * whole spawn path, so the first `/btw` of a conversation regularly arrives + * before there is anything to ask. Bounded, because the running turn may + * belong to another provider and then no process is ever coming. + */ +const SPAWN_WAIT_MAX_MS = 30_000 + +const INLINE_POLL_MS = 200 +/** + * How long to keep trying to write into the turn. A turn is a run of streams, + * not one: every proxy tool call ends the current stream and opencode opens + * the next one with the tool's result, so an answer that arrives inside that + * gap has nothing to write to yet and has to wait for the next stream. + */ +const INLINE_WAIT_MAX_MS = 20_000 + +const PENDING_ANSWER_TTL_MS = 10 * 60_000 +const PENDING_ANSWER_CAP = 32 + +interface PendingAnswer { + question: string + answer: Promise + at: number +} + +/** Answers the hook requested ahead of the queued prompt, one per opencode session. */ +const pendingAnswers = new Map() + +export function rememberSideQuestionAnswer( + sessionID: string, + question: string, + answer: Promise, + now = Date.now(), +): void { + for (const [id, entry] of pendingAnswers) { + if (now - entry.at > PENDING_ANSWER_TTL_MS) pendingAnswers.delete(id) + } + pendingAnswers.delete(sessionID) + while (pendingAnswers.size >= PENDING_ANSWER_CAP) { + const oldest = pendingAnswers.keys().next().value + if (oldest === undefined) break + pendingAnswers.delete(oldest) + } + pendingAnswers.set(sessionID, { question: question.trim(), answer, at: now }) +} + +/** + * The answer the hook already requested for this session, if it was for this + * question and is still fresh. Taking it consumes it: a later `/btw` with the + * same text asks again rather than replaying a stale answer. + * + * The question the turn parses may be longer than what the hook saw: a + * harness can append trailing metadata to the message text (opencode-dcp adds + * a `` marker), so the hook's question only has to be a prefix. + * Measured live: an exact match missed, the turn asked again, and the + * single-flight guard refused it as a second concurrent aside. + */ +export function takeSideQuestionAnswer( + sessionID: string, + question: string, + now = Date.now(), +): Promise | undefined { + const entry = pendingAnswers.get(sessionID) + if (!entry) return undefined + pendingAnswers.delete(sessionID) + if (!question.trim().startsWith(entry.question) || now - entry.at > PENDING_ANSWER_TTL_MS) return undefined + return entry.answer +} + +/** Test seam. */ +export function clearPendingSideQuestionAnswers(): void { + pendingAnswers.clear() +} + +/** + * Header of the block an aside writes into the running turn's own reply, and + * the marker `message-builder` strips by when a transcript has to be rebuilt + * for a fresh Claude process. Kept as the first characters of its own text + * part so the strip is exact rather than a guess at where the block ends. + */ +export const INLINE_ASIDE_MARKER = "▌ **btw:**" + +/** + * Markers of blocks written before the bar replaced the blockquote. Only the + * strip reads these: a conversation that already holds an old aside still has + * to keep it out of a rebuilt transcript. + */ +export const LEGACY_INLINE_ASIDE_MARKERS = ["> **btw:**"] + +/** + * A literal bar on every line, blank ones included, so the aside reads as one + * block down its whole height. + * + * The obvious alternative, a markdown blockquote, was tried first and is why + * this exists: opencode renders assistant text with OpenTUI's markdown, which + * draws a blockquote's left border in the `conceal` scope's colour, not the + * theme's `markdownBlockQuote`. That border is dim by design and there is no + * per-block way to change it, so the bar has to be text the plugin emits. + * Line breaks survive because OpenTUI renders a paragraph from `token.raw`, + * verbatim, rather than reflowing it. + */ +function barEveryLine(text: string): string { + return text + .split("\n") + .map((line) => (line.trim() === "" ? "▌" : `▌ ${line}`)) + .join("\n") +} + +function oneLine(question: string): string { + return question.replace(/\s+/g, " ").trim() +} + +function asideHeader(question: string): string { + return `${INLINE_ASIDE_MARKER} ${oneLine(question)}` +} + +export function formatInlineAside(question: string, answer: string): string { + return `\n${asideHeader(question)}\n▌\n${barEveryLine(answer.trim())}\n` +} + +/** + * The receipt's trailing note. Past tense, because the block stays in the + * conversation and an "answering..." would read as stale the moment the + * answer lands. + */ +export const INLINE_ASIDE_SENT_NOTE = "*sent to Claude on the side*" + +/** + * A receipt written into the running turn the moment the question goes out, so + * a `/btw` typed mid-turn shows as taken instead of looking swallowed until + * the answer arrives. + * + * It quotes the question back **in full**, which is what the operator asked + * for: the prompt box clears on submit and no `/btw` message is ever created, + * so this is the only place the question can be read back. It was briefly + * capped at 240 characters and that was wrong for the same reason, since a + * long aside would then be unreadable everywhere. The note goes on its own bar + * line so the question is never crowded by it. + * + * The answer block repeats the question rather than dropping it, because the + * model keeps streaming its own text between the two and a headerless answer + * arriving after that reads as orphaned. + */ +export function formatInlineAsideAsk(question: string): string { + return `\n${asideHeader(question)}\n▌ ${INLINE_ASIDE_SENT_NOTE}\n` +} + +/** + * Writes one finished text block into a stream that is open right now. + * Returns false when there is nothing to write to, which is the whole reason + * the held-message path is still here. + */ +export type AsideSink = (text: string) => boolean + +/** At most one open stream per conversation, so a plain map is enough. */ +const asideSinks = new Map() + +export function registerAsideSink(sessionID: string, sink: AsideSink): () => void { + asideSinks.set(sessionID, sink) + return () => { + // Only the stream that registered may unregister: a later turn's sink + // must survive the earlier turn's cleanup. + if (asideSinks.get(sessionID) === sink) asideSinks.delete(sessionID) + } +} + +export function emitAsideInline(sessionID: string, text: string): boolean { + const sink = asideSinks.get(sessionID) + if (!sink) return false + try { + return sink(text) + } catch (error) { + log.debug("btw: could not write the aside into the running turn", { sessionID, error: errorText(error) }) + return false + } +} + +/** Test seam. */ +export function clearAsideSinks(): void { + asideSinks.clear() +} + +export function showToast(client: BtwSdkClient | null, body: BtwToast): void { + // Keep the receiver: the SDK's namespace methods read `this._client`, so a + // detached `const show = client.tui.showToast` throws at call time. + try { + void client?.tui?.showToast?.({ body })?.catch((error: unknown) => { + log.debug("btw toast failed", { error: errorText(error) }) + }) + } catch (error) { + log.debug("btw toast failed", { error: errorText(error) }) + } +} + +/** A turn is streaming from this process, so its transcript cannot show an answer yet. */ +export function isProcessBusy(active: Pick): boolean { + return active.lineEmitter.listenerCount("line") > 0 +} + +/** + * opencode's own view of the session: `busy` for the whole turn, including + * the gaps where opencode runs a tool and no stream is attached to the + * process, which `isProcessBusy` cannot see. `unknown` when the SDK has no + * status route or it fails. + */ +export async function sessionStatus( + client: BtwSdkClient | null, + sessionID: string, +): Promise<"busy" | "idle" | "unknown"> { + const status = client?.session?.status + if (!status) return "unknown" + try { + const result = await status.call(client!.session) + const entry = result.data?.[sessionID] + return entry && entry.type !== "idle" ? "busy" : "idle" + } catch (error) { + log.debug("btw: could not read session status", { sessionID, error: errorText(error) }) + return "unknown" + } +} + +/** + * Resolves once the session is no longer busy. Returns false on timeout. A + * client without a status route resolves at once, since there is nothing to + * wait on. + */ +export async function waitForSessionIdle( + client: BtwSdkClient | null, + sessionID: string, + options: { pollMs?: number; timeoutMs?: number; stop?: () => boolean } = {}, +): Promise { + const pollMs = options.pollMs ?? IDLE_POLL_MS + const timeoutMs = options.timeoutMs ?? IDLE_WAIT_MAX_MS + const started = Date.now() + for (;;) { + if (options.stop?.()) return true + if ((await sessionStatus(client, sessionID)) !== "busy") return true + if (Date.now() - started >= timeoutMs) return false + await new Promise((resolve) => setTimeout(resolve, pollMs)) + } +} + +/** + * Puts the answer in the conversation while the turn that prompted it is + * still running, by writing it as its own text block into that turn's live + * stream. It lands in the assistant reply the operator is already watching: + * full markdown, scrollable, kept by opencode, and readable long after a + * toast would have gone. + * + * Retries while the conversation stays busy, because a turn is a run of + * streams rather than one and the gap between two of them is short. Gives up + * once the turn ends, leaving the message to carry the answer instead. + */ +export async function deliverAsideInline( + client: BtwSdkClient | null, + sessionID: string, + text: string, + options: BtwWaitOptions = {}, +): Promise { + const pollMs = options.inlinePollMs ?? INLINE_POLL_MS + const timeoutMs = options.inlineWaitMs ?? INLINE_WAIT_MAX_MS + const started = Date.now() + for (;;) { + if (emitAsideInline(sessionID, text)) return true + if (Date.now() - started >= timeoutMs) return false + if ((await sessionStatus(client, sessionID)) !== "busy") return false + await new Promise((resolve) => setTimeout(resolve, pollMs)) + } +} + +/** + * The `claude` process serving this conversation, waiting for it when a turn + * is already running but has not yet reached the point where doStream tags it + * (`claude-code-language-model.ts`, where the line listener attaches). That + * gap is the whole spawn path on a conversation's first turn, and a `/btw` + * typed inside it used to fall straight through, which is exactly what leaves + * a "Queued" bubble in the transcript: measured live on 2026-09-06, a `/btw` + * logged "no live claude process for session" and the same question 22 s + * later found one and was answered concurrently. + */ +export async function waitForAsideProcess( + client: BtwSdkClient | null, + sessionID: string, + options: BtwWaitOptions = {}, +): Promise { + const pollMs = options.pollMs ?? IDLE_POLL_MS + const settleMs = options.settleMs ?? BUSY_SETTLE_MS + const spawnWaitMs = options.spawnWaitMs ?? SPAWN_WAIT_MAX_MS + const started = Date.now() + for (;;) { + const active = findActiveProcessBySessionId(sessionID) + if (active) return active + const busy = (await sessionStatus(client, sessionID)) === "busy" + const waitedMs = Date.now() - started + if (!busy && waitedMs >= settleMs) { + // Nothing is running, so no process is on its way either. + log.info("btw: no live claude process for session, leaving it to the turn", { sessionID, waitedMs }) + return undefined + } + if (busy && waitedMs >= spawnWaitMs) { + // A turn is running but it never produced a process of ours: it belongs + // to another provider, or the spawn failed. Do not hold the message for + // the rest of it. + log.warn("btw: a turn is running but no claude process appeared for it", { sessionID, waitedMs }) + return undefined + } + await new Promise((resolve) => setTimeout(resolve, pollMs)) + } +} + +/** + * Whether a turn is running, tolerant of the same registration lag: a status + * read taken the instant `/btw` is typed can still say idle while opencode is + * starting the turn, and skipping the hold on that reading is what queues the + * message behind the turn instead of releasing it afterwards. + */ +export async function settleSessionBusy( + client: BtwSdkClient | null, + sessionID: string, + active: Pick, + options: BtwWaitOptions = {}, +): Promise { + const pollMs = options.pollMs ?? IDLE_POLL_MS + const settleMs = options.settleMs ?? BUSY_SETTLE_MS + const started = Date.now() + for (;;) { + const status = await sessionStatus(client, sessionID) + if (status === "busy") return true + // No status route to poll: the process's own stream is all there is. + if (status === "unknown") return isProcessBusy(active) + if (Date.now() - started >= settleMs) return false + await new Promise((resolve) => setTimeout(resolve, pollMs)) + } +} + +function errorText(error: unknown): string { + if (error instanceof Error) return error.message + if (error && typeof error === "object" && "message" in error && typeof (error as { message: unknown }).message === "string") { + return (error as { message: string }).message + } + return String(error) +} + +function isTextPart(part: unknown): part is { type: "text"; text: string } { + return ( + part !== null && + typeof part === "object" && + (part as { type?: unknown }).type === "text" && + typeof (part as { text?: unknown }).text === "string" + ) +} + +/** + * Earlier `/btw` exchanges in this conversation, read back from opencode + * because the hook runs before the current question exists as a message. + * Best effort: a follow-up without history still gets an answer, just one + * that cannot refer to previous asides. + */ +export async function fetchAsideHistory( + client: BtwSdkClient | null, + sessionID: string, + question: string, +): Promise { + const messages = client?.session?.messages + if (!messages) return [] + try { + const result = await messages.call(client!.session, { path: { id: sessionID } }) + const prompt: { role: string; content: unknown }[] = [] + for (const message of result.data ?? []) { + const role = message.info?.role + if (role !== "user" && role !== "assistant") continue + prompt.push({ role, content: (message.parts ?? []).filter(isTextPart) }) + } + // collectSideQuestionHistory skips the final message as the question being + // asked; stand in for the one opencode has not created yet. + prompt.push({ role: "user", content: `/btw ${question}` }) + return collectSideQuestionHistory(prompt) + } catch (error) { + log.debug("btw: could not read aside history", { sessionID, error: errorText(error) }) + return [] + } +} + +/** + * `command.execute.before` handler for `btw`. Returns normally so opencode + * creates the `/btw` message in this conversation; throws only when there is + * nothing to keep (a bare `/btw`, or a turn that never ended). + * + * While the session is busy the return is delayed until it is idle. opencode + * would otherwise queue the message behind the running turn and run it as + * that turn's next step, which is also the step that carries the results of + * the tools opencode just ran: answering the aside there would swallow the + * turn's own continuation (measured live: the main answer never appeared). + * opencode already keeps the command route open for a queued prompt, so + * holding it here changes nothing on the wire, and the TUI's call is + * fire-and-forget. + * + * Both waits before that hold exist because a `/btw` typed early in a turn + * used to be seen as belonging to an idle conversation with no process, and + * was let through to be queued: `waitForAsideProcess` covers the spawn gap, + * `settleSessionBusy` covers opencode registering the turn. + */ +export async function handleBtwCommand( + client: BtwSdkClient | null, + input: BtwCommandInput, + options: BtwWaitOptions = {}, +): Promise { + const question = input.arguments.trim() + if (!question) { + showToast(client, { title: "btw", message: SIDE_QUESTION_USAGE, variant: "warning", duration: 6_000 }) + throw new BtwHandledError("/btw needs a question.") + } + const active = await waitForAsideProcess(client, input.sessionID, options) + const transport = active?.asideTransport + if (!active || !transport) { + // The message still goes through: the aside branch answers it with an + // explanation that stays readable in the conversation. + if (active) log.info("btw: process has no aside transport, leaving it to the turn", { sessionID: input.sessionID }) + return + } + let busy = false + let inlineDone = false + let markInlineDelivered = (): void => {} + const inlineDelivered = new Promise<"inline">((resolve) => { + markInlineDelivered = () => { + inlineDone = true + resolve("inline") + } + }) + if (isSideQuestionPending(active)) { + // One aside per process at a time. Leave the earlier answer in place for + // its own message; this one asks when its turn comes. + busy = await settleSessionBusy(client, input.sessionID, active, options) + log.info("btw: an aside is already in flight, leaving this one to the turn", { sessionID: input.sessionID, busy }) + } else { + // Settled alongside the request rather than before it: an aside asked + // while the conversation is idle must not wait out the settle window + // before it is even sent. + const settling = settleSessionBusy(client, input.sessionID, active, options) + const history = await fetchAsideHistory(client, input.sessionID, question) + const answer = requestSideQuestion(active, question, { + cliVersion: await detectCliVersion(transport.cliPath), + interactive: transport.interactive, + ...(history.length ? { history } : {}), + }) + // Handled from this tick on. The settle below can span several timer + // ticks, and an aside that fails immediately (a dead process, an + // interactive transport) would otherwise raise an unhandled rejection in + // the host before the real handlers further down are attached. + answer.catch(() => undefined) + rememberSideQuestionAnswer(input.sessionID, question, answer) + busy = await settling + log.info("btw: aside sent ahead of its message", { + sessionID: input.sessionID, + busy, + questionLength: question.length, + history: history.length, + }) + // Written before the answer exists, so a `/btw` typed mid-turn shows up in + // the turn straight away rather than looking swallowed until the answer + // arrives. Only while a turn is running: an idle conversation gets the + // whole pair as its own message a moment later anyway. + const asked = busy + ? deliverAsideInline(client, input.sessionID, formatInlineAsideAsk(question), options).catch(() => false) + : Promise.resolve(false) + answer.then( + async (result) => { + log.info("btw: early answer arrived", { sessionID: input.sessionID, busy, responseLength: result.response.length }) + if (!busy || result.synthetic) return + // Awaited, not raced: a receipt that landed after the answer it + // announces would read backwards. In the common case it was written + // long before this and the await is already settled. + await asked + const inline = await deliverAsideInline( + client, + input.sessionID, + formatInlineAside(question, result.response), + options, + ) + if (inline) { + // The answer is in the conversation already, so the `/btw` message + // has nothing left to carry. The remembered answer is deliberately + // left in place: if the drop below does not take, the message + // replays this answer instead of paying for a second one. + log.info("btw: answer written into the running turn", { sessionID: input.sessionID }) + markInlineDelivered() + return + } + // Nothing was open to write to. The held `/btw` message carries this + // same answer into the conversation once the turn ends, which is the + // durable copy, so there is nothing to announce here. + log.info("btw: no open stream for the answer; the held message will carry it", { + sessionID: input.sessionID, + }) + }, + (error: unknown) => { + // The message asks again once its turn runs. + log.warn("btw: early aside failed; the message will ask again", { + sessionID: input.sessionID, + error: errorText(error), + }) + }, + ) + } + if (!busy) return + const started = Date.now() + const outcome = await Promise.race([ + inlineDelivered, + waitForSessionIdle(client, input.sessionID, { ...options, stop: () => inlineDone }).then((idle) => + idle ? ("idle" as const) : ("timeout" as const), + ), + ]) + if (outcome === "inline") { + log.info("btw: answered inside the running turn, dropping the /btw message", { + sessionID: input.sessionID, + waitedMs: Date.now() - started, + }) + throw new BtwHandledError(BTW_INLINE_HANDLED_MESSAGE) + } + log.info("btw: turn over, releasing the /btw message", { + sessionID: input.sessionID, + idle: outcome === "idle", + waitedMs: Date.now() - started, + }) + if (outcome === "timeout") { + showToast(client, { title: "btw", message: BTW_TURN_TOO_LONG_MESSAGE, variant: "warning", duration: 8_000 }) + throw new BtwHandledError(BTW_TURN_TOO_LONG_MESSAGE) + } +} diff --git a/src/bun-terminal.d.ts b/src/bun-terminal.d.ts new file mode 100644 index 0000000..7daffa8 --- /dev/null +++ b/src/bun-terminal.d.ts @@ -0,0 +1,35 @@ +// Minimal ambient types for the subset of Bun's native PTY API used by +// claude-session-bun.ts. Kept local on purpose: pulling full `bun-types` +// conflicts with `@types/node` in this repo, and we only need a few members. +export {} + +declare global { + interface BunTerminal { + write(data: string | Uint8Array): number + close(): void + resize(cols: number, rows: number): void + } + + interface BunSubprocess { + readonly terminal: BunTerminal + readonly exited: Promise + readonly pid: number + kill(signal?: number | string): void + } + + interface BunSpawnTerminalOptions { + cwd?: string + env?: Record + terminal?: { + cols?: number + rows?: number + data?: (terminal: BunTerminal, data: Uint8Array) => void + } + } + + const Bun: { + version: string + which(command: string, options?: { PATH?: string; cwd?: string }): string | null + spawn(command: string[], options?: BunSpawnTerminalOptions): BunSubprocess + } +} diff --git a/src/claude-code-language-model.ts b/src/claude-code-language-model.ts index cc65276..36c60f7 100644 --- a/src/claude-code-language-model.ts +++ b/src/claude-code-language-model.ts @@ -1,29 +1,982 @@ import type { - LanguageModelV2, - LanguageModelV2CallWarning, - LanguageModelV2Content, - LanguageModelV2FinishReason, - LanguageModelV2StreamPart, - LanguageModelV2Usage, + LanguageModelV3, + LanguageModelV3CallOptions, + LanguageModelV3Content, + LanguageModelV3FinishReason, + LanguageModelV3StreamPart, + LanguageModelV3Usage, + SharedV3Warning, } from "@ai-sdk/provider" import { generateId } from "@ai-sdk/provider-utils" -import type { ClaudeCodeConfig, ClaudeStreamMessage } from "./types.js" -import { mapTool } from "./tool-mapping.js" -import { getClaudeUserMessage } from "./message-builder.js" +import type { + ClaudeCodeConfig, + ControlRequestBehavior, + ClaudeStreamMessage, + ReasoningEffort, +} from "./types.js" +import { mapTool, isWebSearchTool, isWebSearchHandledByCli } from "./tool-mapping.js" +import { applyTaskCreateToolResult } from "./todo-ledger.js" +import { + getClaudeUserMessage, + shouldStripContextReminders, +} from "./message-builder.js" +import { resolveAgentEffort, resolveAgentModel } from "./agent-models.js" +import { parseSideQuestion, requestSideQuestion, collectSideQuestionHistory, SIDE_QUESTION_USAGE, type SideQuestionResult } from "./side-question.js" +import { BTW_NO_SESSION_MESSAGE, registerAsideSink, takeSideQuestionAnswer } from "./btw-command.js" +import { + describeResultFailure, + formatResultFailureNote, + formatStreamTimeoutNote, + isRateLimitRejected, + parseRateLimitEvent, + reportCompactBoundary, + reportRateLimitEvent, + reportSystemInit, +} from "./cli-events.js" +import { DEFAULT_ACCOUNT, normalizeAccountName } from "./accounts.js" +import { + buildFailoverContinuationPrompt, + consumeAccountFailoverAnswer, + createAccountFailoverQuestionCall, + failoverCandidates, + failoverUntil, + formatFailoverNote, + formatFailoverStopNote, + isAccountFailoverQuestionActive, + isAccountLimitError, + resolveFailoverSpawn, + setAccountOverride, + type FailoverSpawn, +} from "./account-failover.js" +import { DOCTOR_COMMAND, buildDoctorReport, parseDoctorCommand } from "./doctor.js" +import { + extractTurnStats, + formatTurnStatsBlock, + turnStatsLogPayload, +} from "./turn-stats.js" +import { resolveSkillPluginDirs } from "./skill-bridge.js" +import { parseModelId } from "./models.js" +import { + QUESTION_TOOL_NAME, + consumeExitPlanModeQuestionResult, + createExitPlanModeQuestionCall, + isPlanModeQuestionActive, + type QuestionToolCall, +} from "./plan-mode-question.js" +import { bridgeOpencodeMcp, type RuntimeMcpStatus } from "./mcp-bridge.js" +import { + getRuntimeMcpStatus, + fetchOpencodeToolList, + fetchSessionParentId, + type OpencodeToolListItem, + resolveSpawnCwdForSession, +} from "./runtime-status.js" import { getActiveProcess, + setActiveProcess, spawnClaudeProcess, buildCliArgs, setClaudeSessionId, getClaudeSessionId, deleteClaudeSessionId, deleteActiveProcess, + deleteActiveProcessAndWait, + respawnActiveProcess, + resolveIdleProcessTimeoutMs, + scheduleIdleProcessEviction, + noteTurnStarted, + isTurnInFlight, + interruptTurn, + takeUnattendedLines, + describeChildCrash, + claudeSpawnEnv, + isClaudeThinkingDisabled, sessionKey, + effortSessionKey, + invalidateOtherEffortSessions, } from "./session-manager.js" +import { spawnInteractiveProcess } from "./claude-session-wrapper.js" +import { + clearCompression, + consumeCompressionRestart, + getCompressionSummary, + storeCompressionSummary, +} from "./compression-store.js" import { log } from "./logger.js" +import { detectCliVersion } from "./cli-version.js" +import { + createProxyMcpServer, + resolveDisallowedTools, + resolveProxyOpencodeToolDefs, + resolveMcpProxyToolDefs, + DEFAULT_PROXY_TOOLS, + overlayTaskProxyDescription, + overlayQuestionProxyDescription, + filterQuestionProxyByOpencodeSupport, + PROXY_TOOL_PREFIX, + TASK_BATCH_TOOL_NAME, + taskBatchTasks, + taskBatchChildToolCallId, + formatTaskBatchResults, + type McpProxyToolResolution, + type ModelToolEntry, + type ProxyMcpServer, + type ProxyToolCall, + type ProxyToolDef, + type ProxyToolInterceptor, + type ProxyToolResult, +} from "./proxy-mcp.js" +import { + getPendingProxyCalls, + isPendingProxyCallChannelClosed, + markPendingProxyCallEmitted, + onPendingProxyCall, + queuePendingProxyCall, + rejectAllPendingProxyCallsForSession, + rejectPendingProxyCallById, + resolvePendingProxyCallById, + type PendingProxyCall, +} from "./proxy-broker.js" +import { readFileSync, writeFileSync } from "node:fs" +import { unlink } from "node:fs/promises" +import { homedir, tmpdir } from "node:os" +import { randomUUID } from "node:crypto" +import { dirname, join } from "node:path" + +/** + * Default model used for opencode `/compact`. Haiku 4.5 is fast + * (~150 tok/s), has a hard 8k output cap that bounds latency, and is a + * strong structured summarizer. Override per-project via the + * `compactionModel` provider setting in opencode.json / opencode.jsonc, + * or per-run via the `CLAUDE_CODE_COMPACTION_MODEL` env var (env wins). + */ +export const DEFAULT_COMPACTION_MODEL = "claude-haiku-4-5" + +/** + * Pick the model used to handle /compact. Precedence: + * 1. `CLAUDE_CODE_COMPACTION_MODEL` env var (per-process override) + * 2. `configured` argument (the `compactionModel` provider setting) + * 3. `DEFAULT_COMPACTION_MODEL` + * + * Exported as a free function so it can be unit-tested without + * instantiating the language model class. + */ +export function resolveCompactionModel(configured?: string): string { + const env = process.env.CLAUDE_CODE_COMPACTION_MODEL?.trim() + if (env) return env + const trimmed = configured?.trim() + if (trimmed) return trimmed + return DEFAULT_COMPACTION_MODEL +} + +/** + * Resolve the session affinity token for a given LLM call. The affinity + * token is part of the session key in session-manager so two different + * opencode sessions sharing the same cwd+model still get separate Claude + * CLI processes. + * + * Priority: + * 1. `x-session-affinity` request header (primary — opencode sets it for + * third-party providers in packages/opencode/src/session/llm.ts). + * 2. `opencodeSessionID` inside `providerOptions` (injected by the + * `chat.params` hook in index.ts). Covers cases where the header is + * absent: provider switch mid-session, title synthesis paths, older + * opencode versions. opencode wraps `output.options` under the + * providerID before passing it to the language model, so we look up + * both the configured provider key and the canonical `"claude-code"`. + * 3. `"default"` — safe fallback when neither source is available. + * + * Exported as a free function so it can be unit-tested without + * instantiating the language model class. + */ +export function resolveSessionAffinity( + headers: Record | undefined, + providerOptions: Record | undefined, + providerKey: string, +): string { + if (headers) { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === "x-session-affinity") { + const v = headers[key] + if (typeof v === "string" && v.length > 0) return v + } + } + } + if (providerOptions) { + const bag = + (providerOptions as any)[providerKey] ?? + (providerOptions as any)["claude-code"] + const sid = bag?.opencodeSessionID + if (typeof sid === "string" && sid.length > 0) return sid + } + return "default" +} + +/** + * Stream delta types we handle explicitly. `signature_delta` is listed as + * known-and-silent: it carries encrypted thinking-block signatures that + * are opaque to clients (the server uses them to reconstitute thinking + * across turns), so there's nothing for us to do but ignore it. + */ +const KNOWN_DELTA_TYPES = new Set([ + "thinking_delta", + "text_delta", + "input_json_delta", + "signature_delta", +]) + +/** + * True if the prompt has any user-side content after the last assistant + * message (text, tool_result, or any user role entry). False when the + * prompt ends with an assistant message and there is nothing for Claude + * to respond to — opencode sometimes iterates the agent loop one more + * time after a turn naturally completed; without short-circuiting we'd + * spawn Claude CLI on an empty turn and the model would reply with a + * stub like "Did you mean to send a message?". + */ +export function hasNewUserContent( + prompt: LanguageModelV3CallOptions["prompt"], +): boolean { + for (let i = prompt.length - 1; i >= 0; i--) { + const msg = prompt[i] + if (msg.role === "assistant") return false + // Tool-result turns from opencode's outer loop arrive in `tool`-role + // messages (AI SDK V3 shape). Treat any tool-result part as new + // content so the short-circuit doesn't drop turns where opencode is + // delivering the result for a still-pending proxy MCP call — letting + // that fire `stop` is what was forcing the user to press "continue". + if (msg.role === "tool") { + const content: any = msg.content + if (Array.isArray(content)) { + for (const part of content as any[]) { + if (part?.type === "tool-result") return true + } + } + continue + } + if (msg.role !== "user") continue + const content: any = msg.content + if (typeof content === "string") { + if (content.trim()) return true + continue + } + if (Array.isArray(content)) { + for (const part of content as any[]) { + if (part.type === "text" && part.text && part.text.trim()) return true + if (part.type === "tool-result") return true + // Image/file-only user turns count as new input — without this the + // short-circuit drops them as if the turn were empty. + if (part.type === "image" || part.type === "file") return true + } + } + } + return false +} + +const AUTO_CONTINUE_MAX_ATTEMPTS = 8 +const AUTO_CONTINUE_MAX_ELAPSED_MS = 10 * 60 * 1000 +const AUTO_CONTINUE_NO_PROGRESS_LIMIT = 2 +const PROXY_RESULT_BOUNDARY_GRACE_MS = 250 +// How long a turn that lost its child waits for that child's exit status +// before reporting the crash without one. +const CHILD_EXIT_STATUS_GRACE_MS = 250 + +const AUTO_CONTINUE_PROMPT = + "Continue the task from where you stopped. Do not summarize; keep working until the requested task is complete, you need clarification, or you hit a real blocker." + +/** One per-turn snapshot of opencode's live tool registry. */ +interface LiveToolInfo { + /** False when nothing answered (no SDK client, fetch failed). */ + resolved: boolean + taskDescription: string | undefined + questionDescription: string | undefined + hasQuestion: boolean + /** + * The raw registry entries behind the fields above, so `proxyOpencodeTools` + * can be resolved from the same single fetch rather than a second one. + */ + items?: OpencodeToolListItem[] +} + +interface AutoContinueState { + enabled: boolean | "smart" | undefined + attempts: number + startedAt: number + noProgressCount: number + lastSignature?: string + aborted?: boolean + /** + * Latched true once AskUserQuestion is rendered this turn. Auto-continue + * must never fire afterwards: the model has handed control to the operator + * and is waiting for a real reply. Without this, a short trailing text after + * the question (one that doesn't trip looksLikeQuestion) would let the turn + * look "incomplete", and the auto-continue nudge would make the model + * proceed on its own — which the operator sees as the question being + * answered/cancelled without them ever interacting. + */ + sawAskUserQuestion?: boolean +} + +interface AutoContinueSnapshot { + text: string + /** + * Text of the most recent assistant text block only. Used for final-answer + * detection so mid-task narration like "Implementing now. Updated the + * search index." in an earlier block doesn't trip the keyword regex. + */ + lastVisibleText: string + hadReasoning: boolean + hadToolActivity: boolean + hadProxyActivity: boolean + isError?: boolean + /** + * Protocol-level stop signal from the Claude API (forwarded by Claude + * CLI). When present and non-empty, we trust it as authoritative — the + * model itself signaled why the turn ended (`end_turn`, `max_tokens`, + * `stop_sequence`, `refusal`, `pause_turn`, `tool_use`, etc.) — and stop + * without running the keyword regex. The heuristic only runs as a + * fallback when `stop_reason` is missing (older CLI versions, abrupt + * termination). + */ + stopReason?: string | null + now?: number +} + +/** + * A compaction turn must never be nudged to continue. `AUTO_CONTINUE_PROMPT` + * says "Do not summarize; keep working", the exact inverse of what `/compact` + * is for, and continuation reopens the same stream rather than closing it, so + * the non-summary text would land inside what opencode stores as the session + * summary. This was unreachable while every `stop_reason` ended the turn; + * truncation-continue made a summary that hits the output cap reach it. + * Exported so the wiring is testable, since the state itself is built inline + * in `doStream`. + */ +export function autoContinueEnabledFor( + compactionMode: boolean, + configured: boolean | "smart" | undefined, +): boolean | "smart" | undefined { + return compactionMode ? false : configured +} + +/** + * Stop reasons that mean "cut off", not "done". Anthropic sends `max_tokens`; + * `max_output_tokens` is accepted as a defensive alias so a rename upstream + * degrades to today's behaviour rather than silently mis-reading a real stop. + */ +function isTruncationStopReason(stopReason: string): boolean { + return stopReason === "max_tokens" || stopReason === "max_output_tokens" +} + +interface AutoContinueDecision { + continue: boolean + reason: string +} + +function normalizeVisibleText(text: string): string { + return text.replace(/\s+/g, " ").trim() +} + +/** Tool names that mean "ask the human a question" (CLI casing variants). */ +export function isAskUserQuestionTool(name: string | undefined): boolean { + if (!name) return false + const n = name.toLowerCase() + return n === "askuserquestion" || n === "ask_user_question" +} + +/** + * Deny message returned to the model when it invokes AskUserQuestion. + * + * AskUserQuestion is denied (see controlRequestBehaviorForTool) so the + * headless CLI cannot self-answer against an empty TTY. The question is + * already rendered to the operator by formatAskUserQuestion, so this text + * tells the model to stop and wait — unconditionally. Earlier versions + * offered an "if this is non-interactive, proceed with a reasonable guess" + * escape hatch, but the model could not reliably tell interactive opencode + * from a headless run and routinely took it, so questions appeared to be + * skipped (issue #8). Stopping is the correct default for opencode; a + * headless run simply ends the turn with the question as its final output. + */ +const ASK_USER_QUESTION_DENY_MESSAGE = + "Your question and its options have already been presented to the" + + " operator verbatim. This is NOT a cancellation or a refusal — the" + + " operator simply has not answered yet. Stop now: end your turn without" + + " calling any more tools and without answering the question yourself. Do" + + " not say the question was cancelled, skipped, or declined, and do not" + + " guess, assume, or proceed on their behalf. Wait for the operator's" + + " reply, which arrives as the next user message." + +/** Build the deny message for an auto-denied control request. */ +export function denyMessageForTool( + toolName: string | undefined, + configuredDenyMessage?: string, +): string { + if (isAskUserQuestionTool(toolName)) return ASK_USER_QUESTION_DENY_MESSAGE + return ( + configuredDenyMessage ?? + `Denied by opencode-claude-code policy for tool ${toolName}` + ) +} + +/** + * Render Claude Code's `AskUserQuestion` tool input as visible markdown. + * + * This is the fallback path used when the `Question` proxy is off or the + * opencode build lacks the `question` registry entry. When the proxy is + * enabled, `AskUserQuestion` is disabled via `--disallowedTools` and the + * model calls `mcp__opencode_proxy__question` instead (opencode's native + * `question` tool renders the TUI form). Here, the question + every + * option is rendered as readable assistant text and the user answers in + * the next turn — same approach as the `ExitPlanMode` handling. The + * previous behavior collapsed the whole payload to a single faint + * `_Asking: _` line, dropping all options and any question past the + * first. + */ +function formatAskUserQuestion(input: Record): string { + const anyInput = input as any + const questions: any[] = Array.isArray(anyInput?.questions) + ? anyInput.questions + : [] + + if (questions.length === 0) { + const single = anyInput?.question ?? anyInput?.text + const q = + typeof single === "string" && single.trim() ? single.trim() : "Question?" + return `\n\n**${q}**\n\n_Reply with your answer to continue._\n\n` + } + + const out: string[] = ["\n\n"] + const multiQ = questions.length > 1 + questions.forEach((q, i) => { + const text = + (typeof q?.question === "string" && q.question.trim()) || + (typeof q?.text === "string" && q.text.trim()) || + "Question?" + const header = + typeof q?.header === "string" && q.header.trim() ? q.header.trim() : "" + out.push(`**${multiQ ? `${i + 1}. ` : ""}${text}**`) + if (header) out.push(` _(${header})_`) + out.push("\n\n") + + const options: any[] = Array.isArray(q?.options) ? q.options : [] + options.forEach((opt, j) => { + const label = + (typeof opt?.label === "string" && opt.label.trim()) || + (typeof opt === "string" && opt.trim()) || + `Option ${j + 1}` + const desc = + typeof opt?.description === "string" && opt.description.trim() + ? ` — ${opt.description.trim()}` + : "" + out.push(`${j + 1}. **${label}**${desc}\n`) + }) + + out.push( + q?.multiSelect === true + ? "\n_Select one or more — reply with the numbers or labels._\n\n" + : "\n_Reply with your choice (the number or label)._\n\n", + ) + }) + return out.join("") +} + +function looksLikeQuestion(text: string): boolean { + const normalized = normalizeVisibleText(text).toLowerCase() + if (!normalized) return false + // v0.4.10 tweak 5a: '?' anywhere in the last block, not just trailing. + // Catches long answers that pose a question mid-text then list options + // and end with a period. FP risk on inline code (`result?.value`) is + // accepted — cost is one extra "continue" press, in the safe direction. + if (normalized.includes("?")) return true + // v0.4.11 additions: ready when you are / standing by / i'll stand by / + // let me know when. These are awaiting-input idioms with no '?'. The + // "standing by" addition has historical significance — it's the exact + // stub phrase Claude CLI emits on empty turns that commit 49345e3 was + // designed to suppress at the message-builder layer. This adds a second + // line of defense at the model-output layer for cases where the model + // organically produces the same idiom. + // + // v0.4.12 additions: over to you / your turn / all yours / let me know + // how / i'm here. Defensive coverage of soft-proceed idioms in the + // model's vocabulary. "i'm here" has the highest FP risk ("I'm here to + // help with X" is a conversational opener) but cost of FP is one extra + // continue press — safe direction. + return /\b(please confirm|can you confirm|should i|would you like|do you want|which option|choose|pick one|need your|need you to|what would you like|let me know if|let me know whether|let me know what|let me know when|let me know how|if you'?d like|if you want to|tell me if|tell me which|tell me whether|say (?:go|yes|no)|push back|sign off|sounds? (?:good|right)|your call|your move|your turn|over to you|all yours|up to you|ready to (?:ship|go|proceed|merge)|ready (?:when|whenever|once|if) you|standing by|i'?ll stand ?by|i'?m here|happy to (?:ship|go|proceed|merge))\b/.test(normalized) +} + +function looksLikeBlocker(text: string): boolean { + const normalized = normalizeVisibleText(text).toLowerCase() + if (!normalized) return false + // v0.4.10 tweak 3: 'needs your' / 'needs you to' / 'action required' + // are intent-equivalent to 'requires your' but use the verb-with-s form. + return /\b(blocked|blocker|cannot proceed|can't proceed|unable to proceed|need clarification|need more information|permission denied|failed and needs|requires your|needs your|needs you to|action required|manual step|required from you)\b/.test(normalized) +} + +function looksLikeFinalAnswer(text: string): boolean { + const normalized = normalizeVisibleText(text).toLowerCase() + if (looksLikeQuestion(normalized) || looksLikeBlocker(normalized)) return false + // v0.4.15: strong-completion phrases bypass the 30-char length floor. + // These are unambiguous end-of-turn signals at any text length — even + // a short standalone "We're done." should stop. + if (/\b(we'?re done|we are done|all done|all set)\b/.test(normalized)) { + return true + } + // v0.4.10 tweak 4: floor lowered 40 → 30 chars. Catches short clean + // completions like "Task is now completely done. Pushed." (36 chars) + // while keeping a buffer against ambiguous short narration. + if (normalized.length < 30) return false + // v0.4.15: keyword list extended with deploy/ship verbs the model + // routinely uses at turn end (shipped, deployed, merged, tagged, live, + // pinned). FP risk highest on "live" — "live data" mid-turn could match + // — but cost of FP is one extra continue press, safe direction. + return /\b(done|completed|fixed|implemented|verified|published|released|sent|delivered|updated|shipped|deployed|merged|tagged|live|pinned)\b/.test(normalized) || + // v0.4.15: also accept present-tense "tests pass" / "checks pass". + // Real fire 03:31 ended in "78/78 tests pass" — past-tense-only regex + // missed it. + /\b(checks?|tests?) (?:pass|passes|passed)\b/.test(normalized) || + /\b(summary|what changed|verification)\b/.test(normalized) +} + +function continuationSignature(snapshot: AutoContinueSnapshot): string { + const text = normalizeVisibleText(snapshot.text).slice(-500) + return JSON.stringify({ + text, + reasoning: snapshot.hadReasoning, + tools: snapshot.hadToolActivity, + proxy: snapshot.hadProxyActivity, + }) +} + +export function shouldAutoContinueIncompleteTurn( + state: AutoContinueState, + snapshot: AutoContinueSnapshot, +): AutoContinueDecision { + if (state.enabled === false) return { continue: false, reason: "disabled" } + if (snapshot.isError) return { continue: false, reason: "error" } + if (state.aborted) return { continue: false, reason: "aborted" } + // Once the model asked the operator a question this turn, never nudge it to + // continue — it is waiting for a reply, not stalled. Latched so it holds + // even when the trailing text after the question doesn't read as a question. + if (state.sawAskUserQuestion) return { continue: false, reason: "question" } + // v0.4.17: trust ANY protocol-level stop_reason as authoritative. If + // Claude CLI emitted a stop_reason value at all, the model has signaled + // a stop — honor it without consulting the keyword heuristic. The + // heuristic only runs as a fallback when stop_reason is missing (older + // CLI versions / edge cases). Maps snake_case → kebab-case for reason + // label consistency with other reasons. + if (snapshot.stopReason) { + // ...with one exception, which is the narrow half of @JWebCoder's PR #15 + // worth keeping. Truncation is the single stop_reason that does NOT mean + // the model finished: the response hit the output cap mid-sentence. The + // old guard read it as a stop, so a cut-off answer was silently accepted + // as complete. Falling through to the keyword heuristic below would not + // fix it either, because a truncated prose answer has no tool or + // reasoning activity and would die at the `no-activity` gate. So + // truncation is authoritative in the opposite direction: continue, still + // bounded by the attempt and elapsed rails. PR #15 itself deleted the + // whole guard, which would have handed every turn back to the regex that + // v0.4.17 deliberately demoted; that is why it was closed. + if (isTruncationStopReason(snapshot.stopReason)) { + if (state.attempts >= AUTO_CONTINUE_MAX_ATTEMPTS) { + return { continue: false, reason: "max-attempts" } + } + const truncatedAt = snapshot.now ?? Date.now() + if (truncatedAt - state.startedAt > AUTO_CONTINUE_MAX_ELAPSED_MS) { + return { continue: false, reason: "max-elapsed" } + } + return { continue: true, reason: "truncated" } + } + return { + continue: false, + reason: snapshot.stopReason.replace(/_/g, "-"), + } + } + if (state.attempts >= AUTO_CONTINUE_MAX_ATTEMPTS) { + return { continue: false, reason: "max-attempts" } + } + const now = snapshot.now ?? Date.now() + if (now - state.startedAt > AUTO_CONTINUE_MAX_ELAPSED_MS) { + return { continue: false, reason: "max-elapsed" } + } + + const text = normalizeVisibleText(snapshot.text) + const lastText = normalizeVisibleText(snapshot.lastVisibleText) + if (looksLikeQuestion(text)) return { continue: false, reason: "question" } + if (looksLikeBlocker(text)) return { continue: false, reason: "blocker" } + // Final-answer detection runs on the most recent text block only. Earlier + // blocks may contain mid-task narration that would false-positive the + // keyword regex; the model's actual "I'm done" sentence is in the last + // block before result/end_turn. + if (looksLikeFinalAnswer(lastText)) { + return { continue: false, reason: "final-answer" } + } + + const hadActivity = + snapshot.hadReasoning || snapshot.hadToolActivity || snapshot.hadProxyActivity + if (!hadActivity) return { continue: false, reason: "no-activity" } + + const signature = continuationSignature(snapshot) + const noProgress = signature === state.lastSignature + if (noProgress && state.noProgressCount + 1 >= AUTO_CONTINUE_NO_PROGRESS_LIMIT) { + return { continue: false, reason: "no-progress" } + } + + if (!text) { + return { continue: true, reason: "activity-without-visible-answer" } + } + + return { continue: true, reason: "non-final-progress" } +} + +function makeAutoContinueMessage(): string { + return JSON.stringify({ + type: "user", + message: { + role: "user", + content: [{ type: "text", text: AUTO_CONTINUE_PROMPT }], + }, + }) +} + +/** + * A proxy result whose HTTP reply channel Claude already abandoned cannot + * go back as a `tool_result` (the CLI closed that tool_use with a timeout + * error). Hand it over as a user message that names the call instead. + */ +export function makeLateProxyResultMessage( + entries: Array<{ call: PendingProxyCall; result: ProxyToolResult }>, +): string { + const sections = entries.map(({ call, result }) => { + const failed = result.kind === "error" || result.isError === true + const body = result.kind === "error" ? result.message : result.text + return ( + `Your earlier \`${call.toolName}\` tool call (id ${call.toolCallId})` + + ` has ${failed ? "failed" : "completed"}, but delivery or continuation was interrupted.` + + ` Treat the following as its ${failed ? "error" : "result"} and continue from there;` + + ` do not re-run it.\n\n${body}` + ) + }) + return JSON.stringify({ + type: "user", + message: { + role: "user", + content: [{ type: "text", text: sections.join("\n\n---\n\n") }], + }, + }) +} + +function readPromptFileIfPresent(path: string): string | undefined { + try { + const content = readFileSync(path, "utf8").trim() + return content || undefined + } catch { + return undefined + } +} + +function nearestWorkspaceAgentsPrompt(cwd: string): string | undefined { + let dir = cwd + while (true) { + const content = readPromptFileIfPresent(join(dir, "AGENTS.md")) + if (content) return content + const parent = dirname(dir) + if (parent === dir) return undefined + dir = parent + } +} + +const AGENTS_MAINTENANCE_HINT = `## Keeping AGENTS.md up to date + +When you complete a task, phase, or to-do item that is listed in AGENTS.md, update the file +immediately after the work is done — mark it ✅, check it off, or remove it. Do this inside +the same turn so the next session does not repeat work that is already finished.` + +const MULTI_STEP_TASK_HINT = `## Continuing through multi-step tasks + +opencode requires the user to press "continue" after each turn ends. When a +task has multiple steps, do them all in one turn — chain tool calls rather +than pausing for user confirmation between subtasks. End the turn only +when the task is done, you need clarification on intent, or you hit a real +blocker. The user can interrupt or abort at any time; turn endings should +mark meaningful checkpoints, not every completed substep.` + +/** + * Appended to the system prompt whenever the `task` proxy tool is + * enabled. Live sessions (2026-07-04) showed models resolving opencode's + * "call the task tool with subagent: X" mention hint to Claude Code's + * native TaskCreate: haiku created a todo and narrated a dispatch that + * never happened; sonnet probed TaskCreate's schema before recovering. + * The proxy tool can also be deferred behind ToolSearch, in which case + * "the task tool" is invisible while TaskCreate is not. Name the exact + * tool, the recovery path, and the failure mode. + */ +export const SUBAGENT_DISPATCH_HINT = `## opencode subagents + +Subagent dispatch in this environment goes through exactly two tools: \`mcp__opencode_proxy__task\` for one subagent and \`mcp__opencode_proxy__task_batch\` for two or more at once. + +- Two or more independent subagents in one response: make ONE \`mcp__opencode_proxy__task_batch\` call with a \`tasks\` array (each item is a normal task input). Claude Code runs MCP calls one at a time, so several \`mcp__opencode_proxy__task\` calls in the same response run serially; \`task_batch\` runs them concurrently in opencode and returns every result together, labelled in order. +- When the user mentions \`@\` or an instruction says "call the task tool with subagent: ", call \`mcp__opencode_proxy__task\` with \`subagent_type: ""\`. +- If that tool is not in your visible tool list it is deferred — load it with ToolSearch (\`select:mcp__opencode_proxy__task\`), then call it. +- Claude Code's built-in TaskCreate/TaskUpdate/TaskList manage a local todo list. They cannot dispatch subagents; creating a task there runs nothing. Never report a subagent as dispatched unless \`mcp__opencode_proxy__task\` returned its result. +- Do not verify a subagent's existence by searching config files — the tool's description lists the available agent types, and invalid types fail fast with a clear error.` + +/** + * Appended to the system prompt whenever the `question` proxy tool is + * enabled. Live testing (2026-07-05, haiku) showed the model's reasoning + * correctly identified `mcp__opencode_proxy__question` as the tool to use, + * but then emitted a tool call for bare `question` — stripping the MCP + * prefix. opencode's AI SDK bridge has no bare `question` tool, so the + * call rendered as `⚙ invalid`. Same near-miss pattern the task proxy + * hit (TaskCreate vs mcp__opencode_proxy__task); the fix is the same: + * name the exact tool in the system prompt so the model doesn't + * abbreviate. + */ +export const QUESTION_PROXY_HINT = `## Asking the operator questions + +Structured questions in this environment go through exactly one tool: \`mcp__opencode_proxy__question\`. + +- When you need to ask the operator a question with options, call \`mcp__opencode_proxy__question\` with a \`questions\` array (each item has \`question\`, \`header\`, \`options\` of \`{label, description}\`, and optional \`multiple\`). +- If that tool is not in your visible tool list it is deferred — load it with ToolSearch (\`select:mcp__opencode_proxy__question\`), then call it by its FULL name. +- Do NOT call bare \`question\` — that is not a tool. Always use the full \`mcp__opencode_proxy__question\` name when invoking it. +- Claude Code's built-in \`AskUserQuestion\` is disabled in this environment; the proxy is the only way to ask structured questions.` + +/** + * Prepended to every appended system prompt so Claude knows which + * context-management tools exist in the Claude CLI runtime versus a + * direct API provider. DCP and similar plugins forward compress/distill/ + * prune instructions via system.transform; those reach us through + * extractSystemMessages, but the tools themselves are not available in + * the CLI environment. Without this note Claude wastes thinking cycles + * searching for tools that don't exist. + */ +const CLAUDE_CLI_CONTEXT_NOTE = `## Runtime environment: Claude Code CLI + +You are running via the Claude Code CLI (not a direct API call). This affects context management: + +- The \`compress\` tool is NOT available. Do not attempt to call it. +- The \`distill\`, \`prune\`, and \`extract\` tools are NOT available. +- Context window management is handled automatically by Claude CLI's own session history. +- Ignore any system instructions that tell you to call \`compress\` — they are intended for direct API providers, not this environment. +- DCP context injections (AGENTS.md, dynamic state) arrive via the system prompt and are already applied.` + +/** + * Replaces the note above when `compress` is in the resolved proxy list. + * The full MCP name is spelled out for the same reason the question proxy + * hint spells its own out: models strip the prefix and call bare + * `compress`, which opencode renders as `⚙ invalid`. + */ +const CLAUDE_CLI_COMPRESS_NOTE = `## Runtime environment: Claude Code CLI + +You are running via the Claude Code CLI (not a direct API call). This affects context management: + +- To compress context, call \`mcp__opencode_proxy__compress\` with a \`summary\` argument. Use that exact full name. +- The reset happens at the start of your NEXT turn: this Claude Code session is discarded and a fresh one starts with your summary as its only prior context. Keep working normally after the call. +- Everything outside the summary is gone after the reset — tool output, files you read, and the earlier conversation are not replayed. Write the summary as the authoritative record. +- The \`distill\`, \`prune\`, and \`extract\` tools are NOT available. +- DCP context injections (AGENTS.md, dynamic state) arrive via the system prompt and are already applied.` + +/** + * Used when opencode's own `compress` tool is forwarded through the proxy + * (`proxyOpencodeTools: ["compress"]`) instead of the plugin's in-process + * one. The two shrink different windows and the difference has to be said + * out loud: opencode's rewrites opencode's transcript, so the live Claude + * Code session keeps everything it already had. A model told otherwise + * would assume detail it can still see had been discarded. + */ +const CLAUDE_CLI_OPENCODE_COMPRESS_NOTE = `## Runtime environment: Claude Code CLI + +You are running via the Claude Code CLI (not a direct API call). This affects context management: -export class ClaudeCodeLanguageModel implements LanguageModelV2 { - readonly specificationVersion = "v2" +- To compress context, call \`mcp__opencode_proxy__compress\`. Use that exact full name. It runs opencode's own \`compress\` tool, which is what a "MAX CONTEXT LIMIT REACHED" reminder is asking you to do. +- It compresses opencode's stored conversation, NOT this Claude Code session. Your current session keeps the context it already has, so do not assume earlier detail is gone after the call. +- The \`distill\`, \`prune\`, and \`extract\` tools are NOT available. +- DCP context injections (AGENTS.md, dynamic state) arrive via the system prompt and are already applied.` + +/** + * Extract text content from all `system`-role messages in the prompt. + * Standard API providers forward these as the `system` parameter; for + * Claude CLI, the only equivalent path is --append-system-prompt-file. + * Plugins like opencode-dcp inject AGENTS.md and other context via + * system-role messages and would otherwise be silently dropped. + */ +function extractSystemMessages( + prompt: LanguageModelV3CallOptions["prompt"], +): string[] { + const out: string[] = [] + for (const msg of prompt) { + if (msg.role !== "system") continue + if (typeof msg.content === "string") { + if (msg.content.trim()) out.push(msg.content.trim()) + } else if (Array.isArray(msg.content)) { + for (const part of msg.content as any[]) { + if ( + part?.type === "text" && + typeof part.text === "string" && + part.text.trim() + ) { + out.push(part.text.trim()) + } + } + } + } + return out +} + +export interface AppendedSystemPromptOptions { + /** True when the plugin's own `compress` def is in the proxy list. */ + compressEnabled?: boolean + /** True when opencode's `compress` tool is forwarded through the proxy. */ + opencodeCompressEnabled?: boolean + /** Summary from a previous `compress` call, if this key has one. */ + compressionSummary?: string +} + +export function buildAppendedSystemPrompt( + cwd: string, + includeMultiStepHint = true, + extraSystemContent: string[] = [], + options: AppendedSystemPromptOptions = {}, +): string | undefined { + const parts: string[] = [] + // First, so it reads as prior context for everything that follows. + if (options.compressionSummary?.trim()) { + parts.push( + `## Summary of earlier work (context was compressed)\n\n${options.compressionSummary.trim()}`, + ) + } + // The plugin's own compress wins when both are somehow live, matching the + // def-level precedence in resolveProxyOpencodeToolDefs: it is the one that + // holds the name, so it is the one the model would reach. + parts.push( + options.compressEnabled + ? CLAUDE_CLI_COMPRESS_NOTE + : options.opencodeCompressEnabled + ? CLAUDE_CLI_OPENCODE_COMPRESS_NOTE + : CLAUDE_CLI_CONTEXT_NOTE, + ) + for (const s of extraSystemContent) { + if (s.trim()) parts.push(s.trim()) + } + const configRoot = + process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config") + const globalAgents = readPromptFileIfPresent(join(configRoot, "opencode", "AGENTS.md")) + const workspaceAgents = nearestWorkspaceAgentsPrompt(cwd) + + // opencode already forwards AGENTS.md inside its own system prompt + // (`extraSystemContent`, under an "Instructions from:" header), so a + // disk-read copy would reach the model twice. Only push ours when the + // forwarded text does not already contain it. No match (formatting drift, + // or the interactive path, which forwards nothing) keeps the old behaviour, + // so AGENTS.md is never lost. (Dedup by @HeikoAtGitHub, 25260a4.) + const forwarded = extraSystemContent.join("\n\n") + const pushGlobal = !!globalAgents && !forwarded.includes(globalAgents) + const pushWorkspace = + !!workspaceAgents && workspaceAgents !== globalAgents && + !forwarded.includes(workspaceAgents) + if (pushGlobal) parts.push(globalAgents) + if (pushWorkspace) parts.push(workspaceAgents) + if (pushGlobal || pushWorkspace) parts.push(AGENTS_MAINTENANCE_HINT) + if (includeMultiStepHint) parts.push(MULTI_STEP_TASK_HINT) + + const content = parts.join("\n\n") + if (!content) return undefined + + const path = join(tmpdir(), `opencode-cc-sys-${randomUUID()}.md`) + try { + writeFileSync(path, content, "utf8") + return path + } catch (err) { + log.warn("failed to write system prompt file", { error: String(err) }) + return undefined + } +} + +/** + * Human-readable explanations for the CLI's `fast_mode_disabled_reason` codes, + * so a downgrade tells the user what to do instead of leaking an enum. + */ +const FAST_MODE_REASONS: Record = { + sdk_opt_in_required: + "the CLI did not receive the headless opt-in (--settings). This is a plugin bug, please report it", + extra_usage_disabled: + "your account has usage credits turned off. Run /usage-credits in an interactive `claude` session to enable them", + free: "fast mode requires a paid subscription or purchased credits", + preference: "fast mode is turned off for your organization", + model_not_allowed: + "this model is not in your organization's allowed models", + not_first_party: + "fast mode only works against the Anthropic API directly, not Bedrock / Vertex / Foundry", + network_error: "the CLI could not reach Anthropic to check availability", + disabled_by_env: "CLAUDE_CODE_DISABLE_FAST_MODE is set in the environment", + pending: "the CLI is still checking availability", +} + +/** Reasons already surfaced this process, so a persistent block warns once. */ +const warnedFastModeReasons = new Set() + +/** Test-only. */ +export function _resetFastModeWarnings(): void { + warnedFastModeReasons.clear() +} + +/** + * Report what actually happened to a fast-mode request. + * + * Fast mode fails soft: an ineligible account or a rate-limit cooldown drops + * back to standard speed with no error. That silence is the problem worth + * solving here: the fast model ids advertise 10x pricing in opencode's picker, + * so a downgrade the user cannot see means the picker is lying about cost for + * every subsequent turn. + * + * A hard block is therefore a WARN, which this codebase routes to the TUI + * unconditionally (NOTICE only surfaces in debug mode, which would defeat the + * purpose). It is deduped per reason per process because the blocking + * conditions are account-level and would otherwise repeat on every respawn. + * Cooldown stays quieter: it is transient and clears on its own. + */ +export function reportFastModeState( + msg: ClaudeStreamMessage, + requested: boolean, +): void { + const state = msg.fast_mode_state + if (!state) return + + if (!requested) { + // Nothing was asked for. Only interesting at debug level. + log.debug("fast mode state", { state }) + return + } + + if (state === "on") { + log.info("fast mode active", { state }) + return + } + + const reason = msg.fast_mode_disabled_reason + if (state === "cooldown") { + log.notice( + "fast mode is in cooldown after a rate limit; this turn runs at standard speed and is billed at standard Opus rates, not the 10x shown in the model picker.", + { state, reason: reason ?? null }, + ) + return + } + + const key = reason ?? "unknown" + const explanation = reason ? FAST_MODE_REASONS[reason] : undefined + const message = `fast mode was requested but is off${ + explanation ? `: ${explanation}` : reason ? ` (${reason})` : "" + }. Turns run at standard speed and are billed at standard Opus rates, not the 10x shown in the model picker. Switch to the non-fast model id to make the picker's price accurate.` + + if (warnedFastModeReasons.has(key)) { + log.debug(message, { state, reason: reason ?? null }) + return + } + warnedFastModeReasons.add(key) + log.warn(message, { state, reason: reason ?? null }) +} + +export class ClaudeCodeLanguageModel implements LanguageModelV3 { + readonly specificationVersion = "v3" readonly modelId: string private readonly config: ClaudeCodeConfig @@ -38,12 +991,614 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { return this.config.provider } + private toUsage(rawUsage?: ClaudeStreamMessage["usage"]): LanguageModelV3Usage { + // Prefer the last iteration's counters over cumulative totals. + // CLI usage is the sum across all internal tool-use iterations; + // using it directly inflates context size and triggers premature compaction. + const iter = rawUsage?.iterations + const effective = iter?.length ? iter[iter.length - 1] : rawUsage + // Claude CLI reports input_tokens as non-cached input only. + // OpenCode expects total = noCache + cacheRead + cacheWrite. + const noCache = effective?.input_tokens ?? 0 + const cacheRead = effective?.cache_read_input_tokens ?? 0 + const cacheWrite = effective?.cache_creation_input_tokens ?? 0 + return { + inputTokens: { + total: noCache + cacheRead + cacheWrite, + noCache, + cacheRead: cacheRead || undefined, + cacheWrite: cacheWrite || undefined, + }, + outputTokens: { + total: effective?.output_tokens, + text: effective?.output_tokens, + reasoning: undefined, + }, + raw: rawUsage as any, + } + } + + private toFinishReason( + reason: "stop" | "tool-calls" | "error" = "stop", + ): LanguageModelV3FinishReason { + return { + unified: reason, + raw: reason, + } + } + private requestScope(options: { tools?: unknown }): "tools" | "no-tools" { - return Array.isArray(options?.tools) ? "tools" : "no-tools" + const tools = options?.tools + if (Array.isArray(tools)) return "tools" + if (tools && typeof tools === "object") { + return Object.keys(tools as Record).length > 0 + ? "tools" + : "no-tools" + } + return "no-tools" + } + + /** + * Build the combined `--mcp-config` list and return both the list and the + * hash of the bridged opencode MCP block (or null when bridging is off / + * yields nothing). The hash is used to detect mid-session config changes + * and respawn the underlying claude process. + * + * `runtimeStatus` is a snapshot of opencode's `client.mcp.status()`. When + * provided it overlays opencode's UI-toggled state on top of disk config + * so `/mcps` toggles propagate without a config file write. + */ + private effectiveMcpConfig( + cwd: string, + proxyConfigPath?: string, + runtimeStatus?: RuntimeMcpStatus, + excludeServers?: ReadonlySet, + ): { + paths: string[] + bridgedHash: string | null + allEnabledServerNames: string[] + } { + const paths = Array.isArray(this.config.mcpConfig) + ? this.config.mcpConfig.slice() + : this.config.mcpConfig + ? [this.config.mcpConfig] + : [] + let bridgedHash: string | null = null + let allEnabledServerNames: string[] = [] + if (this.config.bridgeOpencodeMcp !== false) { + const bridged = bridgeOpencodeMcp(cwd, runtimeStatus, excludeServers) + if (bridged) { + if (bridged.path) paths.push(bridged.path) + bridgedHash = bridged.hash + allEnabledServerNames = bridged.allEnabledServerNames + } + } + if (proxyConfigPath) paths.push(proxyConfigPath) + return { paths, bridgedHash, allEnabledServerNames } + } + + /** Resolve ProxyToolDef[] for the configured proxyTools names. */ + private resolvedProxyTools(): ProxyToolDef[] | null { + const names = this.config.proxyTools + if (!names || names.length === 0) return null + const defsByName = new Map( + DEFAULT_PROXY_TOOLS.map((t) => [t.name.toLowerCase(), t]), + ) + const picked: ProxyToolDef[] = [] + const seen = new Set() + const unknown: string[] = [] + const pick = (def: ProxyToolDef) => { + if (seen.has(def.name)) return + seen.add(def.name) + picked.push(def) + } + for (const n of names) { + const def = defsByName.get(String(n).toLowerCase()) + if (!def) { + unknown.push(String(n)) + continue + } + pick(def) + // `task_batch` rides along with `task`: it is the same dispatch path for + // two or more subagents at once (TASK_BATCH_PROXY_NOTE), and a + // `proxyTools` list that names `Task` should not have to know it exists. + if (def.name === "task") { + const batch = defsByName.get(TASK_BATCH_TOOL_NAME) + if (batch) pick(batch) + } + } + // A typo used to vanish here. Silence is the wrong response: unknown + // names are not proxied, so the matching Claude built-in stays enabled + // and unmediated, and if *every* name is unknown the whole turn runs + // with no proxy at all (issue #26). + if (unknown.length > 0) { + const known = [...defsByName.keys()].join(", ") + if (picked.length === 0) { + log.warn( + "no proxyTools entry was recognised; nothing will be proxied this turn", + { unknown, known }, + ) + } else { + log.warn("ignoring unknown proxyTools entries", { unknown, known }) + } + } + return picked.length > 0 ? picked : null + } + + /** + * Resolve ProxyToolDef[] for opencode's MCP-backed tools so they go + * through the in-process proxy instead of being bridged into Claude CLI's + * `--mcp-config`. Routing through the proxy keeps a single execution site + * (opencode), so the call is permission-prompted and rendered as an + * opencode tool call. + * + * Opt-in (`proxyOpencodeMcpTools: true`) and off by default. It used to + * default to true while finding nothing, because it discovered tools via + * `client.tool.list()`, which enumerates opencode's `ToolRegistry` and not + * the MCP tools merged into the model's tool set afterwards. Discovery now + * reads that merged set, the `tools` array opencode passes `doStream`, so + * the option does what it says. Turning it on by default at the same time + * would have silently moved every existing user's MCP traffic off the + * working direct bridge, so the default went to false instead: today's + * behaviour is preserved exactly and crossing over is the operator's call. + * + * Returns null when the feature is off or nothing matched, which leaves + * every server on the direct bridge. + */ + private resolvedProxyMcpTools( + allEnabledServerNames: string[], + modelTools: readonly ModelToolEntry[] | undefined, + taken?: ReadonlySet, + ): McpProxyToolResolution | null { + if (this.config.proxyOpencodeMcpTools !== true) return null + if (this.config.bridgeOpencodeMcp === false) return null + if (allEnabledServerNames.length === 0) return null + + const resolution = resolveMcpProxyToolDefs({ + serverNames: allEnabledServerNames, + tools: modelTools, + taken, + }) + if (resolution.defs.length === 0) { + // WARN, not NOTICE: only warn and error are alwaysStderr in + // src/logger.ts, so a NOTICE would be invisible to the very operator + // who opted in and is entitled to know their MCP calls are still + // going direct, and so still are not permission-prompted by opencode. + log.warn( + "proxyOpencodeMcpTools is on but no MCP tool was found in opencode's" + + " tool set; those servers stay on the direct bridge this spawn", + { servers: allEnabledServerNames, modelTools: modelTools?.length ?? 0 }, + ) + return null + } + log.debug("routing opencode MCP tools through the proxy", { + servers: [...resolution.coveredServers], + tools: resolution.defs.map((def) => def.name), + }) + return resolution + } + + /** + * Live tool info derived from a single `client.tool.list()` fetch: + * + * - `taskDescription`: opencode's `task` tool description exactly as the + * registry renders it for native models, including the "Available + * agent types" list. Overlaid onto the static `task` proxy def so + * Claude sees the same subagent catalog native models see, instead + * of hunting through config files. + * - `questionDescription` / `hasQuestion`: opencode's `question` tool + * description and whether the registry has the entry at all. Older + * builds lack it, in which case a `mcp__opencode_proxy__question` + * call resolves to `⚙ invalid`; the version gate drops the def. + * + * Returns undefined/false when the SDK client is unavailable (direct + * AI-SDK use, tests) so the static defs stand. `resolved` distinguishes + * "the registry answered and has no `question` entry" from "nobody + * answered": only the former is a real version-gate signal. + */ + private async fetchLiveToolInfo(): Promise { + const items = await fetchOpencodeToolList( + this.config.provider, + this.modelId, + this.config.cwd, + ) + const question = items?.find((item) => item.id === "question") + return { + resolved: items !== undefined, + taskDescription: items?.find((item) => item.id === "task")?.description, + questionDescription: question?.description, + hasQuestion: !!question, + items, + } + } + + /** + * Whether dcp-style context reminders should be stripped from this turn's + * messages. Config-only and synchronous, so it can be answered before the + * spawn block resolves anything: `userMsg` is built well ahead of it. + */ + private stripContextRemindersEnabled(): boolean { + return shouldStripContextReminders({ + enabled: this.config.stripContextReminders, + proxyTools: this.config.proxyTools, + proxyOpencodeTools: this.config.proxyOpencodeTools, + }) + } + + /** Share one lazy registry request within a turn without making it stale. */ + private createLiveToolInfoLoader(): () => Promise { + let pending: Promise | undefined + return () => { + pending ??= this.fetchLiveToolInfo() + return pending + } + } + + /** + * Whether the ExitPlanMode approval bridge is live for this turn: the + * operator opted in AND opencode's registry actually has the `question` + * tool. Without the registry entry the emitted tool-call would render as + * `⚙ invalid` and wedge the turn, so the plugin keeps the text path. + */ + private async resolvePlanModeQuestion( + compactionMode: boolean, + loadLiveToolInfo = () => this.fetchLiveToolInfo(), + ): Promise { + if (compactionMode || this.config.planModeQuestion !== true) return false + const info = await loadLiveToolInfo() + const active = isPlanModeQuestionActive({ + configured: this.config.planModeQuestion, + opencodeHasQuestion: info.hasQuestion, + compactionMode, + }) + if (!active) { + // Same reasoning as the question proxy's version-gate log: a silent + // fallback to the text path looks from the outside like the setting + // was ignored. + log.info("plan-mode question gate", { + opencodeHasQuestion: info.hasQuestion, + registryResolved: info.resolved, + active, + }) + } + return active + } + + /** + * Create a proxy MCP server for a single active Claude process/session. + * The process lifecycle owns the server lifecycle via session-manager. + */ + private async ensureProxyServer( + tools: ProxyToolDef[], + sessionKeyForCalls: string, + // Whether the `compress` in `tools` is the PLUGIN's def rather than + // opencode's forwarded one. Keying the interceptor on the name alone + // would answer a forwarded `compress` in-process and opencode would + // never see the call: the same name, the wrong tool, silently. The + // caller knows which list the def came from, so it decides. + interceptCompress: boolean, + ): Promise { + const timeoutOverrides = this.config.proxyToolTimeoutMs + const interceptors = new Map() + if (interceptCompress && tools.some((t) => t.name === "compress")) { + interceptors.set("compress", (input) => { + const summary = typeof input.summary === "string" ? input.summary.trim() : "" + if (!summary) { + return { + kind: "error", + message: + "compress needs a non-empty `summary`: it becomes the only" + + " prior context after the reset. Nothing was compressed.", + } + } + storeCompressionSummary(sessionKeyForCalls, summary) + log.info("compress stored summary; session resets next turn", { + sessionKey: sessionKeyForCalls, + summaryLength: summary.length, + }) + return { + kind: "text", + text: + "Summary stored. Finish this turn as normal; the next turn starts" + + " a fresh Claude Code session with this summary as its only prior" + + " context.", + } + }) + } + const srv = await createProxyMcpServer(tools, timeoutOverrides, interceptors) + srv.calls.on("call", (call: ProxyToolCall) => { + queuePendingProxyCall(sessionKeyForCalls, call, timeoutOverrides) + }) + return srv + } + + private extractPendingProxyResult( + prompt: LanguageModelV3CallOptions["prompt"], + toolCallId: string, + ): ProxyToolResult | null { + for (let i = prompt.length - 1; i >= 0; i--) { + const msg = prompt[i] + if (msg.role !== "tool" || !Array.isArray(msg.content)) continue + + for (const part of msg.content) { + if (part.type !== "tool-result" || part.toolCallId !== toolCallId) continue + + const output = part.output as any + if (!output || typeof output !== "object") { + return { + kind: "text", + text: String(output ?? ""), + } + } + + if (output.type === "text") { + return { + kind: "text", + text: String(output.value ?? ""), + } + } + + if (output.type === "json") { + return { + kind: "text", + text: JSON.stringify(output.value), + } + } + + if (output.type === "content" && Array.isArray(output.value)) { + const text = output.value + .filter((v: any) => v?.type === "text" && typeof v.text === "string") + .map((v: any) => v.text) + .join("\n") + return { + kind: "text", + text, + } + } + + return { + kind: "text", + text: JSON.stringify(output), + } + } + } + + return null + } + + /** + * The result opencode produced for a pending proxy call, if the prompt + * carries it. For `task_batch` that means every child's result gathered + * back onto the parent: opencode runs the children in one step and hands + * all their results to the next call together, so a partial set is not + * expected. If it ever happens the batch still resolves, with the gap + * named in the text, because leaving the parent pending would send this + * turn down the fresh-envelope path and reject the call as orphaned. + */ + private extractPendingProxyResultForCall( + prompt: LanguageModelV3CallOptions["prompt"], + call: PendingProxyCall, + ): ProxyToolResult | null { + if (call.toolName !== TASK_BATCH_TOOL_NAME) { + return this.extractPendingProxyResult(prompt, call.toolCallId) + } + const tasks = taskBatchTasks(call.input) + if (tasks.length === 0) { + return { kind: "error", message: "task_batch input is not a list of task objects" } + } + const children = tasks.map((task, index) => ({ + task, + result: this.extractPendingProxyResult( + prompt, + taskBatchChildToolCallId(call.toolCallId, index), + ), + })) + const answered = children.filter((child) => child.result !== null).length + if (answered === 0) return null + if (answered < children.length) { + log.warn("task_batch resolving with child results missing", { + toolCallId: call.toolCallId, + answered, + total: children.length, + }) + } + return formatTaskBatchResults(children) + } + + /** + * Resolve the session affinity token for this LLM call. Delegates to the + * exported `resolveSessionAffinity` helper so the logic is unit-testable. + * Priority: + * 1. `x-session-affinity` request header (primary). + * 2. `opencodeSessionID` in providerOptions (chat.params hook fallback — + * covers provider switches mid-session and title synthesis paths + * where the header is absent). + * 3. `"default"`. + */ + private sessionAffinity( + options: LanguageModelV3CallOptions, + ): string { + const headers = (options as any)?.headers as + | Record + | undefined + return resolveSessionAffinity( + headers, + options.providerOptions as Record | undefined, + this.config.provider, + ) + } + + private controlRequestBehaviorForTool(toolName: string): ControlRequestBehavior { + const configured = this.config.controlRequestToolBehaviors + if (configured && toolName) { + const direct = configured[toolName] ?? configured[toolName.toLowerCase()] + if (direct === "allow" || direct === "deny") return direct + + const lower = toolName.toLowerCase() + for (const [key, behavior] of Object.entries(configured)) { + if (key.toLowerCase() === lower && (behavior === "allow" || behavior === "deny")) { + return behavior + } + } + } + + // AskUserQuestion must never be auto-allowed. Allowing it lets the + // Claude CLI resolve its own question internally — in headless mode + // there is no TTY, so the CLI fabricates/empties the answer and the + // model proceeds on a guess. Deny so the CLI cannot self-answer; the + // tool_use is still streamed and rendered to the opencode user by + // formatAskUserQuestion, and the turn stops for a real reply. An + // explicit controlRequestToolBehaviors entry above can still override. + if (isAskUserQuestionTool(toolName)) return "deny" + + return this.config.controlRequestBehavior ?? "allow" + } + + private writeControlResponse( + proc: import("child_process").ChildProcess, + requestId: string, + response?: Record, + ): void { + const payload = { + type: "control_response", + response: { + subtype: "success", + request_id: requestId, + response, + }, + } + + try { + proc.stdin?.write(JSON.stringify(payload) + "\n") + } catch (error) { + log.warn("failed to write control response", { + requestId, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + /** + * Handle Claude stream-json control requests (`can_use_tool`, etc.) and + * respond via stdin with a matching `control_response`. + */ + private handleControlRequest( + msg: ClaudeStreamMessage, + proc: import("child_process").ChildProcess, + ): boolean { + if (msg.type !== "control_request") return false + const requestId = msg.request_id + const request = msg.request + if (!requestId || !request?.subtype) return false + + if (request.subtype === "can_use_tool") { + const toolName = request.tool_name ?? "unknown" + const behavior = this.controlRequestBehaviorForTool(toolName) + + if (behavior === "allow") { + this.writeControlResponse(proc, requestId, { + behavior: "allow", + updatedInput: request.input ?? {}, + toolUseID: request.tool_use_id, + }) + log.info("control request auto-allowed", { + requestId, + toolName, + }) + } else { + const denyMessage = denyMessageForTool( + toolName, + this.config.controlRequestDenyMessage, + ) + this.writeControlResponse(proc, requestId, { + behavior: "deny", + message: denyMessage, + toolUseID: request.tool_use_id, + }) + log.info("control request auto-denied", { + requestId, + toolName, + }) + } + + return true + } + + // For control request subtypes we don't actively handle yet, acknowledge + // with an empty success so the CLI stream does not stall. + this.writeControlResponse(proc, requestId, {}) + log.debug("control request acknowledged", { + requestId, + subtype: request.subtype, + }) + return true + } + + private getReasoningEffort( + providerOptions?: LanguageModelV3CallOptions["providerOptions"], + ): ReasoningEffort | undefined { + if (!providerOptions) return undefined + const ownKey = this.config.provider + const bag = + (providerOptions as any)[ownKey] ?? + (providerOptions as any)["claude-code"] + const effort = bag?.reasoningEffort + const valid: ReasoningEffort[] = [ + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ] + return valid.includes(effort) ? effort : undefined + } + + private getOpencodeAgent( + providerOptions?: LanguageModelV3CallOptions["providerOptions"], + ): string | undefined { + if (!providerOptions) return undefined + const ownKey = this.config.provider + const bag = + (providerOptions as any)[ownKey] ?? + (providerOptions as any)["claude-code"] + const agent = bag?.opencodeAgent + return typeof agent === "string" ? agent : undefined + } + + private isCompactionCall( + options: LanguageModelV3CallOptions, + ): boolean { + return this.getOpencodeAgent(options.providerOptions) === "compaction" + } + + /** + * Pick the model used to handle /compact. Precedence: + * 1. `CLAUDE_CODE_COMPACTION_MODEL` env var (per-process override) + * 2. `compactionModel` provider setting (opencode.json / .jsonc) + * 3. Built-in default (claude-haiku-4-5) + */ + private resolveCompactionModel(): string { + return resolveCompactionModel(this.config.compactionModel) + } + + private thinkingCliOptions(): { + thinking?: "enabled" + thinkingDisplay?: "summarized" + } { + if (isClaudeThinkingDisabled()) return {} + + return { + thinking: "enabled", + thinkingDisplay: + process.env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES === undefined + ? "summarized" + : undefined, + } } private latestUserText( - prompt: Parameters[0]["prompt"], + prompt: LanguageModelV3CallOptions["prompt"], ): string { for (let i = prompt.length - 1; i >= 0; i--) { const msg = prompt[i] @@ -67,7 +1622,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } private synthesizeTitle( - prompt: Parameters[0]["prompt"], + prompt: LanguageModelV3CallOptions["prompt"], ): string { const source = this.latestUserText(prompt) .replace(/\s+/g, " ") @@ -130,24 +1685,137 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { return picked || "New Session" } + private async doGenerateViaStream( + options: LanguageModelV3CallOptions, + ): Promise>> { + const result = await this.doStream(options) + const reader = result.stream.getReader() + + let text = "" + let reasoning = "" + const toolCalls: LanguageModelV3Content[] = [] + let finishReason = this.toFinishReason("stop") + let usage: LanguageModelV3Usage = this.toUsage() + let providerMetadata: any + + while (true) { + const { value, done } = await reader.read() + if (done) break + + switch ((value as any).type) { + case "text-delta": + text += (value as any).delta ?? "" + break + case "reasoning-delta": + reasoning += (value as any).delta ?? "" + break + case "tool-call": + toolCalls.push({ + type: "tool-call", + toolCallId: (value as any).toolCallId, + toolName: (value as any).toolName, + input: (value as any).input, + providerExecuted: (value as any).providerExecuted, + } as any) + break + case "finish": + finishReason = (value as any).finishReason ?? finishReason + usage = (value as any).usage ?? usage + providerMetadata = (value as any).providerMetadata ?? providerMetadata + break + } + } + + const content: LanguageModelV3Content[] = [] + if (reasoning) { + content.push({ type: "reasoning", text: reasoning } as any) + } + if (text) { + content.push({ type: "text", text, providerMetadata } as any) + } + content.push(...toolCalls) + + return { + content, + finishReason, + usage, + request: result.request, + response: { + id: generateId(), + timestamp: new Date(), + modelId: this.modelId, + }, + providerMetadata, + warnings: [], + } + } + async doGenerate( - options: Parameters[0], - ): Promise>> { - const warnings: LanguageModelV2CallWarning[] = [] - const cwd = this.config.cwd ?? process.cwd() + options: LanguageModelV3CallOptions, + ): Promise>> { + if (!this.isCompactionCall(options) && this.requestScope(options as any) !== "no-tools" && parseSideQuestion(options.prompt)) { + return this.doGenerateViaStream(options) + } + const warnings: SharedV3Warning[] = [] const scope = this.requestScope(options as any) - const sk = sessionKey(cwd, `${this.modelId}::${scope}`) + const affinity = this.sessionAffinity(options) + const cwd = await resolveSpawnCwdForSession(this.config.cwd, affinity) + // An agent may run on a different model than the one opencode routed here + // (see agent-models.ts). The session key must carry the effective model or + // an overridden agent shares a claude process with its caller. + const effectiveModelId = resolveAgentModel( + this.getOpencodeAgent(options.providerOptions), + this.modelId, + ) + const reasoningEffort = resolveAgentEffort( + this.getOpencodeAgent(options.providerOptions), + this.getReasoningEffort(options.providerOptions), + ) as ReasoningEffort | undefined + // Keep effort invalidation inside one agent/provider, even when callers + // share a model and opencode session (for example switching agents). + const baseKey = sessionKey( + cwd, + `${effectiveModelId}::${scope}::${affinity}::context=${JSON.stringify([this.config.provider, this.getOpencodeAgent(options.providerOptions) ?? null])}`, + ) + const sk = effortSessionKey(baseKey, reasoningEffort) + + // When selective proxying is enabled, doGenerate must not bypass the + // proxy path. Reuse doStream and aggregate its events so proxied tools + // still route through opencode permissions/execution. Same for + // opencode MCP proxying — doStream is the only path that wires up the + // proxy server with the dynamically-discovered MCP tool defs. + const compactionMode = this.isCompactionCall(options) + + if ( + scope === "tools" && + (this.resolvedProxyTools() || + (this.config.proxyOpencodeMcpTools === true && + this.config.bridgeOpencodeMcp !== false)) + ) { + return this.doGenerateViaStream(options) + } + + // Route compaction through doStream so it gets the lean spawn path, + // model override, and rich transcript handling. Aggregating a stream + // for doGenerate matches what doGenerateViaStream already does for + // proxy tools. + if (compactionMode) { + return this.doGenerateViaStream(options) + } if (scope === "no-tools") { + log.info("doGenerate no-tools title stub", { + compactionMode, + opencodeAgent: this.getOpencodeAgent(options.providerOptions), + providerOptionsKeys: options.providerOptions + ? Object.keys(options.providerOptions) + : [], + }) const text = this.synthesizeTitle(options.prompt) return { content: [{ type: "text", text }] as any, - finishReason: "stop", - usage: { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - }, + finishReason: this.toFinishReason("stop"), + usage: this.toUsage({ input_tokens: 0, output_tokens: 0 }), request: { body: { text: "" } }, response: { id: generateId(), @@ -164,32 +1832,116 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } } + // Short-circuit when opencode iterates the agent loop one more time + // after a turn already finished. The prompt ends with an assistant + // message and has no fresh user input — spawning Claude here would + // just produce a stub like "No input received. Standing by". + if (!hasNewUserContent(options.prompt)) { + log.info("doGenerate short-circuit: no new user content") + return { + content: [], + finishReason: this.toFinishReason("stop"), + usage: this.toUsage({ input_tokens: 0, output_tokens: 0 }), + request: { body: { text: "" } }, + response: { + id: generateId(), + timestamp: new Date(), + modelId: this.modelId, + }, + providerMetadata: { + "claude-code": { synthetic: true, path: "no-new-user-content" }, + }, + warnings, + } + } + + invalidateOtherEffortSessions(baseKey, reasoningEffort) + const hasPriorConversation = options.prompt.filter((m) => m.role === "user" || m.role === "assistant") .length > 1 - // New session — clear any stale state from a previous session + // New session — clear any stale state from a previous session. + // A compression summary is scoped to one conversation, so this is the + // one place it is dropped: the compress restart itself calls + // deleteClaudeSessionId, and clearing there would wipe the summary + // just before the fresh spawn reads it. if (!hasPriorConversation) { deleteClaudeSessionId(sk) deleteActiveProcess(sk) + clearCompression(sk) } const hasExistingSession = !!getClaudeSessionId(sk) const includeHistoryContext = !hasExistingSession && hasPriorConversation - const userMsg = getClaudeUserMessage(options.prompt, includeHistoryContext) + const userMsg = + consumeExitPlanModeQuestionResult(sk, options.prompt as any) ?? + // doGenerate has no proxy wiring, so this process issued no tool calls + // at all: every tool result reaching it belongs to opencode and must be + // rendered as text rather than an orphaned `tool_result` (issue #29). + getClaudeUserMessage(options.prompt, includeHistoryContext, { + cliToolCallIds: new Set(), + stripContextReminders: this.stripContextRemindersEnabled(), + }) + + // The same account override doStream applies, with no dialog of its own: + // a title or no-tools call must not ask anything, but it must follow the + // account the conversation was moved to, or it bills the limited one. + const failover = await resolveFailoverSpawn({ + account: this.config.account ?? DEFAULT_ACCOUNT, + baseCliPath: this.config.baseCliPath ?? this.config.cliPath, + cliPath: this.config.cliPath, + modelId: effectiveModelId, + }) + const cliPath = failover.cliPath - // doGenerate always spawns a fresh process, never reuse session ID + // doGenerate always spawns a fresh process, never reuse session ID. + // Pre-fetch opencode's MCP runtime status so the bridge overlays + // UI-toggled state on top of disk config. + const [runtimeStatus, cliVersion, planModeQuestionActive] = await Promise.all([ + getRuntimeMcpStatus(), + detectCliVersion(cliPath), + this.resolvePlanModeQuestion(compactionMode), + ]) + const systemPromptFile = buildAppendedSystemPrompt( + cwd, + this.config.multiStepContinuation !== false, + extractSystemMessages(options.prompt), + // doGenerate has no proxy wiring, so `compress` is not callable here. + // An existing summary still carries: it is this key's prior context. + { compressEnabled: false, compressionSummary: getCompressionSummary(sk) }, + ) + const { model: spawnModelId, fast: fastMode } = parseModelId(failover.modelId) + // The same skill bridge as doStream's spawn: Claude's Skill tool is the + // only way a Claude-routed turn can load an opencode skill, on this path + // as much as on the streaming one. + const skillPluginDirs = await resolveSkillPluginDirs({ + cwd, + cliPath, + enabled: this.config.bridgeOpencodeSkills === true, + }) const cliArgs = buildCliArgs({ sessionKey: sk, skipPermissions: this.config.skipPermissions !== false, includeSessionId: false, - model: this.modelId, + model: spawnModelId, + permissionMode: this.config.permissionMode, + mcpConfig: this.effectiveMcpConfig(cwd, undefined, runtimeStatus).paths, + strictMcpConfig: this.config.strictMcpConfig, + disallowedTools: + this.config.webSearch === "disabled" ? ["WebSearch"] : undefined, + appendSystemPromptFile: systemPromptFile, + pluginDirs: skillPluginDirs, + ...this.thinkingCliOptions(), + fastMode, + cliVersion, }) log.info("doGenerate starting", { cwd, - model: this.modelId, + model: effectiveModelId, + requestedModel: this.modelId, textLength: userMsg.length, includeHistoryContext, }) @@ -197,12 +1949,22 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { const { spawn } = await import("node:child_process") const { createInterface } = await import("node:readline") - const proc = spawn(this.config.cliPath, cliArgs, { + const proc = spawn(cliPath, cliArgs, { cwd, stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, TERM: "xterm-256color" }, + env: claudeSpawnEnv({ + ignoreAnthropicApiKey: this.config.ignoreAnthropicApiKey, + effort: reasoningEffort, + }), + shell: process.platform === "win32", }) + if (systemPromptFile) { + proc.on("exit", () => { + void unlink(systemPromptFile).catch(() => {}) + }) + } + const rl = createInterface({ input: proc.stdout! }) let responseText = "" @@ -211,9 +1973,27 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { sessionId?: string costUsd?: number durationMs?: number + durationApiMs?: number + numTurns?: number usage?: ClaudeStreamMessage["usage"] + modelUsage?: ClaudeStreamMessage["modelUsage"] + permissionDenials?: ClaudeStreamMessage["permission_denials"] } = {} const toolCalls: Array<{ id: string; name: string; args: unknown }> = [] + // Streaming tool_use entries keyed by content-block index. We accumulate + // partial_json chunks here instead of trying to JSON.parse each chunk + // independently, and flush to `toolCalls` at content_block_stop. The + // previous code indexed `toolCalls` by `msg.index` directly, which is + // wrong whenever non-tool blocks (text, thinking) precede a tool_use. + const toolCallStreams = new Map< + number, + { id: string; name: string; inputJson: string } + >() + + // Set true once we observe a `stream_event` envelope. When on, the + // top-level `assistant` message is a duplicate of content already + // accumulated via the inner content_block_* events — skip it. + let gotPartialEvents = false const result = await new Promise< typeof resultMeta & { @@ -222,18 +2002,52 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { toolCalls: typeof toolCalls } >((resolve, reject) => { + const cleanup = () => { + try { + if (!proc.killed && proc.exitCode === null) proc.kill() + } catch {} + } + rl.on("line", (line) => { if (!line.trim()) return try { - const msg: ClaudeStreamMessage = JSON.parse(line) + const outer: ClaudeStreamMessage = JSON.parse(line) + + // Unwrap stream_event envelope (--include-partial-messages). + // Inner event uses the same content_block_* / message_* shape. + const msg: ClaudeStreamMessage = + outer.type === "stream_event" && outer.event + ? { ...outer.event, session_id: outer.session_id } + : outer + + if (outer.type === "stream_event") { + gotPartialEvents = true + } + + if (this.handleControlRequest(msg, proc)) { + return + } if (msg.type === "system" && msg.subtype === "init") { if (msg.session_id) { setClaudeSessionId(sk, msg.session_id) } + reportFastModeState(msg, fastMode) + reportSystemInit(msg, { + ignoreAnthropicApiKey: this.config.ignoreAnthropicApiKey, + }) } - if (msg.type === "assistant" && msg.message?.content) { + if (msg.type === "rate_limit_event") { + reportRateLimitEvent(msg) + return + } + + if ( + msg.type === "assistant" && + msg.message?.content && + !gotPartialEvents + ) { for (const block of msg.message.content) { if (block.type === "text" && block.text) { responseText += block.text @@ -242,18 +2056,14 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { thinkingText += block.thinking } if (block.type === "tool_use" && block.id && block.name) { - if ( - block.name === "AskUserQuestion" || - block.name === "ask_user_question" - ) { - // Emit question as text + if (isAskUserQuestionTool(block.name)) { + // Render the full question + options as visible text so + // the user can actually see and answer it. const parsedInput = (block.input ?? {}) as Record< string, unknown > - const question = - (parsedInput?.question as string) || "Question?" - responseText += `\n\n_Asking: ${question}_\n\n` + responseText += formatAskUserQuestion(parsedInput) continue } @@ -263,6 +2073,20 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { unknown > const plan = (parsedInput?.plan as string) || "" + if (planModeQuestionActive) { + const questionCall = createExitPlanModeQuestionCall( + sk, + block.id, + plan, + ) + responseText += questionCall.text + toolCalls.push({ + id: questionCall.toolCallId, + name: questionCall.toolName, + args: questionCall.input, + }) + continue + } responseText += `\n\n${plan}\n\n---\n**Do you want to proceed with this plan?** (yes/no)\n` continue } @@ -276,21 +2100,29 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } } - if (msg.type === "content_block_start" && msg.content_block) { + if ( + msg.type === "content_block_start" && + msg.content_block && + msg.index !== undefined + ) { if ( msg.content_block.type === "tool_use" && msg.content_block.id && msg.content_block.name ) { - toolCalls.push({ + toolCallStreams.set(msg.index, { id: msg.content_block.id, name: msg.content_block.name, - args: {}, + inputJson: "", }) } } - if (msg.type === "content_block_delta" && msg.delta) { + if ( + msg.type === "content_block_delta" && + msg.delta && + msg.index !== undefined + ) { if (msg.delta.type === "text_delta" && msg.delta.text) { responseText += msg.delta.text } @@ -299,17 +2131,39 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } if ( msg.delta.type === "input_json_delta" && - msg.delta.partial_json && - msg.index !== undefined + msg.delta.partial_json ) { - const tc = toolCalls[msg.index] - if (tc) { - try { - tc.args = JSON.parse(msg.delta.partial_json) - } catch { - // Partial JSON, accumulate - } + const tc = toolCallStreams.get(msg.index) + if (tc) tc.inputJson += msg.delta.partial_json + } + } + + if (msg.type === "content_block_stop" && msg.index !== undefined) { + const tc = toolCallStreams.get(msg.index) + if (tc) { + let args: unknown = {} + try { + args = tc.inputJson ? JSON.parse(tc.inputJson) : {} + } catch (err) { + log.warn("tool input JSON parse failed", { + name: tc.name, + error: String(err), + }) } + if (tc.name === "ExitPlanMode" && planModeQuestionActive) { + const parsedInput = args as Record + const plan = (parsedInput?.plan as string) || "" + const questionCall = createExitPlanModeQuestionCall(sk, tc.id, plan) + responseText += questionCall.text + toolCalls.push({ + id: questionCall.toolCallId, + name: questionCall.toolName, + args: questionCall.input, + }) + } else { + toolCalls.push({ id: tc.id, name: tc.name, args }) + } + toolCallStreams.delete(msg.index) } } @@ -317,12 +2171,39 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { if (msg.session_id) { setClaudeSessionId(sk, msg.session_id) } + + // Some CLI failures only surface user-readable text on the final + // `result` message (without prior assistant text blocks). Preserve + // that so callers don't receive an empty response. + if ( + !responseText && + msg.is_error && + typeof msg.result === "string" && + msg.result.trim().length > 0 + ) { + responseText = msg.result + } + resultMeta = { sessionId: msg.session_id, costUsd: msg.total_cost_usd, durationMs: msg.duration_ms, + durationApiMs: msg.duration_api_ms, + numTurns: msg.num_turns, usage: msg.usage, + modelUsage: msg.modelUsage, + permissionDenials: msg.permission_denials?.map((denial) => ({ + tool_name: denial.tool_name, + tool_use_id: denial.tool_use_id, + })), } + log.info("conversation result", { + sessionId: msg.session_id, + isError: msg.is_error, + subtype: msg.subtype, + ...turnStatsLogPayload(extractTurnStats(msg)), + }) + cleanup() resolve({ ...resultMeta, text: responseText, @@ -336,6 +2217,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { }) rl.on("close", () => { + cleanup() resolve({ ...resultMeta, text: responseText, @@ -346,6 +2228,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { proc.on("error", (err) => { log.error("process error", { error: err.message }) + cleanup() reject(err) }) @@ -356,7 +2239,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { proc.stdin?.write(userMsg + "\n") }) - const content: LanguageModelV2Content[] = [] + const content: LanguageModelV3Content[] = [] if (result.thinking) { content.push({ @@ -375,17 +2258,40 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { costUsd: result.costUsd ?? null, durationMs: result.durationMs ?? null, }, + ...(typeof result.usage?.cache_creation_input_tokens === "number" + ? { + anthropic: { + cacheCreationInputTokens: + result.usage.cache_creation_input_tokens, + }, + } + : {}), }, }) } for (const tc of result.toolCalls) { + if (tc.name === QUESTION_TOOL_NAME) { + content.push({ + type: "tool-call", + toolCallId: tc.id, + toolName: tc.name, + input: JSON.stringify(tc.args), + providerExecuted: false, + } as any) + continue + } + const { name: mappedName, input: mappedInput, executed, skip, - } = mapTool(tc.name, tc.args) + } = mapTool(tc.name, tc.args, { + webSearch: this.config.webSearch, + sessionId: getClaudeSessionId(sk), + toolUseId: tc.id, + }) if (skip) continue content.push({ type: "tool-call", @@ -396,20 +2302,19 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } as any) } - const usage: LanguageModelV2Usage = { - inputTokens: result.usage?.input_tokens, - outputTokens: result.usage?.output_tokens, - totalTokens: - result.usage?.input_tokens && result.usage?.output_tokens - ? result.usage.input_tokens + result.usage.output_tokens - : undefined, - } + const usage = this.toUsage(result.usage) return { content, - finishReason: (result.toolCalls.length > 0 - ? "tool-calls" - : "stop") as LanguageModelV2FinishReason, + // Claude CLI's `result` message normally signals a fully-completed turn: + // tools have already been executed internally and final assistant text + // has been produced. ExitPlanMode is the exception: we surface it as + // opencode's native question tool so the outer loop must run that tool. + finishReason: this.toFinishReason( + result.toolCalls.some((tc) => tc.name === QUESTION_TOOL_NAME) + ? "tool-calls" + : "stop", + ), usage, request: { body: { text: userMsg } }, response: { @@ -423,139 +2328,1566 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { costUsd: result.costUsd ?? null, durationMs: result.durationMs ?? null, }, + ...(typeof result.usage?.cache_creation_input_tokens === "number" + ? { + anthropic: { + cacheCreationInputTokens: + result.usage.cache_creation_input_tokens, + }, + } + : {}), }, warnings, } } async doStream( - options: Parameters[0], - ): Promise>> { - const warnings: LanguageModelV2CallWarning[] = [] - const cwd = this.config.cwd ?? process.cwd() - const cliPath = this.config.cliPath + options: LanguageModelV3CallOptions, + ): Promise>> { + const warnings: SharedV3Warning[] = [] const skipPermissions = this.config.skipPermissions !== false const scope = this.requestScope(options as any) - const sk = sessionKey(cwd, `${this.modelId}::${scope}`) + const affinity = this.sessionAffinity(options) + const cwd = await resolveSpawnCwdForSession(this.config.cwd, affinity) + const compactionMode = this.isCompactionCall(options) + // Use a separate session key for compaction so its short-lived spawn + // never collides with the main conversation's claude process. + const effectiveModelId = compactionMode + ? this.resolveCompactionModel() + : resolveAgentModel( + this.getOpencodeAgent(options.providerOptions), + this.modelId, + ) + // Compaction skips request/agent effort overrides; other calls key on it. + const reasoningEffort = compactionMode + ? undefined + : (resolveAgentEffort( + this.getOpencodeAgent(options.providerOptions), + this.getReasoningEffort(options.providerOptions), + ) as ReasoningEffort | undefined) + const baseKey = sessionKey( + cwd, + `${effectiveModelId}::${scope}::${affinity}::context=${JSON.stringify([this.config.provider, this.getOpencodeAgent(options.providerOptions) ?? null])}`, + ) + const sk = compactionMode + ? sessionKey(cwd, `${effectiveModelId}::compaction::${affinity}`) + : effortSessionKey(baseKey, reasoningEffort) + const toUsage = this.toUsage.bind(this) + const toFinishReason = this.toFinishReason.bind(this) + const handleControlRequest = this.handleControlRequest.bind(this) + const flagOn = (v: string | undefined) => + v !== undefined && + !["", "0", "false", "no", "off"].includes(v.trim().toLowerCase()) + // Interactive (subscription) transport: drive the claude TUI over Bun's + // native ConPTY + JSONL tail instead of headless `--print` stream-json. + // Prefer the provider option (config-driven, reliable in the GUI app where + // process env vars are not inherited); fall back to the env var. Self-healing: + // if Bun.Terminal is unavailable (e.g. not under Bun), use the headless path. + const interactivePref = + this.config.interactive ?? + flagOn(process.env.CLAUDE_CODE_INTERACTIVE_TRANSPORT) + const useInteractive = + interactivePref && typeof (globalThis as any).Bun?.Terminal === "function" + const interactiveBypassRequested = + this.config.interactiveBypass ?? + flagOn(process.env.CLAUDE_CODE_INTERACTIVE_BYPASS) - if (scope === "no-tools") { + // Account failover. When a previous turn hit this account's usage limit + // and the operator picked another account, every turn from then on spawns + // that account's wrapper instead, until the limit's reset time. The + // override is keyed on the ACCOUNT, so it covers every session running on + // it, subagents included. Resolved here, before anything reads `cliPath`. + // + // Excluded for the interactive transport, which drives a TUI over a PTY + // with no proxy server: nothing in that path can show the form or replay + // the conversation, so it keeps the plain rate-limit error. + const sourceAccount = normalizeAccountName( + this.config.account ?? DEFAULT_ACCOUNT, + ) + const baseCliPath = this.config.baseCliPath ?? this.config.cliPath + let failover: FailoverSpawn = + useInteractive || compactionMode + ? { cliPath: this.config.cliPath, modelId: effectiveModelId, failedOver: false } + : await resolveFailoverSpawn({ + account: sourceAccount, + baseCliPath, + cliPath: this.config.cliPath, + modelId: effectiveModelId, + }) + let cliPath = failover.cliPath + + // Tagged onto the process each turn so the /btw command hook, which only + // knows the opencode session id, can find it and ask it early + // (btw-command.ts). + const asideTransportRef = { cliPath, interactive: !!useInteractive } + + // `/claude-code-doctor` is answered here, by the plugin, with no CLI + // inference at all: everything in the report is already in this process. + // Same shape as the aside branch below, and the exchange is stripped from + // rebuilt transcripts the same way a `/btw` pair is. + const doctor = + !compactionMode && scope !== "no-tools" ? parseDoctorCommand(options.prompt) : null + if (doctor) { + const doctorOptions = { + cliPath, + interactive: !!useInteractive, + turnStats: this.config.turnStats === true, + } + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue({ type: "stream-start", warnings }) + try { + const text = await buildDoctorReport(doctorOptions) + const id = generateId() + controller.enqueue({ type: "text-start", id }) + controller.enqueue({ type: "text-delta", id, delta: text }) + controller.enqueue({ type: "text-end", id }) + controller.enqueue({ + type: "finish", + finishReason: toFinishReason("stop"), + usage: toUsage({ input_tokens: 0, output_tokens: 0 }), + providerMetadata: { + "claude-code": { path: "doctor", synthetic: true, usageUnavailable: true }, + }, + }) + } catch (error) { + controller.enqueue({ type: "error", error }) + } finally { + controller.close() + } + }, + }) + return { stream, request: { body: { text: `/${DOCTOR_COMMAND}` } } } + } + + const aside = !compactionMode && scope !== "no-tools" ? parseSideQuestion(options.prompt) : null + if (aside) { + // `/btw` is an ordinary user message in this conversation, so opencode + // keeps the exchange, but it is answered over the CLI's side_question + // control channel, never as a turn. The command hook normally sent the + // question ahead, while the previous turn was still streaming, and its + // answer is taken here; otherwise the process is idle now and is asked + // directly. Earlier asides in this conversation ride along as history. + const active = getActiveProcess(sk) + const early = aside.question ? takeSideQuestionAnswer(affinity, aside.question) : undefined + const history = collectSideQuestionHistory(options.prompt) + const answerAside = async (): Promise => { + if (!aside.question) return { response: SIDE_QUESTION_USAGE, synthetic: true } + if (early) { + try { + return await early + } catch (error) { + log.info("btw: early answer failed, asking the idle process", { error: String(error) }) + } + } + if (!active) return { response: BTW_NO_SESSION_MESSAGE, synthetic: true } + return requestSideQuestion(active, aside.question, { + cliVersion: await detectCliVersion(cliPath), + interactive: useInteractive, + abortSignal: options.abortSignal, + ...(history.length ? { history } : {}), + }) + } + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue({ type: "stream-start", warnings }) + try { + const answer = await answerAside() + const id = generateId() + controller.enqueue({ type: "text-start", id }) + controller.enqueue({ type: "text-delta", id, delta: answer.response }) + controller.enqueue({ type: "text-end", id }) + controller.enqueue({ + type: "finish", + finishReason: toFinishReason("stop"), + usage: toUsage({}), + providerMetadata: { "claude-code": { path: "side-question", synthetic: answer.synthetic, usageUnavailable: true } }, + }) + } catch (error) { + controller.enqueue({ type: "error", error }) + } finally { + controller.close() + } + }, + }) + return { stream, request: { body: { text: aside.question } } } + } + + if (scope === "no-tools" && !compactionMode) { + log.info("doStream no-tools title stub", { + compactionMode, + opencodeAgent: this.getOpencodeAgent(options.providerOptions), + providerOptionsKeys: options.providerOptions + ? Object.keys(options.providerOptions) + : [], + }) const text = this.synthesizeTitle(options.prompt) const textId = generateId() - const stream = new ReadableStream({ + const stream = new ReadableStream({ + start(controller) { + controller.enqueue({ type: "stream-start", warnings }) + controller.enqueue({ type: "text-start", id: textId } as any) + controller.enqueue({ + type: "text-delta", + id: textId, + delta: text, + }) + controller.enqueue({ type: "text-end", id: textId }) + controller.enqueue({ + type: "finish", + finishReason: toFinishReason("stop"), + usage: toUsage({ input_tokens: 0, output_tokens: 0 }), + providerMetadata: { + "claude-code": { + synthetic: true, + path: "no-tools", + }, + }, + }) + controller.close() + }, + }) + + return { + stream, + request: { body: { text: "" } }, + } + } + + // Short-circuit when opencode iterates the agent loop one more time + // after a turn already finished. The prompt ends with an assistant + // message and has no fresh user input — spawning Claude here would + // just produce a stub like "No input received. Standing by". + if (!hasNewUserContent(options.prompt)) { + log.info("doStream short-circuit: no new user content") + const stream = new ReadableStream({ + start(controller) { + controller.enqueue({ type: "stream-start", warnings }) + controller.enqueue({ + type: "finish", + finishReason: toFinishReason("stop"), + usage: toUsage({ input_tokens: 0, output_tokens: 0 }), + providerMetadata: { + "claude-code": { synthetic: true, path: "no-new-user-content" }, + }, + }) + controller.close() + }, + }) + return { stream, request: { body: { text: "" } } } + } + + if (!compactionMode) invalidateOtherEffortSessions(baseKey, reasoningEffort) + + const hasPriorConversation = + options.prompt.filter((m) => m.role === "user" || m.role === "assistant") + .length > 1 + + // New session — clear any stale state from a previous session. + // A compression summary is scoped to one conversation, so this is the + // one place it is dropped: the compress restart itself calls + // deleteClaudeSessionId, and clearing there would wipe the summary + // just before the fresh spawn reads it. + if (!hasPriorConversation) { + deleteClaudeSessionId(sk) + deleteActiveProcess(sk) + clearCompression(sk) + } + + // The operator's answer to a failover form this session asked on an + // earlier turn. Consumed before the session/process state below is read, + // because a switch changes which account those belong to. + const failoverAnswer = + compactionMode || useInteractive + ? null + : consumeAccountFailoverAnswer(sk, options.prompt as any) + + if (failoverAnswer?.kind === "stop") { + // Dismissed, answered `stop`, or answered with something that is not + // one of the offered accounts. End the turn the way the rate-limit + // error ends it today: no CLI inference, nothing spawned. + log.warn("account failover declined; ending the turn", { + sessionKey: sk, + account: sourceAccount, + reason: failoverAnswer.reason, + }) + const note = formatFailoverStopNote(failoverAnswer.reason) + const stream = new ReadableStream({ start(controller) { controller.enqueue({ type: "stream-start", warnings }) - controller.enqueue({ type: "text-start", id: textId } as any) + const id = generateId() + controller.enqueue({ type: "text-start", id } as any) + controller.enqueue({ type: "text-delta", id, delta: note }) + controller.enqueue({ type: "text-end", id }) controller.enqueue({ - type: "text-delta", - id: textId, - delta: text, + type: "error", + error: new Error( + `Claude account "${sourceAccount}" is out of usage and no other account was picked.`, + ), }) - controller.enqueue({ type: "text-end", id: textId }) controller.enqueue({ type: "finish", - finishReason: "stop", - usage: { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - }, + finishReason: { unified: "error" as const, raw: "account_limit" }, + usage: toUsage({ input_tokens: 0, output_tokens: 0 }), providerMetadata: { "claude-code": { + path: "account-failover-stop", synthetic: true, - path: "no-tools", + usageUnavailable: true, }, }, }) controller.close() }, }) - - return { - stream, - request: { body: { text: "" } }, - } + return { stream, request: { body: { text: "" } } } } - const hasPriorConversation = - options.prompt.filter((m) => m.role === "user" || m.role === "assistant") - .length > 1 + // The pick applies from this turn on, so re-resolve before the spawn + // reads anything: this turn is the one that continues the task. + let failoverNote: string | null = null + if (failoverAnswer?.kind === "switch") { + setAccountOverride( + failoverAnswer.sourceAccount, + failoverAnswer.target, + failoverUntil(failoverAnswer.resetsAt), + ) + failover = await resolveFailoverSpawn({ + account: sourceAccount, + baseCliPath, + cliPath: this.config.cliPath, + modelId: effectiveModelId, + }) + cliPath = failover.cliPath + asideTransportRef.cliPath = cliPath + failoverNote = formatFailoverNote({ + sourceAccount: failoverAnswer.sourceAccount, + target: failoverAnswer.target, + resetsAt: failoverAnswer.resetsAt, + }) + } - // New session — clear any stale state from a previous session - if (!hasPriorConversation) { - deleteClaudeSessionId(sk) + // A live process belongs to the account it was spawned with, and its + // Claude transcript lives under that account's config dir, so neither can + // follow the conversation across a switch. Dropping both here (before + // `includeHistoryContext` is computed) is what turns the switch into a + // fresh session with the thread replayed, and it is equally what switches + // back once the override expires. The `?.cliPath &&` guard keeps the + // interactive shim, which carries no path, out of it. + const processForAccount = getActiveProcess(sk) + if ( + !compactionMode && + !useInteractive && + processForAccount?.cliPath && + processForAccount.cliPath !== cliPath + ) { + log.notice("claude process belongs to another account; starting fresh", { + sessionKey: sk, + was: processForAccount.cliPath, + now: cliPath, + failedOver: failover.failedOver, + }) deleteActiveProcess(sk) + deleteClaudeSessionId(sk) } const hasExistingSession = !!getClaudeSessionId(sk) const hasActiveProcess = !!getActiveProcess(sk) - const includeHistoryContext = + let includeHistoryContext = !hasExistingSession && !hasActiveProcess && hasPriorConversation + // A fresh session on the other account holds none of this conversation, + // so the replay is not optional on a switch the way it is on a normal turn. + if (failoverAnswer?.kind === "switch" && hasPriorConversation) { + includeHistoryContext = true + } - const userMsg = getClaudeUserMessage(options.prompt, includeHistoryContext) + // `effectiveModelId` stays intact for session keys, logs, and metadata; + // only the name handed to the CLI gets the `-fast` marker stripped, and + // (on a failover) the `@account` suffix the other account's wrapper would + // not recognise. + const { model: spawnModelId, fast: fastMode } = parseModelId(failover.modelId) + + const exitPlanModeQuestionResult = compactionMode + ? null + : consumeExitPlanModeQuestionResult(sk, options.prompt as any) + if (exitPlanModeQuestionResult) { + // The whole user message for this turn is the `tool_result` for the + // pending ExitPlanMode call, so say so: an operator looking at a turn + // that carries none of their typed text needs the reason in the log. + log.info("sending plan approval decision to claude", { sk }) + } + // Read before the envelope is built, and used by it: only these ids were + // issued by this CLI process, so only these may be sent back as + // `tool_result` blocks (issue #29). + const previousPendingProxyCalls = compactionMode + ? [] + : getPendingProxyCalls(sk) + // On a switch the dialog comes out of the transcript and a short note + // telling the fresh session to carry on goes in as the current message. + const effectivePrompt = + failoverAnswer?.kind === "switch" + ? buildFailoverContinuationPrompt(options.prompt, failoverAnswer.target) + : options.prompt + const userMsg = + exitPlanModeQuestionResult ?? + getClaudeUserMessage(effectivePrompt, includeHistoryContext, { + compactionMode, + cliToolCallIds: new Set(previousPendingProxyCalls.map((c) => c.toolCallId)), + stripContextReminders: this.stripContextRemindersEnabled(), + }) + const resolvedProxy = compactionMode ? null : this.resolvedProxyTools() + const loadLiveToolInfo = this.createLiveToolInfoLoader() + // Resolved here, not inside the stream body: the ExitPlanMode branches + // run in a synchronous line handler and a reused process never reaches + // the spawn block where the registry snapshot is otherwise taken. + const planModeQuestionActive = await this.resolvePlanModeQuestion( + compactionMode, + loadLiveToolInfo, + ) + const self = this + + const previousPendingProxyMatches: Array<{ + call: PendingProxyCall + result: ProxyToolResult | null + }> = previousPendingProxyCalls.map((call) => ({ + call, + result: this.extractPendingProxyResultForCall(options.prompt, call), + })) + const hasMatchedPendingResults = previousPendingProxyMatches.some( + (m) => m.result !== null, + ) + + // Pre-fetch opencode's MCP runtime status before constructing the + // ReadableStream so the sync hot-reload check and async setup() see + // the same overlay snapshot. One in-process call per turn — cheap; + // the SDK client routes through `Server.app.fetch` (no socket). + // Detect the Claude CLI version in parallel so the spawn can decide + // which optional flags it supports without crashing older binaries. + const [runtimeStatus, cliVersion] = await Promise.all([ + compactionMode ? Promise.resolve(undefined) : getRuntimeMcpStatus(), + detectCliVersion(cliPath), + ]) + + // Whether a usage limit on this account should end the turn with the + // switch form. Resolved here, in the prologue, for the same reason the + // plan-mode gate is: the `result` branch that needs the answer runs in a + // synchronous line handler. The candidate check comes first so a + // single-account install never pays for the two lookups behind it. + const failoverAccounts = failoverCandidates( + this.config.failoverAccounts, + sourceAccount, + ) + const failoverAskActive = + failoverAccounts.length > 0 && + this.config.accountFailover !== "off" && + !compactionMode && + !useInteractive && + isAccountFailoverQuestionActive({ + configured: this.config.accountFailover, + candidates: failoverAccounts, + opencodeHasQuestion: (await loadLiveToolInfo()).hasQuestion, + compactionMode, + interactive: !!useInteractive, + // A subagent follows its parent's account for free, because the + // override is account-scoped. Asking it would put a form in a session + // the operator is usually not even looking at. + childSession: !!(await fetchSessionParentId(affinity)), + }) log.info("doStream starting", { cwd, - model: this.modelId, + model: effectiveModelId, textLength: userMsg.length, includeHistoryContext, hasActiveProcess, + reasoningEffort, + proxyTools: resolvedProxy?.map((t) => t.name) ?? null, + compactionMode, + scope, + opencodeAgent: this.getOpencodeAgent(options.providerOptions), + providerOptionsKeys: options.providerOptions + ? Object.keys(options.providerOptions) + : [], }) - const cliArgs = buildCliArgs({ - sessionKey: sk, - skipPermissions, - model: this.modelId, - }) - - const stream = new ReadableStream({ + const stream = new ReadableStream({ start(controller) { + // Compaction is a one-shot call. Don't reuse any cached process + // from a prior compaction — each /compact gets a fresh spawn so + // the new transcript isn't appended to a stale claude session. + if (compactionMode) { + deleteActiveProcess(sk) + deleteClaudeSessionId(sk) + } + + // A compress call lands mid-turn, when the child is still streaming, + // so the reset it asks for happens here instead: drop the child and + // its session id, and the spawn below starts clean. `userMsg` and + // `includeHistoryContext` were resolved above while the session + // still existed, so the fresh process is given only this turn's + // message — the summary in its system prompt is the whole of its + // prior context, exactly as the tool promised. + // + // Not while this turn carries results for the live child: evicting + // it would send a tool_result to a process that never issued the + // matching tool_use. The mark survives to the next turn. + if (!compactionMode && !hasMatchedPendingResults && consumeCompressionRestart(sk)) { + deleteActiveProcess(sk) + deleteClaudeSessionId(sk) + log.info("compress reset: dropped claude process and session id", { + sessionKey: sk, + }) + } + let activeProcess = getActiveProcess(sk) let proc: import("child_process").ChildProcess let lineEmitter: import("events").EventEmitter + let cliArgs: string[] + let proxyServer: ProxyMcpServer | null = activeProcess?.proxyServer ?? null - if (activeProcess) { - proc = activeProcess.proc - lineEmitter = activeProcess.lineEmitter - log.debug("reusing active process", { sk }) - } else { - const ap = spawnClaudeProcess(cliPath, cliArgs, cwd, sk) - proc = ap.proc - lineEmitter = ap.lineEmitter + const setup = async () => { + // Wait for the old owner to exit before resuming its session ID in + // the replacement, so two processes never append to one transcript. + if ( + !compactionMode && + activeProcess && + self.config.hotReloadMcp !== false && + self.config.bridgeOpencodeMcp !== false + ) { + const probe = self.effectiveMcpConfig(cwd, undefined, runtimeStatus!) + const previousHash = activeProcess.mcpHash ?? null + if (previousHash !== probe.bridgedHash) { + if (previousPendingProxyCalls.length > 0) { + log.info("deferring MCP hot reload until proxy calls resolve", { + sk, + previousHash, + currentHash: probe.bridgedHash, + pendingCalls: previousPendingProxyCalls.length, + }) + } else { + log.info("opencode MCP config changed, respawning claude", { + sk, + previousHash, + currentHash: probe.bridgedHash, + }) + await deleteActiveProcessAndWait(sk) + activeProcess = undefined + proxyServer = null + } + } + } + + if (useInteractive && !compactionMode) { + // Interactive Bun-ConPTY transport. Reuse the live session if one + // exists for this key; else spawn a new interactive claude. The + // wrapper conforms to ActiveProcess, so reuse/eviction/hot-reload + // and the whole emission body below work unchanged. + const mcp = self.effectiveMcpConfig(cwd, undefined, runtimeStatus!) + if (activeProcess) { + proc = activeProcess.proc + lineEmitter = activeProcess.lineEmitter + log.debug("reusing active interactive session", { sk }) + } else { + // MCP wildcards are always derived from the live bridge config; + // the built-in tool list is overridable via interactiveAllowTools. + const allow = [ + ...mcp.allEnabledServerNames.map((n) => `mcp__${n}__*`), + "mcp__opencode_proxy__*", + ...(self.config.interactiveAllowTools ?? [ + "Bash", + "Edit", + "Write", + "Read", + "WebFetch", + ]), + ] + const systemPromptFile = + self.config.interactiveSystemPrompt === false + ? undefined + : buildAppendedSystemPrompt( + cwd, + self.config.multiStepContinuation !== false, + // Do not forward opencode's own system prompt into the + // interactive TUI. Live subscription-account testing + // showed that large forwarded payload can trigger Claude + // Code's third-party-app usage gate, while our static + // CLI/AGENTS/continuation prompt remains safe. + ) + if (self.config.interactiveSystemPrompt === false) { + log.warn( + "interactive system prompt disabled; opencode agent prompts will not be appended", + ) + } + if (interactiveBypassRequested) { + log.warn( + "interactiveBypass ignored: Claude Code prompts for bypassPermissions confirmation in the interactive TUI", + ) + } + // Same skill bridge as the headless spawn: the TUI's native + // Skill tool reads `--plugin-dir` too, and the flag probe + // keeps it off a CLI that does not know the flag. + const skillPluginDirs = await resolveSkillPluginDirs({ + cwd, + cliPath, + enabled: self.config.bridgeOpencodeSkills === true, + }) + const ap = spawnInteractiveProcess({ + cwd, + cliPath, + configDir: self.config.configDir, + model: spawnModelId, + fastMode, + mcpConfigPaths: mcp.paths, + pluginDirs: skillPluginDirs, + permissionsAllow: allow, + systemPromptFile, + ignoreAnthropicApiKey: self.config.ignoreAnthropicApiKey, + effort: reasoningEffort, + }) + ap.mcpHash = mcp.bridgedHash + setActiveProcess(sk, ap) + proc = ap.proc + lineEmitter = ap.lineEmitter + activeProcess = ap + log.info("spawned interactive claude session", { + sk, + cliPath, + configDir: self.config.configDir, + model: effectiveModelId, + }) + } + } else { + let spawnSystemPromptFile: string | undefined + let spawnProxyServer: ProxyMcpServer | null = null + let spawnMcpHash: string | null = null + + if (compactionMode) { + // Compaction takes a lean spawn: no MCP servers, no proxy, no + // appended system prompt, no disallowed-tools list. The model + // is asked for text output only on a single turn — all the + // normal tool wiring is pure overhead and adds latency. + // Explicitly opt out of `--resume` so a stale id can never + // resume into the lean spawn. + cliArgs = buildCliArgs({ + sessionKey: sk, + skipPermissions, + includeSessionId: false, + model: spawnModelId, + permissionMode: self.config.permissionMode, + fastMode, + cliVersion, + }) + } else { + // First pass: discover which opencode MCP servers would be + // bridged. We use this to decide which ones to re-route through + // the proxy instead. No --mcp-config path is consumed here; + // it's recomputed below with the exclusion set in place. + const discovery = self.effectiveMcpConfig( + cwd, + undefined, + runtimeStatus!, + ) + + // Fetch the proxy MCP tools (one ProxyToolDef per opencode + // MCP-bridged tool). If discovery returns nothing or the SDK + // is unreachable, this is null and we fall back to direct + // bridging. + const mcpResolution = self.resolvedProxyMcpTools( + discovery.allEnabledServerNames, + options.tools as readonly ModelToolEntry[] | undefined, + new Set((resolvedProxy ?? []).map((def) => def.name)), + ) + const proxyMcpTools = mcpResolution?.defs ?? null + // Exclude only the servers a def was actually built for. Excluding + // every enabled server, as this did while the resolution was always + // null, would strand a server whose tools were not in the model's + // tool set: dropped from `--mcp-config` and absent from the proxy, + // so reachable by neither route. + const excludeServers: ReadonlySet | undefined = mcpResolution + ? mcpResolution.coveredServers + : undefined + + // Overlay opencode's live tool info onto the static proxy defs. + // Both the `task` description (with the "Available agent types" + // list, so the model sees which subagents exist instead of + // grepping configs) and the `question` version gate (older + // opencode builds lack the `question` registry entry; the def + // must be dropped or a forwarded call renders `⚙ invalid`) + // derive from a single tool-list fetch. Spawn-time only, like + // the rest of this block; a reused process keeps its defs. + const taskProxyEnabled = + resolvedProxy?.some((t) => t.name === "task") ?? false + const questionProxyEnabled = + resolvedProxy?.some((t) => t.name === "question") ?? false + // `proxyOpencodeTools` reads its defs out of the same registry + // snapshot, so it joins the condition instead of fetching again. + const opencodeToolsRequested = + (self.config.proxyOpencodeTools?.length ?? 0) > 0 + log.debug("opencode tool forwarding gate", { + requested: self.config.proxyOpencodeTools ?? null, + opencodeToolsRequested, + }) + const liveToolInfo = + taskProxyEnabled || questionProxyEnabled || opencodeToolsRequested + ? await loadLiveToolInfo() + : { + resolved: false, + taskDescription: undefined, + questionDescription: undefined, + hasQuestion: false, + } + let enrichedProxy = resolvedProxy + if (enrichedProxy && taskProxyEnabled) { + enrichedProxy = overlayTaskProxyDescription( + enrichedProxy, + liveToolInfo.taskDescription, + ) + // Whether the model will see opencode's agent list is the + // difference between a dispatch and an "Unknown agent type" + // guess, so say so out loud. + log.info("task proxy description overlay", { + applied: Boolean(liveToolInfo.taskDescription), + liveDescriptionLength: liveToolInfo.taskDescription?.length ?? 0, + listsAgentTypes: Boolean( + liveToolInfo.taskDescription?.includes( + "Available agent types", + ), + ), + }) + } + if (enrichedProxy && questionProxyEnabled) { + // When the version gate is about to drop the def + // (`hasQuestion === false`) the live description is moot, + // so only overlay when the entry actually exists. + enrichedProxy = overlayQuestionProxyDescription( + enrichedProxy, + liveToolInfo.hasQuestion + ? liveToolInfo.questionDescription + : undefined, + ) + enrichedProxy = filterQuestionProxyByOpencodeSupport( + enrichedProxy, + liveToolInfo.hasQuestion, + ) + // Same reasoning as the task overlay log: when the gate drops + // the def the model silently falls back to the deny/markdown + // path, which looks from the outside like the feature is off. + log.info("question proxy version gate", { + opencodeHasQuestion: liveToolInfo.hasQuestion, + kept: liveToolInfo.hasQuestion, + }) + } + + // Combine the static proxy defs with any MCP-bridged proxy + // tools. Guard against the empty case: a version gate can + // drop every configured def (e.g. `proxyTools: ["Question"]` + // on an opencode build that lacks the `question` registry + // entry), and spinning up an MCP server with zero tools is + // wasteful and wrong shape. + // Opencode tools that belong to no MCP server are invisible to + // resolvedProxyMcpTools, so an explicitly named one is resolved + // here. It goes into the same combined list, which means the same + // broker path, and therefore the same abort / orphan-sweep / + // session-delete / child-exit release as every other proxy call. + // Last in `taken`, so a static def or an MCP tool keeps a + // contested name (`compress`) and this one is dropped with a + // warning rather than shadowing it. + const opencodeToolDefs = resolveProxyOpencodeToolDefs({ + requested: self.config.proxyOpencodeTools, + items: liveToolInfo.items, + taken: new Set( + [...(enrichedProxy ?? []), ...(proxyMcpTools ?? [])].map( + (t) => t.name, + ), + ), + }) + if (opencodeToolDefs.length > 0) { + log.info("forwarding opencode tools through the proxy", { + tools: opencodeToolDefs.map((t) => t.name), + }) + } + + const combinedList = [ + ...(enrichedProxy ?? []), + ...(proxyMcpTools ?? []), + ...opencodeToolDefs, + ] + const combinedProxyTools: ProxyToolDef[] | null = + combinedList.length > 0 ? combinedList : null + + const pluginCompressEnabled = + enrichedProxy?.some((t) => t.name === "compress") ?? false + + if (!proxyServer && combinedProxyTools) { + proxyServer = await self.ensureProxyServer( + combinedProxyTools, + sk, + pluginCompressEnabled, + ) + } + + // Whether the question proxy actually survived the version + // gate (post-filter). Used to decide whether to inject the + // QUESTION_PROXY_HINT — if the gate dropped the def, the + // model must fall back to AskUserQuestion (the deny/markdown + // path) and must NOT be told to call a proxy tool that does + // not exist. + const questionProxyActive = + enrichedProxy?.some((t) => t.name === "question") ?? false + + // Compute disallowed flags from the POST-FILTER proxy list + // (enrichedProxy), not the pre-filter one (resolvedProxy). + // When the version gate drops `question` on an older opencode + // build, AskUserQuestion must NOT be added to + // --disallowedTools — otherwise the native tool is disabled + // while the proxy replacement is absent, leaving the model + // with no way to ask questions at all (neither proxy nor the + // deny/markdown fallback path fires). + const allDisallowed = resolveDisallowedTools({ + proxyTools: enrichedProxy, + extraDisallowedTools: self.config.extraDisallowedTools, + disableWebSearch: self.config.webSearch === "disabled", + }) + const mcp = self.effectiveMcpConfig( + cwd, + proxyServer?.configPath(), + runtimeStatus!, + excludeServers, + ) + const systemPromptFile = activeProcess + ? undefined + : buildAppendedSystemPrompt( + cwd, + self.config.multiStepContinuation !== false, + [ + ...extractSystemMessages(options.prompt), + ...(taskProxyEnabled ? [SUBAGENT_DISPATCH_HINT] : []), + ...(questionProxyActive ? [QUESTION_PROXY_HINT] : []), + ], + { + compressEnabled: pluginCompressEnabled, + opencodeCompressEnabled: opencodeToolDefs.some( + (t) => t.name === "compress", + ), + compressionSummary: getCompressionSummary(sk), + }, + ) + // Skill bridge (@broskees): stage opencode skills as a + // session-scoped --plugin-dir so Claude's Skill tool can run them. + // Opt-in via `bridgeOpencodeSkills: true`; the bundled skill is + // staged either way. + const skillPluginDirs = await resolveSkillPluginDirs({ + cwd, + cliPath, + enabled: self.config.bridgeOpencodeSkills === true, + }) + cliArgs = buildCliArgs({ + sessionKey: sk, + skipPermissions, + model: spawnModelId, + permissionMode: self.config.permissionMode, + mcpConfig: mcp.paths, + strictMcpConfig: self.config.strictMcpConfig, + disallowedTools: allDisallowed.length > 0 ? allDisallowed : undefined, + appendSystemPromptFile: systemPromptFile, + pluginDirs: skillPluginDirs, + ...self.thinkingCliOptions(), + fastMode, + cliVersion, + }) + spawnSystemPromptFile = systemPromptFile + spawnProxyServer = proxyServer + spawnMcpHash = mcp.bridgedHash + } + + if (activeProcess && !compactionMode) { + proc = activeProcess.proc + lineEmitter = activeProcess.lineEmitter + log.debug("reusing active process", { sk }) + } else { + const ap = spawnClaudeProcess( + cliPath, + cliArgs, + cwd, + sk, + spawnProxyServer, + spawnMcpHash, + spawnSystemPromptFile, + self.config.ignoreAnthropicApiKey, + reasoningEffort, + ) + proc = ap.proc + lineEmitter = ap.lineEmitter + activeProcess = ap + } + } + + // The CLI serves one turn at a time. If the previous one is still + // running (the user aborted it, or it ended on our inactivity + // fallback rather than a real `result`), stop it before this turn + // attaches any listeners; otherwise its tail streams into us and its + // `result` closes us before our own answer arrives. Skipped for + // tool-result turns: there the CLI is deliberately parked inside a + // proxy MCP call waiting for the result we are about to deliver. + if (activeProcess && !hasMatchedPendingResults && isTurnInFlight(activeProcess)) { + log.warn("previous turn still in flight; interrupting it", { sk }) + const idle = await interruptTurn(activeProcess) + if (!idle) { + log.warn("previous turn did not stop in time; this turn may see stale output", { sk }) + } + } + + controller.enqueue({ type: "stream-start", warnings }) + + let currentTextId: string | null = null + const textBlockIndices = new Set() + + const startTextBlock = (): string => { + if (currentTextId) { + controller.enqueue({ type: "text-end", id: currentTextId }) + } + const id = generateId() + currentTextId = id + controller.enqueue({ type: "text-start", id } as any) + return id + } + + const endTextBlock = (): void => { + if (currentTextId) { + controller.enqueue({ type: "text-end", id: currentTextId }) + currentTextId = null + } + } + + // Its own text part, led by FAILOVER_MARKER, so a later transcript + // rebuild strips it exactly: it was never Claude's output. + if (failoverNote) { + controller.enqueue({ + type: "text-delta", + id: startTextBlock(), + delta: failoverNote, + }) + endTextBlock() + } + + const reasoningIds = new Map() + const reasoningStarted = new Map() + let hadThinkingTextFromStream = false + + let turnCompleted = false + let controllerClosed = false + // Buffered terminal results belong to the previous CLI turn. + let unattendedTurnEnded = false + let watchdogMessage = userMsg + let pendingProxyUnsubscribe: (() => void) | null = null + let asideSinkUnregister: (() => void) | null = null + let resultFallbackTimer: ReturnType | null = null + let pendingResultCompletion: (() => void) | null = null + let hasReceivedContent = false + let hasReceivedProgress = false + let visibleTextSinceContinue = "" + let lastVisibleTextSinceContinue = "" + let hadReasoningSinceContinue = false + let hadToolActivitySinceContinue = false + let hadProxyActivitySinceContinue = false + // v0.4.16: protocol-level stop signal captured from Claude CLI's + // stream. Set by either the `message_delta` partial event or the + // top-level `assistant` message, whichever arrives first. + let lastStopReason: string | null = null + const autoContinueState: AutoContinueState = { + enabled: autoContinueEnabledFor( + compactionMode, + self.config.autoContinueIncompleteTurns, + ), + attempts: 0, + startedAt: Date.now(), + noProgressCount: 0, + } + + const clearFallbackTimer = () => { + if (resultFallbackTimer) { + clearTimeout(resultFallbackTimer) + resultFallbackTimer = null + } + } + + // Wire-inactivity watchdog. Resets on every line received from the + // CLI; only fires if the CLI has emitted content and then gone + // silent on stdout for `delayMs` without sending a `result`. The + // previous design armed this on every text content_block_stop, + // which killed legitimate mid-turn think pauses (most visibly + // with sonnet between text-end and the next tool_use_start). + // Tunable for reproduces and for the regression test, the same seam + // CLAUDE_CODE_START_WATCHDOG_MS gives the start watchdog below. + const RESULT_FALLBACK_MS = (() => { + const env = process.env.CLAUDE_CODE_RESULT_FALLBACK_MS + const parsed = env ? Number.parseInt(env, 10) : NaN + return Number.isFinite(parsed) && parsed > 0 ? parsed : 60_000 + })() + const startResultFallback = (delayMs = RESULT_FALLBACK_MS) => { + clearFallbackTimer() + if ((!hasReceivedContent && !hasReceivedProgress) || controllerClosed) return + resultFallbackTimer = setTimeout(() => { + if (controllerClosed) return + log.warn("result fallback timer fired — closing stream without result event", { + delayMs, + }) + // Closing on a log line alone left the operator with a reply that + // just stopped. An abort is exempt: they asked for it, and the + // short grace period there is not a silent CLI. + if (!autoContinueState.aborted) { + controller.enqueue({ + type: "text-delta", + id: startTextBlock(), + delta: formatStreamTimeoutNote(delayMs), + }) + endTextBlock() + } + closeHandler() + }, delayMs) + } + + // Start watchdog: complementary to the inactivity watchdog above. + // That one only arms once content has arrived; this one covers the + // gap the other explicitly skips — a reused process that produces + // NO stdout at all after a fresh-turn envelope write. Seen after a + // very long proxy-blocked tool call resumed successfully (the child + // stays silent on stdout). On first fire we respawn the child with + // --session-id to resume the conversation transparently; on a + // second fire (respawn also silent) we end the turn cleanly so the + // next opencode turn spawns fresh. Tunable via env for reproduces. + const START_WATCHDOG_MS = (() => { + const env = process.env.CLAUDE_CODE_START_WATCHDOG_MS + const parsed = env ? Number.parseInt(env, 10) : NaN + return Number.isFinite(parsed) && parsed > 0 ? parsed : 90_000 + })() + let startWatchdog: ReturnType | null = null + let respawnAttempted = false + const clearStartWatchdog = () => { + if (startWatchdog) { + clearTimeout(startWatchdog) + startWatchdog = null + } + } + const onStartWatchdogFire = () => { + startWatchdog = null + if (controllerClosed || hasReceivedContent || hasReceivedProgress) return + if (respawnAttempted) { + log.error( + "claude process still silent after respawn; ending turn", + { sessionKey: sk }, + ) + deleteActiveProcess(sk) + deleteClaudeSessionId(sk) + controllerClosed = true + cleanupTurn() + controller.enqueue({ + type: "error", + error: new Error( + "Claude process produced no output after the envelope write (start watchdog timeout).", + ), + }) + try { + controller.close() + } catch {} + return + } + respawnAttempted = true + log.warn( + "no stdout after envelope write; respawning claude process to resume conversation", + { sessionKey: sk, startWatchdogMs: START_WATCHDOG_MS }, + ) + lineEmitter.off("line", lineHandler) + lineEmitter.off("close", closeHandler) + proc.off("error", procErrorHandler) + const newAp = respawnActiveProcess( + sk, + cliPath, + cliArgs, + cwd, + self.config.ignoreAnthropicApiKey, + ) + if (!newAp) { + log.error( + "no active process to respawn (start watchdog); ending turn", + { sessionKey: sk }, + ) + controllerClosed = true + cleanupTurn() + controller.enqueue({ + type: "error", + error: new Error( + "No active claude process to respawn after start watchdog timeout.", + ), + }) + try { + controller.close() + } catch {} + return + } + proc = newAp.proc + lineEmitter = newAp.lineEmitter + activeProcess = newAp + lineEmitter.on("line", lineHandler) + lineEmitter.on("close", closeHandler) + proc.on("error", procErrorHandler) + try { + if (!deliverPendingCompletions(true)) { + noteTurnStarted(newAp) + proc.stdin?.write(watchdogMessage + "\n") + } + log.debug("re-sent user message after respawn", { + textLength: watchdogMessage.length, + }) + } catch (err) { + log.error("failed to re-send envelope after respawn", { + error: err instanceof Error ? err.message : String(err), + }) + } + armStartWatchdog() + } + const armStartWatchdog = () => { + clearStartWatchdog() + if (controllerClosed) return + startWatchdog = setTimeout(onStartWatchdogFire, START_WATCHDOG_MS) + } + + // Both buffered/live terminal boundaries and respawn consume through + // this path. Open-channel results remain available for a later close. + const deliverPendingCompletions = (force = false): boolean => { + const pending = activeProcess?.pendingProxyCompletions + const entries = [...(pending?.values() ?? [])].filter( + (entry) => force || entry.recoveryRequired || isPendingProxyCallChannelClosed(entry.call), + ) + if (entries.length === 0) return false + endTextBlock() + watchdogMessage = makeLateProxyResultMessage(entries) + // This write asks the CLI for work like any fresh envelope, so + // abort, LRU eviction and the idle timer must see it as busy. + if (activeProcess) noteTurnStarted(activeProcess) + proc.stdin!.write(watchdogMessage + "\n") + for (const { call } of entries) pending!.delete(call.toolCallId) + log.warn("delivering proxy results after interrupted continuation", { + sessionKey: sk, + toolCallIds: entries.map(({ call }) => call.toolCallId), + respawn: force, + }) + gotPartialEvents = false + hasReceivedContent = false + hasReceivedProgress = false + turnCompleted = false + resetAutoContinueWindow() + clearFallbackTimer() + armStartWatchdog() + return true + } + + const toolCallMap = new Map< + number, + { id: string; name: string; inputJson: string; started: boolean } + >() + // Tool calls the plugin reported as providerExecuted:false — opencode + // will run these itself and emit its own tool-result, so we must NOT + // forward Claude CLI's tool_result for them (would short-circuit + // opencode's execute). + const skipResultForIds = new Set() + const toolCallsById = new Map< + string, + { id: string; name: string; input: unknown } + >() + + let resultMeta: { + sessionId?: string + costUsd?: number + durationMs?: number + durationApiMs?: number + numTurns?: number + usage?: ClaudeStreamMessage["usage"] + modelUsage?: ClaudeStreamMessage["modelUsage"] + permissionDenials?: ClaudeStreamMessage["permission_denials"] + } = {} + + // Subtype of a failing `result`, so the finish below reports the + // turn as an error instead of a clean stop. + let resultFailure: string | undefined + + // Set only by a REJECTED rate-limit event or by one of the two + // known account-limit error texts, never by a generic failure: a + // transient error must not open a form that moves the billing. + let accountLimitHit: { resetsAt?: number; window?: string } | null = null + + // Batched drain so claude CLI's parallel tool_use blocks (e.g. two + // bash calls in one assistant message) end up in a single + // tool-calls finish event. Without this, the broker would reject + // every overlapping call and claude would see spurious tool errors. + const drainBuffer: PendingProxyCall[] = [] + let drainTimer: ReturnType | null = null + const DRAIN_QUIET_MS = 100 + + const finishWithToolCalls = (calls: PendingProxyCall[]) => { + if (controllerClosed) return + if (calls.length === 0) return + const enqueueToolCall = ( + toolCallId: string, + toolName: string, + input: Record, + ) => { + controller.enqueue({ + type: "tool-input-start", + id: toolCallId, + toolName, + } as any) + controller.enqueue({ + type: "tool-call", + toolCallId, + toolName, + input: JSON.stringify(input), + providerExecuted: false, + } as any) + skipResultForIds.add(toolCallId) + } + for (const call of calls) { + if (call.toolName === TASK_BATCH_TOOL_NAME) { + // One MCP call from the CLI becomes N opencode `task` calls in + // this single tool boundary, which is what makes them run at the + // same time: the CLI serialises MCP calls, opencode runs the + // tool calls of one step concurrently. Their results are + // gathered back onto the parent id in + // extractPendingProxyResultForCall. + for (const [index, task] of taskBatchTasks(call.input).entries()) { + enqueueToolCall( + taskBatchChildToolCallId(call.toolCallId, index), + "task", + task, + ) + } + skipResultForIds.add(call.toolCallId) + } else { + enqueueToolCall(call.toolCallId, call.toolName, call.input) + } + markPendingProxyCallEmitted(call.toolCallId) + } + controller.enqueue({ + type: "finish", + finishReason: toFinishReason("tool-calls"), + usage: toUsage(resultMeta.usage), + providerMetadata: { + "claude-code": resultMeta, + }, + }) + controllerClosed = true + cleanupTurn() + try { + controller.close() + } catch {} + } + + /** + * End the turn on a synthetic call to opencode's native `question` + * tool. opencode runs the tool, and the operator's answer arrives on + * the NEXT doStream as a `tool-result` carrying this same id, which + * is what keeps the whole exchange inside one opencode turn. Shared + * by the plan-mode approval bridge and the account-failover form. + */ + const finishWithQuestionCall = (call: QuestionToolCall) => { + if (controllerClosed) return + endTextBlock() + controller.enqueue({ + type: "tool-input-start", + id: call.toolCallId, + toolName: call.toolName, + providerExecuted: false, + } as any) + controller.enqueue({ + type: "tool-call", + toolCallId: call.toolCallId, + toolName: call.toolName, + input: JSON.stringify(call.input), + providerExecuted: false, + } as any) + controller.enqueue({ + type: "finish", + finishReason: toFinishReason("tool-calls"), + usage: toUsage(resultMeta.usage), + providerMetadata: { + "claude-code": resultMeta, + }, + }) + controllerClosed = true + cleanupTurn() + try { + controller.close() + } catch {} + } + + const drainNow = () => { + if (drainTimer) { + clearTimeout(drainTimer) + drainTimer = null + } + if (drainBuffer.length === 0) return + if (controllerClosed) return + const batch = drainBuffer.splice(0, drainBuffer.length) + log.info("draining pending proxy calls into stream finish", { + sessionKey: sk, + count: batch.length, + toolCallIds: batch.map((c) => c.toolCallId), + }) + finishWithToolCalls(batch) + } + + const settleResultBoundary = () => { + drainTimer = null + const completeResult = pendingResultCompletion + pendingResultCompletion = null + if (!completeResult || controllerClosed) return + if (drainBuffer.length > 0) { + drainNow() + return + } + completeResult() + } + + const scheduleResultBoundary = ( + completeResult: () => void, + delayMs: number, + ) => { + pendingResultCompletion = completeResult + if (drainTimer) clearTimeout(drainTimer) + drainTimer = setTimeout(settleResultBoundary, delayMs) + } + + const noteResultBoundaryCall = (): boolean => { + if (!pendingResultCompletion) return false + if (drainTimer) clearTimeout(drainTimer) + drainTimer = setTimeout(settleResultBoundary, DRAIN_QUIET_MS) + return true + } + + const noteVisibleText = (text: string) => { + visibleTextSinceContinue += text + lastVisibleTextSinceContinue += text + } + + const resetLastVisibleTextBlock = () => { + lastVisibleTextSinceContinue = "" + } + + const noteReasoning = () => { + hadReasoningSinceContinue = true + } + + const noteToolActivity = () => { + hadToolActivitySinceContinue = true + } + + const noteProxyActivity = () => { + hadProxyActivitySinceContinue = true + } + + const resetAutoContinueWindow = () => { + visibleTextSinceContinue = "" + lastVisibleTextSinceContinue = "" + hadReasoningSinceContinue = false + hadToolActivitySinceContinue = false + hadProxyActivitySinceContinue = false + lastStopReason = null } - controller.enqueue({ type: "stream-start", warnings }) + const completeResult = (msg: ClaudeStreamMessage) => { + if (controllerClosed) return + // The socket may have closed after the tool-result prompt was matched, + // or while the result-boundary grace timer was running. + if (deliverPendingCompletions()) { + if (drainBuffer.length > 0) drainNow() + return + } + if (drainBuffer.length > 0) { + drainNow() + return + } + + const pendingSiblings = getPendingProxyCalls(sk) + if (pendingSiblings.length > 0) { + log.info("leaving parallel proxy calls pending at result boundary", { + sessionKey: sk, + count: pendingSiblings.length, + }) + } + + activeProcess?.pendingProxyCompletions?.clear() - const textId = generateId() - let textStarted = false + // This account is out of usage. Rather than finish as an error the + // operator can only act on by editing config, end the turn on a + // form listing the other configured accounts. Leaving it unanswered + // waits and costs nothing; every answer that is not one of those + // accounts comes back as a `stop` and ends the turn as before. + if (accountLimitHit && failoverAskActive) { + const call = createAccountFailoverQuestionCall(sk, { + sourceAccount, + candidates: failoverAccounts, + resetsAt: accountLimitHit.resetsAt, + window: accountLimitHit.window, + }) + log.warn( + `Claude account "${sourceAccount}" is out of usage; asking which account to continue on.`, + { + sessionKey: sk, + candidates: failoverAccounts, + toolCallId: call.toolCallId, + resetsAt: accountLimitHit.resetsAt ?? null, + }, + ) + finishWithQuestionCall(call) + return + } + + const autoDecision = shouldAutoContinueIncompleteTurn( + autoContinueState, + { + text: visibleTextSinceContinue, + lastVisibleText: lastVisibleTextSinceContinue, + hadReasoning: hadReasoningSinceContinue, + hadToolActivity: hadToolActivitySinceContinue, + hadProxyActivity: hadProxyActivitySinceContinue, + isError: msg.is_error, + stopReason: lastStopReason, + }, + ) + if (autoDecision.continue) { + const signature = continuationSignature({ + text: visibleTextSinceContinue, + lastVisibleText: lastVisibleTextSinceContinue, + hadReasoning: hadReasoningSinceContinue, + hadToolActivity: hadToolActivitySinceContinue, + hadProxyActivity: hadProxyActivitySinceContinue, + isError: msg.is_error, + }) + autoContinueState.noProgressCount = + signature === autoContinueState.lastSignature + ? autoContinueState.noProgressCount + 1 + : 0 + autoContinueState.lastSignature = signature + autoContinueState.attempts++ + log.notice("auto-continuing incomplete claude result", { + sessionKey: sk, + reason: autoDecision.reason, + attempts: autoContinueState.attempts, + textLength: visibleTextSinceContinue.length, + lastTextLength: lastVisibleTextSinceContinue.length, + hadReasoning: hadReasoningSinceContinue, + hadToolActivity: hadToolActivitySinceContinue, + hadProxyActivity: hadProxyActivitySinceContinue, + }) + turnCompleted = false + resetAutoContinueWindow() + // The `result` just consumed marked the CLI idle; this puts it back to work. + if (activeProcess) noteTurnStarted(activeProcess) + proc.stdin?.write(makeAutoContinueMessage() + "\n") + return + } + log.notice("auto-continuation stopped", { + sessionKey: sk, + reason: autoDecision.reason, + stopReason: lastStopReason, + attempts: autoContinueState.attempts, + textLength: visibleTextSinceContinue.length, + lastTextLength: lastVisibleTextSinceContinue.length, + hadReasoning: hadReasoningSinceContinue, + hadToolActivity: hadToolActivitySinceContinue, + hadProxyActivity: hadProxyActivitySinceContinue, + }) + + for (const [idx, reasoningId] of reasoningIds) { + if (reasoningStarted.get(idx)) { + controller.enqueue({ + type: "reasoning-end", + id: reasoningId, + } as any) + } + } - const reasoningIds = new Map() - const reasoningStarted = new Map() + controller.enqueue({ + type: "finish", + finishReason: resultFailure + ? { unified: "error" as const, raw: resultFailure } + : toFinishReason("stop"), + usage: toUsage(msg.usage), + providerMetadata: { + "claude-code": { + ...resultMeta, + ...(resultFailure ? { resultSubtype: resultFailure } : {}), + ...(compactionMode + ? { compactionModel: effectiveModelId } + : {}), + }, + ...(typeof msg.usage?.cache_creation_input_tokens === "number" + ? { + anthropic: { + cacheCreationInputTokens: + msg.usage.cache_creation_input_tokens, + }, + } + : {}), + }, + }) - let turnCompleted = false - let controllerClosed = false + controllerClosed = true + cleanupTurn() + if (!useInteractive && !compactionMode) { + scheduleIdleProcessEviction( + sk, + resolveIdleProcessTimeoutMs(self.config.idleProcessTimeoutMs), + ) + } - const toolCallMap = new Map< - number, - { id: string; name: string; inputJson: string } - >() - const toolCallsById = new Map< - string, - { id: string; name: string; input: unknown } - >() + try { + controller.close() + } catch {} + } - let resultMeta: { - sessionId?: string - costUsd?: number - durationMs?: number - usage?: ClaudeStreamMessage["usage"] - } = {} + // Set true once we observe a `stream_event` envelope. When on, the + // top-level `assistant` message is a duplicate of what we already + // streamed via content_block_* deltas — skip its content. + let gotPartialEvents = false const lineHandler = (line: string) => { if (!line.trim()) return if (controllerClosed) return + // Any line from the CLI counts as activity — reset the inactivity + // watchdog so mid-turn pauses between blocks don't get killed. + startResultFallback() + try { - const msg: ClaudeStreamMessage = JSON.parse(line) + const outer: ClaudeStreamMessage = JSON.parse(line) + + // Unwrap stream_event envelope (--include-partial-messages). + // Inner event uses the same content_block_* / message_* shape. + const msg: ClaudeStreamMessage = + outer.type === "stream_event" && outer.event + ? { ...outer.event, session_id: outer.session_id } + : outer + + const modelProgress = + (msg.type === "assistant" && !!msg.message?.content?.length) || + (msg.type === "content_block_start" && msg.content_block?.type === "tool_use") || + (msg.type === "content_block_delta" && + ((msg.delta?.type === "text_delta" && !!msg.delta.text) || + (msg.delta?.type === "thinking_delta" && !!msg.delta.thinking))) + if (modelProgress) { + hasReceivedProgress = true + clearStartWatchdog() + startResultFallback() + } + + if (outer.type === "stream_event") { + gotPartialEvents = true + } + + if (handleControlRequest(msg, proc)) { + return + } log.debug("stream message", { type: msg.type, @@ -570,6 +3902,42 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { claudeSessionId: msg.session_id, }) } + reportFastModeState(msg, fastMode) + reportSystemInit(msg, { + ignoreAnthropicApiKey: self.config.ignoreAnthropicApiKey, + }) + } + + // The CLI compacted its own context. Nothing else tells the user + // that everything before this point is now a summary. + if (msg.type === "system" && msg.subtype === "compact_boundary") { + const note = reportCompactBoundary(msg) + if (note) { + controller.enqueue({ type: "text-delta", id: startTextBlock(), delta: note }) + endTextBlock() + } + } + + // A rejection is why the turn is about to fail. Put it in the + // transcript so the reason does not live only in a log file that + // is off by default. + if (msg.type === "rate_limit_event") { + // Parsed separately from the reporter, which dedupes per + // process and returns null on a repeat: the second rejection in + // a session is still a rejection this turn has to act on. + const info = parseRateLimitEvent(msg) + if (info && isRateLimitRejected(info)) { + accountLimitHit = { + resetsAt: info.resetsAt ?? info.overageResetsAt, + window: info.rateLimitType, + } + } + const note = reportRateLimitEvent(msg) + if (note) { + controller.enqueue({ type: "text-delta", id: startTextBlock(), delta: note }) + endTextBlock() + } + return } // content_block_start @@ -582,43 +3950,61 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { const idx = msg.index if (block.type === "thinking") { + noteReasoning() const reasoningId = generateId() - reasoningIds.set(idx, reasoningId) - controller.enqueue({ - type: "reasoning-start", - id: reasoningId, - } as any) - reasoningStarted.set(idx, true) + reasoningIds.set(idx, reasoningId) } if (block.type === "text") { - if (!textStarted) { + textBlockIndices.add(idx) + // New text block — clear last-block buffer so final-answer + // detection only considers this block's contents, not earlier + // mid-task narration. + resetLastVisibleTextBlock() + if (block.text) { + if (!currentTextId) startTextBlock() controller.enqueue({ - type: "text-start", - id: textId, - } as any) - textStarted = true + type: "text-delta", + id: currentTextId!, + delta: block.text, + }) + noteVisibleText(block.text) + hasReceivedContent = true } } if (block.type === "tool_use" && block.id && block.name) { - toolCallMap.set(idx, { + noteToolActivity() + const entry = { id: block.id, name: block.name, inputJson: "", - }) + started: false, + } + toolCallMap.set(idx, entry) if ( block.name !== "AskUserQuestion" && block.name !== "ask_user_question" && - block.name !== "ExitPlanMode" + block.name !== "ExitPlanMode" && + !block.name.startsWith(PROXY_TOOL_PREFIX) ) { - const { name: mappedName, skip } = mapTool(block.name) + const { name: mappedName, skip, executed } = mapTool( + block.name, + undefined, + { + webSearch: self.config.webSearch, + sessionId: getClaudeSessionId(sk), + toolUseId: block.id, + }, + ) if (!skip) { + entry.started = true controller.enqueue({ type: "tool-input-start", id: block.id, toolName: mappedName, + providerExecuted: executed, } as any) log.info("tool started", { name: block.name, @@ -640,8 +4026,17 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { const idx = msg.index if (delta.type === "thinking_delta" && delta.thinking) { + noteReasoning() + hadThinkingTextFromStream = true const reasoningId = reasoningIds.get(idx) if (reasoningId) { + if (!reasoningStarted.get(idx)) { + controller.enqueue({ + type: "reasoning-start", + id: reasoningId, + } as any) + reasoningStarted.set(idx, true) + } controller.enqueue({ type: "reasoning-delta", id: reasoningId, @@ -651,31 +4046,43 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } if (delta.type === "text_delta" && delta.text) { - if (!textStarted) { - controller.enqueue({ - type: "text-start", - id: textId, - } as any) - textStarted = true - } + if (!currentTextId) startTextBlock() controller.enqueue({ type: "text-delta", - id: textId, + id: currentTextId!, delta: delta.text, }) + noteVisibleText(delta.text) + hasReceivedContent = true } if (delta.type === "input_json_delta" && delta.partial_json) { const tc = toolCallMap.get(idx) if (tc) { tc.inputJson += delta.partial_json - controller.enqueue({ - type: "tool-input-delta", - id: tc.id, - delta: delta.partial_json, - } as any) + // Only forward deltas for tool calls whose tool-input-start + // was actually emitted. Skipped tools (CLAUDE_INTERNAL_TOOLS, + // TaskCreate/TaskUpdate, CLI-internal WebSearch, AskUserQuestion, + // ExitPlanMode, proxy tools) never get a named start part, so + // forwarding their deltas makes opencode's AI SDK bridge fall + // back to a nameless pending part rendered as `⚙ unknown`. + if (tc.started) { + controller.enqueue({ + type: "tool-input-delta", + id: tc.id, + delta: delta.partial_json, + } as any) + } } } + + if (!KNOWN_DELTA_TYPES.has(delta.type)) { + log.debug("unrecognized content_block_delta type", { + type: delta.type, + idx, + keys: Object.keys(delta), + }) + } } // content_block_stop @@ -694,70 +4101,104 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { reasoningStarted.delete(idx) } + if (textBlockIndices.has(idx)) { + endTextBlock() + textBlockIndices.delete(idx) + } + const tc = toolCallMap.get(idx) if (tc) { + // Block indices restart at 0 on every assistant message, and a + // turn can hold several (tool_use -> tool_result -> answer). + // Without this delete the entry outlives its message, so the + // next message's block at the same index re-emits a tool-call + // for an id opencode already completed. That second part never + // gets a result, opencode aborts it at stream end, and a + // subagent's `task` call reports "Tool execution aborted" + // even though the child answered correctly. + toolCallMap.delete(idx) let parsedInput: any = {} try { parsedInput = JSON.parse(tc.inputJson || "{}") } catch {} - if ( - tc.name === "AskUserQuestion" || - tc.name === "ask_user_question" - ) { - // Emit question as text - let question = "Question?" - if ( - parsedInput?.questions && - Array.isArray(parsedInput.questions) && - parsedInput.questions.length > 0 - ) { - question = - parsedInput.questions[0].question || - parsedInput.questions[0].text || - "Question?" - } else { - question = - parsedInput?.question || - parsedInput?.text || - "Question?" - } - - if (!textStarted) { - controller.enqueue({ - type: "text-start", - id: textId, - } as any) - textStarted = true - } + if (isAskUserQuestionTool(tc.name)) { + // Latch: the model handed control to the operator. Block any + // auto-continue nudge for the rest of the turn so it can't + // proceed on its own before the operator replies. + autoContinueState.sawAskUserQuestion = true + const askId = startTextBlock() controller.enqueue({ type: "text-delta", - id: textId, - delta: `\n\n_Asking: ${question}_\n\n`, + id: askId, + delta: formatAskUserQuestion(parsedInput), }) + endTextBlock() } else if (tc.name === "ExitPlanMode") { - // Emit plan as text and ask user to accept/refuse const plan = (parsedInput?.plan as string) || "" - if (!textStarted) { + if (planModeQuestionActive) { + // Approval bridge: render the plan, then hand the + // yes/no back to opencode's own `question` tool and end + // the turn on "tool-calls" so the outer loop runs it. + const questionCall = createExitPlanModeQuestionCall( + sk, + tc.id, + plan, + ) + const planId = startTextBlock() controller.enqueue({ - type: "text-start", - id: textId, - } as any) - textStarted = true + type: "text-delta", + id: planId, + delta: questionCall.text, + }) + finishWithQuestionCall(questionCall) + return } + + const planId = startTextBlock() controller.enqueue({ type: "text-delta", - id: textId, + id: planId, delta: `\n\n${plan}\n\n---\n**Do you want to proceed with this plan?** (yes/no)\n`, }) + endTextBlock() + } else if ( + isWebSearchTool(tc.name) && + isWebSearchHandledByCli(self.config.webSearch) + ) { + // Claude CLI runs WebSearch internally. Forwarding the + // "WebSearch" tool-call part would render an invalid tool + // row in opencode (no registry entry), so show the query + // as a text line instead. The result stays CLI-internal. + const query = + typeof parsedInput?.query === "string" + ? parsedInput.query + : JSON.stringify(parsedInput) + const searchId = startTextBlock() + controller.enqueue({ + type: "text-delta", + id: searchId, + delta: `\n> **Web search:** ${query}\n`, + }) + endTextBlock() + } else if (tc.name.startsWith(PROXY_TOOL_PREFIX)) { + noteProxyActivity() + log.debug("ignoring proxy tool_use block; broker handles it", { + name: tc.name, + id: tc.id, + }) } else { const { name: mappedName, input: mappedInput, executed, skip, - } = mapTool(tc.name, parsedInput) + } = mapTool(tc.name, parsedInput, { + webSearch: self.config.webSearch, + sessionId: getClaudeSessionId(sk), + toolUseId: tc.id, + }) if (!skip) { toolCallsById.set(tc.id, { @@ -765,6 +4206,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { name: tc.name, input: parsedInput, }) + if (!executed) skipResultForIds.add(tc.id) controller.enqueue({ type: "tool-call", @@ -784,25 +4226,114 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } } - // assistant message (complete, not streaming) - if (msg.type === "assistant" && msg.message?.content) { + // Capture protocol-level stop_reason from the streaming + // `message_delta` event (sent right before the final + // `message_stop`). Any non-empty value is the source-of-truth + // for why the turn ended — used to bypass the keyword heuristic. + if ( + gotPartialEvents && + msg.type === "message_delta" && + typeof (msg as any).delta?.stop_reason === "string" + ) { + lastStopReason = (msg as any).delta.stop_reason + } + + // assistant message (complete, not streaming). + // When --include-partial-messages is on, this is a duplicate of + // what we already streamed via content_block_* events. Skip it + // for content, but still capture stop_reason from it for the + // non-partial path. + if ( + msg.type === "assistant" && + msg.message && + typeof (msg.message as any).stop_reason === "string" + ) { + lastStopReason = (msg.message as any).stop_reason + } + // Fallback: extract thinking from the complete assistant + // message. opus-4-7's CLI strips thinking_delta from stream + // events but may include thinking in the final message. + if ( + msg.type === "assistant" && + msg.message?.content && + gotPartialEvents + ) { + const thinkingBlocks = (msg.message.content as any[]).filter( + (b) => b.type === "thinking", + ) + if (thinkingBlocks.length > 0) { + log.info("assistant message thinking blocks", { + count: thinkingBlocks.length, + hasText: thinkingBlocks.some( + (b) => typeof b.thinking === "string" && b.thinking.length > 0, + ), + hadStreamThinking: hadThinkingTextFromStream, + }) + if (!hadThinkingTextFromStream) { + for (const block of thinkingBlocks) { + if (block.thinking && block.thinking.length > 0) { + noteReasoning() + hadThinkingTextFromStream = true + const thinkingId = generateId() + controller.enqueue({ + type: "reasoning-start", + id: thinkingId, + } as any) + controller.enqueue({ + type: "reasoning-delta", + id: thinkingId, + delta: block.thinking, + } as any) + controller.enqueue({ + type: "reasoning-end", + id: thinkingId, + } as any) + } + } + } + } + } + if ( + msg.type === "assistant" && + msg.message?.content && + !gotPartialEvents + ) { + const hasText = msg.message.content.some( + (b: any) => b.type === "text" && b.text, + ) + const hasToolUse = msg.message.content.some( + (b: any) => b.type === "tool_use", + ) + + if (hasText) { + hasReceivedContent = true + } + + if (hasText && !hasToolUse) { + startResultFallback() + } + if (hasToolUse) { + clearFallbackTimer() + } + for (const block of msg.message.content) { if (block.type === "text" && block.text) { - if (!textStarted) { - controller.enqueue({ - type: "text-start", - id: textId, - } as any) - textStarted = true - } + // New text block — keep only this block's text in the + // last-block buffer for final-answer detection. + resetLastVisibleTextBlock() + const blockId = startTextBlock() controller.enqueue({ type: "text-delta", - id: textId, + id: blockId, delta: block.text, }) + endTextBlock() + noteVisibleText(block.text) + hasReceivedContent = true } if (block.type === "thinking" && block.thinking) { + noteReasoning() const thinkingId = generateId() controller.enqueue({ type: "reasoning-start", @@ -820,76 +4351,95 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } if (block.type === "tool_use" && block.id && block.name) { + noteToolActivity() const parsedInput = (block.input ?? {}) as Record< string, unknown > - toolCallsById.set(block.id, { - id: block.id, - name: block.name, - input: parsedInput, - }) - - if ( - block.name === "AskUserQuestion" || - block.name === "ask_user_question" - ) { - let question = "Question?" - if ( - parsedInput?.questions && - Array.isArray(parsedInput.questions) && - parsedInput.questions.length > 0 - ) { - const q = parsedInput.questions[0] as any - question = q.question || q.text || "Question?" - } else { - question = - (parsedInput?.question as string) || - (parsedInput?.text as string) || - "Question?" - } - if (!textStarted) { - controller.enqueue({ - type: "text-start", - id: textId, - } as any) - textStarted = true - } + if (isAskUserQuestionTool(block.name)) { + const askId = startTextBlock() controller.enqueue({ type: "text-delta", - id: textId, - delta: `\n\n_Asking: ${question}_\n\n`, + id: askId, + delta: formatAskUserQuestion(parsedInput), }) + endTextBlock() } else if (block.name === "ExitPlanMode") { - // Emit plan as text and ask user to accept/refuse const plan = (parsedInput?.plan as string) || "" - if (!textStarted) { + if (planModeQuestionActive) { + const questionCall = createExitPlanModeQuestionCall( + sk, + block.id, + plan, + ) + const planId = startTextBlock() controller.enqueue({ - type: "text-start", - id: textId, - } as any) - textStarted = true + type: "text-delta", + id: planId, + delta: questionCall.text, + }) + finishWithQuestionCall(questionCall) + return } + + const planId = startTextBlock() controller.enqueue({ type: "text-delta", - id: textId, + id: planId, delta: `\n\n${plan}\n\n---\n**Do you want to proceed with this plan?** (yes/no)\n`, }) + endTextBlock() + } else if ( + isWebSearchTool(block.name) && + isWebSearchHandledByCli(self.config.webSearch) + ) { + // CLI-internal WebSearch: render the query as text and + // drop the call/result parts (no opencode registry entry + // for "WebSearch" — would render as an invalid tool row). + toolCallsById.delete(block.id) + const query = + typeof parsedInput?.query === "string" + ? parsedInput.query + : JSON.stringify(parsedInput) + const searchId = startTextBlock() + controller.enqueue({ + type: "text-delta", + id: searchId, + delta: `\n> **Web search:** ${query}\n`, + }) + endTextBlock() + } else if (block.name.startsWith(PROXY_TOOL_PREFIX)) { + noteProxyActivity() + log.debug("ignoring proxy tool_use from assistant message", { + name: block.name, + id: block.id, + }) } else { const { name: mappedName, input: mappedInput, executed, skip, - } = mapTool(block.name, parsedInput) + } = mapTool(block.name, parsedInput, { + webSearch: self.config.webSearch, + sessionId: getClaudeSessionId(sk), + toolUseId: block.id, + }) if (!skip) { + toolCallsById.set(block.id, { + id: block.id, + name: block.name, + input: parsedInput, + }) + if (!executed) skipResultForIds.add(block.id) controller.enqueue({ type: "tool-input-start", id: block.id, toolName: mappedName, + providerExecuted: executed, } as any) controller.enqueue({ type: "tool-call", @@ -920,24 +4470,74 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { if (msg.type === "user" && msg.message?.content) { for (const block of msg.message.content) { if (block.type === "tool_result" && block.tool_use_id) { - const toolCall = toolCallsById.get(block.tool_use_id) - if (toolCall) { - let resultText = "" - if (typeof block.content === "string") { - resultText = block.content - } else if (Array.isArray(block.content)) { - resultText = block.content - .filter( - ( - c, - ): c is { type: string; text: string } => - c.type === "text" && - typeof c.text === "string", - ) - .map((c) => c.text) - .join("\n") + if (skipResultForIds.has(block.tool_use_id)) { + log.debug("skipping tool-result (opencode runs it)", { + toolUseId: block.tool_use_id, + }) + continue + } + + let resultText = "" + if (typeof block.content === "string") { + resultText = block.content + } else if (Array.isArray(block.content)) { + resultText = block.content + .filter( + ( + c, + ): c is { type: string; text: string } => + c.type === "text" && + typeof c.text === "string", + ) + .map((c) => c.text) + .join("\n") + } + + // Ledger hook: commit pending TaskCreate to opencode's todo + // panel via a synthetic todowrite emission. Pass-through — + // returns null for non-TaskCreate ids, so cheap and silent. + const claudeSessionId = getClaudeSessionId(sk) + if (claudeSessionId) { + const list = applyTaskCreateToolResult( + claudeSessionId, + block.tool_use_id, + resultText, + ) + if (list) { + const synthId = `todowrite_${block.tool_use_id}` + controller.enqueue({ + type: "tool-input-start", + id: synthId, + toolName: "todowrite", + providerExecuted: false, + } as any) + controller.enqueue({ + type: "tool-call", + toolCallId: synthId, + toolName: "todowrite", + input: JSON.stringify({ + todos: list.map((t) => ({ + id: t.id, + content: t.content, + status: t.status, + priority: "medium", + })), + }), + providerExecuted: false, + } as any) + noteToolActivity() } + } + const toolCall = toolCallsById.get(block.tool_use_id) + if (toolCall) { + // A CLI-executed tool that failed carries `is_error`. The + // AI SDK turns a `tool-result` with `isError` into a + // `tool-error` part, which is what makes opencode render + // the row as failed; without the flag every failed CLI + // tool was forwarded as a success whose output happened + // to be an error message. + const isError = block.is_error === true controller.enqueue({ type: "tool-result", toolCallId: block.tool_use_id, @@ -945,13 +4545,16 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { result: { output: resultText, title: toolCall.name, - metadata: {}, + metadata: isError ? { error: true } : {}, }, + ...(isError ? { isError: true } : {}), providerExecuted: true, } as any) + noteToolActivity() log.info("tool result emitted", { toolUseId: block.tool_use_id, name: toolCall.name, + isError, }) toolCallsById.delete(block.tool_use_id) } @@ -961,64 +4564,147 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { // result - end of conversation turn if (msg.type === "result") { + clearFallbackTimer() + if (msg.session_id) { setClaudeSessionId(sk, msg.session_id) } + + if (deliverPendingCompletions()) { + // Finish the abandoned turn before submitting its late result. + // Otherwise this result could close the stream for the new turn. + return + } + + // Some CLI failures only include user-readable text in + // `result.result` (no prior assistant text blocks). Emit it so + // opencode users don't see a blank turn. + if ( + !currentTextId && + msg.is_error && + typeof msg.result === "string" && + msg.result.trim().length > 0 + ) { + const errId = startTextBlock() + controller.enqueue({ + type: "text-delta", + id: errId, + delta: msg.result, + }) + } + + // The other half of the limit signal: some rejections only ever + // reach us as the error text of the terminal result. + if ( + !accountLimitHit && + msg.is_error && + isAccountLimitError({ + resultText: typeof msg.result === "string" ? msg.result : null, + }) + ) { + accountLimitHit = {} + } + + // A non-`success` subtype is a failed turn. Name it in the + // transcript and finish as an error, rather than letting it be + // recorded as an ordinary reply with the subtype only in a + // debug log line. + const failure = describeResultFailure(msg) + if (failure) { + resultFailure = msg.subtype + controller.enqueue({ + type: "text-delta", + id: startTextBlock(), + delta: formatResultFailureNote(failure), + }) + log.warn(failure, { sessionKey: sk, subtype: msg.subtype }) + } + + const turnStats = extractTurnStats(msg) resultMeta = { sessionId: msg.session_id, costUsd: msg.total_cost_usd, durationMs: msg.duration_ms, + durationApiMs: msg.duration_api_ms, + numTurns: msg.num_turns, usage: msg.usage, + modelUsage: msg.modelUsage, + // Names and ids only: a denial's `tool_input` can be a whole + // file write payload and has no business in metadata. + permissionDenials: msg.permission_denials?.map((denial) => ({ + tool_name: denial.tool_name, + tool_use_id: denial.tool_use_id, + })), } + // Logged whatever `turnStats` is set to: the footer is a + // display preference, the numbers are diagnostics. log.info("conversation result", { sessionId: msg.session_id, - durationMs: msg.duration_ms, numTurns: msg.num_turns, isError: msg.is_error, + subtype: msg.subtype, + ...turnStatsLogPayload(turnStats), }) - turnCompleted = true - - if (textStarted) { - controller.enqueue({ type: "text-end", id: textId }) - } - - for (const [idx, reasoningId] of reasoningIds) { - if (reasoningStarted.get(idx)) { + // Never on a compaction turn (the footer would be appended to + // what opencode stores as the summary) and never on a failed + // one (the error is the thing to read, not the bill). + if (self.config.turnStats && !compactionMode && !msg.is_error && !failure) { + const footer = formatTurnStatsBlock(turnStats) + if (footer) { controller.enqueue({ - type: "reasoning-end", - id: reasoningId, - } as any) + type: "text-delta", + id: startTextBlock(), + delta: footer, + }) } } - controller.enqueue({ - type: "finish", - finishReason: - toolCallMap.size > 0 ? "tool-calls" : "stop", - usage: { - inputTokens: msg.usage?.input_tokens, - outputTokens: msg.usage?.output_tokens, - totalTokens: - msg.usage?.input_tokens && - msg.usage?.output_tokens - ? msg.usage.input_tokens + - msg.usage.output_tokens - : undefined, - }, - providerMetadata: { - "claude-code": resultMeta, - }, - }) + turnCompleted = true - controllerClosed = true - lineEmitter.off("line", lineHandler) - lineEmitter.off("close", closeHandler) + endTextBlock() - try { - controller.close() - } catch {} + const shouldDeferResult = + !msg.is_error && + !autoContinueState.aborted && + !autoContinueState.sawAskUserQuestion + + if (drainBuffer.length > 0 && shouldDeferResult) { + log.info( + "waiting for parallel proxy calls at turn-result boundary", + { + sessionKey: sk, + count: drainBuffer.length, + }, + ) + scheduleResultBoundary( + () => completeResult(msg), + DRAIN_QUIET_MS, + ) + return + } + + if ( + drainBuffer.length === 0 && + hadProxyActivitySinceContinue && + shouldDeferResult + ) { + log.info( + "waiting for delayed proxy call at turn-result boundary", + { + sessionKey: sk, + graceMs: PROXY_RESULT_BOUNDARY_GRACE_MS, + }, + ) + scheduleResultBoundary( + () => completeResult(msg), + PROXY_RESULT_BOUNDARY_GRACE_MS, + ) + return + } + + completeResult(msg) } } catch (e) { log.debug("failed to parse line", { @@ -1031,65 +4717,422 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { const closeHandler = () => { log.debug("readline closed") if (controllerClosed) return + // Claude CLI's stdio is gone. The proxy-mcp HTTP requests that + // backed any pending tool calls have no one to answer them now — + // reject so the handlers return errors rather than hang. + if (drainBuffer.length > 0 || getPendingProxyCalls(sk).length > 0) { + rejectAllPendingProxyCallsForSession( + sk, + new Error( + "Claude CLI subprocess closed before pending tool calls were resolved", + ), + ) + drainBuffer.length = 0 + } + // A close without a terminal `result` means the child died mid-turn. + // Reporting that as `stop` with empty usage made a crashed CLI look + // like a short but successful answer. An abort is not a crash: the + // operator asked for it, and the CLI may exit before its interrupt + // result lands. + const crashed = !turnCompleted && !autoContinueState.aborted controllerClosed = true - lineEmitter.off("line", lineHandler) - lineEmitter.off("close", closeHandler) - if (textStarted) { - controller.enqueue({ type: "text-end", id: textId }) + cleanupTurn() + endTextBlock() + + const finishClose = ( + exitCode: number | null, + signal: NodeJS.Signals | null, + ) => { + if (crashed) { + log.warn("claude process closed without a result", { + sessionKey: sk, + exitCode, + signal, + stderrBytes: activeProcess?.lastStderr?.length ?? 0, + }) + controller.enqueue({ + type: "error", + error: new Error( + describeChildCrash(exitCode, signal, activeProcess?.lastStderr), + ), + }) + } + controller.enqueue({ + type: "finish", + finishReason: toFinishReason(crashed ? "error" : "stop"), + usage: toUsage(), + providerMetadata: { + "claude-code": { + ...resultMeta, + ...(compactionMode + ? { compactionModel: effectiveModelId } + : {}), + }, + }, + }) + try { + controller.close() + } catch {} } - controller.enqueue({ - type: "finish", - finishReason: "stop", - usage: { - inputTokens: undefined, - outputTokens: undefined, - totalTokens: undefined, - }, - providerMetadata: { - "claude-code": resultMeta, - }, - }) - try { - controller.close() - } catch {} + + // stdout usually reaches EOF a tick before the child's `exit` event, + // so the status that explains the crash is not known yet here. The + // turn is over either way; wait briefly for it rather than report a + // bare "closed its output". Bounded, and only on the crash path. + if (crashed && proc.exitCode === null && proc.signalCode === null) { + let reported = false + const report = ( + exitCode: number | null, + signal: NodeJS.Signals | null, + ) => { + if (reported) return + reported = true + clearTimeout(exitGrace) + proc.off("exit", onExit) + finishClose(exitCode, signal) + } + const onExit = (code: number | null, signal: NodeJS.Signals | null) => + report(code, signal) + const exitGrace = setTimeout( + () => report(proc.exitCode, proc.signalCode), + CHILD_EXIT_STATUS_GRACE_MS, + ) + proc.once("exit", onExit) + return + } + finishClose(proc.exitCode, proc.signalCode) } - lineEmitter.on("line", lineHandler) - lineEmitter.on("close", closeHandler) + // Centralised per-turn teardown. Every exit path funnels through here + // so we don't accumulate listeners across turns on a reused process. + let cleanedUp = false + const cleanupTurn = () => { + if (cleanedUp) return + cleanedUp = true + clearFallbackTimer() + pendingResultCompletion = null + clearStartWatchdog() + if (drainTimer) { + clearTimeout(drainTimer) + drainTimer = null + } + lineEmitter.off("line", lineHandler) + lineEmitter.off("close", closeHandler) + pendingProxyUnsubscribe?.() + pendingProxyUnsubscribe = null + asideSinkUnregister?.() + asideSinkUnregister = null + proc.off("error", procErrorHandler) + } - proc.on("error", (err: Error) => { + const procErrorHandler = (err: Error) => { log.error("process error", { error: err.message }) + deleteActiveProcess(sk) + deleteClaudeSessionId(sk) if (controllerClosed) return + // Subprocess failure invalidates every pending HTTP-bound tool + // call for this session. Reject them so proxy-mcp returns errors + // to Claude rather than letting the sockets stall. + if (drainBuffer.length > 0 || getPendingProxyCalls(sk).length > 0) { + rejectAllPendingProxyCallsForSession( + sk, + new Error( + `Claude CLI subprocess error: ${err.message}`, + ), + ) + drainBuffer.length = 0 + } controllerClosed = true + cleanupTurn() controller.enqueue({ type: "error", error: err }) try { controller.close() } catch {} + } + + // Whatever the child said while no turn was listening comes first: + // the operator gets to see it, and a turn that already ended on the + // CLI's side is known before this one decides what to send. + if (activeProcess) { + const unattended = takeUnattendedLines(activeProcess) + if (unattended.lines.length > 0 || unattended.dropped > 0) { + log.notice("replaying stdout the child emitted between turns", { + sessionKey: sk, + lines: unattended.lines.length, + dropped: unattended.dropped, + }) + // Render narration only. Replaying actionable events could execute + // old tools or close this new stream on a stale approval/result. + let partialText = false + { + if (unattended.dropped > 0) { + const id = startTextBlock() + controller.enqueue({ + type: "text-delta", + id, + delta: `> _${unattended.dropped} lines of output emitted between turns were dropped._\n\n`, + }) + } + for (const line of unattended.lines) { + try { + const outer: ClaudeStreamMessage = JSON.parse(line) + const msg = outer.type === "stream_event" && outer.event ? outer.event : outer + let text = "" + if (msg.type === "content_block_delta" && msg.delta?.type === "text_delta") { + text = msg.delta.text ?? "" + partialText = true + } else if (msg.type === "assistant") { + if (!partialText) text = (msg.message?.content ?? []).filter((part) => part.type === "text").map((part) => part.text ?? "").join("") + partialText = false + } else if (msg.type === "result") { + unattendedTurnEnded = true + for (const entry of activeProcess.pendingProxyCompletions?.values() ?? []) { + if (isPendingProxyCallChannelClosed(entry.call)) entry.recoveryRequired = true + } + if (outer.session_id) setClaudeSessionId(sk, outer.session_id) + if (msg.is_error && msg.result) text = msg.result + } + if (text) controller.enqueue({ type: "text-delta", id: currentTextId ?? startTextBlock(), delta: text }) + } catch { /* Ignore incomplete or malformed buffered lines. */ } + } + } + endTextBlock() + // Replayed lines are history, not liveness: the watchdogs below + // must judge the child on what it does from here on. + clearFallbackTimer() + hasReceivedContent = false + } + } + + if (activeProcess && !compactionMode) { + activeProcess.opencodeSessionID = affinity + activeProcess.asideTransport = asideTransportRef + } + if (!compactionMode) { + // Lets a `/btw` answered while this turn runs land in the turn's own + // reply instead of a toast (btw-command.ts). Its own text block, so + // the marker stays at the start of a part and the block can be + // stripped exactly when a transcript is rebuilt. + asideSinkUnregister = registerAsideSink(affinity, (text) => { + if (controllerClosed) return false + const asideId = startTextBlock() + controller.enqueue({ type: "text-delta", id: asideId, delta: text }) + endTextBlock() + return true + }) + } + lineEmitter.on("line", lineHandler) + lineEmitter.on("close", closeHandler) + + pendingProxyUnsubscribe = onPendingProxyCall(sk, (call) => { + if (controllerClosed) { + // Stream already closed (we already drained). Late arrival — + // reject immediately so the proxy-mcp HTTP request returns + // instead of hanging until its 10-min timeout. + log.warn( + "pending proxy call arrived after stream close; rejecting", + { + sessionKey: sk, + toolCallId: call.toolCallId, + toolName: call.toolName, + }, + ) + rejectPendingProxyCallById( + call.toolCallId, + new Error( + `Pending proxy call '${call.toolName}' arrived after the stream was already closed`, + ), + ) + return + } + log.info("received pending proxy call for session", { + sessionKey: sk, + toolCallId: call.toolCallId, + toolName: call.toolName, + }) + noteProxyActivity() + noteToolActivity() + drainBuffer.push(call) + if (noteResultBoundaryCall()) return + if (drainTimer) clearTimeout(drainTimer) + drainTimer = setTimeout(drainNow, DRAIN_QUIET_MS) }) + proc.on("error", procErrorHandler) + // On abort, keep process alive for next message if (options.abortSignal) { + // Proxy calls this turn handed to opencode will never get a result + // once the operator aborts: opencode stops its tool runs with the + // turn. Release them now, so the CLI's parked requests return and + // nothing waits for the next message to find out. Late-result + // recovery is untouched: it holds results that already arrived. + const releaseAbandonedProxyCalls = (reason: string) => { + if (drainBuffer.length === 0 && getPendingProxyCalls(sk).length === 0) return + rejectAllPendingProxyCallsForSession(sk, new Error(reason)) + drainBuffer.length = 0 + } options.abortSignal.addEventListener("abort", () => { - if (!turnCompleted) { + autoContinueState.aborted = true + if (turnCompleted || controllerClosed) { + // This stream already ended on a proxy tool boundary and + // opencode was running the tool when the operator aborted. + // The CLI is parked in that call and nobody else will answer + // it; but only while no later turn has attached to the + // process, since that turn's calls are its own. + if ( + activeProcess && + activeProcess.lineEmitter.listenerCount("line") === 0 && + getPendingProxyCalls(sk).length > 0 + ) { + log.info("abort between proxy tool boundaries; releasing pending calls", { sk }) + void interruptTurn(activeProcess).then((idle) => { + log.info("interrupt sent for aborted turn", { sk, idle }) + }) + releaseAbandonedProxyCalls( + "Provider stream was aborted while opencode was running its proxy tool calls", + ) + } + return + } + + // Stop the CLI's turn, not just our end of the stream: it would + // otherwise run the abandoned turn to completion, billing tokens + // and executing tools, with its late output landing in the next + // turn. The process itself stays alive for the next message. + if (activeProcess) { + void interruptTurn(activeProcess).then((idle) => { + log.info("interrupt sent for aborted turn", { sk, idle }) + }) + } + + if (!hasReceivedContent) { log.info( - "abort signal received mid-turn, keeping process alive", + "abort signal received before content, closing stream immediately", { cwd }, ) - } - if (!controllerClosed) { + releaseAbandonedProxyCalls( + "Provider stream was aborted before pending proxy calls were emitted", + ) controllerClosed = true - lineEmitter.off("line", lineHandler) - lineEmitter.off("close", closeHandler) + cleanupTurn() try { controller.close() } catch {} + return } + + log.info( + "abort signal received mid-turn, starting grace period", + { cwd }, + ) + releaseAbandonedProxyCalls( + "Provider stream was aborted while proxy tool calls were pending", + ) + // Abort grace period — short, since the user already asked to stop. + startResultFallback(5_000) }) } - // Send the user message + if (hasMatchedPendingResults) { + // Tool-result turn: the prompt carries opencode's results for the + // proxy tool calls we drained on the previous turn. Resolve each + // matched call (claude CLI's HTTP handlers wake up and continue). + // Parallel tools may complete in separate opencode turns. Keep + // unmatched siblings pending until their own result, an explicit + // abort/new user turn, or the proxy deadline. + for (const { call, result } of previousPendingProxyMatches) { + if (result) { + const channelClosed = isPendingProxyCallChannelClosed(call) + log.info("resolving pending proxy call from tool result prompt", { + sessionKey: sk, + toolCallId: call.toolCallId, + toolName: call.toolName, + channelClosed, + }) + const completions = (activeProcess!.pendingProxyCompletions ??= new Map()) + if (!completions.has(call.toolCallId)) { + completions.set(call.toolCallId, { + call, + result, + recoveryRequired: channelClosed || unattendedTurnEnded, + }) + } + // With a closed channel this only clears the broker entry; + // proxy-mcp drops the write and the result travels below. + resolvePendingProxyCallById(call.toolCallId, result) + } else { + log.info( + "leaving unmatched parallel proxy call pending", + { + sessionKey: sk, + toolCallId: call.toolCallId, + toolName: call.toolName, + }, + ) + } + } + + if (unattendedTurnEnded) deliverPendingCompletions() + + // Calls queued while no turn was attached were never handed to + // opencode; the child is blocked on them right now. + const unemitted = getPendingProxyCalls(sk).filter( + (call) => !call.emitted, + ) + if (unemitted.length > 0) { + log.notice("draining proxy calls queued between turns", { + sessionKey: sk, + toolCallIds: unemitted.map((call) => call.toolCallId), + }) + drainBuffer.push(...unemitted) + drainNow() + return + } + + if (getPendingProxyCalls(sk).length === 0) { + armStartWatchdog() + } + return + } + + // No pending calls had matching tool-results. If any pending calls + // are still hanging around from a prior turn, reject them so the + // HTTP handlers in proxy-mcp don't sit blocked forever while we + // proceed with a brand new user message. + if (previousPendingProxyCalls.length > 0) { + for (const call of previousPendingProxyCalls) { + rejectPendingProxyCallById( + call.toolCallId, + new Error( + `Pending proxy call '${call.toolName}' (${call.toolCallId}) was orphaned by a new user turn; rejecting`, + ), + ) + } + } + + // Send the user message for a fresh turn. + if (activeProcess) noteTurnStarted(activeProcess) proc.stdin?.write(userMsg + "\n") log.debug("sent user message", { textLength: userMsg.length }) + // Arm the start watchdog so a reused child that goes silent after + // the envelope write (seen after a long proxy-blocked tool call) + // is respawned with --session-id instead of hanging the turn. + armStartWatchdog() + } + + void setup().catch((err) => { + log.error("failed to set up doStream", { + error: err instanceof Error ? err.message : String(err), + }) + controller.enqueue({ + type: "error", + error: err instanceof Error ? err : new Error(String(err)), + }) + try { + controller.close() + } catch {} + }) }, cancel() { // Consumer cancelled the stream diff --git a/src/claude-session-bun.ts b/src/claude-session-bun.ts new file mode 100644 index 0000000..01ffc47 --- /dev/null +++ b/src/claude-session-bun.ts @@ -0,0 +1,568 @@ +import * as os from "node:os" +import * as fs from "node:fs" +import * as path from "node:path" +import { execFileSync } from "node:child_process" +import { randomUUID } from "node:crypto" +import { cliHygieneEnv } from "./cli-version.js" + +/** + * Persistent interactive Claude Code session driven over Bun's NATIVE PTY + * (Bun.spawn `terminal` option = openpty on POSIX, ConPTY on Windows). This is + * the in-process Bun port of claude-tui-bridge/src/claudeSession.ts: same + * design, node-pty swapped for Bun's own ConPTY so it runs inside opencode's + * Bun runtime with NO node sidecar and NO node-pty dependency. + * + * - ONE long-lived interactive `claude` process per session (multi-turn), + * - turns injected by writing into the terminal (bracketed paste + Enter), + * - replies captured by tailing the session JSONL transcript + * (/projects//.jsonl) and + * parsing the assistant records; completion detected by a terminal + * `stop_reason`. + * + * Driving the INTERACTIVE TUI (real TTY) keeps model calls on the subscription + * billing path (not `claude -p` / Agent SDK, which meter after 2026-06-15). + */ + +function resolveClaude(cmd = "claude"): string { + if (path.isAbsolute(cmd) && fs.existsSync(cmd)) return cmd + const viaBun = Bun.which(cmd) + if (viaBun) return viaBun + const isWin = os.platform() === "win32" + try { + const out = execFileSync(isWin ? "where" : "which", [cmd], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }) + const first = out + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean) + .find((p) => fs.existsSync(p)) + if (first) return first + } catch {} + throw new Error(`Could not resolve command on PATH: ${cmd}`) +} + +/** Claude encodes the absolute cwd into the transcript dir name by replacing + * EVERY non-alphanumeric char with `-` (no collapsing of runs). Verified on + * Windows against ~/.claude/projects, e.g.: + * C:\code\my-app -> C--code-my-app + * C:\dev\My Project -> C--dev-My-Project (the space also becomes `-`). */ +export function encodeCwd(cwd: string): string { + return path.resolve(cwd).replace(/[^a-zA-Z0-9]/g, "-") +} + +export interface TurnResult { + text: string + stopReason: string | null + usage: any | null + cacheReadTokens: number + cacheCreationTokens: number + ephemeral1hTokens: number + ephemeral5mTokens: number + inputTokens: number + outputTokens: number + elapsedMs: number +} + +export interface ClaudeSessionOptions { + cwd?: string + /** Claude CLI executable or account wrapper path. */ + cliPath?: string + /** Claude config root used for JSONL transcripts (defaults to ~/.claude). */ + configDir?: string + model?: string + /** '' bypasses CLAUDE.md + user/project/local settings load (fast tests). + * null/undefined omits the flag entirely (normal settings). */ + settingSources?: string | null + extraArgs?: string[] + /** Strip ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN from the spawn env so the + * CLI uses subscription auth instead of pay-as-you-go API billing. */ + ignoreAnthropicApiKey?: boolean + /** CLI effort level (low | medium | high | xhigh | max), exported as + * CLAUDE_CODE_EFFORT_LEVEL so it overrides the account's settings.json. */ + effort?: string + cols?: number + rows?: number + bootMinMs?: number + bootQuietMs?: number + bootMaxMs?: number + pollMs?: number + turnTimeoutMs?: number + /** false = plain write(prompt)+Enter; true = wrap in bracketed-paste so + * multi-line prompts don't submit early. Default true. */ + bracketedPaste?: boolean + /** Submitting a turn: a large/multi-line bracketed paste collapses into a + * "[Pasted text]" placeholder, and an Enter sent while claude is still + * ingesting the paste is silently DROPPED — so a single fixed-delay Enter is + * unreliable and the turn can hang until turnTimeoutMs. Instead: wait + * submitMinMs, send Enter, then confirm the turn was accepted (a new + * transcript record appears) within submitConfirmMs; if not, resend Enter, + * up to submitMaxRetries times. */ + submitMinMs?: number + submitConfirmMs?: number + submitMaxRetries?: number + /** Abort the call (during boot or an in-flight turn): kills the process and + * rejects with an "aborted" error. */ + signal?: AbortSignal + debug?: boolean +} + +/** + * Env for the interactive (TUI) child. The headless counterpart is + * `claudeSpawnEnv` in session-manager.ts; both must apply `cliHygieneEnv`, so + * this is a named function rather than an object literal inside `Bun.spawn`, + * which no test can reach without a real PTY. + */ +export function interactiveSpawnEnv(opts: { + configDir: string + ignoreAnthropicApiKey?: boolean + effort?: string +}): Record { + return { + ...process.env, + CLAUDE_CONFIG_DIR: opts.configDir, + TERM: "xterm-256color", + // Pin the binary so a mid-session autoupdate cannot invalidate the + // detected version the flag gates read, and skip non-essential traffic. + // Fills gaps only, so a var the user exported survives untouched. + ...cliHygieneEnv(), + ...(opts.ignoreAnthropicApiKey + ? { ANTHROPIC_API_KEY: undefined, ANTHROPIC_AUTH_TOKEN: undefined } + : {}), + ...(opts.effort ? { CLAUDE_CODE_EFFORT_LEVEL: opts.effort } : {}), + } +} + +const TERMINAL_STOP = new Set(["end_turn", "stop_sequence", "max_tokens"]) +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +function resolveConfigDir(configDir: string | undefined): string { + const value = configDir ?? process.env.CLAUDE_CONFIG_DIR + if (!value) return path.join(os.homedir(), ".claude") + if (value === "~") return os.homedir() + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(os.homedir(), value.slice(2)) + } + return path.resolve(value) +} + +export class ClaudeSession { + readonly sessionId: string + readonly cwd: string + readonly configDir: string + readonly jsonlPath: string + raw = "" + + private proc: BunSubprocess | null = null + private cursor = 0 // index into transcript split('\n') + private lastDataAt = 0 + private exited = false + private exitCode: number | null = null + private aborted = false + private readonly signal?: AbortSignal + private readonly o: Required< + Omit< + ClaudeSessionOptions, + | "cliPath" + | "configDir" + | "model" + | "settingSources" + | "extraArgs" + | "signal" + | "ignoreAnthropicApiKey" + | "effort" + > + > & + Pick< + ClaudeSessionOptions, + | "cliPath" + | "configDir" + | "model" + | "settingSources" + | "extraArgs" + | "ignoreAnthropicApiKey" + | "effort" + > + + constructor(opts: ClaudeSessionOptions = {}) { + this.cwd = path.resolve(opts.cwd ?? process.cwd()) + this.configDir = resolveConfigDir(opts.configDir) + this.signal = opts.signal + this.sessionId = randomUUID() + this.jsonlPath = path.join( + this.configDir, + "projects", + encodeCwd(this.cwd), + `${this.sessionId}.jsonl`, + ) + this.o = { + cwd: this.cwd, + cliPath: opts.cliPath, + configDir: this.configDir, + model: opts.model, + settingSources: opts.settingSources, + extraArgs: opts.extraArgs ?? [], + ignoreAnthropicApiKey: opts.ignoreAnthropicApiKey, + effort: opts.effort, + cols: opts.cols ?? 200, + rows: opts.rows ?? 50, + bootMinMs: opts.bootMinMs ?? 3000, + bootQuietMs: opts.bootQuietMs ?? 1500, + bootMaxMs: opts.bootMaxMs ?? 25000, + pollMs: opts.pollMs ?? 250, + // Agentic turns (tool loops) routinely run for many minutes; a short + // cap would surface as a mid-task error result. 30 min mirrors the + // proxy-tool ceiling rather than a chat-reply expectation. + turnTimeoutMs: opts.turnTimeoutMs ?? 1_800_000, + bracketedPaste: opts.bracketedPaste ?? true, + submitMinMs: opts.submitMinMs ?? 200, + submitConfirmMs: opts.submitConfirmMs ?? 1500, + submitMaxRetries: opts.submitMaxRetries ?? 8, + debug: opts.debug ?? false, + } + } + + async start(): Promise { + if (this.signal?.aborted) throw new Error("aborted before start") + this.signal?.addEventListener( + "abort", + () => { + this.aborted = true + this.dispose() + }, + { once: true }, + ) + const claude = resolveClaude(this.o.cliPath ?? "claude") + const args: string[] = ["--session-id", this.sessionId] + if (this.o.model) args.push("--model", this.o.model) + if (this.o.settingSources !== null && this.o.settingSources !== undefined) { + args.push("--setting-sources", this.o.settingSources) + } + if (this.o.extraArgs && this.o.extraArgs.length) args.push(...this.o.extraArgs) + + if (this.o.debug) + process.stderr.write(`[session] spawn: ${claude} ${args.join(" ")}\n`) + + this.lastDataAt = Date.now() + this.proc = Bun.spawn([claude, ...args], { + cwd: this.cwd, + env: interactiveSpawnEnv({ + // The resolved field, not `this.o.configDir`: same value (the + // constructor copies it in) but typed as always present. + configDir: this.configDir, + ignoreAnthropicApiKey: this.o.ignoreAnthropicApiKey, + effort: this.o.effort, + }), + terminal: { + cols: this.o.cols, + rows: this.o.rows, + data: (_term, d) => { + this.lastDataAt = Date.now() + const chunk = Buffer.from(d).toString("utf8") + this.raw += chunk + if (this.o.debug) process.stdout.write(chunk) + }, + }, + }) + this.proc.exited + .then((code) => { + this.exitCode = typeof code === "number" ? code : null + this.exited = true + this.proc = null + }) + .catch(() => { + this.exited = true + this.proc = null + }) + + await this.waitForBoot() + this.cursor = this.lineCount() + } + + /** Wait until the TUI has been quiet for bootQuietMs (Ink ready), bounded by + * bootMinMs..bootMaxMs. */ + private async waitForBoot(): Promise { + const start = Date.now() + while (Date.now() - start < this.o.bootMaxMs) { + await delay(150) + if (this.aborted) throw new Error("aborted during boot") + if (this.exited) { + throw new Error(this.failureMessage("claude exited during boot", true)) + } + const elapsed = Date.now() - start + const sinceData = Date.now() - this.lastDataAt + if (elapsed >= this.o.bootMinMs && sinceData >= this.o.bootQuietMs) return + } + } + + /** Submit the freshly-injected prompt and confirm the turn was actually + * accepted. A large bracketed paste collapses into a "[Pasted text]" + * placeholder; an Enter sent while claude is still ingesting the paste is + * silently dropped, so a single fixed-delay Enter races the paste and can + * leave the prompt sitting unsubmitted (→ hang until turnTimeoutMs). Send + * Enter, then poll for transcript growth past the cursor (the turn's records + * are written on acceptance); resend Enter until accepted or the retry + * budget is spent. Polling growth (not a blind delay) also stops us from + * sending a stray Enter once the turn is in flight. */ + private async submitTurn(): Promise { + await delay(this.o.submitMinMs) + for (let attempt = 0; attempt < this.o.submitMaxRetries; attempt++) { + if (this.aborted || this.exited || !this.proc) return + this.proc.terminal.write("\r") + const until = Date.now() + this.o.submitConfirmMs + while (Date.now() < until) { + await delay(80) + if (this.aborted || this.exited) return + if (this.lineCount() > this.cursor) return // turn accepted + } + } + } + + private readRawLines(): string[] { + try { + return fs.readFileSync(this.jsonlPath, "utf8").split("\n") + } catch { + return [] + } + } + + /** Count of complete lines (split('\n') minus the trailing/partial element). */ + private lineCount(): number { + const lines = this.readRawLines() + return lines.length > 0 ? lines.length - 1 : 0 + } + + private rawTail(max = 600): string { + const clean = this.raw + // Strip ANSI escape/control sequences before including terminal output in diagnostics. + .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "") + .replace(/\s+/g, " ") + .trim() + return clean.length > max ? clean.slice(-max) : clean + } + + private failureMessage(reason: string, includeRaw = false): string { + const parts = [ + `${reason} (sessionId=${this.sessionId}, jsonlPath=${this.jsonlPath}, exitCode=${this.exitCode ?? "unknown"})`, + ] + if (includeRaw) { + const tail = this.rawTail() + if (tail) parts.push(`terminalTail=${JSON.stringify(tail)}`) + } + return parts.join("; ") + } + + /** + * Inject a turn into the live session and return the assistant reply once a + * terminal stop_reason is observed in the transcript. + */ + async ask(prompt: string, perTurnTimeoutMs?: number): Promise { + if (this.aborted) throw new Error("aborted") + if (!this.proc || this.exited) + throw new Error("session not started or already exited") + const timeout = perTurnTimeoutMs ?? this.o.turnTimeoutMs + const t0 = Date.now() + + // Inject. Bracketed paste keeps multi-line prompts from submitting early; + // submitTurn() then presses Enter and confirms the turn was accepted, + // resending Enter if the (collapsed) paste swallowed the first one. + if (this.o.bracketedPaste) { + this.proc.terminal.write("\x1b[200~" + prompt + "\x1b[201~") + } else { + this.proc.terminal.write(prompt) + } + await this.submitTurn() + + const collected: string[] = [] + let lastUsage: any = null + let stopReason: string | null = null + const deadline = Date.now() + timeout + + while (Date.now() < deadline) { + await delay(this.o.pollMs) + if (this.aborted) throw new Error("aborted mid-turn") + const lines = this.readRawLines() + const lastComplete = lines.length - 1 // exclusive bound; trailing/partial line skipped + if (lastComplete <= this.cursor) { + // Drain the transcript before reacting to exit: a final assistant record + // can be flushed in the same tick the process exits. + if (this.exited) throw new Error(this.failureMessage("claude exited mid-turn", true)) + continue + } + + for (let i = this.cursor; i < lastComplete; i++) { + const s = lines[i] + if (!s || !s.trim()) continue + let rec: any + try { + rec = JSON.parse(s) + } catch { + continue + } + if (rec.type === "assistant" && rec.message) { + for (const b of rec.message.content ?? []) { + if (b?.type === "text" && typeof b.text === "string") + collected.push(b.text) + } + if (rec.message.usage) lastUsage = rec.message.usage + if ( + rec.message.stop_reason && + TERMINAL_STOP.has(rec.message.stop_reason) + ) { + stopReason = rec.message.stop_reason + } + } + } + this.cursor = lastComplete + if (stopReason) break + } + + if (!stopReason) { + throw new Error( + this.failureMessage( + `turn timed out after ${timeout}ms (no terminal assistant record; collected ${collected.length} text block(s))`, + ), + ) + } + + const u = lastUsage ?? {} + return { + text: collected.join("\n").trim(), + stopReason, + usage: lastUsage, + cacheReadTokens: u.cache_read_input_tokens ?? 0, + cacheCreationTokens: u.cache_creation_input_tokens ?? 0, + ephemeral1hTokens: u.cache_creation?.ephemeral_1h_input_tokens ?? 0, + ephemeral5mTokens: u.cache_creation?.ephemeral_5m_input_tokens ?? 0, + inputTokens: u.input_tokens ?? 0, + outputTokens: u.output_tokens ?? 0, + elapsedMs: Date.now() - t0, + } + } + + /** + * Like ask(), but instead of collecting the reply text it re-emits each NEW + * raw JSONL transcript line via onLine (verbatim) until a terminal + * stop_reason. Returns the terminal stop_reason + the last assistant usage. + * Used by the opencode plugin transport shim, which feeds these raw lines + * into the existing stream-json line handler unchanged. + */ + async tailTurn( + prompt: string, + onLine: (rawLine: string) => void, + perTurnTimeoutMs?: number + ): Promise<{ stopReason: string | null; usage: any | null }> { + if (this.aborted) throw new Error("aborted") + if (!this.proc || this.exited) + throw new Error("session not started or already exited") + const timeout = perTurnTimeoutMs ?? this.o.turnTimeoutMs + + if (this.o.bracketedPaste) { + this.proc.terminal.write("\x1b[200~" + prompt + "\x1b[201~") + } else { + this.proc.terminal.write(prompt) + } + await this.submitTurn() + + let lastUsage: any = null + let totalOutput = 0 + let stopReason: string | null = null + const deadline = Date.now() + timeout + + while (Date.now() < deadline) { + await delay(this.o.pollMs) + if (this.aborted) throw new Error("aborted mid-turn") + const lines = this.readRawLines() + const lastComplete = lines.length - 1 + if (lastComplete <= this.cursor) { + // Drain the transcript before reacting to exit: the terminal assistant + // record can land in the same tick the process exits. + if (this.exited) { + throw new Error(this.failureMessage("claude exited mid-turn", true)) + } + continue + } + for (let i = this.cursor; i < lastComplete; i++) { + const s = lines[i] + if (!s || !s.trim()) continue + onLine(s) + let rec: any + try { + rec = JSON.parse(s) + } catch { + continue + } + if (rec.type === "assistant" && rec.message) { + if (rec.message.usage) { + lastUsage = rec.message.usage + totalOutput += rec.message.usage.output_tokens ?? 0 + } + if ( + rec.message.stop_reason && + TERMINAL_STOP.has(rec.message.stop_reason) + ) { + stopReason = rec.message.stop_reason + } + } + } + this.cursor = lastComplete + if (stopReason) break + } + + // Context (input/cache) = the LAST record's full conversation state; output + // = SUM across all assistant records this turn (each generation), else + // multi-record tool turns undercount output. toUsage() prefers + // iterations[last], so patch that entry's output too. + let usage: any = lastUsage + if (lastUsage) { + usage = { ...lastUsage, output_tokens: totalOutput } + if (Array.isArray(lastUsage.iterations) && lastUsage.iterations.length > 0) { + const iters = lastUsage.iterations.map((it: any) => ({ ...it })) + iters[iters.length - 1] = { + ...iters[iters.length - 1], + output_tokens: totalOutput, + } + usage.iterations = iters + } + } + if (!stopReason) { + throw new Error( + this.failureMessage( + `turn timed out after ${timeout}ms (no terminal assistant record)`, + ), + ) + } + + return { stopReason, usage } + } + + dispose(): void { + if (this.proc) { + try { + this.proc.terminal.write("\x03") + } catch {} + try { + this.proc.kill() + } catch {} + try { + this.proc.terminal.close() + } catch {} + } + this.proc = null + } +} + +/** One-shot convenience (drop-in for `claude -p`): start, ask, dispose. */ +export async function askOnce( + prompt: string, + opts: ClaudeSessionOptions = {}, +): Promise { + const s = new ClaudeSession(opts) + await s.start() + try { + return await s.ask(prompt) + } finally { + s.dispose() + } +} diff --git a/src/claude-session-wrapper.ts b/src/claude-session-wrapper.ts new file mode 100644 index 0000000..5554017 --- /dev/null +++ b/src/claude-session-wrapper.ts @@ -0,0 +1,294 @@ +import { EventEmitter } from "node:events" +import { unlink } from "node:fs/promises" +import { ClaudeSession } from "./claude-session-bun.js" +import { cliEffortLevel, type ActiveProcess } from "./session-manager.js" +import type { ReasoningEffort } from "./types.js" +import { log } from "./logger.js" + +export interface InteractiveSpawnOptions { + cwd: string + /** Claude CLI executable or account wrapper path. */ + cliPath?: string + /** Claude config root used for JSONL transcripts. */ + configDir?: string + model?: string + /** Request Claude Code's fast mode (Opus 4.8 / Opus 5 only). Folded into + * the single `--settings` payload alongside `permissions`. */ + fastMode?: boolean + /** Bridged Claude `--mcp-config` file paths (from effectiveMcpConfig). */ + mcpConfigPaths?: string[] + /** Session-scoped `--plugin-dir` paths (from `resolveSkillPluginDirs`), + * which expose opencode skills to the TUI's native Skill tool. Already + * filtered for CLI support, and empty when there is nothing to bridge. */ + pluginDirs?: string[] + /** permissions.allow rules (e.g. mcp__server__*, Bash, Edit). */ + permissionsAllow?: string[] + /** Optional permission mode. `bypassPermissions` is ignored for interactive + * sessions because Claude Code shows a safety confirmation screen first. */ + permissionMode?: string + /** Temp file for --append-system-prompt-file (parity with the headless + * spawn; unlinked when the session is killed). */ + systemPromptFile?: string + /** "" = skip CLAUDE.md + ambient settings (fast e2e); null/undefined = + * normal settings (default — parity with the headless transport). */ + settingSources?: string | null + /** Strip ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN from the spawn env so the + * CLI uses subscription auth instead of pay-as-you-go API billing. */ + ignoreAnthropicApiKey?: boolean + /** Reasoning effort, exported as CLAUDE_CODE_EFFORT_LEVEL for the session. */ + effort?: ReasoningEffort +} + +/** + * doStream writes stream-json user envelopes to stdin + * (`{"type":"user","message":{content:[...]}}`). The interactive TUI expects + * plain typed text, so decode the envelope: extract the text blocks and drop + * anything that can't be typed into a terminal (an image block would paste + * megabytes of base64 into the chat). Tool results are rendered as labeled + * text so the model still sees the outcome. Non-envelope input (already plain + * text) passes through verbatim. + */ +export function decodeUserEnvelope(chunk: string): string { + let parsed: any + try { + parsed = JSON.parse(chunk) + } catch { + return chunk + } + if (!parsed || parsed.type !== "user" || !parsed.message) return chunk + const content = parsed.message.content + if (typeof content === "string") return content + if (!Array.isArray(content)) return chunk + + const parts: string[] = [] + let dropped = 0 + for (const block of content) { + if (block?.type === "text" && typeof block.text === "string") { + parts.push(block.text) + } else if (block?.type === "tool_result") { + const v = block.content + const text = + typeof v === "string" + ? v + : Array.isArray(v) + ? v + .map((i: any) => (i?.type === "text" ? i.text : "")) + .filter(Boolean) + .join("\n") + : "" + parts.push( + `[Tool result${block.tool_use_id ? ` ${block.tool_use_id}` : ""}]\n${text}`, + ) + } else { + dropped++ + } + } + if (dropped > 0) { + log.warn("interactive transport dropped non-text content blocks", { + dropped, + }) + } + return parts.join("\n\n") +} + +/** + * Adapt a ClaudeSession (interactive Bun ConPTY transport) to the ActiveProcess + * contract the doStream line handler depends on. The shim's `proc.stdin.write` + * injects a turn into the live interactive `claude` and re-emits each new JSONL + * transcript record on `lineEmitter` as a 'line' event, plus a synthetic + * `{type:'result'}` line on a terminal stop_reason so the existing finish branch + * (usage + providerMetadata + controller.close) fires unchanged. + * + * No node-pty, no node sidecar: runs in-process under opencode's Bun (which + * bundles a Bun version with native ConPTY). Interactive = subscription billing. + */ +/** + * The CLI flags an interactive spawn adds after `ClaudeSession`'s own + * `--session-id` / `--model` / `--setting-sources`. Exported so the spawn + * arguments can be checked without a PTY. + */ +export function interactiveExtraArgs(opts: InteractiveSpawnOptions): string[] { + const extraArgs: string[] = [] + if (opts.mcpConfigPaths && opts.mcpConfigPaths.length > 0) { + extraArgs.push( + "--mcp-config", + ...opts.mcpConfigPaths, + "--strict-mcp-config", + ) + } + // `--plugin-dir` is repeatable and scoped to this session only. + for (const dir of opts.pluginDirs ?? []) { + extraArgs.push("--plugin-dir", dir) + } + // One `--settings` for the whole flag-settings layer. The CLI accepts the + // flag once, so pushing a second occurrence would silently drop the first + // rather than merge it. + const flagSettings: Record = {} + if (opts.permissionsAllow && opts.permissionsAllow.length > 0) { + flagSettings.permissions = { allow: opts.permissionsAllow } + } + if (opts.fastMode) { + flagSettings.fastMode = true + } + if (Object.keys(flagSettings).length > 0) { + extraArgs.push("--settings", JSON.stringify(flagSettings)) + } + if (opts.permissionMode === "bypassPermissions") { + log.warn( + "interactive permissionMode bypassPermissions ignored: Claude Code prompts for confirmation in the TUI", + ) + } else if (opts.permissionMode) { + extraArgs.push("--permission-mode", opts.permissionMode) + } + if (opts.systemPromptFile) { + extraArgs.push("--append-system-prompt-file", opts.systemPromptFile) + } + return extraArgs +} + +export function spawnInteractiveProcess( + opts: InteractiveSpawnOptions, +): ActiveProcess { + const extraArgs = interactiveExtraArgs(opts) + + const session = new ClaudeSession({ + cwd: opts.cwd, + cliPath: opts.cliPath, + configDir: opts.configDir, + model: opts.model, + // Default null = normal CLAUDE.md + settings load, matching what the + // headless spawn does. "" (skip everything) is for fast e2e runs only. + settingSources: + opts.settingSources === undefined ? null : opts.settingSources, + extraArgs, + ignoreAnthropicApiKey: opts.ignoreAnthropicApiKey, + effort: opts.effort ? cliEffortLevel(opts.effort) : undefined, + }) + log.info("prepared interactive claude session", { + cwd: opts.cwd, + cliPath: opts.cliPath ?? "claude", + configDir: session.configDir, + model: opts.model, + effort: opts.effort, + sessionId: session.sessionId, + jsonlPath: session.jsonlPath, + }) + + const lineEmitter = new EventEmitter() + const errorHandlers = new Set<(err: Error) => void>() + let startPromise: Promise | null = null + + const ensureStarted = (): Promise => { + if (!startPromise) startPromise = session.start() + return startPromise + } + + const emitResult = ( + subtype: string, + isError: boolean, + result?: string, + usage?: unknown, + ): void => { + lineEmitter.emit( + "line", + JSON.stringify({ + type: "result", + subtype, + is_error: isError, + result, + session_id: session.sessionId, + usage: usage ?? {}, + total_cost_usd: null, + duration_ms: 0, + }), + ) + } + + const runTurn = (userMsg: string): void => { + void (async () => { + try { + await ensureStarted() + const { stopReason, usage } = await session.tailTurn(userMsg, (raw) => { + lineEmitter.emit("line", raw) + }) + // Synthesize the `result` line the headless transport would have + // emitted, so doStream's existing finish branch runs verbatim. A turn + // with no terminal stop_reason (timeout / session exit mid-turn) is + // reported HONESTLY as an error result — not a clean end_turn — so + // truncation is visible to the user and to auto-continue. + const timedOut = !stopReason + emitResult( + timedOut ? "error_during_execution" : stopReason, + timedOut, + timedOut + ? "Interactive transport: the turn ended without a terminal stop_reason (turn timeout or claude exit). Output above may be incomplete." + : undefined, + usage, + ) + } catch (err) { + const e = err instanceof Error ? err : new Error(String(err)) + log.error("interactive turn failed", { error: e.message }) + emitResult( + "error_during_execution", + true, + `Interactive transport failed: ${e.message}`, + ) + if (errorHandlers.size > 0) { + for (const h of errorHandlers) h(e) + } else { + lineEmitter.emit("close") + } + } + })() + } + + // Minimal ChildProcess-shaped shim: only the members doStream/session-manager + // actually touch (stdin.write, on/off 'error', kill). + const proc: any = { + stdin: { + write(chunk: string): boolean { + const raw = + typeof chunk === "string" && chunk.endsWith("\n") + ? chunk.slice(0, -1) + : chunk + // doStream writes stream-json envelopes; the TUI needs plain text. + runTurn(decodeUserEnvelope(raw)) + return true + }, + end(): void {}, + }, + stdout: null, + stderr: null, + pid: -1, + killed: false, + on(event: string, fn: (err: Error) => void): unknown { + if (event === "error") errorHandlers.add(fn) + return proc + }, + once(): unknown { + return proc + }, + off(event: string, fn: (err: Error) => void): unknown { + if (event === "error") errorHandlers.delete(fn) + return proc + }, + kill(): boolean { + try { + session.dispose() + } catch {} + if (opts.systemPromptFile) { + void unlink(opts.systemPromptFile).catch(() => {}) + } + proc.killed = true + return true + }, + } + + return { + proc: proc as unknown as ActiveProcess["proc"], + lineEmitter, + proxyServer: null, + mcpHash: undefined, + systemPromptFile: opts.systemPromptFile, + } +} diff --git a/src/cleanup-stale.ts b/src/cleanup-stale.ts new file mode 100644 index 0000000..fe7c011 --- /dev/null +++ b/src/cleanup-stale.ts @@ -0,0 +1,139 @@ +// Removes a stale unscoped `opencode-claude-code-plugin` install left in +// opencode's plugin cache by older configs. The unscoped name is a different +// artifact than this scoped plugin and shadows it when both coexist. +// Disable with OPENCODE_CLAUDE_CODE_PLUGIN_NO_CLEANUP=1. + +import { + existsSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs" +import { homedir } from "node:os" +import { join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { log } from "./logger.js" + +const STALE_PACKAGE_NAME = "opencode-claude-code-plugin" +const SUSPECT_DESCRIPTION_TOKEN = "Claude Code" + +let alreadyRan = false + +function candidateCacheRoots(): string[] { + const xdg = process.env.XDG_CACHE_HOME + return [ + xdg ? join(xdg, "opencode") : null, + join(homedir(), ".cache", "opencode"), + join(homedir(), "Library", "Caches", "opencode"), + ].filter((p): p is string => Boolean(p)) +} + +function userOpencodeJsonPath(): string { + const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config") + return join(xdgConfig, "opencode", "opencode.json") +} + +function userIntendsToUseUnscoped(): boolean { + const cfg = userOpencodeJsonPath() + if (!existsSync(cfg)) return false + try { + const json = JSON.parse(readFileSync(cfg, "utf8")) + const plugins: unknown = json.plugin + if (!Array.isArray(plugins)) return false + return plugins.some( + (entry) => + typeof entry === "string" && + /^opencode-claude-code-plugin(@[^/]+)?$/.test(entry), + ) + } catch { + return false + } +} + +function ourLoadedDir(): string | null { + try { + const filePath = fileURLToPath(import.meta.url) + return realpathSync(resolve(filePath, "..", "..")) + } catch { + return null + } +} + +export function cleanupStaleUnscopedInstall(): void { + if (alreadyRan) return + alreadyRan = true + + if (process.env.OPENCODE_CLAUDE_CODE_PLUGIN_NO_CLEANUP === "1") return + if (userIntendsToUseUnscoped()) return + + const ourDir = ourLoadedDir() + + for (const cacheRoot of candidateCacheRoots()) { + try { + cleanupOne(cacheRoot, ourDir) + } catch (err) { + log.warn("cleanup-stale: error processing cache root", { + cacheRoot, + error: String(err), + }) + } + } +} + +function cleanupOne(cacheRoot: string, ourDir: string | null): void { + if (!existsSync(cacheRoot)) return + + const stalePath = join(cacheRoot, "node_modules", STALE_PACKAGE_NAME) + if (!existsSync(stalePath)) return + + // Don't self-delete if we are the unscoped install. + let realStalePath = stalePath + try { + realStalePath = realpathSync(stalePath) + } catch { + // ignore + } + if (ourDir && realStalePath === ourDir) return + + // Verify identity before removing. + const pkgJsonPath = join(stalePath, "package.json") + if (!existsSync(pkgJsonPath)) return + let pkg: { name?: string; description?: string } = {} + try { + pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8")) + } catch { + return + } + if (pkg.name !== STALE_PACKAGE_NAME) return + if (!pkg.description?.includes(SUSPECT_DESCRIPTION_TOKEN)) return + + log.info("cleanup-stale: removing unscoped install", { stalePath }) + try { + rmSync(stalePath, { recursive: true, force: true }) + } catch (err) { + log.warn("cleanup-stale: rmSync failed", { + stalePath, + error: String(err), + }) + return + } + + // Drop the dep from the cache root's package.json so opencode's installer + // doesn't reinstate it on its next pass. Lockfile is left alone; bun + // reconciles against package.json on the next install. + const cachePkgJson = join(cacheRoot, "package.json") + if (!existsSync(cachePkgJson)) return + try { + const cfg = JSON.parse(readFileSync(cachePkgJson, "utf8")) + if (cfg?.dependencies?.[STALE_PACKAGE_NAME]) { + delete cfg.dependencies[STALE_PACKAGE_NAME] + writeFileSync(cachePkgJson, JSON.stringify(cfg, null, 2) + "\n") + log.info("cleanup-stale: pruned dep from cache package.json") + } + } catch (err) { + log.warn("cleanup-stale: cache package.json update failed", { + error: String(err), + }) + } +} diff --git a/src/cli-events.ts b/src/cli-events.ts new file mode 100644 index 0000000..34e0cb9 --- /dev/null +++ b/src/cli-events.ts @@ -0,0 +1,450 @@ +import { log } from "./logger.js" +import type { ClaudeStreamMessage } from "./types.js" + +/** + * Claude CLI stream events the plugin used to drop on the floor. + * + * Every shape below was read out of the CLI's own zod schemas in the installed + * bundle (`rg -a` over `~/.local/share/claude/versions/`) on 2.1.263, not + * guessed, but they are still parsed defensively: a future CLI may rename a + * field, and a diagnostic that throws is worse than one that stays quiet. + * + * Levels follow the rule the rest of this codebase uses. `src/logger.ts` routes + * only WARN and ERROR to stderr unconditionally, so anything the user has to + * see without turning on debug logging is either a WARN or a `▌` line written + * into the transcript. Everything else is INFO or NOTICE for the file log. + */ + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +function str(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined +} + +function num(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +// --------------------------------------------------------------------------- +// rate_limit_event +// --------------------------------------------------------------------------- + +/** + * `{type:"rate_limit_event", rate_limit_info:{...}}`, emitted whenever the + * CLI's view of the account's limits changes. `status` is the plan window, + * `overageStatus` is paid extra usage on top of it; either can be `rejected` + * on its own, so both are read. + */ +export interface RateLimitInfo { + status?: string + rateLimitType?: string + resetsAt?: number + utilization?: number + isUsingOverage?: boolean + overageStatus?: string + overageResetsAt?: number + overageDisabledReason?: string +} + +export const RATE_LIMIT_MARKER = "▌ **rate limit:**" + +/** + * What the operator can actually do about it. Same four levers the billing + * note in AGENTS.md lists, which is where this wording comes from: none of + * them is something the plugin may do on its own. + */ +export const RATE_LIMIT_ACTION = + "Enable extra usage or add credits on the account, wait for the window to reset, switch account, org or plan, or authenticate with an API key." + +const OVERAGE_DISABLED_REASONS: Record = { + overage_not_provisioned: "extra usage is not set up on this account", + org_level_disabled: "extra usage is disabled for your organization", + org_level_disabled_until: "extra usage is disabled for your organization for now", + out_of_credits: "the account's usage credits are spent", + seat_tier_level_disabled: "your seat tier does not allow extra usage", + member_level_disabled: "extra usage is disabled for your member account", + seat_tier_zero_credit_limit: "your seat tier has a zero credit limit", + group_zero_credit_limit: "your group has a zero credit limit", + member_zero_credit_limit: "your member account has a zero credit limit", + org_service_level_disabled: "your organization's service level does not include extra usage", + no_limits_configured: "no usage limits are configured for this account", + fetch_error: "the CLI could not read the account's usage limits", +} + +const RATE_LIMIT_WINDOWS: Record = { + five_hour: "the 5-hour window", + seven_day: "the 7-day window", + seven_day_opus: "the 7-day Opus window", + seven_day_sonnet: "the 7-day Sonnet window", + seven_day_overage_included: "the 7-day extra-usage window", + overage: "extra usage", +} + +export function parseRateLimitEvent(msg: ClaudeStreamMessage): RateLimitInfo | null { + if (msg.type !== "rate_limit_event") return null + const info = (msg as { rate_limit_info?: unknown }).rate_limit_info + if (!isRecord(info)) return null + return { + status: str(info.status), + rateLimitType: str(info.rateLimitType), + resetsAt: num(info.resetsAt), + utilization: num(info.utilization), + isUsingOverage: info.isUsingOverage === true, + overageStatus: str(info.overageStatus), + overageResetsAt: num(info.overageResetsAt), + overageDisabledReason: str(info.overageDisabledReason), + } +} + +/** The CLI sends unix seconds; tolerate milliseconds rather than mean 1970. */ +export function resetsAtToMs(resetsAt: number | undefined): number | undefined { + if (resetsAt === undefined || !Number.isFinite(resetsAt)) return undefined + return resetsAt < 1e12 ? resetsAt * 1000 : resetsAt +} + +export function formatResetsAt(resetsAt: number | undefined): string | undefined { + const ms = resetsAtToMs(resetsAt) + if (ms === undefined) return undefined + const date = new Date(ms) + return Number.isNaN(date.getTime()) ? undefined : date.toISOString() +} + +/** Dedup identity: one warning per (window, overage status, reason) per process. */ +export function rateLimitKey(info: RateLimitInfo): string { + return [ + info.status ?? "?", + info.rateLimitType ?? "?", + info.overageStatus ?? "?", + info.overageDisabledReason ?? "?", + ].join("|") +} + +export function isRateLimitRejected(info: RateLimitInfo): boolean { + return info.status === "rejected" || info.overageStatus === "rejected" +} + +export interface RateLimitReport { + key: string + level: "warn" | "notice" | "info" + message: string + /** Text to put in the transcript, or null when this is log-only. */ + transcript: string | null +} + +/** + * A rejection is the only state the user has to act on, so it is the only one + * that gets a WARN and a transcript line. A warning state is a NOTICE: it is + * real but not yet blocking, and a per-turn TUI bubble for "you are at 82%" + * would train people to ignore the blocking one. + */ +export function describeRateLimit(info: RateLimitInfo): RateLimitReport | null { + if (!info.status && !info.overageStatus) return null + const window = info.rateLimitType ? RATE_LIMIT_WINDOWS[info.rateLimitType] ?? info.rateLimitType : undefined + const resets = formatResetsAt(info.resetsAt) + const overageResets = formatResetsAt(info.overageResetsAt) + const key = rateLimitKey(info) + + if (isRateLimitRejected(info)) { + const parts: string[] = [] + parts.push( + info.status === "rejected" + ? `Claude Code rejected this request: you are out of usage${window ? ` in ${window}` : ""}.` + : "Claude Code rejected this request: paid extra usage is not available on this account.", + ) + const reason = info.overageDisabledReason + ? OVERAGE_DISABLED_REASONS[info.overageDisabledReason] ?? info.overageDisabledReason + : undefined + if (reason) parts.push(`Extra usage is unavailable because ${reason}.`) + const resetAt = resets ?? overageResets + if (resetAt) parts.push(`Resets at ${resetAt}.`) + parts.push(RATE_LIMIT_ACTION) + const message = parts.join(" ") + return { key, level: "warn", message, transcript: `\n${RATE_LIMIT_MARKER} ${message}\n` } + } + + if (info.status === "allowed_warning" || info.overageStatus === "allowed_warning") { + const used = + info.utilization === undefined ? "" : ` (${Math.round(info.utilization * 100)}% used)` + return { + key, + level: "notice", + message: `Approaching the usage limit${window ? ` for ${window}` : ""}${used}${ + resets ? `, resets at ${resets}` : "" + }.`, + transcript: null, + } + } + + return { + key, + level: "info", + message: `usage limits updated${window ? ` for ${window}` : ""}`, + transcript: null, + } +} + +const reportedRateLimits = new Set() + +/** Test-only. */ +export function _resetRateLimitReports(): void { + reportedRateLimits.clear() +} + +/** + * Logs the event and returns the transcript line to enqueue, if any. Deduped + * per identity per process: the CLI re-emits the same rejection on every + * subsequent request, and a repeated WARN would bury the first one. + */ +export function reportRateLimitEvent(msg: ClaudeStreamMessage): string | null { + const info = parseRateLimitEvent(msg) + if (!info) return null + const report = describeRateLimit(info) + if (!report) return null + const data: Record = { ...info } + if (reportedRateLimits.has(report.key)) { + log.debug(report.message, data) + return null + } + reportedRateLimits.add(report.key) + log[report.level](report.message, data) + return report.transcript +} + +// --------------------------------------------------------------------------- +// system / init +// --------------------------------------------------------------------------- + +export interface SystemInitInfo { + apiKeySource?: string + permissionMode?: string + model?: string + cliVersion?: string + toolCount: number + mcpServers: Array<{ name: string; status: string }> +} + +/** + * Credential sources that mean the CLI authenticated with an API key rather + * than the logged-in subscription, so the turn bills pay-as-you-go against the + * Console account and never touches the plan's Agent SDK credit. `oauth` is + * the subscription and `none` is no credential at all; everything else in the + * CLI's enum is a key from some scope. + */ +export const API_KEY_SOURCES = new Set([ + "ANTHROPIC_API_KEY", + "apiKeyHelper", + "/login managed key", + "user", + "project", + "org", + "temporary", +]) + +export function parseSystemInit(msg: ClaudeStreamMessage): SystemInitInfo | null { + if (msg.type !== "system" || msg.subtype !== "init") return null + const raw = msg as unknown as Record + const servers: Array<{ name: string; status: string }> = [] + if (Array.isArray(raw.mcp_servers)) { + for (const entry of raw.mcp_servers) { + if (!isRecord(entry)) continue + servers.push({ name: str(entry.name) ?? "unknown", status: str(entry.status) ?? "unknown" }) + } + } + return { + apiKeySource: str(raw.apiKeySource), + permissionMode: str(raw.permissionMode), + model: str(raw.model), + cliVersion: str(raw.claude_code_version), + toolCount: Array.isArray(raw.tools) ? raw.tools.length : 0, + mcpServers: servers, + } +} + +/** + * The warning text for an API-key session, or null when there is nothing to + * say. Kept pure and separate from the dedup so both halves are testable. + * + * `ignoreAnthropicApiKey` strips the env vars from the spawn, so a key still + * in effect after that came from the CLI's own settings and the option is not + * the fix; saying so is the whole point of reading this field rather than + * `process.env`, which only sees one of the two ways a key gets in. + */ +export function apiKeySourceWarning( + apiKeySource: string | undefined, + ignoreAnthropicApiKey: boolean | undefined, +): string | null { + if (!apiKeySource || !API_KEY_SOURCES.has(apiKeySource)) return null + const base = `Claude Code authenticated with an API key (apiKeySource: ${apiKeySource}), so these turns bill as pay-as-you-go API usage instead of your subscription's Agent SDK credit.` + return ignoreAnthropicApiKey + ? `${base} \`ignoreAnthropicApiKey\` is already on, so the key is not coming from the environment: check the CLI's own settings (\`claude config\`) or an \`apiKeyHelper\`.` + : `${base} Set the provider option \`ignoreAnthropicApiKey: true\` to strip the key from spawns and fall back to the stored subscription auth.` +} + +const warnedApiKeySources = new Set() +const warnedMcpFailures = new Set() + +/** Test-only. */ +export function _resetSystemInitReports(): void { + warnedApiKeySources.clear() + warnedMcpFailures.clear() +} + +/** + * Log the CLI's own view of the session it just started, and warn about the + * two things in it a user has to act on: a credential that changes who gets + * billed, and an MCP server that did not come up (the model simply will not + * have those tools, with no other sign of it). + */ +export function reportSystemInit( + msg: ClaudeStreamMessage, + options: { ignoreAnthropicApiKey?: boolean } = {}, +): void { + const info = parseSystemInit(msg) + if (!info) return + log.info("claude session init", { + apiKeySource: info.apiKeySource ?? null, + permissionMode: info.permissionMode ?? null, + model: info.model ?? null, + cliVersion: info.cliVersion ?? null, + tools: info.toolCount, + mcpServers: info.mcpServers, + }) + + for (const server of info.mcpServers) { + if (server.status === "connected") continue + const key = `${server.name}:${server.status}` + const message = `MCP server "${server.name}" is ${server.status} in Claude Code; its tools are not available to the model this session.` + if (warnedMcpFailures.has(key)) { + log.debug(message, { server: server.name, status: server.status }) + continue + } + warnedMcpFailures.add(key) + log.warn(message, { server: server.name, status: server.status }) + } + + const apiKeyMessage = apiKeySourceWarning(info.apiKeySource, options.ignoreAnthropicApiKey) + if (!apiKeyMessage) return + const key = `${info.apiKeySource}:${options.ignoreAnthropicApiKey ? "ignored" : "passed"}` + if (warnedApiKeySources.has(key)) { + log.debug(apiKeyMessage, { apiKeySource: info.apiKeySource }) + return + } + warnedApiKeySources.add(key) + log.warn(apiKeyMessage, { apiKeySource: info.apiKeySource }) +} + +// --------------------------------------------------------------------------- +// system / compact_boundary +// --------------------------------------------------------------------------- + +export const COMPACT_BOUNDARY_MARKER = "▌ **context compacted:**" + +export interface CompactBoundary { + trigger: string + preTokens?: number + postTokens?: number +} + +/** + * The CLI compacted its own context mid-conversation. Nothing in opencode + * shows this today, so a conversation can silently lose everything before the + * boundary and the next answer just looks forgetful. + * + * Field name confirmed on CLI 2.1.263: the stream schema emits + * `compact_metadata`, while the CLI's own transcript reader uses + * `compactMetadata`. Both are read, because it costs one line and the two + * spellings genuinely coexist inside the binary. + */ +export function parseCompactBoundary(msg: ClaudeStreamMessage): CompactBoundary | null { + if (msg.type !== "system" || msg.subtype !== "compact_boundary") return null + const raw = msg as unknown as Record + const meta = isRecord(raw.compact_metadata) + ? raw.compact_metadata + : isRecord(raw.compactMetadata) + ? raw.compactMetadata + : undefined + return { + trigger: str(meta?.trigger) ?? "unknown", + preTokens: num(meta?.pre_tokens) ?? num(meta?.preTokens), + postTokens: num(meta?.post_tokens) ?? num(meta?.postTokens), + } +} + +export function formatCompactBoundaryNote(boundary: CompactBoundary): string { + const how = boundary.trigger === "auto" ? "on its own" : `on a ${boundary.trigger} request` + const sizes = + boundary.preTokens !== undefined && boundary.postTokens !== undefined + ? ` (${boundary.preTokens.toLocaleString("en-US")} tokens to ${boundary.postTokens.toLocaleString("en-US")})` + : "" + return `\n${COMPACT_BOUNDARY_MARKER} Claude Code compacted its own context ${how}${sizes}. Earlier detail in this conversation is now a summary.\n` +} + +/** Logs the boundary and returns the transcript note, or null when not one. */ +export function reportCompactBoundary(msg: ClaudeStreamMessage): string | null { + const boundary = parseCompactBoundary(msg) + if (!boundary) return null + log.notice("claude code compacted its own context", { + trigger: boundary.trigger, + preTokens: boundary.preTokens ?? null, + postTokens: boundary.postTokens ?? null, + }) + return formatCompactBoundaryNote(boundary) +} + +// --------------------------------------------------------------------------- +// result subtype +// --------------------------------------------------------------------------- + +export const RESULT_ERROR_MARKER = "▌ **claude code error:**" + +const RESULT_SUBTYPES: Record = { + error_max_turns: "it hit its internal turn limit before finishing", + error_during_execution: "it failed while running the turn", + error_max_budget_usd: "it hit the configured spend limit for the turn", + error_max_structured_output_retries: "it could not produce valid structured output", +} + +/** + * A `result` whose subtype is not `success` is a failed turn, and until now it + * finished as a clean `stop`: opencode recorded it as a normal reply and the + * only trace of the subtype was a debug log line. + * + * This is the with-result case only. A CLI that dies without emitting a + * `result` at all is a different failure, handled elsewhere. + */ +export function describeResultFailure(msg: ClaudeStreamMessage): string | null { + if (msg.type !== "result") return null + const subtype = msg.subtype + if (!subtype || subtype === "success") return null + const explanation = RESULT_SUBTYPES[subtype] + return explanation + ? `Claude Code ended the turn with \`${subtype}\`: ${explanation}.` + : `Claude Code ended the turn with \`${subtype}\`.` +} + +export function formatResultFailureNote(message: string): string { + return `\n${RESULT_ERROR_MARKER} ${message}\n` +} + +// --------------------------------------------------------------------------- +// stdout silence after content +// --------------------------------------------------------------------------- + +export const STREAM_TIMEOUT_MARKER = "▌ **stream timeout:**" + +/** + * The inactivity watchdog in `doStream` closes a turn whose CLI produced + * output and then stopped talking without sending a `result`. That decision + * was log-only, so the operator saw a reply that simply stopped mid-thought + * with nothing saying why. This is the note that says it. + */ +export function formatStreamTimeoutNote(silenceMs: number): string { + const seconds = Math.max(1, Math.round(silenceMs / 1000)) + return ( + `\n${STREAM_TIMEOUT_MARKER} The Claude Code CLI produced output and then went ` + + `silent for ${seconds}s without finishing the turn, so it was closed without a ` + + "result. The answer above may be incomplete.\n" + ) +} diff --git a/src/cli-version.ts b/src/cli-version.ts new file mode 100644 index 0000000..e30e68c --- /dev/null +++ b/src/cli-version.ts @@ -0,0 +1,196 @@ +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { log } from "./logger.js" + +const execFileAsync = promisify(execFile) + +export interface CliVersion { + major: number + minor: number + patch: number + raw: string +} + +const cache = new Map>() + +/** + * Env vars set on every `claude` child so the binary we detected stays the + * binary we run. + * + * `detectCliVersion` resolves once per cliPath and caches that answer for the + * life of the opencode process, and several flags are gated on it: + * `--thinking-display summarized`, `--plugin-dir`, and fast mode via + * `--settings`. If the CLI autoupdates underneath a long-running opencode the + * cached version stops describing the binary actually being spawned, so a gated + * flag can be passed to a CLI that rejects it or withheld from one that + * supports it. A binary swapped mid-session is a plain correctness hazard + * besides. + * + * Both names were read out of the Claude Code 2.1.263 bundle rather than + * assumed (`rg -a` over the Mach-O, the technique AGENTS.md records for the CLI + * stream events). `DISABLE_AUTOUPDATER` is read as an update blocker, and + * `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` both suppresses non-essential + * network traffic and counts as a second, independent update blocker. + * Anthropic's own runner sets `DISABLE_AUTOUPDATER: "1"` on the children it + * spawns, which is the same use we are putting it to here. + */ +export const CLI_HYGIENE_ENV_VARS = [ + "DISABLE_AUTOUPDATER", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", +] as const + +/** + * The hygiene vars that are missing from `inherited`, each set to "1". + * + * Only ever fills a gap: a var the user exported themselves is left exactly as + * they set it, including an empty string, which both vars read as off. That is + * the same rule the thinking vars follow in `claudeSpawnEnv`, and it is the + * escape hatch for anyone who deliberately wants the autoupdater, so this needs + * no provider option of its own. + */ +export function cliHygieneEnv( + inherited: Record = process.env, +): Record { + const filled: Record = {} + for (const name of CLI_HYGIENE_ENV_VARS) { + if (inherited[name] === undefined) filled[name] = "1" + } + return filled +} + +/** + * Run `claude --version` once per cliPath and parse the leading semver. + * Returns null on any failure (binary missing, unparseable output, etc.) + * so callers can fall back to the most conservative flag set. + */ +export function detectCliVersion(cliPath: string): Promise { + const cached = cache.get(cliPath) + if (cached) return cached + const promise = (async (): Promise => { + try { + const { stdout } = await execFileAsync(cliPath, ["--version"], { + timeout: 5000, + }) + const match = /(\d+)\.(\d+)\.(\d+)/.exec(stdout.trim()) + if (!match) { + log.warn("claude --version output unparseable", { stdout: stdout.trim() }) + return null + } + const v: CliVersion = { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + raw: stdout.trim(), + } + log.info("detected claude cli version", { cliPath, version: v.raw }) + if (!cliSupportsThinkingDisplay(v)) { + log.notice( + "claude cli < 2.1.142 detected; Opus 4.7 thinking summaries unavailable. Run `npm i -g @anthropic-ai/claude-code` to upgrade.", + { version: v.raw }, + ) + } + return v + } catch (err) { + log.warn("failed to detect claude cli version", { + cliPath, + error: err instanceof Error ? err.message : String(err), + }) + return null + } + })() + cache.set(cliPath, promise) + return promise +} + +function gte(v: CliVersion, target: { major: number; minor: number; patch: number }): boolean { + if (v.major !== target.major) return v.major > target.major + if (v.minor !== target.minor) return v.minor > target.minor + return v.patch >= target.patch +} + +/** + * `--thinking-display` was introduced in Claude Code 2.1.142 alongside + * Opus 4.7's "omitted by default" thinking behavior. Older CLIs reject + * the flag with a parse error, so we gate it. Unknown version → return + * false so we don't risk crashing the spawn. + */ +export function cliSupportsThinkingDisplay(v: CliVersion | null): boolean { + if (!v) return false + return gte(v, { major: 2, minor: 1, patch: 142 }) +} + +/** + * Fast mode's headless opt-in. In print mode the CLI reports + * `fast_mode_disabled_reason: "sdk_opt_in_required"` unless the *flag* settings + * layer carries `fastMode: true`, which only `--settings` populates (there is + * no `--fast` flag, and no fast-mode model name the CLI still accepts). + * + * 2.1.220 is the floor because it is the oldest binary the opt-in path was + * confirmed present in, not because 2.1.219 is known to lack it. An unknown + * settings key is ignored rather than fatal, so the downside of gating too + * high is only that fast mode stays off. + */ +export function cliSupportsFastMode(v: CliVersion | null): boolean { + if (!v) return false + return gte(v, { major: 2, minor: 1, patch: 220 }) +} + +/** 2.1.258 is the oldest verified side_question control protocol, not its introduction date. */ +export function cliSupportsSideQuestion(v: CliVersion | null): boolean { + if (!v) return false + return gte(v, { major: 2, minor: 1, patch: 258 }) +} + +/** + * `--thinking` has been part of Claude Code's CLI since the 2.x line. + * We require a detected 2.0.0+ before passing it; unknown version → skip + * to avoid crashing a pre-flag binary. Anyone on the 1.x line should + * upgrade. + */ +export function cliSupportsThinking(v: CliVersion | null): boolean { + if (!v) return false + return gte(v, { major: 2, minor: 0, patch: 0 }) +} + +/** For tests. */ +const flagSupport = new Map>() + +/** + * Probe whether the binary's own `--help` mentions a flag. For flags with no + * published version marker (`--plugin-dir`), where an invented semver + * threshold would be a guess. One `--help` spawn per cliPath+flag, cached for + * the process lifetime. Any failure is false, so the caller skips the flag + * rather than risking a parse error on spawn. (From @broskees' 68ed142.) + */ +export function detectCliSupportsFlag(cliPath: string, flag: string): Promise { + const key = `${cliPath}\x00${flag}` + const cached = flagSupport.get(key) + if (cached) return cached + const promise = (async (): Promise => { + try { + const execution = execFileAsync(cliPath, ["--help"], { + timeout: 5000, + killSignal: "SIGKILL", + maxBuffer: 4 * 1024 * 1024, + }) + // A wrapper may wait for stdin EOF even when asked for help. + execution.child.stdin?.end() + const { stdout } = await execution + return stdout.includes(flag) + } catch (err) { + log.warn("failed to probe claude cli flag support", { + cliPath, + flag, + error: err instanceof Error ? err.message : String(err), + }) + return false + } + })() + flagSupport.set(key, promise) + return promise +} + +export function _clearCache(): void { + flagSupport.clear() + cache.clear() +} diff --git a/src/compression-store.ts b/src/compression-store.ts new file mode 100644 index 0000000..57d8bb4 --- /dev/null +++ b/src/compression-store.ts @@ -0,0 +1,67 @@ +/** + * Per-session state for the opt-in `compress` proxy tool. + * + * Keyed by session key (the same `cwd::modelId::scope::affinity` string + * session-manager uses). When Claude calls the intercepted `compress` tool + * the summary is stored here and the session is marked for restart. The + * next `doStream` turn consumes that mark, evicts the running child and its + * Claude session id, and the fresh spawn gets the summary prepended to its + * appended system prompt. + * + * The summary deliberately survives `deleteClaudeSessionId()`: the restart + * path calls it, so clearing there would wipe the summary microseconds + * before the new spawn reads it (the original fork version did exactly + * that, which made the whole feature a no-op). It is dropped when a new + * opencode conversation starts on the same key, and by the entry cap below. + */ + +import { log } from "./logger.js" + +interface CompressionState { + summary: string + restartPending: boolean +} + +/** + * Session keys are bounded in practice by workspaces × models, and each + * entry is one summary string, but a long-lived opencode process that + * hops workspaces should not accumulate them forever. + */ +const MAX_COMPRESSION_ENTRIES = 32 + +const compressions = new Map() + +/** + * Record a summary and mark the session for restart. Storing and marking + * are one event on purpose: a stored summary that never resets the session + * would silently do nothing. + */ +export function storeCompressionSummary(sessionKey: string, summary: string): void { + compressions.set(sessionKey, { summary, restartPending: true }) + while (compressions.size > MAX_COMPRESSION_ENTRIES) { + const oldest = compressions.keys().next() + if (oldest.done) break + compressions.delete(oldest.value) + log.info("compression store evicted oldest entry", { sessionKey: oldest.value }) + } +} + +export function getCompressionSummary(sessionKey: string): string | undefined { + return compressions.get(sessionKey)?.summary +} + +/** + * True once per compress call, for the turn that performs the reset. The + * summary is kept: it is the prior context for every spawn that follows, + * until a new conversation clears it. + */ +export function consumeCompressionRestart(sessionKey: string): boolean { + const state = compressions.get(sessionKey) + if (!state?.restartPending) return false + state.restartPending = false + return true +} + +export function clearCompression(sessionKey: string): void { + compressions.delete(sessionKey) +} diff --git a/src/doctor.ts b/src/doctor.ts new file mode 100644 index 0000000..f2e07ef --- /dev/null +++ b/src/doctor.ts @@ -0,0 +1,338 @@ +import { detectCliVersion } from "./cli-version.js" +import { log } from "./logger.js" +import { + snapshotPendingProxyCalls, + type PendingProxyCallSnapshot, +} from "./proxy-broker.js" +import { + snapshotActiveProcesses, + type ActiveProcessSnapshot, +} from "./session-manager.js" +import { + collectStartupDiagnostics, + detectOpencodeVersion, + lastDiagnosticsProviders, + lastKnownOpencodeVersion, + type CwdSource, +} from "./startup-diagnostics.js" + +/** + * `/claude-code-doctor`: what the plugin thinks is happening, in the chat, + * right now. + * + * The startup block already answers most of this, but it is logged once per + * process to a file that is off by default, so in practice nobody sees it. The + * command is answered by the plugin itself with no CLI inference, following + * the `/btw` branch in `claude-code-language-model.ts`: the report is emitted + * as assistant text at zero tokens, and the whole exchange is stripped from + * any transcript rebuilt for the CLI. + * + * The name is `claude-code-doctor`, not `claude-code doctor`: opencode + * commands are invoked as `/` with everything after the first space taken + * as `$ARGUMENTS`, so a space in the name would make the second word an + * argument rather than part of the command. + * + * Nothing secret goes in it. Not the proxy bearer token, not the value of + * `ANTHROPIC_API_KEY`, not the system prompt, not a pending call's arguments. + */ + +export const DOCTOR_COMMAND = "claude-code-doctor" + +/** Leading marker of the report block, so `message-builder` can strip it. */ +export const DOCTOR_MARKER = "▌ **claude-code doctor**" + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +/** + * The same `` strip `/btw` needs: opencode appends its own + * reminder blocks as extra text parts on the user message, and without this a + * bare `/claude-code-doctor` would never look bare. + */ +const SYSTEM_REMINDER_BLOCK = /[\s\S]*?<\/system-reminder>/g + +export function parseDoctorCommandContent(content: unknown): { rest: string } | null { + let text: string + if (typeof content === "string") { + text = content + } else if (Array.isArray(content)) { + const parts: string[] = [] + for (const part of content) { + if (!isRecord(part) || part.type !== "text" || typeof part.text !== "string") return null + parts.push(part.text) + } + text = parts.join("\n") + } else { + return null + } + const match = new RegExp(`^/${DOCTOR_COMMAND}(?:\\s+([\\s\\S]*))?$`).exec( + text.replace(SYSTEM_REMINDER_BLOCK, "").trim(), + ) + return match ? { rest: (match[1] ?? "").trim() } : null +} + +/** Only the newest user message, so a historical report is never re-run. */ +export function parseDoctorCommand( + prompt: readonly { role: string; content: unknown }[], +): { rest: string } | null { + const latest = prompt.at(-1) + return latest?.role === "user" ? parseDoctorCommandContent(latest.content) : null +} + +export type ProxyAuthCheck = + | { status: "ok"; code: number } + | { status: "unsafe"; code: number } + | { status: "unreachable"; error: string } + | { status: "skipped" } + +export interface DoctorProxyRow { + url: string + auth: ProxyAuthCheck +} + +export interface DoctorReport { + plugin: string + opencode: string + claudeCli: { path: string; version: string } + cwd: { resolved: string; source: CwdSource } + providers: string[] + accounts: string[] + proxyTools: string[] + mcpServers: string[] + transport: "headless" | "interactive" + planModeQuestion: boolean + turnStats: boolean + anthropicApiKeyInEnv: boolean + processes: ActiveProcessSnapshot[] + pendingCalls: PendingProxyCallSnapshot[] + proxyServers: DoctorProxyRow[] +} + +function formatAge(ms: number | undefined): string { + if (ms === undefined) return "unknown" + if (ms < 1000) return `${Math.round(ms)}ms` + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` + const minutes = Math.floor(ms / 60_000) + if (minutes < 60) return `${minutes}m` + return `${Math.floor(minutes / 60)}h ${minutes % 60}m` +} + +function list(values: string[]): string { + return values.length ? values.join(", ") : "none" +} + +function describeAuth(auth: ProxyAuthCheck): string { + switch (auth.status) { + case "ok": + return `${auth.code}, good` + case "unsafe": + return `${auth.code}, UNSAFE: an unauthenticated caller was accepted. Restart every opencode window; a window opened before 0.13.2 keeps serving an open port.` + case "unreachable": + return `could not be checked (${auth.error})` + case "skipped": + return "not checked" + } +} + +/** + * Markdown, in one text part, led by `DOCTOR_MARKER`. Pure so a test can pin + * the whole report against a fixed object; everything live is gathered in + * `gatherDoctorReport`. + */ +export function formatDoctorReport(report: DoctorReport): string { + const lines: string[] = [] + lines.push(DOCTOR_MARKER) + lines.push("") + lines.push("| Field | Value |") + lines.push("|---|---|") + lines.push(`| plugin | ${report.plugin} |`) + lines.push(`| opencode | ${report.opencode} |`) + lines.push(`| claude CLI | \`${report.claudeCli.path}\` (${report.claudeCli.version}) |`) + lines.push(`| cwd | \`${report.cwd.resolved}\` (${report.cwd.source}) |`) + lines.push(`| providers | ${list(report.providers)} |`) + lines.push(`| accounts | ${list(report.accounts)} |`) + lines.push(`| proxyTools | ${list(report.proxyTools)} |`) + lines.push(`| MCP servers (on disk) | ${list(report.mcpServers)} |`) + lines.push(`| transport | ${report.transport} |`) + lines.push(`| planModeQuestion | ${report.planModeQuestion} |`) + lines.push(`| turnStats | ${report.turnStats} |`) + lines.push(`| ANTHROPIC_API_KEY in env | ${report.anthropicApiKeyInEnv ? "yes" : "no"} |`) + + lines.push("") + lines.push("**Live `claude` processes**") + lines.push("") + if (report.processes.length === 0) { + lines.push("None. The next message in a Claude Code session spawns one.") + } else { + lines.push("| session | model | pid | in flight | age | effort |") + lines.push("|---|---|---|---|---|---|") + for (const proc of report.processes) { + lines.push( + `| ${proc.session}${proc.compaction ? " (compaction)" : ""} | ${proc.model} | ${ + proc.pid ?? "unknown" + } | ${proc.inFlight ? "yes" : "no"} | ${formatAge(proc.ageMs)} | ${proc.effort ?? "inherited"} |`, + ) + } + } + + lines.push("") + lines.push("**Pending proxy calls**") + lines.push("") + if (report.pendingCalls.length === 0) { + lines.push("None.") + } else { + lines.push("| tool | call id | age | deadline |") + lines.push("|---|---|---|---|") + for (const call of report.pendingCalls) { + // A deadline of 0 is "none": task calls wait for the subagent by default. + const deadline = call.deadlineMs > 0 ? formatAge(call.deadlineMs) : "none" + lines.push( + `| ${call.toolName} | \`${call.toolCallId}\` | ${formatAge(call.ageMs)} | ${deadline} |`, + ) + } + } + + lines.push("") + lines.push("**Proxy servers**") + lines.push("") + if (report.proxyServers.length === 0) { + lines.push("None running.") + } else { + lines.push("| url | unauthenticated `initialize` |") + lines.push("|---|---|") + for (const server of report.proxyServers) { + lines.push(`| ${server.url} | ${describeAuth(server.auth)} |`) + } + } + + const stderr = report.processes.filter((proc) => proc.lastStderr) + if (stderr.length > 0) { + lines.push("") + lines.push("**Last stderr**") + lines.push("") + for (const proc of stderr) { + lines.push(`\`${proc.session}\`:`) + lines.push("") + lines.push("```text") + lines.push(proc.lastStderr!.trimEnd()) + lines.push("```") + } + } + + return lines.join("\n") +} + +/** + * The security probe from the README: an unauthenticated `initialize` with the + * right Host, no Origin and a JSON content type must be refused. 401 is the + * patched behaviour; a 200 means this opencode window predates 0.13.2 and is + * serving an open loopback port that executes Bash through opencode. + * + * Deliberately only `initialize`: a `tools/call` probe would run something. + */ +export async function checkProxyAuth( + url: string, + fetchImpl: typeof fetch = fetch, + timeoutMs = 3000, +): Promise { + let authority: string + try { + authority = new URL(url).host + } catch { + return { status: "skipped" } + } + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetchImpl(url, { + method: "POST", + headers: { "content-type": "application/json", host: authority }, + body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "initialize", params: {} }), + signal: controller.signal, + }) + // Drain so the socket is not left half-read. + await response.text().catch(() => "") + return response.status === 401 + ? { status: "ok", code: response.status } + : { status: "unsafe", code: response.status } + } catch (error) { + return { + status: "unreachable", + error: error instanceof Error ? error.message : String(error), + } + } finally { + clearTimeout(timer) + } +} + +export interface GatherDoctorOptions { + cliPath: string + interactive: boolean + turnStats: boolean + fetchImpl?: typeof fetch +} + +/** Assemble the live report. Never throws: a broken field reads as unknown. */ +export async function gatherDoctorReport( + options: GatherDoctorOptions, +): Promise { + const providers = lastDiagnosticsProviders() + const opencodeVersion = + lastKnownOpencodeVersion() ?? + process.env.OPENCODE_VERSION ?? + (await detectOpencodeVersion().catch(() => undefined)) + const { claudeCliPath, ...base } = collectStartupDiagnostics(providers, opencodeVersion) + const cliPath = options.cliPath || claudeCliPath + const cli = await detectCliVersion(cliPath).catch(() => null) + + const processes = snapshotActiveProcesses() + const seen = new Set() + const proxyServers: DoctorProxyRow[] = [] + for (const proc of processes) { + if (!proc.proxyUrl || seen.has(proc.proxyUrl)) continue + seen.add(proc.proxyUrl) + proxyServers.push({ + url: proc.proxyUrl, + auth: await checkProxyAuth(proc.proxyUrl, options.fetchImpl ?? fetch), + }) + } + + return { + plugin: base.plugin, + opencode: base.opencode, + claudeCli: { path: cliPath, version: cli?.raw ?? "not detected" }, + cwd: base.cwd, + providers: base.providers, + accounts: base.accounts, + proxyTools: base.proxyTools, + mcpServers: base.mcpServers, + transport: options.interactive || base.interactiveTransport ? "interactive" : "headless", + planModeQuestion: base.planModeQuestion, + turnStats: options.turnStats, + anthropicApiKeyInEnv: base.anthropicApiKeyInEnv, + processes, + pendingCalls: snapshotPendingProxyCalls(), + proxyServers, + } +} + +/** The whole command: gather, format, and never let a failure eat the answer. */ +export async function buildDoctorReport(options: GatherDoctorOptions): Promise { + try { + const report = await gatherDoctorReport(options) + log.info("claude-code doctor report", { + plugin: report.plugin, + opencode: report.opencode, + cwd: report.cwd, + processes: report.processes.length, + pendingCalls: report.pendingCalls.length, + proxyServers: report.proxyServers.map((server) => server.auth.status), + }) + return formatDoctorReport(report) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log.warn("claude-code doctor failed to build its report", { error: message }) + return `${DOCTOR_MARKER}\n\nCould not build the report: ${message}` + } +} diff --git a/src/index.ts b/src/index.ts index 8e74f47..2077f5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,26 +1,219 @@ -import type { LanguageModelV2, ProviderV2 } from "@ai-sdk/provider" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { ClaudeCodeLanguageModel } from "./claude-code-language-model.js" +import { defaultModels, toConfigModel } from "./models.js" +import type { + OpenCodeConfig, + OpenCodeEvent, + OpenCodeModel, + OpenCodePlugin, + OpenCodeProvider, +} from "./opencode-types.js" import type { ClaudeCodeProviderSettings } from "./types.js" +import { + BASE_PROVIDER_ID, + accountDisplayName, + accountModelSuffix, + accountProviderId, + ensureAccountRuntime, + resolveAccounts, +} from "./accounts.js" +import { + type AgentRecord, + agentDirectories, + getDefaultSubagentModel, + readAgentMarkdownRecords, + setAgentRegistry, + setDefaultSubagentModel, +} from "./agent-models.js" +import { cleanupStaleUnscopedInstall } from "./cleanup-stale.js" +import { DOCTOR_COMMAND } from "./doctor.js" +import { configureLogger, log } from "./logger.js" +import { handleBtwCommand, type BtwSdkClient } from "./btw-command.js" +import { registerBundledSkillPath } from "./skill-bridge.js" +import { + deleteActiveProcessesForSession, + ensureProcessExitCleanup, +} from "./session-manager.js" +import { getOpencodeClient } from "./runtime-status.js" +import { + getOpencodeProjectDirectory, + isUsableDirectory, + setOpencodeClient, + setOpencodeProjectDirectory, +} from "./runtime-status.js" +import { + logStartupDiagnostics, + pickOpencodeVersion, + type DiagnosticsProviderEntry, +} from "./startup-diagnostics.js" -export interface ClaudeCodeProvider extends ProviderV2 { - (modelId: string): LanguageModelV2 - languageModel(modelId: string): LanguageModelV2 +export interface ClaudeCodeProvider { + specificationVersion: "v3" + (modelId: string): LanguageModelV3 + languageModel(modelId: string): LanguageModelV3 +} + +// Picks the best directory from opencode's plugin context (`directory` / +// `worktree`). Result is handed to runtime-status so it's available as a +// *fallback* at spawn time only when `process.cwd()` is unusable (macOS +// GUI launches at `/`). Never baked into provider config — see #4. +function pickOpencodeDirectory(input: unknown): string | undefined { + if (!input || typeof input !== "object") return undefined + const ctx = input as { directory?: unknown; worktree?: unknown } + if (isUsableDirectory(ctx.directory)) return ctx.directory + if (isUsableDirectory(ctx.worktree)) return ctx.worktree + return undefined +} + +let warnedAnthropicApiKey = false +let warnedPlanModeNoExit = false + +// `Question` is deliberately absent: enabling it disables Claude Code's +// built-in AskUserQuestion (via --disallowedTools) and replaces the +// stop-and-wait deny/markdown path with an in-turn blocking form. That is a +// behavior trade against the issue-#8 guarantee, so it stays opt-in until it +// has the same live mileage Task had before v0.10.0 flipped it on. Users opt +// in by listing it in `proxyTools`; see README "Question proxy tool". +export const DEFAULT_PROXY_TOOL_NAMES = [ + "Bash", + "Edit", + "Write", + "WebFetch", + "Task", +] + +/** + * Registers `/btw` unless the user defined their own. Returns whether the + * registration is ours: the command hook only intercepts `btw` in that case, + * so a user-defined command keeps opencode's normal behaviour end to end. + */ +export function registerSideQuestionCommand(config: OpenCodeConfig): boolean { + config.command ??= {} + if (config.command.btw) return false + config.command.btw = { + template: "/btw $ARGUMENTS", + description: "Ask a side question in the live Claude Code session without changing its context", + } + return true +} + +/** + * Registers `/claude-code-doctor` unless the user defined their own command of + * that name. Unlike `/btw` there is no hook to guard: the command is a plain + * template and the language model answers the message it produces, so leaving + * a user definition alone here is the whole guard. + * + * The name carries no slash. opencode invokes a command as `/` and takes + * everything after the first space as `$ARGUMENTS`, so `claude-code doctor` + * would be the command `claude-code` with the argument `doctor`. + */ +export function registerDoctorCommand(config: OpenCodeConfig): boolean { + config.command ??= {} + if (config.command[DOCTOR_COMMAND]) return false + config.command[DOCTOR_COMMAND] = { + template: `/${DOCTOR_COMMAND} $ARGUMENTS`, + description: "Report what the Claude Code plugin sees: versions, cwd, live processes, pending proxy calls", + } + return true +} + +let ownsSideQuestionCommand = false + +// One-time heads-up: an API key in the environment makes Claude Code bill +// pay-as-you-go (Console) instead of the logged-in Pro/Max subscription, which +// silently bypasses the Agent SDK plan credit. Surfaced once per process. +function warnIfAnthropicApiKey(ignore: boolean | undefined): void { + if (warnedAnthropicApiKey) return + if (!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_AUTH_TOKEN) return + warnedAnthropicApiKey = true + if (ignore) { + log.warn( + "ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN detected; stripping it from claude spawns (ignoreAnthropicApiKey) so requests use your subscription auth, not pay-as-you-go API billing.", + ) + } else { + log.warn( + "ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN detected; claude may bill as pay-as-you-go API usage instead of your subscription / Agent SDK credit. Set provider option `ignoreAnthropicApiKey: true` to force subscription auth.", + ) + } +} + +// Plan mode is enforced (buildCliArgs drops the skip-permissions flag for it), +// so the read-only guarantee holds. The cost is that headless Claude Code is +// not offered an `ExitPlanMode` tool, measured on 2.1.258, so nothing can +// release plan mode mid-session and approving a plan in chat will not let +// Claude write. Say so once per process rather than let it look like a hang. +export function _resetPlanModeWarningForTests(): void { + warnedPlanModeNoExit = false +} + +export function warnIfPlanModeCannotExit(permissionMode: string | undefined): void { + if (permissionMode !== "plan") return + if (warnedPlanModeNoExit) return + warnedPlanModeNoExit = true + log.warn( + "permissionMode \"plan\" is enforced: claude cannot edit files or run commands, and --dangerously-skip-permissions is deliberately not passed so it stays that way. Headless Claude Code is not offered an ExitPlanMode tool, so nothing releases plan mode mid-session; approving a plan in chat does not unlock writes. Leaving plan mode means changing the config and restarting opencode.", + { permissionMode, measuredOn: "claude-code 2.1.258" }, + ) } export function createClaudeCode( settings: ClaudeCodeProviderSettings = {}, ): ClaudeCodeProvider { + if (settings.logging) { + configureLogger({ + file: settings.logging.file ?? false, + dir: settings.logging.dir ?? null, + mode: settings.logging.mode ?? "silent", + level: settings.logging.level ?? "info", + }) + } + warnIfAnthropicApiKey(settings.ignoreAnthropicApiKey) + warnIfPlanModeCannotExit(settings.permissionMode) const cliPath = settings.cliPath ?? process.env.CLAUDE_CLI_PATH ?? "claude" - const cwd = settings.cwd ?? process.cwd() - const providerName = settings.name ?? "claude-code" + const providerName = settings.providerID ?? settings.name ?? "claude-code" + const proxyTools = settings.proxyTools ?? [...DEFAULT_PROXY_TOOL_NAMES] - const createModel = (modelId: string): LanguageModelV2 => { + const createModel = (modelId: string): LanguageModelV3 => { return new ClaudeCodeLanguageModel(modelId, { provider: providerName, cliPath, - cwd, + cwd: settings.cwd, + account: settings.account, + configDir: settings.configDir, + failoverAccounts: settings.failoverAccounts, + baseCliPath: settings.baseCliPath ?? cliPath, + accountFailover: settings.accountFailover ?? "ask", + providerID: settings.providerID, skipPermissions: settings.skipPermissions ?? true, + permissionMode: settings.permissionMode, + mcpConfig: settings.mcpConfig, + strictMcpConfig: settings.strictMcpConfig, + bridgeOpencodeMcp: settings.bridgeOpencodeMcp ?? true, + controlRequestBehavior: settings.controlRequestBehavior ?? "allow", + controlRequestToolBehaviors: settings.controlRequestToolBehaviors, + controlRequestDenyMessage: settings.controlRequestDenyMessage, + proxyTools, + proxyOpencodeTools: settings.proxyOpencodeTools, + stripContextReminders: settings.stripContextReminders === true, + extraDisallowedTools: settings.extraDisallowedTools, + proxyToolTimeoutMs: settings.proxyToolTimeoutMs, + planModeQuestion: settings.planModeQuestion ?? false, + webSearch: settings.webSearch, + hotReloadMcp: settings.hotReloadMcp ?? true, + proxyOpencodeMcpTools: settings.proxyOpencodeMcpTools === true, + multiStepContinuation: settings.multiStepContinuation ?? true, + autoContinueIncompleteTurns: + settings.autoContinueIncompleteTurns ?? "smart", + compactionModel: settings.compactionModel, + ignoreAnthropicApiKey: settings.ignoreAnthropicApiKey, + idleProcessTimeoutMs: settings.idleProcessTimeoutMs, + bridgeOpencodeSkills: settings.bridgeOpencodeSkills === true, + turnStats: settings.turnStats === true, + interactive: settings.interactive, + interactiveBypass: settings.interactiveBypass, + interactiveAllowTools: settings.interactiveAllowTools, + interactiveSystemPrompt: settings.interactiveSystemPrompt, }) } @@ -28,14 +221,455 @@ export function createClaudeCode( return createModel(modelId) } as ClaudeCodeProvider + provider.specificationVersion = "v3" provider.languageModel = createModel return provider } +// --------------------------------------------------------------------------- +// OpenCode plugin interface +// --------------------------------------------------------------------------- + +const PROVIDER_ID = BASE_PROVIDER_ID +const PACKAGE_NPM = "@khalilgharbaoui/opencode-claude-code-plugin" + +function pluginEntrypoint(): string { + return import.meta.url.startsWith("file:") ? import.meta.url : PACKAGE_NPM +} + +function cleanProviderOptions( + options: Record = {}, +): Record { + const result = { ...options } + delete result.accounts + // Consumed by the config hook (agent registry), not by the language model. + delete result.defaultSubagentModel + return result +} + +function defaultModelsForProvider( + providerModels: OpenCodeProvider["models"], + providerID = PROVIDER_ID, + modelSuffix?: string, +) { + const models = Object.fromEntries( + Object.entries(defaultModels).map(([id, model]) => { + const modelId = modelSuffix ? `${id}@${modelSuffix}` : id + const existing = providerModels[id] ?? providerModels[modelId] + return [ + modelId, + { + ...model, + id: modelId, + providerID, + api: { + ...model.api, + id: modelId, + npm: existing?.api?.npm ?? model.api.npm, + url: existing?.api?.url ?? model.api.url, + }, + }, + ] + }), + ) + + for (const [id, model] of Object.entries(providerModels)) { + if (!(id in models)) { + models[id] = { + ...model, + providerID, + } + } + } + + return models +} + +/** + * Build models in OpenCode's config schema format (flat properties like + * `temperature`, `reasoning`, `cost.cache_read`, `modalities`, etc.) + * so the config-path provider loader parses them correctly. + */ +export function configModelsForProvider( + providerModels: OpenCodeProvider["models"], + providerID: string, + modelSuffix?: string, +): Record> { + const models: Record> = {} + + for (const [id, model] of Object.entries(defaultModels)) { + const modelId = modelSuffix ? `${id}@${modelSuffix}` : id + const existing = providerModels[id] ?? providerModels[modelId] + const existingVariants = + existing && typeof (existing as { variants?: unknown }).variants === "object" + ? ((existing as { variants?: Record> }).variants ?? {}) + : {} + const full: OpenCodeModel = { + ...model, + id: modelId, + providerID, + api: { + ...model.api, + id: modelId, + npm: existing?.api?.npm ?? model.api.npm, + url: existing?.api?.url ?? model.api.url, + }, + variants: { + ...(model.variants ?? {}), + ...existingVariants, + }, + } + models[modelId] = toConfigModel(full) + } + + for (const [id, model] of Object.entries(providerModels)) { + if (!(id in models)) { + models[id] = toConfigModel({ ...model, providerID } as OpenCodeModel) + } + } + + return models +} + +async function providerConfig( + existing: { + name?: string + npm?: string + options?: Record + models?: Record + } | undefined, + providerID = PROVIDER_ID, + optionDefaults: Record = {}, + displayName?: string, +) { + const mergedOptions: Record = { + cliPath: "claude", + proxyTools: [...DEFAULT_PROXY_TOOL_NAMES], + ...optionDefaults, + ...cleanProviderOptions(existing?.options), + providerID, + } + + const cliPath = String(mergedOptions.cliPath ?? "claude") + const account = + typeof mergedOptions.account === "string" ? mergedOptions.account : undefined + const runtime = account + ? await ensureAccountRuntime(account, cliPath) + : { cliPath } + + return { + name: displayName ?? existing?.name, + npm: existing?.npm ?? pluginEntrypoint(), + options: { + ...mergedOptions, + ...runtime, + // The pre-wrapper binary, kept because `runtime` replaces `cliPath` + // with the account's wrapper and a failover has to build ANOTHER + // account's wrapper on top of the same base (src/account-failover.ts). + baseCliPath: cliPath, + }, + // models is intentionally omitted: both callers overwrite it with + // configModelsForProvider(), which emits the flat config schema + // opencode's config-path loader parses (and merges user variants). + } +} + +/** + * Narrow opencode's full provider map down to the ones this plugin owns + * (`claude-code` plus every `claude-code-` expansion) so startup + * diagnostics never report another provider's options. + */ +export function claudeCodeProviders( + providers: Record | undefined, +): Record { + const out: Record = {} + for (const [id, entry] of Object.entries(providers ?? {})) { + if (id === PROVIDER_ID || id.startsWith(`${PROVIDER_ID}-`)) out[id] = entry + } + return out +} + +async function expandAccountProviders(config: { + provider?: Record< + string, + { + name?: string + npm?: string + options?: Record + models?: Record + } + > +}): Promise { + const seed = config.provider?.[PROVIDER_ID] + const accounts = resolveAccounts(seed?.options?.accounts) + + if (!accounts) return false + + config.provider ??= {} + + const seedOptions = cleanProviderOptions(seed?.options) + let expandedCount = 0 + + for (const account of accounts) { + const providerID = accountProviderId(account) + try { + const existing = config.provider[providerID] + const modelSuffix = accountModelSuffix(account) + + config.provider[providerID] = { + ...existing, + ...(await providerConfig( + existing, + providerID, + { + ...seedOptions, + account, + // The resolved list, so this account's language model can offer + // the others when it runs out of usage. `accounts` itself stays + // stripped by cleanProviderOptions. + failoverAccounts: accounts, + }, + accountDisplayName(account), + )), + models: configModelsForProvider( + (existing?.models ?? seed?.models ?? {}) as OpenCodeProvider["models"], + providerID, + modelSuffix, + ), + } + expandedCount++ + } catch (err) { + log.error("failed to expand account provider", { + account, + providerID, + error: String(err), + }) + } + } + + if (expandedCount > 0) { + delete config.provider[PROVIDER_ID] + } + + return expandedCount > 0 +} + +/** + * Record what every known agent asked for, so `resolveAgentModel` and + * `resolveAgentEffort` can answer at spawn time without the language model + * needing to see opencode's config. + * + * Runs BEFORE `expandAccountProviders`, which deletes the seed provider entry + * once it has expanded it: `defaultSubagentModel` has to be read while it is + * still there. + * + * Purely observational. It defines no agents and changes no agent's config; + * an agent this plugin never heard of is simply absent from the registry, + * which is what keeps opencode's built-ins out of the override path. + */ +async function buildAgentRegistry(config: OpenCodeConfig): Promise { + const options = config.provider?.[PROVIDER_ID]?.options + const configured = options?.defaultSubagentModel + setDefaultSubagentModel( + typeof configured === "string" ? configured : undefined, + ) + + // Markdown agents may or may not reach a plugin's config hook (undocumented + // either way), so they are read from disk and then overlaid with whatever + // config does carry, which is authoritative when both describe one agent. + const records: Record = await readAgentMarkdownRecords( + agentDirectories( + process.env.HOME ?? process.env.USERPROFILE, + getOpencodeProjectDirectory(), + ), + ) + + for (const [name, agent] of Object.entries(config.agent ?? {})) { + const bag = (agent.options ?? {}) as Record + const pick = (key: string): string | undefined => { + const value = agent[key] ?? bag[key] + return typeof value === "string" ? value : undefined + } + + records[name] = { + mode: pick("mode") ?? records[name]?.mode, + model: pick("model") ?? records[name]?.model, + forceModel: pick("forceModel") ?? records[name]?.forceModel, + reasoningEffort: + pick("reasoningEffort") ?? records[name]?.reasoningEffort, + } + } + + setAgentRegistry(records) + log.debug("agent registry built", { + agents: Object.keys(records).length, + defaultSubagentModel: getDefaultSubagentModel(), + }) +} + +/** + * The opencode session id a `session.deleted` bus event names, or undefined + * for any other event. opencode publishes `{ type, properties: { info } }` + * under `payload`, and the deleted session's own record is `properties.info`. + */ +export function extractDeletedSessionId(event: OpenCodeEvent | undefined): string | undefined { + const payload = event?.payload ?? event + if (!payload || payload.type !== "session.deleted") return undefined + const properties = payload.properties as { info?: { id?: unknown } } | undefined + const id = properties?.info?.id + return typeof id === "string" && id.length > 0 ? id : undefined +} + +const server: OpenCodePlugin = async (input) => { + cleanupStaleUnscopedInstall() + // Retained `claude` children would otherwise outlive a hard opencode exit, + // reparented to init. Armed once per process however often this runs. + ensureProcessExitCleanup() + + const opencodeVersion = pickOpencodeVersion(input) + + // Capture the SDK client so the language model can query opencode's + // in-memory MCP state per-turn for the runtime overlay. `input` is + // `unknown` here (kept loose since opencode adds fields over time); + // narrow defensively. + if (input && typeof input === "object" && "client" in input) { + setOpencodeClient((input as { client?: unknown }).client) + } + + // Capture opencode's project-aware directory as a *fallback* used at + // Claude CLI spawn time only when `process.cwd()` is unusable. Rescues + // macOS GUI launches at `/` without freezing the value into provider + // config, so opencode workspace switches mid-session still take effect. + // See `resolveSpawnCwd` in runtime-status.ts and issue #4. + setOpencodeProjectDirectory(pickOpencodeDirectory(input)) + + return { + config: async (config) => { + if (registerSideQuestionCommand(config)) ownsSideQuestionCommand = true + registerDoctorCommand(config) + // The bundled `claude-code-plugin` skill: opencode lists it for every + // provider via skills.paths; the spawn path also stages it as a + // --plugin-dir so Claude's own Skill tool can load it. + registerBundledSkillPath(config) + config.provider ??= {} + + await buildAgentRegistry(config) + + const expanded = await expandAccountProviders(config) + if (expanded) { + logStartupDiagnostics( + claudeCodeProviders(config.provider), + opencodeVersion, + ) + return + } + + const existing = config.provider[PROVIDER_ID] + config.provider[PROVIDER_ID] = { + ...existing, + ...(await providerConfig(existing)), + models: configModelsForProvider( + (existing?.models ?? {}) as OpenCodeProvider["models"], + PROVIDER_ID, + ), + } + logStartupDiagnostics( + claudeCodeProviders(config.provider), + opencodeVersion, + ) + }, + // Only `session.deleted` is acted on. MCP config drift is still detected + // at turn start by the hot-reload check in `claude-code-language-model.ts`, + // which respawns claude safely between turns, and eviction on + // `global.disposed` would kill an in-flight stream and abort the user's + // current turn. A deleted session has no turn left to abort, and its + // `claude` child would otherwise linger until the idle timer or LRU + // pressure took it, with its session id kept for a resume that never comes. + event: async ({ event }) => { + const sessionID = extractDeletedSessionId(event) + if (!sessionID) return + const released = deleteActiveProcessesForSession(sessionID) + if (released.length > 0) { + log.info("released claude state for deleted session", { sessionID, released }) + } + }, + provider: { + id: PROVIDER_ID, + models: async (provider) => defaultModelsForProvider(provider.models), + }, + // Inject opencode's agent name into providerOptions so the language + // model can distinguish /compact (and title) calls from normal turns. + // Without this, every no-tools call looks like a title request and + // gets short-circuited to a synthetic stub. + // /btw is asked from here, the moment the command is typed, busy or not. + // The message itself still goes through: opencode queues it behind the + // running turn and the aside branch in the language model then answers it + // from the early answer, so the exchange is kept in this conversation. + "command.execute.before": async (input) => { + if (input.command !== "btw" || !ownsSideQuestionCommand) return + await handleBtwCommand(getOpencodeClient() as BtwSdkClient | null, input) + }, + "chat.params": async (input, output) => { + const providerID = input.model?.providerID ?? input.provider?.info?.id + // The hook fires for every provider opencode is configured with, not + // just ours — keep this at debug to avoid log spam on non-claude-code + // calls. + log.debug("chat.params hook fired", { + agent: input.agent, + providerID, + sessionID: input.sessionID, + }) + if (typeof providerID !== "string") return + if (providerID !== PROVIDER_ID && !providerID.startsWith(`${PROVIDER_ID}-`)) return + + // Inject sessionID BEFORE the agent guard so session isolation works + // even when input.agent is absent (older opencode, provider-switch + // edge paths). resolveSessionAffinity reads this as a fallback when + // the x-session-affinity header is missing. + if (typeof input.sessionID === "string" && input.sessionID.length > 0) { + output.options ??= {} + ;(output.options as Record).opencodeSessionID = input.sessionID + } + + if (!input.agent) return + // opencode wraps the entire `output.options` bag under the providerID + // via ProviderTransform.providerOptions(model, options) → { [providerID]: options } + // before handing it to the language model as `providerOptions`. So we + // write fields at the TOP LEVEL of output.options, not nested under + // providerID — otherwise the model sees providerOptions[id][id].opencodeAgent. + output.options ??= {} + ;(output.options as Record).opencodeAgent = input.agent + log.debug("chat.params tagged providerOptions", { + agent: input.agent, + sessionID: input.sessionID, + providerID, + }) + }, + } +} + +export default { + id: "@khalilgharbaoui/opencode-claude-code-plugin", + server, +} + +// --------------------------------------------------------------------------- +// Re-exports +// --------------------------------------------------------------------------- + export { ClaudeCodeLanguageModel } from "./claude-code-language-model.js" +export { bridgeOpencodeMcp } from "./mcp-bridge.js" +export { + type AgentRecord, + getAgentRegistry, + getDefaultSubagentModel, + resolveAgentModel, +} from "./agent-models.js" +export { defaultModels } from "./models.js" export type { ClaudeCodeConfig, ClaudeCodeProviderSettings, ClaudeStreamMessage, } from "./types.js" +export type { OpenCodeHooks, OpenCodeModel, OpenCodePlugin } from "./opencode-types.js" diff --git a/src/logger.ts b/src/logger.ts index a6dd62a..91ab8d2 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,4 +1,128 @@ -const DEBUG = process.env.DEBUG?.includes("opencode-claude-code") ?? false +import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs" +import { homedir } from "node:os" +import { dirname, join } from "node:path" + +export type LogLevel = "debug" | "info" | "notice" | "warn" | "error" +export type LogMode = "silent" | "debug" + +export interface LoggerConfig { + file: boolean + dir: string | null + mode: LogMode + level: LogLevel +} + +const LEVEL_RANK: Record = { + debug: 0, + info: 1, + notice: 2, + warn: 3, + error: 4, +} + +const MAX_LOG_BYTES = 5 * 1024 * 1024 // 5 MB +const DEFAULT_DIR = join(homedir(), ".local", "share", "opencode-claude-code") + +const DEFAULT_CONFIG: LoggerConfig = { + file: false, + dir: null, + mode: "silent", + level: "info", +} + +function parseBoolEnv(v: string | undefined): boolean | undefined { + if (v == null) return undefined + const s = v.toLowerCase().trim() + if (s === "") return undefined + if (s === "0" || s === "false" || s === "no" || s === "off") return false + return true +} + +function parseLevelEnv(v: string | undefined): LogLevel | undefined { + if (v == null) return undefined + const s = v.toLowerCase().trim() + if (s === "") return undefined + if (s === "debug" || s === "info" || s === "notice" || s === "warn" || s === "error") { + return s + } + return undefined +} + +function parseModeFromDebugEnv(v: string | undefined): LogMode | undefined { + if (v == null || v === "") return undefined + return v.includes("opencode-claude-code") ? "debug" : undefined +} + +function withEnvOverrides(base: LoggerConfig): LoggerConfig { + const result: LoggerConfig = { ...base } + const envFile = parseBoolEnv(process.env.OPENCODE_CLAUDE_CODE_LOG_FILE) + if (envFile !== undefined) result.file = envFile + const envDir = process.env.OPENCODE_CLAUDE_CODE_LOG_DIR + if (envDir !== undefined && envDir !== "") result.dir = envDir + const envMode = parseModeFromDebugEnv(process.env.DEBUG) + if (envMode !== undefined) result.mode = envMode + const envLevel = parseLevelEnv(process.env.OPENCODE_CLAUDE_CODE_LOG_LEVEL) + if (envLevel !== undefined) result.level = envLevel + return result +} + +let activeConfig: LoggerConfig = withEnvOverrides(DEFAULT_CONFIG) +let fileLoggingDisabled = false + +/** + * Configure the logger from plugin settings. Env vars override the supplied + * config when explicitly set, so a developer can flip behavior for a single + * process without editing opencode.jsonc. + * + * `OPENCODE_CLAUDE_CODE_LOG_FILE` → `file` (1/true/on/yes vs 0/false/no/off) + * `OPENCODE_CLAUDE_CODE_LOG_DIR` → `dir` + * `DEBUG=opencode-claude-code` → `mode: "debug"` + * `OPENCODE_CLAUDE_CODE_LOG_LEVEL` → `level` (debug | info | notice | warn | error) + */ +export function configureLogger(input: Partial): void { + const merged: LoggerConfig = { ...DEFAULT_CONFIG, ...input } + activeConfig = withEnvOverrides(merged) + fileLoggingDisabled = false +} + +export function getLoggerConfig(): LoggerConfig { + return { ...activeConfig } +} + +/** Test-only helper. Resets to defaults+env so tests are deterministic. */ +export function _resetLoggerForTests(): void { + activeConfig = withEnvOverrides(DEFAULT_CONFIG) + fileLoggingDisabled = false +} + +function resolvedLogFile(): string { + return join(activeConfig.dir ?? DEFAULT_DIR, "plugin.log") +} + +function rotateIfNeeded(logFile: string): void { + try { + const stat = statSync(logFile) + if (stat.size > MAX_LOG_BYTES) { + renameSync(logFile, `${logFile}.1`) + } + } catch { + // file does not exist yet — nothing to rotate + } +} + +function writeToFile(line: string): void { + if (!activeConfig.file) return + if (fileLoggingDisabled) return + try { + const logFile = resolvedLogFile() + mkdirSync(dirname(logFile), { recursive: true }) + rotateIfNeeded(logFile) + appendFileSync(logFile, line + "\n", "utf8") + } catch { + // Disable on first failure to avoid spamming errors on a read-only FS. + fileLoggingDisabled = true + } +} function fmt(level: string, msg: string, data?: Record): string { const ts = new Date().toISOString() @@ -9,17 +133,41 @@ function fmt(level: string, msg: string, data?: Record): string return base } +function shouldEmit(level: LogLevel): boolean { + return LEVEL_RANK[level] >= LEVEL_RANK[activeConfig.level] +} + +function shouldTui(level: LogLevel): boolean { + // warn/error are alwaysStderr: a developer who passes the level threshold + // should still see real problems in the TUI regardless of mode. Below- + // threshold entries are filtered earlier by shouldEmit(). + if (level === "warn" || level === "error") return true + return activeConfig.mode === "debug" +} + +function emit(level: LogLevel, msg: string, data?: Record): void { + if (!shouldEmit(level)) return + const line = fmt(level.toUpperCase(), msg, data) + if (shouldTui(level)) { + console.error(line) + } + writeToFile(line) +} + export const log = { + debug(msg: string, data?: Record) { + emit("debug", msg, data) + }, info(msg: string, data?: Record) { - if (DEBUG) console.error(fmt("INFO", msg, data)) + emit("info", msg, data) + }, + notice(msg: string, data?: Record) { + emit("notice", msg, data) }, warn(msg: string, data?: Record) { - if (DEBUG) console.error(fmt("WARN", msg, data)) + emit("warn", msg, data) }, error(msg: string, data?: Record) { - console.error(fmt("ERROR", msg, data)) - }, - debug(msg: string, data?: Record) { - if (DEBUG) console.error(fmt("DEBUG", msg, data)) + emit("error", msg, data) }, } diff --git a/src/mcp-bridge.ts b/src/mcp-bridge.ts new file mode 100644 index 0000000..29354b9 --- /dev/null +++ b/src/mcp-bridge.ts @@ -0,0 +1,648 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import * as os from "node:os" +import * as crypto from "node:crypto" +import { + parse as parseJsonc, + printParseErrorCode, + type ParseError, +} from "jsonc-parser" +import { log } from "./logger.js" +import { pluginTmpDir } from "./tmp.js" + +/** + * Bridge opencode's `mcp` config block into a Claude CLI `--mcp-config` file. + * + * Opencode core schema (packages/opencode/src/config/mcp.ts): + * { + * "mcp": { + * "name": { + * "type": "local" | "remote", + * "command"?: string[], // local + * "environment"?: Record, + * "url"?: string, // remote + * "headers"?: Record, + * "oauth"?: object | false, // remote — NOT bridged (Claude --mcp-config has no slot) + * "timeout"?: number, // NOT bridged (Claude --mcp-config has no slot) + * "enabled"?: boolean + * } + * } + * } + * + * Claude CLI `--mcp-config` schema: + * { + * "mcpServers": { + * "name": { + * "type": "stdio" | "http", + * "command"?: string, "args"?: string[], "env"?: Record, + * "url"?: string, "headers"?: Record + * } + * } + * } + * + * Discovery + merge are aligned with opencode core's `loadInstanceState` + * (packages/opencode/src/config/config.ts). In merge order (last wins), + * opencode loads: + * + * 1. Auth `.well-known` remote configs ← NOT bridged + * 2. Global: ~/.config/opencode/{config.json,opencode.json,opencode.jsonc} + * — all three deep-merged, jsonc highest priority + * 3. OPENCODE_CONFIG env var (single file) + * 4. Project walk-up: opencode.json[c] in each dir from cwd up to (not past) + * worktree, both extensions per dir, parent-most first + * 5. .opencode/ siblings: from cwd up + home dir + OPENCODE_CONFIG_DIR, + * both extensions per dir, opencode-iteration order (cwd-most first + * in walk-up — so parent-most `.opencode/` wins, matching upstream) + * 6. OPENCODE_CONFIG_CONTENT env var (inline JSON) ← NOT bridged + * 7. Active org remote config ← NOT bridged + * 8. Managed config dir / macOS MDM ← NOT bridged + * + * Sources marked NOT bridged are niche and would require live opencode + * runtime state (auth tokens, account context, MDM access). Document them + * here so the gap is explicit; functionality of the common path is intact. + * + * Per-server merge is deep-merge (matching opencode's `mergeConfigConcatArrays` + * → `mergeDeep`), so a project layer can override one field of a global server + * spec — e.g. `{ "linear": { "enabled": true } }` lifts global linear's URL. + */ + +const FILE_NAMES = ["opencode.jsonc", "opencode.json", "config.json"] as const +const PROJECT_FILE_NAMES = ["opencode.json", "opencode.jsonc"] as const + +function fileExists(p: string): boolean { + try { + return fs.statSync(p).isFile() + } catch { + return false + } +} + +function dirExists(p: string): boolean { + try { + return fs.statSync(p).isDirectory() + } catch { + return false + } +} + +function readAndParse(file: string): Record | null { + try { + const raw = fs.readFileSync(file, "utf8") + const errors: ParseError[] = [] + const parsed = parseJsonc(raw, errors, { allowTrailingComma: true }) + if (errors.length > 0) { + const first = errors[0] + throw new Error( + `${printParseErrorCode(first.error)} at offset ${first.offset}`, + ) + } + return parsed as Record + } catch (e) { + log.warn("failed to parse opencode config", { + file, + error: e instanceof Error ? e.message : String(e), + }) + return null + } +} + +/** + * Deep merge two plain-object trees. Arrays and primitives are replaced + * (not concatenated). Matches the effective behavior of opencode's + * `mergeDeep` from `remeda` for the MCP block — opencode does not special + * case array fields inside `mcp.` (its only special case is + * `instructions`, which is concat-deduped at the config root). + */ +function isPlainObject(x: unknown): x is Record { + return typeof x === "object" && x !== null && !Array.isArray(x) +} + +function deepMerge( + target: Record, + source: Record, +): Record { + const out: Record = { ...target } + for (const [k, v] of Object.entries(source)) { + if (v === undefined) continue + const existing = out[k] + if (isPlainObject(existing) && isPlainObject(v)) { + out[k] = deepMerge(existing, v) + } else { + out[k] = v + } + } + return out +} + +/** + * Walk up from `start` toward filesystem root (or `stop` if provided), + * collecting paths where each `target` exists. Mirrors opencode core's + * `FileSystem.up` (packages/core/src/filesystem.ts): cwd-most first, + * parent-most last. + */ +function walkUp(opts: { + start: string + stop?: string + targets: readonly string[] + predicate: (p: string) => boolean +}): string[] { + const out: string[] = [] + let current = path.resolve(opts.start) + while (true) { + for (const target of opts.targets) { + const candidate = path.join(current, target) + if (opts.predicate(candidate)) out.push(candidate) + } + if (opts.stop && current === path.resolve(opts.stop)) break + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + return out +} + +/** + * Find the worktree root by walking up from `cwd` looking for a `.git` + * entry (file or directory — submodules use a file). If no `.git` is + * found, walk to filesystem root. Honors OPENCODE_WORKTREE override. + */ +function detectWorktree(cwd: string): string | undefined { + const override = process.env.OPENCODE_WORKTREE + if (override) return path.resolve(override) + let current = path.resolve(cwd) + while (true) { + const gitPath = path.join(current, ".git") + try { + if (fs.existsSync(gitPath)) return current + } catch { + // ignore + } + const parent = path.dirname(current) + if (parent === current) return undefined + current = parent + } +} + +function globalConfigDir(): string { + const xdg = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config") + return path.join(xdg, "opencode") +} + +/** + * Load the merged global config from `~/.config/opencode/`. Mirrors + * opencode core's `loadGlobal`: deep-merges config.json → opencode.json + * → opencode.jsonc in that order (jsonc wins). + */ +function loadGlobalConfig(): Record { + const dir = globalConfigDir() + let merged: Record = {} + for (const name of FILE_NAMES.slice().reverse()) { + // FILE_NAMES is jsonc-first; reverse to get config.json-first order. + const file = path.join(dir, name) + if (!fileExists(file)) continue + const parsed = readAndParse(file) + if (parsed) merged = deepMerge(merged, parsed) + } + return merged +} + +/** Load both `opencode.json` and `opencode.jsonc` in `dir`, deep-merged. */ +function loadProjectFilesInDir(dir: string): Record { + let merged: Record = {} + for (const name of PROJECT_FILE_NAMES) { + const file = path.join(dir, name) + if (!fileExists(file)) continue + const parsed = readAndParse(file) + if (parsed) merged = deepMerge(merged, parsed) + } + return merged +} + +/** + * Build the list of `.opencode/` directories to consider, in opencode core's + * order (matching `ConfigPaths.directories`): + * project walk-up (cwd-most first) → home-dir `.opencode/` → OPENCODE_CONFIG_DIR + */ +function dotOpencodeDirs(cwd: string, worktree?: string): string[] { + const dirs: string[] = [] + const seen = new Set() + const push = (p: string) => { + const abs = path.resolve(p) + if (!seen.has(abs) && dirExists(abs)) { + seen.add(abs) + dirs.push(abs) + } + } + + for (const dir of walkUp({ + start: cwd, + stop: worktree, + targets: [".opencode"], + predicate: dirExists, + })) { + push(dir) + } + + const home = os.homedir() + if (home) { + const homeDot = path.join(home, ".opencode") + if (dirExists(homeDot)) push(homeDot) + } + + const envDir = process.env.OPENCODE_CONFIG_DIR + if (envDir && dirExists(envDir)) push(envDir) + + return dirs +} + +interface OpencodeLocalServer { + type?: "local" + command?: string[] + environment?: Record + enabled?: boolean +} + +interface OpencodeRemoteServer { + type?: "remote" + url?: string + headers?: Record + enabled?: boolean +} + +type OpencodeServer = OpencodeLocalServer | OpencodeRemoteServer | { enabled?: boolean } + +/** + * Substitute opencode's `{env:VAR}` interpolation in a string-keyed record + * using values from `process.env`. Returns a new object. If the source is + * not a flat string-valued record, returns it unchanged. + * + * Opencode performs this substitution itself when it spawns MCP servers + * directly, but the spec we read from disk still contains the literal + * placeholders. Without substituting them here, Claude CLI hands the + * literal string `{env:FOO}` to the MCP subprocess as the env value, and + * any server that validates credentials at startup (e.g. slack-mcp-server) + * crashes before exposing tools. Servers that defer validation to + * request time (e.g. github-mcp-server) appear to register but every API + * call 401s. + */ +function substituteEnvPlaceholders( + source: Record, +): Record { + const out: Record = {} + for (const [k, v] of Object.entries(source)) { + if (typeof v !== "string") continue + out[k] = v.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => { + const resolved = process.env[name] + return typeof resolved === "string" ? resolved : "" + }) + } + return out +} + +function translateServer( + name: string, + spec: Record, +): Record | null { + if (spec.enabled === false) return null + + const type = spec.type + if (type === "local") { + const cmd = spec.command + if (!Array.isArray(cmd) || cmd.length === 0) { + log.warn("skipping local MCP server with no command", { name }) + return null + } + const out: Record = { + type: "stdio", + command: String(cmd[0]), + } + if (cmd.length > 1) out.args = cmd.slice(1).map((s) => String(s)) + if (spec.environment && typeof spec.environment === "object") { + out.env = substituteEnvPlaceholders( + spec.environment as Record, + ) + } + return out + } + + if (type === "remote") { + if (typeof spec.url !== "string" || !spec.url) { + log.warn("skipping remote MCP server with no url", { name }) + return null + } + const out: Record = { + type: "http", + url: spec.url, + } + if (spec.headers && typeof spec.headers === "object") { + out.headers = substituteEnvPlaceholders( + spec.headers as Record, + ) + } + return out + } + + log.warn("skipping MCP server with unknown type", { + name, + type: type ?? null, + }) + return null +} + +function extractMcpBlock( + config: Record, +): Record { + const mcp = config.mcp + if (!mcp || typeof mcp !== "object" || Array.isArray(mcp)) return {} + return mcp as Record +} + +/** + * Deep-merge per-server specs from `source` into `target`. Mirrors opencode's + * `mergeDeep` semantics for the `mcp` record: each server entry is recursively + * merged so a partial layer (e.g. `{ "linear": { "enabled": true } }`) can + * override one field without dropping the rest. + */ +function mergeMcp( + target: Record, + source: Record, +): Record { + const out: Record = { ...target } + for (const [name, spec] of Object.entries(source)) { + if (!spec || typeof spec !== "object") continue + const existing = out[name] + if (existing && typeof existing === "object") { + out[name] = deepMerge( + existing as Record, + spec as Record, + ) as OpencodeServer + } else { + out[name] = spec + } + } + return out +} + +export interface BridgedMcp { + /** Path to the temp file containing the translated `--mcp-config`. */ + path: string + /** Stable hash of the merged opencode mcp block (pre-translation). */ + hash: string + /** + * Names of opencode MCP servers that were bridged into Claude CLI's + * `--mcp-config`. Excludes any servers passed in `excludeServers`. + */ + serverNames: string[] + /** + * Names of every enabled opencode MCP server after merge + runtime + * overlay, regardless of whether they ended up bridged or excluded. + * Callers (e.g. the proxy-tool builder) use this to decide which + * `_` IDs in opencode's tool catalog are MCP-origin. + */ + allEnabledServerNames: string[] +} + +/** Result of merging opencode's MCP config layers + applying runtime overlay. */ +export interface MergedMcp { + /** Merged, overlay-applied server specs keyed by opencode server name. */ + servers: Record + /** Server names whose final spec is enabled (or implicitly enabled). */ + enabledServerNames: string[] + /** Stable hash of the merged (pre-translation) MCP block. */ + hash: string +} + +/** + * Per-server runtime status from opencode's `client.mcp.status()`. Used as + * an overlay on top of the on-disk merged config so opencode's UI-toggled + * state — which lives only in-memory; `connect()`/`disconnect()` never + * touch disk — propagates to the bridged claude subprocess. + * + * Treatment per server: + * - "connected" → force `enabled: true` (mirror opencode) + * - any other status → force `enabled: false` (don't ship a server + * opencode can't run; user fixes it in opencode first) + * - missing entry → leave disk value + * + * Omit the overlay and the bridge falls back to disk-only. + */ +export type RuntimeMcpStatus = Record + +/** + * Read opencode config layers, deep-merge their `mcp` blocks per opencode's + * own semantics, optionally apply an opencode runtime-status overlay, then + * translate each server to Claude CLI format, write a scratch file, and + * return its path + a stable hash. Returns null when no enabled MCP servers + * remain after the merge + overlay. + */ +export function bridgeOpencodeMcp( + cwd: string, + runtimeStatus?: RuntimeMcpStatus, + excludeServers?: ReadonlySet, +): BridgedMcp | null { + const { + servers: merged, + enabledServerNames: allEnabledServerNames, + hash, + } = mergeOpencodeMcp(cwd, runtimeStatus) + + // Translate every still-enabled server, skipping any caller has asked us + // to exclude (because they're being routed through the proxy instead). + const servers: Record = {} + const bridgedServerNames: string[] = [] + for (const [name, spec] of Object.entries(merged)) { + if (!spec || typeof spec !== "object") continue + if (excludeServers?.has(name)) continue + const translated = translateServer(name, spec as Record) + if (translated) { + servers[name] = translated + bridgedServerNames.push(name) + } + } + return finishBridge({ + servers, + bridgedServerNames, + allEnabledServerNames, + hash, + excludeServers, + }) +} + +/** + * Merge opencode's MCP config layers (global → `OPENCODE_CONFIG` → project + * walk-up → `.opencode/` siblings), apply the opencode runtime-status + * overlay, and hash the result. Split out of `bridgeOpencodeMcp` so + * read-only callers (startup diagnostics) can inspect what would be bridged + * without translating servers or writing a scratch config file. + */ +export function mergeOpencodeMcp( + cwd: string, + runtimeStatus?: RuntimeMcpStatus, +): MergedMcp { + const worktree = detectWorktree(cwd) + + // Layer 1: global merged + let merged: Record = {} + merged = mergeMcp(merged, extractMcpBlock(loadGlobalConfig())) + + // Layer 2: OPENCODE_CONFIG (single file, applied before project walk-up) + const explicitConfig = process.env.OPENCODE_CONFIG + if (explicitConfig && fileExists(explicitConfig)) { + const parsed = readAndParse(explicitConfig) + if (parsed) merged = mergeMcp(merged, extractMcpBlock(parsed)) + } + + // Layer 3: project walk-up — opencode.json[c] in each dir from cwd to + // (not past) worktree, both extensions per dir. walkUp returns cwd-most + // first; collect distinct dirs in that order then reverse for merge so + // cwd-most wins under last-merge-wins. + const projectFiles = walkUp({ + start: cwd, + stop: worktree, + targets: PROJECT_FILE_NAMES, + predicate: fileExists, + }) + const projectDirs: string[] = [] + const seenProjectDirs = new Set() + for (const f of projectFiles) { + const d = path.dirname(f) + if (!seenProjectDirs.has(d)) { + seenProjectDirs.add(d) + projectDirs.push(d) + } + } + for (const dir of projectDirs.slice().reverse()) { + merged = mergeMcp(merged, extractMcpBlock(loadProjectFilesInDir(dir))) + } + + // Layer 4: `.opencode/` siblings — project walk-up then home-dir then + // OPENCODE_CONFIG_DIR, in that order. Iteration order matches opencode's + // (cwd-most first within walk-up), so under deep-merge "later wins" + // parent-most `.opencode/` overrides cwd-most. This is upstream's + // behavior, surprising though it is. + for (const dir of dotOpencodeDirs(cwd, worktree)) { + merged = mergeMcp(merged, extractMcpBlock(loadProjectFilesInDir(dir))) + } + + // Layer 5: opencode runtime overlay. opencode's `/mcps` UI toggle calls + // `mcp.connect()` / `mcp.disconnect()` which only mutate in-memory state, + // never the on-disk config. Without this overlay the bridge can't see + // those toggles and claude misses servers the user just enabled. + if (runtimeStatus) { + for (const name of Object.keys(merged)) { + const status = runtimeStatus[name] + if (status === undefined) continue + const existing = merged[name] + const base = + existing && typeof existing === "object" + ? (existing as Record) + : {} + merged[name] = { ...base, enabled: status === "connected" } as OpencodeServer + } + } + + // Compute the set of enabled server names BEFORE exclusion so callers can + // tell whether a tool ID like `slack_conversations_add_message` came from + // an opencode MCP server (vs a built-in tool that happens to contain `_`). + const enabledServerNames: string[] = [] + for (const [name, spec] of Object.entries(merged)) { + if (!spec || typeof spec !== "object") continue + const enabled = (spec as { enabled?: unknown }).enabled + if (enabled === false) continue + enabledServerNames.push(name) + } + + // Hash the pre-exclusion merged block so the hot-reload detector picks up + // upstream config changes even when every server is excluded. + const mergedBody = JSON.stringify({ mcpServers: merged }, null, 2) + const hash = crypto + .createHash("sha256") + .update(mergedBody) + .digest("hex") + .slice(0, 12) + + return { servers: merged, enabledServerNames, hash } +} + +/** Write the translated config (if any) and shape `bridgeOpencodeMcp`'s result. */ +function finishBridge(input: { + servers: Record + bridgedServerNames: string[] + allEnabledServerNames: string[] + hash: string + excludeServers?: ReadonlySet +}): BridgedMcp | null { + const { servers, bridgedServerNames, allEnabledServerNames, hash, excludeServers } = + input + + if (Object.keys(servers).length === 0) { + const allEnabledServersExcluded = + excludeServers && + allEnabledServerNames.length > 0 && + allEnabledServerNames.every((name) => excludeServers.has(name)) + + if (!allEnabledServersExcluded) return null + + return { + path: "", + hash, + serverNames: [], + allEnabledServerNames, + } + } + + const body = JSON.stringify({ mcpServers: servers }, null, 2) + // Content-addressed on purpose. `hash` covers the merged opencode config and + // NOT `excludeServers`, so two calls that differ only in exclusions share it, + // and the file is written only when absent. Naming the file after `hash` + // alone therefore let the first writer win: the hot-reload probe runs first + // with no exclusions, so the spawn's own exclusions never reached disk and a + // server being routed through the proxy stayed in the bridged config as well, + // reachable by both routes. That is the double execution this whole option + // exists to prevent. The returned `hash` is unchanged, because drift + // detection still wants to track the config rather than the exclusions. + const bodyDigest = crypto + .createHash("sha256") + .update(body) + .digest("hex") + .slice(0, 12) + const outPath = path.join( + pluginTmpDir(), + `mcp-${bodyDigest}.json`, + ) + try { + if (!fileExists(outPath)) { + fs.writeFileSync(outPath, body, { encoding: "utf8", mode: 0o600 }) + } + } catch (e) { + log.warn("failed to write bridged MCP config", { + error: e instanceof Error ? e.message : String(e), + }) + return null + } + + log.info("bridged opencode MCP config", { + target: outPath, + hash, + servers: bridgedServerNames, + excluded: excludeServers ? Array.from(excludeServers) : [], + }) + return { + path: outPath, + hash, + serverNames: bridgedServerNames, + allEnabledServerNames, + } +} + +// Internal helpers exported for tests only. +export const __test = { + deepMerge, + mergeMcp, + translateServer, + substituteEnvPlaceholders, + detectWorktree, + loadGlobalConfig, + loadProjectFilesInDir, + dotOpencodeDirs, +} diff --git a/src/message-builder.ts b/src/message-builder.ts index aaae2f0..dcb0b74 100644 --- a/src/message-builder.ts +++ b/src/message-builder.ts @@ -1,15 +1,366 @@ -import type { LanguageModelV2 } from "@ai-sdk/provider" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { + FAILOVER_MARKER, + stripAccountFailoverParts, +} from "./account-failover.js" +import { INLINE_ASIDE_MARKER, LEGACY_INLINE_ASIDE_MARKERS } from "./btw-command.js" +import { + COMPACT_BOUNDARY_MARKER, + RATE_LIMIT_MARKER, + RESULT_ERROR_MARKER, + STREAM_TIMEOUT_MARKER, +} from "./cli-events.js" +import { DOCTOR_MARKER, parseDoctorCommandContent } from "./doctor.js" import { log } from "./logger.js" +import { parseSideQuestionContent } from "./side-question.js" +import { TURN_STATS_MARKER } from "./turn-stats.js" -type Prompt = Parameters[0]["prompt"] +type Prompt = Parameters[0]["prompt"] /** - * Compact conversation history into a context summary for when we start - * a fresh Claude CLI session but want to preserve conversation context. + * Leading markers of text parts the plugin itself wrote into an assistant + * reply: the `/btw` aside and its pre-bar form, the turn-stats footer, and the + * `▌` notes for a CLI compaction, a rate-limit rejection and a failed result + * subtype. None of them was ever model output or ever in Claude's context, so + * a transcript rebuilt for a fresh CLI process must not hand any of them back + * as something Claude said. Each is the first characters of its own text part, + * which is what makes the strip exact instead of a guess at where a block ends. */ -export function compactConversationHistory(prompt: Prompt): string | null { +const PLUGIN_NOTE_MARKERS = [ + INLINE_ASIDE_MARKER, + ...LEGACY_INLINE_ASIDE_MARKERS, + TURN_STATS_MARKER, + COMPACT_BOUNDARY_MARKER, + RATE_LIMIT_MARKER, + RESULT_ERROR_MARKER, + DOCTOR_MARKER, + STREAM_TIMEOUT_MARKER, + FAILOVER_MARKER, +] + +function isPluginNote(part: any): boolean { + if (!part || part.type !== "text" || typeof part.text !== "string") return false + const text = part.text.trimStart() + return PLUGIN_NOTE_MARKERS.some((marker) => text.startsWith(marker)) +} + +function stripPluginNotes(content: unknown): unknown { + if (!Array.isArray(content)) return content + const kept = content.filter((part: any) => !isPluginNote(part)) + return kept.length === content.length ? content : kept +} + +/** + * Drop every plugin-authored exchange and note from a transcript before it is + * replayed to the CLI: the `/btw` question with its answer, the + * `/claude-code-doctor` report with its command, and the `▌` blocks listed in + * `PLUGIN_NOTE_MARKERS`. Named for the `/btw` case it started as; it is the + * one place all of them are removed, and it is called from both transcript + * rebuild paths. + */ +export function filterSideQuestionHistory(prompt: Prompt): Prompt { + // The account-failover form is a synthetic `question` call Claude never + // issued, answered by a `tool-result` it never saw. It has to come out + // before anything is replayed, which is on the switch turn by definition. + prompt = stripAccountFailoverParts(prompt) + let pluginCommand = false + const kept = prompt.filter((message) => { + if (message.role === "user") { + pluginCommand = + parseSideQuestionContent(message.content) !== null || + parseDoctorCommandContent(message.content) !== null + return !pluginCommand + } + return message.role !== "assistant" || !pluginCommand + }) + return kept.map((message) => + message.role === "assistant" ? ({ ...message, content: stripPluginNotes(message.content) } as typeof message) : message, + ) +} + +/** + * opencode-dcp anchors its nudges into message text as + * `` blocks (its `lib/messages/inject/utils.ts` appends + * one to an existing text part, or splices in a synthetic part), and the + * loudest of them orders the model to "use the `compress` tool now". Under + * this provider that tool is only reachable when the operator forwards it, + * so otherwise the block is an order that cannot be obeyed, carried by every + * message it is anchored to. + * + * Blocks are removed wherever they sit rather than by matching a whole part, + * because dcp appends its own `` marker after one and an + * end-anchored check would miss it. That is the same trap the `/btw` + * reminder strip hit in production. + */ +const DCP_REMINDER_BLOCK = + /]*>[\s\S]*?<\/dcp-system-reminder>/gi + +/** Remove every dcp reminder block from one piece of text. */ +export function stripContextReminderBlocks(text: string): string { + if (!text.includes(" + (list ?? []).some((name) => String(name).trim().toLowerCase() === "compress") + return !namesCompress(options.proxyTools) && !namesCompress(options.proxyOpencodeTools) +} + +/** + * Strip dcp reminder blocks from every user and assistant text part. + * + * Emptied parts are kept as empty strings rather than dropped: a nudge is + * sometimes a message's only text part, and removing the part outright could + * leave a user message with no content at all, which takes the empty-content + * sentinel path in `getClaudeUserMessage`. Every consumer here already skips + * a falsy `text`. + */ +export function stripContextReminders(prompt: Prompt): { + prompt: Prompt + removed: number +} { + let removed = 0 + const countIn = (text: string): number => + (text.match(DCP_REMINDER_BLOCK) ?? []).length + + const out = prompt.map((message) => { + if (message.role !== "user" && message.role !== "assistant") return message + + // AI SDK v3 always delivers user/assistant content as a part array, so + // there is no string form to handle here. + if (!Array.isArray(message.content)) return message + + let touched = false + const parts = (message.content as any[]).map((part) => { + if (!part || part.type !== "text" || typeof part.text !== "string") return part + const hits = countIn(part.text) + if (hits === 0) return part + removed += hits + touched = true + return { ...part, text: stripContextReminderBlocks(part.text) } + }) + return touched ? ({ ...message, content: parts } as typeof message) : message + }) + + return removed > 0 ? { prompt: out, removed } : { prompt, removed: 0 } +} + +const SUPPORTED_IMAGE_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +]) + +function toImageBlock(part: any): any | null { + const raw: unknown = part.image ?? part.data ?? part.url ?? part.source?.data + if (!raw) { + log.warn("file part without data, skipping") + return null + } + + let resolvedMediaType: string = part.mediaType || part.mimeType || part.mime || "" + let base64: string | null = null + + if (typeof raw === "string") { + if (raw.startsWith("data:")) { + const match = /^data:([^;,]+)(?:;[^,]*)*(?:;base64)?,(.*)$/s.exec(raw) + if (!match) { + log.warn("malformed data URI, skipping file part") + return null + } + resolvedMediaType = resolvedMediaType || match[1] + base64 = match[2] + } else if (/^https?:\/\//i.test(raw)) { + log.warn("remote URL images are not supported by Claude CLI, skipping") + return null + } else { + base64 = raw + } + } else if (raw instanceof URL) { + log.warn("remote URL images are not supported by Claude CLI, skipping") + return null + } else if (raw instanceof Uint8Array || Buffer.isBuffer(raw)) { + base64 = Buffer.from(raw as Uint8Array).toString("base64") + } else { + log.warn("unsupported file part data type", { dataType: typeof raw }) + return null + } + + if (!resolvedMediaType || !SUPPORTED_IMAGE_TYPES.has(resolvedMediaType)) { + log.warn("unsupported media type for Claude image block, skipping", { + mediaType: resolvedMediaType, + }) + return null + } + + return { + type: "image", + source: { type: "base64", media_type: resolvedMediaType, data: base64 }, + } +} + +function getToolResultText(part: any): string { + const value = part.output ?? part.result + + if (typeof value === "string") { + return value + } + + if (!value || typeof value !== "object") { + return JSON.stringify(value) + } + + switch (value.type) { + case "text": + case "error-text": + return String(value.value) + case "json": + case "error-json": + return JSON.stringify(value.value) + case "execution-denied": + return value.reason ? `Execution denied: ${value.reason}` : "Execution denied" + case "content": + return Array.isArray(value.value) + ? value.value + .map((item: any) => { + if (item?.type === "text") return item.text + return JSON.stringify(item) + }) + .join("\n") + : JSON.stringify(value.value) + default: + return JSON.stringify(value) + } +} + +// Compaction-mode caps. These are the only knobs that affect how much +// transcript content reaches the model when opencode invokes /compact. +// 180k chars ≈ 60k tokens worst-case — well under Haiku 4.5's 200k window +// after accounting for system prompt + output budget. +const MAX_HISTORY_CHARS = 180_000 +const MAX_TOOL_RESULT_CHARS = 10_000 +const MAX_TOOL_INPUT_CHARS = 2_000 + +function clipWithMarker(text: string, max: number): string { + if (text.length <= max) return text + return `${text.slice(0, max)}\n…[truncated ${text.length - max} chars]` +} + +function renderToolInput(input: unknown): string { + let raw: string + try { + raw = typeof input === "string" ? input : JSON.stringify(input) + } catch { + raw = String(input) + } + return clipWithMarker(raw, MAX_TOOL_INPUT_CHARS) +} + +function renderMessageContentForCompaction( + msg: any, +): { text: string; toolResultCount: number } { + const lines: string[] = [] + let toolResultCount = 0 + + if (typeof msg.content === "string") { + return { text: msg.content, toolResultCount: 0 } + } + + if (!Array.isArray(msg.content)) { + return { text: "", toolResultCount: 0 } + } + + for (const part of msg.content as any[]) { + if (!part) continue + switch (part.type) { + case "text": + if (part.text) lines.push(part.text) + break + case "tool-call": + lines.push( + `[tool_use:${part.toolName ?? "unknown"}(${renderToolInput(part.input)})]`, + ) + break + case "tool-result": + toolResultCount++ + lines.push( + `[tool_result:${part.toolName ?? part.toolCallId ?? "unknown"}]\n${clipWithMarker( + getToolResultText(part), + MAX_TOOL_RESULT_CHARS, + )}`, + ) + break + case "image": + lines.push( + `[image: ${part.mediaType ?? part.mimeType ?? "unknown"}]`, + ) + break + case "file": + lines.push( + `[file: ${part.mediaType ?? part.mimeType ?? "unknown"}]`, + ) + break + case "reasoning": + // Skip reasoning blocks in compaction — they bloat input without + // helping the summarizer. + break + } + } + + return { text: lines.join("\n"), toolResultCount } +} + +/** + * Compact conversation history into a context summary. + * + * - mode "fresh-session" (default): includes user, assistant and tool roles, + * renders each with the same serializer /compact uses so tool inputs and + * result bodies survive, then clips each message at 2000 chars. Used when + * starting a fresh CLI session that lost its prior session id. It used to + * filter to user/assistant only and reduce tool content to + * `[Called N tool(s)]` placeholders, which silently dropped subagent + * output entirely (issue #29). + * - mode "compaction": rich serializer for opencode /compact. Includes + * tool roles, renders tool_use input and tool_result content (each + * clipped at MAX_TOOL_RESULT_CHARS), and caps aggregate output at + * MAX_HISTORY_CHARS by dropping oldest entries first. + */ +export function compactConversationHistory( + prompt: Prompt, + opts: { mode?: "fresh-session" | "compaction" } = {}, +): string | null { + const mode = opts.mode ?? "fresh-session" + prompt = filterSideQuestionHistory(prompt) + + if (mode === "compaction") { + return buildCompactionHistory(prompt) + } + + // `tool`-role messages carry the results of everything opencode ran itself, + // so they belong in the transcript. Filtering them out (issue #29) meant a + // subagent's whole answer vanished: the assistant message kept a + // `[Called 1 tool(s): task]` placeholder and the result it referred to was + // never rendered at all. const conversationMessages = prompt.filter( - (m) => m.role === "user" || m.role === "assistant", + (m) => m.role === "user" || m.role === "assistant" || m.role === "tool", ) if (conversationMessages.length <= 1) { @@ -20,31 +371,14 @@ export function compactConversationHistory(prompt: Prompt): string | null { for (let i = 0; i < conversationMessages.length - 1; i++) { const msg = conversationMessages[i] - const role = msg.role === "user" ? "User" : "Assistant" - - let text = "" - if (typeof msg.content === "string") { - text = msg.content - } else if (Array.isArray(msg.content)) { - const textParts = (msg.content as any[]) - .filter((p) => p.type === "text" && p.text) - .map((p) => p.text) - text = textParts.join("\n") - - const toolCalls = (msg.content as any[]).filter( - (p) => p.type === "tool-call", - ) - const toolResults = (msg.content as any[]).filter( - (p) => p.type === "tool-result", - ) - - if (toolCalls.length > 0) { - text += `\n[Called ${toolCalls.length} tool(s): ${toolCalls.map((t: any) => t.toolName).join(", ")}]` - } - if (toolResults.length > 0) { - text += `\n[Received ${toolResults.length} tool result(s)]` - } - } + const role = + msg.role === "user" ? "User" : msg.role === "assistant" ? "Assistant" : "Tool" + + // Same renderer the /compact transcript uses, so tool inputs and result + // bodies survive instead of collapsing to counts. This path used to write + // `[Called N tool(s): ...]` / `[Received N tool result(s)]` and discard + // every byte of the payload, which is the second half of issue #29. + const { text } = renderMessageContentForCompaction(msg) if (text.trim()) { const truncated = @@ -60,16 +394,151 @@ export function compactConversationHistory(prompt: Prompt): string | null { return historyParts.join("\n\n") } +function buildCompactionHistory(prompt: Prompt): string | null { + // Iterate newest-first, accumulate up to MAX_HISTORY_CHARS, then reverse + // to chronological order. Oldest messages get dropped when the budget + // is exhausted — they are the least relevant for a summary of recent + // work. + const entries: string[] = [] + let total = 0 + let totalToolResults = 0 + let droppedOldest = 0 + + // Skip the trailing user message: opencode's /compact appends the + // synthesis instruction as the final user turn. The instruction itself + // is added by getClaudeUserMessage after the transcript block, so we + // don't want it duplicated inside the transcript. + const end = prompt.length > 0 && prompt[prompt.length - 1].role === "user" + ? prompt.length - 1 + : prompt.length + + for (let i = end - 1; i >= 0; i--) { + const msg = prompt[i] as any + const roleLabel = + msg.role === "user" + ? "User" + : msg.role === "assistant" + ? "Assistant" + : msg.role === "tool" + ? "Tool" + : msg.role + + const { text, toolResultCount } = renderMessageContentForCompaction(msg) + if (!text.trim()) continue + + const entry = `${roleLabel}: ${text}` + if (total + entry.length > MAX_HISTORY_CHARS) { + droppedOldest = i + 1 + break + } + entries.push(entry) + total += entry.length + 2 // +2 for the "\n\n" join + totalToolResults += toolResultCount + } + + if (entries.length === 0) return null + + entries.reverse() + log.info("built compaction history", { + entries: entries.length, + chars: total, + toolResults: totalToolResults, + droppedOldestBefore: droppedOldest, + }) + + return entries.join("\n\n") +} + /** * Convert AI SDK prompt into a Claude CLI stream-json user message. + * + * `compactionMode` switches behavior for opencode /compact: the prior + * transcript is rendered with rich tool content (not placeholders) and the + * wrapper framing tells the model this is the authoritative thread. + * + * Reasoning effort is not part of the message. It used to ride here as a + * thinking keyword ("(ultrathink)"), but Claude Code dropped every keyword + * except that one, so effort now reaches the CLI as CLAUDE_CODE_EFFORT_LEVEL + * at spawn time (see `claudeSpawnEnv`). */ export function getClaudeUserMessage( prompt: Prompt, includeHistoryContext: boolean = false, + opts: { + compactionMode?: boolean + cliToolCallIds?: ReadonlySet + stripContextReminders?: boolean + } = {}, ): string { + const compactionMode = opts.compactionMode === true + const cliToolCallIds = opts.cliToolCallIds const content: any[] = [] - if (includeHistoryContext) { + // Done once here, at the top, so every path below (the current message, + // the fresh-session rebuild and the /compact transcript) sees the cleaned + // text without each needing its own flag. + if (opts.stripContextReminders) { + const stripped = stripContextReminders(prompt) + if (stripped.removed > 0) { + log.info("stripped unsatisfiable context reminders", { + blocks: stripped.removed, + }) + prompt = stripped.prompt + } + } + + /** + * A `tool_result` block is only meaningful to a resumed CLI session when + * that session issued the matching `tool_use`. Anything opencode ran on its + * own behalf (a `subtask: true` command's `task` call, issue #29) has an id + * the CLI never emitted, so the block is orphaned: Claude cannot resolve it + * and the payload, which is right there in the envelope, is unreachable. + * Those are rendered as plain text instead, which keeps the content and + * loses only the pairing the CLI could not have honoured anyway. + * + * `cliToolCallIds` is the set of calls this CLI process is waiting on. When + * a caller does not supply it we keep the old unconditional block, so a + * forgotten call site degrades to today's behaviour rather than breaking + * the proxy round-trip. + */ + const pushToolResult = (part: any): void => { + const id = part.toolCallId + const text = getToolResultText(part) + if (!cliToolCallIds || cliToolCallIds.has(id)) { + content.push({ type: "tool_result", tool_use_id: id, content: text }) + return + } + log.info("rendering opencode-side tool result as text", { + toolCallId: id, + toolName: part.toolName, + chars: text.length, + }) + content.push({ + type: "text", + text: `\n${text}\n`, + }) + } + + if (compactionMode) { + const transcript = compactConversationHistory(prompt, { + mode: "compaction", + }) + if (transcript) { + log.info("including compaction transcript", { + historyLength: transcript.length, + }) + content.push({ + type: "text", + text: ` +${transcript} + + +The complete prior conversation appears above. The synthesis instructions follow below. + +`, + }) + } + } else if (includeHistoryContext) { const historyContext = compactConversationHistory(prompt) if (historyContext) { log.info("including conversation history context", { @@ -100,31 +569,41 @@ Now continuing with the current message: for (const msg of messages) { if (msg.role === "user") { + if (parseSideQuestionContent(msg.content) !== null) continue if (typeof msg.content === "string") { - content.push({ type: "text", text: msg.content }) + const str = msg.content as string + if (str.trim()) { + content.push({ type: "text", text: str }) + } } else if (Array.isArray(msg.content)) { for (const part of msg.content as any[]) { if (part.type === "text") { - content.push({ type: "text", text: part.text }) - } else if (part.type === "tool-result") { - const p = part as any - let resultText = "" - if (typeof p.result === "string") { - resultText = p.result - } else if ( - typeof p.result === "object" && - p.result && - "output" in p.result - ) { - resultText = String(p.result.output) + if (part.text && part.text.trim()) { + content.push({ type: "text", text: part.text }) + } + } else if (part.type === "file" || part.type === "image") { + const block = toImageBlock(part) + if (block) { + content.push(block) } else { - resultText = JSON.stringify(p.result) + log.debug("skipped non-image file part", { + mediaType: part.mediaType, + }) } - content.push({ - type: "tool_result", - tool_use_id: p.toolCallId, - content: resultText, - }) + } else if (part.type === "tool-result") { + pushToolResult(part) + } + } + } + } else if (msg.role === "tool") { + // AI SDK V3 delivers tool results in `tool`-role messages, not `user`. + // Without this branch we'd hit the empty-content sentinel path and + // send "(empty)" to Claude CLI instead of the actual tool result — + // forcing the user to press "continue" between proxy tool calls. + if (Array.isArray(msg.content)) { + for (const part of msg.content as any[]) { + if (part?.type === "tool-result") { + pushToolResult(part) } } } @@ -132,11 +611,18 @@ Now continuing with the current message: } if (content.length === 0) { + // CLI rejects a zero-block message with 400, and Anthropic rejects + // whitespace-only text blocks — so we need a non-whitespace sentinel. + // "(empty)" matches the parenthetical meta-note convention this file + // already uses for reasoning keywords ("(think)", "(megathink)", etc.), + // which the model reads as out-of-band metadata rather than a prompt to + // continue its previous turn. + log.warn("empty user content; sending sentinel to satisfy CLI") return JSON.stringify({ type: "user", message: { role: "user", - content: [{ type: "text", text: "" }], + content: [{ type: "text", text: "(empty)" }], }, }) } diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..0b07165 --- /dev/null +++ b/src/models.ts @@ -0,0 +1,354 @@ +import type { OpenCodeModel } from "./opencode-types.js" + +const PROVIDER_ID = "claude-code" +const NPM = "@khalilgharbaoui/opencode-claude-code-plugin" + +const reasoningVariants: Record> = { + low: { reasoningEffort: "low" }, + medium: { reasoningEffort: "medium" }, + high: { reasoningEffort: "high" }, + xhigh: { reasoningEffort: "xhigh" }, + max: { reasoningEffort: "max" }, +} + +const baseCapabilities = { + temperature: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false as const, +} + +function defineModel(opts: { + id: string + name: string + family: string + reasoning: boolean + context: number + output: number + cost: { input: number; output: number; cacheRead: number; cacheWrite: number } + releaseDate: string + // List-price multiplier relative to Haiku (the cheapest model). Derived + // exactly from published per-token pricing: input AND output ratios both come + // out to haiku 1, sonnet 3, opus 5, fable/mythos 10. Rendered as an + // `(N×)` suffix so it surfaces in opencode's model picker, which has no + // dedicated multiplier field. + // Display-only: model resolution keys off `id`. + multiplier: number + status?: OpenCodeModel["status"] +}): OpenCodeModel { + return { + id: opts.id, + providerID: PROVIDER_ID, + api: { id: opts.id, url: "", npm: NPM }, + name: `${opts.name} (${opts.multiplier}×)`, + family: opts.family, + capabilities: { ...baseCapabilities, reasoning: opts.reasoning }, + cost: { + input: opts.cost.input, + output: opts.cost.output, + cache: { read: opts.cost.cacheRead, write: opts.cost.cacheWrite }, + }, + limit: { context: opts.context, output: opts.output }, + status: opts.status ?? "active", + options: {}, + headers: {}, + release_date: opts.releaseDate, + variants: opts.reasoning ? reasoningVariants : undefined, + } +} + +// Costs in US dollars per MILLION tokens, matching Anthropic's published +// pricing verbatim. This is the unit opencode and models.dev use: opencode +// divides by 1e6 itself when it multiplies a cost by a token count, so writing +// per-token values here under-reports session cost by exactly 1,000,000x. +// Compare models.dev's own entry for the same model: +// `anthropic/claude-haiku-4-5 -> {"input": 1, "output": 5, "cache_read": 0.1, +// "cache_write": 1.25}`. +// +// There is no long-context premium to model. Anthropic's pricing page states +// that Claude 4.6 and later ship the full 1M-token context window at standard +// pricing ("a 900k-token request is billed at the same per-token rate as a +// 9k-token request"), and caching/batch discounts apply unchanged across it. +// opencode 1.18.5 added optional `cost.tiers` / `cost.experimentalOver200K` +// fields for above-200K pricing; they stay unset here deliberately, because a +// tier would misreport the real price. Re-check only if Anthropic introduces +// one. Verified against the pricing docs 2026-07-26. +const haikuCost = { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 } +const sonnetCost = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 } +// Opus 4.5+ standard pricing is $5/M in, $25/M out (the price cut at 4.5; held +// through 4.6/4.7/4.8/5). Cache read 0.1x input, cache write 1.25x input. +const opusCost = { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 } +// Fable 5 and Mythos 5 are the Mythos-class tier above Opus and share pricing +// ($10/M in, $50/M out). Cache read/write follow Anthropic's standard 0.1x / 1.25x +// input ratios (not separately published). +const fableCost = { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 } +// Fable 5.1 and Mythos 5.1 keep the same input/output and cache-write rates, +// but Anthropic cut cache reads to $0.25/M (one quarter of the 5.0 price). +const fable51Cost = { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 } +// Fast mode bills the same per-token rates as the Mythos-class tier: $10/M in, +// $50/M out, cache read 1, cache write 12.5. Not an inference; this is the +// exact table the CLI itself applies for `speed: "fast"` on Opus 4.8 / Opus 5 +// (`{inputTokens: 10, outputTokens: 50, promptCacheWriteTokens: 12.5, +// promptCacheReadTokens: 1}`). Kept as its own binding rather than reusing +// `fableCost` so a future divergence in either tier stays a one-line change. +// Verified against Claude Code 2.1.245, 2026-08-30. +const opusFastCost = { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 } + +/** + * Convert an OpenCodeModel to the flat config schema that OpenCode's + * provider.ts config parser expects (model.temperature, model.reasoning, + * model.cost.cache_read, model.modalities, etc.). + */ +export function toConfigModel(model: OpenCodeModel): Record { + const inputMods: string[] = [] + const outputMods: string[] = [] + for (const [k, v] of Object.entries(model.capabilities.input)) { + if (v) inputMods.push(k) + } + for (const [k, v] of Object.entries(model.capabilities.output)) { + if (v) outputMods.push(k) + } + + return { + id: model.api.id, + name: model.name, + status: model.status, + family: model.family ?? "", + release_date: model.release_date, + + temperature: model.capabilities.temperature, + reasoning: model.capabilities.reasoning, + attachment: model.capabilities.attachment, + tool_call: model.capabilities.toolcall, + modalities: { input: inputMods, output: outputMods }, + + cost: { + input: model.cost.input, + output: model.cost.output, + cache_read: model.cost.cache.read, + cache_write: model.cost.cache.write, + }, + + limit: model.limit, + options: model.options, + headers: model.headers, + variants: model.variants, + } +} + +export const defaultModels: Record = { + "claude-haiku-4-5": defineModel({ + id: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + family: "haiku", + reasoning: false, + context: 200_000, + output: 64_000, + cost: haikuCost, + multiplier: 1, + releaseDate: "2025-10-01", + }), + "claude-sonnet-4-5": defineModel({ + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + family: "sonnet", + reasoning: true, + context: 200_000, + output: 64_000, + cost: sonnetCost, + multiplier: 3, + releaseDate: "2025-09-29", + }), + "claude-sonnet-4-6": defineModel({ + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + family: "sonnet", + reasoning: true, + context: 1_000_000, + output: 128_000, + cost: sonnetCost, + multiplier: 3, + releaseDate: "2025-06-19", + }), + "claude-sonnet-5": defineModel({ + id: "claude-sonnet-5", + name: "Claude Sonnet 5", + family: "sonnet", + reasoning: true, + context: 1_000_000, + output: 128_000, + cost: sonnetCost, + multiplier: 3, + releaseDate: "2026-06-30", + }), + "claude-opus-4-5": defineModel({ + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + family: "opus", + reasoning: true, + context: 200_000, + output: 64_000, + cost: opusCost, + multiplier: 5, + releaseDate: "2025-11-01", + }), + "claude-opus-4-6": defineModel({ + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + family: "opus", + reasoning: true, + context: 1_000_000, + output: 128_000, + cost: opusCost, + multiplier: 5, + releaseDate: "2025-06-19", + }), + "claude-opus-4-7": defineModel({ + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + family: "opus", + reasoning: true, + context: 1_000_000, + output: 128_000, + cost: opusCost, + multiplier: 5, + releaseDate: "2025-07-16", + }), + "claude-opus-4-8": defineModel({ + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + family: "opus", + reasoning: true, + context: 1_000_000, + output: 128_000, + cost: opusCost, + multiplier: 5, + releaseDate: "2026-05-28", + }), + // Fast mode. The `-fast` suffix is OUR marker, not a model name Anthropic + // serves: `parseModelId` strips it before `--model` and turns it into + // `--settings {"fastMode":true}` on the spawn. Retired `-fast` model strings + // (`claude-opus-4-6-fast`) are a different thing and are not registered here. + // + // Only Opus 4.8 and Opus 5 qualify: the CLI gates fast mode on the resolved + // model name containing `opus-4-8` or `opus-5`, so registering a fast entry + // for any other model would produce a picker option that silently runs at + // standard speed while displaying the 10x price. + "claude-opus-4-8-fast": defineModel({ + id: "claude-opus-4-8-fast", + name: "Claude Opus 4.8 Fast", + family: "opus", + reasoning: true, + context: 1_000_000, + output: 128_000, + cost: opusFastCost, + multiplier: 10, + releaseDate: "2026-05-28", + }), + "claude-opus-5": defineModel({ + id: "claude-opus-5", + name: "Claude Opus 5", + family: "opus", + reasoning: true, + context: 1_000_000, + output: 128_000, + cost: opusCost, + multiplier: 5, + releaseDate: "2026-07-24", + }), + "claude-opus-5-fast": defineModel({ + id: "claude-opus-5-fast", + name: "Claude Opus 5 Fast", + family: "opus", + reasoning: true, + context: 1_000_000, + output: 128_000, + cost: opusFastCost, + multiplier: 10, + releaseDate: "2026-07-24", + }), + "claude-fable-5": defineModel({ + id: "claude-fable-5", + name: "Claude Fable 5", + family: "fable", + reasoning: true, + context: 1_000_000, + output: 128_000, + cost: fableCost, + multiplier: 10, + releaseDate: "2026-06-09", + }), + "claude-fable-5-1": defineModel({ + id: "claude-fable-5-1", + name: "Claude Fable 5.1", + family: "fable", + reasoning: true, + context: 1_000_000, + output: 128_000, + cost: fable51Cost, + multiplier: 10, + releaseDate: "2026-09-01", + }), + // Mythos 5 and 5.1 share the corresponding Fable models' capabilities and + // pricing without the safety classifiers; limited availability via Project + // Glasswing. `claude --model` simply errors for accounts without access, so + // they are safe to register unconditionally. + "claude-mythos-5": defineModel({ + id: "claude-mythos-5", + name: "Claude Mythos 5", + family: "mythos", + reasoning: true, + context: 1_000_000, + output: 128_000, + cost: fableCost, + multiplier: 10, + releaseDate: "2026-06-09", + }), + "claude-mythos-5-1": defineModel({ + id: "claude-mythos-5-1", + name: "Claude Mythos 5.1", + family: "mythos", + reasoning: true, + context: 1_000_000, + output: 128_000, + cost: fable51Cost, + multiplier: 10, + releaseDate: "2026-09-01", + }), +} + +/** Marker this plugin appends to build a fast-mode model id. See below. */ +const FAST_SUFFIX = "-fast" + +/** + * Split an opencode model id into the name the Claude CLI actually accepts + * and whether fast mode was requested. + * + * Two suffixes can ride on one id and they are NOT interchangeable: + * + * claude-opus-5-fast@work + * \_____________/\___/\__/ + * CLI model ours accounts.ts's + * + * `@work` must survive: the per-account wrapper script strips it at spawn + * time to pick a CLAUDE_CONFIG_DIR. `-fast` must not: the CLI has no such + * model (`claude-opus-4-6-fast` is retired and `claude-opus-4-7-fast` errors + * outright), so it becomes `--settings {"fastMode":true}` instead. + * + * The `defaultModels` lookup is the guard against a false positive. Only ids + * we registered are treated as fast markers, so a user-defined model that + * happens to end in `-fast` is passed through untouched rather than being + * silently rewritten into a model name that does not exist. + */ +export function parseModelId(modelId: string): { model: string; fast: boolean } { + const at = modelId.indexOf("@") + const base = at === -1 ? modelId : modelId.slice(0, at) + const account = at === -1 ? "" : modelId.slice(at) + + if (!base.endsWith(FAST_SUFFIX)) return { model: modelId, fast: false } + if (!Object.hasOwn(defaultModels, base)) return { model: modelId, fast: false } + + return { model: base.slice(0, -FAST_SUFFIX.length) + account, fast: true } +} diff --git a/src/opencode-types.ts b/src/opencode-types.ts new file mode 100644 index 0000000..85970bb --- /dev/null +++ b/src/opencode-types.ts @@ -0,0 +1,169 @@ +export type ModelID = string +export type ProviderID = string + +export type OpenCodeModel = { + id: ModelID + providerID: ProviderID + api: { + id: string + url: string + npm: string + } + name: string + family?: string + capabilities: { + temperature: boolean + reasoning: boolean + attachment: boolean + toolcall: boolean + input: { + text: boolean + audio: boolean + image: boolean + video: boolean + pdf: boolean + } + output: { + text: boolean + audio: boolean + image: boolean + video: boolean + pdf: boolean + } + // opencode widened this between 1.18.5 and 1.18.18: `reasoning_details` + // became `reasoning_text`, and bare strings are now accepted. This is a + // hand-written mirror of opencode's schema, so it drifts silently: + // re-check it when auditing a new opencode version. Audited clean at + // 1.18.29 on 2026-09-07. Note the type below is deliberately a BLEND of + // two upstream schemas (v1 config for `release_date` and the flat + // provider entry, v2 runtime for nested `capabilities`/`interleaved`), + // so do not "correct" it by copying either one wholesale. See AGENTS.md. + interleaved: + | boolean + | string + | { field: "reasoning" | "reasoning_content" | "reasoning_text" | string } + } + cost: { + input: number + output: number + cache: { + read: number + write: number + } + } + limit: { + context: number + input?: number + output: number + } + status: "alpha" | "beta" | "deprecated" | "active" + options: Record + headers: Record + release_date: string + variants?: Record> +} + +export type OpenCodeProvider = { + id: ProviderID + name?: string + source?: string + options?: Record + models: Record +} + +export type OpenCodeConfig = { + command?: Record + provider?: Record< + string, + { + name?: string + npm?: string + env?: string[] + options?: Record + models?: Record + } + > + // Agent definitions. Kept loose (opencode adds agent fields over time) and + // only ever added to: `expandAccountAgents` never overwrites an entry the + // user defined. + agent?: Record> + // Extra skill roots opencode scans for `**/SKILL.md` (absolute or `~/` + // paths). The plugin adds its bundled skills directory here. + skills?: { paths?: string[]; urls?: string[] } +} + +/** + * Bus events surface to plugins. Shape mirrors what opencode core publishes + * via `GlobalBus.emit("event", { directory, payload: { type, properties } })` + * but kept loose since opencode adds events over time and this plugin only + * reacts to a small subset (currently just `session.deleted`). + */ +export type OpenCodeEvent = { + type?: string + payload?: { type?: string; properties?: Record } + [key: string]: unknown +} + +/** + * Input shape for the `chat.params` hook. opencode passes the agent name + * for the current call ("default", "compaction", "title", etc.), the + * resolved model, and the user message. Output is the mutable params bag + * the hook can adjust before opencode forwards them to the LM. + * + * The plugin injects `input.agent` as `opencodeAgent` and `input.sessionID` + * as `opencodeSessionID` into `output.options` so the language model can + * read them from `providerOptions[providerID]` on every LLM request. + * `opencodeSessionID` serves as a fallback affinity token when the + * `x-session-affinity` request header is absent (provider switch + * mid-session, title synthesis paths, older opencode versions). + */ +export type OpenCodeChatParamsInput = { + sessionID?: string + agent?: string + model?: OpenCodeModel & { providerID: ProviderID } + // Matches opencode SDK ProviderContext: { source, info, options }. + // The provider id lives at provider.info.id, not provider.id. + provider?: { source?: string; info?: { id?: ProviderID }; options?: Record } + message?: unknown +} + +export type OpenCodeChatParamsOutput = { + temperature?: number + topP?: number + topK?: number + maxOutputTokens?: number + options?: Record +} + +export type OpenCodeHooks = { + config?: (input: OpenCodeConfig) => Promise + provider?: { + id: string + models?: (provider: OpenCodeProvider) => Promise> + } + // Called for every bus event opencode publishes. This plugin only acts on + // `session.deleted` (releasing that session's `claude` children); MCP + // config drift is handled at turn start. + event?: (input: { event: OpenCodeEvent }) => Promise + "chat.params"?: ( + input: OpenCodeChatParamsInput, + output: OpenCodeChatParamsOutput, + ) => Promise + // Fires as soon as a slash command is submitted, even while the session is + // busy; the resulting prompt is what gets queued, not the hook. Throwing + // drops that prompt (opencode answers the command route with a 500 the + // TUI ignores). Used for /btw. + "command.execute.before"?: ( + input: { command: string; sessionID: string; arguments: string }, + output: { parts: unknown[] }, + ) => Promise +} + +export type OpenCodePlugin = (input: unknown, options?: Record) => Promise diff --git a/src/plan-mode-question.ts b/src/plan-mode-question.ts new file mode 100644 index 0000000..b78c5a8 --- /dev/null +++ b/src/plan-mode-question.ts @@ -0,0 +1,257 @@ +export const QUESTION_TOOL_NAME = "question" + +export const APPROVED_EXIT_PLAN_MODE_MESSAGE = + "User has approved your plan. You can now start coding. Start with updating your todo list if applicable." + +const REJECTED_EXIT_PLAN_MODE_PREFIX = + "The user doesn't want to proceed with this tool use. The tool use was rejected. To tell you how to proceed, the user said:" + +const PLAN_MODE_APPROVAL_QUESTION = "Do you want to proceed with this plan?" +const OPENCODE_QUESTION_RESULT_PREFIX = + `User has answered your questions: "${PLAN_MODE_APPROVAL_QUESTION}"="` +const OPENCODE_QUESTION_RESULT_SUFFIX = + `". You can now continue with the user's answers in mind.` + +const KEY_SEPARATOR = "\u0000" + +/** + * A synthetic call to opencode's native `question` tool, emitted so the turn + * ends on `tool-calls` and the operator's answer arrives on the next + * `doStream` as a `tool-result` with the same id. Shared with the account + * failover form (`src/account-failover.ts`), which uses the identical + * mechanism for a different question. + */ +export interface QuestionToolCall { + toolCallId: string + toolName: typeof QUESTION_TOOL_NAME + input: { + questions: Array<{ + header: string + question: string + options: Array<{ label: string; description: string }> + multiple: boolean + custom: boolean + }> + } + text: string +} + +export type ExitPlanModeQuestionCall = QuestionToolCall + +/** + * Whether to bridge `ExitPlanMode` into opencode's native `question` tool + * this turn. + * + * Opt-in (`planModeQuestion`) because the bridge is dormant on the headless + * transport: `--print` offers the model no `ExitPlanMode` tool at all + * (measured on CLI 2.1.258), so there is nothing to key on and the model asks + * for approval in prose instead. opencode's question form itself is fine; the + * older claim here that it never rendered (anomalyco/opencode#36604) was + * retracted on 2026-09-06, and both the native form and the `question` proxy + * were verified round-tripping. Gated on the live registry because emitting a + * `question` tool-call on a build without that entry renders `⚙ invalid` and + * wedges the turn. Never bridged during compaction: that turn is text-only and + * its answer would have nowhere to go. + */ +export function isPlanModeQuestionActive(input: { + configured: boolean | undefined + opencodeHasQuestion: boolean + compactionMode: boolean +}): boolean { + if (input.compactionMode) return false + if (input.configured !== true) return false + return input.opencodeHasQuestion +} + +const pendingQuestions = new Map() + +function pendingKey(sessionKey: string, questionToolCallId: string): string { + return `${sessionKey}${KEY_SEPARATOR}${questionToolCallId}` +} + +export function clearExitPlanModeQuestions(sessionKey: string): void { + const prefix = `${sessionKey}${KEY_SEPARATOR}` + for (const key of pendingQuestions.keys()) { + if (key.startsWith(prefix)) pendingQuestions.delete(key) + } +} + +export function hasExitPlanModeQuestions(sessionKey: string): boolean { + const prefix = `${sessionKey}${KEY_SEPARATOR}` + return [...pendingQuestions.keys()].some((key) => key.startsWith(prefix)) +} + +export function createExitPlanModeQuestionCall( + sessionKey: string, + exitPlanModeToolUseId: string, + plan: string, + questionToolCallId = `exit_plan_question_${exitPlanModeToolUseId}`, +): ExitPlanModeQuestionCall { + pendingQuestions.set(pendingKey(sessionKey, questionToolCallId), exitPlanModeToolUseId) + + return { + toolCallId: questionToolCallId, + toolName: QUESTION_TOOL_NAME, + input: { + questions: [ + { + header: "Plan approval", + question: PLAN_MODE_APPROVAL_QUESTION, + options: [ + { label: "yes", description: "" }, + { label: "no", description: "" }, + ], + multiple: false, + custom: true, + }, + ], + }, + text: plan ? `\n\n${plan}\n` : "\n\n", + } +} + +function buildToolResultMessage(input: { + toolUseId: string + approved: boolean + feedback: string +}): string { + return JSON.stringify({ + type: "user", + message: { + role: "user", + content: [ + input.approved + ? { + type: "tool_result", + tool_use_id: input.toolUseId, + content: APPROVED_EXIT_PLAN_MODE_MESSAGE, + } + : { + type: "tool_result", + tool_use_id: input.toolUseId, + content: `${REJECTED_EXIT_PLAN_MODE_PREFIX}\n${input.feedback || "no"}`, + is_error: true, + }, + ], + }, + }) +} + +function tryParseJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return text + } +} + +/** + * Pull the operator's answer out of whatever shape opencode wrapped the + * `question` tool result in. Exported because the account failover form reads + * the same results through the same tool; a second copy of this would drift. + */ +export function unwrapToolOutput(part: any): unknown { + const output = part?.output ?? part?.result + if (typeof output === "string") return tryParseJson(output) + if (!output || typeof output !== "object") return output + + switch (output.type) { + case "json": + case "error-json": + return output.value + case "text": + case "error-text": + return tryParseJson(String(output.value ?? "")) + case "execution-denied": + return { + denied: true, + reason: String(output.reason ?? "question rejected"), + } + case "content": + return Array.isArray(output.value) + ? output.value + .map((item: any) => { + if (item?.type === "text") return item.text + return JSON.stringify(item) + }) + .join("\n") + : output.value + default: + return output + } +} + +function unwrapOpencodeQuestionResult(value: string): string { + if ( + value.startsWith(OPENCODE_QUESTION_RESULT_PREFIX) && + value.endsWith(OPENCODE_QUESTION_RESULT_SUFFIX) + ) { + return value.slice( + OPENCODE_QUESTION_RESULT_PREFIX.length, + -OPENCODE_QUESTION_RESULT_SUFFIX.length, + ) + } + return value +} + +/** Flatten an unwrapped `question` result into the answer strings it holds. */ +export function collectAnswerStrings(value: unknown): string[] { + if (typeof value === "string") return [unwrapOpencodeQuestionResult(value)] + if (Array.isArray(value)) return value.flatMap(collectAnswerStrings) + if (!value || typeof value !== "object") return [] + + const obj = value as Record + if (obj.denied === true) return [String(obj.reason ?? "question rejected")] + + for (const key of ["answers", "answer", "selected", "selection", "value"]) { + if (key in obj) return collectAnswerStrings(obj[key]) + } + + return [] +} + +function classifyQuestionResult(part: any): { approved: boolean; feedback: string } { + const output = unwrapToolOutput(part) + const answers = collectAnswerStrings(output) + .map((answer) => answer.trim()) + .filter(Boolean) + + if (answers.length === 1 && answers[0].toLowerCase() === "yes") { + return { approved: true, feedback: "" } + } + + return { + approved: false, + feedback: answers.length > 0 ? answers.join("\n") : "no", + } +} + +export function consumeExitPlanModeQuestionResult( + sessionKey: string, + prompt: Array<{ role: string; content?: unknown }>, +): string | null { + for (let i = prompt.length - 1; i >= 0; i--) { + const msg = prompt[i] + if (!Array.isArray(msg.content)) continue + + for (const part of msg.content as any[]) { + if (part?.type !== "tool-result" || typeof part.toolCallId !== "string") { + continue + } + + const key = pendingKey(sessionKey, part.toolCallId) + const exitPlanModeToolUseId = pendingQuestions.get(key) + if (!exitPlanModeToolUseId) continue + + pendingQuestions.delete(key) + const result = classifyQuestionResult(part) + return buildToolResultMessage({ + toolUseId: exitPlanModeToolUseId, + approved: result.approved, + feedback: result.feedback, + }) + } + } + + return null +} diff --git a/src/proxy-broker.ts b/src/proxy-broker.ts new file mode 100644 index 0000000..e416e54 --- /dev/null +++ b/src/proxy-broker.ts @@ -0,0 +1,358 @@ +import { EventEmitter } from "node:events" +import { + buildProxyTimeoutError, + PROXY_NO_DEADLINE_MS, + resolveProxyCallTimeoutMs, + type ProxyCallChannel, + type ProxyToolCall, + type ProxyToolResult, +} from "./proxy-mcp.js" +import { log } from "./logger.js" + +export interface PendingProxyCall { + sessionKey: string + toolCallId: string + toolName: string + input: Record + /** + * Liveness of Claude's HTTP request for this call. Once `closed`, a + * result written to it is lost; the language model then delivers the + * result as a user message instead. Absent means open. + */ + channel?: ProxyCallChannel + /** + * True once the language model has handed this call to opencode as a + * tool-call part. A call that is still pending without it was queued + * while no turn was attached and has to be drained by the next one. + */ + emitted?: boolean +} + +type InternalPending = PendingProxyCall & { + createdAt: number + /** `PROXY_NO_DEADLINE_MS` (0) when the call has no deadline. */ + deadlineMs: number + /** Absent when the call has no deadline. */ + timer: ReturnType | null + /** Stall heartbeat; only armed for calls that have no deadline. */ + stallTimer: ReturnType | null + /** One-shot "this is going to run out" notice; deadline-bearing calls only. */ + deadlineWarnTimer: ReturnType | null + resolve(result: ProxyToolResult): void + reject(error: Error): void +} + +/** + * How long a call with NO deadline may wait before the broker starts saying + * so, and how often it repeats afterwards. + * + * `task` and `task_batch` have had no deadline since v0.20.0, which is right: + * every way a call can end is an event the plugin observes, so a wall clock + * could only ever kill a subagent that was still working. The cost is that a + * genuinely wedged subagent is now silent forever, with nothing to notice it + * but the operator. This is the missing half: it never ends a call, it only + * reports one. Deliberately long, because a real subagent routinely runs + * minutes and a warning on healthy work is noise. Deadline-bearing calls are + * not armed at all: their deadline already reports them. + */ +export const PROXY_STALL_WARNING_MS = 5 * 60_000 + +/** + * Where in a deadline-bearing call's life to say it is going to run out. + * + * The heartbeat above deliberately skips these calls, on the reasoning that + * their deadline already reports them. It does, but only by killing them: + * the first and last thing you hear is the failure. Measured the hard way on + * 2026-09-19, when two proxied calls that were still working were rejected at + * their 10-minute deadline with no prior signal, and the operator had to + * infer from silence what was happening. + * + * So one notice, at 60% of the deadline, saying how long is left. Once, never + * repeating, because the deadline itself is the next thing that will speak. + * Calls whose deadline is under `PROXY_DEADLINE_WARNING_MIN_MS` are skipped: + * on a short deadline the notice and the rejection would arrive together and + * tell you nothing you are not about to be told anyway. + */ +export const PROXY_DEADLINE_WARNING_FRACTION = 0.6 +export const PROXY_DEADLINE_WARNING_MIN_MS = 60_000 + +/** Every timer a pending call can hold. Each removal site must use this. */ +function clearPendingTimers(pending: InternalPending): void { + if (pending.timer) clearTimeout(pending.timer) + if (pending.stallTimer) clearInterval(pending.stallTimer) + if (pending.deadlineWarnTimer) clearTimeout(pending.deadlineWarnTimer) +} + +/** One pending call, flattened for `/claude-code-doctor`. */ +export interface PendingProxyCallSnapshot { + sessionKey: string + toolCallId: string + toolName: string + ageMs: number + deadlineMs: number + emitted: boolean + channelClosed: boolean +} + +// Primary index: callId -> pending. Tool call IDs are UUIDs produced by +// proxy-mcp, so they are globally unique across sessions. +const pendingByCallId = new Map() +// Reverse index: sessionKey -> set of callIds, so the language model can +// drain or reject every pending call for one Claude subprocess at once. +const callIdsBySession = new Map>() + +const emitter = new EventEmitter() + +function eventName(sessionKey: string) { + return `pending:${sessionKey}` +} + +function indexAdd(sessionKey: string, callId: string) { + let s = callIdsBySession.get(sessionKey) + if (!s) { + s = new Set() + callIdsBySession.set(sessionKey, s) + } + s.add(callId) +} + +function indexRemove(sessionKey: string, callId: string) { + const s = callIdsBySession.get(sessionKey) + if (!s) return + s.delete(callId) + if (s.size === 0) callIdsBySession.delete(sessionKey) +} + +export function onPendingProxyCall( + sessionKey: string, + handler: (call: PendingProxyCall) => void, +): () => void { + const name = eventName(sessionKey) + emitter.on(name, handler) + return () => emitter.off(name, handler) +} + +export function queuePendingProxyCall( + sessionKey: string, + call: ProxyToolCall, + timeoutOverrides?: Record, + /** Test seam, same shape as `createProxyMcpServer`'s `keepaliveMs`. */ + stallWarningMs: number = PROXY_STALL_WARNING_MS, + /** Test seam: lower it so a short test deadline still warns. */ + deadlineWarningMinMs: number = PROXY_DEADLINE_WARNING_MIN_MS, +): PendingProxyCall { + // Defensive: if this exact callId is somehow already pending (UUID + // collision or retry storm), replace it cleanly so we never leak two + // entries for the same id. + const previous = pendingByCallId.get(call.id) + if (previous) { + clearPendingTimers(previous) + previous.reject( + new Error(`Replaced pending proxy call ${call.id} with a fresh one`), + ) + pendingByCallId.delete(call.id) + indexRemove(previous.sessionKey, call.id) + } + + const deadlineMs = resolveProxyCallTimeoutMs( + call.toolName, + call.input, + timeoutOverrides, + ) + + // Same rule as the proxy-mcp handler: a call with no deadline gets no timer + // (a zero-delay timer would fire on the next tick). It stays pending until + // a result, an abort, the next turn's orphan sweep, or its process going. + const timer = + deadlineMs > PROXY_NO_DEADLINE_MS + ? setTimeout(() => { + const current = pendingByCallId.get(call.id) + if (!current) return + pendingByCallId.delete(call.id) + indexRemove(current.sessionKey, call.id) + clearPendingTimers(current) + current.reject(buildProxyTimeoutError(call.toolName, deadlineMs)) + // v0.4.13: demoted from warn to notice. AFK-permission-pending + // sessions can stack many of these; demoting keeps the UI quiet on + // return while preserving the audit trail in plugin.log. + log.notice("timed out pending proxy call", { + sessionKey: current.sessionKey, + toolCallId: call.id, + toolName: call.toolName, + deadlineMs, + }) + }, deadlineMs) + : null + + // A call with no deadline has nothing that will ever report it, so it gets + // a heartbeat instead. WARN on purpose: only warn and error are always on + // stderr (see `src/logger.ts`), and a NOTICE nobody sees outside debug mode + // would defeat the point of the line existing at all. + const stallTimer = + deadlineMs === PROXY_NO_DEADLINE_MS && stallWarningMs > 0 + ? setInterval(() => { + const current = pendingByCallId.get(call.id) + if (!current) return + log.warn("proxy call still waiting, no deadline", { + sessionKey: current.sessionKey, + toolCallId: current.toolCallId, + toolName: current.toolName, + waitedMs: Date.now() - current.createdAt, + emitted: current.emitted === true, + channelClosed: current.channel?.closed === true, + note: "nothing will time this out; it ends when opencode returns a result, you abort, you send another message, or the claude process goes", + }) + }, stallWarningMs) + : null + // Never hold opencode's process open for a heartbeat. + stallTimer?.unref?.() + + // The other half: a call that DOES have a deadline says so before the + // deadline takes it, rather than only by dying. Same WARN reasoning, and + // one-shot, since the rejection is the next thing that will report. + const warnAtMs = Math.floor(deadlineMs * PROXY_DEADLINE_WARNING_FRACTION) + const deadlineWarnTimer = + deadlineMs >= deadlineWarningMinMs && deadlineMs > 0 && warnAtMs > 0 + ? setTimeout(() => { + const current = pendingByCallId.get(call.id) + if (!current) return + const waitedMs = Date.now() - current.createdAt + log.warn("proxy call still waiting, deadline approaching", { + sessionKey: current.sessionKey, + toolCallId: current.toolCallId, + toolName: current.toolName, + waitedMs, + deadlineMs, + remainingMs: Math.max(0, deadlineMs - waitedMs), + emitted: current.emitted === true, + channelClosed: current.channel?.closed === true, + note: "it will be rejected when the deadline passes; raise this tool's proxyToolTimeoutMs if the work is legitimately this long", + }) + }, warnAtMs) + : null + deadlineWarnTimer?.unref?.() + + const pending: InternalPending = { + sessionKey, + toolCallId: call.id, + toolName: call.toolName, + input: call.input, + channel: call.channel, + createdAt: Date.now(), + deadlineMs, + timer, + stallTimer, + deadlineWarnTimer, + resolve: call.resolve, + reject: call.reject, + } + pendingByCallId.set(call.id, pending) + indexAdd(sessionKey, call.id) + emitter.emit(eventName(sessionKey), pending) + log.info("queued pending proxy call", { + sessionKey, + toolCallId: call.id, + toolName: call.toolName, + }) + return pending +} + +/** Record that opencode has been given this call as a tool-call part. */ +export function markPendingProxyCallEmitted(toolCallId: string): void { + const pending = pendingByCallId.get(toolCallId) + if (pending) pending.emitted = true +} + +/** True when Claude's request for this call is gone (see `channel`). */ +export function isPendingProxyCallChannelClosed( + call: PendingProxyCall, +): boolean { + return call.channel?.closed === true +} + +export function getPendingProxyCalls(sessionKey: string): PendingProxyCall[] { + const s = callIdsBySession.get(sessionKey) + if (!s || s.size === 0) return [] + const out: PendingProxyCall[] = [] + for (const id of s) { + const p = pendingByCallId.get(id) + if (p) out.push(p) + } + return out +} + +/** + * Every call the broker is currently holding, across all sessions, with how + * long it has waited and when it gives up. Read-only view for the doctor + * report; deliberately carries no `input`, since a pending call's arguments + * can be a whole file's contents. + */ +export function snapshotPendingProxyCalls(now = Date.now()): PendingProxyCallSnapshot[] { + const out: PendingProxyCallSnapshot[] = [] + for (const pending of pendingByCallId.values()) { + out.push({ + sessionKey: pending.sessionKey, + toolCallId: pending.toolCallId, + toolName: pending.toolName, + ageMs: Math.max(0, now - pending.createdAt), + deadlineMs: pending.deadlineMs, + emitted: pending.emitted === true, + channelClosed: pending.channel?.closed === true, + }) + } + return out +} + +export function resolvePendingProxyCallById( + toolCallId: string, + result: ProxyToolResult, +): boolean { + const pending = pendingByCallId.get(toolCallId) + if (!pending) return false + pendingByCallId.delete(toolCallId) + indexRemove(pending.sessionKey, toolCallId) + clearPendingTimers(pending) + pending.resolve(result) + log.info("resolved pending proxy call", { + sessionKey: pending.sessionKey, + toolCallId: pending.toolCallId, + toolName: pending.toolName, + }) + return true +} + +export function rejectPendingProxyCallById( + toolCallId: string, + error: Error, +): boolean { + const pending = pendingByCallId.get(toolCallId) + if (!pending) return false + pendingByCallId.delete(toolCallId) + indexRemove(pending.sessionKey, toolCallId) + clearPendingTimers(pending) + pending.reject(error) + // Rejection is the broker's cleanup mechanism — fires on timeouts, orphans, + // stream closes, etc. None are user-actionable. File-log them at NOTICE so + // the audit trail is intact; rely on caller sites to decide TUI visibility. + log.notice("rejected pending proxy call", { + sessionKey: pending.sessionKey, + toolCallId: pending.toolCallId, + toolName: pending.toolName, + error: error.message, + }) + return true +} + +export function rejectAllPendingProxyCallsForSession( + sessionKey: string, + error: Error, +): number { + const s = callIdsBySession.get(sessionKey) + if (!s) return 0 + const ids = [...s] + let count = 0 + for (const id of ids) { + if (rejectPendingProxyCallById(id, error)) count++ + } + return count +} diff --git a/src/proxy-mcp.ts b/src/proxy-mcp.ts new file mode 100644 index 0000000..9a08537 --- /dev/null +++ b/src/proxy-mcp.ts @@ -0,0 +1,1641 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http" +import type { AddressInfo } from "node:net" +import * as fs from "node:fs" +import * as path from "node:path" +import * as crypto from "node:crypto" +import { EventEmitter } from "node:events" +import { log } from "./logger.js" +import { pluginTmpDir } from "./tmp.js" + +/** + * Minimal MCP HTTP server embedded in-process. Exposes a set of "proxy" + * tools (Bash, Edit, Write, etc.) that Claude CLI calls when its built-in + * equivalents are disabled via --disallowedTools. Our handler blocks until + * an external broker resolves the call, then responds to Claude. + * + * Wire protocol: JSON-RPC 2.0 over plain HTTP POST to `/mcp`. Protocol + * methods (`initialize`, `tools/list`) and calls answered in-process get a + * single JSON reply. A broker-backed `tools/call` can block for as long as + * opencode takes to run the tool, so its reply is streamed: SSE when the + * client accepts it, otherwise a chunked JSON body whose headers go out at + * once and which carries keepalive whitespace until the result is ready + * (see `openEventStream` / `openJsonStream`). + */ + +export interface ProxyMcpServer { + url: string + serverName: string + tools: ProxyToolDef[] + /** Per-server bearer secret. Minted on start, handed to Claude via the + * `headers` block of the generated MCP config, and required on every + * request. Exposed so callers (and tests) can authenticate; MUST NOT be + * logged or placed in the URL. */ + authToken: string + /** Fires when Claude invokes one of our proxy tools. The handler resolves + * the returned pending call once a result is available. */ + calls: EventEmitter + /** Write `--mcp-config `-compatible scratch file and return its path. */ + configPath(): string + /** + * Ids of the `tools/call` requests this server is still holding open. + * Read-only. An entry leaves this list only when its promise settles, so + * it is the direct evidence that a lifecycle event released the HTTP side + * of a call and not just the broker's entry for it. + */ + pendingCallIds(): string[] + close(): Promise +} + +export interface ProxyToolDef { + /** Raw name as seen by Claude once proxied: the MCP exposed tool name. */ + name: string + description: string + inputSchema: Record +} + +/** + * Liveness of the HTTP reply channel behind one proxy call. Shared by + * reference between proxy-mcp (which flips `closed` when Claude's request + * goes away) and the broker / language model (which read it before + * answering), so the two never need to import each other. + */ +export interface ProxyCallChannel { + closed: boolean +} + +export interface ProxyToolCall { + id: string + toolName: string + input: Record + resolve: (result: ProxyToolResult) => void + reject: (err: Error) => void + /** Absent for calls built by hand in tests; treated as open. */ + channel?: ProxyCallChannel +} + +/** + * Keep unanswered HTTP calls active independently of the tool deadline. + * A held call timed out before delivery on CLI 2.1.258; with immediate + * headers and these comments, the same 390-second hold completed. The same + * cadence drives the whitespace keepalive of a JSON-only reply: both must + * stay well under the ~300 s header/body timers in the CLI's HTTP client. + */ +export const PROXY_KEEPALIVE_MS = 15_000 + +/** True when the client advertised `text/event-stream` in Accept. */ +export function acceptsEventStream(acceptHeader: unknown): boolean { + return ( + typeof acceptHeader === "string" && + acceptHeader.toLowerCase().includes("text/event-stream") + ) +} + +export type ProxyToolResult = + | { kind: "text"; text: string; isError?: boolean } + | { kind: "error"; message: string } + +/** + * Handler that answers a `tools/call` inside this process instead of + * queueing it for opencode. Used by tools that act on plugin state rather + * than on the workspace (currently only `compress`), so they never reach + * the broker, never block on a human, and have no deadline. + */ +export type ProxyToolInterceptor = ( + input: Record, +) => Promise | ProxyToolResult + +export const SERVER_CLOSED_MESSAGE = "proxy MCP server closed" + +/** Rejections that fire on normal lifecycle transitions: AFK-permission + * timeouts, orphan rejections at turn boundaries, stream aborts, and server + * close while its owning Claude process exits or is replaced. None are + * user-actionable — file-log them at NOTICE. Anything else stays WARN so + * genuine bugs remain visible in the TUI. */ +export function isExpectedCleanupError(message: string): boolean { + return ( + (message.includes("timed out after") && + message.includes("waiting for opencode to resolve")) || + message.includes("rejecting as orphaned") || + message.includes("was orphaned by a new user turn") || + message.includes("stream was aborted") || + message.includes(SERVER_CLOSED_MESSAGE) + ) +} + +const PROTOCOL_VERSION = "2024-11-05" +const SERVER_NAME = "opencode_proxy" +export const PROXY_TOOL_PREFIX = `mcp__${SERVER_NAME}__` + +// Flat fallback cap on how long a proxy tool call may wait for opencode to +// resolve it. Matches Claude CLI's hard upper bound for Bash (10 min). The +// effective deadline is resolved per tool — see `resolveProxyCallTimeoutMs`. +export const PROXY_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000 + +/** A resolved deadline of 0 means the call waits until a lifecycle event + * releases it: a result, an abort, the next user turn's orphan sweep, the + * child closing, or the proxy server closing with its process. */ +export const PROXY_NO_DEADLINE_MS = 0 + +// Per-tool default deadlines, keyed by lowercase proxy tool name. `task` and +// `task_batch` dispatch opencode subagents, and the wall clock is the wrong +// unit for those: a 10-min flat ceiling fired mid-subagent and dropped the +// late result on the floor (@jknlsn, live session ses_0cfc0da6, 2026-07-05), +// and a 60-min one did the same to any subagent that ran longer (@broskees' +// dd494a8). So they carry no deadline at all: an abandoned task call is +// released by the same lifecycle events that already release every other +// call, and a positive `proxyToolTimeoutMs` override restores a backstop. +// +// `question` blocks on a human reading a TUI form, so the flat ceiling is +// the wrong unit entirely: a question posed just before the operator steps +// away would be rejected mid-answer. 30 min is jknlsn's original figure and +// matches the "prefer fewer, high-signal questions" guidance in the def. +export const PROXY_PER_TOOL_DEFAULT_TIMEOUT_MS: Record = { + task: PROXY_NO_DEADLINE_MS, + task_batch: PROXY_NO_DEADLINE_MS, // same reasoning: it IS task calls + question: 30 * 60 * 1000, // 30 min +} + +// Node's setTimeout delay is a signed 32-bit int; values above 2^31-1 ms +// (~24.85 days) trigger TimeoutOverflowWarning and fire at ~1ms instead. +// Clamp absurd overrides / input.timeouts so a misconfigured deadline +// can't collapse to "fires immediately". +export const MAX_PROXY_TIMEOUT_MS = 2 ** 31 - 1 + +/** + * Resolve the proxy deadline for a tool call. Layers, most-specific last: + * 1. flat default (`PROXY_DEFAULT_TIMEOUT_MS`, 10 min) + * 2. per-tool default (`PROXY_PER_TOOL_DEFAULT_TIMEOUT_MS`; `task` and + * `task_batch` have none) + * 3. user override via `proxyToolTimeoutMs` config (case-insensitive key). + * A positive value replaces the default, `0` disables the deadline for + * that tool, and a negative or non-finite value is ignored. + * 4. for `bash`, the call's own `input.timeout` -- the proxy must never + * undercut a build the caller explicitly asked to run long. The bash + * proxy def advertises a `timeout` field; before this fix the proxy + * ignored it and killed the call at the flat ceiling anyway. It only + * ever raises, so it also turns a disabled bash deadline back into one. + * Finally clamped to `MAX_PROXY_TIMEOUT_MS` to stay within Node's timer range. + * Returns `PROXY_NO_DEADLINE_MS` (0) when the call has no deadline; callers + * must not arm a timer for that value. + */ +export function resolveProxyCallTimeoutMs( + toolName: string, + input: Record | undefined, + overrides: Record | undefined, +): number { + const key = toolName.toLowerCase() + let ms = PROXY_PER_TOOL_DEFAULT_TIMEOUT_MS[key] ?? PROXY_DEFAULT_TIMEOUT_MS + if (overrides) { + const ov = lookupCaseInsensitive(overrides, key) + if (isDeadlineOverride(ov)) ms = ov + } + if (key === "bash") { + const requested = input?.timeout + if (typeof requested === "number" && requested > ms) ms = requested + } + return Math.min(ms, MAX_PROXY_TIMEOUT_MS) +} + +/** `0` (no deadline) or a positive finite number of milliseconds. */ +function isDeadlineOverride(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 +} + +function lookupCaseInsensitive( + map: Record, + key: string, +): number | undefined { + if (Object.prototype.hasOwnProperty.call(map, key)) return map[key] + for (const k of Object.keys(map)) { + if (k.toLowerCase() === key) return map[k] + } + return undefined +} + +/** + * Client-side abort ceiling written into Claude's `--mcp-config` entry for + * the proxy server. Without a `timeout` there, Claude CLI's remote-HTTP MCP + * client aborts each call at its 60-second default even while an opencode + * subagent is still running (@broskees, PR #18). It must be >= the largest + * server-side deadline or the client gives up before the broker does, so it + * tracks the max of every tool's effective deadline: the flat default, the + * per-tool defaults, and the user's overrides applied on top of them. A tool + * with no deadline needs the largest value the client accepts, because the + * CLI rejects `timeout: 0` in the MCP config outright (measured on the fork + * this came from, @broskees' dd494a8), and this is also Node's timer max. + * (A bash call raising its own `input.timeout` above this ceiling is a known + * edge; Claude CLI caps bash at 10 min anyway.) + */ +export function resolveProxyClientCeilingMs( + overrides: Record | undefined, +): number { + let ms = PROXY_DEFAULT_TIMEOUT_MS + const consider = (deadlineMs: number): boolean => { + if (deadlineMs === PROXY_NO_DEADLINE_MS) return true + if (deadlineMs > ms) ms = deadlineMs + return false + } + for (const [toolName, defaultMs] of Object.entries(PROXY_PER_TOOL_DEFAULT_TIMEOUT_MS)) { + const override = overrides ? lookupCaseInsensitive(overrides, toolName) : undefined + if (consider(isDeadlineOverride(override) ? override : defaultMs)) { + return MAX_PROXY_TIMEOUT_MS + } + } + for (const value of Object.values(overrides ?? {})) { + if (isDeadlineOverride(value) && consider(value)) return MAX_PROXY_TIMEOUT_MS + } + return Math.min(ms, MAX_PROXY_TIMEOUT_MS) +} + +/** + * Build the timeout error surfaced to Claude. Keeps the substrings + * `"timed out after"` and `"waiting for opencode to resolve"` that the + * proxy-mcp catch block classifies as expected cleanup (notice, not warn). + * For `task` we append guidance: a Task timeout means the subagent may + * still be running but its result is now unreachable, and the model must + * neither declare the dispatch failed nor "schedule a wake-up" -- that is a + * Claude Code affordance which cannot fire in this headless/proxy context, + * so deferring silently drops the work. + */ +export function buildProxyTimeoutError(toolName: string, ms: number): Error { + const key = toolName.toLowerCase() + const base = `Proxy tool '${toolName}' timed out after ${ms}ms waiting for opencode to resolve the call` + if (key === "task" || key === TASK_BATCH_TOOL_NAME) { + return new Error( + base + + (key === "task" ? " (the subagent)." : " (the subagents).") + + " The subagent may still be running but its result" + + " is no longer reachable in this session. Do not declare the dispatch" + + " failed, and do not 'schedule a wake-up' or defer -- that mechanism" + + " does not apply here. If the result is required, re-dispatch or" + + " verify it directly now.", + ) + } + return new Error(base) +} + +/** + * Disambiguation appended to the `task` proxy def (both the static + * fallback and the live overlay). Models routinely resolve opencode's + * "call the task tool with subagent: X" mention hint to Claude Code's + * native TaskCreate (a todo tool) — creating a todo, dispatching nothing, + * and then narrating a successful dispatch. Others burn turns grepping + * config files to verify a subagent exists before daring to call it. + * Both failure modes are addressed here, at the tool the model reads. + */ +export const TASK_PROXY_NOTE = + "This and task_batch are the ONLY tools that dispatch opencode subagents" + + " (including user @-mentions). Claude Code's built-in TaskCreate/TaskUpdate" + + " manage a local todo list and cannot dispatch subagents. Do not search" + + " config files to verify a subagent type exists: invalid types fail fast" + + " with a clear error. Foreground calls block until the subagent finishes;" + + " set `background` to request opencode's background execution mode. For" + + " two or more independent subagents in one response use task_batch, not" + + " several task calls: those run one after another. Task calls have no" + + " proxy deadline by default: the call waits for the subagent to finish" + + " (a positive proxyToolTimeoutMs override adds a deadline)." + +/** + * `task_batch`: one MCP call that opencode runs as N parallel `task` calls. + * + * Design and first implementation by Joseph Roberts (@broskees) on his fork + * (68ed142), absorbed here with credit. The limitation it works around is + * measured, not assumed: Claude Code emits several `mcp__opencode_proxy__*` + * tool_use blocks in one assistant message but sends the MCP requests one at + * a time, each only after the previous result (2026-09-06, haiku, two + * 8-second bash calls: second request arrived 7 ms after the first resolved). + * So "call task twice" is serial by construction, and the only way to get two + * subagents running at once is a single proxy call that the plugin fans out + * inside one opencode tool boundary, where opencode executes tool calls + * concurrently. The children are ordinary `task` calls with ids derived from + * the parent (`taskBatchChildToolCallId`), and their results are gathered + * back onto the parent id (`formatTaskBatchResults`) before the CLI sees it. + */ +export const TASK_BATCH_TOOL_NAME = "task_batch" + +export const TASK_BATCH_PROXY_NOTE = + "Use this instead of several task calls in one response: Claude Code runs" + + " MCP tool calls one at a time, so separate task calls run serially even" + + " when emitted together, while one task_batch call fans them out as" + + " parallel opencode task calls. Each task takes the same fields as the" + + " task tool. Results come back in task order, each labelled. Like task it" + + " has no proxy deadline by default (a positive proxyToolTimeoutMs override" + + " adds one)." + +export const TASK_INPUT_REQUIRED = ["description", "prompt", "subagent_type"] + +/** Why a `task_batch` input is unusable, or null when it is fine. */ +export function taskBatchInputError(input: Record | undefined): string | null { + const tasks = input?.tasks + if (!Array.isArray(tasks) || tasks.length < 2) { + return "task_batch requires a `tasks` array with at least two items; use `task` for one subagent" + } + for (const [index, task] of tasks.entries()) { + if (task === null || typeof task !== "object" || Array.isArray(task)) { + return `task_batch tasks[${index}] must be an object` + } + const item = task as Record + for (const field of TASK_INPUT_REQUIRED) { + if (typeof item[field] !== "string") { + return `task_batch tasks[${index}].${field} must be a string` + } + } + } + return null +} + +/** The batch's task inputs, or [] when the input never passed validation. */ +export function taskBatchTasks(input: Record | undefined): Record[] { + if (taskBatchInputError(input)) return [] + return input!.tasks as Record[] +} + +/** + * Child ids stay derivable from the parent so the next turn can find every + * child's `tool-result` without extra state. Only `[A-Za-z0-9_-]`: AI SDK + * bridges normalise other characters and the round trip would not match. + */ +export function taskBatchChildToolCallId(parentToolCallId: string, index: number): string { + return `${parentToolCallId}_task_${index}` +} + +/** + * One readable result for the parent call. Children are labelled in task + * order; a child opencode did not answer is said so rather than dropped, + * since a silent gap would read as a subagent that never ran. + */ +export function formatTaskBatchResults( + children: Array<{ task: Record; result: ProxyToolResult | null }>, +): ProxyToolResult { + const total = children.length + const sections = children.map(({ task, result }, index) => { + const label = typeof task.description === "string" ? task.description : `task ${index + 1}` + const agent = typeof task.subagent_type === "string" ? ` (${task.subagent_type})` : "" + const header = `## task ${index + 1} of ${total}: ${label}${agent}` + if (!result) return `${header}\n[missing] opencode returned no result for this task in the batch` + if (result.kind === "error") return `${header}\n[error] ${result.message}` + return `${header}\n${result.isError ? "[error] " : ""}${result.text}` + }) + const failed = children.some(({ result }) => !result || result.kind === "error" || result.isError) + return { kind: "text", text: sections.join("\n\n"), ...(failed ? { isError: true } : {}) } +} + +const AGENT_TYPES_HEADING = "Available agent types" + +/** Longest per-agent blurb we keep; enough to choose, short enough to survive. */ +const AGENT_BLURB_LIMIT = 140 + +/** + * Disambiguation appended to the `question` proxy def. Claude Code ships + * a built-in `AskUserQuestion` that, when proxied, is disabled via + * `--disallowedTools`; without an explicit hand-off note models keep + * reaching for the disabled built-in or fall back to plain text. This + * states that the proxy is the structured-questions path and summarises + * the answer shape so the model can act on the result without a second + * round-trip. + */ +export const QUESTION_PROXY_NOTE = + "This routes structured questions through opencode's native `question`" + + " tool, which renders a TUI form with the options you provide and" + + " blocks until the operator answers. Claude Code's built-in" + + " AskUserQuestion is disabled in this environment; this proxy is the" + + " ONLY way to ask the operator for a decision or clarification." + + " Answers come back as arrays of selected labels (set `multiple: true`" + + " to allow more than one). If the operator dismisses the form the call" + + " returns an error — treat that as 'no answer' and stop, do not guess." + + " Question calls get a 30-minute proxy deadline by default (configurable" + + " via proxyToolTimeoutMs); for long-AFK scenarios prefer fewer," + + " high-signal questions." + +/** + * Disambiguation appended to the `compress` proxy def. Two things the + * model gets wrong without it: when the reset happens (not mid-turn, so + * it can keep working after the call), and how much survives it (only + * the summary, because the fresh spawn is not given the prior transcript). + */ +export const COMPRESS_PROXY_NOTE = + "The current turn continues normally after this call — finish what you" + + " are doing. The reset happens at the START of the next turn: the" + + " Claude Code session is discarded and a fresh one begins with your" + + " summary as its only prior context. Everything else, including tool" + + " output and files you read, is gone, so write the summary as the" + + " authoritative record. Call this once per compression, when older" + + " resolved work no longer needs full detail." + +/** + * Pull *only* the agent-type list out of opencode's live `task` description. + * + * jknlsn's original overlaid the whole live description (2.8 KB here) in front + * of the static def. Live check 2026-07-26 showed that backfires: Claude Code + * truncates long MCP tool descriptions, and opencode puts the agent list at + * the *end* (char 2306 of 2858), so the one part the model needs is exactly + * what gets cut — haiku then guessed `general-purpose`, `default`, and + * `code-reviewer` (Claude Code's own agent names) and every dispatch failed + * with "Unknown agent type". So: keep the list, drop opencode's preamble + * (generic delegation advice the model already has), trim each blurb, and let + * the caller put it first. + * + * Returns undefined when the description carries no parsable list, so callers + * leave the static def alone. + */ +export function extractAgentTypeList( + liveDescription: string | undefined, +): string | undefined { + const live = liveDescription?.trim() + if (!live) return undefined + const start = live.indexOf(AGENT_TYPES_HEADING) + if (start === -1) return undefined + const entries: string[] = [] + for (const raw of live.slice(start).split("\n")) { + const match = /^-\s*([^:]+):\s*(.+)$/.exec(raw.trim()) + if (!match) continue + const name = match[1].trim() + const blurb = match[2].trim() + entries.push( + `- ${name}: ${ + blurb.length > AGENT_BLURB_LIMIT + ? `${blurb.slice(0, AGENT_BLURB_LIMIT).trimEnd()}…` + : blurb + }`, + ) + } + if (entries.length === 0) return undefined + return `Valid subagent_type values, from opencode's live registry — anything else fails:\n${entries.join("\n")}` +} + +/** + * Front-load opencode's live agent-type list onto the static `task` proxy def + * so the model picks a real `subagent_type` instead of guessing a Claude Code + * name. First, not last: see `extractAgentTypeList` for why position matters. + * No-op when no list can be extracted (SDK client missing, older opencode) or + * the `task` def is not among the tools. + */ +export function overlayTaskProxyDescription( + tools: ProxyToolDef[], + liveDescription: string | undefined, +): ProxyToolDef[] { + const agentTypes = extractAgentTypeList(liveDescription) + if (!agentTypes) return tools + return tools.map((t) => + t.name === "task" || t.name === TASK_BATCH_TOOL_NAME + ? { ...t, description: `${agentTypes}\n\n${t.description}` } + : t, + ) +} + +/** + * Overlay opencode's live `question` tool description onto the static + * proxy def, then append the disambiguation note. No-op when the live + * description is unavailable (older opencode, SDK client missing) — the + * static def + note stands. Mirrors `overlayTaskProxyDescription`. + */ +export function overlayQuestionProxyDescription( + tools: ProxyToolDef[], + liveDescription: string | undefined, +): ProxyToolDef[] { + const live = liveDescription?.trim() + if (!live) return tools + return tools.map((t) => + t.name === "question" + ? { ...t, description: `${live}\n\n${QUESTION_PROXY_NOTE}` } + : t, + ) +} + +/** + * Version gate for the `question` proxy. opencode added a built-in + * `question` tool (registry id `question`) — on older builds that entry + * is absent and a forwarded `mcp__opencode_proxy__question` call would + * resolve to `⚙ invalid` in opencode. Drop the def silently when the + * live registry does not contain it so the model never sees a dead tool. + */ +export function filterQuestionProxyByOpencodeSupport( + tools: ProxyToolDef[], + opencodeHasQuestion: boolean, +): ProxyToolDef[] { + if (opencodeHasQuestion) return tools + return tools.filter((t) => t.name !== "question") +} + +/** Input fields of one `task`, shared with each `task_batch` item. */ +export const TASK_INPUT_PROPERTIES = { + description: { + type: "string", + description: "A short (3-5 words) description of the task", + }, + prompt: { + type: "string", + description: "The task for the agent to perform", + }, + subagent_type: { + type: "string", + description: "The type of specialized agent to use for this task", + }, + task_id: { + type: "string", + description: + "Set this only if you mean to resume a previous task: pass the" + + " prior task_id to continue the same subagent session instead of" + + " creating a fresh one.", + }, + command: { + type: "string", + description: "The command that triggered this task", + }, + background: { + type: "boolean", + description: + "Run the task in the background when supported by opencode", + }, +} + +export const DEFAULT_PROXY_TOOLS: ProxyToolDef[] = [ + { + name: "bash", + description: + "Execute a shell command. Routed through opencode's bash tool so" + + " permission prompts flow through opencode's UI.", + inputSchema: { + type: "object", + properties: { + command: { + type: "string", + description: "The shell command to execute.", + }, + description: { + type: "string", + description: "Short human-readable description of what the command does.", + }, + timeout: { + type: "number", + description: "Optional timeout in milliseconds.", + }, + }, + required: ["command"], + }, + }, + { + name: "write", + description: + "Write a file. Routed through opencode's write tool so permission prompts flow through opencode's UI.", + inputSchema: { + type: "object", + properties: { + filePath: { + type: "string", + description: "The file to write. Absolute paths are preferred.", + }, + content: { + type: "string", + description: "The full content to write to the file.", + }, + }, + required: ["filePath", "content"], + }, + }, + { + name: "edit", + description: + "Replace text in an existing file. Routed through opencode's edit tool so permission prompts flow through opencode's UI.", + inputSchema: { + type: "object", + properties: { + filePath: { + type: "string", + description: "The file to edit. Absolute paths are preferred.", + }, + oldString: { + type: "string", + description: "The exact text to replace.", + }, + newString: { + type: "string", + description: "The replacement text.", + }, + replaceAll: { + type: "boolean", + description: "Replace all occurrences instead of just the first one.", + }, + }, + required: ["filePath", "oldString", "newString"], + }, + }, + { + name: "webfetch", + description: + "Fetch content from a URL. Routed through opencode's webfetch tool so" + + " permission prompts flow through opencode's UI. Returns the page" + + " content in the requested format.", + inputSchema: { + type: "object", + properties: { + url: { + type: "string", + description: "The URL to fetch content from. Must start with http:// or https://.", + }, + format: { + type: "string", + enum: ["text", "markdown", "html"], + description: + "The format to return the content in. Defaults to markdown.", + }, + timeout: { + type: "number", + description: "Optional timeout in seconds (max 120).", + }, + }, + required: ["url"], + }, + }, + { + name: "task", + description: + "Launch an opencode subagent to handle a complex multi-step task" + + " autonomously. Routed through opencode's task tool so subagent" + + " orchestration, permission, and lifecycle are handled by opencode." + + " Use `subagent_type` to pick which configured subagent runs (e.g." + + " `build`, `general`, `explore`, or any custom subagent declared in" + + " opencode.json). " + + TASK_PROXY_NOTE, + inputSchema: { + type: "object", + properties: TASK_INPUT_PROPERTIES, + required: TASK_INPUT_REQUIRED, + }, + }, + { + name: TASK_BATCH_TOOL_NAME, + description: + "Launch two or more independent opencode subagents at the same time and" + + " get all their results back together. Put one ordinary task input in" + + " `tasks` for each subagent. " + + TASK_BATCH_PROXY_NOTE, + inputSchema: { + type: "object", + properties: { + tasks: { + type: "array", + minItems: 2, + description: "Independent subagent tasks to run concurrently", + items: { + type: "object", + properties: TASK_INPUT_PROPERTIES, + required: TASK_INPUT_REQUIRED, + }, + }, + }, + required: ["tasks"], + }, + }, + { + name: "question", + description: + "Ask the operator structured questions with options and receive" + + " their answers back. Routed through opencode's native `question`" + + " tool so the prompt renders as a real TUI form (with options and a" + + " custom-answer field) instead of a plain text turn. Use this when" + + " you need a decision, clarification, or preference from the" + + " operator mid-task. " + + QUESTION_PROXY_NOTE, + inputSchema: { + type: "object", + properties: { + questions: { + type: "array", + description: "Questions to ask.", + items: { + type: "object", + properties: { + question: { + type: "string", + description: "Complete question.", + }, + header: { + type: "string", + description: "Very short label (max 30 chars).", + }, + options: { + type: "array", + description: "Available choices.", + items: { + type: "object", + properties: { + label: { + type: "string", + description: "Display text (1-5 words, concise).", + }, + description: { + type: "string", + description: "Explanation of choice.", + }, + }, + required: ["label", "description"], + }, + }, + multiple: { + type: "boolean", + description: + "Allow selecting multiple choices. Defaults to false.", + }, + }, + required: ["question", "header", "options"], + }, + }, + }, + required: ["questions"], + }, + }, + { + name: "compress", + description: + "Replace older conversation detail with a summary you write, then" + + " continue in a fresh Claude Code session. Handled inside the plugin," + + " so it never prompts the operator. " + + COMPRESS_PROXY_NOTE, + inputSchema: { + type: "object", + properties: { + summary: { + type: "string", + description: + "Dense technical summary of the work being compressed: decisions" + + " made, files changed, commands run and their outcomes, and what" + + " is still open. This is the ONLY prior context that survives, so" + + " anything omitted is lost.", + }, + }, + required: ["summary"], + }, + }, +] + +export async function createProxyMcpServer( + tools: ProxyToolDef[] = DEFAULT_PROXY_TOOLS, + timeoutOverrides?: Record, + interceptors?: Map, + options: { + /** Keepalive cadence for streamed replies; a test seam, defaults to `PROXY_KEEPALIVE_MS`. */ + keepaliveMs?: number + } = {}, +): Promise { + const calls = new EventEmitter() + const pending = new Map() + const keepaliveMs = options.keepaliveMs ?? PROXY_KEEPALIVE_MS + + // Per-server bearer secret (256 bits). This endpoint executes Bash/Edit/ + // Write through opencode's executor, so an unauthenticated caller on + // loopback would have arbitrary command execution. The token lives only + // in this process and in the 0600 MCP config file Claude reads; it is + // deliberately kept out of the URL, because query strings leak into logs + // and process listings. + const authToken = crypto.randomBytes(32).toString("hex") + const expectedAuth = Buffer.from(`Bearer ${authToken}`) + // The exact authority we hand to Claude. Set once the ephemeral port is + // known; compared against the Host header to defeat DNS rebinding. + let boundAuthority = "" + + function authOk(req: IncomingMessage): boolean { + const got = req.headers.authorization + if (typeof got !== "string") return false + const candidate = Buffer.from(got) + // timingSafeEqual throws on length mismatch, so length-check first. + // Length is not secret (the token is fixed-width). + if (candidate.length !== expectedAuth.length) return false + return crypto.timingSafeEqual(candidate, expectedAuth) + } + + /** + * Reject a request without leaving the connection usable. + * + * Ending the response alone is not enough. A peer can declare a large + * Content-Length, send a single byte, take the rejection, and leave the + * request still arriving — and `server.close()` does not reap connections + * that are still sending, so a shutdown would hang behind it. Node's + * default whole-request timeout is five minutes, which is five minutes of + * a socket held by an unauthenticated caller. + * + * `Connection: close` tells Node to close once the response is flushed; + * destroying the socket on `finish` covers the case where the peer never + * finishes its body. + */ + function reject( + req: IncomingMessage, + res: ServerResponse, + statusCode: number, + reason: string, + ): void { + // Every guard below is a measured property of the client we spawn, not a + // guarantee about future ones. If a later Claude CLI starts sending an + // Origin header, or a different Content-Type, every proxy call would + // 403/415 with no other symptom than tools mysteriously not working — so + // say why, here, once per rejected request. Header VALUES are omitted: + // this line must never carry the bearer token. + log.notice("proxy-mcp rejected a request", { + statusCode, + reason, + method: req.method, + hasAuthorization: typeof req.headers.authorization === "string", + }) + res.statusCode = statusCode + res.setHeader("Connection", "close") + res.on("finish", () => { + req.socket?.destroy() + }) + res.end() + } + + const server = createServer(async (req, res) => { + if (req.method !== "POST" || !req.url?.startsWith("/mcp")) { + reject(req, res, 404, "not a POST to /mcp") + return + } + // Everything below runs BEFORE readBody: an unauthenticated peer must + // not be able to stream an unbounded body into memory. + // + // DNS rebinding: a browser rebound onto this port via an attacker + // hostname sends that hostname in Host, never the loopback authority we + // generated. This does NOT block a page posting directly to + // 127.0.0.1: — such a request carries exactly the expected Host — + // so it is a rebinding defense specifically, not a browser defense. The + // Origin and Content-Type guards below, and the token, cover that case. + if (req.headers.host !== boundAuthority) { + reject(req, res, 403, "host header is not the bound authority") + return + } + // Claude Code 2.1.226 sends no Origin on MCP requests (verified). The MCP + // transport spec obliges SERVERS to validate Origin; it does not oblige + // clients to omit it, so this is a measured property of the client we + // spawn rather than a guarantee about all conforming clients. + if (req.headers.origin !== undefined) { + reject(req, res, 403, "origin header present") + return + } + // Requiring application/json forces a CORS preflight for cross-origin + // callers (which then fails), closing the text/plain "simple request" + // bypass that would otherwise allow blind cross-site POSTs. + const contentType = String(req.headers["content-type"] ?? "") + .split(";")[0] + .trim() + .toLowerCase() + if (contentType !== "application/json") { + reject(req, res, 415, "content-type is not application/json") + return + } + if (!authOk(req)) { + reject(req, res, 401, "missing or invalid bearer token") + return + } + // Hoist the request id and method so the catch block can echo them + // in error responses. Without this, a broker rejection (timeout / + // orphan) on a tools/call lands in the catch with no visible id, and + // the response goes back with `id: null` which Claude CLI cannot + // match to the original request. The method is also needed because + // tools/call errors must be returned as MCP results with isError + // (not JSON-RPC errors) or Claude CLI rejects them as a "malformed + // result that failed schema validation" (seen live 2026-07-04). + let requestId: number | string | null = null + let requestMethod: string | null = null + // Hoisted for the same reason: once a streamed reply's headers are out, + // an error must travel down that stream instead of through writeJson + // (which would try to set headers again and throw inside the catch). + let reply: ReplyStream | null = null + try { + const body = await readBody(req) + const request = JSON.parse(body) as { + jsonrpc?: string + id?: number | string | null + method?: string + params?: Record + } + requestId = request?.id ?? null + requestMethod = typeof request?.method === "string" ? request.method : null + + if (request?.jsonrpc !== "2.0" || typeof request.method !== "string") { + writeJson(res, { + jsonrpc: "2.0", + id: requestId, + error: { code: -32600, message: "Invalid request" }, + }) + return + } + + log.debug("proxy-mcp request", { + method: request.method, + id: request.id, + }) + + if (request.method === "initialize") { + writeJson(res, { + jsonrpc: "2.0", + id: requestId, + result: { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { + name: SERVER_NAME, + version: "0.1.0", + }, + }, + }) + return + } + + if (request.method === "notifications/initialized") { + res.statusCode = 204 + res.end() + return + } + + if (request.method === "tools/list") { + writeJson(res, { + jsonrpc: "2.0", + id: requestId, + result: { + tools: tools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })), + }, + }) + return + } + + if (request.method === "tools/call") { + const params = request.params ?? {} + const toolName = String(params.name ?? "") + const input = (params.arguments ?? {}) as Record + + if (!tools.some((t) => t.name === toolName)) { + // tools/call failures MUST be MCP results with isError, never + // JSON-RPC error envelopes: Claude CLI validates every tools/call + // response against the MCP result schema and rejects JSON-RPC + // errors as malformed (@jknlsn, seen live 2026-07-04). + writeJson(res, { + jsonrpc: "2.0", + id: requestId, + result: { + content: [{ type: "text", text: `Unknown proxy tool: ${toolName}` }], + isError: true, + }, + }) + return + } + + if (toolName === TASK_BATCH_TOOL_NAME) { + const problem = taskBatchInputError(input) + if (problem) { + // Same rule as the unknown-tool path: an MCP result with isError, + // never a JSON-RPC error envelope. + writeToolCallResult(res, requestId, { kind: "error", message: problem }) + return + } + } + + // Intercepted tools act on plugin state, not on the workspace, so + // they are answered here and never queued for opencode. The result + // still goes through the shared MCP envelope below — a JSON-RPC + // error here would be rejected by Claude CLI exactly like any other + // tools/call error envelope. + const interceptor = interceptors?.get(toolName) + if (interceptor) { + let intercepted: ProxyToolResult + try { + intercepted = await interceptor(input) + } catch (interceptorError) { + const message = + interceptorError instanceof Error + ? interceptorError.message + : String(interceptorError) + log.warn("proxy-mcp interceptor failed", { toolName, error: message }) + intercepted = { kind: "error", message } + } + writeToolCallResult(res, requestId, intercepted) + return + } + + const callId = crypto.randomUUID() + log.info("proxy-mcp tool call received", { + callId, + toolName, + hasInput: input != null, + sse: acceptsEventStream(req.headers.accept), + }) + + // Broker-backed calls can block for as long as a subagent runs. The + // reply is streamed either way so the client's own HTTP timers never + // fire on a silent connection: SSE when the client accepts it + // (headers and a comment now, keepalive comments, the JSON-RPC result + // as the final event), otherwise a chunked JSON body whose headers go + // out now and which carries keepalive whitespace until the result. + // Every guard above has already run, so nothing is flushed for an + // unauthenticated peer, an unknown tool, or a rejected batch. + const channel: ProxyCallChannel = { closed: false } + reply = acceptsEventStream(req.headers.accept) + ? openEventStream(res, keepaliveMs) + : openJsonStream(res, keepaliveMs) + res.once("close", () => { + reply?.stop() + if (res.writableFinished) return + channel.closed = true + log.notice("proxy-mcp client closed a tool call before its result", { + callId, + toolName, + }) + }) + + let timer: ReturnType | null = null + const result = await new Promise( + (resolve, reject) => { + const entry: ProxyToolCall = { + id: callId, + toolName, + input, + resolve, + reject, + channel, + } + pending.set(callId, entry) + const deadlineMs = resolveProxyCallTimeoutMs( + toolName, + input, + timeoutOverrides, + ) + // No deadline means no timer at all: `setTimeout(fn, 0)` would + // reject the call on the next tick. The broker applies the same + // rule to the same resolved value, so the two layers agree. + if (deadlineMs > PROXY_NO_DEADLINE_MS) { + timer = setTimeout(() => { + if (!pending.has(callId)) return + pending.delete(callId) + // v0.4.13: demoted from warn to notice. Timeouts are usually + // permission-pending while the user is AFK — surfacing each as + // a yellow UI bubble produces a wall of noise on return. The + // file log still captures the event for diagnostics. + log.notice("proxy-mcp tool call timed out", { + callId, + toolName, + deadlineMs, + }) + reject(buildProxyTimeoutError(toolName, deadlineMs)) + }, deadlineMs) + } + calls.emit("call", entry) + }, + ).finally(() => { + if (timer) clearTimeout(timer) + pending.delete(callId) + }) + + if (channel.closed) { + // Nobody is reading. The language model already saw the closed + // channel and hands the result to Claude another way. + log.notice("proxy-mcp dropping result for a closed tool call", { + callId, + toolName, + }) + return + } + writeToolCallResult(res, requestId, result, reply) + return + } + + writeJson(res, { + jsonrpc: "2.0", + id: requestId, + error: { code: -32601, message: `Unknown method: ${request.method}` }, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const logFn = isExpectedCleanupError(errorMessage) ? log.notice : log.warn + logFn("proxy-mcp error handling request", { + error: errorMessage, + }) + // Broker rejections (timeouts, orphans, server close) surface here for + // tools/call requests. Same rule as above: respond with an MCP result + // carrying isError, never a JSON-RPC error envelope, or Claude CLI + // rejects the response as schema-invalid. + if (requestMethod === "tools/call") { + try { + writeToolCallResult( + res, + requestId, + { kind: "error", message: errorMessage }, + reply, + ) + } catch { + try { + res.statusCode = 500 + res.end() + } catch {} + } + return + } + try { + // tools/call already returned above with an MCP result; anything + // reaching here is a protocol-level method (initialize, tools/list) + // where a JSON-RPC error is the correct shape. + writeJson(res, { + jsonrpc: "2.0", + id: requestId, + error: { + code: -32603, + message: error instanceof Error ? error.message : "Internal error", + }, + }) + } catch { + try { + res.statusCode = 500 + res.end() + } catch {} + } + } + }) + + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + server.off("error", reject) + resolve() + }) + }) + + const addr = server.address() as AddressInfo | null + if (!addr) { + server.close() + throw new Error("Failed to bind proxy MCP server") + } + + boundAuthority = `127.0.0.1:${addr.port}` + const url = `http://${boundAuthority}/mcp` + + // NOTE: authToken is deliberately absent from this line and every other + // log call. The plugin log is written to disk and echoed to the TUI in + // debug mode; a leaked token there would defeat the whole mechanism. + log.info("proxy-mcp server started", { + url, + tools: tools.map((t) => t.name), + }) + + let configFilePath: string | null = null + + const api: ProxyMcpServer = { + url, + serverName: SERVER_NAME, + tools, + authToken, + calls, + configPath() { + if (configFilePath) return configFilePath + const body = JSON.stringify( + { + mcpServers: { + [SERVER_NAME]: { + type: "http", + url, + // Claude CLI replays these headers on every request to this + // server, which is what lets the handler above reject anyone + // who did not read this 0600 file. + headers: { Authorization: `Bearer ${authToken}` }, + timeout: resolveProxyClientCeilingMs(timeoutOverrides), + }, + }, + }, + null, + 2, + ) + const hash = crypto + .createHash("sha256") + .update(body) + .digest("hex") + .slice(0, 12) + const outPath = path.join( + pluginTmpDir(), + `proxy-${hash}.json`, + ) + fs.writeFileSync(outPath, body, { encoding: "utf8", mode: 0o600 }) + configFilePath = outPath + return outPath + }, + pendingCallIds() { + return [...pending.keys()] + }, + async close() { + for (const entry of pending.values()) { + entry.reject(new Error(SERVER_CLOSED_MESSAGE)) + } + pending.clear() + await new Promise((resolve) => { + server.close(() => resolve()) + }) + if (configFilePath) { + try { + fs.unlinkSync(configFilePath) + } catch {} + configFilePath = null + } + }, + } + + return api +} + +/** CLI-ready list of Claude tool names to disable, for each proxied tool. */ +export function disallowedToolFlags(tools: ProxyToolDef[]): string[] { + // Map our lowercase MCP tool names to the Claude tool name(s) they replace. + // `edit` covers both `Edit` and `MultiEdit` because opencode has no + // MultiEdit equivalent; without disabling MultiEdit, Claude can batch + // file changes through it and bypass opencode's permission UI. + // `task` disables Claude CLI's `Agent` tool (its built-in subagent + // dispatcher) so subagent calls flow through opencode's `task` tool + // instead — which lets opencode's configured subagent set (`build`, + // `general`, custom subagents in opencode.json) execute the work + // under opencode's permission/lifecycle, rather than Claude's + // internal-only general-purpose / Explore / Plan options. + const nameMap: Record = { + bash: ["Bash"], + read: ["Read"], + write: ["Write"], + edit: ["Edit", "MultiEdit"], + glob: ["Glob"], + grep: ["Grep"], + webfetch: ["WebFetch"], + task: ["Agent"], + task_batch: ["Agent"], + // `question` disables Claude Code's built-in `AskUserQuestion` so the + // structured-questions path flows through opencode's native `question` + // tool instead — same UI/permission/audit benefits as the other + // proxies. Without this, the model can call both and the two paths + // diverge (opencode's form vs the headless deny-and-render fallback). + question: ["AskUserQuestion"], + } + const out: string[] = [] + const seen = new Set() + for (const t of tools) { + const mapped = nameMap[t.name.toLowerCase()] + if (!mapped) continue + for (const claudeTool of mapped) { + if (seen.has(claudeTool)) continue + seen.add(claudeTool) + out.push(claudeTool) + } + } + return out +} + +/** + * Everything that goes to `--disallowedTools` for one spawn: the built-ins + * the proxied tools replace, plus the ones the operator named directly. + * + * `disallowedToolFlags` can only cover tools the plugin has a proxy for, so + * a built-in with no equivalent (`NotebookEdit`, and anything Claude Code + * ships next) is unreachable without `extraDisallowedTools` — issue #26. + */ +export function resolveDisallowedTools(options: { + proxyTools?: ProxyToolDef[] | null + extraDisallowedTools?: string[] + disableWebSearch?: boolean +}): string[] { + const out: string[] = [] + const seen = new Set() + const push = (name: string) => { + const trimmed = name.trim() + if (!trimmed || seen.has(trimmed)) return + seen.add(trimmed) + out.push(trimmed) + } + + for (const name of disallowedToolFlags(options.proxyTools ?? [])) push(name) + for (const name of options.extraDisallowedTools ?? []) push(String(name)) + if (options.disableWebSearch) push("WebSearch") + return out +} + +/** The shape of one `client.tool.list()` entry this resolver needs. */ +export interface OpencodeToolListEntry { + id: string + description?: string + parameters?: unknown +} + +/** + * Build proxy defs for the opencode tools named in `proxyOpencodeTools`. + * + * `resolvedProxyMcpTools` only forwards a tool whose id matches an enabled + * MCP server (`` or `_`), so a tool another opencode + * plugin declares directly matches nothing and is dropped. opencode-dcp's + * `compress` is the case that motivated this: it is in opencode's registry, + * dcp tells the model "you MUST use the `compress` tool now", and under this + * provider the model was never offered it. This is the explicit allowlist + * that forwards such a tool. It is deliberately never automatic: these run + * inside opencode with the caller's permissions, so which ones cross over is + * the operator's decision. + * + * A name already held by another proxy def wins, and the forwarded entry is + * dropped with a warning. That is not arbitrary: `ensureProxyServer` + * registers interceptors by name and an intercepted call is answered + * in-process, so a forwarded def sharing a name with an intercepted one + * (`compress` again) could never reach opencode at all. Dropping it loudly + * is the difference between documented precedence and a silent shadow. + */ +export function resolveProxyOpencodeToolDefs(options: { + requested?: readonly string[] + items?: readonly OpencodeToolListEntry[] + taken?: ReadonlySet +}): ProxyToolDef[] { + const requested = options.requested ?? [] + if (requested.length === 0) return [] + + const items = options.items + if (!items) { + log.warn( + "proxyOpencodeTools is set but opencode's tool registry did not answer;" + + " forwarding nothing this spawn", + { requested: requested.map(String) }, + ) + return [] + } + + const byLowerId = new Map() + for (const item of items) { + const key = item.id.toLowerCase() + if (!byLowerId.has(key)) byLowerId.set(key, item) + } + + const taken = options.taken ?? new Set() + const out: ProxyToolDef[] = [] + const seen = new Set() + const unknown: string[] = [] + const collided: string[] = [] + + for (const raw of requested) { + const name = String(raw).trim() + if (!name) continue + const item = byLowerId.get(name.toLowerCase()) + if (!item) { + unknown.push(name) + continue + } + if (taken.has(item.id)) { + collided.push(item.id) + continue + } + if (seen.has(item.id)) continue + seen.add(item.id) + out.push({ + name: item.id, + description: typeof item.description === "string" ? item.description : "", + inputSchema: + item.parameters && typeof item.parameters === "object" + ? (item.parameters as Record) + : { type: "object", properties: {} }, + }) + } + + // Same reasoning as the `proxyTools` typo warning: an unrecognised name is + // simply not forwarded, and silence looks from the outside like the option + // was ignored. + if (unknown.length > 0) { + log.warn("ignoring unknown proxyOpencodeTools entries", { + unknown, + known: [...byLowerId.values()].map((item) => item.id).join(", "), + }) + } + if (collided.length > 0) { + log.warn( + "proxyOpencodeTools entry dropped: a proxy tool already holds that name," + + " and it keeps it", + { collided }, + ) + } + return out +} + +/** One entry of the AI SDK `tools` array opencode hands `doStream`. */ +export interface ModelToolEntry { + type?: string + name?: string + description?: string + inputSchema?: unknown +} + +/** What `resolveMcpProxyToolDefs` found, split by the two things callers need. */ +export interface McpProxyToolResolution { + /** One def per MCP tool that will be served from the proxy instead. */ + defs: ProxyToolDef[] + /** Only the servers a def was actually built for. */ + coveredServers: Set +} + +/** + * Build proxy defs for opencode's MCP-backed tools out of the tool array + * opencode already passes to `doStream`. + * + * The discovery source matters, and it is the whole reason this function + * exists. The obvious source, `client.tool.list()` behind + * `/experimental/tool`, enumerates opencode's `ToolRegistry` only: built-ins + * plus plugin-declared tools. MCP tools are not in that registry on 1.18.31, + * they are merged into the model's tool set afterwards, so a registry-based + * match finds nothing however the prefix rule is written. The AI SDK `tools` + * argument is downstream of that merge, so it is the one place a provider + * plugin can see them at all. + * + * Matching is still by enabled-server prefix, longest name first so + * `slack_intl_*` resolves to `slack_intl` and not `slack`. That is + * deliberately narrow: everything else in the array is a built-in or another + * plugin's tool, and forwarding those wholesale is what the explicit + * `proxyOpencodeTools` allowlist is for. + */ +export function resolveMcpProxyToolDefs(options: { + serverNames: readonly string[] + tools?: readonly ModelToolEntry[] + taken?: ReadonlySet +}): McpProxyToolResolution { + const empty: McpProxyToolResolution = { defs: [], coveredServers: new Set() } + const serverNames = options.serverNames ?? [] + if (serverNames.length === 0) return empty + + const tools = options.tools + if (!tools || tools.length === 0) return empty + + const serversByLengthDesc = [...serverNames].sort((a, b) => b.length - a.length) + const taken = options.taken ?? new Set() + const defs: ProxyToolDef[] = [] + const coveredServers = new Set() + const seen = new Set() + const collided: string[] = [] + + for (const tool of tools) { + // opencode only ever puts plain function tools in this array; a + // provider-defined entry has no opencode executor behind it, so + // forwarding one would produce a call nothing can answer. + if (tool?.type !== undefined && tool.type !== "function") continue + const name = typeof tool?.name === "string" ? tool.name.trim() : "" + if (!name) continue + + const matchedServer = serversByLengthDesc.find( + (server) => name === server || name.startsWith(`${server}_`), + ) + if (!matchedServer) continue + if (seen.has(name)) continue + if (taken.has(name)) { + collided.push(name) + continue + } + seen.add(name) + coveredServers.add(matchedServer) + defs.push({ + name, + description: typeof tool.description === "string" ? tool.description : "", + inputSchema: + tool.inputSchema && typeof tool.inputSchema === "object" + ? (tool.inputSchema as Record) + : { type: "object", properties: {} }, + }) + } + + if (collided.length > 0) { + // WARN, not NOTICE: only warn and error are alwaysStderr in src/logger.ts, + // and a shadowed MCP tool silently stops being routed through opencode, + // which is exactly the class of surprise this lane exists to end. + log.warn( + "MCP tool not routed through the proxy: another proxy tool already holds" + + " that name, and it keeps it", + { collided }, + ) + } + return { defs, coveredServers } +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + req.on("data", (chunk: Buffer) => chunks.push(chunk)) + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))) + req.on("error", reject) + }) +} + +/** + * The single exit for every `tools/call`, broker-backed or intercepted. + * Success and failure share one MCP result envelope: a JSON-RPC error for + * `kind: "error"` was rejected by Claude CLI as a "malformed result that + * failed schema validation", so tool failures must surface as + * `isError: true` instead. + */ +function writeToolCallResult( + res: ServerResponse, + requestId: unknown, + result: ProxyToolResult, + reply: ReplyStream | null = null, +): void { + const text = result.kind === "error" ? result.message : result.text + const isError = result.kind === "error" || result.isError === true + const envelope = { + jsonrpc: "2.0", + id: requestId ?? null, + result: { + content: [{ type: "text", text }], + isError, + }, + } + if (reply) { + reply.finish(envelope) + return + } + writeJson(res, envelope) +} + +/** + * An in-flight streamed reply whose headers are already on the wire. + * `finish` writes the JSON-RPC response and ends the body; `stop` only + * cancels the keepalive, for when the client went away first. + */ +interface ReplyStream { + finish(envelope: unknown): void + stop(): void +} + +/** + * Write `ping` every `keepaliveMs` until stopped or the response is gone. + * Never keeps the host process alive on its own. + */ +function startKeepalive( + res: ServerResponse, + keepaliveMs: number, + ping: string, +): () => void { + let timer: ReturnType | null = setInterval(() => { + if (res.writableEnded || res.destroyed) { + stop() + return + } + res.write(ping) + }, keepaliveMs) + timer.unref?.() + const stop = () => { + if (timer) { + clearInterval(timer) + timer = null + } + } + return stop +} + +/** + * SSE reply: the JSON-RPC response goes out as the single `message` event, + * which is what the MCP Streamable HTTP client expects for a request + * answered over SSE. + */ +function openEventStream(res: ServerResponse, keepaliveMs: number): ReplyStream { + res.statusCode = 200 + res.setHeader("Content-Type", "text/event-stream") + res.setHeader("Cache-Control", "no-cache, no-transform") + res.setHeader("Connection", "keep-alive") + res.flushHeaders() + // Start the response body without waiting for the tool result. + res.write(": open\n\n") + const stop = startKeepalive(res, keepaliveMs, ": keepalive\n\n") + return { + stop, + finish(envelope) { + stop() + if (res.writableEnded || res.destroyed) return + res.end(`event: message\ndata: ${JSON.stringify(envelope)}\n\n`) + }, + } +} + +/** + * JSON reply for a client that did not ask for SSE (@broskees' 68ed142, + * adapted). Headers are flushed at once, which stops the client's header + * timer, and whitespace is written on the keepalive cadence, which stops its + * body timer. There is no `Content-Length`, so the body is chunked, and the + * envelope is written last: whitespace before a JSON value is insignificant + * (RFC 8259), so the whole body still parses as the one JSON-RPC response, + * on success and on error alike. + */ +function openJsonStream(res: ServerResponse, keepaliveMs: number): ReplyStream { + res.statusCode = 200 + res.setHeader("Content-Type", "application/json") + res.setHeader("Cache-Control", "no-cache, no-transform") + res.flushHeaders() + const stop = startKeepalive(res, keepaliveMs, " ") + return { + stop, + finish(envelope) { + stop() + if (res.writableEnded || res.destroyed) return + res.end(JSON.stringify(envelope)) + }, + } +} + +function writeJson(res: ServerResponse, body: unknown): void { + if (res.destroyed || res.writableEnded) return + const payload = JSON.stringify(body) + res.statusCode = 200 + res.setHeader("Content-Type", "application/json") + res.setHeader("Content-Length", Buffer.byteLength(payload).toString()) + res.end(payload) +} diff --git a/src/runtime-status.ts b/src/runtime-status.ts new file mode 100644 index 0000000..b8b08b6 --- /dev/null +++ b/src/runtime-status.ts @@ -0,0 +1,277 @@ +import type { RuntimeMcpStatus } from "./mcp-bridge.js" +import { log } from "./logger.js" + +/** + * Captured opencode runtime context (SDK client + project directory) from + * `PluginInput`. Lives in its own module to break the cycle that would + * otherwise form between `index.ts` and `claude-code-language-model.ts`. + * Values are `null`/`undefined` until the plugin's `server` factory runs + * (e.g. early provider lookups, direct AI-SDK use, tests). + */ +type OpencodeClient = { + mcp?: { + status?: () => Promise<{ data?: unknown; error?: unknown }> + } + tool?: { + list?: (options: { + query: { provider: string; model: string; directory?: string } + }) => Promise<{ data?: unknown; error?: unknown }> + } + session?: { + /** `GET /session/{id}` — the returned Session carries `directory`. */ + get?: (options: { + path: { id: string } + query?: { directory?: string } + }) => Promise<{ data?: unknown; error?: unknown }> + } +} + +let opencodeClient: OpencodeClient | null = null + +export function setOpencodeClient(client: unknown): void { + if (client && typeof client === "object") { + opencodeClient = client as OpencodeClient + } +} + +/** + * The captured SDK client, untyped: callers narrow to the surface they use + * (this module's `OpencodeClient` only mirrors the MCP/tool routes). + */ +export function getOpencodeClient(): unknown { + return opencodeClient +} + +/** + * Captured opencode project directory from `PluginInput.directory` (with + * `worktree` as secondary signal). Used as a *fallback* at Claude CLI + * spawn time only when `process.cwd()` is unusable (macOS GUI launches + * where launchd hands the process `cwd=/`). + * + * IMPORTANT: never bake this into provider config (`mergedOptions.cwd`). + * Doing so freezes the value at plugin init and breaks workspace + * switching mid-session, because subsequent workspace changes in + * opencode's UI never get reflected in `this.config.cwd`. See issue #4. + */ +let opencodeProjectDirectory: string | undefined + +export function setOpencodeProjectDirectory(dir: string | undefined): void { + opencodeProjectDirectory = dir +} + +export function getOpencodeProjectDirectory(): string | undefined { + return opencodeProjectDirectory +} + +export function isUsableDirectory(d: unknown): d is string { + return typeof d === "string" && d.length > 1 && d !== "/" +} + +/** + * Resolve the cwd for a Claude CLI subprocess spawn. Priority: + * + * 1. Explicit `configured` value (`options.cwd` from `opencode.json`). + * Users who pinned a directory keep their override unconditionally. + * 2. The opencode session's own `directory` (resolved per-call from the + * `x-session-affinity` id via the SDK). Authoritative for + * `opencode serve` / web-UI mode, where one long-lived server process + * handles many projects and `process.cwd()` is the server's launch + * dir — not the session's project. Equals `process.cwd()` in the TUI, + * so it does not regress that path. + * 3. Live `process.cwd()` when it's a real directory. Lazy resolution + * that lets opencode's project-aware behavior (chdir on workspace + * switch, project-per-shell on terminal launch) flow through. + * 4. Captured project directory from plugin init. Rescues macOS GUI + * launches where `process.cwd()` is `/`. + * 5. Final fallback to `process.cwd()` (returns `/` in the pathological + * case where neither override nor capture is available). + */ +export function resolveSpawnCwd(configured: string | undefined): string { + return resolveSpawnCwdFrom( + configured, + process.cwd(), + opencodeProjectDirectory, + ) +} + +export function resolveSpawnCwdFrom( + configured: string | undefined, + live: string, + captured: string | undefined, + sessionDir?: string, +): string { + if (configured) return configured + if (isUsableDirectory(sessionDir)) return sessionDir + if (isUsableDirectory(live)) return live + return captured ?? live +} + +/** + * Resolve the spawn cwd for a specific opencode session. Looks up the + * session's `directory` via the SDK (keyed by the `x-session-affinity` + * id opencode sets on LLM calls) and feeds it into `resolveSpawnCwdFrom` + * as tier 2. Falls back cleanly to the non-session resolution when the + * id is absent ("default"), no SDK client is captured, or the lookup + * fails — so the TUI / direct-AI-SDK / test paths are unaffected. + */ +export async function resolveSpawnCwdForSession( + configured: string | undefined, + sessionID: string | undefined, +): Promise { + // An explicit pin wins unconditionally — skip the lookup entirely. + if (configured) return configured + const sessionDir = sessionID + ? await fetchSessionDirectory(sessionID) + : undefined + return resolveSpawnCwdFrom( + configured, + process.cwd(), + opencodeProjectDirectory, + sessionDir, + ) +} + +/** + * Fetch an opencode session's project directory via `GET /session/{id}`. + * Returns `undefined` on any failure (no client, "default"/empty id, + * rejected call, malformed response, unusable directory) so callers fall + * back to `process.cwd()`-based resolution. No caching: a session's + * directory can change (workspace switch) and the call is a cheap + * localhost round-trip relative to spawning Claude. + */ +export async function fetchSessionDirectory( + sessionID: string, +): Promise { + if (!sessionID || sessionID === "default") return undefined + const client = opencodeClient + if (!client?.session?.get) return undefined + try { + const res = await client.session.get({ path: { id: sessionID } }) + const data = (res as { data?: unknown }).data + if (!data || typeof data !== "object") return undefined + const dir = (data as { directory?: unknown }).directory + return isUsableDirectory(dir) ? dir : undefined + } catch (err) { + log.warn("failed to fetch opencode session directory", { + sessionID, + error: err instanceof Error ? err.message : String(err), + }) + return undefined + } +} + +/** + * The id of the session that spawned this one, or undefined when it is a + * top-level session (or the lookup is unavailable). Read off the same + * `GET /session/{id}` response `fetchSessionDirectory` uses, kept separate + * because the two are needed at different points in a turn. + * + * Account failover is the only caller: a subagent must never be shown the + * switch form. It follows its parent's account for free, because the override + * is scoped to the account rather than the session. + */ +export async function fetchSessionParentId( + sessionID: string, +): Promise { + if (!sessionID || sessionID === "default") return undefined + const client = opencodeClient + if (!client?.session?.get) return undefined + try { + const res = await client.session.get({ path: { id: sessionID } }) + const data = (res as { data?: unknown }).data + if (!data || typeof data !== "object") return undefined + const parentID = (data as { parentID?: unknown }).parentID + return typeof parentID === "string" && parentID.length > 0 + ? parentID + : undefined + } catch (err) { + log.warn("failed to fetch opencode session parent", { + sessionID, + error: err instanceof Error ? err.message : String(err), + }) + return undefined + } +} + +/** + * Snapshot opencode's current MCP runtime status so the bridge can overlay + * UI-toggled state on top of disk config. Returns `undefined` on any + * failure (no client captured, status call rejected, malformed response) + * so the bridge falls back to disk-only. + */ +export async function getRuntimeMcpStatus(): Promise< + RuntimeMcpStatus | undefined +> { + const client = opencodeClient + if (!client?.mcp?.status) return undefined + try { + const res = await client.mcp.status() + const data = (res as { data?: unknown }).data + if (!data || typeof data !== "object") return undefined + const out: RuntimeMcpStatus = {} + for (const [name, entry] of Object.entries(data as Record)) { + if (entry && typeof entry === "object") { + const status = (entry as { status?: unknown }).status + if (typeof status === "string") out[name] = status + } + } + return out + } catch (err) { + log.warn("failed to fetch opencode MCP runtime status", { + error: err instanceof Error ? err.message : String(err), + }) + return undefined + } +} + +export interface OpencodeToolListItem { + id: string + description: string + parameters: Record +} + +/** + * Fetch opencode's full tool catalog (built-ins + MCP-bridged) with JSON + * Schema parameters via `client.tool.list()`. The provider/model query + * narrows the schema variants opencode returns; in practice MCP-origin + * tool schemas are model-agnostic, so any registered (provider, model) + * works as the query target. Returns `undefined` on any failure so callers + * can fall back to direct-bridge behavior. + */ +export async function fetchOpencodeToolList( + provider: string, + model: string, + directory?: string, +): Promise { + const client = opencodeClient + if (!client?.tool?.list) return undefined + try { + const res = await client.tool.list({ + query: { provider, model, ...(directory ? { directory } : {}) }, + }) + const data = (res as { data?: unknown }).data + if (!Array.isArray(data)) return undefined + const out: OpencodeToolListItem[] = [] + for (const entry of data as unknown[]) { + if (!entry || typeof entry !== "object") continue + const e = entry as Record + const id = typeof e.id === "string" ? e.id : null + const description = + typeof e.description === "string" ? e.description : "" + const parameters = + e.parameters && typeof e.parameters === "object" + ? (e.parameters as Record) + : {} + if (!id) continue + out.push({ id, description, parameters }) + } + return out + } catch (err) { + log.warn("failed to fetch opencode tool list", { + provider, + model, + error: err instanceof Error ? err.message : String(err), + }) + return undefined + } +} diff --git a/src/session-manager.ts b/src/session-manager.ts index cbf0be0..8f2ccce 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -1,78 +1,821 @@ import { spawn, type ChildProcess } from "node:child_process" import { createInterface } from "node:readline" +import { randomUUID } from "node:crypto" import { EventEmitter } from "node:events" +import { unlink } from "node:fs/promises" import { log } from "./logger.js" +import { SERVER_CLOSED_MESSAGE, type ProxyMcpServer, type ProxyToolResult } from "./proxy-mcp.js" +import { + getPendingProxyCalls, + rejectAllPendingProxyCallsForSession, + type PendingProxyCall, +} from "./proxy-broker.js" +import { clearLedger } from "./todo-ledger.js" +import { clearExitPlanModeQuestions, hasExitPlanModeQuestions } from "./plan-mode-question.js" +import { clearAccountFailoverQuestions } from "./account-failover.js" +import { clearCompression } from "./compression-store.js" +import { + cliHygieneEnv, + cliSupportsFastMode, + cliSupportsThinking, + cliSupportsThinkingDisplay, + type CliVersion, +} from "./cli-version.js" +import type { ReasoningEffort } from "./types.js" +import { dispatchSideQuestionResponse, isSideQuestionPending } from "./side-question.js" export interface ActiveProcess { proc: ChildProcess lineEmitter: EventEmitter + proxyServer?: ProxyMcpServer | null + /** + * Hash of the bridged opencode MCP config the process was spawned with. + * `null` when the bridge produced nothing (no MCP servers). `undefined` + * when the bridge was disabled. Used to detect mid-session config drift + * and force a respawn. + */ + mcpHash?: string | null + /** Temp file holding `--append-system-prompt-file` content; unlinked on exit. */ + systemPromptFile?: string + /** Effort the process was spawned with, so a respawn keeps it. */ + effort?: ReasoningEffort + /** When the child was spawned, so `/claude-code-doctor` can report its age. */ + startedAt?: number + /** + * The binary this child was spawned with. Account failover compares it + * against the path the current turn resolves to: a difference means the + * conversation has moved to another account, and the process plus its + * Claude session id have to go because a transcript cannot resume across + * accounts. Absent on the interactive shim, which never fails over. + */ + cliPath?: string + cliArgs?: string[] + // Retain resolved calls until continuation settles, including late channel closure. + pendingProxyCompletions?: Map + /** + * stdout lines the child emitted while no turn had a line listener + * attached (between opencode turns). Bounded; see `bufferUnattendedLine`. + * Absent on the interactive shim, which has no unattended window. + */ + unattendedLines?: string[] + /** Lines evicted from `unattendedLines` because the cap was hit. */ + unattendedDropped?: number + /** + * opencode session this process last served, tagged by doStream each turn. + * `/btw` runs from a command hook that only knows the session id, so this is + * how it finds the process to ask (see `findActiveProcessBySessionId`). + */ + opencodeSessionID?: string + /** What the /btw command hook needs to send a side question to this process early. */ + asideTransport?: { cliPath: string; interactive: boolean } + /** + * True from a stdin write that asks the CLI for work until its terminal + * `result` line, whether or not a turn is still listening. Set by + * `noteTurnStarted`, cleared by `noteTurnLine` (see `interruptTurn`). + */ + turnInFlight?: boolean + turnIdleWaiters?: Array<() => void> + /** + * Tail of what the child last wrote to stderr, capped at + * `STDERR_RETAIN_BYTES` with the newest bytes kept. Often the only record + * of why a child died when it closed without emitting a terminal `result` + * line; see `describeChildCrash`. + */ + lastStderr?: string } -// Keyed by cwd - one active process per working directory -const activeProcesses = new Map() +/** Most recently used process serving an opencode session id, if any. */ +export function findActiveProcessBySessionId(sessionID: string): ActiveProcess | undefined { + let found: ActiveProcess | undefined + // Map order is LRU (see `touch`), so the last match is the freshest. + for (const ap of activeProcesses.values()) { + if (ap.opencodeSessionID === sessionID) found = ap + } + return found +} + +// A child normally only speaks while a doStream turn is listening. The one +// exception is a turn that ended on the CLI's side while opencode was still +// waiting on a proxy call (Claude's MCP client gave up on the request and +// the model carried on alone). Keep what it said so the next turn can show +// it instead of losing it; cap it so a runaway child cannot grow the heap. +const UNATTENDED_LINE_CAP = 500 +const UNATTENDED_BYTE_CAP = 2 * 1024 * 1024 + +export function bufferUnattendedLine(ap: ActiveProcess, line: string): void { + const lines = (ap.unattendedLines ??= []) + lines.push(line) + let bytes = 0 + for (const kept of lines) bytes += Buffer.byteLength(kept) + while ( + lines.length > 0 && + (lines.length > UNATTENDED_LINE_CAP || bytes > UNATTENDED_BYTE_CAP) + ) { + bytes -= Buffer.byteLength(lines.shift()!) + ap.unattendedDropped = (ap.unattendedDropped ?? 0) + 1 + } +} -// Map cwd -> Claude CLI session ID for session reuse +/** Hand over and clear everything the child said while nobody listened. */ +export function takeUnattendedLines(ap: ActiveProcess): { + lines: string[] + dropped: number +} { + const lines = ap.unattendedLines ?? [] + const dropped = ap.unattendedDropped ?? 0 + ap.unattendedLines = [] + ap.unattendedDropped = 0 + return { lines, dropped } +} + +// The CLI writes its own diagnostics to stderr, which is where the reason a +// child died is usually the only thing on record. Keep the tail so a turn +// that ends with the child gone can say why; bounded, newest bytes win. +const STDERR_RETAIN_BYTES = 2 * 1024 + +export function retainStderr(ap: ActiveProcess, chunk: string): void { + ap.lastStderr = ((ap.lastStderr ?? "") + chunk).slice(-STDERR_RETAIN_BYTES) +} + +/** + * One line (plus the stderr tail) explaining a child that closed its stdio + * without emitting a terminal `result`. Before this the turn simply finished + * with reason `stop` and empty usage, so a crashed CLI read as a short but + * successful answer. + */ +export function describeChildCrash( + exitCode: number | null | undefined, + signal: NodeJS.Signals | null | undefined, + lastStderr: string | undefined, +): string { + const how = signal + ? `was killed by ${signal}` + : typeof exitCode === "number" + ? `exited with code ${exitCode}` + : "closed its output" + const tail = lastStderr?.trim() + return ( + `The Claude Code CLI ${how} before finishing this turn (no result was emitted), ` + + "so the answer above may be incomplete." + + (tail ? `\n\nLast stderr from the CLI:\n${tail}` : "") + ) +} + +// One active CLI process per session key. Keyed by a composite +// (cwd + model + opencode session-affinity) so two chats don't race. +// Iteration order is insertion order, which we refresh on access to +// make this a poor-man's LRU; see `touch()` below. +const activeProcesses = new Map() const claudeSessions = new Map() +// Idle-eviction timers keyed like `activeProcesses` (idle timeout by +// @bernardofortes, absorbed from a5f723a). +const idleEvictionTimers = new Map>() +const MAX_IDLE_TIMEOUT_MS = 2_147_483_647 + +/** + * Idle eviction is off unless `idleProcessTimeoutMs` is set: an unset option + * resolves to 0, which arms no timer, so workers live until LRU eviction as + * they always have. PR #36 (@broskees) proposed 30 minutes by default; that + * was reverted at merge because it changes when a resumed chat pays for a + * fresh `--resume` spawn, which is the user's call. An idle `claude --print` + * holds roughly 250 MB resident, so setting it is worth documenting, not + * imposing. The Claude session id survives eviction either way. + */ +export const DEFAULT_IDLE_PROCESS_TIMEOUT_MS = 0 + +/** The idle timeout a caller-facing option resolves to: unset means the default. */ +export function resolveIdleProcessTimeoutMs(configured: number | undefined): number { + return configured === undefined ? DEFAULT_IDLE_PROCESS_TIMEOUT_MS : configured +} + +// Cap on live CLI subprocesses. Session-affinity-keyed entries accumulate +// one-per-chat, so an unbounded map would leak processes as users open new +// chats. This caps at a reasonable working-set and evicts the oldest idle +// one, never a process that is mid-turn. Kept at 16: PR #36 proposed 8 on +// the assumption that a default idle timer does the real work, and that +// default was not adopted, so the cap is still the only bound. +export const MAX_ACTIVE_PROCESSES = 16 +const PROCESS_EXIT_TIMEOUT_MS = 1_500 +const PROCESS_FORCE_EXIT_TIMEOUT_MS = 500 +/** Same wording the attached turn's close handler uses, so one log line + * shape covers a child that died mid-turn and one that died between turns. */ +export const CHILD_EXITED_MESSAGE = + "Claude CLI subprocess closed before pending tool calls were resolved" + +function envFlagEnabled(value: string | undefined): boolean { + if (value === undefined) return false + const normalized = value.trim().toLowerCase() + if (!normalized) return false + return !["0", "false", "no", "off"].includes(normalized) +} + +export function isClaudeThinkingDisabled(): boolean { + return ( + envFlagEnabled(process.env.CLAUDE_CODE_DISABLE_THINKING) || + envFlagEnabled(process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING) + ) +} + +/** + * The CLI's effort vocabulary is low | medium | high | xhigh | max. `minimal` + * is this provider's own lowest step with no CLI counterpart, so it lands on + * `low`. + */ +export function cliEffortLevel(effort: ReasoningEffort): string { + return effort === "minimal" ? "low" : effort +} + +export function claudeSpawnEnv(opts?: { + ignoreAnthropicApiKey?: boolean + /** Reasoning effort for this spawn; wins over a shell-level override. */ + effort?: ReasoningEffort +}): Record { + const env: Record = { + ...process.env, + TERM: "xterm-256color", + // Pin the child to the binary whose version we detected, and keep it off + // non-essential network calls. Fills gaps only, so an explicit shell value + // survives: see `cliHygieneEnv` for why the version has to hold still. + ...cliHygieneEnv(), + } + + // Effort travels as CLAUDE_CODE_EFFORT_LEVEL, which the CLI treats as the + // session-wide override (it beats settings.json and `/effort`). An env var + // rather than `--effort` because a CLI too old to know it ignores it + // instead of refusing to start. Unlike the thinking vars below, an explicit + // effort from the request wins over the shell: the variant picker and an + // agent's `reasoningEffort` are per-request choices, a shell export is not. + if (opts?.effort) { + env.CLAUDE_CODE_EFFORT_LEVEL = cliEffortLevel(opts.effort) + } + + // Force subscription auth: with an API key in the env, Claude Code bills + // pay-as-you-go (Console) instead of the logged-in plan, bypassing the + // Agent SDK credit. Opt-in via `ignoreAnthropicApiKey`. + if (opts?.ignoreAnthropicApiKey) { + delete env.ANTHROPIC_API_KEY + delete env.ANTHROPIC_AUTH_TOKEN + } + + // Default-on thinking summaries for opus-4-7 (which omits thinking by + // default on the CLI side). Any var the user has explicitly set in their + // shell is passed through untouched; the plugin only fills in the default. + if ( + !isClaudeThinkingDisabled() && + process.env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES === undefined + ) { + env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES = "1" + } + + return env +} + +function touch(key: string): void { + const existing = activeProcesses.get(key) + if (existing) { + activeProcesses.delete(key) + activeProcesses.set(key, existing) + } +} + +/** + * Make room for a new child, but never by killing one that is mid-turn. + * Insertion order is LRU, so the first idle entry is the oldest safe victim. + * Evicting an in-flight process truncates that turn silently: its readline + * closes, the close handler finishes the stream, and the operator sees a + * half-written answer with no error. When every process is busy we exceed the + * cap for now rather than kill live work; the next spawn tries again. + */ +export function evictIfNeeded(): void { + while (activeProcesses.size >= MAX_ACTIVE_PROCESSES) { + let victimKey: string | undefined + for (const [key, ap] of activeProcesses) { + if (!isTurnInFlight(ap)) { + victimKey = key + break + } + } + if (!victimKey) { + log.warn("every claude process is mid-turn; skipping LRU eviction", { + active: activeProcesses.size, + cap: MAX_ACTIVE_PROCESSES, + }) + return + } + log.info("evicting LRU claude process", { sessionKey: victimKey }) + deleteActiveProcess(victimKey) + } +} + +// Turn lifecycle and interrupt (from @broskees' 68ed142, adapted). +// +// The Claude CLI runs one turn per process. Closing the opencode-side stream +// tells it nothing: before this, an abort only detached our listeners and the +// CLI ran the abandoned turn to completion (Joseph Roberts measured ~7,500 +// extra characters generated after abort on a haiku probe), kept billing, kept +// running tools, and its late output landed in whatever turn came next, whose +// own stream was then closed early by the stale `result`. The CLI answers a +// stream-json `control_request` of subtype `interrupt` by aborting the turn +// and emitting a terminal `result`, normally within milliseconds. + +const TURN_INTERRUPT_TIMEOUT_MS = 5_000 + +/** Cheap pre-filter before JSON.parse, since every CLI stdout line hits this. */ +function isTerminalResultLine(line: string): boolean { + if (!line.includes('"result"')) return false + try { + return (JSON.parse(line) as { type?: string }).type === "result" + } catch { + return false + } +} + +function settleTurn(ap: ActiveProcess): void { + ap.turnInFlight = false + const waiters = ap.turnIdleWaiters ?? [] + ap.turnIdleWaiters = [] + for (const wake of waiters) wake() +} + +/** Call immediately before any stdin write that asks the CLI to do work. */ +export function noteTurnStarted(ap: ActiveProcess): void { + // The interactive transport never reports through `noteTurnLine`, so a flag + // set there would never clear. + if (ap.asideTransport?.interactive) return + ap.turnInFlight = true +} + +/** + * Feed every CLI stdout line here, independent of whichever turn currently + * owns the stream: a `result` that lands after its turn detached (the abort + * case) must still mark the CLI idle rather than leak into the next turn. + */ +export function noteTurnLine(ap: ActiveProcess, line: string): void { + if (!ap.turnInFlight) return + if (isTerminalResultLine(line)) settleTurn(ap) +} + +export function isTurnInFlight(ap: ActiveProcess): boolean { + return ap.turnInFlight === true +} + +/** Resolves true once the CLI is idle, false if it stayed busy past the timeout. */ +export function awaitTurnIdle(ap: ActiveProcess, timeoutMs: number): Promise { + if (!ap.turnInFlight) return Promise.resolve(true) + return new Promise((resolve) => { + const wake = () => { + clearTimeout(timer) + resolve(true) + } + const timer = setTimeout(() => { + const waiters = ap.turnIdleWaiters ?? [] + const at = waiters.indexOf(wake) + if (at >= 0) waiters.splice(at, 1) + resolve(false) + }, timeoutMs) + ;(ap.turnIdleWaiters ??= []).push(wake) + }) +} + +/** Ask the CLI to abandon the in-flight turn, and wait for it to say it did. */ +export function interruptTurn( + ap: ActiveProcess, + timeoutMs = TURN_INTERRUPT_TIMEOUT_MS, +): Promise { + if (!ap.turnInFlight) return Promise.resolve(true) + const stdin = ap.proc.stdin + if (ap.asideTransport?.interactive || !stdin || !stdin.writable) { + // A TUI stdin would type the JSON in as text. Wait the turn out instead. + log.notice("cannot interrupt this transport; waiting for the turn to end") + return awaitTurnIdle(ap, timeoutMs) + } + try { + stdin.write( + JSON.stringify({ + type: "control_request", + request_id: randomUUID(), + request: { subtype: "interrupt" }, + }) + "\n", + ) + } catch (error) { + log.warn("failed to write interrupt control request", { + error: error instanceof Error ? error.message : String(error), + }) + return Promise.resolve(false) + } + return awaitTurnIdle(ap, timeoutMs) +} + +function cancelIdleProcessEviction(key: string): void { + const timer = idleEvictionTimers.get(key) + if (!timer) return + clearTimeout(timer) + idleEvictionTimers.delete(key) +} export function getActiveProcess(key: string): ActiveProcess | undefined { - return activeProcesses.get(key) + const ap = activeProcesses.get(key) + if (ap) { + cancelIdleProcessEviction(key) + touch(key) + } + return ap } export function setActiveProcess(key: string, ap: ActiveProcess): void { + cancelIdleProcessEviction(key) activeProcesses.set(key, ap) } -export function deleteActiveProcess(key: string): void { +/** + * Evict a headless Claude worker after a completed turn has stayed idle. + * Armed by the turn's `completeResult`, so the clock starts when a turn + * finishes, not when the child was spawned. Reusing the worker through + * `getActiveProcess` cancels the timer. The Claude session id is + * intentionally retained so the next turn can continue the same conversation + * via `--resume`. A process found mid-turn when the timer fires (a recovered + * continuation, an auto-continue, a late tool result) is not evicted; the + * timer is re-armed instead, the same rule the LRU cap follows. + */ +export function scheduleIdleProcessEviction( + key: string, + timeoutMs: number | undefined, +): void { + cancelIdleProcessEviction(key) + if ( + typeof timeoutMs !== "number" || + !Number.isFinite(timeoutMs) || + timeoutMs <= 0 || + timeoutMs > MAX_IDLE_TIMEOUT_MS + ) { + return + } + + const scheduledProcess = activeProcesses.get(key) + if (!scheduledProcess) return + + const timer = setTimeout(() => { + idleEvictionTimers.delete(key) + if (activeProcesses.get(key) !== scheduledProcess) return + if (isTurnInFlight(scheduledProcess)) { + log.info("idle timer found a turn in flight; re-arming", { sessionKey: key, timeoutMs }) + scheduleIdleProcessEviction(key, timeoutMs) + return + } + log.info("evicting idle claude process", { sessionKey: key, timeoutMs }) + deleteActiveProcess(key) + }, timeoutMs) + timer.unref() + idleEvictionTimers.set(key, timer) +} + +/** Whether an idle-eviction timer is armed for the key (read-only, for tests). */ +export function isIdleProcessEvictionScheduled(key: string): boolean { + return idleEvictionTimers.has(key) +} + +function detachActiveProcess(key: string): ActiveProcess | undefined { + cancelIdleProcessEviction(key) const ap = activeProcesses.get(key) - if (ap) { - ap.proc.kill() - activeProcesses.delete(key) + if (!ap) return undefined + activeProcesses.delete(key) + if (ap.proxyServer) { + void ap.proxyServer.close() + // The server's close already answered every open HTTP request with an + // error; the broker's entries for them can never be resolved to anyone + // now, and a `task` call has no deadline that would otherwise reap them. + rejectAllPendingProxyCallsForSession(key, new Error(SERVER_CLOSED_MESSAGE)) + } + return ap +} + +/** + * Release everything this plugin holds for one opencode session that was + * deleted: its live `claude` children (any model, effort, or compaction + * spawn), the remembered Claude session ids, and per-session state. Unlike + * idle eviction this is a real deletion, so nothing is kept for a resume. + * The `"default"` affinity is the shared bucket used when no session id is + * known and is deliberately never matched. Returns the released keys. + */ +export function deleteActiveProcessesForSession(sessionID: string): string[] { + if (!sessionID || sessionID === "default") return [] + const released: string[] = [] + for (const [key, ap] of [...activeProcesses]) { + const owned = + ap.opencodeSessionID === sessionID || describeSessionKey(key).session === sessionID + if (!owned) continue + log.info("releasing claude process for deleted session", { sessionKey: key, sessionID }) + void deleteActiveProcessAndWait(key) + released.push(key) } + // Session ids and per-session state can outlive their process (idle + // eviction keeps them for `--resume`); a deleted session never resumes. + for (const key of [...claudeSessions.keys()]) { + if (describeSessionKey(key).session !== sessionID) continue + deleteClaudeSessionId(key) + clearCompression(key) + if (!released.includes(key)) released.push(key) + } + return released +} + +/** + * Synchronous best-effort sweep for host process exit. Node does not kill + * children on exit, so without this a hard opencode shutdown reparents every + * live `claude` to init. Must stay sync: `process.on("exit")` runs no async + * work. Session ids are left alone; the process is going away with them. + */ +export function killAllActiveProcesses(): string[] { + const keys = [...activeProcesses.keys()] + for (const key of keys) deleteActiveProcess(key) + return keys +} + +let processExitCleanupWired = false + +/** + * Arm `killAllActiveProcesses` for host process exit, once per process. The + * plugin entry can run more than once (tests, account expansion), and each + * run must not add another `exit` listener. Returns whether this call armed it. + */ +export function ensureProcessExitCleanup(): boolean { + if (processExitCleanupWired) return false + processExitCleanupWired = true + process.once("exit", () => { + killAllActiveProcesses() + }) + return true +} + +export function deleteActiveProcess(key: string): void { + const ap = detachActiveProcess(key) + ap?.proc.kill() +} + +function hasProcessExited(proc: ChildProcess): boolean { + return proc.exitCode !== null || proc.signalCode !== null +} + +function waitForProcessExit( + proc: ChildProcess, + timeoutMs: number, +): Promise { + if (hasProcessExited(proc)) return Promise.resolve(true) + + return new Promise((resolve) => { + const onExit = () => { + clearTimeout(timer) + resolve(true) + } + const timer = setTimeout(() => { + proc.off("exit", onExit) + resolve(hasProcessExited(proc)) + }, timeoutMs) + proc.once("exit", onExit) + }) +} + +export async function deleteActiveProcessAndWait( + key: string, + options: { + exitTimeoutMs?: number + forceExitTimeoutMs?: number + } = {}, +): Promise { + const ap = detachActiveProcess(key) + if (!ap || hasProcessExited(ap.proc)) return true + + const gracefulExit = waitForProcessExit( + ap.proc, + options.exitTimeoutMs ?? PROCESS_EXIT_TIMEOUT_MS, + ) + ap.proc.kill() + if (await gracefulExit) return true + + const forcedExit = waitForProcessExit( + ap.proc, + options.forceExitTimeoutMs ?? PROCESS_FORCE_EXIT_TIMEOUT_MS, + ) + ap.proc.kill("SIGKILL") + if (await forcedExit) return true + + log.warn("claude process did not exit; starting a fresh session", { + sessionKey: key, + }) + deleteClaudeSessionId(key) + return false } export function getClaudeSessionId(key: string): string | undefined { return claudeSessions.get(key) } +/** + * A Claude session id outlives its process on purpose, so nothing in the + * ordinary lifecycle ever removes one: under a long-lived `opencode serve` + * that hops projects and models this map only grows, and each entry pins a + * todo ledger with it. The cap is the same shape as + * `MAX_COMPRESSION_ENTRIES`, and it is generous because the cost of getting + * it wrong is a conversation that silently restarts. + */ +export const MAX_CLAUDE_SESSION_ENTRIES = 64 + +/** + * A key with a live process, a proxied call still in the air or an unanswered + * plan-mode question is doing work that the id is part of; dropping it would + * strand that work on a session the next turn no longer resumes. Read from + * the map directly rather than through `getActiveProcess`: that one refreshes + * LRU order and cancels idle timers, which a scan must not do. + */ +function claudeSessionIsBusy(key: string): boolean { + if (activeProcesses.has(key)) return true + return getPendingProxyCalls(key).length > 0 || hasExitPlanModeQuestions(key) +} + +/** + * Shed the least recently used idle sessions. When every key is busy this + * evicts nothing and the map runs over the cap for a while, the same rule + * `evictIfNeeded` follows: exceeding a cap briefly is cheaper than cutting a + * conversation that is still running. + */ +function capClaudeSessions(): void { + for (const key of [...claudeSessions.keys()]) { + if (claudeSessions.size <= MAX_CLAUDE_SESSION_ENTRIES) return + if (claudeSessionIsBusy(key)) continue + log.info("claude session cap reached; releasing an idle session", { + sessionKey: key, + size: claudeSessions.size, + cap: MAX_CLAUDE_SESSION_ENTRIES, + }) + // Through the central release so the todo ledger and any pending + // plan-mode question go with it rather than outliving the id. + deleteClaudeSessionId(key) + } +} + export function setClaudeSessionId(key: string, sessionId: string): void { + // Re-inserting moves the key to the back, so the cap sheds the conversation + // that has been quiet longest rather than the one that started first. + claudeSessions.delete(key) claudeSessions.set(key, sessionId) + capClaudeSessions() } export function deleteClaudeSessionId(key: string): void { + clearExitPlanModeQuestions(key) + clearAccountFailoverQuestions(key) + const claudeSessionId = claudeSessions.get(key) + if (claudeSessionId) clearLedger(claudeSessionId) claudeSessions.delete(key) } +export function effortSessionKey(baseKey: string, effort?: ReasoningEffort): string { + return effort ? `${baseKey}::effort=${effort}` : baseKey +} + +/** Retire sibling effort sessions before deciding whether to replay history. */ +export function invalidateOtherEffortSessions( + baseKey: string, + effort?: ReasoningEffort, +): void { + const levels: (ReasoningEffort | undefined)[] = [ + undefined, "minimal", "low", "medium", "high", "xhigh", "max", + ] + const staleKeys = levels + .filter((level) => level !== effort) + .map((level) => effortSessionKey(baseKey, level)) + + // Refuse the transition atomically. Tool results and recovery completions + // still belong to the old process; they must finish at its original effort. + for (const key of staleKeys) { + const active = activeProcesses.get(key) + if ( + getPendingProxyCalls(key).length || + hasExitPlanModeQuestions(key) || + active?.pendingProxyCompletions?.size || + (active && (active.lineEmitter.listenerCount("line") > 0 || isSideQuestionPending(active))) + ) { + throw new Error( + "Cannot change reasoning effort while the previous effort session has pending work. Finish that work at its original effort first.", + ) + } + } + for (const key of staleKeys) { + deleteActiveProcess(key) + deleteClaudeSessionId(key) + clearCompression(key) + } +} + export function spawnClaudeProcess( cliPath: string, cliArgs: string[], cwd: string, sessionKey: string, + proxyServer?: ProxyMcpServer | null, + mcpHash?: string | null, + systemPromptFile?: string, + ignoreAnthropicApiKey?: boolean, + effort?: ReasoningEffort, ): ActiveProcess { - log.info("spawning new claude process", { cliPath, cliArgs, cwd, sessionKey }) + evictIfNeeded() + log.info("spawning new claude process", { + cliPath, + cliArgs, + cwd, + sessionKey, + effort, + }) const proc = spawn(cliPath, cliArgs, { cwd, stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, TERM: "xterm-256color" }, + env: claudeSpawnEnv({ ignoreAnthropicApiKey, effort }), + shell: process.platform === "win32", }) const lineEmitter = new EventEmitter() + const ap: ActiveProcess = { + proc, + lineEmitter, + proxyServer: proxyServer ?? null, + mcpHash, + systemPromptFile, + effort, + startedAt: Date.now(), + cliPath, + cliArgs: [...cliArgs], + unattendedLines: [], + unattendedDropped: 0, + } + const rl = createInterface({ input: proc.stdout! }) rl.on("line", (line: string) => { + if (dispatchSideQuestionResponse(ap, line)) return + noteTurnLine(ap, line) + if (lineEmitter.listenerCount("line") === 0) { + bufferUnattendedLine(ap, line) + return + } lineEmitter.emit("line", line) }) rl.on("close", () => { + settleTurn(ap) lineEmitter.emit("close") }) - - const ap: ActiveProcess = { proc, lineEmitter } + cancelIdleProcessEviction(sessionKey) activeProcesses.set(sessionKey, ap) + // Baseline 'error' listener so Node doesn't throw when the process emits + // an error between stream turns (no per-stream listener attached then). + proc.on("error", (err) => { + log.error("claude process error", { sessionKey, error: err.message }) + }) + + // Same baseline for the child's stdin, which is a separate emitter. Every + // write that asks the CLI for work (fresh envelope, auto-continue, the + // watchdog re-send, the interrupt request) can land after the child died, + // and an unhandled 'error' on a stream throws inside opencode's own + // process. Ending the turn is not this handler's job: the child is gone, + // so its readline 'close' follows and the turn's close handler reports it + // (see `describeChildCrash`). Releasing `turnInFlight` is, since no + // terminal `result` is ever coming for a write that never arrived. + proc.stdin?.on("error", (err: NodeJS.ErrnoException) => { + log.warn("claude process stdin error", { + sessionKey, + code: err.code, + error: err.message, + }) + settleTurn(ap) + }) + proc.on("exit", (code, signal) => { log.info("claude process exited", { code, signal, sessionKey }) - activeProcesses.delete(sessionKey) - if (code !== 0 && code !== null) { + void proxyServer?.close() + if (systemPromptFile) { + void unlink(systemPromptFile).catch(() => {}) + } + const ownsSessionKey = activeProcesses.get(sessionKey) === ap + if (ownsSessionKey) { + cancelIdleProcessEviction(sessionKey) + activeProcesses.delete(sessionKey) + // The child is the only thing that could still consume these calls' + // results. A turn that is attached rejects them from its own close + // handler; this covers a child that dies between turns, which no + // deadline would otherwise reap now that `task` has none. + if (getPendingProxyCalls(sessionKey).length > 0) { + rejectAllPendingProxyCallsForSession( + sessionKey, + new Error(CHILD_EXITED_MESSAGE), + ) + } + } + if (ownsSessionKey && code !== 0 && code !== null) { log.info("process exited with error, clearing session", { code, sessionKey, @@ -84,36 +827,164 @@ export function spawnClaudeProcess( proc.stderr?.on("data", (data: Buffer) => { const stderr = data.toString() log.debug("stderr", { data: stderr.slice(0, 200) }) + retainStderr(ap, stderr) + // "No conversation found with session ID: " is what `--resume` + // prints for a purged transcript — note the lowercase "session ID", + // which the capitalized match below does not catch. if ( - stderr.includes("Session ID") && - (stderr.includes("already in use") || - stderr.includes("not found") || - stderr.includes("invalid")) + stderr.includes("No conversation found") || + (stderr.includes("Session ID") && + (stderr.includes("already in use") || + stderr.includes("not found") || + stderr.includes("invalid"))) ) { - log.warn("claude session ID error, clearing session", { - sessionKey, - error: stderr.slice(0, 200), - }) - claudeSessions.delete(sessionKey) + if (activeProcesses.get(sessionKey) === ap) { + log.warn("claude session ID error, clearing session", { + sessionKey, + error: stderr.slice(0, 200), + }) + claudeSessions.delete(sessionKey) + } else { + log.debug("ignoring session ID error from stale claude process", { + sessionKey, + }) + } } }) return ap } +/** + * Append `--resume ` to an already-built args vector when a Claude + * conversation id is known for the session and the args don't already carry + * a session flag. Used by `respawnActiveProcess` to resume the conversation + * in a fresh child without rebuilding the whole (version-gated) args vector. + * `--resume`, not `--session-id`: the latter means "create a NEW session + * with this UUID" and the CLI rejects it with "Session ID ... is already in + * use" whenever a transcript exists on disk — which is exactly the state a + * mid-conversation respawn is in. If the wedged child died before writing + * any transcript, `--resume` fails with "No conversation found with session + * ID", which the stderr recovery matcher already catches (fresh-session + * fallback). + */ +export function appendResumeIfNeeded( + sessionKey: string, + cliArgs: string[], +): string[] { + if (cliArgs.includes("--resume") || cliArgs.includes("--session-id")) { + return cliArgs + } + const sid = claudeSessions.get(sessionKey) + if (!sid) return cliArgs + return [...cliArgs, "--resume", sid] +} + +/** + * Replace a wedged reused process with a fresh one, resuming the same + * Claude conversation. Used by the doStream start-watchdog when a reused + * process produces no stdout within a grace window after a fresh-turn + * envelope write — observed after a very long proxy-blocked tool call + * (e.g. a multi-minute `task` subagent). Before the per-tool proxy timeout + * fix this was masked because the flat 10-minute ceiling ended the turn + * first; now that the task proxy blocks and returns successfully, resuming + * a reused child after such a long wait can leave it silent on stdout. + * + * Reuses the existing proxy server, system-prompt file, and MCP hash (their + * handles are already baked into `cliArgs`' `--mcp-config`/append-prompt + * paths), so this only swaps the child process. The old child's exit + * handler is silenced before kill so it doesn't close the proxy server we + * are reusing; the new child gets its own exit handler from + * `spawnClaudeProcess`. `claudeSessions` is left intact so the respawn can + * add `--resume` (see `appendResumeIfNeeded`). + * + * The respawn happens in the middle of the same logical turn, and the caller + * re-sends that turn's envelope at once. Turn state lives on the + * `ActiveProcess`, so the replacement inherits the old process's in-flight + * marker (@broskees' b719497); without that handoff abort, LRU eviction, the + * idle timer and the next turn's quiesce all mistake the busy replacement + * for an idle process. + * + * Returns the new `ActiveProcess`, or `undefined` if there was no active + * process for the key (caller should treat that as "nothing to respawn"). + */ +export function respawnActiveProcess( + sessionKey: string, + cliPath: string, + cliArgs: string[], + cwd: string, + ignoreAnthropicApiKey?: boolean, +): ActiveProcess | undefined { + const old = activeProcesses.get(sessionKey) + if (!old) return undefined + const turnWasInFlight = isTurnInFlight(old) + activeProcesses.delete(sessionKey) + // Silence the old exit handler so it doesn't close the proxy server, + // unlink the system-prompt file, or touch claudeSessions on its way out + // — those handles are reused by the new child. spawnClaudeProcess wires + // a fresh exit handler for the respawned child. + old.proc.removeAllListeners("exit") + try { + old.proc.kill() + } catch {} + const replacement = spawnClaudeProcess( + cliPath, + appendResumeIfNeeded(sessionKey, old.cliArgs ?? cliArgs), + cwd, + sessionKey, + old.proxyServer, + old.mcpHash, + old.systemPromptFile, + ignoreAnthropicApiKey, + old.effort, + ) + replacement.pendingProxyCompletions = old.pendingProxyCompletions + delete old.pendingProxyCompletions + if (turnWasInFlight) noteTurnStarted(replacement) + return replacement +} + export function buildCliArgs(opts: { sessionKey: string skipPermissions: boolean includeSessionId?: boolean model?: string + permissionMode?: string + mcpConfig?: string | string[] + strictMcpConfig?: boolean + disallowedTools?: string[] + appendSystemPromptFile?: string + /** `--plugin-dir` values (skill bridge), one flag per directory. */ + pluginDirs?: string[] + thinking?: "enabled" | "disabled" + thinkingDisplay?: "summarized" | "omitted" + fastMode?: boolean + cliVersion?: CliVersion | null }): string[] { - const { sessionKey, skipPermissions, includeSessionId = true, model } = opts + const { + sessionKey, + skipPermissions, + includeSessionId = true, + model, + permissionMode, + mcpConfig, + strictMcpConfig, + disallowedTools, + appendSystemPromptFile, + pluginDirs, + thinking, + thinkingDisplay, + fastMode, + cliVersion, + } = opts const args = [ + "--print", "--output-format", "stream-json", "--input-format", "stream-json", + "--include-partial-messages", "--verbose", ] @@ -121,14 +992,78 @@ export function buildCliArgs(opts: { args.push("--model", model) } + if (permissionMode) { + args.push("--permission-mode", permissionMode) + } + + // `--session-id` means "create a NEW session with this UUID" and the CLI + // exits with "Session ID ... is already in use" whenever a transcript for + // that ID already exists on disk. Continuing an existing session requires + // `--resume` (which keeps the same session ID in print mode). if (includeSessionId) { const sessionId = claudeSessions.get(sessionKey) if (sessionId && !activeProcesses.has(sessionKey)) { - args.push("--session-id", sessionId) + args.push("--resume", sessionId) + } + } + + if (mcpConfig) { + const configs = Array.isArray(mcpConfig) ? mcpConfig : [mcpConfig] + const filtered = configs.filter((c) => typeof c === "string" && c.length > 0) + if (filtered.length > 0) { + args.push("--mcp-config", ...filtered) } } - if (skipPermissions) { + if (strictMcpConfig) { + args.push("--strict-mcp-config") + } + + if (disallowedTools && disallowedTools.length > 0) { + args.push("--disallowedTools", ...disallowedTools) + } + + // `--thinking` is only present from Claude Code 2.x onward; gate so + // pre-2.x binaries don't crash with a parse error. Unknown version → + // skip (the spawn still works, the user just doesn't get extended + // thinking until they upgrade). + if (thinking && cliSupportsThinking(cliVersion ?? null)) { + args.push("--thinking", thinking) + } + + // `--thinking-display` was added in Claude Code 2.1.142. Older CLIs + // reject it with a parse error, so gate on detected version. When + // version is unknown (detection failed), be conservative and skip. + if (thinkingDisplay && cliSupportsThinkingDisplay(cliVersion ?? null)) { + args.push("--thinking-display", thinkingDisplay) + } + + if (appendSystemPromptFile) { + args.push("--append-system-prompt-file", appendSystemPromptFile) + } + for (const dir of pluginDirs ?? []) { + args.push("--plugin-dir", dir) + } + + // Fast mode's only headless opt-in. `--settings` feeds the CLI's + // `flagSettings` layer, which is the one its SDK gate checks; a `fastMode` + // in the user's own settings.json is NOT enough for a `--print` run. + // Built as one object so later flag-settings keys merge here instead of + // adding a second `--settings` (the CLI takes the flag once). + if (fastMode && cliSupportsFastMode(cliVersion ?? null)) { + args.push("--settings", JSON.stringify({ fastMode: true })) + } + + // Plan mode is a capability restriction, not a prompt policy, and the CLI + // lets `--dangerously-skip-permissions` override it outright: measured on + // 2.1.258, a plan-mode run carrying both flags wrote a file on request + // without prompting, while the same run without the skip flag refused and + // created nothing. Since `skipPermissions` defaults to true, passing both + // is the common case, so anyone asking for plan mode was silently getting + // full write access. Plan mode must never permit edits, so it wins here. + // Every other `permissionMode` value governs prompting, which is exactly + // what the skip flag is for, so those still pass both. + if (skipPermissions && permissionMode !== "plan") { args.push("--dangerously-skip-permissions") } @@ -142,3 +1077,71 @@ export function buildCliArgs(opts: { export function sessionKey(cwd: string, modelId: string): string { return `${cwd}::${modelId}` } + +/** + * Pull the readable parts back out of a session key for the doctor report. + * The key is `::::::::context=[...]` with an + * optional `::effort=` tail, and the compaction variant is + * `::::compaction::`, so the model and the opencode + * session id sit at the same two positions either way. + */ +export function describeSessionKey(key: string): { + cwd: string + model: string + session: string + compaction: boolean +} { + const parts = key.split("::") + return { + cwd: parts[0] ?? "unknown", + model: parts[1] ?? "unknown", + session: parts[3] ?? "unknown", + compaction: parts[2] === "compaction", + } +} + +/** One live `claude` child, flattened for `/claude-code-doctor`. */ +export interface ActiveProcessSnapshot { + sessionKey: string + session: string + model: string + compaction: boolean + pid?: number + inFlight: boolean + ageMs?: number + effort?: ReasoningEffort + attached: boolean + proxyUrl?: string + /** + * Tail of the child's stderr, when something upstream of this module is + * recording one. Read through an optional property so the doctor works + * whether or not that field exists on the running build. + */ + lastStderr?: string +} + +/** + * Every live child, oldest-used first (the map is LRU). Read-only; nothing + * here touches eviction or the process's own listeners. + */ +export function snapshotActiveProcesses(now = Date.now()): ActiveProcessSnapshot[] { + const out: ActiveProcessSnapshot[] = [] + for (const [key, ap] of activeProcesses) { + const described = describeSessionKey(key) + const lastStderr = (ap as { lastStderr?: unknown }).lastStderr + out.push({ + sessionKey: key, + session: ap.opencodeSessionID ?? described.session, + model: described.model, + compaction: described.compaction, + pid: ap.proc.pid, + inFlight: ap.turnInFlight === true, + ageMs: ap.startedAt === undefined ? undefined : Math.max(0, now - ap.startedAt), + effort: ap.effort, + attached: ap.lineEmitter.listenerCount("line") > 0, + proxyUrl: ap.proxyServer?.url, + lastStderr: typeof lastStderr === "string" ? lastStderr : undefined, + }) + } + return out +} diff --git a/src/side-question.ts b/src/side-question.ts new file mode 100644 index 0000000..4203ade --- /dev/null +++ b/src/side-question.ts @@ -0,0 +1,267 @@ +import { randomUUID } from "node:crypto" +import type { ChildProcess } from "node:child_process" +import { cliSupportsSideQuestion, type CliVersion } from "./cli-version.js" +import type { ActiveProcess } from "./session-manager.js" + +type SideQuestionProcess = Pick + +export interface SideQuestionResult { + response: string + synthetic: boolean +} + +export interface SideQuestionOptions { + cliVersion: CliVersion | null + interactive?: boolean + abortSignal?: AbortSignal + timeoutMs?: number + history?: readonly { question: string; response: string }[] +} + +export interface SideQuestionExchange { + question: string + response: string +} + +const MAX_HISTORY_EXCHANGES = 20 + +export const SIDE_QUESTION_USAGE = + "Usage: /btw . Ask a side question about the current conversation without adding it to the main context." + +const pendingProcesses = new WeakSet() + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +/** + * opencode appends its own `` blocks to the user message, as + * extra text parts on the same message. They instruct a normal turn and are not + * part of what the operator typed after `/btw`, so they must not travel with the + * aside: a plan-mode reminder alone is over 1.5 KB, and measured live it both + * steered the answer and kept a bare `/btw` from ever looking empty. + * + * Blocks are removed wherever they sit rather than by matching a whole part, + * because a harness may append its own trailing metadata after one (opencode-dcp + * adds a `` marker), which an end-anchored check would miss. + */ +const SYSTEM_REMINDER_BLOCK = /[\s\S]*?<\/system-reminder>/g + +export function parseSideQuestionContent(content: unknown): { question: string } | null { + let text: string + if (typeof content === "string") { + text = content + } else if (Array.isArray(content)) { + const parts: string[] = [] + for (const part of content) { + if (!isRecord(part) || part.type !== "text" || typeof part.text !== "string") return null + parts.push(part.text) + } + text = parts.join("\n") + } else { + return null + } + const match = /^\/btw(?:\s+([\s\S]*))?$/.exec(text.replace(SYSTEM_REMINDER_BLOCK, "").trim()) + return match ? { question: (match[1] ?? "").trim() } : null +} + +/** Do not replay a historical /btw during an assistant/tool continuation. */ +export function parseSideQuestion( + prompt: readonly { role: string; content: unknown }[], +): { question: string } | null { + const latest = prompt.at(-1) + return latest?.role === "user" ? parseSideQuestionContent(latest.content) : null +} + +function assistantText(content: unknown): string { + if (typeof content === "string") return content.trim() + if (!Array.isArray(content)) return "" + const parts: string[] = [] + for (const part of content) { + if (isRecord(part) && part.type === "text" && typeof part.text === "string") parts.push(part.text) + } + return parts.join("\n").trim() +} + +/** + * Earlier `/btw` exchanges in this conversation, oldest first, for the + * control request's `history` so follow-ups can refer to previous asides. + * The final user message is the current question and is left out. + */ +export function collectSideQuestionHistory( + prompt: readonly { role: string; content: unknown }[], +): SideQuestionExchange[] { + const history: SideQuestionExchange[] = [] + for (let index = 0; index < prompt.length - 1; index++) { + const message = prompt[index] + if (message.role !== "user") continue + const aside = parseSideQuestionContent(message.content) + if (!aside?.question) continue + const reply = prompt[index + 1] + if (reply.role !== "assistant") continue + const response = assistantText(reply.content) + if (!response || response === SIDE_QUESTION_USAGE) continue + history.push({ question: aside.question, response }) + } + return history.slice(-MAX_HISTORY_EXCHANGES) +} + +export function isSideQuestionPending(activeProcess: SideQuestionProcess): boolean { + return pendingProcesses.has(activeProcess.proc) +} + +/** + * Call before the normal stdout line/buffer dispatch. Only a response with an + * active request-ID listener is consumed. Progress and unrelated lines retain + * their existing routing; the helper never subscribes to the shared `line` event. + */ +export function dispatchSideQuestionResponse( + activeProcess: SideQuestionProcess, + line: string, +): boolean { + if (!pendingProcesses.has(activeProcess.proc)) return false + let message: unknown + try { + message = JSON.parse(line) + } catch { + return false + } + if (!isRecord(message) || message.type !== "control_response") return false + const response = message.response + if (!isRecord(response) || typeof response.request_id !== "string") return false + return activeProcess.lineEmitter.emit(`side-question:${response.request_id}`, response) +} + +/** + * Uses an existing headless process, never a user envelope or a new spawn. + * The process may be mid-turn: Claude Code answers `side_question` on a + * separate advisor call while the main loop keeps running (measured live on + * 2.1.258 with the turn blocked on a held MCP tool). Only one aside per + * process is in flight at a time; responses are matched by request id ahead + * of the normal stdout routing, so a streaming turn never sees them. + */ +export async function requestSideQuestion( + activeProcess: SideQuestionProcess, + question: string, + options: SideQuestionOptions, +): Promise { + question = question.trim() + if (!question) return { response: SIDE_QUESTION_USAGE, synthetic: true } + options.abortSignal?.throwIfAborted() + const { proc, lineEmitter } = activeProcess + if (options.interactive || !proc.stdout) { + throw new Error("/btw requires the headless Claude Code transport; interactive sessions are not supported.") + } + if (!cliSupportsSideQuestion(options.cliVersion)) { + throw new Error("/btw requires Claude Code CLI 2.1.258 or newer (the oldest verified version).") + } + if (pendingProcesses.has(proc)) { + throw new Error("Wait for the current /btw to finish before asking another.") + } + const stdin = proc.stdin + if (proc.killed || proc.exitCode != null || proc.signalCode != null || + !stdin || stdin.destroyed || stdin.writableEnded || !stdin.writable) { + throw new Error("/btw requires a live Claude Code session with writable stdin.") + } + const timeoutMs = options.timeoutMs ?? 120_000 + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) { + throw new Error("/btw timeoutMs must be a positive 32-bit integer.") + } + const requestId = randomUUID() + const request = JSON.stringify({ + type: "control_request", + request_id: requestId, + request: { + subtype: "side_question", + question, + ...(options.history === undefined ? {} : { history: options.history }), + }, + }) + + pendingProcesses.add(proc) + return new Promise((resolve, reject) => { + const event = `side-question:${requestId}` + let settled = false + let sent = false + let cancelPending = false + + const cleanup = (): void => { + clearTimeout(timer) + lineEmitter.off(event, onResponse) + lineEmitter.off("close", onClose) + lineEmitter.off("error", onError) + proc.off("exit", onClose) + proc.off("close", onClose) + proc.off("error", onError) + if (!cancelPending) stdin.off("error", onError) + options.abortSignal?.removeEventListener("abort", onAbort) + pendingProcesses.delete(proc) + } + const fail = (error: unknown, cancel = false): void => { + if (settled) return + settled = true + if (cancel && sent && !stdin.destroyed && !stdin.writableEnded && stdin.writable) { + try { + cancelPending = true + stdin.write( + JSON.stringify({ type: "control_cancel_request", request_id: requestId }) + "\n", + () => { + // A failed write emits `error` after its callback. Keep the pipe + // listener through that event without delaying abort/timeout. + queueMicrotask(() => stdin.off("error", onError)) + }, + ) + } catch { + cancelPending = false + // Preserve the original abort/timeout even if the child has gone away. + } + } + cleanup() + reject(error) + } + const onClose = (): void => fail(new Error("Claude Code closed before answering /btw.")) + const onError = (error: Error): void => fail(error) + const onAbort = (): void => fail( + options.abortSignal?.reason ?? new DOMException("/btw was aborted.", "AbortError"), + true, + ) + const onResponse = (response: Record): void => { + if (settled || response.request_id !== requestId) return + if (response.subtype === "error") { + fail(new Error(typeof response.error === "string" ? response.error : "Claude Code rejected /btw.")) + return + } + const result = response.response + if (response.subtype !== "success" || !isRecord(result) || + typeof result.response !== "string" || typeof result.synthetic !== "boolean") { + fail(new Error("Claude Code returned an invalid /btw response.")) + return + } + settled = true + cleanup() + resolve({ response: result.response, synthetic: result.synthetic }) + } + const timer = setTimeout(() => { + fail(new Error(`/btw timed out after ${timeoutMs}ms.`), true) + }, timeoutMs) + + lineEmitter.on(event, onResponse) + lineEmitter.on("close", onClose) + lineEmitter.on("error", onError) + proc.on("exit", onClose) + proc.on("close", onClose) + proc.on("error", onError) + stdin.on("error", onError) + options.abortSignal?.addEventListener("abort", onAbort, { once: true }) + if (options.abortSignal?.aborted) { + onAbort() + return + } + try { + sent = true + stdin.write(request + "\n") + } catch (error) { + fail(error) + } + }) +} diff --git a/src/skill-bridge.ts b/src/skill-bridge.ts new file mode 100644 index 0000000..85c2903 --- /dev/null +++ b/src/skill-bridge.ts @@ -0,0 +1,282 @@ +import * as crypto from "node:crypto" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" +import { fileURLToPath } from "node:url" +import { detectCliSupportsFlag } from "./cli-version.js" +import { log } from "./logger.js" +import { pluginTmpDir } from "./tmp.js" + +/** + * Bridge opencode skills into Claude Code's native Skill tool. + * + * Written by Joseph Roberts (@broskees) on his fork, commit 68ed142, and + * absorbed here with light edits. Opt-in via `bridgeOpencodeSkills`; see + * README for why it is off by default upstream. + * + * opencode and Claude Code use the same on-disk skill format, a + * `/SKILL.md` file whose YAML frontmatter carries `name` and + * `description`, but they read from different roots. opencode looks in + * `~/.config/opencode/skills/` and `.opencode/skills/`; the Claude CLI we + * wrap looks in `~/.claude/skills/` and its own plugins. So opencode's + * skills are invisible to the CLI, while opencode still advertises them in + * the system prompt it forwards. The model reads that list, calls + * `Skill("browser-automation")`, and gets `Unknown skill`. + * + * Fix: assemble a throwaway Claude Code *plugin* directory whose `skills/` + * folder links each discovered opencode skill, and hand it to the CLI with + * `--plugin-dir`. Claude registers them natively as + * `opencode-skills:`, listed by the Skill tool, invocable, and + * usable as `/opencode-skills:`. + * + * `--plugin-dir` is documented as "for this session only", so this never + * writes into the user's `~/.claude`. The staging dir lives under the + * per-process tmp dir and is removed on exit with everything else. + */ + +/** Plugin name, and therefore the `:` prefix Claude assigns. */ +export const SKILL_PLUGIN_NAME = "opencode-skills" + +/** + * Skills shipped inside this package, at `/skills//SKILL.md`. + * Today that is `claude-code-plugin`, the skill that lets a model configure + * this plugin from its own reference instead of the README. It reaches the + * model two ways: `registerBundledSkillPath` adds the directory to opencode's + * `skills.paths` so opencode lists it for every provider, and + * `resolveSkillPluginDirs` always stages it as a `--plugin-dir` (the user's + * own skills stay opt-in) because a Claude-routed turn cannot call opencode's + * `skill` tool and only sees Claude's native Skill tool. + * + * Both `dist/index.js` (built) and `src/skill-bridge.ts` (tsx, tests) sit one + * level below the package root, so the same relative walk finds it. + */ +export function bundledSkillsDir(): string | null { + try { + const here = fileURLToPath(import.meta.url) + const dir = path.resolve(path.dirname(here), "..", "skills") + return dirExists(dir) ? dir : null + } catch { + return null + } +} + +export interface DiscoveredSkill { + name: string + /** Absolute path to the skill directory containing SKILL.md. */ + dir: string +} + +function dirExists(p: string): boolean { + try { + return fs.statSync(p).isDirectory() + } catch { + return false + } +} + +function fileExists(p: string): boolean { + try { + return fs.statSync(p).isFile() + } catch { + return false + } +} + +/** + * Skill roots in opencode's own precedence order: nearest project + * `.opencode/skills` first, then outward, then the home-dir `.opencode`, + * then `OPENCODE_CONFIG_DIR`, then the global `~/.config/opencode`. First + * occurrence of a given skill name wins, so a project can shadow a global + * skill, matching how opencode resolves its own config. + */ +export function skillRoots(cwd: string): string[] { + const roots: string[] = [] + const seen = new Set() + const push = (p: string) => { + const abs = path.resolve(p) + if (seen.has(abs)) return + seen.add(abs) + if (dirExists(abs)) roots.push(abs) + } + + let current = path.resolve(cwd) + while (true) { + push(path.join(current, ".opencode", "skills")) + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + + const home = os.homedir() + if (home) push(path.join(home, ".opencode", "skills")) + + const envDir = process.env.OPENCODE_CONFIG_DIR + if (envDir) push(path.join(envDir, "skills")) + + const xdg = process.env.XDG_CONFIG_HOME ?? (home ? path.join(home, ".config") : null) + if (xdg) push(path.join(xdg, "opencode", "skills")) + + return roots +} + +/** + * Walk the skill roots and collect every `/SKILL.md`. Directories + * without a SKILL.md are skipped silently, opencode ignores them too. + */ +function collectSkills(root: string, claimed: Set, found: DiscoveredSkill[]): void { + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(root, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + // `withFileTypes` reports a symlinked dir as a link, not a dir. + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue + const name = entry.name + if (name.startsWith(".")) continue + if (claimed.has(name)) continue + const dir = path.join(root, name) + if (!fileExists(path.join(dir, "SKILL.md"))) continue + claimed.add(name) + found.push({ name, dir }) + } +} + +const byName = (a: DiscoveredSkill, b: DiscoveredSkill) => a.name.localeCompare(b.name) + +export function discoverOpencodeSkills(cwd: string): DiscoveredSkill[] { + const found: DiscoveredSkill[] = [] + const claimed = new Set() + for (const root of skillRoots(cwd)) collectSkills(root, claimed, found) + return found.sort(byName) +} + +/** The skills this package ships (see `bundledSkillsDir`). */ +export function discoverBundledSkills(): DiscoveredSkill[] { + const root = bundledSkillsDir() + if (!root) return [] + const found: DiscoveredSkill[] = [] + collectSkills(root, new Set(), found) + return found.sort(byName) +} + +/** + * Add the bundled skills directory to opencode's `skills.paths` (scanned for + * nested SKILL.md files) so opencode itself lists the + * skill for every provider and its own `skill` tool can load it. Idempotent; + * returns whether anything was added. + */ +export function registerBundledSkillPath(config: { + skills?: { paths?: string[]; urls?: string[] } +}): boolean { + const dir = bundledSkillsDir() + if (!dir) return false + config.skills ??= {} + const paths = (config.skills.paths ??= []) + if (paths.some((p) => path.resolve(p) === dir)) return false + paths.push(dir) + return true +} + +/** Link a skill dir into the staging tree, falling back to a copy. */ +function linkSkill(source: string, target: string): void { + try { + // Windows needs an explicit junction for directory links, and even then + // only with the right privileges, hence the copy fallback below. + fs.symlinkSync(source, target, process.platform === "win32" ? "junction" : "dir") + return + } catch { + fs.cpSync(source, target, { recursive: true, dereference: true }) + } +} + +/** + * Materialise the synthetic plugin directory. Returns its path, or null if + * there are no skills to bridge. The path is keyed by a hash of the + * resolved skill set, so an unchanged set reuses the existing tree instead + * of rebuilding it on every spawn. + */ +export function buildSkillPluginDir(skills: DiscoveredSkill[]): string | null { + if (skills.length === 0) return null + + const fingerprint = skills.map((s) => `${s.name}\0${s.dir}`).join("\n") + const hash = crypto.createHash("sha256").update(fingerprint).digest("hex").slice(0, 12) + const root = path.join(pluginTmpDir(), `skills-${hash}`) + const manifest = path.join(root, ".claude-plugin", "plugin.json") + + // Same skill set as a previous spawn in this process, reuse the tree. + if (fileExists(manifest)) return root + + try { + fs.rmSync(root, { recursive: true, force: true }) + fs.mkdirSync(path.join(root, ".claude-plugin"), { recursive: true }) + fs.mkdirSync(path.join(root, "skills"), { recursive: true }) + fs.writeFileSync( + manifest, + JSON.stringify( + { + name: SKILL_PLUGIN_NAME, + description: + "Skills discovered from this opencode installation, bridged into Claude Code.", + }, + null, + 2, + ), + { encoding: "utf8", mode: 0o600 }, + ) + for (const skill of skills) { + linkSkill(skill.dir, path.join(root, "skills", skill.name)) + } + } catch (err) { + log.warn("failed to stage opencode skill plugin dir", { + root, + error: err instanceof Error ? err.message : String(err), + }) + return null + } + + return root +} + +/** + * One-call entry point for the spawn sites: discover, stage, and return the + * `--plugin-dir` values. The package's own skills are always staged; the + * user's opencode skills only when `enabled` (`bridgeOpencodeSkills`). A user + * skill with the same name as a bundled one wins, so it can be overridden. + * Returns an empty array when the CLI is too old to accept the flag or there + * is nothing to stage, so callers can spread the result unconditionally. + */ +export async function resolveSkillPluginDirs(opts: { + cwd: string + cliPath: string + enabled: boolean +}): Promise { + const user = opts.enabled ? discoverOpencodeSkills(opts.cwd) : [] + const claimed = new Set(user.map((s) => s.name)) + const bundled = discoverBundledSkills().filter((s) => !claimed.has(s.name)) + const skills = [...user, ...bundled].sort(byName) + if (skills.length === 0) return [] + + // No published version marks `--plugin-dir`'s arrival, so probe the + // binary's own help text rather than inventing a semver threshold. + const supported = await detectCliSupportsFlag(opts.cliPath, "--plugin-dir") + if (!supported) { + log.notice( + "claude cli does not support --plugin-dir; opencode skills will not be bridged. Run `npm i -g @anthropic-ai/claude-code` to upgrade.", + { skills: skills.length }, + ) + return [] + } + + const dir = buildSkillPluginDir(skills) + if (!dir) return [] + + log.info("bridged opencode skills into claude", { + count: skills.length, + names: skills.map((s) => s.name), + bundled: bundled.map((s) => s.name), + pluginDir: dir, + }) + return [dir] +} diff --git a/src/startup-diagnostics.ts b/src/startup-diagnostics.ts new file mode 100644 index 0000000..702302b --- /dev/null +++ b/src/startup-diagnostics.ts @@ -0,0 +1,261 @@ +import { execFile } from "node:child_process" +import * as fs from "node:fs" +import * as path from "node:path" +import { promisify } from "node:util" +import { fileURLToPath } from "node:url" + +import { detectCliVersion } from "./cli-version.js" +import { log } from "./logger.js" +import { mergeOpencodeMcp } from "./mcp-bridge.js" +import { getOpencodeProjectDirectory, isUsableDirectory } from "./runtime-status.js" + +/** + * One compact status block logged once per process, right after providers are + * registered. Every field here answers a question that previously cost a live + * debugging session: which plugin build is loaded, whether the Claude CLI is + * even reachable, which cwd the spawn will use and why, what is proxied, and + * how many MCP servers the bridge sees. Keep it cheap and never let it throw: + * diagnostics must not be able to break provider registration. + */ +export interface StartupDiagnostics { + plugin: string + opencode: string + claudeCli: { path: string; version: string } + cwd: { resolved: string; source: CwdSource } + providers: string[] + accounts: string[] + proxyTools: string[] + mcpServers: string[] + interactiveTransport: boolean + /** ExitPlanMode approval routed through opencode's `question` tool. */ + planModeQuestion: boolean + anthropicApiKeyInEnv: boolean +} + +/** Which branch of `resolveSpawnCwd` a Claude CLI spawn would take right now. */ +export type CwdSource = "configured" | "process" | "captured" | "unresolved" + +export interface DiagnosticsProviderEntry { + name?: string + options?: Record +} + +let cachedPluginVersion: string | undefined + +/** Version of this plugin, read from the package manifest one level up. */ +export function pluginVersion(): string { + if (cachedPluginVersion) return cachedPluginVersion + try { + const here = path.dirname(fileURLToPath(import.meta.url)) + const raw = fs.readFileSync(path.join(here, "..", "package.json"), "utf8") + const version = (JSON.parse(raw) as { version?: unknown }).version + cachedPluginVersion = typeof version === "string" ? version : "unknown" + } catch { + cachedPluginVersion = "unknown" + } + return cachedPluginVersion +} + +/** + * Best-effort opencode version from the plugin input. Re-verified on opencode + * 1.18.5: nothing on the plugin surface carries it. `PluginInput` has no + * version field, the SDK client's `app` namespace exposes only `log`/`agents`, + * and the server has no `/version` route. So this probes a couple of plausible + * shapes for future opencode releases and otherwise returns undefined, leaving + * the binary probe (`detectOpencodeVersion`) as the fallback. Do not replace it + * with a `client.app.get()` call — that method does not exist. + */ +export function pickOpencodeVersion(input: unknown): string | undefined { + if (!input || typeof input !== "object") return undefined + const app = (input as { app?: unknown }).app + if (app && typeof app === "object") { + const version = (app as { version?: unknown }).version + if (typeof version === "string" && version.length > 0) return version + } + const direct = (input as { version?: unknown }).version + if (typeof direct === "string" && direct.length > 0) return direct + return undefined +} + +const execFileAsync = promisify(execFile) + +let opencodeVersionProbe: Promise | undefined + +/** + * The plugin runs *inside* opencode's process, so `process.execPath` is the + * opencode binary itself — asking it for `--version` is the only reliable way + * to name the version, since the plugin API exposes it nowhere (see + * `pickOpencodeVersion`). Guarded on the basename: when opencode is run from + * source (`bun run packages/opencode/src/index.ts`) execPath is the Bun binary, + * and reporting Bun's version as opencode's would be worse than "unknown". + * Cached, 5s timeout, never throws. + */ +export function detectOpencodeVersion( + execPath: string = process.execPath, +): Promise { + if (opencodeVersionProbe) return opencodeVersionProbe + opencodeVersionProbe = (async (): Promise => { + if (!path.basename(execPath).toLowerCase().includes("opencode")) { + log.debug("skipping opencode version probe: execPath is not opencode", { execPath }) + return undefined + } + try { + const { stdout } = await execFileAsync(execPath, ["--version"], { timeout: 5000 }) + const match = /\d+\.\d+\.\d+\S*/.exec(stdout.trim()) + return match ? match[0] : undefined + } catch (err) { + log.debug("opencode version probe failed", { + execPath, + error: err instanceof Error ? err.message : String(err), + }) + return undefined + } + })() + return opencodeVersionProbe +} + +/** Test seam: drop the cached probe so a fresh execPath is honored. */ +export function resetOpencodeVersionProbe(): void { + opencodeVersionProbe = undefined +} + +/** + * Mirror of `resolveSpawnCwd`'s priority order, but reporting *which* branch + * won. `configured` means `options.cwd` pinned it, `process` is the normal + * lazy path, `captured` means `process.cwd()` was unusable (macOS GUI launch + * at `/`) and the captured project directory rescued it — that one is the + * fingerprint of issue #4. + */ +export function describeSpawnCwd( + configured: unknown, + live: string = process.cwd(), + captured: string | undefined = getOpencodeProjectDirectory(), +): { resolved: string; source: CwdSource } { + if (typeof configured === "string" && configured.length > 0) { + return { resolved: configured, source: "configured" } + } + if (isUsableDirectory(live)) return { resolved: live, source: "process" } + if (isUsableDirectory(captured)) return { resolved: captured, source: "captured" } + return { resolved: live, source: "unresolved" } +} + +function stringList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((entry): entry is string => typeof entry === "string") +} + +function firstOption( + providers: Record, + key: string, +): unknown { + for (const entry of Object.values(providers)) { + const value = entry?.options?.[key] + if (value !== undefined) return value + } + return undefined +} + +export function collectStartupDiagnostics( + providers: Record, + opencodeVersion?: string, +): Omit & { claudeCliPath: string } { + const accounts: string[] = [] + for (const entry of Object.values(providers)) { + const account = entry?.options?.account + if (typeof account === "string" && account.length > 0) accounts.push(account) + } + + const cwd = describeSpawnCwd(firstOption(providers, "cwd")) + + let mcpServers: string[] = [] + try { + // Disk-only view: opencode's runtime MCP status isn't settled at plugin + // init (servers are still connecting), so the per-turn overlay is not + // applied here. This is what the bridge would ship on a cold start. + mcpServers = mergeOpencodeMcp(cwd.resolved).enabledServerNames + } catch (err) { + log.debug("startup diagnostics could not read MCP config", { + error: err instanceof Error ? err.message : String(err), + }) + } + + return { + plugin: pluginVersion(), + opencode: opencodeVersion ?? process.env.OPENCODE_VERSION ?? "unknown", + claudeCliPath: String(firstOption(providers, "cliPath") ?? "claude"), + cwd, + providers: Object.keys(providers), + accounts, + proxyTools: stringList(firstOption(providers, "proxyTools")), + mcpServers, + interactiveTransport: + firstOption(providers, "interactive") === true || + process.env.CLAUDE_CODE_INTERACTIVE_TRANSPORT === "1", + planModeQuestion: firstOption(providers, "planModeQuestion") === true, + anthropicApiKeyInEnv: Boolean( + process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN, + ), + } +} + +let logged = false +let lastProviders: Record = {} +let lastOpencodeVersion: string | undefined + +/** + * The providers the config hook last registered, so `/claude-code-doctor` can + * re-run `collectStartupDiagnostics` on demand instead of reporting a snapshot + * frozen at startup. Kept here rather than in the doctor because this is + * already the module that owns the shape. + */ +export function lastDiagnosticsProviders(): Record { + return lastProviders +} + +export function lastKnownOpencodeVersion(): string | undefined { + return lastOpencodeVersion +} + +/** + * Emit the startup block once per process. Fire-and-forget: the Claude CLI + * version probe is async (`claude --version`, 5s timeout, cached), and a slow + * or missing binary must never delay provider registration. + */ +export function logStartupDiagnostics( + providers: Record, + opencodeVersion?: string, +): void { + // Recorded before the once-per-process guard: an account expansion calls + // this a second time with the real provider set, and the doctor should read + // that one rather than the pre-expansion view. + lastProviders = providers + if (opencodeVersion) lastOpencodeVersion = opencodeVersion + if (logged) return + logged = true + void (async () => { + try { + // Probe the binary only when the plugin input and env gave us nothing, + // so a future opencode that reports its version costs no spawn. + const version = + opencodeVersion ?? process.env.OPENCODE_VERSION ?? (await detectOpencodeVersion()) + const { claudeCliPath, ...rest } = collectStartupDiagnostics(providers, version) + const cli = await detectCliVersion(claudeCliPath) + const diagnostics: StartupDiagnostics = { + ...rest, + claudeCli: { path: claudeCliPath, version: cli?.raw ?? "not detected" }, + } + log.notice("claude-code plugin ready", { ...diagnostics }) + } catch (err) { + log.debug("startup diagnostics failed", { + error: err instanceof Error ? err.message : String(err), + }) + } + })() +} + +/** For tests. */ +export function _resetStartupDiagnostics(): void { + logged = false + lastProviders = {} + lastOpencodeVersion = undefined +} diff --git a/src/tmp.ts b/src/tmp.ts new file mode 100644 index 0000000..ec54a92 --- /dev/null +++ b/src/tmp.ts @@ -0,0 +1,35 @@ +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" + +/** + * Per-process scratch directory for plugin tmp files (bridged MCP config, + * proxy server config, etc.). Created lazily on first use and rm'd on + * normal process exit so we don't leak across runs. PID-isolated so two + * concurrent opencode processes don't race on the same files. + * + * Caveat: `process.on("exit")` does not fire for SIGKILL or unhandled + * external signals, so abnormal terminations still leak. OS-level tmpdir + * cleanup (`systemd-tmpfiles`, macOS periodic) handles those eventually. + */ +const PLUGIN_TMP_DIR = path.join( + os.tmpdir(), + `opencode-claude-code-${process.pid}`, +) + +let registered = false + +export function pluginTmpDir(): string { + if (!fs.existsSync(PLUGIN_TMP_DIR)) { + fs.mkdirSync(PLUGIN_TMP_DIR, { recursive: true }) + } + if (!registered) { + registered = true + process.on("exit", () => { + try { + fs.rmSync(PLUGIN_TMP_DIR, { recursive: true, force: true }) + } catch {} + }) + } + return PLUGIN_TMP_DIR +} diff --git a/src/todo-ledger.ts b/src/todo-ledger.ts new file mode 100644 index 0000000..f785e8f --- /dev/null +++ b/src/todo-ledger.ts @@ -0,0 +1,151 @@ +import { log } from "./logger.js" + +export type TodoStatus = "pending" | "in_progress" | "completed" + +export interface TodoEntry { + id: string + content: string + status: TodoStatus +} + +interface PendingCreate { + subject: string + createdAt: number +} + +interface SessionLedger { + todos: Map + pendingCreates: Map +} + +const ledgers = new Map() + +const PENDING_CREATE_TTL_MS = 60_000 +/** + * A ledger is normally released with its Claude session id, but a session + * that is never deleted (a long-lived `opencode serve` hopping projects, a + * CLI session whose id the plugin never sees again) leaves one behind. Same + * insertion-order cap as the compression store; a todo list belonging to a + * session that old is not going to be written to opencode again. + */ +export const MAX_LEDGER_SESSIONS = 64 +const TASK_CREATED_PATTERN = /Task\s*#?\s*(\d+)\s+created/i +const VALID_STATUSES: ReadonlySet = new Set(["pending", "in_progress", "completed"]) + +function getOrCreate(sessionId: string): SessionLedger { + let ledger = ledgers.get(sessionId) + if (!ledger) { + ledger = { todos: new Map(), pendingCreates: new Map() } + ledgers.set(sessionId, ledger) + capLedgers() + } + return ledger +} + +function capLedgers(): void { + while (ledgers.size > MAX_LEDGER_SESSIONS) { + const oldest = ledgers.keys().next() + if (oldest.done) break + ledgers.delete(oldest.value) + log.info("todo ledger evicted oldest session", { sessionId: oldest.value }) + } +} + +function prunePending(ledger: SessionLedger): void { + const cutoff = Date.now() - PENDING_CREATE_TTL_MS + for (const [id, pending] of ledger.pendingCreates) { + if (pending.createdAt < cutoff) ledger.pendingCreates.delete(id) + } +} + +function materialize(ledger: SessionLedger): TodoEntry[] { + return Array.from(ledger.todos.values()) +} + +function resolveSubject(input: { subject?: unknown; description?: unknown } | undefined): string { + const subject = typeof input?.subject === "string" ? input.subject.trim() : "" + if (subject) return subject + const description = typeof input?.description === "string" ? input.description.trim() : "" + if (description) return description + return "(no subject)" +} + +export function applyTaskCreateToolUse( + sessionId: string, + toolUseId: string, + input: { subject?: unknown; description?: unknown } | undefined, +): void { + if (!sessionId || !toolUseId) return + const ledger = getOrCreate(sessionId) + prunePending(ledger) + ledger.pendingCreates.set(toolUseId, { + subject: resolveSubject(input), + createdAt: Date.now(), + }) +} + +export function applyTaskCreateToolResult( + sessionId: string, + toolUseId: string, + resultText: string, +): TodoEntry[] | null { + if (!sessionId || !toolUseId) return null + const ledger = ledgers.get(sessionId) + if (!ledger) return null + const pending = ledger.pendingCreates.get(toolUseId) + if (!pending) return null + ledger.pendingCreates.delete(toolUseId) + const match = typeof resultText === "string" ? resultText.match(TASK_CREATED_PATTERN) : null + if (!match) { + log.debug("TaskCreate result did not match expected format", { sessionId, toolUseId, resultText }) + return null + } + const claudeId = match[1] + if (ledger.todos.has(claudeId)) { + log.debug("TaskCreate result for already-known claude id; overwriting", { sessionId, claudeId }) + } + ledger.todos.set(claudeId, { id: claudeId, content: pending.subject, status: "pending" }) + return materialize(ledger) +} + +export function applyTaskUpdate( + sessionId: string, + input: { taskId?: unknown; subject?: unknown; status?: unknown } | undefined, +): TodoEntry[] | null { + if (!sessionId) return null + const taskId = typeof input?.taskId === "string" ? input.taskId : null + if (!taskId) return null + const ledger = ledgers.get(sessionId) + if (!ledger) return null + const entry = ledger.todos.get(taskId) + if (!entry) { + log.debug("TaskUpdate for unknown task id", { sessionId, taskId }) + return null + } + if (input?.status === "deleted") { + ledger.todos.delete(taskId) + return materialize(ledger) + } + if (typeof input?.status === "string" && VALID_STATUSES.has(input.status as TodoStatus)) { + entry.status = input.status as TodoStatus + } + if (typeof input?.subject === "string" && input.subject.trim().length > 0) { + entry.content = input.subject.trim() + } + return materialize(ledger) +} + +export function clearLedger(sessionId: string): void { + if (!sessionId) return + ledgers.delete(sessionId) +} + +export function getLedger(sessionId: string): TodoEntry[] { + const ledger = ledgers.get(sessionId) + if (!ledger) return [] + return materialize(ledger) +} + +export function _resetAllLedgersForTests(): void { + ledgers.clear() +} diff --git a/src/tool-mapping.ts b/src/tool-mapping.ts index f2a23cb..5b4a03a 100644 --- a/src/tool-mapping.ts +++ b/src/tool-mapping.ts @@ -1,4 +1,27 @@ import { log } from "./logger.js" +import { applyTaskCreateToolUse, applyTaskUpdate, type TodoEntry } from "./todo-ledger.js" +import type { WebSearchRouting } from "./types.js" + +export interface MapToolOptions { + webSearch?: WebSearchRouting + sessionId?: string + toolUseId?: string +} + +/** Claude CLI's built-in web search tool (name varies by CLI version). */ +export function isWebSearchTool(name: string): boolean { + return name === "WebSearch" || name === "web_search" +} + +/** + * True when WebSearch runs inside Claude CLI (default) rather than being + * forwarded to an opencode tool. In that case the tool-call part must not + * reach opencode — "WebSearch" has no registry entry there and renders as + * an invalid tool row. Callers show the query as a text line instead. + */ +export function isWebSearchHandledByCli(route?: WebSearchRouting): boolean { + return !route || route === "claude" || route === "disabled" +} /** * Map Claude CLI tool input (snake_case) to OpenCode tool input (camelCase) @@ -74,7 +97,6 @@ const OPENCODE_HANDLED_TOOLS = new Set([ "Write", "Bash", "NotebookEdit", - "TodoWrite", "Read", "Glob", "Grep", @@ -82,47 +104,133 @@ const OPENCODE_HANDLED_TOOLS = new Set([ // Claude CLI internal tools that should not be forwarded to opencode. // These are part of Claude Code's own system and have no opencode equivalent. +// Tools the Claude CLI emits for its own internal bookkeeping (sub-agents, +// task tracking, search). opencode has no matching tool registry entry, so +// forwarding them surfaces as `⚙ invalid` rows in the UI. Skip them. +// TaskOutput is intentionally NOT here — it has an explicit bash-echo mapping +// below so the result stays visible. const CLAUDE_INTERNAL_TOOLS = new Set([ "ToolSearch", "Agent", "AskFollowupQuestion", + "TaskList", + "TaskGet", + "TaskStop", ]) +/** + * Wrap model-controlled text as one shell single-quoted word. + * + * `TaskOutput` is displayed by running a real `bash` call, so its payload + * reaches a shell. Double quotes are not enough: inside them `$(…)`, + * backticks and `${…}` still expand, so `TaskOutput({content: "X$(id -u)Y"})` + * executed `id` while the operator saw a command that read like a print + * (issue #27). Single quotes suppress every expansion; the only character + * needing care is `'` itself, closed and reopened around an escaped one. + */ +export function singleQuoteForShell(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function emitTodoWrite(todos: TodoEntry[]) { + return { + name: "todowrite", + input: { + todos: todos.map((todo) => ({ + id: todo.id, + content: todo.content, + status: todo.status, + priority: "medium", + })), + }, + executed: false, + } +} + export function mapTool( name: string, input?: any, + opts?: MapToolOptions, ): { name: string; input?: any; executed: boolean; skip?: boolean } { // Claude CLI internal tools — skip entirely if (CLAUDE_INTERNAL_TOOLS.has(name)) { log.debug("skipping Claude CLI internal tool", { name }) return { name, input, executed: true, skip: true } } + + // TaskCreate: stash subject keyed by tool_use_id; emission happens on tool_result. + // Without sessionId+toolUseId we cannot maintain the ledger, so fall back to skip + // (preserves old behavior for callers that haven't been threaded yet). + if (name === "TaskCreate") { + if (opts?.sessionId && opts?.toolUseId) { + applyTaskCreateToolUse(opts.sessionId, opts.toolUseId, input) + } + return { name, input, executed: true, skip: true } + } + + // TaskUpdate: mutate ledger and emit full list as opencode todowrite. Without + // sessionId, fall back to skip. Unknown task ids return null from the ledger + // and we drop the event. + if (name === "TaskUpdate") { + if (opts?.sessionId) { + const list = applyTaskUpdate(opts.sessionId, input) + if (list !== null) return emitTodoWrite(list) + } + return { name, input, executed: true, skip: true } + } + // Plan mode tools if (name === "EnterPlanMode") return { name: "plan_enter", input: {}, executed: false } if (name === "ExitPlanMode") return { name: "plan_exit", input, executed: false } - // WebSearch - if (name === "WebSearch" || name === "web_search") { + // TodoWrite needs opencode to run it locally so Todo.Service (and the UI + // widget backed by it) gets populated. Reporting as provider-executed would + // short-circuit opencode's own execute and leave the todo panel empty. + if (name === "TodoWrite") { + const mappedInput = mapToolInput(name, input) + return { name: "todowrite", input: mappedInput, executed: false } + } + + // WebSearch — routing controlled by config.webSearch + if (isWebSearchTool(name)) { const mappedInput = input?.query ? { query: input.query } : input - log.debug("mapping WebSearch", { originalInput: input, mappedInput }) - return { name: "websearch_web_search_exa", input: mappedInput, executed: false } + const route = opts?.webSearch + if (route && route !== "claude" && route !== "disabled") { + log.debug("routing WebSearch to opencode tool", { target: route, mappedInput }) + return { name: route, input: mappedInput, executed: false } + } + // Claude CLI runs WebSearch internally; "WebSearch" has no opencode + // registry entry, so forwarding the tool-call part surfaces a + // "Model tried to call unavailable tool" invalid row in opencode. + // Skip the part — callers render the query as a text line instead. + log.debug("WebSearch executed by Claude CLI", { mappedInput }) + return { name: "WebSearch", input: mappedInput, executed: true, skip: true } } - // TaskOutput -> bash echo + // TaskOutput -> bash printf if (name === "TaskOutput") { if (!input) return { name: "bash", executed: false } const output = input?.content || input?.output || JSON.stringify(input) return { name: "bash", input: { - command: `echo "TASK OUTPUT: ${String(output).replace(/"/g, '\\"')}"`, + command: `printf '%s\\n' ${singleQuoteForShell(`TASK OUTPUT: ${String(output)}`)}`, description: "Displaying task output", }, executed: false, } } - // MCP tools: mcp____ -> _ + // Third-party MCP tools: mcp____ -> _. + // Marked provider-executed because Claude CLI runs these internally via + // its own --mcp-config; the tool-result is already in the stream. If we + // reported executed:false, opencode would look up the tool in its own + // registry, fail to find it, and emit an `invalid` tool error that + // shadows the real result. + // + // Our own proxy tools (`mcp__opencode_proxy__*`) are filtered out by + // callers before reaching here, so this branch only ever sees user MCP + // servers configured in Claude CLI's settings. if (name.startsWith("mcp__")) { const parts = name.slice(5).split("__") if (parts.length >= 2) { @@ -130,7 +238,7 @@ export function mapTool( const toolName = parts.slice(1).join("_") const openCodeName = `${serverName}_${toolName}` log.debug("mapping MCP tool", { original: name, mapped: openCodeName }) - return { name: openCodeName, input, executed: false } + return { name: openCodeName, input, executed: true } } } diff --git a/src/turn-stats.ts b/src/turn-stats.ts new file mode 100644 index 0000000..925eecc --- /dev/null +++ b/src/turn-stats.ts @@ -0,0 +1,132 @@ +import type { ClaudeStreamMessage } from "./types.js" + +/** + * What a finished Claude CLI turn cost, and how much of its input came out of + * the prompt cache. + * + * The CLI already reports all of it on the terminal `result` line, and until + * now most of it was thrown away: `modelUsage` and `permission_denials` were + * dropped outright, and the rest only reached `providerMetadata`, where + * nothing in opencode's UI shows it. The numbers below are always logged at + * INFO; the one-line footer is opt-in via the `turnStats` provider option, + * because a cost line under every single reply is a preference, not a default. + */ +export interface TurnStats { + costUsd?: number + durationMs?: number + durationApiMs?: number + numTurns?: number + inputTokens?: number + outputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number + /** Per-model totals, keyed by model id. Present from CLI 2.1.x on. */ + modelUsage?: Record + /** Tool calls the permission layer refused during the turn. */ + permissionDenials?: unknown[] +} + +/** + * Header of the footer block, and the marker `message-builder` strips by when + * a transcript is rebuilt for a fresh Claude process. The footer is the + * plugin's own accounting, never something the model said, so it must not come + * back as model output on a resume. Kept as the first characters of its own + * text part so the strip is exact. + */ +export const TURN_STATS_MARKER = "▌ **stats:**" + +/** + * Usage here is the turn total, not the last iteration `toUsage` prefers. + * Those two answer different questions: `toUsage` feeds opencode's context + * gauge, where summing every tool-use iteration would inflate the window and + * trigger premature compaction, while a cost footer has to match the cost the + * CLI reports, and that cost is cumulative over the whole turn. + */ +export function extractTurnStats(msg: ClaudeStreamMessage): TurnStats { + const usage = msg.usage + const stats: TurnStats = {} + if (typeof msg.total_cost_usd === "number") stats.costUsd = msg.total_cost_usd + if (typeof msg.duration_ms === "number") stats.durationMs = msg.duration_ms + if (typeof msg.duration_api_ms === "number") stats.durationApiMs = msg.duration_api_ms + if (typeof msg.num_turns === "number") stats.numTurns = msg.num_turns + if (typeof usage?.input_tokens === "number") stats.inputTokens = usage.input_tokens + if (typeof usage?.output_tokens === "number") stats.outputTokens = usage.output_tokens + if (typeof usage?.cache_read_input_tokens === "number") { + stats.cacheReadTokens = usage.cache_read_input_tokens + } + if (typeof usage?.cache_creation_input_tokens === "number") { + stats.cacheWriteTokens = usage.cache_creation_input_tokens + } + if (msg.modelUsage && typeof msg.modelUsage === "object") stats.modelUsage = msg.modelUsage + if (Array.isArray(msg.permission_denials)) stats.permissionDenials = msg.permission_denials + return stats +} + +/** Dollars, at the precision the number actually carries information at. */ +export function formatCost(costUsd: number): string { + if (!Number.isFinite(costUsd) || costUsd < 0) return "$0.00" + return costUsd >= 1 ? `$${costUsd.toFixed(2)}` : `$${costUsd.toFixed(4)}` +} + +export function formatDuration(durationMs: number): string { + if (!Number.isFinite(durationMs) || durationMs < 0) return "0.0 s" + if (durationMs < 60_000) return `${(durationMs / 1000).toFixed(1)} s` + const totalSeconds = Math.round(durationMs / 1000) + return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s` +} + +export function formatTokens(tokens: number): string { + if (!Number.isFinite(tokens) || tokens < 0) return "0" + if (tokens < 1000) return String(Math.round(tokens)) + if (tokens < 1_000_000) return `${(tokens / 1000).toFixed(1)}k` + return `${(tokens / 1_000_000).toFixed(1)}M` +} + +/** + * One compact line, or null when the CLI reported nothing worth a line. + * + * Zero-valued cache counters are dropped rather than printed as `0`: a turn + * with no cache activity should read as short, not as a row of zeroes. Cost, + * duration and turn count are printed whenever the CLI sent them, including at + * zero, because a genuinely free turn is information. + */ +export function formatTurnStatsLine(stats: TurnStats): string | null { + const parts: string[] = [] + if (stats.costUsd !== undefined) parts.push(formatCost(stats.costUsd)) + if (stats.durationMs !== undefined) parts.push(formatDuration(stats.durationMs)) + if (stats.numTurns !== undefined) { + parts.push(`${stats.numTurns} CLI ${stats.numTurns === 1 ? "turn" : "turns"}`) + } + if (stats.inputTokens !== undefined) parts.push(`in ${formatTokens(stats.inputTokens)}`) + if (stats.outputTokens !== undefined) parts.push(`out ${formatTokens(stats.outputTokens)}`) + if (stats.cacheReadTokens) parts.push(`cache read ${formatTokens(stats.cacheReadTokens)}`) + if (stats.cacheWriteTokens) parts.push(`cache write ${formatTokens(stats.cacheWriteTokens)}`) + if (stats.permissionDenials?.length) { + const count = stats.permissionDenials.length + parts.push(`${count} permission ${count === 1 ? "denial" : "denials"}`) + } + if (parts.length === 0) return null + return `${TURN_STATS_MARKER} ${parts.join(" · ")}` +} + +/** The footer as its own text part: a leading newline keeps it off the reply's last line. */ +export function formatTurnStatsBlock(stats: TurnStats): string | null { + const line = formatTurnStatsLine(stats) + return line === null ? null : `\n${line}\n` +} + +/** Flat payload for the INFO line, which is emitted whether or not the footer is. */ +export function turnStatsLogPayload(stats: TurnStats): Record { + return { + costUsd: stats.costUsd ?? null, + durationMs: stats.durationMs ?? null, + durationApiMs: stats.durationApiMs ?? null, + numTurns: stats.numTurns ?? null, + inputTokens: stats.inputTokens ?? null, + outputTokens: stats.outputTokens ?? null, + cacheReadTokens: stats.cacheReadTokens ?? null, + cacheWriteTokens: stats.cacheWriteTokens ?? null, + modelUsage: stats.modelUsage ?? null, + permissionDenials: stats.permissionDenials?.length ?? 0, + } +} diff --git a/src/types.ts b/src/types.ts index 89ab498..e654a74 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,15 +1,475 @@ +import type { LogLevel, LogMode } from "./logger" + +export type { LogLevel, LogMode } + export interface ClaudeCodeConfig { provider: string cliPath: string + /** Drive interactive claude (subscription) instead of headless --print. */ + interactive?: boolean + /** Deprecated/no-op with interactive: Claude Code's TUI requires manual confirmation for bypassPermissions. */ + interactiveBypass?: boolean + /** With interactive: built-in tools to allow without prompting (replaces + * the default Bash/Edit/Write/Read/WebFetch list; MCP wildcards are always + * derived from the bridged config). */ + interactiveAllowTools?: string[] + /** With interactive: append this plugin's own prompts via --append-system-prompt-file. Defaults to true. */ + interactiveSystemPrompt?: boolean cwd?: string + account?: string + configDir?: string + /** + * Every account the provider expansion produced, so a limited account can + * offer the others. Set by `providerConfig`, not by the user. + */ + failoverAccounts?: string[] + /** + * The CLI path BEFORE the per-account wrapper substitution, so a failover + * can build another account's wrapper on top of the same binary. Set by + * `providerConfig`, not by the user. + */ + baseCliPath?: string + accountFailover?: AccountFailoverMode + providerID?: string skipPermissions?: boolean + permissionMode?: PermissionMode + mcpConfig?: string | string[] + strictMcpConfig?: boolean + bridgeOpencodeMcp?: boolean + controlRequestBehavior?: ControlRequestBehavior + controlRequestToolBehaviors?: Record + controlRequestDenyMessage?: string + proxyTools?: string[] + proxyOpencodeTools?: string[] + stripContextReminders?: boolean + extraDisallowedTools?: string[] + proxyToolTimeoutMs?: Record + /** + * Route `ExitPlanMode` through opencode's native `question` tool so plan + * approval is a real form instead of a "(yes/no)" line the operator has to + * answer in prose. Off by default because it cannot currently fire: headless + * `--print` is not offered an `ExitPlanMode` tool at all, and this bridge + * keys on that tool call. See the plan-mode gotcha in AGENTS.md. + */ + planModeQuestion?: boolean + webSearch?: WebSearchRouting + hotReloadMcp?: boolean + proxyOpencodeMcpTools?: boolean + multiStepContinuation?: boolean + autoContinueIncompleteTurns?: boolean | "smart" + compactionModel?: string + ignoreAnthropicApiKey?: boolean + /** Kill an idle headless Claude worker after this many milliseconds. */ + idleProcessTimeoutMs?: number + /** Stage opencode skills as a `--plugin-dir` so Claude's Skill tool can run them. */ + bridgeOpencodeSkills?: boolean + /** Append a one-line cost / duration / cache footer to each finished turn. */ + turnStats?: boolean + logging?: LoggingConfig +} + +export interface LoggingConfig { + /** + * Persist log activity (DEBUG / INFO / NOTICE / WARN / ERROR — those + * passing `level`) to a file. Default: `false`. When `false`, entries + * below WARN vanish entirely; WARN / ERROR still surface in the TUI via + * stderr. Set to `true` to capture the audit trail to disk for review + * via `tail` / `grep`. + */ + file?: boolean + /** + * Optional custom directory for the file log. Defaults to + * `~/.local/share/opencode-claude-code/`. Has no effect when `file:false`. + */ + dir?: string + /** + * TUI policy. `"silent"` (default) routes DEBUG / INFO / NOTICE to file + * only; WARN / ERROR still bubble in the TUI as they always do. `"debug"` + * additionally echoes every emitted level to stderr (which opencode's TUI + * surfaces as warning bubbles). + */ + mode?: LogMode + /** + * Minimum level to emit anywhere. Anything below the threshold is dropped + * before either destination decides what to do. Order: + * `debug` < `info` < `notice` < `warn` < `error`. Default: `"info"`. + */ + level?: LogLevel } +export type WebSearchRouting = "claude" | "disabled" | (string & {}) + +/** + * What happens when the account a conversation runs on is out of usage. + * `"ask"` (default) shows the operator a form listing the other configured + * accounts and applies the pick inside the same turn; `"off"` keeps today's + * behaviour, where the turn ends with the rate-limit error. + */ +export type AccountFailoverMode = "ask" | "off" + export interface ClaudeCodeProviderSettings { cliPath?: string + /** Drive interactive claude (subscription) instead of headless --print. */ + interactive?: boolean + /** Deprecated/no-op with interactive: Claude Code's TUI requires manual confirmation for bypassPermissions. */ + interactiveBypass?: boolean + /** With interactive: built-in tools to allow without prompting (replaces + * the default Bash/Edit/Write/Read/WebFetch list; MCP wildcards are always + * derived from the bridged config). */ + interactiveAllowTools?: string[] + /** With interactive: append this plugin's own prompts via --append-system-prompt-file. Defaults to true. */ + interactiveSystemPrompt?: boolean cwd?: string name?: string + providerID?: string + account?: string + configDir?: string + accounts?: string[] + /** + * Every account the provider expansion produced. Written by the config + * hook; setting it by hand only limits what a limited account may offer. + */ + failoverAccounts?: string[] + /** The CLI path before the per-account wrapper substitution. */ + baseCliPath?: string + /** + * When this account is out of usage, show the operator a form listing the + * other configured accounts and continue the task on the pick, inside the + * same opencode turn. `"ask"` by default, which only does anything when + * more than one account is configured. `"off"` keeps the plain rate-limit + * error. See README "Account failover". + */ + accountFailover?: AccountFailoverMode + /** + * Model that subagents run on when their own definition pins nothing. + * Unset means no implicit override at all, so an agent keeps inheriting the + * caller's model exactly as opencode intends. See `src/agent-models.ts`. + */ + defaultSubagentModel?: string skipPermissions?: boolean + permissionMode?: PermissionMode + mcpConfig?: string | string[] + strictMcpConfig?: boolean + /** + * Auto-translate opencode's `mcp` config block (from opencode.json/jsonc + * discovered via cwd/OPENCODE_CONFIG/XDG) into a Claude CLI `--mcp-config` + * file and pass it through on spawn. Defaults to `true` so the CLI sees + * the same MCP servers opencode is configured with. + */ + bridgeOpencodeMcp?: boolean + /** + * Behavior for Claude CLI `control_request` permission checks + * (`subtype: can_use_tool`) when `skipPermissions` is false. + * + * - allow: approve tool use requests automatically. + * - deny: reject tool use requests automatically. + * + * Defaults to `allow`. + */ + controlRequestBehavior?: ControlRequestBehavior + + /** + * Optional per-tool overrides for control-request behavior. + * Keys are Claude tool names (eg. `Bash`, `Read`, `mcp__github__list_prs`) and + * values are `allow` or `deny`. + */ + controlRequestToolBehaviors?: Record + + /** + * Custom deny message sent back to Claude CLI when behavior resolves to deny. + */ + controlRequestDenyMessage?: string + + /** + * Proxy these Claude built-in tools through opencode instead of letting the + * CLI execute them directly. When a tool is listed here, the plugin: + * - passes `--disallowedTools ` to the CLI, and + * - exposes an equivalent tool via an in-process HTTP MCP server named + * `opencode_proxy`. Claude calls the MCP tool, which blocks on + * opencode's tool executor (with its native permission UI) and returns + * the result. + * + * Supported: `bash`, `write`, `edit`, `webfetch`, `task`, `question`. Leave empty or unset to disable proxying. + * + * `task` proxies Claude CLI's `Agent` (subagent dispatch) tool through + * opencode's `task` tool, so subagent calls run under opencode's + * configured subagent set (build/general/custom) with opencode's + * permission and lifecycle handling, instead of Claude CLI's + * internal-only general-purpose / Explore / Plan options. The calling + * agent must have `permission.task: allow` for the target subagent + * (see opencode's agent docs). + * + * `question` proxies Claude CLI's `AskUserQuestion` through opencode's + * native `question` tool (TUI form with options + custom answer). The + * calling agent must have `permission.question: allow`. Version-gated: + * silently dropped on opencode builds that lack the `question` registry + * entry, in which case the deny/markdown fallback applies. + */ + proxyTools?: string[] + + /** + * opencode tools to forward through the proxy by name, on top of the + * built-in `proxyTools` defs. Empty by default. + * + * MCP-backed opencode tools are already routed automatically (see + * `proxyOpencodeMcpTools`), but that match is `` or + * `_`, so a tool another opencode plugin declares directly + * belongs to no server and is never offered to Claude. opencode-dcp's + * `compress` is the motivating case: dcp injects "MAX CONTEXT LIMIT + * REACHED ... You MUST use the `compress` tool now" reminders that the + * model could not act on, because the tool was never in its list. + * + * Names are opencode's tool ids as `client.tool.list()` reports them + * (matched case-insensitively): `["compress"]`. An unknown name is + * skipped with a warning. This is an explicit allowlist and never + * automatic: a forwarded tool executes inside opencode with the calling + * agent's permissions. + * + * A name already held by a proxy def is NOT taken over. Listing + * `"compress"` here while `proxyTools` also contains `"Compress"` leaves + * the plugin's own in-process compress in charge and drops the forwarded + * one with a warning, because the two do different things: the plugin's + * resets the Claude Code session, opencode's compresses opencode's + * transcript. Pick one. + */ + proxyOpencodeTools?: string[] + + /** + * Remove `` blocks from message text when no + * `compress` tool is being proxied. Off by default. + * + * opencode-dcp anchors those reminders into messages, so they are re-sent + * with every message that carries one. When compress is not reachable + * they are an instruction the model cannot follow, and the plugin already + * tells it to ignore them in the appended system prompt. Turning this on + * stops paying for them as well. It is inert whenever `compress` is + * proxied (via either `proxyTools` or `proxyOpencodeTools`), since the + * reminder is then something the model can actually act on. + */ + stripContextReminders?: boolean + + /** + * Extra Claude Code built-ins to switch off with `--disallowedTools`, + * on top of the ones implied by `proxyTools`. + * + * `proxyTools` can only disable built-ins the plugin knows how to + * replace, so a built-in with no proxy equivalent (`NotebookEdit`, and + * anything Claude Code adds after this release) has no off switch + * otherwise. Names are Claude's, not opencode's: `["NotebookEdit"]`. + * + * Disabling a tool with no replacement removes the capability rather + * than routing it through opencode — that is the point, but it does mean + * the model has to work without it. + */ + extraDisallowedTools?: string[] + + /** + * Per-tool proxy call timeouts in milliseconds, keyed by the proxy tool + * name (`bash`, `edit`, `write`, `webfetch`, `task`, `question` — + * case-insensitive). When a proxied tool call waits longer than its + * deadline for opencode to resolve it, the call is rejected and Claude + * receives a timeout error. + * + * Defaults (used when a tool is absent here): `bash`/`edit`/`write`/ + * `webfetch` → 10 min (matches Claude CLI's Bash ceiling); `task` and + * `task_batch` → no deadline (the call waits for the subagent; abandoned + * calls are released by aborts, the next user turn, and the process going + * away); `question` → 30 min (operator AFK). A positive value here replaces + * the default for that tool, `0` disables its deadline, and a negative or + * non-finite value is ignored. + * + * For `bash` specifically the call's own `input.timeout` is honoured on + * top: the effective deadline is `max(resolved, input.timeout)`, so a + * long build the caller explicitly asked to run is never undercut, and a + * positive `input.timeout` restores a deadline that `bash: 0` disabled. + */ + proxyToolTimeoutMs?: Record + + /** + * Route Claude's `ExitPlanMode` through opencode's native `question` tool. + * + * Off (default): the plan is rendered as markdown followed by + * `**Do you want to proceed with this plan?** (yes/no)` and the operator + * answers in prose. On: the plan is rendered, the turn ends on + * `tool-calls`, and opencode runs its own `question` tool so approval is a + * real form; the answer is fed back to the CLI as the `tool_result` for + * the original `ExitPlanMode` call, which is what unlocks plan mode. + * + * Opt-in, and currently dormant. The delivery surface works: opencode's + * `question` form renders and round-trips (verified 2026-09-06, correcting + * an earlier claim here that it was broken upstream). What does not work is + * the trigger: headless `--print` does not offer the model an + * `ExitPlanMode` tool, measured on CLI 2.1.258, so the bridge has nothing + * to key on and the text path is what you get. Older opencode builds also + * have no `question` registry entry, in which case the plugin silently + * keeps the text path. Re-run the probes in AGENTS.md on a newer CLI before + * assuming the bridge is reachable. + */ + planModeQuestion?: boolean + + /** + * Strip `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` from the environment of + * every spawned `claude` process. When an API key is present, Claude Code + * authenticates with it (pay-as-you-go Console billing) instead of the + * logged-in Pro/Max subscription — silently bypassing the Agent SDK plan + * credit. Set this to `true` to force the CLI to fall back to its stored + * subscription auth. Defaults to `false` (the key is passed through, so + * deliberate API-key users are unaffected). Regardless of this setting, the + * plugin logs a one-time warning at startup when an API key is detected. + */ + ignoreAnthropicApiKey?: boolean + + /** + * Kill a retained headless Claude worker after this many milliseconds of + * inactivity following a completed turn. Off unless set. The timer + * starts when a turn completes (not at spawn), starting another turn cancels + * it, a worker found mid-turn when it fires is left alone and re-timed, and + * the Claude session id is retained for a transparent resume. Omit or set 0 + * to keep workers until LRU eviction (16 processes). Interactive transport is + * excluded because it does not currently guarantee session-id resume. + */ + idleProcessTimeoutMs?: number + /** + * Expose your opencode skills (`.opencode/skills`, `~/.config/opencode/skills`) + * to Claude Code's native Skill tool by staging them as a session-scoped + * `--plugin-dir`, so a `Skill("")` call for a skill opencode advertises + * does not fail with `Unknown skill`. Off by default: every bridged skill + * is also listed in the system prompt opencode forwards, so a large skill + * set costs prompt tokens twice per turn. When on it applies to the + * headless, interactive and direct `doGenerate` spawns alike; compaction + * never loads it, and the bundled configuration skill is staged either way. + * No-op on CLIs without `--plugin-dir`. + */ + bridgeOpencodeSkills?: boolean + + /** + * Append one compact line to the end of every finished (non-compaction, + * non-error) turn with what that turn cost: dollars, wall duration, how many + * internal CLI turns it took, and input / output / cache-read / cache-write + * tokens. It is rendered as its own text part led by `▌ **stats:**` and is + * stripped again from any transcript rebuilt for the CLI, so the model never + * reads its own accounting. + * + * Off by default, because a cost line under every reply is a preference. + * The same numbers are logged at INFO regardless of this setting, and + * `total_cost_usd`, `duration_ms`, `usage`, `modelUsage` and + * `permission_denials` always reach `providerMetadata`. + */ + turnStats?: boolean + + /** + * Routing for Claude's built-in `WebSearch` tool. + * + * - `"claude"` (default): Claude CLI runs WebSearch internally via + * Anthropic's web search. No MCP setup required, no extra cost. + * - `""` (e.g. `"websearch_web_search_exa"`): forward + * the call to that opencode-side tool with `executed:false`. Requires + * the corresponding MCP server to be configured in opencode. + * - `"disabled"`: prevent the model from calling WebSearch entirely + * (passes `WebSearch` via `--disallowedTools`). + */ + webSearch?: WebSearchRouting + + /** + * Detect mid-session opencode MCP config changes and respawn the + * underlying claude process so newly enabled / disabled MCPs become + * visible to the model without restarting opencode or starting a new + * chat. Eviction happens at the start of the next user turn (never mid + * tool-call) and the session id is preserved for `--resume` so the conversation + * continues seamlessly. Defaults to `true`. + * + * Set to `false` to keep the previous behavior (cached subprocess + * survives MCP changes until the chat is reset). + */ + hotReloadMcp?: boolean + + /** + * Route opencode MCP server tools through the in-process `opencode_proxy` + * MCP server instead of bridging them directly into Claude CLI's + * `--mcp-config`. Routing through the proxy keeps a single execution site + * (opencode), so the call is permission-prompted and rendered as an + * opencode tool call instead of running inside Claude CLI's own MCP child. + * + * Defaults to `false`, and that is a change of default rather than of + * behaviour. It used to default to `true` while routing nothing at all: + * discovery read `client.tool.list()`, which enumerates opencode's tool + * registry (built-ins plus plugin-declared tools) and has never contained + * an MCP tool, so no def was ever built. Discovery now reads the model tool + * set opencode passes the provider, which is where MCP tools actually live, + * so the option works. Leaving it on by default would then have silently + * moved every existing user's MCP traffic off the direct bridge that is + * carrying it today, so switching over is the operator's call. + * + * Two things to know before enabling it: + * + * - It only affects the servers this plugin bridges. If the same server is + * also registered in Claude Code's own config, Claude reaches it directly + * and the proxy is bypassed. Pair this with `strictMcpConfig: true` so + * Claude sees only the config this plugin writes. + * - A routed call executes inside opencode with the calling agent's + * permissions, the same trade `proxyOpencodeTools` makes. + */ + proxyOpencodeMcpTools?: boolean + + /** + * Append a short system-prompt hint that nudges Claude to chain + * multiple tool calls within a single turn instead of pausing for user + * confirmation between subtasks. Each turn boundary in opencode + * requires the user to manually press "continue" to resume, so for + * multi-step tasks this option reduces friction. Defaults to `true`. + * + * Set to `false` if you prefer the un-nudged model behavior (Claude + * decides when to end the turn entirely on its own). + */ + multiStepContinuation?: boolean + + /** + * Smartly continue incomplete Claude CLI results inside the same opencode + * turn. Claude CLI sometimes emits `result` after reasoning/tool activity + * without a useful final answer, which makes opencode stop and wait for the + * user to type "continue". With the default `"smart"`, the plugin detects + * those incomplete result boundaries, feeds Claude a small continuation + * message internally, and keeps the opencode stream open. Final answers, + * questions, blockers, errors, aborts, and safety-budget exhaustion still + * stop normally. + * + * Set to `false` to disable. + */ + autoContinueIncompleteTurns?: boolean | "smart" + + /** + * Model id used when opencode invokes `/compact`. Defaults to + * `claude-haiku-4-5` — fast, cheap, strong structured summarizer. Set + * to override per-project in `opencode.json` / `opencode.jsonc`; the + * `CLAUDE_CODE_COMPACTION_MODEL` env var overrides this in turn for + * one-off runs without editing config. + */ + compactionModel?: string + + /** + * Logger configuration. See `LoggingConfig` for fields. Env vars + * (`OPENCODE_CLAUDE_CODE_LOG_FILE`, `OPENCODE_CLAUDE_CODE_LOG_DIR`, + * `OPENCODE_CLAUDE_CODE_LOG_LEVEL`, `DEBUG=opencode-claude-code`) override + * these values when explicitly set, so a developer can flip behavior for + * one process without editing opencode.jsonc. + */ + logging?: LoggingConfig +} + +export type ReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max" + +export type PermissionMode = + | "acceptEdits" + | "auto" + | "bypassPermissions" + | "default" + | "dontAsk" + | "plan" + +export type ControlRequestBehavior = "allow" | "deny" + +export interface ClaudeCodeCallOptions { + reasoningEffort?: ReasoningEffort } /** @@ -18,6 +478,33 @@ export interface ClaudeCodeProviderSettings { export interface ClaudeStreamMessage { type: string subtype?: string + request_id?: string + + // Fast mode, reported on both `system`/`init` and `result`. `off` with a + // reason is how a request that asked for fast mode but did not get it shows + // up: the CLI degrades to standard speed rather than failing, so without + // reading these the downgrade is invisible. `cooldown` is the post-rate-limit + // state and is temporary. + fast_mode_state?: "on" | "off" | "cooldown" + fast_mode_disabled_reason?: string + + // Present on `stream_event` envelopes when --include-partial-messages is on. + // The inner event mirrors the same shape (content_block_*, message_*, etc). + event?: ClaudeStreamMessage + + request?: { + subtype?: string + tool_name?: string + input?: Record + tool_use_id?: string + permission_suggestions?: unknown[] + blocked_path?: string + decision_reason?: string + title?: string + display_name?: string + agent_id?: string + description?: string + } message?: { role?: string @@ -31,9 +518,28 @@ export interface ClaudeStreamMessage { tool_use_id?: string content?: string | Array<{ type: string; text?: string }> thinking?: string + /** On a `tool_result` block: the CLI-executed tool failed. */ + is_error?: boolean }> } + // `system`/`init` fields. Read by `reportSystemInit` in `cli-events.ts`; + // shapes confirmed against the CLI's own zod schemas on 2.1.263. + apiKeySource?: string + permissionMode?: string + model?: string + claude_code_version?: string + tools?: string[] + mcp_servers?: Array<{ name?: string; status?: string }> + + // `system`/`compact_boundary`. The stream schema emits `compact_metadata`; + // the CLI's own transcript reader uses `compactMetadata`. + compact_metadata?: Record + compactMetadata?: Record + + // `rate_limit_event`. See `RateLimitInfo` in `cli-events.ts`. + rate_limit_info?: Record + tool?: { name?: string id?: string @@ -50,17 +556,41 @@ export interface ClaudeStreamMessage { total_cost_usd?: number duration_ms?: number duration_api_ms?: number - request_id?: string id?: string result?: string is_error?: boolean num_turns?: number + stop_reason?: string | null + + /** + * Per-model totals on `result`, keyed by model id: `inputTokens`, + * `outputTokens`, `cacheReadInputTokens`, `cacheCreationInputTokens`, + * `webSearchRequests`, `costUSD`. All numeric, which is what makes it safe + * to forward whole into `providerMetadata`. + */ + modelUsage?: Record> + /** + * Tool calls the CLI's permission layer refused during the turn. Each entry + * also carries a `tool_input` on the wire; it is deliberately not declared + * here, because it can be a whole file's contents and must not be copied + * into provider metadata. + */ + permission_denials?: Array<{ + tool_name?: string + tool_use_id?: string + }> usage?: { input_tokens?: number output_tokens?: number cache_read_input_tokens?: number cache_creation_input_tokens?: number + iterations?: Array<{ + input_tokens?: number + output_tokens?: number + cache_read_input_tokens?: number + cache_creation_input_tokens?: number + }> } content_block?: { diff --git a/test-account-failover.ts b/test-account-failover.ts new file mode 100644 index 0000000..b0d4637 --- /dev/null +++ b/test-account-failover.ts @@ -0,0 +1,821 @@ +/** + * Account failover: the limit signal, the account-scoped override, the form, + * the answer, and the replay. + * + * The unit half pins the pieces a wrong answer would silently break (the + * detection's NEGATIVE cases above all: a transient error must never move + * where the billing lands). The fake-CLI half drives a real `doStream` twice + * and asserts what only the wiring can show: that the limited turn ends on a + * `question` tool-call rather than an error, and that the answer turn spawns + * the OTHER account with the `@` suffix off the model and the conversation + * replayed. + * + * Usage: npx tsx --test test-account-failover.ts + */ +import assert from "node:assert/strict" +import { after, test } from "node:test" +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { ensureAccountRuntime } from "./src/accounts.js" +import { + ACCOUNT_FAILOVER_TOOL_CALL_PREFIX, + FAILOVER_MARKER, + _resetAccountOverrides, + buildFailoverContinuationPrompt, + clearAccountOverride, + consumeAccountFailoverAnswer, + createAccountFailoverQuestionCall, + failoverCandidates, + failoverUntil, + formatFailoverNote, + isAccountFailoverQuestionActive, + isAccountLimitError, + resolveAccountOverride, + resolveFailoverSpawn, + setAccountOverride, + stripAccountFailoverParts, + stripAccountSuffix, +} from "./src/account-failover.js" +import { _resetRateLimitReports, _resetSystemInitReports } from "./src/cli-events.js" +import { createClaudeCode } from "./src/index.js" +import { filterSideQuestionHistory } from "./src/message-builder.js" +import { setOpencodeClient } from "./src/runtime-status.js" +import { deleteActiveProcess, sessionKey } from "./src/session-manager.js" + +// Every account runtime this file builds lands under a throwaway HOME, so no +// test ever writes a wrapper or a config dir into the real one. +const HOME = mkdtempSync(join(tmpdir(), "opencode-failover-home-")) +const originalHome = process.env.HOME +const originalCache = process.env.XDG_CACHE_HOME +process.env.HOME = HOME +process.env.XDG_CACHE_HOME = join(HOME, "cache") + +after(() => { + if (originalHome === undefined) delete process.env.HOME + else process.env.HOME = originalHome + if (originalCache === undefined) delete process.env.XDG_CACHE_HOME + else process.env.XDG_CACHE_HOME = originalCache + rmSync(HOME, { recursive: true, force: true }) +}) + +// --------------------------------------------------------------------------- +// Detection +// --------------------------------------------------------------------------- + +test("a rejected rate-limit event is an account limit", () => { + assert.equal( + isAccountLimitError({ rateLimit: { status: "rejected", rateLimitType: "five_hour" } }), + true, + ) + assert.equal(isAccountLimitError({ rateLimit: { overageStatus: "rejected" } }), true) +}) + +test("both known limit error texts are recognised", () => { + assert.equal( + isAccountLimitError({ + resultText: + "API Error: 400 Third-party apps now draw from your extra usage balance.", + }), + true, + ) + assert.equal( + isAccountLimitError({ + resultText: "You've hit your individual spend limit. Resets at 2026-09-20T18:00:00Z.", + }), + true, + ) + // Curly apostrophe, which is what a copy-pasted CLI message often carries. + assert.equal( + isAccountLimitError({ resultText: "You’ve hit your individual spend limit." }), + true, + ) +}) + +test("nothing else counts as an account limit", () => { + // The whole point of matching two exact strings: a transient failure that + // opened this form would silently move where the billing lands. + assert.equal( + isAccountLimitError({ rateLimit: { status: "allowed_warning", utilization: 0.9 } }), + false, + ) + assert.equal(isAccountLimitError({ rateLimit: { status: "allowed" } }), false) + assert.equal( + isAccountLimitError({ resultText: "API Error: 400 invalid model name" }), + false, + ) + assert.equal( + isAccountLimitError({ resultText: "fetch failed: ECONNRESET" }), + false, + ) + assert.equal(isAccountLimitError({}), false) + assert.equal(isAccountLimitError({ resultText: "" }), false) +}) + +// --------------------------------------------------------------------------- +// The override store +// --------------------------------------------------------------------------- + +test("an override is account-scoped, expires at the reset time, and can be cleared", () => { + _resetAccountOverrides() + const now = 1_000_000 + setAccountOverride("appical", "default", now + 5_000, now) + + assert.equal(resolveAccountOverride("appical", now), "default") + // Account-scoped, so every other account is untouched. + assert.equal(resolveAccountOverride("default", now), undefined) + // One second past the reset and the conversation goes back on its own. + assert.equal(resolveAccountOverride("appical", now + 5_001), undefined) + // Expiry deletes, so the next read is not a second log line. + assert.equal(resolveAccountOverride("appical", now), undefined) + + setAccountOverride("appical", "default") + // No reset time from the CLI means "until opencode restarts". + assert.equal(resolveAccountOverride("appical", now + 10_000_000), "default") + clearAccountOverride("appical") + assert.equal(resolveAccountOverride("appical", now), undefined) + + // An override onto itself would be a spawn loop, not a failover. + setAccountOverride("appical", "appical") + assert.equal(resolveAccountOverride("appical", now), undefined) + _resetAccountOverrides() +}) + +test("a reset time that is not in the future does not expire the switch at once", () => { + // Found by the fake-CLI test: with `until` behind `now` (clock skew, or a + // stale `resetsAt`), the very next read deleted the override and the turn + // spawned the limited account again and re-hit the same limit. + _resetAccountOverrides() + const now = 1_000_000 + setAccountOverride("appical", "default", now - 1, now) + assert.equal(resolveAccountOverride("appical", now), "default") + assert.equal(resolveAccountOverride("appical", now + 10_000_000), "default") + _resetAccountOverrides() +}) + +test("failoverUntil accepts the CLI's seconds and tolerates milliseconds", () => { + assert.equal(failoverUntil(1_700_000_000), 1_700_000_000_000) + assert.equal(failoverUntil(1_700_000_000_000), 1_700_000_000_000) + assert.equal(failoverUntil(undefined), undefined) +}) + +// --------------------------------------------------------------------------- +// Resolving the spawn +// --------------------------------------------------------------------------- + +test("the model's @account suffix comes off for a failover spawn", () => { + assert.equal(stripAccountSuffix("claude-opus-5@appical"), "claude-opus-5") + assert.equal(stripAccountSuffix("claude-opus-5"), "claude-opus-5") +}) + +test("resolveFailoverSpawn leaves everything alone without an override", async () => { + _resetAccountOverrides() + const spawn = await resolveFailoverSpawn({ + account: "appical", + baseCliPath: "/bin/claude", + cliPath: "/cache/claude-appical", + modelId: "claude-opus-5@appical", + }) + assert.deepEqual(spawn, { + cliPath: "/cache/claude-appical", + modelId: "claude-opus-5@appical", + failedOver: false, + }) +}) + +test("a default target spawns the bare binary, a named target its wrapper", async () => { + _resetAccountOverrides() + setAccountOverride("appical", "default") + const toDefault = await resolveFailoverSpawn({ + account: "appical", + baseCliPath: "/bin/claude", + cliPath: "/cache/claude-appical", + modelId: "claude-opus-5@appical", + }) + // `default` has no config dir at all, so it is the base binary itself. + assert.equal(toDefault.cliPath, "/bin/claude") + assert.equal(toDefault.modelId, "claude-opus-5") + assert.equal(toDefault.target, "default") + assert.equal(toDefault.failedOver, true) + + _resetAccountOverrides() + setAccountOverride("default", "work") + const toNamed = await resolveFailoverSpawn({ + account: "default", + baseCliPath: "/bin/claude", + cliPath: "/bin/claude", + modelId: "claude-opus-5", + }) + assert.equal(toNamed.target, "work") + assert.equal(toNamed.failedOver, true) + assert.match(toNamed.cliPath, /claude-work$/) + assert.equal(existsSync(toNamed.cliPath), true) + _resetAccountOverrides() +}) + +// --------------------------------------------------------------------------- +// The gate and the candidate list +// --------------------------------------------------------------------------- + +test("candidates are every configured account except the limited one", () => { + assert.deepEqual(failoverCandidates(["default", "work", "appical"], "work"), [ + "default", + "appical", + ]) + assert.deepEqual(failoverCandidates(["default"], "default"), []) + assert.deepEqual(failoverCandidates(undefined, "default"), []) + // Normalised and deduped, the same way accounts.ts normalises them. + assert.deepEqual(failoverCandidates(["My Work", "my-work"], "default"), ["my-work"]) +}) + +test("the form is gated on more than one account, a question tool, and the transport", () => { + const base = { + configured: "ask" as const, + candidates: ["work"], + opencodeHasQuestion: true, + compactionMode: false, + interactive: false, + childSession: false, + } + assert.equal(isAccountFailoverQuestionActive(base), true) + // On by default: an unset option behaves as "ask". + assert.equal( + isAccountFailoverQuestionActive({ ...base, configured: undefined }), + true, + ) + assert.equal(isAccountFailoverQuestionActive({ ...base, configured: "off" }), false) + assert.equal(isAccountFailoverQuestionActive({ ...base, candidates: [] }), false) + assert.equal( + isAccountFailoverQuestionActive({ ...base, opencodeHasQuestion: false }), + false, + ) + assert.equal(isAccountFailoverQuestionActive({ ...base, compactionMode: true }), false) + assert.equal(isAccountFailoverQuestionActive({ ...base, interactive: true }), false) + // A subagent follows its parent's account for free. + assert.equal(isAccountFailoverQuestionActive({ ...base, childSession: true }), false) +}) + +// --------------------------------------------------------------------------- +// The form and its answer +// --------------------------------------------------------------------------- + +test("the form offers the other accounts plus stop, and names the reset time", () => { + const call = createAccountFailoverQuestionCall("sk-form", { + sourceAccount: "appical", + candidates: ["default", "work"], + resetsAt: 1_700_000_000, + window: "five_hour", + }) + + assert.equal(call.toolName, "question") + assert.ok(call.toolCallId.startsWith(ACCOUNT_FAILOVER_TOOL_CALL_PREFIX)) + const question = call.input.questions[0] + assert.equal(question.header, "Account limit") + assert.match(question.question, /"appical" is out of usage/) + assert.match(question.question, /five_hour/) + assert.match(question.question, /2023-11-14/) + assert.match(question.question, /Leaving this unanswered waits/) + assert.deepEqual( + question.options.map((option) => option.label), + ["default", "work", "stop"], + ) + // The source account is never one of its own options. + assert.equal( + question.options.some((option) => option.label === "appical"), + false, + ) + // The two costs an operator cannot see from the label alone. + assert.match(question.options[0].description, /replayed as a fresh Claude session/) + assert.match(question.options[0].description, /MCP server configured only in "appical"/) + assert.equal(question.custom, true) + assert.equal(question.multiple, false) +}) + +function answer(toolCallId: string, output: unknown) { + return [ + { + role: "tool", + content: [{ type: "tool-result", toolCallId, toolName: "question", output }], + }, + ] +} + +test("picking an offered account switches to it", () => { + const call = createAccountFailoverQuestionCall("sk-a", { + sourceAccount: "appical", + candidates: ["default", "work"], + resetsAt: 1_700_000_000, + }) + const result = consumeAccountFailoverAnswer( + "sk-a", + answer(call.toolCallId, { type: "text", value: "work" }) as any, + ) + assert.deepEqual(result, { + kind: "switch", + target: "work", + sourceAccount: "appical", + resetsAt: 1_700_000_000, + }) +}) + +test("custom text naming an account switches, and the answer is consumed once", () => { + const call = createAccountFailoverQuestionCall("sk-b", { + sourceAccount: "default", + candidates: ["work"], + }) + const prompt = answer(call.toolCallId, { + type: "text", + // opencode wraps a picked answer in its own sentence; the unwrapper + // handles that, and the name itself is normalised the way accounts are. + value: " Work ", + }) as any + assert.deepEqual(consumeAccountFailoverAnswer("sk-b", prompt), { + kind: "switch", + target: "work", + sourceAccount: "default", + resetsAt: undefined, + }) + // Consumed: a replayed prompt must not switch a second time. + assert.equal(consumeAccountFailoverAnswer("sk-b", prompt), null) +}) + +test("stop, a dismissal and unrecognised text all end the turn", () => { + for (const [label, output] of [ + ["stop", { type: "text", value: "stop" }], + ["dismissal", { type: "execution-denied", reason: "The user dismissed this question" }], + ["unknown text", { type: "text", value: "use my other laptop" }], + ["an account that was not offered", { type: "text", value: "appical" }], + ["an empty answer", { type: "text", value: " " }], + ] as const) { + const call = createAccountFailoverQuestionCall(`sk-${label}`, { + sourceAccount: "default", + candidates: ["work"], + }) + const result = consumeAccountFailoverAnswer( + `sk-${label}`, + answer(call.toolCallId, output) as any, + ) + assert.equal(result?.kind, "stop", `${label} should stop`) + } +}) + +test("a tool-result for another call is not a failover answer", () => { + createAccountFailoverQuestionCall("sk-c", { + sourceAccount: "default", + candidates: ["work"], + }) + assert.equal( + consumeAccountFailoverAnswer( + "sk-c", + answer("toolu_something_else", { type: "text", value: "work" }) as any, + ), + null, + ) +}) + +// --------------------------------------------------------------------------- +// Transcript handling +// --------------------------------------------------------------------------- + +const dialogPrompt = [ + { role: "user", content: [{ type: "text", text: "build the thing" }] }, + { + role: "assistant", + content: [ + { type: "text", text: "Working on it." }, + { + type: "tool-call", + toolCallId: `${ACCOUNT_FAILOVER_TOOL_CALL_PREFIX}abc123`, + toolName: "question", + input: {}, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: `${ACCOUNT_FAILOVER_TOOL_CALL_PREFIX}abc123`, + toolName: "question", + output: { type: "text", value: "work" }, + }, + ], + }, +] as any + +test("the dialog is stripped from a replayed transcript, keeping real content", () => { + const stripped = stripAccountFailoverParts(dialogPrompt) as any[] + assert.equal(stripped.length, 2) + // The assistant's own words survive; only the synthetic call goes. + assert.deepEqual(stripped[1].content, [{ type: "text", text: "Working on it." }]) + // The tool message held nothing but the answer, so it is dropped entirely + // rather than replayed as an empty message. + assert.equal( + stripped.some((message) => message.role === "tool"), + false, + ) +}) + +test("filterSideQuestionHistory drops the dialog and the failover note", () => { + const withNote = [ + ...dialogPrompt, + { + role: "assistant", + content: [{ type: "text", text: `${FAILOVER_MARKER} moved to "work".` }], + }, + ] as any + const filtered = filterSideQuestionHistory(withNote) as any[] + const serialized = JSON.stringify(filtered) + assert.equal(serialized.includes(ACCOUNT_FAILOVER_TOOL_CALL_PREFIX), false) + assert.equal(serialized.includes(FAILOVER_MARKER), false) + assert.match(serialized, /build the thing/) + assert.match(serialized, /Working on it/) +}) + +test("the continuation prompt replaces the dialog with a carry-on instruction", () => { + const built = buildFailoverContinuationPrompt(dialogPrompt, "work") as any[] + const last = built[built.length - 1] + assert.equal(last.role, "user") + const text = last.content[0].text + assert.match(text, /"work" account/) + assert.match(text, /Continue the task from where it stopped/) + assert.match(text, /do not start over/i) + assert.match(text, /Do not mention the account switch/) + assert.equal(JSON.stringify(built).includes(ACCOUNT_FAILOVER_TOOL_CALL_PREFIX), false) +}) + +test("the failover note is a ▌ line naming both accounts", () => { + const note = formatFailoverNote({ + sourceAccount: "appical", + target: "work", + resetsAt: 1_700_000_000, + }) + assert.ok(note.trimStart().startsWith(FAILOVER_MARKER)) + assert.match(note, /"appical" is out of usage/) + assert.match(note, /continues on "work"/) + assert.match(note, /2023-11-14/) +}) + +// --------------------------------------------------------------------------- +// The wiring, through a real doStream and a fake CLI +// --------------------------------------------------------------------------- + +/** + * A fake `claude` that answers differently depending on the account it was + * reached through: the limited one (via its wrapper, so `CLAUDE_CONFIG_DIR` + * is set) rejects, the failover target (the bare binary) answers. Every run + * appends what it saw, which is how the spawn's account and `--model` are + * asserted without reaching into the plugin. + */ +function createFakeCli() { + const cwd = mkdtempSync(join(tmpdir(), "opencode-failover-")) + const cliPath = join(cwd, "fake-claude.cjs") + const record = join(cwd, "spawns.jsonl") + const source = `#!/usr/bin/env node +const fs = require("node:fs") +const readline = require("node:readline") + +if (process.argv.includes("--version")) { + process.stdout.write("2.1.263\\n") + process.exit(0) +} + +const limited = !!process.env.CLAUDE_CONFIG_DIR +const LIMITED_LINES = [ + { type: "system", subtype: "init", session_id: "limited-session", tools: [] }, + { + type: "rate_limit_event", + session_id: "limited-session", + rate_limit_info: { status: "rejected", rateLimitType: "five_hour", resetsAt: 4102444800 }, + }, + { + type: "result", + subtype: "error_during_execution", + session_id: "limited-session", + is_error: true, + result: "You've hit your individual spend limit.", + duration_ms: 10, + num_turns: 1, + }, +] +const FAILOVER_LINES = [ + { type: "system", subtype: "init", session_id: "failover-session", tools: [] }, + { + type: "stream_event", + session_id: "failover-session", + event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "carried on" } }, + }, + { + type: "stream_event", + session_id: "failover-session", + event: { type: "message_delta", delta: { stop_reason: "end_turn" } }, + }, + { + type: "result", + subtype: "success", + session_id: "failover-session", + is_error: false, + result: "carried on", + duration_ms: 10, + num_turns: 1, + usage: { input_tokens: 1, output_tokens: 1 }, + }, +] + +const rl = readline.createInterface({ input: process.stdin }) +let answered = false +rl.on("line", (line) => { + if (answered) return + answered = true + fs.appendFileSync( + ${JSON.stringify(record)}, + JSON.stringify({ + argv: process.argv.slice(2), + configDir: process.env.CLAUDE_CONFIG_DIR || null, + stdin: line, + }) + "\\n", + ) + for (const l of (limited ? LIMITED_LINES : FAILOVER_LINES)) { + process.stdout.write(JSON.stringify(l) + "\\n") + } +}) +` + writeFileSync(cliPath, source) + chmodSync(cliPath, 0o755) + return { + cliPath, + cwd, + spawns: (): any[] => + existsSync(record) + ? readFileSync(record, "utf8") + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) + : [], + } +} + +/** opencode's registry must carry `question`, or the form is not offered. */ +setOpencodeClient({ + tool: { + list: async () => ({ data: [{ id: "question", description: "", parameters: {} }] }), + }, +}) + +const MODEL_ID = "claude-test-failover@appical" + +async function buildFailoverModel(fake: ReturnType) { + // The limited account is reached through its own wrapper, exactly as a real + // account provider reaches it; `default` is the failover target and has no + // wrapper at all. + const runtime = await ensureAccountRuntime("appical", fake.cliPath) + return createClaudeCode({ + cliPath: runtime.cliPath, + baseCliPath: fake.cliPath, + configDir: runtime.configDir, + account: "appical", + failoverAccounts: ["default", "appical"], + cwd: fake.cwd, + bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, + proxyTools: [], + }).languageModel(MODEL_ID) +} + +const TOOLS = [ + { + type: "function", + name: "read", + description: "Read a file", + inputSchema: { type: "object", properties: {} }, + }, +] + +async function drain(response: any): Promise { + const parts: any[] = [] + for await (const part of response.stream) parts.push(part) + return parts +} + +function textOf(parts: any[]): string { + return parts + .filter((part) => part.type === "text-delta") + .map((part) => part.delta) + .join("") +} + +function modelArg(argv: string[]): string | undefined { + const at = argv.indexOf("--model") + return at === -1 ? undefined : argv[at + 1] +} + +const turnOnePrompt = [{ role: "user", content: [{ type: "text", text: "go" }] }] + +test("a usage limit ends the turn on a question listing the other account", async () => { + _resetAccountOverrides() + _resetRateLimitReports() + _resetSystemInitReports() + const fake = createFakeCli() + const sk = sessionKey( + fake.cwd, + `${MODEL_ID}::tools::default::context=["claude-code",null]`, + ) + try { + const model = await buildFailoverModel(fake) + const parts = await drain( + await model.doStream({ prompt: turnOnePrompt, tools: TOOLS } as any), + ) + + const call = parts.find((part) => part.type === "tool-call") + assert.ok(call, "the limited turn must end on a question tool-call") + assert.equal(call.toolName, "question") + assert.ok(call.toolCallId.startsWith(ACCOUNT_FAILOVER_TOOL_CALL_PREFIX)) + const input = JSON.parse(call.input) + assert.deepEqual( + input.questions[0].options.map((option: any) => option.label), + ["default", "stop"], + ) + + // `tool-calls`, not the error finish the same result produces today: + // opencode only runs the tool when the turn ends this way. + const finish = parts.find((part) => part.type === "finish") + assert.equal(finish.finishReason.unified, "tool-calls") + + // The operator still sees why, from the existing rate-limit note. + assert.match(textOf(parts), /▌ \*\*rate limit:\*\*/) + + // The limited account really was the one that ran. + const spawns = fake.spawns() + assert.equal(spawns.length, 1) + assert.match(String(spawns[0].configDir), /\.claude-appical$/) + } finally { + deleteActiveProcess(sk) + _resetAccountOverrides() + rmSync(fake.cwd, { recursive: true, force: true }) + } +}) + +test("answering with the other account continues the task on it, replayed", async () => { + _resetAccountOverrides() + _resetRateLimitReports() + _resetSystemInitReports() + const fake = createFakeCli() + const sk = sessionKey( + fake.cwd, + `${MODEL_ID}::tools::default::context=["claude-code",null]`, + ) + try { + const model = await buildFailoverModel(fake) + const first = await drain( + await model.doStream({ prompt: turnOnePrompt, tools: TOOLS } as any), + ) + const call = first.find((part) => part.type === "tool-call") + assert.ok(call) + + const second = await drain( + await model.doStream({ + prompt: [ + ...turnOnePrompt, + { + role: "assistant", + content: [ + { type: "text", text: "Starting." }, + { + type: "tool-call", + toolCallId: call.toolCallId, + toolName: "question", + input: JSON.parse(call.input), + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: call.toolCallId, + toolName: "question", + output: { type: "text", value: "default" }, + }, + ], + }, + ], + tools: TOOLS, + } as any), + ) + + const spawns = fake.spawns() + assert.equal(spawns.length, 2, "the switch must spawn a second process") + const failoverSpawn = spawns[1] + + // Routed through the OTHER account: `default` has no config dir at all, + // so the failover spawn is the bare binary. + assert.equal(failoverSpawn.configDir, null) + + // The `@account` suffix must not reach a CLI that is not behind the + // account's own wrapper; without the strip this is `...@appical` and the + // CLI rejects the model outright. + assert.equal(modelArg(failoverSpawn.argv), "claude-test-failover") + assert.equal(String(modelArg(failoverSpawn.argv)).includes("@"), false) + + // A transcript cannot resume across accounts, so the thread is replayed. + assert.match(failoverSpawn.stdin, //) + assert.match(failoverSpawn.stdin, /Continue the task from where it stopped/) + // ...and the dialog itself never reaches the fresh session. + assert.equal( + failoverSpawn.stdin.includes(ACCOUNT_FAILOVER_TOOL_CALL_PREFIX), + false, + ) + + const body = textOf(second) + assert.ok( + body.trimStart().startsWith(FAILOVER_MARKER), + "the note must be the first thing in the switched turn", + ) + assert.match(body, /carried on/) + assert.equal( + second.find((part) => part.type === "finish").finishReason.unified, + "stop", + ) + + // Sticky for the limited account until the limit's own reset time, which + // is what makes the pick cover every other session on that account. + assert.equal(resolveAccountOverride("appical"), "default") + assert.equal(resolveAccountOverride("appical", 4_102_444_800_001), undefined) + } finally { + deleteActiveProcess(sk) + _resetAccountOverrides() + rmSync(fake.cwd, { recursive: true, force: true }) + } +}) + +test("answering stop ends the turn as an error and spawns nothing", async () => { + _resetAccountOverrides() + _resetRateLimitReports() + _resetSystemInitReports() + const fake = createFakeCli() + const sk = sessionKey( + fake.cwd, + `${MODEL_ID}::tools::default::context=["claude-code",null]`, + ) + try { + const model = await buildFailoverModel(fake) + const first = await drain( + await model.doStream({ prompt: turnOnePrompt, tools: TOOLS } as any), + ) + const call = first.find((part) => part.type === "tool-call") + assert.ok(call) + + const second = await drain( + await model.doStream({ + prompt: [ + ...turnOnePrompt, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: call.toolCallId, + toolName: "question", + input: JSON.parse(call.input), + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: call.toolCallId, + toolName: "question", + output: { type: "text", value: "stop" }, + }, + ], + }, + ], + tools: TOOLS, + } as any), + ) + + // Exactly the one spawn from the limited turn: declining costs nothing. + assert.equal(fake.spawns().length, 1) + const finish = second.find((part) => part.type === "finish") + assert.equal(finish.finishReason.unified, "error") + assert.ok(second.some((part) => part.type === "error")) + assert.match(textOf(second), /▌ \*\*account failover:\*\*/) + assert.equal(resolveAccountOverride("appical"), undefined) + } finally { + deleteActiveProcess(sk) + _resetAccountOverrides() + rmSync(fake.cwd, { recursive: true, force: true }) + } +}) diff --git a/test-agent-models.ts b/test-agent-models.ts new file mode 100644 index 0000000..1c6203d --- /dev/null +++ b/test-agent-models.ts @@ -0,0 +1,252 @@ +import assert from "node:assert/strict" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { test } from "node:test" +import { + type AgentRecord, + _resetAgentRegistryForTests, + agentDirectories, + getDefaultSubagentModel, + parseAgentFrontmatter, + readAgentMarkdownRecords, + resolveAgentEffort, + resolveAgentModel, + setAgentRegistry, + setDefaultSubagentModel, +} from "./src/agent-models.js" + +const records: Record = { + implementor: { mode: "subagent" }, + designer: { mode: "subagent", forceModel: "claude-haiku-4-5" }, + pinned: { mode: "subagent", model: "claude-code-default/claude-sonnet-5" }, + primary: { mode: "primary" }, + bogus: { mode: "subagent", forceModel: "claude-does-not-exist" }, +} + +const withOpus = { records, defaultSubagentModel: "claude-opus-5" } +const withoutDefault = { records } + +// --- the opt-in default ---------------------------------------------------- + +test("with no defaultSubagentModel nothing is overridden", () => { + // The whole safety property: an existing setup that upgrades the plugin + // must not find its cheap subagents silently running on an expensive model. + assert.equal( + resolveAgentModel("implementor", "claude-fable-5-1", withoutDefault), + "claude-fable-5-1", + ) +}) + +test("a discovered subagent takes the default when one is set", () => { + assert.equal( + resolveAgentModel("implementor", "claude-fable-5-1", withOpus), + "claude-opus-5", + ) +}) + +test("the account marker survives the swap", () => { + // A Fable parent on the appical account must hand its subagent Opus ON + // APPICAL, and `@appical` is how the spawn wrapper knows which account. + assert.equal( + resolveAgentModel("implementor", "claude-fable-5-1@appical", withOpus), + "claude-opus-5@appical", + ) +}) + +test("forceModel wins over the default", () => { + assert.equal( + resolveAgentModel("designer", "claude-opus-5@work", withOpus), + "claude-haiku-4-5@work", + ) +}) + +test("forceModel works with no default set at all", () => { + assert.equal( + resolveAgentModel("designer", "claude-fable-5-1", withoutDefault), + "claude-haiku-4-5", + ) +}) + +// --- what must never be touched ------------------------------------------- + +test("an agent that pinned provider and model is left alone", () => { + assert.equal( + resolveAgentModel("pinned", "claude-sonnet-5", withOpus), + "claude-sonnet-5", + ) +}) + +test("a primary agent is left alone", () => { + assert.equal( + resolveAgentModel("primary", "claude-fable-5-1", withOpus), + "claude-fable-5-1", + ) +}) + +test("an agent the plugin never discovered is left alone", () => { + // opencode's built-ins land here. Forcing Opus onto `explore` would make a + // cheap agent expensive without anyone asking for it. + assert.equal( + resolveAgentModel("explore", "claude-haiku-4-5", withOpus), + "claude-haiku-4-5", + ) +}) + +test("an untagged request is left alone", () => { + assert.equal( + resolveAgentModel(undefined, "claude-fable-5-1", withOpus), + "claude-fable-5-1", + ) +}) + +test("an unknown model fails closed rather than spawning a bad --model", () => { + assert.equal( + resolveAgentModel("bogus", "claude-fable-5-1", withOpus), + "claude-fable-5-1", + ) +}) + +// --- module-level state ---------------------------------------------------- + +test("module state is used when no overrides are passed", () => { + _resetAgentRegistryForTests() + setAgentRegistry({ implementor: { mode: "subagent" } }) + + assert.equal(getDefaultSubagentModel(), undefined) + assert.equal(resolveAgentModel("implementor", "claude-fable-5-1"), "claude-fable-5-1") + + setDefaultSubagentModel("claude-opus-5") + assert.equal(resolveAgentModel("implementor", "claude-fable-5-1"), "claude-opus-5") + + // A blank setting is the same as no setting, so an empty config value + // cannot half-enable the override. + setDefaultSubagentModel(" ") + assert.equal(getDefaultSubagentModel(), undefined) + assert.equal(resolveAgentModel("implementor", "claude-fable-5-1"), "claude-fable-5-1") + + _resetAgentRegistryForTests() +}) + +// --- frontmatter ----------------------------------------------------------- + +test("parseAgentFrontmatter reads the three fields it cares about", () => { + const record = parseAgentFrontmatter( + [ + "---", + "description: does things", + "mode: subagent", + 'forceModel: "claude-haiku-4-5"', + "---", + "body", + ].join("\n"), + ) + assert.deepEqual(record, { mode: "subagent", forceModel: "claude-haiku-4-5" }) +}) + +test("parseAgentFrontmatter ignores nested keys and stops at the fence", () => { + const record = parseAgentFrontmatter( + [ + "---", + "mode: subagent", + "permission:", + " bash: allow", + " edit: allow", + "---", + "model: claude-opus-5", + ].join("\n"), + ) + assert.deepEqual(record, { mode: "subagent" }) +}) + +test("parseAgentFrontmatter tolerates a file with no frontmatter", () => { + assert.deepEqual(parseAgentFrontmatter("just a prompt\n"), {}) +}) + +test("readAgentMarkdownRecords reads a directory, project before global", async () => { + const root = mkdtempSync(join(tmpdir(), "agent-models-")) + try { + const project = join(root, "project") + const global = join(root, "global") + mkdirSync(project, { recursive: true }) + mkdirSync(global, { recursive: true }) + writeFileSync( + join(project, "designer.md"), + "---\nmode: subagent\nforceModel: claude-haiku-4-5\n---\n", + ) + writeFileSync(join(global, "designer.md"), "---\nmode: subagent\n---\n") + writeFileSync(join(global, "notes.txt"), "ignored") + + const found = await readAgentMarkdownRecords([project, global]) + assert.deepEqual(Object.keys(found), ["designer"]) + assert.equal(found.designer.forceModel, "claude-haiku-4-5") + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test("readAgentMarkdownRecords skips directories that do not exist", async () => { + assert.deepEqual( + await readAgentMarkdownRecords([join(tmpdir(), "no-such-agent-dir")]), + {}, + ) +}) + +// --- effort ---------------------------------------------------------------- + +const effortRecords: Record = { + thrifty: { mode: "subagent", reasoningEffort: "high" }, + quiet: { mode: "subagent" }, + wrong: { mode: "subagent", reasoningEffort: "enormous" }, +} +const withEffort = { records: effortRecords } + +test("an agent's declared effort beats the caller's inherited one", () => { + // The cost property: a caller who picked max for their own turn must not + // hand max to every worker it dispatches. + assert.equal(resolveAgentEffort("thrifty", "max", withEffort), "high") +}) + +test("an agent that declares no effort keeps whatever it inherited", () => { + assert.equal(resolveAgentEffort("quiet", "max", withEffort), "max") + assert.equal(resolveAgentEffort("quiet", undefined, withEffort), undefined) +}) + +test("an unknown agent keeps the inherited effort", () => { + assert.equal(resolveAgentEffort("explore", "medium", withEffort), "medium") + assert.equal(resolveAgentEffort(undefined, "medium", withEffort), "medium") +}) + +test("an unknown effort level is refused, not forwarded to the CLI", () => { + assert.equal(resolveAgentEffort("wrong", "medium", withEffort), "medium") +}) + +test("effort is read from the registry when no overrides are passed", () => { + _resetAgentRegistryForTests() + setAgentRegistry(effortRecords) + try { + assert.equal(resolveAgentEffort("thrifty", "max"), "high") + } finally { + _resetAgentRegistryForTests() + } +}) + +test("parseAgentFrontmatter reads reasoningEffort", () => { + assert.deepEqual( + parseAgentFrontmatter( + ["---", "mode: subagent", "reasoningEffort: xhigh", "---", "body"].join( + "\n", + ), + ), + { mode: "subagent", reasoningEffort: "xhigh" }, + ) +}) + +test("agentDirectories covers both names, project before global", () => { + assert.deepEqual(agentDirectories("/home/k", "/work/app"), [ + "/work/app/.opencode/agents", + "/work/app/.opencode/agent", + "/home/k/.config/opencode/agents", + "/home/k/.config/opencode/agent", + ]) +}) diff --git a/test-ask-user-question.ts b/test-ask-user-question.ts new file mode 100644 index 0000000..1c84c76 --- /dev/null +++ b/test-ask-user-question.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { + denyMessageForTool, + isAskUserQuestionTool, +} from "./src/claude-code-language-model.js" + +test("isAskUserQuestionTool matches CLI casing variants", () => { + assert.equal(isAskUserQuestionTool("AskUserQuestion"), true) + assert.equal(isAskUserQuestionTool("ask_user_question"), true) + assert.equal(isAskUserQuestionTool("askuserquestion"), true) + assert.equal(isAskUserQuestionTool("Bash"), false) + assert.equal(isAskUserQuestionTool(undefined), false) +}) + +// Regression guard for issue #8 ("Questions are skipped"): the deny message +// must instruct the model to stop and wait, with NO "proceed if +// non-interactive" escape hatch that the model used to take routinely. +test("AskUserQuestion deny message stops unconditionally", () => { + const msg = denyMessageForTool("AskUserQuestion") + assert.match(msg, /stop now/i) + assert.match(msg, /wait for the operator/i) + assert.match(msg, /do not guess/i) + // Must explicitly defuse the "the user cancelled, so I'll proceed" + // rationalization the model otherwise reaches for after the deny. + assert.match(msg, /not a cancellation/i) + assert.match(msg, /cancelled, skipped, or declined/i) + // None of the old "proceed if non-interactive" escape-hatch markers. + assert.doesNotMatch(msg, /non-interactive/i) + assert.doesNotMatch(msg, /reasonable/i) + assert.doesNotMatch(msg, /do not stall/i) + // Same message regardless of any configured fallback. + assert.equal(denyMessageForTool("ask_user_question", "custom fallback"), msg) +}) + +test("non-question tools use configured or default deny message", () => { + assert.equal( + denyMessageForTool("Bash", "blocked by policy"), + "blocked by policy", + ) + assert.equal( + denyMessageForTool("Bash"), + "Denied by opencode-claude-code policy for tool Bash", + ) +}) + +// Regression guard for the question proxy path: when "Question" is in +// proxyTools, the model calls `mcp__opencode_proxy__question` instead of +// the native `AskUserQuestion`. The proxy tool name must NOT be matched +// by isAskUserQuestionTool, otherwise the sawAskUserQuestion latch would +// fire on the proxied path too — blocking auto-continue even though the +// proxy already blocked until the operator answered (no waiting needed). +test("proxy question tool name is NOT matched by isAskUserQuestionTool", () => { + assert.equal( + isAskUserQuestionTool("mcp__opencode_proxy__question"), + false, + ) + assert.equal(isAskUserQuestionTool("mcp__opencode_proxy__Question"), false) + // The native names the proxy replaces must still match, so the + // deny/markdown fallback stays correct when the proxy is off. + assert.equal(isAskUserQuestionTool("AskUserQuestion"), true) + assert.equal(isAskUserQuestionTool("ask_user_question"), true) +}) diff --git a/test-auto-continue.ts b/test-auto-continue.ts new file mode 100644 index 0000000..dda3570 --- /dev/null +++ b/test-auto-continue.ts @@ -0,0 +1,713 @@ +/** + * Unit tests for smart auto-continuation policy in + * src/claude-code-language-model.ts. + */ +import { test } from "node:test" +import assert from "node:assert/strict" + +import { + shouldAutoContinueIncompleteTurn, + autoContinueEnabledFor, +} from "./src/claude-code-language-model.js" + +function state(overrides: Record = {}) { + return { + enabled: "smart" as const, + attempts: 0, + startedAt: 1_000, + noProgressCount: 0, + ...overrides, + } as any +} + +function snap(overrides: Record = {}) { + const base: Record = { + text: "", + lastVisibleText: "", + hadReasoning: false, + hadToolActivity: false, + hadProxyActivity: false, + now: 1_500, + ...overrides, + } + // Default lastVisibleText to mirror text unless explicitly overridden, so + // legacy single-block test cases keep working. + if ( + overrides.text !== undefined && + overrides.lastVisibleText === undefined + ) { + base.lastVisibleText = overrides.text + } + return base as any +} + +test("smart auto-continue is disabled by false", () => { + const result = shouldAutoContinueIncompleteTurn( + state({ enabled: false }), + snap({ hadReasoning: true }), + ) + assert.deepEqual(result, { continue: false, reason: "disabled" }) +}) + +test("continues reasoning-only result with no visible answer", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ hadReasoning: true }), + ) + assert.equal(result.continue, true) + assert.equal(result.reason, "activity-without-visible-answer") +}) + +test("continues tool activity without visible answer", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ hadToolActivity: true }), + ) + assert.equal(result.continue, true) +}) + +test("continues non-final visible progress", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ text: "I found the relevant files and am checking the tests.", hadToolActivity: true }), + ) + assert.deepEqual(result, { continue: true, reason: "non-final-progress" }) +}) + +test("stops for final-looking visible answer", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ text: "Done. Implemented the fix and tests passed successfully.", hadToolActivity: true }), + ) + assert.deepEqual(result, { continue: false, reason: "final-answer" }) +}) + +test("stops for question", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ text: "Which option do you want me to use?", hadReasoning: true }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +test("stops for blocker", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ text: "I cannot proceed because the required token is missing.", hadToolActivity: true }), + ) + assert.deepEqual(result, { continue: false, reason: "blocker" }) +}) + +test("stops for errors", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ isError: true, hadToolActivity: true }), + ) + assert.deepEqual(result, { continue: false, reason: "error" }) +}) + +test("stops at max attempts", () => { + const result = shouldAutoContinueIncompleteTurn( + state({ attempts: 8 }), + snap({ hadReasoning: true }), + ) + assert.deepEqual(result, { continue: false, reason: "max-attempts" }) +}) + +test("stops when elapsed budget is exhausted", () => { + const result = shouldAutoContinueIncompleteTurn( + state({ startedAt: 0 }), + snap({ hadReasoning: true, now: 10 * 60 * 1000 + 1 }), + ) + assert.deepEqual(result, { continue: false, reason: "max-elapsed" }) +}) + +test("stops on repeated no-progress continuation", () => { + const snapshot = snap({ hadReasoning: true }) + const first = shouldAutoContinueIncompleteTurn(state(), snapshot) + assert.equal(first.continue, true) + + const second = shouldAutoContinueIncompleteTurn( + state({ + lastSignature: JSON.stringify({ + text: "", + reasoning: true, + tools: false, + proxy: false, + }), + noProgressCount: 1, + }), + snapshot, + ) + assert.deepEqual(second, { continue: false, reason: "no-progress" }) +}) + +test("stops when there was no activity", () => { + const result = shouldAutoContinueIncompleteTurn(state(), snap()) + assert.deepEqual(result, { continue: false, reason: "no-activity" }) +}) + +test("ignores final-answer keywords in earlier text blocks", () => { + // Earlier mid-task narration contains keywords like 'implemented' and + // 'updated' — but the LAST text block is a mid-task pause. Should still + // continue. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: + "I implemented the helper. Updated the search index. " + + "Now checking the next set of files.", + lastVisibleText: "Now checking the next set of files.", + hadToolActivity: true, + }), + ) + assert.equal(result.continue, true) + assert.equal(result.reason, "non-final-progress") +}) + +test("stops when the last text block looks like a final answer", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: + "Let me check the files. " + + "Found three matches. " + + "Done. Implemented the fix and tests passed successfully.", + lastVisibleText: + "Done. Implemented the fix and tests passed successfully.", + hadToolActivity: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "final-answer" }) +}) + +test("question in any earlier text block still stops continuation", () => { + // Even if the last block looks mid-task, a question raised earlier in the + // turn should still block auto-continue — answering a question is the + // user's job. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: + "Which option do you want me to use? Continuing with the first one for now.", + lastVisibleText: "Continuing with the first one for now.", + hadToolActivity: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +// ─── v0.4.10 regression tests for tweaks 2, 3, 4, 5 ──────────────────────── + +test("v0.4.10 tweak 2: 'let me know if you'd like' stops as question", () => { + // Indirect offer of next steps without literal '?'. C03 in the sim corpus. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: + "Let me know if you'd like me to proceed with the cleanup phase or stop here.", + hadReasoning: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +test("v0.4.10 tweak 3: 'needs your approval' stops as blocker", () => { + // 'needs your' is intent-equivalent to 'requires your' but slipped past + // the regex pre-0.4.10. D03 in the sim corpus. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: + "Needs your approval before I push the tag — auto-push is not enabled.", + hadReasoning: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "blocker" }) +}) + +test("v0.4.10 tweak 4: short completion (36 chars) stops as final-answer", () => { + // Pre-0.4.10 floor of 40 chars let "Task is now completely done. Pushed." + // through as non-final-progress. Floor lowered to 30. I01 in the sim corpus. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "Task is now completely done. Pushed.", + hadToolActivity: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "final-answer" }) +}) + +test("v0.4.10 tweak 5a: '?' anywhere in last block stops as question", () => { + // Real fire shape from 2026-05-14T03:31 — long answer that asks a + // question early then lists options and ends in a period. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: + "Here's the plan. Want me to proceed with that? Concretely: 1. Do X. 2. Do Y. 3. Do Z. Say 'go' or push back on any step.", + hadReasoning: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +test("v0.4.10 tweak 5b: 'say go or push back' (no '?') stops as question", () => { + // Pure soft-proceed phrasing with no '?' anywhere. Tests that the + // phrase-based half of tweak 5 fires independently of the '?' check. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: + "Pick the option you want. Say 'go' to ship as planned, or push back on any specific step.", + hadReasoning: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +test("v0.4.10 tweak 5c: 'if you want to' stops as question", () => { + // Reconstruction of 02:48:11-style fire — long analysis ending in a + // conditional action offer with no '?'. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: + "Three options are on the table. The recommendation is to leave DEBUG off. Consider option C if you want to re-enable DEBUG without UI noise.", + hadReasoning: true, hadToolActivity: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +test("v0.4.10 tweak 5d: A-class continues unaffected (no '?' or soft-proceed phrase)", () => { + // Sanity check: mid-task narration without question signals should still + // continue. Catches regressions where '?' or phrase regex accidentally + // expands. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "Now I'll read the file. Then I'll diff against previous. Then summarize.", + hadReasoning: true, + }), + ) + assert.deepEqual(result, { continue: true, reason: "non-final-progress" }) +}) + +// ─── v0.4.11 regression tests ────────────────────────────────────────────── + +test("v0.4.11 'ready when you are' stops as question", () => { + // Real fire from 2026-05-14T04:00:41 — short answer ending in this + // canonical 'your move' phrase fired 4-δ inappropriately on v0.4.10. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "The standing-by stub lives in training, not just the CLI's empty-turn behavior. Ready when you are.", + hadReasoning: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +test("v0.4.11 'standing by' stops as question (the meta-irony stub)", () => { + // Commit 49345e3 originally fought 'No input received. Standing by.' at + // the message-builder layer (suppressing the CLI stub on empty turns). + // This test guards against the model organically producing the same + // idiom at the response layer. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "All done on my side; the rest is on you. Standing by.", + hadReasoning: true, hadToolActivity: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +test("v0.4.11 'let me know when' stops as question", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "I've staged everything for the release. Let me know when you've reviewed.", + hadReasoning: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +// ─── v0.4.12 regression tests ────────────────────────────────────────────── + +test("v0.4.12 'over to you' stops as question", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "I've prepared the patch and tests are green. Over to you.", + hadReasoning: true, hadToolActivity: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +test("v0.4.12 'your turn' stops as question", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "Reviewed the diff and flagged three concerns. Your turn to pick a direction.", + hadReasoning: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +test("v0.4.12 'all yours' stops as question", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "Branch is rebased and the PR template filled. The rest is all yours.", + hadReasoning: true, hadToolActivity: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +test("v0.4.12 'let me know how' stops as question", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "Three viable paths surfaced. Let me know how you'd like to proceed.", + hadReasoning: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +test("v0.4.12 'i'm here' stops as question", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "All staged for the release. I'm here when you're ready to ship.", + hadReasoning: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +// ─── v0.4.15 regression tests ────────────────────────────────────────────── + +test("v0.4.15 'shipped' as final-answer keyword", () => { + // Real fire shape from 03:31 — long completion narrative ending with + // 'shipped'-style verbs that weren't in the v0.4.14 keyword list. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "v0.4.15 on npm, pin matches, 78/78 tests pass, sim corpus preserved as future leverage. Shipped.", + hadReasoning: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "final-answer" }) +}) + +test("v0.4.15 'deployed/merged/tagged' as keywords", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "Patch merged to master, tagged v0.4.15, deployed via CI. Restart at your convenience.", + hadReasoning: true, hadToolActivity: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "final-answer" }) +}) + +test("v0.4.15 'pinned' as keyword", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "Plugin pinned at @0.4.15 in opencode.jsonc. Restart loads it.", + hadReasoning: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "final-answer" }) +}) + +test("v0.4.15 short 'We're done.' bypasses length floor", () => { + // 11 chars — would have been below the 30-char threshold and missed + // pre-v0.4.15. The strong-completion phrase override catches it. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "We're done.", + hadReasoning: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "final-answer" }) +}) + +test("v0.4.15 short 'All set.' bypasses length floor", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "All set.", + hadReasoning: true, hadToolActivity: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "final-answer" }) +}) + +test("v0.4.15 'tests pass' (present tense) stops as final-answer", () => { + // Real fire 03:31 ended in "78/78 tests pass" — the v0.4.14 regex + // matched only past tense ("tests passed") so the fire was missed. + // This case is the actual 03:31 message text. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "v0.4.13 on npm, pin matches, 78/78 tests pass, sim corpus + regression bench preserved as future leverage.", + hadReasoning: true, + }), + ) + assert.deepEqual(result, { continue: false, reason: "final-answer" }) +}) + +test("v0.4.16 end_turn stop_reason short-circuits heuristic", () => { + // Even a long ambiguous mid-task narration with no completion keywords + // and visible tool activity gets stopped immediately when Claude CLI + // signals end_turn. This is the architectural alternative to chasing + // soft-proceed idioms via regex (v0.4.10-15). + const ambiguous = + "Running the next probe to inspect the build output and confirm bundle sizes are roughly equal." + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: ambiguous, + hadReasoning: true, + hadToolActivity: true, + stopReason: "end_turn", + }), + ) + assert.deepEqual(result, { continue: false, reason: "end-turn" }) +}) + +test("v0.4.16 end_turn beats max-attempts (decided last)", () => { + // End-turn wins over budget guards too — once the model says it's done, + // there's no value in burning more attempts. + const result = shouldAutoContinueIncompleteTurn( + state({ attempts: 999 }), + snap({ stopReason: "end_turn", hadReasoning: true }), + ) + assert.deepEqual(result, { continue: false, reason: "end-turn" }) +}) + +test("v0.4.16 end_turn does NOT beat genuine error", () => { + // is_error still wins. Defensive: we don't want to silently treat a CLI + // error as a clean stop. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ stopReason: "end_turn", isError: true }), + ) + assert.deepEqual(result, { continue: false, reason: "error" }) +}) + +test("v0.4.16 end_turn does NOT beat abort", () => { + const result = shouldAutoContinueIncompleteTurn( + state({ aborted: true }), + snap({ stopReason: "end_turn" }), + ) + assert.deepEqual(result, { continue: false, reason: "aborted" }) +}) + +test("max_tokens continues: truncation is not a finished turn", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "Working on it", + hadReasoning: true, + hadToolActivity: true, + stopReason: "max_tokens", + }), + ) + assert.deepEqual(result, { continue: true, reason: "truncated" }) +}) + +test("truncated prose continues even with no tool or reasoning activity", () => { + // The common truncation case: one long answer, cut off mid-sentence. This + // is why truncation cannot simply fall through to the keyword heuristic — + // it would stop at the no-activity gate. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "The migration works by first taking the old rows and", + lastVisibleText: "The migration works by first taking the old rows and", + stopReason: "max_tokens", + }), + ) + assert.deepEqual(result, { continue: true, reason: "truncated" }) +}) + +test("max_output_tokens is treated as truncation too", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ stopReason: "max_output_tokens" }), + ) + assert.deepEqual(result, { continue: true, reason: "truncated" }) +}) + +// Mirrors the module-private caps: 8 attempts, 10 minutes. +const MAX_ATTEMPTS = 8 +const MAX_ELAPSED_MS = 10 * 60 * 1000 + +test("truncation still respects the attempt cap", () => { + const result = shouldAutoContinueIncompleteTurn( + { ...state(), attempts: MAX_ATTEMPTS }, + snap({ stopReason: "max_tokens" }), + ) + assert.deepEqual(result, { continue: false, reason: "max-attempts" }) +}) + +test("truncation still respects the elapsed cap", () => { + const started = 1_000 + const result = shouldAutoContinueIncompleteTurn( + { ...state(), startedAt: started }, + snap({ + stopReason: "max_tokens", + now: started + MAX_ELAPSED_MS + 1, + }), + ) + assert.deepEqual(result, { continue: false, reason: "max-elapsed" }) +}) + +test("truncation does not override an abort or an error", () => { + assert.deepEqual( + shouldAutoContinueIncompleteTurn( + { ...state(), aborted: true }, + snap({ stopReason: "max_tokens" }), + ), + { continue: false, reason: "aborted" }, + ) + assert.deepEqual( + shouldAutoContinueIncompleteTurn( + state(), + snap({ stopReason: "max_tokens", isError: true }), + ), + { continue: false, reason: "error" }, + ) +}) + +test("truncation does not override a pending operator question", () => { + const result = shouldAutoContinueIncompleteTurn( + { ...state(), sawAskUserQuestion: true }, + snap({ stopReason: "max_tokens" }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +test("v0.4.17 stop_sequence stops via protocol signal", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ stopReason: "stop_sequence", hadReasoning: true }), + ) + assert.deepEqual(result, { continue: false, reason: "stop-sequence" }) +}) + +test("v0.4.17 refusal stops via protocol signal", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ stopReason: "refusal" }), + ) + assert.deepEqual(result, { continue: false, reason: "refusal" }) +}) + +test("v0.4.17 pause_turn stops via protocol signal", () => { + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ stopReason: "pause_turn", hadReasoning: true }), + ) + assert.deepEqual(result, { continue: false, reason: "pause-turn" }) +}) + +test("v0.4.17 tool_use stops via protocol signal", () => { + // Defensive: tool_use shouldn't normally reach the result boundary + // (drain timer closes the stream first), but if it does we honor it. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ stopReason: "tool_use", hadToolActivity: true }), + ) + assert.deepEqual(result, { continue: false, reason: "tool-use" }) +}) + +test("v0.4.17 unknown stop_reason still stops (forward-compat)", () => { + // If Anthropic adds a new stop_reason value, we trust it as authoritative + // and stop. Safer than running the keyword heuristic on unknown shape. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ stopReason: "future_value_we_dont_know" }), + ) + assert.deepEqual(result, { + continue: false, + reason: "future-value-we-dont-know", + }) +}) + +test("v0.4.17 empty-string stop_reason falls through (falsy)", () => { + // Empty string is falsy — fall back to heuristic, same as null/undefined. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "We're done.", + hadReasoning: true, + stopReason: "", + }), + ) + assert.deepEqual(result, { continue: false, reason: "final-answer" }) +}) + +test("v0.4.16 missing stop_reason falls through (back-compat)", () => { + // When stop_reason is undefined or null, the heuristic must still run + // unchanged. Protects against CLI versions / paths that don't surface it. + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "We're done.", + hadReasoning: true, + stopReason: null, + }), + ) + assert.deepEqual(result, { continue: false, reason: "final-answer" }) +}) + +test("sawAskUserQuestion latch blocks auto-continue even with non-question trailing text", () => { + // After AskUserQuestion the model may emit a short trailing line that does + // not read as a question (no '?'). Without the latch, that would look like + // an incomplete turn and trigger a nudge that makes the model proceed on + // its own. The latch must stop it regardless. + const result = shouldAutoContinueIncompleteTurn( + state({ sawAskUserQuestion: true }), + snap({ + text: "I'll go with the first option.", + hadToolActivity: true, + stopReason: null, + }), + ) + assert.deepEqual(result, { continue: false, reason: "question" }) +}) + +test("a compaction turn never continues, not even on truncation", () => { + // doStream builds the state with `enabled: false` for compaction turns. + // AUTO_CONTINUE_PROMPT says "Do not summarize; keep working", so nudging a + // /compact turn would append non-summary text to the session summary. + const result = shouldAutoContinueIncompleteTurn( + { ...state(), enabled: false }, + snap({ stopReason: "max_tokens" }), + ) + assert.deepEqual(result, { continue: false, reason: "disabled" }) +}) + +test("doStream disables auto-continue for compaction turns", () => { + // The wiring doStream uses. A compaction turn is off regardless of config; + // every other turn passes the configured value through untouched. + assert.equal(autoContinueEnabledFor(true, "smart"), false) + assert.equal(autoContinueEnabledFor(true, true), false) + assert.equal(autoContinueEnabledFor(false, "smart"), "smart") + assert.equal(autoContinueEnabledFor(false, true), true) + assert.equal(autoContinueEnabledFor(false, false), false) + assert.equal(autoContinueEnabledFor(false, undefined), undefined) +}) diff --git a/test-bridge.ts b/test-bridge.ts new file mode 100644 index 0000000..a9b4306 --- /dev/null +++ b/test-bridge.ts @@ -0,0 +1,557 @@ +/** + * Unit tests for src/mcp-bridge.ts. + * + * Runs offline against fake config trees written under a per-test temp dir. + * Uses Node's built-in `node:test` so no extra dependencies are pulled in. + * + * Usage: + * bun test-bridge.ts + * node --experimental-strip-types --test test-bridge.ts + */ +import { test } from "node:test" +import assert from "node:assert/strict" +import * as fs from "node:fs" +import * as path from "node:path" +import * as os from "node:os" + +import { bridgeOpencodeMcp, __test } from "./src/mcp-bridge.js" +import { defaultModels, toConfigModel } from "./src/models.js" + +const { + deepMerge, + mergeMcp, + translateServer, + substituteEnvPlaceholders, + detectWorktree, +} = __test + +function mkTmp(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)) +} + +function writeJson(p: string, obj: unknown) { + fs.mkdirSync(path.dirname(p), { recursive: true }) + fs.writeFileSync(p, JSON.stringify(obj, null, 2)) +} + +async function withIsolatedEnv(fn: (xdgRoot: string) => Promise | T): Promise { + const xdgRoot = mkTmp("oc-test-xdg-") + const original: Record = { + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + OPENCODE_CONFIG: process.env.OPENCODE_CONFIG, + OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR, + OPENCODE_WORKTREE: process.env.OPENCODE_WORKTREE, + HOME: process.env.HOME, + } + process.env.XDG_CONFIG_HOME = xdgRoot + delete process.env.OPENCODE_CONFIG + delete process.env.OPENCODE_CONFIG_DIR + delete process.env.OPENCODE_WORKTREE + process.env.HOME = xdgRoot + try { + return await fn(xdgRoot) + } finally { + for (const [k, v] of Object.entries(original)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + fs.rmSync(xdgRoot, { recursive: true, force: true }) + } +} + +test("deepMerge replaces primitives, deep-merges objects, replaces arrays", () => { + const out = deepMerge( + { a: 1, b: { x: 1, y: 2 }, c: [1, 2] }, + { a: 9, b: { y: 99, z: 3 }, c: [3] }, + ) + assert.deepEqual(out, { a: 9, b: { x: 1, y: 99, z: 3 }, c: [3] }) +}) + +test("toConfigModel omits unsupported interleaved field", () => { + const configModel = toConfigModel(defaultModels["claude-haiku-4-5"]) + + assert.equal(Object.hasOwn(configModel, "interleaved"), false) +}) + +test("deepMerge ignores undefined source values, keeps target", () => { + const out = deepMerge({ a: 1 }, { a: undefined as unknown as number, b: 2 }) + assert.deepEqual(out, { a: 1, b: 2 }) +}) + +test("mergeMcp: partial {enabled:true} layers onto full global spec", () => { + const merged = mergeMcp( + { linear: { type: "remote", url: "https://mcp.linear.app/mcp", enabled: false } }, + { linear: { enabled: true } }, + ) + assert.deepEqual(merged.linear, { + type: "remote", + url: "https://mcp.linear.app/mcp", + enabled: true, + }) +}) + +test("mergeMcp: per-server, environment block deep-merges", () => { + const merged = mergeMcp( + { + gh: { + type: "local", + command: ["github-mcp-server"], + environment: { TOKEN: "old", BASE_URL: "https://api.github.com" }, + enabled: true, + }, + } as any, + { gh: { environment: { TOKEN: "new" } } } as any, + ) + assert.deepEqual((merged.gh as any).environment, { + TOKEN: "new", + BASE_URL: "https://api.github.com", + }) + assert.equal((merged.gh as any).type, "local") +}) + +test("mergeMcp: command array is replaced, not concatenated", () => { + const merged = mergeMcp( + { srv: { type: "local", command: ["a", "b"], enabled: true } } as any, + { srv: { command: ["c"] } } as any, + ) + assert.deepEqual((merged.srv as any).command, ["c"]) +}) + +test("translateServer: enabled:false skips", () => { + assert.equal( + translateServer("x", { type: "local", command: ["foo"], enabled: false } as any), + null, + ) +}) + +test("translateServer: local→stdio with args", () => { + const out = translateServer("x", { type: "local", command: ["bin", "--flag"] } as any) + assert.deepEqual(out, { type: "stdio", command: "bin", args: ["--flag"] }) +}) + +test("translateServer: remote→http with headers", () => { + const out = translateServer("x", { + type: "remote", + url: "https://example.com", + headers: { A: "1" }, + } as any) + assert.deepEqual(out, { + type: "http", + url: "https://example.com", + headers: { A: "1" }, + }) +}) + +test("translateServer: remote without url is skipped", () => { + assert.equal(translateServer("x", { type: "remote" } as any), null) +}) + +test("translateServer: unknown type is skipped", () => { + assert.equal(translateServer("x", { type: "weird" } as any), null) +}) + +test("substituteEnvPlaceholders: replaces {env:VAR} from process.env", () => { + const prev = process.env.OC_TEST_ENV_SUB + process.env.OC_TEST_ENV_SUB = "secret-123" + try { + assert.deepEqual( + substituteEnvPlaceholders({ TOKEN: "{env:OC_TEST_ENV_SUB}" }), + { TOKEN: "secret-123" }, + ) + } finally { + if (prev === undefined) delete process.env.OC_TEST_ENV_SUB + else process.env.OC_TEST_ENV_SUB = prev + } +}) + +test("substituteEnvPlaceholders: missing var becomes empty string", () => { + delete process.env.OC_TEST_DOES_NOT_EXIST + assert.deepEqual( + substituteEnvPlaceholders({ TOKEN: "{env:OC_TEST_DOES_NOT_EXIST}" }), + { TOKEN: "" }, + ) +}) + +test("substituteEnvPlaceholders: leaves non-placeholder strings intact", () => { + assert.deepEqual( + substituteEnvPlaceholders({ A: "literal", B: "op://Private/X/y" }), + { A: "literal", B: "op://Private/X/y" }, + ) +}) + +test("substituteEnvPlaceholders: substitutes inside larger string", () => { + const prev = process.env.OC_TEST_PARTIAL + process.env.OC_TEST_PARTIAL = "abc" + try { + assert.deepEqual( + substituteEnvPlaceholders({ K: "prefix-{env:OC_TEST_PARTIAL}-suffix" }), + { K: "prefix-abc-suffix" }, + ) + } finally { + if (prev === undefined) delete process.env.OC_TEST_PARTIAL + else process.env.OC_TEST_PARTIAL = prev + } +}) + +test("substituteEnvPlaceholders: drops non-string values", () => { + const result = substituteEnvPlaceholders({ + OK: "value", + N: 42 as any, + O: { nested: true } as any, + }) + assert.deepEqual(result, { OK: "value" }) +}) + +test("translateServer: local server env is env-substituted", () => { + const prev = process.env.OC_TEST_LOCAL_TOKEN + process.env.OC_TEST_LOCAL_TOKEN = "xoxp-real" + try { + const out = translateServer("slack", { + type: "local", + command: ["op", "run", "--", "npx", "slack-mcp-server"], + environment: { + SLACK_MCP_XOXP_TOKEN: "{env:OC_TEST_LOCAL_TOKEN}", + SLACK_MCP_ADD_MESSAGE_TOOL: "true", + }, + } as any) + assert.deepEqual(out, { + type: "stdio", + command: "op", + args: ["run", "--", "npx", "slack-mcp-server"], + env: { + SLACK_MCP_XOXP_TOKEN: "xoxp-real", + SLACK_MCP_ADD_MESSAGE_TOOL: "true", + }, + }) + } finally { + if (prev === undefined) delete process.env.OC_TEST_LOCAL_TOKEN + else process.env.OC_TEST_LOCAL_TOKEN = prev + } +}) + +test("translateServer: remote server headers are env-substituted", () => { + const prev = process.env.OC_TEST_REMOTE_TOKEN + process.env.OC_TEST_REMOTE_TOKEN = "Basic xyz" + try { + const out = translateServer("furno-postgres", { + type: "remote", + url: "https://mcp.furno.app/sse", + headers: { Authorization: "{env:OC_TEST_REMOTE_TOKEN}" }, + } as any) + assert.deepEqual(out, { + type: "http", + url: "https://mcp.furno.app/sse", + headers: { Authorization: "Basic xyz" }, + }) + } finally { + if (prev === undefined) delete process.env.OC_TEST_REMOTE_TOKEN + else process.env.OC_TEST_REMOTE_TOKEN = prev + } +}) + +test("detectWorktree: finds .git ancestor", async () => { + await withIsolatedEnv(async (xdgRoot) => { + const repo = path.join(xdgRoot, "repo") + const sub = path.join(repo, "a", "b", "c") + fs.mkdirSync(sub, { recursive: true }) + fs.mkdirSync(path.join(repo, ".git")) + assert.equal(detectWorktree(sub), repo) + }) +}) + +test("detectWorktree: OPENCODE_WORKTREE env override wins", async () => { + await withIsolatedEnv(async (xdgRoot) => { + const repo = path.join(xdgRoot, "repo") + const override = path.join(xdgRoot, "elsewhere") + fs.mkdirSync(repo, { recursive: true }) + fs.mkdirSync(override, { recursive: true }) + fs.mkdirSync(path.join(repo, ".git")) + process.env.OPENCODE_WORKTREE = override + assert.equal(detectWorktree(path.join(repo, "deep")), override) + }) +}) + +test("bridgeOpencodeMcp: project {enabled:true} unlocks global linear", async () => { + await withIsolatedEnv(async (xdgRoot) => { + const globalDir = path.join(xdgRoot, "opencode") + writeJson(path.join(globalDir, "opencode.json"), { + mcp: { + linear: { + type: "remote", + url: "https://mcp.linear.app/mcp", + enabled: false, + }, + }, + }) + const repo = path.join(xdgRoot, "proj") + fs.mkdirSync(path.join(repo, ".git"), { recursive: true }) + writeJson(path.join(repo, "opencode.json"), { + mcp: { linear: { enabled: true } }, + }) + const result = bridgeOpencodeMcp(repo) + assert.ok(result, "expected bridge to produce a config") + const written = JSON.parse(fs.readFileSync(result.path, "utf8")) as { + mcpServers: Record + } + assert.deepEqual(written.mcpServers.linear, { + type: "http", + url: "https://mcp.linear.app/mcp", + }) + }) +}) + +test("bridgeOpencodeMcp: project file overrides one field, others preserved", async () => { + await withIsolatedEnv(async (xdgRoot) => { + const globalDir = path.join(xdgRoot, "opencode") + writeJson(path.join(globalDir, "opencode.json"), { + mcp: { + gh: { + type: "local", + command: ["gh-mcp"], + environment: { TOKEN: "GLOBAL" }, + enabled: true, + }, + }, + }) + const repo = path.join(xdgRoot, "proj") + fs.mkdirSync(path.join(repo, ".git"), { recursive: true }) + writeJson(path.join(repo, "opencode.json"), { + mcp: { gh: { environment: { TOKEN: "PROJECT" } } }, + }) + const result = bridgeOpencodeMcp(repo) + assert.ok(result) + const written = JSON.parse(fs.readFileSync(result.path, "utf8")) as { + mcpServers: Record + } + assert.deepEqual(written.mcpServers.gh, { + type: "stdio", + command: "gh-mcp", + env: { TOKEN: "PROJECT" }, + }) + }) +}) + +test("bridgeOpencodeMcp: walk-up stops at worktree root", async () => { + await withIsolatedEnv(async (xdgRoot) => { + writeJson(path.join(xdgRoot, "opencode.json"), { + mcp: { + linear: { + type: "remote", + url: "https://mcp.linear.app/mcp", + enabled: true, + }, + }, + }) + const repo = path.join(xdgRoot, "repo") + fs.mkdirSync(path.join(repo, ".git"), { recursive: true }) + const cwd = path.join(repo, "src") + fs.mkdirSync(cwd, { recursive: true }) + const result = bridgeOpencodeMcp(cwd) + assert.equal(result, null) + }) +}) + +test("bridgeOpencodeMcp: hash is stable for identical config, changes when config changes", async () => { + await withIsolatedEnv(async (xdgRoot) => { + const globalDir = path.join(xdgRoot, "opencode") + writeJson(path.join(globalDir, "opencode.json"), { + mcp: { gh: { type: "local", command: ["gh-mcp"], enabled: true } }, + }) + const repo = path.join(xdgRoot, "proj") + fs.mkdirSync(path.join(repo, ".git"), { recursive: true }) + const a = bridgeOpencodeMcp(repo) + const b = bridgeOpencodeMcp(repo) + assert.ok(a && b) + assert.equal(a.hash, b.hash) + assert.equal(a.path, b.path) + writeJson(path.join(globalDir, "opencode.json"), { + mcp: { + gh: { type: "local", command: ["gh-mcp", "--verbose"], enabled: true }, + }, + }) + const c = bridgeOpencodeMcp(repo) + assert.ok(c) + assert.notEqual(a.hash, c.hash) + }) +}) + +test("bridgeOpencodeMcp: opencode.jsonc beats opencode.json in same dir", async () => { + await withIsolatedEnv(async (xdgRoot) => { + const globalDir = path.join(xdgRoot, "opencode") + writeJson(path.join(globalDir, "opencode.json"), { + mcp: { srv: { type: "local", command: ["from-json"], enabled: true } }, + }) + fs.writeFileSync( + path.join(globalDir, "opencode.jsonc"), + `{ + // jsonc wins for the same dir + "mcp": { "srv": { "type": "local", "command": ["from-jsonc"], "enabled": true } } +}`, + ) + const repo = path.join(xdgRoot, "proj") + fs.mkdirSync(path.join(repo, ".git"), { recursive: true }) + const result = bridgeOpencodeMcp(repo) + assert.ok(result) + const written = JSON.parse(fs.readFileSync(result.path, "utf8")) as { + mcpServers: Record + } + assert.equal(written.mcpServers.srv.command, "from-jsonc") + }) +}) + +test("bridgeOpencodeMcp: parses JSONC syntax from opencode.json", async () => { + await withIsolatedEnv(async (xdgRoot) => { + const globalDir = path.join(xdgRoot, "opencode") + fs.mkdirSync(globalDir, { recursive: true }) + fs.writeFileSync( + path.join(globalDir, "opencode.json"), + `{ + // OpenCode accepts JSONC regardless of the config file extension. + "mcp": { + "srv": { + "type": "local", + "command": ["jsonc-server"], + "enabled": true, + }, + }, +}`, + ) + + const repo = path.join(xdgRoot, "proj") + fs.mkdirSync(path.join(repo, ".git"), { recursive: true }) + const result = bridgeOpencodeMcp(repo) + assert.ok(result) + + const written = JSON.parse(fs.readFileSync(result.path, "utf8")) as { + mcpServers: Record + } + assert.equal(written.mcpServers.srv.command, "jsonc-server") + }) +}) + +test("bridgeOpencodeMcp: cwd-most project file beats parent project file", async () => { + await withIsolatedEnv(async (xdgRoot) => { + const repo = path.join(xdgRoot, "repo") + fs.mkdirSync(path.join(repo, ".git"), { recursive: true }) + writeJson(path.join(repo, "opencode.json"), { + mcp: { srv: { type: "local", command: ["parent"], enabled: true } }, + }) + const cwd = path.join(repo, "deep") + fs.mkdirSync(cwd, { recursive: true }) + writeJson(path.join(cwd, "opencode.json"), { + mcp: { srv: { command: ["cwd"] } }, + }) + const result = bridgeOpencodeMcp(cwd) + assert.ok(result) + const written = JSON.parse(fs.readFileSync(result.path, "utf8")) as { + mcpServers: Record + } + assert.equal(written.mcpServers.srv.command, "cwd") + }) +}) + +test("bridgeOpencodeMcp: returns null when no MCP block present", async () => { + await withIsolatedEnv(async (xdgRoot) => { + const repo = path.join(xdgRoot, "proj") + fs.mkdirSync(path.join(repo, ".git"), { recursive: true }) + const result = bridgeOpencodeMcp(repo) + assert.equal(result, null) + }) +}) + +test("runtime overlay: connected status enables disk-disabled server", async () => { + await withIsolatedEnv(async (xdgRoot) => { + const globalDir = path.join(xdgRoot, "opencode") + writeJson(path.join(globalDir, "opencode.json"), { + mcp: { + linear: { + type: "remote", + url: "https://mcp.linear.app/mcp", + enabled: false, + }, + }, + }) + const repo = path.join(xdgRoot, "proj") + fs.mkdirSync(path.join(repo, ".git"), { recursive: true }) + + assert.equal(bridgeOpencodeMcp(repo), null) + + const result = bridgeOpencodeMcp(repo, { linear: "connected" }) + assert.ok(result) + const written = JSON.parse(fs.readFileSync(result.path, "utf8")) as { + mcpServers: Record + } + assert.deepEqual(written.mcpServers.linear, { + type: "http", + url: "https://mcp.linear.app/mcp", + }) + }) +}) + +test("runtime overlay: non-connected status disables disk-enabled server", async () => { + await withIsolatedEnv(async (xdgRoot) => { + const globalDir = path.join(xdgRoot, "opencode") + writeJson(path.join(globalDir, "opencode.json"), { + mcp: { + gh: { type: "local", command: ["gh-mcp"], enabled: true }, + linear: { + type: "remote", + url: "https://mcp.linear.app/mcp", + enabled: true, + }, + }, + }) + const repo = path.join(xdgRoot, "proj") + fs.mkdirSync(path.join(repo, ".git"), { recursive: true }) + + const result = bridgeOpencodeMcp(repo, { + gh: "disabled", + linear: "failed", + }) + assert.equal(result, null) + }) +}) + +test("runtime overlay: hash differs between snapshots to drive eviction", async () => { + await withIsolatedEnv(async (xdgRoot) => { + const globalDir = path.join(xdgRoot, "opencode") + writeJson(path.join(globalDir, "opencode.json"), { + mcp: { + linear: { + type: "remote", + url: "https://mcp.linear.app/mcp", + enabled: false, + }, + gh: { type: "local", command: ["gh-mcp"], enabled: true }, + }, + }) + const repo = path.join(xdgRoot, "proj") + fs.mkdirSync(path.join(repo, ".git"), { recursive: true }) + + const off = bridgeOpencodeMcp(repo, { gh: "connected" }) + const on = bridgeOpencodeMcp(repo, { + gh: "connected", + linear: "connected", + }) + assert.ok(off && on) + assert.notEqual(off.hash, on.hash) + }) +}) + +test("runtime overlay: missing entry leaves disk value untouched", async () => { + await withIsolatedEnv(async (xdgRoot) => { + const globalDir = path.join(xdgRoot, "opencode") + writeJson(path.join(globalDir, "opencode.json"), { + mcp: { gh: { type: "local", command: ["gh-mcp"], enabled: true } }, + }) + const repo = path.join(xdgRoot, "proj") + fs.mkdirSync(path.join(repo, ".git"), { recursive: true }) + + const result = bridgeOpencodeMcp(repo, { other: "connected" }) + assert.ok(result) + const written = JSON.parse(fs.readFileSync(result.path, "utf8")) as { + mcpServers: Record + } + assert.equal(written.mcpServers.gh.command, "gh-mcp") + }) +}) diff --git a/test-broker.ts b/test-broker.ts new file mode 100644 index 0000000..81723fb --- /dev/null +++ b/test-broker.ts @@ -0,0 +1,504 @@ +/** + * Unit tests for src/proxy-broker.ts — the per-session pending-call + * registry used to coordinate proxy-mcp HTTP handlers with the language + * model's stream lifecycle. + * + * Usage: + * bun test-broker.ts + * node --experimental-strip-types --test test-broker.ts + */ +import { test } from "node:test" +import assert from "node:assert/strict" + +import { + queuePendingProxyCall, + getPendingProxyCalls, + onPendingProxyCall, + resolvePendingProxyCallById, + rejectPendingProxyCallById, + rejectAllPendingProxyCallsForSession, + isPendingProxyCallChannelClosed, + markPendingProxyCallEmitted, + snapshotPendingProxyCalls, + PROXY_STALL_WARNING_MS, + PROXY_DEADLINE_WARNING_FRACTION, + PROXY_DEADLINE_WARNING_MIN_MS, + type PendingProxyCall, +} from "./src/proxy-broker.js" +import { configureLogger, _resetLoggerForTests } from "./src/logger.js" +import { PROXY_NO_DEADLINE_MS, type ProxyToolCall, type ProxyToolResult } from "./src/proxy-mcp.js" + +type CallHandle = { + id: string + promise: Promise + resolved: boolean + rejected: boolean + call: ProxyToolCall +} + +let callCounter = 0 + +function makeCall(toolName: string, input: Record = {}): CallHandle { + const id = `call-${++callCounter}` + const state = { + id, + resolved: false, + rejected: false, + } as CallHandle + state.promise = new Promise((resolve, reject) => { + state.call = { + id, + toolName, + input, + resolve: (result) => { + state.resolved = true + resolve(result) + }, + reject: (err) => { + state.rejected = true + reject(err) + }, + } + }) + // Swallow rejections so test runner doesn't crash on unawaited rejects. + state.promise.catch(() => {}) + return state +} + +test("queue + getPendingProxyCalls returns every queued call in order", () => { + const sk = `sk-multi-${Date.now()}` + const a = makeCall("bash", { command: "ls" }) + const b = makeCall("bash", { command: "pwd" }) + + queuePendingProxyCall(sk, a.call) + queuePendingProxyCall(sk, b.call) + + const pending = getPendingProxyCalls(sk) + assert.equal(pending.length, 2) + const ids = new Set(pending.map((p) => p.toolCallId)) + assert.ok(ids.has(a.id)) + assert.ok(ids.has(b.id)) + + // Clean up + rejectAllPendingProxyCallsForSession(sk, new Error("test cleanup")) +}) + +test("resolvePendingProxyCallById resolves only the matching call", async () => { + const sk = `sk-resolve-${Date.now()}` + const a = makeCall("bash") + const b = makeCall("write") + + queuePendingProxyCall(sk, a.call) + queuePendingProxyCall(sk, b.call) + + const ok = resolvePendingProxyCallById(a.id, { kind: "text", text: "a-result" }) + assert.equal(ok, true) + + const result = await a.promise + assert.deepEqual(result, { kind: "text", text: "a-result" }) + + // b should still be pending + const remaining = getPendingProxyCalls(sk) + assert.equal(remaining.length, 1) + assert.equal(remaining[0].toolCallId, b.id) + assert.equal(b.resolved, false) + assert.equal(b.rejected, false) + + // Clean up + rejectAllPendingProxyCallsForSession(sk, new Error("test cleanup")) +}) + +test("rejectPendingProxyCallById rejects only the matching call", async () => { + const sk = `sk-reject-${Date.now()}` + const a = makeCall("bash") + const b = makeCall("bash") + + queuePendingProxyCall(sk, a.call) + queuePendingProxyCall(sk, b.call) + + const ok = rejectPendingProxyCallById(a.id, new Error("a-rejected")) + assert.equal(ok, true) + + await assert.rejects(a.promise, /a-rejected/) + assert.equal(getPendingProxyCalls(sk).length, 1) + + // Clean up + rejectAllPendingProxyCallsForSession(sk, new Error("test cleanup")) +}) + +test("rejectAllPendingProxyCallsForSession rejects every pending call", async () => { + const sk = `sk-reject-all-${Date.now()}` + const a = makeCall("bash") + const b = makeCall("bash") + const c = makeCall("bash") + + queuePendingProxyCall(sk, a.call) + queuePendingProxyCall(sk, b.call) + queuePendingProxyCall(sk, c.call) + + const count = rejectAllPendingProxyCallsForSession(sk, new Error("session gone")) + assert.equal(count, 3) + assert.equal(getPendingProxyCalls(sk).length, 0) + + await assert.rejects(a.promise, /session gone/) + await assert.rejects(b.promise, /session gone/) + await assert.rejects(c.promise, /session gone/) +}) + +test("onPendingProxyCall fires once per queued call for the matching session", () => { + const sk = `sk-onevent-${Date.now()}` + const otherSk = `sk-other-${Date.now()}` + const fired: PendingProxyCall[] = [] + const unsubscribe = onPendingProxyCall(sk, (call) => { + fired.push(call) + }) + + const a = makeCall("bash") + const b = makeCall("write") + const c = makeCall("bash") // different session — should not fire + + queuePendingProxyCall(sk, a.call) + queuePendingProxyCall(sk, b.call) + queuePendingProxyCall(otherSk, c.call) + + assert.equal(fired.length, 2) + const firedIds = new Set(fired.map((f) => f.toolCallId)) + assert.ok(firedIds.has(a.id)) + assert.ok(firedIds.has(b.id)) + assert.ok(!firedIds.has(c.id)) + + unsubscribe() + rejectAllPendingProxyCallsForSession(sk, new Error("test cleanup")) + rejectAllPendingProxyCallsForSession(otherSk, new Error("test cleanup")) +}) + +test("getPendingProxyCalls is empty for unknown session", () => { + assert.deepEqual(getPendingProxyCalls(`sk-empty-${Date.now()}`), []) +}) + +test("resolve / reject on already-resolved id is a no-op returning false", () => { + const sk = `sk-double-${Date.now()}` + const a = makeCall("bash") + queuePendingProxyCall(sk, a.call) + + assert.equal(resolvePendingProxyCallById(a.id, { kind: "text", text: "ok" }), true) + assert.equal(resolvePendingProxyCallById(a.id, { kind: "text", text: "again" }), false) + assert.equal(rejectPendingProxyCallById(a.id, new Error("late")), false) +}) + +test("parallel queue from same session: index reflects every callId", () => { + const sk = `sk-parallel-${Date.now()}` + const calls = Array.from({ length: 5 }, () => makeCall("bash")) + for (const c of calls) queuePendingProxyCall(sk, c.call) + + const pending = getPendingProxyCalls(sk) + assert.equal(pending.length, 5) + const ids = new Set(pending.map((p) => p.toolCallId)) + for (const c of calls) assert.ok(ids.has(c.id)) + + // Resolve a couple, reject the rest + resolvePendingProxyCallById(calls[0].id, { kind: "text", text: "0" }) + resolvePendingProxyCallById(calls[2].id, { kind: "text", text: "2" }) + const left = getPendingProxyCalls(sk) + assert.equal(left.length, 3) + + rejectAllPendingProxyCallsForSession(sk, new Error("cleanup")) + assert.equal(getPendingProxyCalls(sk).length, 0) +}) + +// --- per-tool proxy timeouts ------------------------------------------------ + +test("queuePendingProxyCall honours a short per-tool override", async () => { + const sk = `sk-timeout-${Date.now()}` + const a = makeCall("bash") + queuePendingProxyCall(sk, a.call, { bash: 40 }) + + // The override (40ms) must beat the flat 10-min default decisively. + const t0 = Date.now() + await assert.rejects(a.promise, /timed out after 40ms/) + const elapsed = Date.now() - t0 + assert.ok(elapsed < 2000, `rejected too late: ${elapsed}ms`) + + assert.equal(getPendingProxyCalls(sk).length, 0) +}) + +test("queuePendingProxyCall: task timeout text warns against scheduling a wake-up", async () => { + const sk = `sk-task-timeout-${Date.now()}` + const a = makeCall("task") + queuePendingProxyCall(sk, a.call, { task: 40 }) + + await assert.rejects(a.promise, /wake-up/) +}) + +test("queuePendingProxyCall: a call with no deadline arms no timer and stays pending", async () => { + // `task` has no default deadline. The broker must not turn 0 into a + // zero-delay timer (which would reject on the next tick); the call waits + // until a lifecycle event releases it. + const sk = `sk-no-deadline-${Date.now()}` + const a = makeCall("task") + queuePendingProxyCall(sk, a.call) + const b = makeCall("bash") + queuePendingProxyCall(sk, b.call, { bash: 0 }) + + await new Promise((r) => setTimeout(r, 60)) + assert.equal(a.rejected, false, "task must not time out") + assert.equal(b.rejected, false, "a 0 override disables the bash deadline") + const snapshot = snapshotPendingProxyCalls().filter((c) => c.sessionKey === sk) + assert.deepEqual( + snapshot.map((c) => c.deadlineMs), + [PROXY_NO_DEADLINE_MS, PROXY_NO_DEADLINE_MS], + "the doctor sees 0 as the deadline", + ) + + // The next user turn's orphan sweep is one such lifecycle event. + assert.equal(rejectAllPendingProxyCallsForSession(sk, new Error("orphaned")), 2) + await assert.rejects(a.promise, /orphaned/) + await assert.rejects(b.promise, /orphaned/) + assert.equal(getPendingProxyCalls(sk).length, 0) +}) + +test("queuePendingProxyCall: bash input.timeout keeps the call alive past a shorter override", async () => { + // Override 40ms, but the caller asked for a 30s bash timeout — the + // effective deadline is 30s, so resolving at ~80ms must succeed rather + // than the call having already timed out. + const sk = `sk-bash-input-${Date.now()}` + const a = makeCall("bash", { command: "build", timeout: 30000 }) + queuePendingProxyCall(sk, a.call, { bash: 40 }) + + // Wait past the override deadline to prove input.timeout governs. + await new Promise((r) => setTimeout(r, 100)) + assert.equal(a.rejected, false, "must not have timed out at the override") + + const ok = resolvePendingProxyCallById(a.id, { kind: "text", text: "ok" }) + assert.equal(ok, true) + const result = await a.promise + assert.deepEqual(result, { kind: "text", text: "ok" }) +}) + +test("queuePendingProxyCall with a duplicate callId replaces the old entry cleanly", async () => { + // Defensive path: a duplicate id (UUID collision / retry storm) must + // reject the FIRST promise with "Replaced", clear its timer, and leave + // exactly one pending entry (the new one). A leaked double-entry would + // risk a double-fire on timeout. + const sk = `sk-replace-${Date.now()}` + const dupId = `dup-${Date.now()}` + const first: CallHandle = (() => { + const state = { id: dupId, resolved: false, rejected: false } as CallHandle + state.promise = new Promise((resolve, reject) => { + state.call = { + id: dupId, + toolName: "bash", + input: {}, + resolve: (r) => { + state.resolved = true + resolve(r) + }, + reject: (e) => { + state.rejected = true + reject(e) + }, + } + }) + state.promise.catch(() => {}) + return state + })() + const second = makeCall("bash") + + queuePendingProxyCall(sk, first.call) + queuePendingProxyCall(sk, second.call) + // Reuse the same id on a freshly-made call to trigger the replace path. + const secondWithDupId = { ...makeCall("bash").call, id: dupId } + queuePendingProxyCall(sk, secondWithDupId) + + await assert.rejects(first.promise, /Replaced pending proxy call/) + + // Exactly one pending entry for that id, and it is the latest call. + const pending = getPendingProxyCalls(sk) + const matching = pending.filter((p) => p.toolCallId === dupId) + assert.equal(matching.length, 1, "only one entry for the replaced id") + + rejectAllPendingProxyCallsForSession(sk, new Error("cleanup")) +}) + +test("queuePendingProxyCall carries the channel and markPendingProxyCallEmitted flags the entry", () => { + const handle = makeCall("task") + handle.call.channel = { closed: false } + const pending = queuePendingProxyCall("sess-channel", handle.call) + assert.equal(isPendingProxyCallChannelClosed(pending), false) + assert.equal(pending.emitted, undefined) + markPendingProxyCallEmitted(handle.id) + assert.equal(getPendingProxyCalls("sess-channel")[0].emitted, true) + handle.call.channel.closed = true + assert.equal(isPendingProxyCallChannelClosed(pending), true) + resolvePendingProxyCallById(handle.id, { kind: "text", text: "ok" }) +}) + +test("isPendingProxyCallChannelClosed treats a call without a channel as open", () => { + const handle = makeCall("bash") + const pending = queuePendingProxyCall("sess-no-channel", handle.call) + assert.equal(isPendingProxyCallChannelClosed(pending), false) + resolvePendingProxyCallById(handle.id, { kind: "text", text: "ok" }) +}) + +// --- stall warning for calls with no deadline ----------------------------- + +/** Like test-cli-args.ts's helper, but it spans awaits. */ +async function captureLogsAsync( + fn: (lines: readonly string[]) => Promise, +): Promise { + const lines: string[] = [] + const original = console.error + console.error = (line: unknown) => { + lines.push(String(line)) + } + try { + _resetLoggerForTests() + configureLogger({ mode: "debug", level: "debug" }) + await fn(lines) + } finally { + console.error = original + _resetLoggerForTests() + } + return lines +} + +const pause = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +function stallLines(lines: string[]): string[] { + return lines.filter((line) => line.includes("proxy call still waiting")) +} + +test("a call with no deadline warns repeatedly while it waits", async () => { + const handle = makeCall("task") + const lines = await captureLogsAsync(async () => { + const pending = queuePendingProxyCall("sess-stall", handle.call, undefined, 15) + assert.equal(pending.deadlineMs, PROXY_NO_DEADLINE_MS, "task has no deadline") + await pause(55) + }) + const warnings = stallLines(lines) + assert.ok(warnings.length >= 2, `expected repeats, got ${warnings.length}`) + assert.match(warnings[0]!, /WARN/) + assert.match(warnings[0]!, new RegExp(handle.id)) + assert.match(warnings[0]!, /"toolName":"task"/) + assert.match(warnings[0]!, /waitedMs/) + rejectAllPendingProxyCallsForSession("sess-stall", new Error("cleanup")) +}) + +test("resolving a call stops its stall warnings", async () => { + const handle = makeCall("task") + const lines = await captureLogsAsync(async () => { + queuePendingProxyCall("sess-stall-stop", handle.call, undefined, 15) + await pause(25) + resolvePendingProxyCallById(handle.id, { kind: "text", text: "done" }) + await pause(60) + }) + // One heartbeat before the result, none after: the interval was cleared + // rather than left running against a deleted entry. + assert.equal(stallLines(lines).length, 1, stallLines(lines).join("\n")) + assert.equal(getPendingProxyCalls("sess-stall-stop").length, 0) +}) + +test("rejecting a call stops its stall warnings", async () => { + const handle = makeCall("task_batch") + const lines = await captureLogsAsync(async () => { + queuePendingProxyCall("sess-stall-reject", handle.call, undefined, 15) + await pause(25) + rejectPendingProxyCallById(handle.id, new Error("aborted")) + await pause(60) + }) + assert.equal(stallLines(lines).length, 1, stallLines(lines).join("\n")) + await handle.promise.catch(() => undefined) +}) + +test("a call that has a deadline is never armed, since the deadline reports it", async () => { + const handle = makeCall("bash", { command: "sleep 1" }) + const lines = await captureLogsAsync(async () => { + const pending = queuePendingProxyCall("sess-stall-deadline", handle.call, undefined, 15) + assert.ok(pending.deadlineMs > PROXY_NO_DEADLINE_MS, "bash has a deadline") + await pause(55) + }) + assert.deepEqual(stallLines(lines), []) + rejectAllPendingProxyCallsForSession("sess-stall-deadline", new Error("cleanup")) +}) + +test("stallWarningMs of 0 arms nothing", async () => { + const handle = makeCall("task") + const lines = await captureLogsAsync(async () => { + queuePendingProxyCall("sess-stall-off", handle.call, undefined, 0) + await pause(40) + }) + assert.deepEqual(stallLines(lines), []) + rejectAllPendingProxyCallsForSession("sess-stall-off", new Error("cleanup")) +}) + +test("the shipped threshold is 5 minutes", () => { + assert.equal(PROXY_STALL_WARNING_MS, 5 * 60_000) +}) + +// --- one notice before a deadline takes the call -------------------------- + +function deadlineLines(lines: string[]): string[] { + return lines.filter((line) => line.includes("deadline approaching")) +} + +test("a deadline-bearing call warns once, before the deadline rejects it", async () => { + const handle = makeCall("bash", {}) + const lines = await captureLogsAsync(async (live) => { + // 200 ms deadline with the minimum lowered to 1 ms, so it arms and warns + // at 60 percent, which is 120 ms. + queuePendingProxyCall("sess-warn", handle.call, { bash: 200 }, 0, 1) + await pause(160) + // Warned, and the call is still alive: the point is a notice BEFORE + // death, so both halves are asserted while it is still pending. + assert.equal(deadlineLines([...live]).length, 1, "expected exactly one notice") + assert.equal(getPendingProxyCalls("sess-warn").length, 1, "still pending") + await pause(120) + }) + const warnings = deadlineLines(lines) + assert.equal(warnings.length, 1, "one-shot, never repeating") + assert.match(warnings[0]!, /WARN/) + assert.match(warnings[0]!, new RegExp(handle.id)) + assert.match(warnings[0]!, /"remainingMs":/) + assert.match(warnings[0]!, /proxyToolTimeoutMs/) + // The deadline still did its job afterwards. + assert.equal(getPendingProxyCalls("sess-warn").length, 0) + await handle.promise.catch(() => undefined) +}) + +test("resolving before the warning point means no notice at all", async () => { + const handle = makeCall("bash", {}) + const lines = await captureLogsAsync(async () => { + queuePendingProxyCall("sess-warn-fast", handle.call, { bash: 200 }, 0, 1) + await pause(30) + resolvePendingProxyCallById(handle.id, { kind: "text", text: "quick" }) + await pause(160) + }) + assert.deepEqual(deadlineLines(lines), []) +}) + +test("a short deadline is not armed: the notice would arrive with the rejection", async () => { + const handle = makeCall("bash", {}) + const lines = await captureLogsAsync(async () => { + // Real minimum this time, so a 200 ms deadline is below the floor. + queuePendingProxyCall("sess-warn-short", handle.call, { bash: 200 }, 0) + await pause(280) + }) + assert.deepEqual(deadlineLines(lines), []) + await handle.promise.catch(() => undefined) +}) + +test("a call with no deadline gets the heartbeat, never this notice", async () => { + const handle = makeCall("task") + const lines = await captureLogsAsync(async () => { + queuePendingProxyCall("sess-warn-none", handle.call, undefined, 15, 1) + await pause(55) + }) + assert.deepEqual(deadlineLines(lines), []) + assert.ok(stallLines(lines).length >= 2, "heartbeat still runs") + rejectAllPendingProxyCallsForSession("sess-warn-none", new Error("cleanup")) +}) + +test("the shipped notice point is 60 percent, with a one minute floor", () => { + assert.equal(PROXY_DEADLINE_WARNING_FRACTION, 0.6) + assert.equal(PROXY_DEADLINE_WARNING_MIN_MS, 60_000) +}) diff --git a/test-btw-command.ts b/test-btw-command.ts new file mode 100644 index 0000000..05ba88e --- /dev/null +++ b/test-btw-command.ts @@ -0,0 +1,649 @@ +import assert from "node:assert/strict" +import type { LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider" +import type { ChildProcess } from "node:child_process" +import { EventEmitter } from "node:events" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { test } from "node:test" +import { + BTW_NO_SESSION_MESSAGE, + BTW_TURN_TOO_LONG_MESSAGE, + BtwHandledError, + clearAsideSinks, + clearPendingSideQuestionAnswers, + emitAsideInline, + fetchAsideHistory, + formatInlineAside, + formatInlineAsideAsk, + handleBtwCommand, + INLINE_ASIDE_MARKER, + registerAsideSink, + rememberSideQuestionAnswer, + settleSessionBusy, + takeSideQuestionAnswer, + waitForAsideProcess, + type BtwSdkClient, + type BtwSdkMessage, + type BtwToast, +} from "./src/btw-command.js" +import { filterSideQuestionHistory } from "./src/message-builder.js" +import { createClaudeCode, registerSideQuestionCommand } from "./src/index.js" +import type { OpenCodeConfig } from "./src/opencode-types.js" +import { + deleteActiveProcess, + deleteActiveProcessAndWait, + deleteClaudeSessionId, + findActiveProcessBySessionId, + getActiveProcess, + sessionKey, + setActiveProcess, + type ActiveProcess, +} from "./src/session-manager.js" +import { SIDE_QUESTION_USAGE } from "./src/side-question.js" + +type Call = { method: string; args: unknown } + +function fakeClient(messages: Record = {}, withStatus = true) { + const calls: Call[] = [] + const status: Record = {} + const client: BtwSdkClient = { + session: { + // Methods, not arrows: the real SDK reads `this._client`, and the first + // live run of the toast failed because it was called detached. + async messages(this: unknown, { path }) { + assert.equal(this, client.session, "SDK methods must be called on their namespace object") + calls.push({ method: "messages", args: path.id }) + return { data: messages[path.id] ?? [] } + }, + ...(withStatus + ? { + async status(this: unknown) { + assert.equal(this, client.session, "SDK methods must be called on their namespace object") + calls.push({ method: "status", args: undefined }) + return { data: { ...status } } + }, + } + : {}), + }, + tui: { + async showToast(this: unknown, { body }) { + assert.equal(this, client.tui, "SDK methods must be called on their namespace object") + calls.push({ method: "toast", args: body }) + return {} + }, + }, + } + const toasts = () => calls.filter((call) => call.method === "toast").map((call) => call.args as BtwToast) + const only = (method: string) => calls.filter((call) => call.method === method) + return { client, calls, toasts, only, status } +} + +function fakeActive(sessionID: string, key: string): ActiveProcess { + const proc = Object.assign(new EventEmitter(), { + pid: 4242, + killed: false, + exitCode: null, + signalCode: null, + kill: () => true, + stdin: null, + stdout: null, + }) + const ap: ActiveProcess = { + proc: proc as unknown as ChildProcess, + lineEmitter: new EventEmitter(), + opencodeSessionID: sessionID, + asideTransport: { cliPath: "claude", interactive: false }, + } + setActiveProcess(key, ap) + return ap +} + +function dropActive(key: string): void { + try { + deleteActiveProcess(key) + } catch { + // The fake process has no real handles; nothing to release. + } +} + +const input = (question: string, sessionID = "ses_parent") => ({ command: "btw", sessionID, arguments: question }) + +test("bare /btw shows the usage text as a toast and drops the prompt", async () => { + clearPendingSideQuestionAnswers() + const fake = fakeClient() + await assert.rejects(handleBtwCommand(fake.client, input(" ")), BtwHandledError) + assert.deepEqual(fake.toasts(), [{ title: "btw", message: SIDE_QUESTION_USAGE, variant: "warning", duration: 6_000 }]) + assert.equal(takeSideQuestionAnswer("ses_parent", ""), undefined) +}) + +test("/btw without a live process lets the message through so the turn can explain", async () => { + clearPendingSideQuestionAnswers() + const fake = fakeClient() + await handleBtwCommand(fake.client, input("why?", "ses_nobody"), { pollMs: 5, settleMs: 20 }) + assert.deepEqual(fake.toasts(), []) + assert.equal(takeSideQuestionAnswer("ses_nobody", "why?"), undefined) +}) + +test("/btw typed before the turn's process is tagged waits for it instead of being queued", async () => { + clearPendingSideQuestionAnswers() + const key = "btw-test::late" + const fake = fakeClient() + fake.status.ses_late = { type: "busy" } + const appear = setTimeout(() => fakeActive("ses_late", key), 30) + const goIdle = setTimeout(() => { + fake.status.ses_late = { type: "idle" } + }, 150) + try { + await handleBtwCommand(fake.client, input("why?", "ses_late"), { + pollMs: 5, + settleMs: 20, + spawnWaitMs: 5_000, + timeoutMs: 5_000, + }) + const early = takeSideQuestionAnswer("ses_late", "why?") + assert.ok(early, "the aside is sent as soon as the process exists, not skipped") + await assert.rejects(early, /headless Claude Code transport/, "the fake process has no stdin") + assert.deepEqual(fake.toasts(), [], "a busy turn is not announced; the answer itself lands in the conversation") + } finally { + clearTimeout(appear) + clearTimeout(goIdle) + dropActive(key) + clearPendingSideQuestionAnswers() + } +}) + +test("/btw during a turn that never produces a claude process gives up rather than holding the message", async () => { + clearPendingSideQuestionAnswers() + const fake = fakeClient() + fake.status.ses_elsewhere = { type: "busy" } + await handleBtwCommand(fake.client, input("why?", "ses_elsewhere"), { pollMs: 5, settleMs: 10, spawnWaitMs: 40 }) + assert.deepEqual(fake.toasts(), [], "the turn belongs to another provider; nothing to say") + assert.equal(takeSideQuestionAnswer("ses_elsewhere", "why?"), undefined) +}) + +test("a status that has not registered the turn yet does not skip the hold", async () => { + const key = "btw-test::settle" + const active = fakeActive("ses_settle", key) + const fake = fakeClient() + const flip = setTimeout(() => { + fake.status.ses_settle = { type: "busy" } + }, 20) + try { + assert.equal(await settleSessionBusy(fake.client, "ses_settle", active, { pollMs: 5, settleMs: 2_000 }), true) + delete fake.status.ses_settle + assert.equal(await settleSessionBusy(fake.client, "ses_settle", active, { pollMs: 5, settleMs: 20 }), false) + assert.equal(await waitForAsideProcess(fake.client, "ses_settle", { pollMs: 5, settleMs: 20 }), active) + } finally { + clearTimeout(flip) + dropActive(key) + } +}) + +test("/btw whose early request cannot be sent still lets the message through", async () => { + clearPendingSideQuestionAnswers() + const key = "btw-test::no-stdin" + fakeActive("ses_nostdin", key) + const fake = fakeClient() + try { + // The fake process has no stdin, so requestSideQuestion rejects. The + // rejection is remembered (and handled) and the queued turn asks again. + await handleBtwCommand(fake.client, input("why?", "ses_nostdin"), { pollMs: 5, settleMs: 20 }) + const early = takeSideQuestionAnswer("ses_nostdin", "why?") + assert.ok(early) + await assert.rejects(early, /headless Claude Code transport/) + assert.deepEqual(fake.toasts(), [], "an idle process gets no toast; the transcript shows the answer") + } finally { + dropActive(key) + clearPendingSideQuestionAnswers() + } +}) + +test("a turn that never ends makes /btw give up with a warning instead of queueing behind it", async () => { + clearPendingSideQuestionAnswers() + const key = "btw-test::endless" + fakeActive("ses_endless", key) + const fake = fakeClient() + fake.status.ses_endless = { type: "busy" } + try { + await assert.rejects( + handleBtwCommand(fake.client, input("why?", "ses_endless"), { pollMs: 5, timeoutMs: 20 }), + BtwHandledError, + ) + assert.equal(fake.toasts().at(-1)?.message, BTW_TURN_TOO_LONG_MESSAGE) + assert.equal(fake.toasts().at(-1)?.variant, "warning") + } finally { + dropActive(key) + clearPendingSideQuestionAnswers() + } +}) + +test("without a status route the hook falls back to the process's own listener count", async () => { + clearPendingSideQuestionAnswers() + const key = "btw-test::nostatus" + const active = fakeActive("ses_nostatus", key) + const fake = fakeClient({}, false) + try { + const listener = () => undefined + active.lineEmitter.on("line", listener) + await handleBtwCommand(fake.client, input("why?", "ses_nostatus"), { pollMs: 5, timeoutMs: 20 }) + assert.deepEqual(fake.toasts(), [], "busy per the listener, and still nothing to announce") + active.lineEmitter.off("line", listener) + } finally { + dropActive(key) + clearPendingSideQuestionAnswers() + } +}) + +test("an aside sink belongs to the stream that registered it", () => { + clearAsideSinks() + const written: string[] = [] + const first = registerAsideSink("ses_sink", (text) => { + written.push(`first:${text}`) + return true + }) + assert.equal(emitAsideInline("ses_sink", "a"), true) + const second = registerAsideSink("ses_sink", (text) => { + written.push(`second:${text}`) + return true + }) + // The previous turn's cleanup must not take the current turn's sink away. + first() + assert.equal(emitAsideInline("ses_sink", "b"), true) + second() + assert.equal(emitAsideInline("ses_sink", "c"), false, "no stream is open") + assert.deepEqual(written, ["first:a", "second:b"]) + // A closed stream reports it rather than throwing, so the toast can stand in. + registerAsideSink("ses_sink", () => false) + assert.equal(emitAsideInline("ses_sink", "d"), false) + registerAsideSink("ses_sink", () => { + throw new Error("stream is gone") + }) + assert.equal(emitAsideInline("ses_sink", "e"), false) + clearAsideSinks() +}) + +test("an aside written into a turn is marked so a rebuilt transcript drops it", () => { + const block = formatInlineAside(" what did i say? ", " You said pineapple.\n\nTwice. ") + assert.equal(block.trimStart().startsWith(INLINE_ASIDE_MARKER), true, "the marker leads the part") + assert.match(block, /what did i say\?/) + assert.match(block, /You said pineapple\./) + assert.deepEqual( + block.trim().split("\n").filter((line) => !line.startsWith("▌")), + [], + "every line carries the bar, so it runs down the whole block", + ) + assert.match(block, /^▌ Twice\.$/m, "a blank line inside the answer keeps the bar") + const kept = filterSideQuestionHistory([ + user("Start."), + { role: "assistant", content: [{ type: "text", text: "Main answer" }, { type: "text", text: block }] }, + user("Next."), + ] as never) + assert.deepEqual(kept.map((message) => message.role), ["user", "assistant", "user"]) + assert.deepEqual( + (kept[1] as { content: { text: string }[] }).content.map((part) => part.text), + ["Main answer"], + "only Claude's own text is replayed", + ) +}) + +test("the receipt quotes the question back, marks it sent, and is dropped like the answer", () => { + const ask = formatInlineAsideAsk(" what did i say? ") + assert.equal(ask.trimStart().startsWith(INLINE_ASIDE_MARKER), true, "the same marker leads it, so the same strip covers it") + assert.match(ask, /what did i say\?/, "the operator sees what was sent, since the prompt box is already cleared") + assert.match(ask, /sent to Claude on the side/) + assert.deepEqual( + ask.trim().split("\n").filter((line) => !line.startsWith("▌")), + [], + "the bar runs down the receipt too", + ) + assert.doesNotMatch(ask, /answering|asking/i, "the note stays true once the answer lands below it") + assert.match(ask, /^▌ \*sent to Claude on the side\*$/m, "the note has its own line, so the question is not crowded") + + const long = formatInlineAsideAsk("q".repeat(400)) + assert.equal(long.includes("q".repeat(400)), true, "the receipt is the only readback of the question, so it is never cut") + assert.doesNotMatch(long, /\.\.\./, "nothing is elided") + + const kept = filterSideQuestionHistory([ + user("Start."), + { + role: "assistant", + content: [ + { type: "text", text: "Main answer" }, + { type: "text", text: ask }, + { type: "text", text: formatInlineAside("what did i say?", "You said pineapple.") }, + ], + }, + user("Next."), + ] as never) + assert.deepEqual( + (kept[1] as { content: { text: string }[] }).content.map((part) => part.text), + ["Main answer"], + "receipt and answer both stay out of Claude's prompt", + ) +}) + +test("an aside written before the bar replaced the blockquote is still dropped", () => { + const kept = filterSideQuestionHistory([ + user("Start."), + { + role: "assistant", + content: [ + { type: "text", text: "Main answer" }, + { type: "text", text: "\n> **btw:** old shape\n>\n> Old answer.\n" }, + ], + }, + user("Next."), + ] as never) + assert.deepEqual( + (kept[1] as { content: { text: string }[] }).content.map((part) => part.text), + ["Main answer"], + "a conversation that predates the bar keeps its asides out of Claude's prompt", + ) +}) + +test("remembered answers are per session, per question, consumed once, and expire", async () => { + clearPendingSideQuestionAnswers() + const answer = Promise.resolve({ response: "yes", synthetic: false }) + rememberSideQuestionAnswer("ses_a", " why? ", answer, 1_000) + assert.equal(takeSideQuestionAnswer("ses_b", "why?", 1_000), undefined) + assert.equal(takeSideQuestionAnswer("ses_a", "how?", 1_000), undefined, "a different question drops the entry") + rememberSideQuestionAnswer("ses_a", "why?", answer, 1_000) + assert.equal(takeSideQuestionAnswer("ses_a", "why?", 1_000 + 10 * 60_000 + 1), undefined, "expired") + rememberSideQuestionAnswer("ses_a", "why?", answer, 1_000) + assert.equal(takeSideQuestionAnswer("ses_a", "why?", 2_000), answer) + assert.equal(takeSideQuestionAnswer("ses_a", "why?", 2_000), undefined, "consumed") + // A harness may append trailing metadata to the message text (opencode-dcp + // does), so the turn's question can be longer than the hook's. + rememberSideQuestionAnswer("ses_a", "why?", answer, 1_000) + assert.equal(takeSideQuestionAnswer("ses_a", "why?\n\nm0003", 2_000), answer) + for (let index = 0; index < 40; index++) rememberSideQuestionAnswer(`ses_${index}`, "q", answer, 5_000) + assert.equal(takeSideQuestionAnswer("ses_0", "q", 5_000), undefined, "capped: the oldest entries are dropped") + assert.equal(takeSideQuestionAnswer("ses_39", "q", 5_000), answer) + clearPendingSideQuestionAnswers() +}) + +test("fetchAsideHistory reads earlier /btw pairs from opencode and ignores everything else", async () => { + const fake = fakeClient({ + ses_hist: [ + { info: { role: "user" }, parts: [{ type: "text", text: "Start." }] }, + { info: { role: "assistant" }, parts: [{ type: "tool", tool: "read" }, { type: "text", text: "Done." }] }, + { info: { role: "user" }, parts: [{ type: "text", text: "/btw First?" }, { type: "text", text: "x" }] }, + { info: { role: "assistant" }, parts: [{ type: "text", text: "Aside one" }] }, + { info: { role: "user" }, parts: [{ type: "text", text: "/btw" }] }, + { info: { role: "assistant" }, parts: [{ type: "text", text: SIDE_QUESTION_USAGE }] }, + ], + }) + assert.deepEqual(await fetchAsideHistory(fake.client, "ses_hist", "Second?"), [{ question: "First?", response: "Aside one" }]) + assert.deepEqual(await fetchAsideHistory(fake.client, "ses_none", "Second?"), []) + assert.deepEqual(await fetchAsideHistory(null, "ses_hist", "Second?"), []) + fake.client.session!.messages = async () => { + throw new Error("offline") + } + assert.deepEqual(await fetchAsideHistory(fake.client, "ses_hist", "Second?"), [], "a failed read is not fatal") +}) + +test("findActiveProcessBySessionId returns the most recently used process for a session", () => { + const older = fakeActive("ses_dup", "btw-test::older") + const newer = fakeActive("ses_dup", "btw-test::newer") + try { + assert.equal(findActiveProcessBySessionId("ses_dup"), newer) + getActiveProcess("btw-test::older") + assert.equal(findActiveProcessBySessionId("ses_dup"), older, "touching moves a process to the back of the LRU") + assert.equal(findActiveProcessBySessionId("ses_other"), undefined) + } finally { + dropActive("btw-test::older") + dropActive("btw-test::newer") + } +}) + +test("registerSideQuestionCommand reports ownership so a user-defined btw command is left alone", () => { + const ours: OpenCodeConfig = {} + assert.equal(registerSideQuestionCommand(ours), true) + assert.equal(registerSideQuestionCommand(ours), false, "re-running config keeps the first registration") + assert.equal(ours.command?.btw?.template, "/btw $ARGUMENTS") + const theirs: OpenCodeConfig = { command: { btw: { template: "mine $ARGUMENTS" } } } + assert.equal(registerSideQuestionCommand(theirs), false) + assert.equal(theirs.command?.btw?.template, "mine $ARGUMENTS") +}) + +function createAsideCli() { + const cwd = mkdtempSync(join(tmpdir(), "opencode-btw-")) + const cliPath = join(cwd, "fake-claude.cjs") + const eventsPath = join(cwd, "events.jsonl") + writeFileSync(eventsPath, "") + writeFileSync(cliPath, `#!/usr/bin/env node +const fs = require("node:fs") +const readline = require("node:readline") +const record = (event) => fs.appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify({ ...event, pid: process.pid }) + "\\n") +const emit = (message) => process.stdout.write(JSON.stringify(message) + "\\n") +if (process.argv.includes("--version")) { + process.stdout.write("2.1.258\\n") + process.exit(0) +} +if (process.argv.includes("--help")) { + process.stdout.write("--plugin-dir \\n") + process.exit(0) +} +record({ type: "spawn" }) +let asides = 0 +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const envelope = JSON.parse(line) + record({ type: "input", envelope }) + if (envelope.type === "control_request" && envelope.request?.subtype === "side_question") { + asides++ + emit({ + type: "control_response", + response: { + subtype: "success", + request_id: envelope.request_id, + response: { response: "Aside " + asides + ": " + envelope.request.question, synthetic: false }, + }, + }) + return + } + const answer = () => { + emit({ + type: "assistant", + session_id: "fake-session", + message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text: "Main answer" }] }, + }) + emit({ type: "result", subtype: "success", session_id: "fake-session", is_error: false, usage: { input_tokens: 3, output_tokens: 2 } }) + } + // "SLOW" keeps the turn's stream open long enough for a test to write an + // aside into it, which is what happens for real while a turn is streaming. + if (line.includes("SLOW")) setTimeout(answer, 1500) + else answer() +}) +`, { mode: 0o755 }) + const modelId = "claude-test-btw" + const model = createClaudeCode({ + cliPath, + cwd, + bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, + proxyTools: [], + interactive: false, + autoContinueIncompleteTurns: false, + }).languageModel(modelId) + const keyFor = (sessionID: string) => + sessionKey(cwd, `${modelId}::tools::${sessionID}::context=["claude-code",null]`) + const tools = [{ type: "function" as const, name: "read", inputSchema: { type: "object", properties: {} } }] + return { + keyFor, + modelId, + events: () => readFileSync(eventsPath, "utf8").trim().split("\n").filter(Boolean).map((line) => + JSON.parse(line) as { type: string; pid: number; envelope?: { type: string; request?: Record } }, + ), + async turn(sessionID: string, prompt: LanguageModelV3CallOptions["prompt"]) { + const response = await model.doStream({ + prompt, + tools, + providerOptions: { "claude-code": { opencodeSessionID: sessionID } }, + abortSignal: AbortSignal.timeout(5_000), + }) + const parts: LanguageModelV3StreamPart[] = [] + for await (const part of response.stream) parts.push(part) + const errors = parts.filter((part) => part.type === "error") + const answer = parts.filter((part) => part.type === "text-delta").map((part) => part.delta).join("") + const finish = parts.find((part) => part.type === "finish") + return { parts, answer, errors, finish } + }, + async cleanup(sessionIDs: string[]) { + for (const sessionID of sessionIDs) { + await deleteActiveProcessAndWait(keyFor(sessionID)) + deleteClaudeSessionId(keyFor(sessionID)) + } + rmSync(cwd, { recursive: true, force: true }) + }, + } +} + +const user = (text: string) => ({ role: "user" as const, content: [{ type: "text" as const, text }] }) +const assistant = (text: string) => ({ role: "assistant" as const, content: [{ type: "text" as const, text }] }) + +test("the hook asks early while the turn is busy, and the queued /btw turn answers from that without asking again", { + timeout: 20_000, +}, async () => { + clearPendingSideQuestionAnswers() + const fake = createAsideCli() + const fakeSdk = fakeClient() + try { + const first = await fake.turn("ses_main", [user("Start.")]) + assert.equal(first.answer, "Main answer") + const active = getActiveProcess(fake.keyFor("ses_main")) + assert.ok(active) + assert.equal(active.opencodeSessionID, "ses_main") + assert.equal(active.asideTransport?.interactive, false) + assert.match(active.asideTransport?.cliPath ?? "", /fake-claude\.cjs$/) + + // opencode reports the session busy (a tool may be running with no stream + // attached, so the process's own listener count is not consulted). + fakeSdk.status.ses_main = { type: "busy" } + let released = false + // Busy with no stream open to write into, which is what a tool step looks + // like: the answer has nowhere to go yet, so the message is held and + // carries it once the turn ends. + const hook = handleBtwCommand(fakeSdk.client, input("First?", "ses_main"), { pollMs: 5, inlineWaitMs: 0 }).then(() => { + released = true + }) + const early = await (async () => { + for (let attempt = 0; attempt < 200; attempt++) { + const found = takeSideQuestionAnswer("ses_main", "First?") + if (found) return found + await new Promise((resolve) => setTimeout(resolve, 5)) + } + return undefined + })() + assert.ok(early, "the early answer is remembered while the turn is still running") + rememberSideQuestionAnswer("ses_main", "First?", early) + const earlyResult = await early + assert.equal(earlyResult.response, "Aside 1: First?") + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(fakeSdk.toasts(), [], "the answer is never toasted; it lands in the conversation") + await new Promise((resolve) => setTimeout(resolve, 30)) + assert.equal(released, false, "the /btw message is held back while the turn runs") + + // The turn ends: the hook lets opencode create the message, which runs at once. + fakeSdk.status.ses_main = { type: "idle" } + await hook + assert.equal(released, true) + const queued = await fake.turn("ses_main", [user("Start."), assistant("Main answer"), user("/btw First?")]) + assert.deepEqual(queued.errors, []) + assert.equal(queued.answer, "Aside 1: First?") + assert.equal((queued.finish as any)?.providerMetadata?.["claude-code"]?.path, "side-question") + assert.equal(fake.events().filter((event) => event.envelope?.type === "control_request").length, 1, "answered from the early request") + + // A follow-up typed while idle: the hook asks at once, the turn takes it, with history. + await handleBtwCommand(fakeSdk.client, input("Second?", "ses_main"), { pollMs: 5, settleMs: 20 }) + assert.deepEqual(fakeSdk.toasts(), [], "still nothing toasted") + const followUp = await fake.turn("ses_main", [ + user("Start."), assistant("Main answer"), user("/btw First?"), assistant("Aside 1: First?"), user("/btw Second?"), + ]) + assert.deepEqual(followUp.errors, []) + assert.equal(followUp.answer, "Aside 2: Second?") + + // No early answer at all (a client that bypasses commands): the idle process is asked directly, with history. + const direct = await fake.turn("ses_main", [ + user("Start."), assistant("Main answer"), user("/btw First?"), assistant("Aside 1: First?"), + user("/btw Second?"), assistant("Aside 2: Second?"), user("/btw Third?"), + ]) + assert.equal(direct.answer, "Aside 3: Third?") + + const inputs = fake.events().filter((event) => event.type === "input") + assert.deepEqual(inputs.map((event) => event.envelope?.type), ["user", "control_request", "control_request", "control_request"]) + assert.deepEqual(inputs[1].envelope?.request, { subtype: "side_question", question: "First?" }) + assert.deepEqual(inputs[3].envelope?.request, { + subtype: "side_question", + question: "Third?", + history: [ + { question: "First?", response: "Aside 1: First?" }, + { question: "Second?", response: "Aside 2: Second?" }, + ], + }) + assert.equal(fake.events().filter((event) => event.type === "spawn").length, 1, "asides never spawn") + + // A conversation with no live process gets a readable explanation, not an error. + const lonely = await fake.turn("ses_lonely", [user("/btw Anyone?")]) + assert.deepEqual(lonely.errors, []) + assert.equal(lonely.answer, BTW_NO_SESSION_MESSAGE) + assert.equal(getActiveProcess(fake.keyFor("ses_lonely")), undefined) + } finally { + fakeSdk.status.ses_main = { type: "idle" } + await fake.cleanup(["ses_main", "ses_lonely"]) + clearPendingSideQuestionAnswers() + } +}) + +test("an answer that arrives while a turn is streaming is written into that turn's own reply", { + timeout: 30_000, +}, async () => { + clearPendingSideQuestionAnswers() + clearAsideSinks() + const fake = createAsideCli() + const fakeSdk = fakeClient() + try { + // A first turn only so the conversation has a live process to ask. + assert.equal((await fake.turn("ses_inline", [user("Start.")])).answer, "Main answer") + fakeSdk.status.ses_inline = { type: "busy" } + + const streaming = fake.turn("ses_inline", [user("Start."), assistant("Main answer"), user("SLOW next.")]) + await assert.rejects( + // Bounded so a missing sink fails on the assertions below rather than + // hanging on a conversation this test never marks idle. + handleBtwCommand(fakeSdk.client, input("What did i say?", "ses_inline"), { + pollMs: 5, + inlinePollMs: 5, + inlineWaitMs: 3_000, + timeoutMs: 3_000, + }), + BtwHandledError, + "the message is dropped because the answer is already in the conversation", + ) + const turn = await streaming + assert.deepEqual(turn.errors, []) + assert.match(turn.answer, /▌ \*\*btw:\*\* What did i say\?/) + assert.match( + turn.answer, + /▌ \*\*btw:\*\* What did i say\?\n▌ \*sent to Claude on the side\*/, + "the receipt names the question it took, since no /btw message is ever created to show it", + ) + assert.match(turn.answer, /Aside 1: What did i say\?/) + assert.match(turn.answer, /Main answer/, "the turn still delivers its own reply") + assert.ok( + turn.answer.indexOf("sent to Claude on the side") < turn.answer.indexOf("Aside 1:"), + "a receipt that landed after its own answer would read backwards", + ) + assert.ok( + turn.parts.filter((part) => part.type === "text-start").length >= 3, + "receipt and answer are blocks of their own, so each marker leads a part", + ) + assert.deepEqual(fakeSdk.toasts(), [], "nothing is toasted: the conversation itself carries the answer") + assert.equal(emitAsideInline("ses_inline", "late"), false, "the sink goes with the stream") + } finally { + fakeSdk.status.ses_inline = { type: "idle" } + await fake.cleanup(["ses_inline"]) + clearAsideSinks() + clearPendingSideQuestionAnswers() + } +}) diff --git a/test-claude-session-wrapper.ts b/test-claude-session-wrapper.ts new file mode 100644 index 0000000..7aeb044 --- /dev/null +++ b/test-claude-session-wrapper.ts @@ -0,0 +1,176 @@ +import assert from "node:assert/strict" +import * as path from "node:path" +import { test } from "node:test" +import { + decodeUserEnvelope, + interactiveExtraArgs, + spawnInteractiveProcess, +} from "./src/claude-session-wrapper.js" +import { ClaudeSession, encodeCwd } from "./src/claude-session-bun.js" + +// --------------------------------------------------------------------------- +// decodeUserEnvelope — doStream writes stream-json envelopes to stdin; the +// interactive TUI must receive plain typed text, never raw JSON or base64. +// --------------------------------------------------------------------------- + +test("decodeUserEnvelope extracts text blocks from a stream-json envelope", () => { + const envelope = JSON.stringify({ + type: "user", + message: { + role: "user", + content: [ + { type: "text", text: "Hello there" }, + { type: "text", text: "(think)" }, + ], + }, + }) + assert.equal(decodeUserEnvelope(envelope), "Hello there\n\n(think)") +}) + +test("decodeUserEnvelope passes string message content through", () => { + const envelope = JSON.stringify({ + type: "user", + message: { role: "user", content: "plain string content" }, + }) + assert.equal(decodeUserEnvelope(envelope), "plain string content") +}) + +test("decodeUserEnvelope drops image blocks but keeps text", () => { + const envelope = JSON.stringify({ + type: "user", + message: { + role: "user", + content: [ + { type: "text", text: "look at this" }, + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "AAAA" }, + }, + ], + }, + }) + const decoded = decodeUserEnvelope(envelope) + assert.equal(decoded, "look at this") + assert.ok(!decoded.includes("AAAA"), "base64 must never reach the TUI") +}) + +test("decodeUserEnvelope renders tool_result blocks as labeled text", () => { + const envelope = JSON.stringify({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tu_1", + content: [{ type: "text", text: "exit code 0" }], + }, + ], + }, + }) + const decoded = decodeUserEnvelope(envelope) + assert.ok(decoded.includes("[Tool result tu_1]")) + assert.ok(decoded.includes("exit code 0")) +}) + +test("decodeUserEnvelope passes non-JSON input through verbatim", () => { + assert.equal(decodeUserEnvelope("just plain text"), "just plain text") +}) + +test("decodeUserEnvelope passes non-user JSON through verbatim", () => { + const control = JSON.stringify({ type: "control_response", response: {} }) + assert.equal(decodeUserEnvelope(control), control) +}) + +// --------------------------------------------------------------------------- +// encodeCwd — transcript dir name: every non-alphanumeric char becomes "-". +// --------------------------------------------------------------------------- + +test("encodeCwd replaces every non-alphanumeric char with a dash", () => { + // Use a relative-free absolute path so path.resolve is a no-op on POSIX. + if (process.platform === "win32") { + assert.equal(encodeCwd("C:\\dev\\My Project"), "C--dev-My-Project") + } else { + assert.equal(encodeCwd("/Users/me/my-app"), "-Users-me-my-app") + assert.equal(encodeCwd("/tmp/My Project"), "-tmp-My-Project") + } +}) + +test("ClaudeSession uses configDir for the transcript path", () => { + const configDir = path.join(process.cwd(), ".tmp-claude-config") + const cwd = path.join(process.cwd(), "workspace") + const session = new ClaudeSession({ cwd, configDir }) + assert.equal(session.configDir, configDir) + assert.equal( + session.jsonlPath, + path.join(configDir, "projects", encodeCwd(cwd), `${session.sessionId}.jsonl`), + ) +}) + +// --------------------------------------------------------------------------- +// spawnInteractiveProcess — ActiveProcess shim shape. No claude is spawned +// until the first stdin.write, so constructing + killing is offline-safe. +// --------------------------------------------------------------------------- + +test("spawnInteractiveProcess returns an ActiveProcess-shaped shim", () => { + const ap = spawnInteractiveProcess({ cwd: process.cwd() }) + const proc = ap.proc as any + assert.equal(typeof proc.stdin.write, "function") + assert.equal(typeof proc.kill, "function") + assert.equal(typeof proc.on, "function") + assert.equal(typeof proc.off, "function") + assert.equal(ap.proxyServer, null) + assert.equal(ap.mcpHash, undefined) + // kill() before any turn must be safe (no session started yet). + assert.equal(proc.kill(), true) + assert.equal(proc.killed, true) +}) + +test("spawnInteractiveProcess threads systemPromptFile into ActiveProcess", () => { + const ap = spawnInteractiveProcess({ + cwd: process.cwd(), + systemPromptFile: "/tmp/nonexistent-system-prompt.txt", + }) + assert.equal(ap.systemPromptFile, "/tmp/nonexistent-system-prompt.txt") + ;(ap.proc as any).kill() +}) + +// The skill bridge reaches the TUI through the same `--plugin-dir` flag as +// the headless spawn. `interactiveExtraArgs` is exactly what ClaudeSession +// appends to its argv, so this is the spawn argument list without a PTY. +test("interactiveExtraArgs passes one --plugin-dir per staged directory, keeping the single --settings payload", () => { + const args = interactiveExtraArgs({ + cwd: process.cwd(), + mcpConfigPaths: ["/tmp/mcp.json"], + pluginDirs: ["/tmp/skills-a", "/tmp/skills-b"], + permissionsAllow: ["Bash"], + fastMode: true, + }) + assert.deepEqual(args.slice(0, 3), ["--mcp-config", "/tmp/mcp.json", "--strict-mcp-config"]) + const dirs = args.reduce((acc, arg, i) => { + if (arg === "--plugin-dir") acc.push(args[i + 1]!) + return acc + }, []) + assert.deepEqual(dirs, ["/tmp/skills-a", "/tmp/skills-b"]) + assert.equal(args.filter((arg) => arg === "--settings").length, 1, "the CLI takes --settings once") + assert.deepEqual(JSON.parse(args[args.indexOf("--settings") + 1]!), { + permissions: { allow: ["Bash"] }, + fastMode: true, + }) +}) + +test("interactiveExtraArgs omits --plugin-dir when nothing was staged", () => { + for (const pluginDirs of [undefined, [] as string[]]) { + const args = interactiveExtraArgs({ cwd: process.cwd(), pluginDirs }) + assert.equal(args.includes("--plugin-dir"), false) + } +}) + +test("error handler registration is add/remove symmetric", () => { + const ap = spawnInteractiveProcess({ cwd: process.cwd() }) + const proc = ap.proc as any + const handler = () => {} + proc.on("error", handler) + proc.off("error", handler) + proc.kill() +}) diff --git a/test-cli-args.ts b/test-cli-args.ts new file mode 100644 index 0000000..c3ab338 --- /dev/null +++ b/test-cli-args.ts @@ -0,0 +1,607 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { + buildCliArgs, + claudeSpawnEnv, + isClaudeThinkingDisabled, +} from "./src/session-manager.js" +import { + cliSupportsFastMode, + cliSupportsThinking, + cliSupportsThinkingDisplay, +} from "./src/cli-version.js" +import { parseModelId } from "./src/models.js" +import { + reportFastModeState, + _resetFastModeWarnings, +} from "./src/claude-code-language-model.js" +import { configureLogger, _resetLoggerForTests } from "./src/logger.js" +import { + disallowedToolFlags, + resolveDisallowedTools, + type ProxyToolDef, +} from "./src/proxy-mcp.js" + +function withClaudeThinkingEnv( + env: { + disableThinking?: string + disableAdaptiveThinking?: string + showSummaries?: string + }, + fn: () => T, +): T { + const previous = { + disableThinking: process.env.CLAUDE_CODE_DISABLE_THINKING, + disableAdaptiveThinking: process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING, + showSummaries: process.env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES, + } + + try { + if (env.disableThinking === undefined) { + delete process.env.CLAUDE_CODE_DISABLE_THINKING + } else { + process.env.CLAUDE_CODE_DISABLE_THINKING = env.disableThinking + } + if (env.disableAdaptiveThinking === undefined) { + delete process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING + } else { + process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING = env.disableAdaptiveThinking + } + if (env.showSummaries === undefined) { + delete process.env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES + } else { + process.env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES = env.showSummaries + } + return fn() + } finally { + if (previous.disableThinking === undefined) { + delete process.env.CLAUDE_CODE_DISABLE_THINKING + } else { + process.env.CLAUDE_CODE_DISABLE_THINKING = previous.disableThinking + } + if (previous.disableAdaptiveThinking === undefined) { + delete process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING + } else { + process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING = previous.disableAdaptiveThinking + } + if (previous.showSummaries === undefined) { + delete process.env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES + } else { + process.env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES = previous.showSummaries + } + } +} + +test("thinking-display is gated on Claude Code CLI 2.1.142+", () => { + assert.equal(cliSupportsThinkingDisplay(null), false) + assert.equal( + cliSupportsThinkingDisplay({ major: 2, minor: 1, patch: 141, raw: "2.1.141" }), + false, + ) + assert.equal( + cliSupportsThinkingDisplay({ major: 2, minor: 1, patch: 142, raw: "2.1.142" }), + true, + ) + assert.equal( + cliSupportsThinkingDisplay({ major: 2, minor: 2, patch: 0, raw: "2.2.0" }), + true, + ) +}) + +test("buildCliArgs skips unsupported thinking-display flag", () => { + const args = buildCliArgs({ + sessionKey: "test", + skipPermissions: true, + model: "claude-opus-4-7", + thinking: "enabled", + thinkingDisplay: "summarized", + cliVersion: { major: 2, minor: 1, patch: 141, raw: "2.1.141" }, + }) + + assert.equal(args.includes("--thinking"), true) + assert.equal(args.includes("enabled"), true) + assert.equal(args.includes("--thinking-display"), false) + assert.equal(args.includes("summarized"), false) +}) + +test("cliSupportsThinking floors at 2.0.0", () => { + assert.equal(cliSupportsThinking(null), false) + assert.equal( + cliSupportsThinking({ major: 1, minor: 99, patch: 99, raw: "1.99.99" }), + false, + ) + assert.equal( + cliSupportsThinking({ major: 2, minor: 0, patch: 0, raw: "2.0.0" }), + true, + ) + assert.equal( + cliSupportsThinking({ major: 2, minor: 1, patch: 142, raw: "2.1.142" }), + true, + ) +}) + +test("buildCliArgs skips --thinking when cliVersion is unknown", () => { + const args = buildCliArgs({ + sessionKey: "test", + skipPermissions: true, + model: "claude-opus-4-7", + thinking: "enabled", + cliVersion: null, + }) + + assert.equal(args.includes("--thinking"), false) + assert.equal(args.includes("enabled"), false) +}) + +test("buildCliArgs skips --thinking on pre-2.x CLI", () => { + const args = buildCliArgs({ + sessionKey: "test", + skipPermissions: true, + model: "claude-opus-4-7", + thinking: "enabled", + cliVersion: { major: 1, minor: 5, patch: 0, raw: "1.5.0" }, + }) + + assert.equal(args.includes("--thinking"), false) +}) + +test("buildCliArgs emits thinking-display for supported CLI", () => { + const args = buildCliArgs({ + sessionKey: "test", + skipPermissions: true, + model: "claude-opus-4-7", + thinking: "enabled", + thinkingDisplay: "summarized", + cliVersion: { major: 2, minor: 1, patch: 142, raw: "2.1.142" }, + }) + + assert.equal(args.includes("--thinking"), true) + assert.equal(args.includes("enabled"), true) + assert.equal(args.includes("--thinking-display"), true) + assert.equal(args.includes("summarized"), true) +}) + +// Fast mode. There is no `--fast` flag and no fast model name the CLI still +// accepts: `--settings {"fastMode":true}` is the only headless opt-in, because +// the CLI's SDK gate reads the *flag* settings layer specifically. Verified +// live against Claude Code 2.1.245 on 2026-08-30: without it the init message +// reports `fast_mode_disabled_reason: "sdk_opt_in_required"`. +test("buildCliArgs opts into fast mode via --settings", () => { + const args = buildCliArgs({ + sessionKey: "test", + skipPermissions: true, + model: "claude-opus-5", + fastMode: true, + cliVersion: { major: 2, minor: 1, patch: 245, raw: "2.1.245" }, + }) + + const at = args.indexOf("--settings") + assert.notEqual(at, -1) + assert.deepEqual(JSON.parse(args[at + 1]!), { fastMode: true }) +}) + +test("buildCliArgs omits --settings when fast mode is not requested", () => { + const args = buildCliArgs({ + sessionKey: "test", + skipPermissions: true, + model: "claude-opus-5", + cliVersion: { major: 2, minor: 1, patch: 245, raw: "2.1.245" }, + }) + + assert.equal(args.includes("--settings"), false) +}) + +test("buildCliArgs skips the fast-mode opt-in on an unverified CLI", () => { + for (const cliVersion of [ + null, + { major: 2, minor: 1, patch: 219, raw: "2.1.219" }, + ]) { + const args = buildCliArgs({ + sessionKey: "test", + skipPermissions: true, + model: "claude-opus-5", + fastMode: true, + cliVersion, + }) + assert.equal(args.includes("--settings"), false) + } +}) + +test("cliSupportsFastMode floors at 2.1.220", () => { + assert.equal(cliSupportsFastMode(null), false) + assert.equal( + cliSupportsFastMode({ major: 2, minor: 1, patch: 219, raw: "2.1.219" }), + false, + ) + assert.equal( + cliSupportsFastMode({ major: 2, minor: 1, patch: 220, raw: "2.1.220" }), + true, + ) + assert.equal( + cliSupportsFastMode({ major: 2, minor: 2, patch: 0, raw: "2.2.0" }), + true, + ) +}) + +// The `-fast` marker is ours and must never reach `--model`; the `@account` +// suffix is accounts.ts's and must survive, since the wrapper script strips it +// to pick a CLAUDE_CONFIG_DIR. +test("parseModelId strips the fast marker and keeps the account suffix", () => { + assert.deepEqual(parseModelId("claude-opus-5-fast"), { + model: "claude-opus-5", + fast: true, + }) + assert.deepEqual(parseModelId("claude-opus-4-8-fast"), { + model: "claude-opus-4-8", + fast: true, + }) + assert.deepEqual(parseModelId("claude-opus-5-fast@work"), { + model: "claude-opus-5@work", + fast: true, + }) +}) + +test("parseModelId leaves standard model ids untouched", () => { + assert.deepEqual(parseModelId("claude-opus-5"), { + model: "claude-opus-5", + fast: false, + }) + assert.deepEqual(parseModelId("claude-opus-5@work"), { + model: "claude-opus-5@work", + fast: false, + }) + assert.deepEqual(parseModelId("claude-haiku-4-5"), { + model: "claude-haiku-4-5", + fast: false, + }) +}) + +test("parseModelId does not claim a -fast id it never registered", () => { + // A user-defined model that happens to end in `-fast` must pass through + // whole. Rewriting it would hand `--model` a name the CLI cannot resolve. + assert.deepEqual(parseModelId("some-vendor-model-fast"), { + model: "some-vendor-model-fast", + fast: false, + }) + // Retired Anthropic fast ids are not registered either, so they are not + // silently rewritten into something that looks like it worked. + assert.deepEqual(parseModelId("claude-opus-4-6-fast"), { + model: "claude-opus-4-6-fast", + fast: false, + }) +}) + +// A downgrade must reach the TUI. `notice` is debug-mode-only in this codebase, +// so a blocked account has to warn or the 10x price tag in the picker silently +// stops matching what is actually billed. +function captureLogs(fn: () => void): string[] { + const lines: string[] = [] + const original = console.error + console.error = (line: unknown) => { + lines.push(String(line)) + } + try { + _resetLoggerForTests() + // `debug` mode so info/notice/debug also reach stderr and the test can + // assert on the level actually chosen. warn/error reach it either way. + configureLogger({ mode: "debug", level: "debug" }) + fn() + } finally { + console.error = original + _resetLoggerForTests() + } + return lines +} + +test("reportFastModeState warns when a requested fast turn was downgraded", () => { + _resetFastModeWarnings() + const lines = captureLogs(() => { + reportFastModeState( + { + type: "system", + subtype: "init", + fast_mode_state: "off", + fast_mode_disabled_reason: "extra_usage_disabled", + }, + true, + ) + }) + + assert.equal(lines.length, 1) + assert.match(lines[0]!, /WARN/) + assert.match(lines[0]!, /\/usage-credits/) + assert.match(lines[0]!, /standard Opus rates/) +}) + +test("reportFastModeState warns once per reason, then drops to debug", () => { + _resetFastModeWarnings() + const msg = { + type: "system", + subtype: "init", + fast_mode_state: "off" as const, + fast_mode_disabled_reason: "extra_usage_disabled", + } + + const lines = captureLogs(() => { + reportFastModeState(msg, true) + reportFastModeState(msg, true) + reportFastModeState(msg, true) + }) + + // Account-level blocks persist across respawns; warning every time would + // bury the TUI. + assert.equal(lines.filter((l) => l.includes("WARN")).length, 1) + assert.equal(lines.filter((l) => l.includes("DEBUG")).length, 2) +}) + +test("reportFastModeState stays quiet when fast mode was never requested", () => { + _resetFastModeWarnings() + const lines = captureLogs(() => { + reportFastModeState( + { + type: "system", + subtype: "init", + fast_mode_state: "off", + fast_mode_disabled_reason: "sdk_opt_in_required", + }, + false, + ) + }) + + assert.equal(lines.filter((l) => l.includes("WARN")).length, 0) +}) + +test("reportFastModeState does not warn when fast mode is actually on", () => { + _resetFastModeWarnings() + const lines = captureLogs(() => { + reportFastModeState( + { type: "system", subtype: "init", fast_mode_state: "on" }, + true, + ) + }) + + assert.equal(lines.filter((l) => l.includes("WARN")).length, 0) + assert.equal(lines.filter((l) => l.includes("INFO")).length, 1) +}) + +test("reportFastModeState treats cooldown as transient, not a misconfiguration", () => { + _resetFastModeWarnings() + const lines = captureLogs(() => { + reportFastModeState( + { type: "system", subtype: "init", fast_mode_state: "cooldown" }, + true, + ) + }) + + assert.equal(lines.filter((l) => l.includes("WARN")).length, 0) + assert.equal(lines.filter((l) => l.includes("NOTICE")).length, 1) +}) + +test("Claude thinking env defaults preserve explicit user choices", () => { + withClaudeThinkingEnv({}, () => { + assert.equal(isClaudeThinkingDisabled(), false) + assert.equal(claudeSpawnEnv().CLAUDE_CODE_SHOW_THINKING_SUMMARIES, "1") + }) + + withClaudeThinkingEnv({ showSummaries: "0" }, () => { + assert.equal(isClaudeThinkingDisabled(), false) + assert.equal(claudeSpawnEnv().CLAUDE_CODE_SHOW_THINKING_SUMMARIES, "0") + }) + + withClaudeThinkingEnv({ disableThinking: "1" }, () => { + assert.equal(isClaudeThinkingDisabled(), true) + assert.equal(claudeSpawnEnv().CLAUDE_CODE_SHOW_THINKING_SUMMARIES, undefined) + }) + + withClaudeThinkingEnv({ disableAdaptiveThinking: "false" }, () => { + assert.equal(isClaudeThinkingDisabled(), false) + assert.equal(claudeSpawnEnv().CLAUDE_CODE_SHOW_THINKING_SUMMARIES, "1") + }) +}) + +// `disallowedToolFlags` translates resolved proxy tool names into the +// Claude built-ins that must be passed to `--disallowedTools` so the +// model can only reach the proxied MCP version. The `question` row is +// the new one — it must disable Claude's built-in `AskUserQuestion` so +// the structured-questions path flows through opencode's `question` tool. +function proxyDef(name: string): ProxyToolDef { + return { + name, + description: "", + inputSchema: { type: "object", properties: {} }, + } +} + +test("disallowedToolFlags maps each proxy tool to its Claude built-ins", () => { + assert.deepEqual( + disallowedToolFlags([proxyDef("bash")]), + ["Bash"], + ) + assert.deepEqual( + disallowedToolFlags([proxyDef("write")]), + ["Write"], + ) + // Edit also disables MultiEdit (opencode has no batched-edit equivalent). + assert.deepEqual( + disallowedToolFlags([proxyDef("edit")]), + ["Edit", "MultiEdit"], + ) + assert.deepEqual( + disallowedToolFlags([proxyDef("webfetch")]), + ["WebFetch"], + ) + assert.deepEqual( + disallowedToolFlags([proxyDef("task")]), + ["Agent"], + ) +}) + +test("disallowedToolFlags disables AskUserQuestion for the question proxy", () => { + assert.deepEqual( + disallowedToolFlags([proxyDef("question")]), + ["AskUserQuestion"], + ) +}) + +test("disallowedToolFlags is case-insensitive on the proxy tool name", () => { + // `resolvedProxyTools` lowercases when matching DEFAULT_PROXY_TOOLS, but + // disallowedToolFlags must tolerate either casing since callers pass the + // def name as-authored. + assert.deepEqual( + disallowedToolFlags([proxyDef("Question")]), + ["AskUserQuestion"], + ) + assert.deepEqual( + disallowedToolFlags([proxyDef("TASK")]), + ["Agent"], + ) +}) + +test("disallowedToolFlags dedupes and preserves order across combined defs", () => { + // A real config typically has several proxies at once. + const out = disallowedToolFlags([ + proxyDef("bash"), + proxyDef("edit"), + proxyDef("write"), + proxyDef("task"), + proxyDef("question"), + ]) + assert.deepEqual(out, [ + "Bash", + "Edit", + "MultiEdit", + "Write", + "Agent", + "AskUserQuestion", + ]) +}) + +test("disallowedToolFlags ignores proxy tools with no Claude equivalent", () => { + // MCP-bridged proxy tools (server-derived names) have no entry in the + // nameMap and must be skipped, not crash. + assert.deepEqual( + disallowedToolFlags([proxyDef("slack_post_message")]), + [], + ) + assert.deepEqual( + disallowedToolFlags([proxyDef("bash"), proxyDef("slack_post_message")]), + ["Bash"], + ) +}) + +// Issue #26: proxyTools is an allowlist by omission. A built-in the plugin +// has no proxy for (NotebookEdit today, whatever ships next) can only be +// closed by naming it directly. +test("resolveDisallowedTools merges proxy-implied and operator-named tools", () => { + assert.deepEqual( + resolveDisallowedTools({ + proxyTools: [proxyDef("bash"), proxyDef("edit")], + extraDisallowedTools: ["NotebookEdit"], + }), + ["Bash", "Edit", "MultiEdit", "NotebookEdit"], + ) +}) + +test("resolveDisallowedTools works with no proxy tools at all", () => { + assert.deepEqual( + resolveDisallowedTools({ + proxyTools: null, + extraDisallowedTools: ["NotebookEdit", "Skill"], + }), + ["NotebookEdit", "Skill"], + ) +}) + +test("resolveDisallowedTools does not repeat a tool the proxy already disabled", () => { + assert.deepEqual( + resolveDisallowedTools({ + proxyTools: [proxyDef("bash")], + extraDisallowedTools: ["Bash", " ", "Bash"], + }), + ["Bash"], + ) +}) + +test("resolveDisallowedTools still appends WebSearch when it is disabled", () => { + assert.deepEqual( + resolveDisallowedTools({ + proxyTools: [proxyDef("bash")], + extraDisallowedTools: ["NotebookEdit"], + disableWebSearch: true, + }), + ["Bash", "NotebookEdit", "WebSearch"], + ) +}) + +test("resolveDisallowedTools is empty when nothing asks for anything", () => { + assert.deepEqual(resolveDisallowedTools({}), []) +}) + +test("plan mode drops --dangerously-skip-permissions so it cannot permit edits", () => { + const args = buildCliArgs({ + sessionKey: "plan-mode", + skipPermissions: true, + permissionMode: "plan", + }) + + assert.equal(args.includes("--dangerously-skip-permissions"), false) + assert.deepEqual(args.slice(-2), ["--permission-mode", "plan"]) +}) + +test("every other permission mode still passes the skip flag", () => { + for (const mode of ["acceptEdits", "auto", "bypassPermissions", "default", "dontAsk"]) { + const args = buildCliArgs({ + sessionKey: `mode-${mode}`, + skipPermissions: true, + permissionMode: mode, + }) + + assert.equal( + args.includes("--dangerously-skip-permissions"), + true, + `${mode} should keep the skip flag`, + ) + } +}) + +test("plan mode without skipPermissions is unchanged", () => { + const args = buildCliArgs({ + sessionKey: "plan-mode-explicit", + skipPermissions: false, + permissionMode: "plan", + }) + + assert.equal(args.includes("--dangerously-skip-permissions"), false) + assert.equal(args.includes("plan"), true) +}) + +test("plan mode warns once that nothing can release it mid-session", async () => { + const { warnIfPlanModeCannotExit, _resetPlanModeWarningForTests } = await import( + "./src/index.js" + ) + + _resetPlanModeWarningForTests() + const lines = captureLogs(() => { + warnIfPlanModeCannotExit("plan") + warnIfPlanModeCannotExit("plan") + }) + + assert.equal(lines.length, 1) + assert.match(lines[0]!, /WARN/) + assert.match(lines[0]!, /ExitPlanMode/) + assert.match(lines[0]!, /restarting opencode/) +}) + +test("no plan-mode warning for other permission modes", async () => { + const { warnIfPlanModeCannotExit, _resetPlanModeWarningForTests } = await import( + "./src/index.js" + ) + + _resetPlanModeWarningForTests() + const lines = captureLogs(() => { + warnIfPlanModeCannotExit("acceptEdits") + warnIfPlanModeCannotExit(undefined) + }) + + assert.deepEqual(lines, []) +}) diff --git a/test-cli-events-stream.ts b/test-cli-events-stream.ts new file mode 100644 index 0000000..8d73271 --- /dev/null +++ b/test-cli-events-stream.ts @@ -0,0 +1,362 @@ +/** + * The CLI-event work as opencode actually sees it: a fake `claude` emits the + * stream lines and the assertions are on the AI SDK parts that come out of + * `doStream`. + * + * The unit tests in `test-cli-events.ts` and `test-turn-stats.ts` cover the + * parsers and formatters; this file covers the wiring, which is the half a + * pure test cannot see: whether a failed CLI tool reaches opencode with the + * error flag, whether a failing result subtype still finishes as `stop`, and + * whether the stats footer is gated on the option. + * + * Usage: npx tsx --test test-cli-events-stream.ts + */ +import assert from "node:assert/strict" +import { test } from "node:test" +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { _resetRateLimitReports, _resetSystemInitReports } from "./src/cli-events.js" +import { createClaudeCode } from "./src/index.js" +import { deleteActiveProcess, sessionKey } from "./src/session-manager.js" + +/** A fake `claude` that replays a fixed line sequence on the first stdin write. */ +function createFakeCli(lines: unknown[]) { + const cwd = mkdtempSync(join(tmpdir(), "opencode-cli-events-")) + const cliPath = join(cwd, "fake-claude.cjs") + const source = `#!/usr/bin/env node +const readline = require("node:readline") + +if (process.argv.includes("--version")) { + process.stdout.write("2.1.263\\n") + process.exit(0) +} + +const LINES = ${JSON.stringify(lines)} +const rl = readline.createInterface({ input: process.stdin }) +let answered = false +rl.on("line", () => { + if (answered) return + answered = true + for (const line of LINES) process.stdout.write(JSON.stringify(line) + "\\n") +}) +` + writeFileSync(cliPath, source) + chmodSync(cliPath, 0o755) + return { cliPath, cwd } +} + +async function streamParts( + lines: unknown[], + settings: Record = {}, +): Promise { + _resetRateLimitReports() + _resetSystemInitReports() + const fake = createFakeCli(lines) + const modelId = "claude-test-cli-events" + const sk = sessionKey( + fake.cwd, + `${modelId}::tools::default::context=["claude-code",null]`, + ) + try { + const model = createClaudeCode({ + cliPath: fake.cliPath, + cwd: fake.cwd, + bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, + proxyTools: [], + ...settings, + }).languageModel(modelId) + + const response = await model.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "go" }] }], + // Presence of tools is what selects the real streaming path. + tools: [ + { + type: "function", + name: "read", + description: "Read a file", + inputSchema: { type: "object", properties: {} }, + }, + ], + } as any) + + const parts: any[] = [] + for await (const part of response.stream) parts.push(part) + return parts + } finally { + deleteActiveProcess(sk) + rmSync(fake.cwd, { recursive: true, force: true }) + } +} + +const init = { type: "system", subtype: "init", session_id: "fake-session", tools: ["Read"] } + +function assistantToolUse(id: string, name: string) { + return { + type: "stream_event", + session_id: "fake-session", + event: { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id, name }, + }, + } +} + +function blockStop(index = 0) { + return { + type: "stream_event", + session_id: "fake-session", + event: { type: "content_block_stop", index }, + } +} + +const successResult = { + type: "result", + subtype: "success", + session_id: "fake-session", + is_error: false, + result: "done", + total_cost_usd: 0.0123, + duration_ms: 4200, + duration_api_ms: 4000, + num_turns: 2, + usage: { + input_tokens: 1234, + output_tokens: 812, + cache_read_input_tokens: 45_120, + cache_creation_input_tokens: 2048, + }, + modelUsage: { "claude-opus-5": { inputTokens: 1234, outputTokens: 812 } }, + permission_denials: [{ tool_name: "Bash", tool_use_id: "toolu_denied" }], +} + +const text = (body: string) => ({ + type: "stream_event", + session_id: "fake-session", + event: { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: body } }, +}) + +/** + * The real CLI always reports a stop reason, and the plugin treats one as + * authoritative. Without it these fixtures fall through to the auto-continue + * keyword heuristic, which nudges the fake for more output it will never send. + */ +const endTurn = { + type: "stream_event", + session_id: "fake-session", + event: { type: "message_delta", delta: { stop_reason: "end_turn" } }, +} + +test("a CLI tool that failed reaches opencode flagged as an error", async () => { + const parts = await streamParts([ + init, + assistantToolUse("toolu_fail", "Read"), + blockStop(), + { + type: "user", + session_id: "fake-session", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_fail", + content: "ENOENT: no such file", + is_error: true, + }, + ], + }, + }, + text("sorry"), + endTurn, + successResult, + ]) + + const result = parts.find( + (part) => part.type === "tool-result" && part.toolCallId === "toolu_fail", + ) + assert.ok(result, "the failed tool result must still be forwarded") + // Without the flag this is undefined and the AI SDK emits an ordinary + // `tool-result`, so opencode renders a failed CLI tool as a success whose + // output happens to be an error message. + assert.equal(result.isError, true) + assert.deepEqual(result.result.metadata, { error: true }) +}) + +test("a CLI tool that succeeded is not flagged", async () => { + const parts = await streamParts([ + init, + assistantToolUse("toolu_ok", "Read"), + blockStop(), + { + type: "user", + session_id: "fake-session", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_ok", content: "file body" }], + }, + }, + endTurn, + successResult, + ]) + + const result = parts.find( + (part) => part.type === "tool-result" && part.toolCallId === "toolu_ok", + ) + assert.ok(result) + assert.equal(result.isError, undefined) + assert.deepEqual(result.result.metadata, {}) +}) + +test("a failing result subtype ends the turn as an error, naming the subtype", async () => { + const parts = await streamParts([ + init, + text("partial work"), + endTurn, + { + type: "result", + subtype: "error_max_turns", + session_id: "fake-session", + is_error: true, + result: "", + duration_ms: 1000, + num_turns: 8, + }, + ]) + + const finish = parts.find((part) => part.type === "finish") + // Previously this was an unconditional `stop`, so opencode recorded a failed + // turn as an ordinary reply. + assert.equal(finish.finishReason.unified, "error") + assert.equal(finish.finishReason.raw, "error_max_turns") + assert.equal(finish.providerMetadata["claude-code"].resultSubtype, "error_max_turns") + + const body = parts + .filter((part) => part.type === "text-delta") + .map((part) => part.delta) + .join("") + assert.match(body, /error_max_turns/) + assert.match(body, /internal turn limit/) +}) + +test("a successful turn still finishes as a clean stop", async () => { + const parts = await streamParts([init, text("done"), endTurn, successResult]) + const finish = parts.find((part) => part.type === "finish") + assert.equal(finish.finishReason.unified, "stop") + assert.equal(finish.providerMetadata["claude-code"].resultSubtype, undefined) +}) + +test("modelUsage and permission denials reach providerMetadata", async () => { + const parts = await streamParts([init, text("done"), endTurn, successResult]) + const meta = parts.find((part) => part.type === "finish").providerMetadata["claude-code"] + assert.deepEqual(meta.modelUsage, { + "claude-opus-5": { inputTokens: 1234, outputTokens: 812 }, + }) + assert.equal(meta.numTurns, 2) + assert.equal(meta.durationApiMs, 4000) + // Names and ids only: a denial's tool_input can be a whole file payload. + assert.deepEqual(meta.permissionDenials, [ + { tool_name: "Bash", tool_use_id: "toolu_denied" }, + ]) +}) + +test("the stats footer appears only when turnStats is on", async () => { + const off = await streamParts([init, text("done"), endTurn, successResult]) + const offText = off + .filter((part) => part.type === "text-delta") + .map((part) => part.delta) + .join("") + assert.equal(offText.includes("**stats:**"), false) + + const on = await streamParts([init, text("done"), endTurn, successResult], { turnStats: true }) + const onText = on + .filter((part) => part.type === "text-delta") + .map((part) => part.delta) + .join("") + assert.match(onText, /▌ \*\*stats:\*\* \$0\.0123 · 4\.2 s · 2 CLI turns/) + assert.match(onText, /cache read 45\.1k · cache write 2\.0k/) + + // Its own text part, which is what makes the transcript strip exact. + const footerStart = on.findIndex( + (part) => part.type === "text-delta" && part.delta.includes("**stats:**"), + ) + assert.ok(footerStart > 0) + assert.equal(on[footerStart - 1].type, "text-start") +}) + +test("a failed turn gets no stats footer even with turnStats on", async () => { + const parts = await streamParts( + [ + init, + text("partial"), + endTurn, + { + type: "result", + subtype: "error_during_execution", + session_id: "fake-session", + is_error: true, + result: "", + total_cost_usd: 0.5, + duration_ms: 1000, + num_turns: 1, + }, + ], + { turnStats: true }, + ) + const body = parts + .filter((part) => part.type === "text-delta") + .map((part) => part.delta) + .join("") + assert.equal(body.includes("**stats:**"), false) + assert.match(body, /error_during_execution/) +}) + +test("a rate-limit rejection is written into the transcript", async () => { + const parts = await streamParts([ + init, + { + type: "rate_limit_event", + session_id: "fake-session", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + overageStatus: "rejected", + overageDisabledReason: "org_level_disabled", + }, + }, + text("cannot continue"), + endTurn, + successResult, + ]) + const body = parts + .filter((part) => part.type === "text-delta") + .map((part) => part.delta) + .join("") + assert.match(body, /▌ \*\*rate limit:\*\*/) + assert.match(body, /out of usage in the 5-hour window/) + assert.match(body, /wait for the window to reset/) +}) + +test("a CLI self-compaction is announced in the transcript", async () => { + const parts = await streamParts([ + init, + { + type: "system", + subtype: "compact_boundary", + session_id: "fake-session", + compact_metadata: { trigger: "auto", pre_tokens: 180_000, post_tokens: 40_000 }, + }, + text("carrying on"), + endTurn, + successResult, + ]) + const body = parts + .filter((part) => part.type === "text-delta") + .map((part) => part.delta) + .join("") + assert.match(body, /▌ \*\*context compacted:\*\* Claude Code compacted its own context on its own/) + assert.match(body, /180,000 tokens to 40,000/) +}) diff --git a/test-cli-events.ts b/test-cli-events.ts new file mode 100644 index 0000000..24571c6 --- /dev/null +++ b/test-cli-events.ts @@ -0,0 +1,250 @@ +/** + * Claude CLI stream events the plugin used to drop: `rate_limit_event`, + * `system`/`init`, `system`/`compact_boundary`, and a `result` whose subtype + * is not `success`. + * + * Every payload here is the shape read out of the CLI's own zod schemas in the + * installed 2.1.263 bundle, so a parser that stops matching is a real drift + * signal and not a fixture that went stale on its own. + * + * Usage: npx tsx --test test-cli-events.ts + */ +import assert from "node:assert/strict" +import { test } from "node:test" +import { + API_KEY_SOURCES, + COMPACT_BOUNDARY_MARKER, + RATE_LIMIT_MARKER, + RESULT_ERROR_MARKER, + _resetRateLimitReports, + _resetSystemInitReports, + apiKeySourceWarning, + describeRateLimit, + describeResultFailure, + formatCompactBoundaryNote, + formatResetsAt, + formatResultFailureNote, + parseCompactBoundary, + parseRateLimitEvent, + parseSystemInit, + rateLimitKey, + reportCompactBoundary, + reportRateLimitEvent, + reportSystemInit, +} from "./src/cli-events.js" +import { _resetLoggerForTests, configureLogger } from "./src/logger.js" +import type { ClaudeStreamMessage } from "./src/types.js" + +/** Capture what reaches the TUI: only warn/error are unconditionally on stderr. */ +function captureStderr(run: () => T): { value: T; lines: string[] } { + const lines: string[] = [] + const original = console.error + console.error = (line: unknown) => { + lines.push(String(line)) + } + try { + return { value: run(), lines } + } finally { + console.error = original + } +} + +const rejected: ClaudeStreamMessage = { + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + resetsAt: 1_757_000_000, + overageStatus: "rejected", + overageDisabledReason: "org_level_disabled", + isUsingOverage: false, + }, +} + +test("parseRateLimitEvent reads the documented rate_limit_info shape", () => { + const info = parseRateLimitEvent(rejected) + assert.equal(info?.status, "rejected") + assert.equal(info?.rateLimitType, "five_hour") + assert.equal(info?.overageDisabledReason, "org_level_disabled") + assert.equal(info?.resetsAt, 1_757_000_000) + assert.equal(parseRateLimitEvent({ type: "result" }), null) + assert.equal(parseRateLimitEvent({ type: "rate_limit_event" }), null) +}) + +test("formatResetsAt reads unix seconds and tolerates milliseconds", () => { + assert.equal(formatResetsAt(1_757_000_000), "2025-09-04T15:33:20.000Z") + assert.equal(formatResetsAt(1_757_000_000_000), "2025-09-04T15:33:20.000Z") + assert.equal(formatResetsAt(undefined), undefined) +}) + +test("a rejection warns, explains the reason, and says what can be done", () => { + const report = describeRateLimit(parseRateLimitEvent(rejected)!) + assert.equal(report?.level, "warn") + assert.match(report!.message, /out of usage in the 5-hour window/) + assert.match(report!.message, /extra usage is disabled for your organization/) + assert.match(report!.message, /Resets at 2025-09-04T15:33:20\.000Z/) + assert.match(report!.message, /wait for the window to reset/) + assert.ok(report!.transcript?.startsWith(`\n${RATE_LIMIT_MARKER} `)) +}) + +test("a warning state is a notice with nothing in the transcript", () => { + const report = describeRateLimit( + parseRateLimitEvent({ + type: "rate_limit_event", + rate_limit_info: { status: "allowed_warning", rateLimitType: "seven_day", utilization: 0.82 }, + })!, + ) + assert.equal(report?.level, "notice") + assert.match(report!.message, /82% used/) + assert.equal(report!.transcript, null) +}) + +test("rate limits warn once per identity per process", () => { + _resetLoggerForTests() + _resetRateLimitReports() + configureLogger({ file: false, mode: "silent", level: "info" }) + + const first = captureStderr(() => reportRateLimitEvent(rejected)) + assert.ok(first.value?.includes(RATE_LIMIT_MARKER), "the first rejection is surfaced") + assert.equal(first.lines.length, 1, "and warns in the TUI") + + const second = captureStderr(() => reportRateLimitEvent(rejected)) + assert.equal(second.value, null, "the same rejection is not repeated") + assert.equal(second.lines.length, 0) + + const other = captureStderr(() => + reportRateLimitEvent({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType: "seven_day" }, + }), + ) + assert.ok(other.value, "a different window is its own warning") + assert.equal(other.lines.length, 1) + _resetLoggerForTests() +}) + +test("rateLimitKey separates the window, the overage status and the reason", () => { + assert.notEqual( + rateLimitKey({ status: "rejected", rateLimitType: "five_hour" }), + rateLimitKey({ status: "rejected", rateLimitType: "seven_day" }), + ) + assert.notEqual( + rateLimitKey({ status: "rejected", overageDisabledReason: "out_of_credits" }), + rateLimitKey({ status: "rejected", overageDisabledReason: "org_level_disabled" }), + ) +}) + +const init: ClaudeStreamMessage = { + type: "system", + subtype: "init", + apiKeySource: "ANTHROPIC_API_KEY", + permissionMode: "default", + model: "claude-opus-5", + claude_code_version: "2.1.263", + tools: ["Bash", "Read", "Write"], + mcp_servers: [ + { name: "github", status: "connected" }, + { name: "slack", status: "failed" }, + ], +} + +test("parseSystemInit reads the init fields worth reporting", () => { + const info = parseSystemInit(init) + assert.equal(info?.apiKeySource, "ANTHROPIC_API_KEY") + assert.equal(info?.permissionMode, "default") + assert.equal(info?.model, "claude-opus-5") + assert.equal(info?.cliVersion, "2.1.263") + assert.equal(info?.toolCount, 3) + assert.deepEqual(info?.mcpServers, [ + { name: "github", status: "connected" }, + { name: "slack", status: "failed" }, + ]) + assert.equal(parseSystemInit({ type: "system", subtype: "compact_boundary" }), null) +}) + +test("apiKeySourceWarning fires for a key and stays quiet for the subscription", () => { + assert.equal(apiKeySourceWarning("oauth", false), null) + assert.equal(apiKeySourceWarning("none", false), null) + assert.equal(apiKeySourceWarning(undefined, false), null) + for (const source of API_KEY_SOURCES) { + assert.ok(apiKeySourceWarning(source, false), `expected a warning for ${source}`) + } + assert.match(apiKeySourceWarning("ANTHROPIC_API_KEY", false)!, /ignoreAnthropicApiKey: true/) + // Already stripping the env vars, so the key came from the CLI's own config + // and the option is not the fix to suggest. + assert.match(apiKeySourceWarning("ANTHROPIC_API_KEY", true)!, /claude config/) +}) + +test("init warns once per failed MCP server and once per api key source", () => { + _resetLoggerForTests() + _resetSystemInitReports() + configureLogger({ file: false, mode: "silent", level: "info" }) + + const first = captureStderr(() => reportSystemInit(init, {})) + assert.equal(first.lines.length, 2, "one for the failed MCP server, one for the API key") + assert.ok(first.lines.some((line) => line.includes('"slack" is failed'))) + assert.ok(first.lines.some((line) => line.includes("apiKeySource: ANTHROPIC_API_KEY"))) + assert.equal( + first.lines.some((line) => line.includes("github")), + false, + "a connected server is not a warning", + ) + + const second = captureStderr(() => reportSystemInit(init, {})) + assert.equal(second.lines.length, 0, "a respawn must not repeat either warning") + _resetLoggerForTests() +}) + +test("compact_boundary is parsed from either spelling of its metadata", () => { + const streamShape = parseCompactBoundary({ + type: "system", + subtype: "compact_boundary", + compact_metadata: { trigger: "auto", pre_tokens: 180_000, post_tokens: 40_000 }, + }) + assert.deepEqual(streamShape, { trigger: "auto", preTokens: 180_000, postTokens: 40_000 }) + + const transcriptShape = parseCompactBoundary({ + type: "system", + subtype: "compact_boundary", + compactMetadata: { trigger: "manual" }, + }) + assert.deepEqual(transcriptShape, { + trigger: "manual", + preTokens: undefined, + postTokens: undefined, + }) + + assert.equal(parseCompactBoundary({ type: "system", subtype: "init" }), null) +}) + +test("a compaction the CLI did on its own is announced in the transcript", () => { + _resetLoggerForTests() + configureLogger({ file: false, mode: "silent", level: "info" }) + const note = reportCompactBoundary({ + type: "system", + subtype: "compact_boundary", + compact_metadata: { trigger: "auto", pre_tokens: 180_000, post_tokens: 40_000 }, + }) + assert.ok(note?.includes(COMPACT_BOUNDARY_MARKER)) + assert.match(note!, /on its own \(180,000 tokens to 40,000\)/) + assert.equal(reportCompactBoundary({ type: "result" }), null) + assert.match( + formatCompactBoundaryNote({ trigger: "manual" }), + /on a manual request\. Earlier detail/, + ) + _resetLoggerForTests() +}) + +test("a failing result subtype is named, a successful one is not", () => { + assert.equal(describeResultFailure({ type: "result", subtype: "success" }), null) + assert.equal(describeResultFailure({ type: "result" }), null) + assert.equal(describeResultFailure({ type: "assistant", subtype: "error_max_turns" }), null) + + const known = describeResultFailure({ type: "result", subtype: "error_max_turns" }) + assert.match(known!, /error_max_turns/) + assert.match(known!, /internal turn limit/) + + const unknown = describeResultFailure({ type: "result", subtype: "error_from_a_future_cli" }) + assert.equal(unknown, "Claude Code ended the turn with `error_from_a_future_cli`.") + assert.ok(formatResultFailureNote(known!).startsWith(`\n${RESULT_ERROR_MARKER} `)) +}) diff --git a/test-compaction-model.ts b/test-compaction-model.ts new file mode 100644 index 0000000..257710c --- /dev/null +++ b/test-compaction-model.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict" +import { mkdtempSync, readFileSync, rmSync, unlinkSync, mkdirSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { test } from "node:test" +import { + buildAppendedSystemPrompt, + DEFAULT_COMPACTION_MODEL, + resolveCompactionModel, +} from "./src/claude-code-language-model.js" + +function withCompactionEnv(value: string | undefined, fn: () => T): T { + const previous = process.env.CLAUDE_CODE_COMPACTION_MODEL + try { + if (value === undefined) { + delete process.env.CLAUDE_CODE_COMPACTION_MODEL + } else { + process.env.CLAUDE_CODE_COMPACTION_MODEL = value + } + return fn() + } finally { + if (previous === undefined) { + delete process.env.CLAUDE_CODE_COMPACTION_MODEL + } else { + process.env.CLAUDE_CODE_COMPACTION_MODEL = previous + } + } +} + +test("resolveCompactionModel falls back to default when nothing is set", () => { + withCompactionEnv(undefined, () => { + assert.equal(resolveCompactionModel(), DEFAULT_COMPACTION_MODEL) + assert.equal(resolveCompactionModel(undefined), DEFAULT_COMPACTION_MODEL) + assert.equal(resolveCompactionModel(""), DEFAULT_COMPACTION_MODEL) + assert.equal(resolveCompactionModel(" "), DEFAULT_COMPACTION_MODEL) + }) +}) + +test("resolveCompactionModel uses configured value when env is unset", () => { + withCompactionEnv(undefined, () => { + assert.equal(resolveCompactionModel("claude-sonnet-4-6"), "claude-sonnet-4-6") + assert.equal(resolveCompactionModel(" claude-opus-4-7 "), "claude-opus-4-7") + }) +}) + +test("CLAUDE_CODE_COMPACTION_MODEL env wins over configured value", () => { + withCompactionEnv("claude-haiku-4-5", () => { + assert.equal(resolveCompactionModel("claude-opus-4-7"), "claude-haiku-4-5") + }) + withCompactionEnv(" claude-sonnet-4-6 ", () => { + assert.equal(resolveCompactionModel("claude-opus-4-7"), "claude-sonnet-4-6") + }) +}) + +test("empty env var falls through to configured/default", () => { + withCompactionEnv("", () => { + assert.equal(resolveCompactionModel(), DEFAULT_COMPACTION_MODEL) + assert.equal(resolveCompactionModel("claude-opus-4-7"), "claude-opus-4-7") + }) + withCompactionEnv(" ", () => { + assert.equal(resolveCompactionModel("claude-opus-4-7"), "claude-opus-4-7") + }) +}) + +test("interactive prompt mitigation can omit forwarded opencode system prompt", () => { + const tmp = mkdtempSync(join(tmpdir(), "opencode-cc-test-")) + const previousConfigHome = process.env.XDG_CONFIG_HOME + let promptFile: string | undefined + + try { + process.env.XDG_CONFIG_HOME = join(tmp, "config") + promptFile = buildAppendedSystemPrompt(tmp, true) + assert.ok(promptFile) + const content = readFileSync(promptFile, "utf8") + + assert.match(content, /Runtime environment: Claude Code CLI/) + assert.match(content, /Continuing through multi-step tasks/) + assert.doesNotMatch(content, /FORWARDED_OPENCODE_SYSTEM_PROMPT/) + } finally { + if (promptFile) unlinkSync(promptFile) + if (previousConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = previousConfigHome + } + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test("headless prompt path still preserves forwarded opencode system prompt", () => { + const tmp = mkdtempSync(join(tmpdir(), "opencode-cc-test-")) + const previousConfigHome = process.env.XDG_CONFIG_HOME + let promptFile: string | undefined + + try { + process.env.XDG_CONFIG_HOME = join(tmp, "config") + promptFile = buildAppendedSystemPrompt(tmp, true, [ + "FORWARDED_OPENCODE_SYSTEM_PROMPT", + ]) + assert.ok(promptFile) + const content = readFileSync(promptFile, "utf8") + + assert.match(content, /FORWARDED_OPENCODE_SYSTEM_PROMPT/) + } finally { + if (promptFile) unlinkSync(promptFile) + if (previousConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = previousConfigHome + } + rmSync(tmp, { recursive: true, force: true }) + } +}) + +// AGENTS.md dedup (from @HeikoAtGitHub's 25260a4): opencode already forwards +// the global AGENTS.md inside its system prompt, so the disk-read copy must +// only be appended when the forwarded text does not already carry it. +test("global AGENTS.md is appended once, not twice, when opencode already forwarded it", () => { + const tmp = mkdtempSync(join(tmpdir(), "opencode-cc-test-")) + const previousConfigHome = process.env.XDG_CONFIG_HOME + const agents = "# AGENTS.md\n\nGLOBAL-AGENTS-SENTINEL-7731\n\nSome rules.\n" + const files: string[] = [] + try { + process.env.XDG_CONFIG_HOME = join(tmp, "config") + mkdirSync(join(tmp, "config", "opencode"), { recursive: true }) + writeFileSync(join(tmp, "config", "opencode", "AGENTS.md"), agents) + + const forwarded = buildAppendedSystemPrompt(tmp, true, [ + "Instructions from: /home/x/.config/opencode/AGENTS.md\n" + agents, + ])! + files.push(forwarded) + const withForward = readFileSync(forwarded, "utf8") + assert.equal(withForward.split("GLOBAL-AGENTS-SENTINEL-7731").length - 1, 1, "forwarded copy only") + + const bare = buildAppendedSystemPrompt(tmp, true, [])! + files.push(bare) + const withoutForward = readFileSync(bare, "utf8") + assert.equal(withoutForward.split("GLOBAL-AGENTS-SENTINEL-7731").length - 1, 1, "disk copy still appended when nothing was forwarded") + } finally { + for (const f of files) unlinkSync(f) + if (previousConfigHome === undefined) delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = previousConfigHome + rmSync(tmp, { recursive: true, force: true }) + } +}) diff --git a/test-compress-tool.ts b/test-compress-tool.ts new file mode 100644 index 0000000..8f7bdfc --- /dev/null +++ b/test-compress-tool.ts @@ -0,0 +1,401 @@ +/** + * Tests for the opt-in `compress` proxy tool: the in-process interceptor + * path in src/proxy-mcp.ts, the summary/restart store in + * src/compression-store.ts, and the system-prompt note it drives. + * + * Usage: + * npx tsx --test test-compress-tool.ts + */ +import assert from "node:assert/strict" +import { test } from "node:test" +import * as http from "node:http" +import { readFileSync, unlinkSync } from "node:fs" + +import { + createProxyMcpServer, + DEFAULT_PROXY_TOOLS, + resolveProxyOpencodeToolDefs, + type ProxyMcpServer, + type ProxyToolDef, + type ProxyToolCall, + type ProxyToolInterceptor, +} from "./src/proxy-mcp.js" +import { + clearCompression, + consumeCompressionRestart, + getCompressionSummary, + storeCompressionSummary, +} from "./src/compression-store.js" +import { buildAppendedSystemPrompt } from "./src/claude-code-language-model.js" +import { DEFAULT_PROXY_TOOL_NAMES } from "./src/index.js" +import { deleteClaudeSessionId, setClaudeSessionId } from "./src/session-manager.js" + +/** The proxy endpoint requires a bearer token; see test-proxy-mcp.ts. */ +function post( + srv: ProxyMcpServer, + body: unknown, +): Promise<{ status: number; json: any }> { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(body) + const req = http.request( + srv.url, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload).toString(), + Authorization: `Bearer ${srv.authToken}`, + }, + }, + (res) => { + const chunks: Buffer[] = [] + res.on("data", (c: Buffer) => chunks.push(c)) + res.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8") + try { + resolve({ status: res.statusCode ?? 0, json: JSON.parse(text) }) + } catch { + resolve({ status: res.statusCode ?? 0, json: text }) + } + }) + }, + ) + req.on("error", reject) + req.write(payload) + req.end() + }) +} + +async function withServer( + interceptors: Map, + fn: (srv: ProxyMcpServer) => Promise, +): Promise { + const srv = await createProxyMcpServer(DEFAULT_PROXY_TOOLS, undefined, interceptors) + try { + return await fn(srv) + } finally { + await srv.close() + } +} + +test("intercepted tools/call is answered in-process, never queued for opencode", async () => { + const seen: string[] = [] + const interceptors = new Map([ + ["compress", () => ({ kind: "text", text: "Summary stored." })], + ]) + + await withServer(interceptors, async (srv) => { + srv.calls.on("call", (call: ProxyToolCall) => { + seen.push(call.toolName) + call.resolve({ kind: "text", text: "should never happen" }) + }) + + const res = await post(srv, { + jsonrpc: "2.0", + id: 11, + method: "tools/call", + params: { name: "compress", arguments: { summary: "did the thing" } }, + }) + + assert.equal(res.json.id, 11) + assert.equal(res.json.error, undefined) + assert.equal(res.json.result.isError, false) + assert.match(res.json.result.content[0].text, /Summary stored/) + assert.deepEqual(seen, [], "interceptor must not reach the broker") + }) +}) + +// Same rule as every other tools/call path: Claude CLI validates the +// response against the MCP result schema and rejects JSON-RPC error +// envelopes as malformed. The fork version this came from wrote +// `error: {code: -32000}` here, which the CLI would have thrown out. +test("throwing interceptor returns an MCP result with isError, not a JSON-RPC error", async () => { + const interceptors = new Map([ + [ + "compress", + () => { + throw new Error("store unavailable") + }, + ], + ]) + + await withServer(interceptors, async (srv) => { + const res = await post(srv, { + jsonrpc: "2.0", + id: "req-c", + method: "tools/call", + params: { name: "compress", arguments: { summary: "x" } }, + }) + + assert.equal(res.status, 200) + assert.equal(res.json.id, "req-c") + assert.equal(res.json.error, undefined, "must not be a JSON-RPC error envelope") + assert.equal(res.json.result.isError, true) + assert.match(res.json.result.content[0].text, /store unavailable/) + }) +}) + +test("interceptors leave non-intercepted tools on the broker path", async () => { + const interceptors = new Map([ + ["compress", () => ({ kind: "text", text: "unused" })], + ]) + + await withServer(interceptors, async (srv) => { + srv.calls.on("call", (call: ProxyToolCall) => { + call.resolve({ kind: "text", text: `broker ran ${call.toolName}` }) + }) + + const res = await post(srv, { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "bash", arguments: { command: "echo hi" } }, + }) + + assert.match(res.json.result.content[0].text, /broker ran bash/) + }) +}) + +// Same call as `Question`: it resets the model's whole working context, so +// it stays something the operator asks for by name in `proxyTools`. +test("compress is in the tool catalogue but off by default", () => { + const compress = DEFAULT_PROXY_TOOLS.find((t) => t.name === "compress") + assert.ok(compress, "compress must be defined so proxyTools can name it") + assert.deepEqual(compress.inputSchema.required, ["summary"]) + assert.equal( + DEFAULT_PROXY_TOOL_NAMES.some((n) => n.toLowerCase() === "compress"), + false, + "compress must stay opt-in", + ) +}) + +// The fork version cleared the summary inside deleteClaudeSessionId, which +// the reset path calls — so the summary was wiped microseconds before the +// fresh spawn read it and the whole feature did nothing. +test("summary survives the session reset that the compress call triggers", () => { + const key = "test::compress::survives" + setClaudeSessionId(key, "claude-session-abc") + storeCompressionSummary(key, "resolved: shipped the parser fix") + + deleteClaudeSessionId(key) + + assert.equal(getCompressionSummary(key), "resolved: shipped the parser fix") + clearCompression(key) +}) + +test("restart is consumed once; the summary stays behind", () => { + const key = "test::compress::once" + storeCompressionSummary(key, "summary text") + + assert.equal(consumeCompressionRestart(key), true, "first turn resets") + assert.equal(consumeCompressionRestart(key), false, "later turns must not") + assert.equal( + getCompressionSummary(key), + "summary text", + "the summary is prior context for every spawn that follows", + ) + + clearCompression(key) + assert.equal(getCompressionSummary(key), undefined) +}) + +test("consumeCompressionRestart is false for a key that never compressed", () => { + assert.equal(consumeCompressionRestart("test::compress::unknown"), false) +}) + +function readPrompt(path: string | undefined): string { + assert.ok(path, "expected a system prompt file") + const content = readFileSync(path, "utf8") + unlinkSync(path) + return content +} + +test("system prompt only advertises compress when it is enabled", () => { + const off = readPrompt(buildAppendedSystemPrompt("/tmp", false, [])) + assert.match(off, /The `compress` tool is NOT available/) + + const on = readPrompt( + buildAppendedSystemPrompt("/tmp", false, [], { compressEnabled: true }), + ) + assert.match(on, /mcp__opencode_proxy__compress/) + assert.doesNotMatch(on, /`compress` tool is NOT available/) +}) + +test("stored summary is prepended ahead of the runtime note", () => { + const content = readPrompt( + buildAppendedSystemPrompt("/tmp", false, ["workspace context"], { + compressEnabled: true, + compressionSummary: "we rewrote the broker timeout resolver", + }), + ) + + const summaryAt = content.indexOf("we rewrote the broker timeout resolver") + const noteAt = content.indexOf("Runtime environment: Claude Code CLI") + assert.ok(summaryAt >= 0, "summary must be present") + assert.ok(noteAt >= 0, "runtime note must be present") + assert.ok(summaryAt < noteAt, "summary reads as prior context, so it comes first") +}) + +test("a blank summary is not injected", () => { + const content = readPrompt( + buildAppendedSystemPrompt("/tmp", false, [], { + compressEnabled: true, + compressionSummary: " ", + }), + ) + assert.doesNotMatch(content, /context was compressed/) +}) + +// --- forwarding opencode's own tools (`proxyOpencodeTools`) ----------------- +// +// opencode-dcp declares a `compress` tool directly rather than through an MCP +// server, so `resolvedProxyMcpTools` (which matches `` / +// `_`) never forwards it and the model could not obey dcp's +// "you MUST use the `compress` tool now" reminder. These cover the allowlist +// and, most importantly, what happens when both compress tools want the name. + +/** A stand-in for what `client.tool.list()` returns on opencode 1.18.31. */ +const REGISTRY = [ + { id: "bash", description: "run a command", parameters: { type: "object" } }, + { + id: "compress", + description: "compress opencode's conversation", + parameters: { + type: "object", + properties: { instructions: { type: "string" } }, + required: ["instructions"], + }, + }, +] + +test("proxyOpencodeTools forwards a named opencode tool with its own schema", () => { + const defs = resolveProxyOpencodeToolDefs({ + requested: ["compress"], + items: REGISTRY, + }) + + assert.deepEqual( + defs.map((d) => d.name), + ["compress"], + ) + assert.equal(defs[0].description, "compress opencode's conversation") + assert.deepEqual(defs[0].inputSchema.required, ["instructions"]) +}) + +test("proxyOpencodeTools is off by default and matches names case-insensitively", () => { + assert.deepEqual(resolveProxyOpencodeToolDefs({ items: REGISTRY }), []) + assert.deepEqual(resolveProxyOpencodeToolDefs({ requested: [], items: REGISTRY }), []) + + const defs = resolveProxyOpencodeToolDefs({ + requested: ["Compress"], + items: REGISTRY, + }) + assert.deepEqual( + defs.map((d) => d.name), + ["compress"], + "the emitted name is opencode's id, whatever case the operator wrote", + ) +}) + +test("an unknown name is skipped, and an unreachable registry forwards nothing", () => { + assert.deepEqual( + resolveProxyOpencodeToolDefs({ requested: ["nope"], items: REGISTRY }), + [], + ) + // Registry silence must not be read as "the tool is gone": nothing is + // forwarded, and the spawn carries on with its static defs. + assert.deepEqual( + resolveProxyOpencodeToolDefs({ requested: ["compress"], items: undefined }), + [], + ) +}) + +test("the name collision resolves to the plugin's own compress, not opencode's", () => { + // Both want the MCP name `compress`. The plugin's def is an interceptor: + // ensureProxyServer answers it in-process, so a forwarded def sharing the + // name could never reach opencode at all. It is dropped instead of + // shadowing, and the operator is told. + const pluginCompress = DEFAULT_PROXY_TOOLS.find((t) => t.name === "compress") + assert.ok(pluginCompress) + + const defs = resolveProxyOpencodeToolDefs({ + requested: ["compress"], + items: REGISTRY, + taken: new Set([pluginCompress.name]), + }) + assert.deepEqual(defs, [], "the forwarded def loses the contested name") + + // Nothing else in the list is affected by the collision. + const alongside = resolveProxyOpencodeToolDefs({ + requested: ["compress", "bash"], + items: REGISTRY, + taken: new Set(["compress"]), + }) + assert.deepEqual( + alongside.map((d) => d.name), + ["bash"], + ) +}) + +test("a forwarded compress is NOT answered by the plugin's interceptor", async () => { + // The second half of the collision, and the one a def-level check cannot + // see: with only opencode's `compress` forwarded there is no plugin def to + // collide with, so an interceptor keyed on the name alone would still + // answer it in-process and opencode would never run the tool. Registering + // the interceptor is therefore the caller's decision, not the name's. + const forwardedOnly: ProxyToolDef[] = [ + { name: "compress", description: "opencode's own", inputSchema: { type: "object" } }, + ] + const queued: string[] = [] + + // interceptCompress === false is what the spawn path passes when the + // plugin's own def is absent: no interceptors at all. + const srv = await createProxyMcpServer(forwardedOnly, undefined, new Map()) + srv.calls.on("call", (call: ProxyToolCall) => { + queued.push(call.toolName) + call.resolve({ kind: "text", text: "opencode ran it" }) + }) + try { + const res = await post(srv, { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "compress", arguments: { topic: "t" } }, + }) + assert.deepEqual(queued, ["compress"], "it must reach opencode through the broker") + const text = JSON.stringify(res.json) + assert.match(text, /opencode ran it/) + assert.doesNotMatch( + text, + /Summary stored/, + "the in-process reset reply would mean the wrong compress answered", + ) + } finally { + await srv.close() + } +}) + +test("the runtime note describes whichever compress is actually reachable", () => { + const forwarded = readPrompt( + buildAppendedSystemPrompt("/tmp", false, [], { + opencodeCompressEnabled: true, + }), + ) + assert.match(forwarded, /mcp__opencode_proxy__compress/) + assert.match( + forwarded, + /compresses opencode's stored conversation, NOT this Claude Code session/, + "the two compress different windows and the model must not confuse them", + ) + + // When both are somehow live the plugin's own def holds the name, so the + // note must describe the session reset, matching the def-level precedence. + const both = readPrompt( + buildAppendedSystemPrompt("/tmp", false, [], { + compressEnabled: true, + opencodeCompressEnabled: true, + }), + ) + assert.match(both, /The reset happens at the start of your NEXT turn/) + assert.doesNotMatch(both, /NOT this Claude Code session/) +}) diff --git a/test-config-models.ts b/test-config-models.ts new file mode 100644 index 0000000..3082509 --- /dev/null +++ b/test-config-models.ts @@ -0,0 +1,255 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { configModelsForProvider, createClaudeCode } from "./src/index.js" +import { defaultModels } from "./src/models.js" +import type { OpenCodeProvider } from "./src/opencode-types.js" + +// Regression guard for PR #7: opencode runs the `provider.models` hook before +// extending the provider DB from config. For plugin-only providers like +// claude-code (absent from the models-dev catalog) that hook bails, so the +// config-path output produced here must carry the real metadata — otherwise +// the context-usage indicator renders 0 / no cost / no model name. + +test("configModelsForProvider emits real metadata, not schema defaults", () => { + const models = configModelsForProvider({}, "claude-code") + + const opus = models["claude-opus-4-8"] as Record + assert.ok(opus, "claude-opus-4-8 should be present") + + const limit = opus.limit as { context: number; output: number } + assert.ok(limit.context > 0, "limit.context must be populated") + assert.ok(limit.output > 0, "limit.output must be populated") + + const cost = opus.cost as { input: number; output: number } + assert.ok(cost.input > 0, "cost.input must be populated") + assert.ok(cost.output > 0, "cost.output must be populated") + + assert.equal(opus.family, "opus") + assert.equal(opus.name, "Claude Opus 4.8 (5×)") + assert.ok(typeof opus.release_date === "string" && opus.release_date.length > 0) + assert.equal(opus.reasoning, true) + + const variants = opus.variants as Record + assert.ok(variants && typeof variants === "object", "variants must be present") + assert.ok("max" in variants, "default reasoning variants must be carried") +}) + +test("configModelsForProvider registers claude-fable-5 with real metadata", () => { + const models = configModelsForProvider({}, "claude-code") + + const fable = models["claude-fable-5"] as Record + assert.ok(fable, "claude-fable-5 should be present") + + assert.equal(fable.family, "fable") + assert.equal(fable.name, "Claude Fable 5 (10×)") + assert.equal(fable.reasoning, true) + + const limit = fable.limit as { context: number; output: number } + assert.ok(limit.context > 0, "limit.context must be populated") + assert.ok(limit.output > 0, "limit.output must be populated") + + const cost = fable.cost as { input: number; output: number } + assert.ok(cost.input > 0, "cost.input must be populated") + assert.ok(cost.output > 0, "cost.output must be populated") + + const variants = fable.variants as Record + assert.ok(variants && "max" in variants, "reasoning variants must be carried") +}) + +test("configModelsForProvider registers claude-mythos-5 with real metadata", () => { + const models = configModelsForProvider({}, "claude-code") + + const mythos = models["claude-mythos-5"] as Record + assert.ok(mythos, "claude-mythos-5 should be present") + + assert.equal(mythos.family, "mythos") + assert.equal(mythos.name, "Claude Mythos 5 (10×)") + assert.equal(mythos.reasoning, true) + + const limit = mythos.limit as { context: number; output: number } + assert.ok(limit.context > 0, "limit.context must be populated") + assert.ok(limit.output > 0, "limit.output must be populated") + + const cost = mythos.cost as { input: number; output: number } + assert.ok(cost.input > 0, "cost.input must be populated") + assert.ok(cost.output > 0, "cost.output must be populated") + + const variants = mythos.variants as Record + assert.ok(variants && "max" in variants, "reasoning variants must be carried") +}) + +test("configModelsForProvider registers Fable and Mythos 5.1 metadata", () => { + const models = configModelsForProvider({}, "claude-code") + + for (const [id, name, family] of [ + ["claude-fable-5-1", "Claude Fable 5.1 (10×)", "fable"], + ["claude-mythos-5-1", "Claude Mythos 5.1 (10×)", "mythos"], + ] as const) { + const model = models[id] as Record + assert.ok(model, `${id} should be present`) + assert.equal(model.name, name) + assert.equal(model.family, family) + assert.equal(model.release_date, "2026-09-01") + assert.equal(model.reasoning, true) + assert.deepEqual(model.limit, { context: 1_000_000, output: 128_000 }) + assert.deepEqual( + model.cost, + { input: 10, output: 50, cache_read: 0.25, cache_write: 12.5 }, + id, + ) + assert.ok( + "max" in (model.variants as Record), + `${id} must carry the reasoning variants`, + ) + } +}) + +test("configModelsForProvider registers Sonnet 5 and Opus 5 metadata", () => { + const models = configModelsForProvider({}, "claude-code") + + const sonnet = models["claude-sonnet-5"] as Record + assert.equal(sonnet.name, "Claude Sonnet 5 (3×)") + assert.equal(sonnet.family, "sonnet") + assert.equal(sonnet.release_date, "2026-06-30") + assert.equal(sonnet.reasoning, true) + assert.deepEqual(sonnet.limit, { context: 1_000_000, output: 128_000 }) + // Dollars per million tokens, the unit opencode/models.dev expect. + assert.deepEqual(sonnet.cost, { + input: 3, + output: 15, + cache_read: 0.3, + cache_write: 3.75, + }) + + const opus = models["claude-opus-5"] as Record + assert.equal(opus.name, "Claude Opus 5 (5×)") + assert.equal(opus.family, "opus") + assert.equal(opus.release_date, "2026-07-24") + assert.equal(opus.reasoning, true) + assert.deepEqual(opus.limit, { context: 1_000_000, output: 128_000 }) + assert.deepEqual(opus.cost, { + input: 5, + output: 25, + cache_read: 0.5, + cache_write: 6.25, + }) + + assert.ok("max" in (sonnet.variants as Record)) + assert.ok("max" in (opus.variants as Record)) +}) + +// Fast mode is only registered for the two models the CLI actually gates it +// on, and it is priced at the Mythos-class rate ($10/$50 per MTok), which is +// the exact table the CLI applies for `speed: "fast"`. A fast entry priced at +// standard Opus rates would under-report every fast turn by half. +test("configModelsForProvider registers the fast Opus entries at fast pricing", () => { + const models = configModelsForProvider({}, "claude-code") + + for (const [id, name, releaseDate] of [ + ["claude-opus-4-8-fast", "Claude Opus 4.8 Fast (10×)", "2026-05-28"], + ["claude-opus-5-fast", "Claude Opus 5 Fast (10×)", "2026-07-24"], + ] as const) { + const model = models[id] as Record + assert.ok(model, `${id} should be present`) + assert.equal(model.name, name) + assert.equal(model.family, "opus") + assert.equal(model.release_date, releaseDate) + assert.equal(model.reasoning, true) + assert.deepEqual(model.limit, { context: 1_000_000, output: 128_000 }) + assert.deepEqual( + model.cost, + { input: 10, output: 50, cache_read: 1, cache_write: 12.5 }, + id, + ) + assert.ok( + "max" in (model.variants as Record), + `${id} must carry the reasoning variants`, + ) + } +}) + +test("configModelsForProvider registers fast entries only for fast-capable models", () => { + const models = configModelsForProvider({}, "claude-code") + const fastIds = Object.keys(models).filter((id) => id.endsWith("-fast")) + + // The CLI gates fast mode on the model name containing `opus-4-8` or + // `opus-5`. Anything else would render a 10x price tag on a model that + // silently runs at standard speed. + assert.deepEqual(fastIds.sort(), ["claude-opus-4-8-fast", "claude-opus-5-fast"]) +}) + +test("fast model ids survive the per-account suffix expansion", () => { + const models = configModelsForProvider({}, "claude-code-work", "work") + + const model = models["claude-opus-5-fast@work"] as Record + assert.ok(model, "account-suffixed fast id must be emitted") + assert.equal(model.id, "claude-opus-5-fast@work") + assert.deepEqual(model.cost, { + input: 10, + output: 50, + cache_read: 1, + cache_write: 12.5, + }) +}) + +// Context and max-output values are published per model and had drifted: the +// 4.5-generation entries claimed a 1M context they never had, and every +// pre-Sonnet-5 entry carried a placeholder 16,384 output cap. Pin the real +// numbers so a future edit can't quietly reintroduce either. +test("configModelsForProvider reports the published context and output limits", () => { + const models = configModelsForProvider({}, "claude-code") + const limitOf = (id: string) => (models[id] as Record).limit + + // 4.5 generation: 200k context, 64k output. Not 1M. + assert.deepEqual(limitOf("claude-haiku-4-5"), { context: 200_000, output: 64_000 }) + assert.deepEqual(limitOf("claude-sonnet-4-5"), { context: 200_000, output: 64_000 }) + assert.deepEqual(limitOf("claude-opus-4-5"), { context: 200_000, output: 64_000 }) + + // 4.6 and later: full 1M context, 128k output. + for (const id of [ + "claude-sonnet-4-6", + "claude-sonnet-5", + "claude-opus-4-6", + "claude-opus-4-7", + "claude-opus-4-8", + "claude-opus-5", + "claude-fable-5", + "claude-fable-5-1", + "claude-mythos-5", + "claude-mythos-5-1", + ]) { + assert.deepEqual(limitOf(id), { context: 1_000_000, output: 128_000 }, id) + } +}) + +test("configModelsForProvider preserves user-defined variants for default models", () => { + const userConfig = { + "claude-opus-4-8": { variants: { custom: { reasoningEffort: "low" } } }, + } as unknown as OpenCodeProvider["models"] + + const models = configModelsForProvider(userConfig, "claude-code") + const variants = (models["claude-opus-4-8"] as Record) + .variants as Record + + // user variant survives the merge... + assert.ok("custom" in variants, "user-defined variant must be preserved") + // ...alongside the plugin defaults. + assert.ok("max" in variants, "default variants must still be present") +}) + +test("configModelsForProvider passes through user models not in defaults", () => { + const userConfig = { + "my-custom-model": { ...defaultModels["claude-opus-4-8"], id: "my-custom-model" }, + } as unknown as OpenCodeProvider["models"] + + const models = configModelsForProvider(userConfig, "claude-code") + assert.ok(models["my-custom-model"], "user-only model must be emitted") +}) + +test("createClaudeCode passes idle process timeout to language models", () => { + const model = createClaudeCode({ idleProcessTimeoutMs: 900_000 })( + "claude-sonnet-5", + ) + + assert.equal((model as any).config.idleProcessTimeoutMs, 900_000) +}) diff --git a/test-configure-skill.ts b/test-configure-skill.ts new file mode 100644 index 0000000..8b4c1d6 --- /dev/null +++ b/test-configure-skill.ts @@ -0,0 +1,122 @@ +/** + * Drift guard for `skills/claude-code-plugin/SKILL.md`, the bundled skill a + * model uses to configure this plugin. + * + * The skill is only useful while it is complete, so this file cross-checks it + * against the code: every provider option in `types.ts`, every logging key, + * every registered model id, every proxy tool def, and every plugin env var + * the source reads must be named in the skill; and every option the skill + * documents must still exist. Adding an option without documenting it fails + * here with the missing name. + * + * Usage: npx tsx --test test-configure-skill.ts + */ +import assert from "node:assert/strict" +import { test } from "node:test" +import * as fs from "node:fs" +import * as path from "node:path" +import { fileURLToPath } from "node:url" +import { defaultModels } from "./src/models.js" +import { DEFAULT_PROXY_TOOLS } from "./src/proxy-mcp.js" +import { DEFAULT_PROXY_TOOL_NAMES } from "./src/index.js" + +const ROOT = path.dirname(fileURLToPath(import.meta.url)) +const SKILL_DIR = path.join(ROOT, "skills", "claude-code-plugin") +const SKILL = fs.readFileSync(path.join(SKILL_DIR, "SKILL.md"), "utf8") + +/** Property names declared directly on an exported interface in `src/types.ts`. */ +function interfaceKeys(name: string): string[] { + const src = fs.readFileSync(path.join(ROOT, "src", "types.ts"), "utf8") + const start = src.indexOf(`export interface ${name} {`) + assert.ok(start >= 0, `interface ${name} not found in src/types.ts`) + const end = src.indexOf("\n}", start) + const body = src.slice(start, end) + return [...body.matchAll(/^ {2}([A-Za-z][A-Za-z0-9]*)\??:/gm)].map((m) => m[1]!) +} + +/** Backticked first-column keys before the next heading, including subheadings. */ +function tableKeys(heading: string): string[] { + const start = SKILL.indexOf(`\n${heading}\n`) + assert.ok(start >= 0, `heading not found in SKILL.md: ${heading}`) + const rest = SKILL.slice(start + heading.length + 2) + const next = rest.search(/\n#{1,6} /) + const section = next >= 0 ? rest.slice(0, next) : rest + return [...section.matchAll(/^\| `([^`]+)`/gm)].map((m) => m[1]!) +} + +const mentions = (name: string) => SKILL.includes(`\`${name}\``) + +test("frontmatter names the skill after its directory and keeps the description within limits", () => { + const fm = SKILL.match(/^---\n([\s\S]*?)\n---\n/) + assert.ok(fm, "SKILL.md must start with YAML frontmatter") + const name = fm![1]!.match(/^name:\s*(.+)$/m)?.[1]?.trim() + const description = fm![1]!.match(/^description:\s*(.+)$/m)?.[1]?.trim() + assert.equal(name, path.basename(SKILL_DIR)) + assert.ok(description && description.length > 80, "description must say when to use it") + assert.ok(description!.length <= 1024, "Claude Code caps skill descriptions at 1024 chars") + assert.match(description!, /opencode-claude-code-plugin/) +}) + +test("every provider option in types.ts is documented, and nothing documented is stale", () => { + const settings = interfaceKeys("ClaudeCodeProviderSettings") + assert.ok(settings.length > 25, `parsed only ${settings.length} settings keys`) + const documented = tableKeys("## Options reference").filter((k) => !k.includes(".")) + const missing = settings.filter((k) => !documented.includes(k)) + assert.deepEqual(missing, [], `options missing from the skill's reference table: ${missing.join(", ")}`) + const stale = documented.filter((k) => !settings.includes(k)) + assert.deepEqual(stale, [], `options documented but gone from types.ts: ${stale.join(", ")}`) +}) + +test("every logging key is documented", () => { + const keys = interfaceKeys("LoggingConfig") + assert.deepEqual(keys.sort(), ["dir", "file", "level", "mode"]) + const documented = tableKeys("### `logging` object") + assert.deepEqual(documented.sort(), keys.sort()) +}) + +test("every registered model id is named", () => { + const ids = Object.values(defaultModels).map((m) => m.id) + assert.ok(ids.length >= 15) + const missing = ids.filter((id) => !mentions(id)) + assert.deepEqual(missing, [], `model ids missing from the skill: ${missing.join(", ")}`) +}) + +test("every proxy tool, default or opt-in, is named", () => { + for (const name of DEFAULT_PROXY_TOOL_NAMES) { + assert.ok(SKILL.includes(`"${name}"`), `default proxyTools value missing: ${name}`) + } + const defs = DEFAULT_PROXY_TOOLS.map((t) => t.name) + const missing = defs.filter((n) => !SKILL.includes(`_${n}`) && !mentions(n)) + assert.deepEqual(missing, [], `proxy tool defs missing from the skill: ${missing.join(", ")}`) +}) + +test("every plugin env var the source reads is documented", () => { + const vars = new Set() + for (const file of fs.readdirSync(path.join(ROOT, "src"))) { + if (!file.endsWith(".ts")) continue + const src = fs.readFileSync(path.join(ROOT, "src", file), "utf8") + for (const m of src.matchAll(/process\.env\.((?:CLAUDE_CODE_|OPENCODE_CLAUDE_CODE_|ANTHROPIC_)[A-Z_]+)/g)) { + vars.add(m[1]!) + } + } + assert.ok(vars.size >= 10, `found only ${vars.size} env vars`) + const missing = [...vars].filter((v) => !mentions(v) && !SKILL.includes(`\`${v}=`)) + assert.deepEqual(missing, [], `env vars missing from the skill: ${missing.join(", ")}`) +}) + +test("agent-file keys the plugin honours are documented", () => { + for (const key of ["forceModel", "reasoningEffort", "defaultSubagentModel", "permission.task", "permission.todowrite"]) { + assert.ok(SKILL.includes(key), `missing: ${key}`) + } +}) + +test("the skill states the two facts every configuration change depends on", () => { + assert.match(SKILL, /provider\.claude-code\.options/) + assert.match(SKILL, /read once, at opencode startup/i) + assert.ok(SKILL.includes("~/.cache/opencode/packages/@khalilgharbaoui/opencode-claude-code-plugin@latest/")) + assert.ok(SKILL.includes("get approval before removing")) +}) + +test("no em dashes", () => { + assert.equal(SKILL.includes("\u2014"), false) +}) diff --git a/test-cwd-resolution.ts b/test-cwd-resolution.ts new file mode 100644 index 0000000..c8c6999 --- /dev/null +++ b/test-cwd-resolution.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { + getOpencodeProjectDirectory, + isUsableDirectory, + resolveSpawnCwd, + resolveSpawnCwdFrom, + setOpencodeProjectDirectory, +} from "./src/runtime-status.js" + +function withCapturedDirectory(value: string | undefined, fn: () => T): T { + const previous = getOpencodeProjectDirectory() + try { + setOpencodeProjectDirectory(value) + return fn() + } finally { + setOpencodeProjectDirectory(previous) + } +} + +test("isUsableDirectory rejects /, empty, single chars, and non-strings", () => { + assert.equal(isUsableDirectory("/"), false) + assert.equal(isUsableDirectory(""), false) + assert.equal(isUsableDirectory("x"), false) + assert.equal(isUsableDirectory(undefined), false) + assert.equal(isUsableDirectory(null), false) + assert.equal(isUsableDirectory(42), false) + assert.equal(isUsableDirectory("/x"), true) + assert.equal(isUsableDirectory("/Users/jessie/projects/foo"), true) +}) + +test("explicit configured value wins over live and captured", () => { + assert.equal( + resolveSpawnCwdFrom("/explicit", "/Users/me/proj", "/Users/me/other"), + "/explicit", + ) + // User override remains absolute even when it's "/". They asked for it. + assert.equal(resolveSpawnCwdFrom("/", "/Users/me/proj", "/Users/me/other"), "/") +}) + +test("live process.cwd() preferred when it's a usable directory", () => { + // Terminal launch: process.cwd() is the project dir, no captured needed. + assert.equal( + resolveSpawnCwdFrom(undefined, "/Users/me/proj", undefined), + "/Users/me/proj", + ) + // Live wins over a captured value too — lazy resolution honors opencode + // workspace switches via chdir, even when we have a stale captured init dir. + assert.equal( + resolveSpawnCwdFrom(undefined, "/Users/me/now", "/Users/me/then"), + "/Users/me/now", + ) +}) + +test("captured directory rescues macOS GUI launches at /", () => { + assert.equal( + resolveSpawnCwdFrom(undefined, "/", "/Users/jessie/projects/svelte-monorepo"), + "/Users/jessie/projects/svelte-monorepo", + ) +}) + +test("session directory overrides live cwd (opencode serve / web-UI fix)", () => { + // The bug: `opencode serve` is one long-lived process whose + // process.cwd() is the server's launch dir (e.g. systemd + // WorkingDirectory=/home/jan), not the web session's project. The + // session's own directory must win over that usable-but-wrong live cwd. + assert.equal( + resolveSpawnCwdFrom(undefined, "/home/jan", undefined, "/home/jan/proj"), + "/home/jan/proj", + ) + // Explicit pin still beats the session directory. + assert.equal( + resolveSpawnCwdFrom("/explicit", "/home/jan", undefined, "/home/jan/proj"), + "/explicit", + ) + // Unusable session dir is ignored — fall back to the existing chain. + assert.equal( + resolveSpawnCwdFrom(undefined, "/home/jan/proj", undefined, "/"), + "/home/jan/proj", + ) + // Absent session dir (TUI / direct AI-SDK / no SDK client) is a no-op: + // behavior is identical to the pre-fix 3-arg resolution. + assert.equal( + resolveSpawnCwdFrom(undefined, "/home/jan/proj", "/cap", undefined), + "/home/jan/proj", + ) +}) + +test("falls through to live when neither configured nor captured is usable", () => { + // Both unavailable: degrade gracefully to live, even if that's "/". + // Caller sees the same value process.cwd() would have returned, so nothing + // worse than pre-fix behavior. + assert.equal(resolveSpawnCwdFrom(undefined, "/", undefined), "/") + assert.equal(resolveSpawnCwdFrom(undefined, "", undefined), "") +}) + +test("empty configured string falls through to the rest of the chain", () => { + // Defensive: a corrupt or empty options.cwd shouldn't pin Claude to "" + // when a real live cwd is available. + assert.equal( + resolveSpawnCwdFrom("", "/Users/me/proj", "/Users/me/captured"), + "/Users/me/proj", + ) + assert.equal( + resolveSpawnCwdFrom("", "/", "/Users/me/captured"), + "/Users/me/captured", + ) +}) + +test("resolveSpawnCwd reads module-level captured state via the setter", () => { + withCapturedDirectory("/Users/jessie/projects/svelte-monorepo", () => { + // Stub process.cwd() temporarily to simulate the GUI-launch case. + const originalCwd = process.cwd + process.cwd = () => "/" + try { + assert.equal( + resolveSpawnCwd(undefined), + "/Users/jessie/projects/svelte-monorepo", + ) + // Explicit config still wins. + assert.equal(resolveSpawnCwd("/explicit/override"), "/explicit/override") + } finally { + process.cwd = originalCwd + } + }) +}) + +test("resolveSpawnCwd returns live cwd when usable, regardless of captured", () => { + withCapturedDirectory("/Users/jessie/projects/captured-at-init", () => { + // Terminal-launched opencode: process.cwd() is the active project. + // Captured value must not override the live one (workspace switching + // depends on this; baking captured into config is what broke #4). + const live = process.cwd() + if (!isUsableDirectory(live)) return // skip if test runner started at / + assert.equal(resolveSpawnCwd(undefined), live) + }) +}) + +test("setter accepts undefined to clear the captured directory", () => { + setOpencodeProjectDirectory("/Users/me/captured") + assert.equal(getOpencodeProjectDirectory(), "/Users/me/captured") + setOpencodeProjectDirectory(undefined) + assert.equal(getOpencodeProjectDirectory(), undefined) +}) diff --git a/test-doctor.ts b/test-doctor.ts new file mode 100644 index 0000000..95d0019 --- /dev/null +++ b/test-doctor.ts @@ -0,0 +1,276 @@ +/** + * `/claude-code-doctor`: the pure report formatter against a fixed report, the + * command-registration guard, the parser, the loopback auth self-check, and + * the strip that keeps the whole exchange out of a rebuilt transcript. + * + * Usage: npx tsx --test test-doctor.ts + */ +import assert from "node:assert/strict" +import { test } from "node:test" +import { + DOCTOR_COMMAND, + DOCTOR_MARKER, + checkProxyAuth, + formatDoctorReport, + parseDoctorCommand, + parseDoctorCommandContent, + type DoctorReport, +} from "./src/doctor.js" +import { EventEmitter } from "node:events" +import { registerDoctorCommand } from "./src/index.js" +import { filterSideQuestionHistory } from "./src/message-builder.js" +import { + deleteActiveProcess, + describeSessionKey, + setActiveProcess, + snapshotActiveProcesses, +} from "./src/session-manager.js" + +const report: DoctorReport = { + plugin: "0.18.3", + opencode: "1.18.29", + claudeCli: { path: "/usr/local/bin/claude", version: "2.1.263 (Claude Code)" }, + cwd: { resolved: "/Users/you/code/app", source: "process" }, + providers: ["claude-code-default", "claude-code-work"], + accounts: ["default", "work"], + proxyTools: ["Bash", "Edit", "Write", "WebFetch", "Task"], + mcpServers: ["github"], + transport: "headless", + planModeQuestion: false, + turnStats: true, + anthropicApiKeyInEnv: false, + processes: [ + { + sessionKey: "/Users/you/code/app::claude-opus-5::full::ses_abc::context=[]", + session: "ses_abc", + model: "claude-opus-5", + compaction: false, + pid: 4242, + inFlight: true, + ageMs: 125_000, + effort: "high", + attached: true, + proxyUrl: "http://127.0.0.1:51234/mcp", + lastStderr: "warning: something happened\n", + }, + ], + pendingCalls: [ + { sessionKey: "sk", toolCallId: "call_1", toolName: "task", ageMs: 30_000, deadlineMs: 3_600_000, emitted: true, channelClosed: false }, + ], + proxyServers: [{ url: "http://127.0.0.1:51234/mcp", auth: { status: "ok", code: 401 } }], +} + +test("the report names every field a bug report needs, and nothing secret", () => { + const text = formatDoctorReport(report) + assert.ok(text.startsWith(DOCTOR_MARKER), "must lead with the strippable marker") + + for (const expected of [ + "| plugin | 0.18.3 |", + "| opencode | 1.18.29 |", + "| claude CLI | `/usr/local/bin/claude` (2.1.263 (Claude Code)) |", + "| cwd | `/Users/you/code/app` (process) |", + "| providers | claude-code-default, claude-code-work |", + "| accounts | default, work |", + "| proxyTools | Bash, Edit, Write, WebFetch, Task |", + "| MCP servers (on disk) | github |", + "| transport | headless |", + "| turnStats | true |", + "| ANTHROPIC_API_KEY in env | no |", + "| ses_abc | claude-opus-5 | 4242 | yes | 2m | high |", + "| task | `call_1` | 30.0s | 1h 0m |", + "| http://127.0.0.1:51234/mcp | 401, good |", + "warning: something happened", + ]) { + assert.ok(text.includes(expected), `report is missing: ${expected}`) + } + + // Nothing that identifies a credential may appear, by value or by name. + assert.equal(/authToken|bearer|sk-ant|Authorization/i.test(text), false) +}) + +test("a pending call with no deadline reads as none, not as 0.0s", () => { + const text = formatDoctorReport({ + ...report, + pendingCalls: [{ ...report.pendingCalls[0]!, toolCallId: "call_2", deadlineMs: 0 }], + }) + assert.ok(text.includes("| task | `call_2` | 30.0s | none |"), text) +}) + +test("an empty runtime reads as empty rather than as broken", () => { + const text = formatDoctorReport({ + ...report, + processes: [], + pendingCalls: [], + proxyServers: [], + providers: [], + accounts: [], + proxyTools: [], + mcpServers: [], + }) + assert.ok(text.includes("None. The next message in a Claude Code session spawns one.")) + assert.ok(text.includes("None running.")) + assert.ok(text.includes("| providers | none |")) + assert.equal(text.includes("Last stderr"), false) +}) + +test("an unauthenticated proxy is called out as unsafe, not reported as fine", () => { + const text = formatDoctorReport({ + ...report, + proxyServers: [{ url: "http://127.0.0.1:1/mcp", auth: { status: "unsafe", code: 200 } }], + }) + assert.match(text, /200, UNSAFE/) + assert.match(text, /Restart every opencode window/) +}) + +test("checkProxyAuth calls initialize unauthenticated and reads 401 as good", async () => { + const seen: Array<{ url: string; init: RequestInit }> = [] + const fake = (async (url: any, init: any) => { + seen.push({ url: String(url), init }) + return new Response("", { status: 401 }) + }) as unknown as typeof fetch + + const ok = await checkProxyAuth("http://127.0.0.1:51234/mcp", fake) + assert.deepEqual(ok, { status: "ok", code: 401 }) + assert.equal(seen[0]!.init.method, "POST") + const headers = seen[0]!.init.headers as Record + assert.equal(headers["content-type"], "application/json") + assert.equal(headers.host, "127.0.0.1:51234") + assert.equal("origin" in headers, false, "an Origin would make the probe meaningless") + assert.equal("authorization" in headers, false, "the probe must be unauthenticated") + assert.match(String(seen[0]!.init.body), /"method":"initialize"/) + assert.equal( + /tools\/call/.test(String(seen[0]!.init.body)), + false, + "a tools/call probe would execute something", + ) + + const unsafe = await checkProxyAuth( + "http://127.0.0.1:51234/mcp", + (async () => new Response("{}", { status: 200 })) as unknown as typeof fetch, + ) + assert.deepEqual(unsafe, { status: "unsafe", code: 200 }) + + const down = await checkProxyAuth( + "http://127.0.0.1:51234/mcp", + (async () => { + throw new Error("ECONNREFUSED") + }) as unknown as typeof fetch, + ) + assert.equal(down.status, "unreachable") +}) + +test("the command is parsed only off the newest user message", () => { + assert.deepEqual(parseDoctorCommandContent(`/${DOCTOR_COMMAND}`), { rest: "" }) + assert.deepEqual(parseDoctorCommandContent(`/${DOCTOR_COMMAND} verbose`), { rest: "verbose" }) + assert.equal(parseDoctorCommandContent("tell me about /claude-code-doctor"), null) + assert.equal(parseDoctorCommandContent(null), null) + + // opencode appends reminder blocks as extra text parts on the same message. + assert.deepEqual( + parseDoctorCommandContent([ + { type: "text", text: `/${DOCTOR_COMMAND}` }, + { type: "text", text: "be careful" }, + ]), + { rest: "" }, + ) + + assert.equal( + parseDoctorCommand([ + { role: "user", content: `/${DOCTOR_COMMAND}` }, + { role: "assistant", content: "report" }, + ]), + null, + "a historical report must not re-run", + ) + assert.deepEqual( + parseDoctorCommand([ + { role: "assistant", content: "hi" }, + { role: "user", content: `/${DOCTOR_COMMAND}` }, + ]), + { rest: "" }, + ) +}) + +test("registration never overwrites a user-defined command of the same name", () => { + const fresh: any = {} + assert.equal(registerDoctorCommand(fresh), true) + assert.equal(fresh.command[DOCTOR_COMMAND].template, `/${DOCTOR_COMMAND} $ARGUMENTS`) + assert.equal(DOCTOR_COMMAND.includes(" "), false, "opencode splits a command name on space") + + const mine: any = { command: { [DOCTOR_COMMAND]: { template: "mine" } } } + assert.equal(registerDoctorCommand(mine), false) + assert.equal(mine.command[DOCTOR_COMMAND].template, "mine") +}) + +test("the doctor exchange is kept out of a transcript rebuilt for the CLI", () => { + const prompt = [ + { role: "user", content: [{ type: "text", text: "real question" }] }, + { role: "assistant", content: [{ type: "text", text: "real answer" }] }, + { role: "user", content: [{ type: "text", text: `/${DOCTOR_COMMAND}` }] }, + { role: "assistant", content: [{ type: "text", text: formatDoctorReport(report) }] }, + { role: "user", content: [{ type: "text", text: "next question" }] }, + ] as any + + const filtered = filterSideQuestionHistory(prompt) + assert.deepEqual( + filtered.map((message: any) => message.content[0]?.text), + ["real question", "real answer", "next question"], + ) +}) + +test("snapshotActiveProcesses reports age, in-flight state and a stderr tail if one exists", () => { + const key = "/w::claude-opus-5::full::ses_snap::context=[]" + const entry: any = { + proc: { pid: 777, kill: () => true }, + lineEmitter: new EventEmitter(), + // What `spawnClaudeProcess` stamps on every child; without it the report + // can only say "unknown". + startedAt: Date.now() - 5_000, + effort: "max", + turnInFlight: true, + opencodeSessionID: "ses_snap", + proxyServer: { url: "http://127.0.0.1:9/mcp", close: async () => {} }, + // Written by nothing in this lane; read defensively so the report works + // whether or not the field exists on the running build. + lastStderr: "boom\n", + } + setActiveProcess(key, entry) + try { + const row = snapshotActiveProcesses().find((candidate) => candidate.sessionKey === key) + assert.ok(row) + assert.equal(row!.session, "ses_snap") + assert.equal(row!.model, "claude-opus-5") + assert.equal(row!.pid, 777) + assert.equal(row!.inFlight, true) + assert.equal(row!.effort, "max") + assert.ok(row!.ageMs !== undefined && row!.ageMs >= 5_000, "age comes from startedAt") + assert.equal(row!.attached, false) + assert.equal(row!.proxyUrl, "http://127.0.0.1:9/mcp") + assert.equal(row!.lastStderr, "boom\n") + + // A build carrying neither field must still produce a usable row. + delete entry.startedAt + delete entry.lastStderr + const bare = snapshotActiveProcesses().find((candidate) => candidate.sessionKey === key) + assert.equal(bare!.ageMs, undefined) + assert.equal(bare!.lastStderr, undefined) + } finally { + deleteActiveProcess(key) + } +}) + +test("describeSessionKey pulls the model and opencode session back out", () => { + assert.deepEqual(describeSessionKey("/w::claude-opus-5::full::ses_abc::context=[]"), { + cwd: "/w", + model: "claude-opus-5", + session: "ses_abc", + compaction: false, + }) + assert.deepEqual(describeSessionKey("/w::claude-haiku-4-5::compaction::ses_abc"), { + cwd: "/w", + model: "claude-haiku-4-5", + session: "ses_abc", + compaction: true, + }) + assert.equal(describeSessionKey("garbage").model, "unknown") +}) diff --git a/test-effort-sessions.ts b/test-effort-sessions.ts new file mode 100644 index 0000000..aff50ce --- /dev/null +++ b/test-effort-sessions.ts @@ -0,0 +1,325 @@ +import assert from "node:assert/strict" +import { EventEmitter } from "node:events" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { test } from "node:test" +import type { ChildProcess } from "node:child_process" +import type { LanguageModelV3CallOptions } from "@ai-sdk/provider" +import { createClaudeCode } from "./src/index.js" +import { + deleteActiveProcess, + deleteActiveProcessAndWait, + deleteClaudeSessionId, + effortSessionKey, + getActiveProcess, + getClaudeSessionId, + invalidateOtherEffortSessions, + setActiveProcess, + setClaudeSessionId, + sessionKey, + type ActiveProcess, +} from "./src/session-manager.js" +import { queuePendingProxyCall, resolvePendingProxyCallById } from "./src/proxy-broker.js" +import { createExitPlanModeQuestionCall, hasExitPlanModeQuestions } from "./src/plan-mode-question.js" +import { getCompressionSummary, storeCompressionSummary } from "./src/compression-store.js" +import { requestSideQuestion } from "./src/side-question.js" + +function fakeActive() { + let killed = false + const active: ActiveProcess = { + proc: { kill: () => { killed = true; return true } } as ChildProcess, + lineEmitter: new EventEmitter(), + } + return { active, killed: () => killed } +} + +test("effort invalidation removes process, transcript id and compression, but keeps same effort", () => { + const base = "effort-invalidation" + const high = effortSessionKey(base, "high") + const low = effortSessionKey(base, "low") + const first = fakeActive() + setActiveProcess(high, first.active) + setClaudeSessionId(high, "old-high") + storeCompressionSummary(high, "outdated summary") + try { + invalidateOtherEffortSessions(base, "high") + assert.equal(getActiveProcess(high), first.active) + assert.equal(getClaudeSessionId(high), "old-high") + assert.equal(first.killed(), false) + invalidateOtherEffortSessions(base, "low") + assert.equal(first.killed(), true) + assert.equal(getActiveProcess(high), undefined) + assert.equal(getClaudeSessionId(high), undefined) + assert.equal(getCompressionSummary(high), undefined) + // An evicted/exited low process still has a transcript id to invalidate. + setClaudeSessionId(low, "old-low") + invalidateOtherEffortSessions(base, "high") + assert.equal(getClaudeSessionId(low), undefined) + assert.equal(getClaudeSessionId(high), undefined) + setClaudeSessionId(base, "no-explicit-effort") + invalidateOtherEffortSessions(base, "high") + assert.equal(getClaudeSessionId(base), undefined) + } finally { + for (const key of [base, high, low]) { + deleteActiveProcess(key) + deleteClaudeSessionId(key) + } + } +}) + +test("effort transition preserves an in-flight native side question", async () => { + const base = "effort-side-question" + const high = effortSessionKey(base, "high") + const first = fakeActive() + first.active.proc = Object.assign(new EventEmitter(), { + stdout: {}, + stdin: Object.assign(new EventEmitter(), { + writable: true, + write: (_line: string, callback?: () => void) => { callback?.(); return true }, + }), + kill: () => true, + }) as unknown as ChildProcess + setActiveProcess(high, first.active) + setClaudeSessionId(high, "aside-high") + const abort = new AbortController() + const pending = requestSideQuestion(first.active, "question", { + cliVersion: { major: 2, minor: 1, patch: 258 }, abortSignal: abort.signal, timeoutMs: 1000, + }) + const rejected = assert.rejects(pending, /abort/i) + try { + assert.throws(() => invalidateOtherEffortSessions(base, "low"), /pending work/) + assert.equal(getActiveProcess(high), first.active) + assert.equal(getClaudeSessionId(high), "aside-high") + } finally { + abort.abort() + await rejected + deleteActiveProcess(high) + deleteClaudeSessionId(high) + } +}) + +for (const busy of ["proxy", "completion", "approval", "stream"] as const) { + test(`effort transition refuses to destroy pending ${busy} work`, () => { + const base = `effort-pending-${busy}` + const high = effortSessionKey(base, "high") + const first = fakeActive() + setActiveProcess(high, first.active) + setClaudeSessionId(high, "pending-high") + const call = { + sessionKey: high, toolCallId: `pending-${busy}`, toolName: "task", input: {}, + } + if (busy === "proxy") { + queuePendingProxyCall(high, { + id: call.toolCallId, toolName: call.toolName, input: {}, resolve() {}, reject() {}, + }) + } else if (busy === "completion") { + first.active.pendingProxyCompletions = new Map([[call.toolCallId, { + call, result: { kind: "text", text: "finished" }, recoveryRequired: true, + }]]) + } else if (busy === "approval") { + createExitPlanModeQuestionCall(high, "exit-plan", "plan") + } else { + first.active.lineEmitter.on("line", () => {}) + } + try { + assert.throws(() => invalidateOtherEffortSessions(base, "low"), /pending work/) + assert.equal(getActiveProcess(high), first.active) + assert.equal(getClaudeSessionId(high), "pending-high") + assert.equal(first.killed(), false) + if (busy === "approval") assert.equal(hasExitPlanModeQuestions(high), true) + // Continuation at the original effort is not blocked or invalidated. + invalidateOtherEffortSessions(base, "high") + assert.equal(getActiveProcess(high), first.active) + } finally { + resolvePendingProxyCallById(call.toolCallId, { kind: "text", text: "cleanup" }) + deleteActiveProcess(high) + deleteClaudeSessionId(high) + } + }) +} + +function fixture() { + const cwd = mkdtempSync(join(tmpdir(), "opencode-effort-")) + const cliPath = join(cwd, "fake-claude.cjs") + writeFileSync(cliPath, `#!/usr/bin/env node +const readline = require("node:readline") +if (process.argv.includes("--version")) { + console.log("2.1.258") + process.exit(0) +} +const emit = (message) => console.log(JSON.stringify(message)) +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const envelope = JSON.parse(line) + if (envelope.type !== "user") return + emit({ type: "assistant", session_id: String(process.pid), message: { + role: "assistant", stop_reason: "end_turn", + content: [{ type: "text", text: "answer " + process.env.CLAUDE_CODE_EFFORT_LEVEL }], + } }) + emit({ type: "result", subtype: "success", session_id: String(process.pid), + is_error: false, usage: { input_tokens: 1, output_tokens: 1 } }) +}) +`, { mode: 0o755 }) + const modelId = "claude-haiku-4-5" + const provider = createClaudeCode({ + cwd, cliPath, bridgeOpencodeMcp: false, proxyOpencodeMcpTools: false, + proxyTools: [], interactive: false, autoContinueIncompleteTurns: false, + }) + const base = sessionKey(cwd, `${modelId}::tools::conversation::context=["claude-code","worker"]`) + const options: LanguageModelV3CallOptions = { + tools: [{ type: "function", name: "read", inputSchema: { type: "object" } }], + providerOptions: { "claude-code": { opencodeSessionID: "conversation", opencodeAgent: "worker" } }, + prompt: [{ role: "user", content: [{ type: "text", text: "first high request" }] }], + } + return { cwd, modelId, provider, base, options } +} + +for (const method of ["doStream", "doGenerate"] as const) { + test(`${method}: synthetic title, completed turn and /btw do not retire another effort`, async () => { + const { cwd, modelId, provider, base, options } = fixture() + const high = effortSessionKey(base, "high") + const first = fakeActive() + setActiveProcess(high, first.active) + setClaudeSessionId(high, "keep-high") + options.providerOptions!["claude-code"].reasoningEffort = "low" + try { + for (const request of [ + { ...options, tools: undefined }, + { ...options, prompt: [...options.prompt, { role: "assistant" as const, content: [{ type: "text" as const, text: "finished" }] }] }, + { ...options, prompt: [{ role: "user" as const, content: [{ type: "text" as const, text: "/btw" }] }] }, + ]) { + const response = await provider.languageModel(modelId)[method](request) + if ("stream" in response) { + for await (const part of response.stream) if (part.type === "error") throw part.error + } + assert.equal(getActiveProcess(high), first.active) + assert.equal(getClaudeSessionId(high), "keep-high") + assert.equal(first.killed(), false) + } + } finally { + deleteActiveProcess(high) + deleteClaudeSessionId(high) + await deleteActiveProcessAndWait(effortSessionKey(base, "low")) + deleteClaudeSessionId(effortSessionKey(base, "low")) + rmSync(cwd, { recursive: true, force: true }) + } + }) + + test(`${method}: effort switch rejects before touching a pending tool session`, async () => { + const { cwd, modelId, provider, base, options } = fixture() + const high = effortSessionKey(base, "high") + const first = fakeActive() + setActiveProcess(high, first.active) + setClaudeSessionId(high, "pending-high") + const id = `pending-integration-${method}` + queuePendingProxyCall(high, { id, toolName: "task", input: {}, resolve() {}, reject() {} }) + options.providerOptions!["claude-code"].reasoningEffort = "low" + options.prompt.push( + { role: "assistant", content: [{ type: "tool-call", toolCallId: id, toolName: "task", input: {} }] }, + { role: "tool", content: [{ type: "tool-result", toolCallId: id, toolName: "task", output: { type: "text", value: "result" } }] }, + ) + try { + await assert.rejects(provider.languageModel(modelId)[method](options), /pending work/) + assert.equal(getActiveProcess(high), first.active) + assert.equal(getClaudeSessionId(high), "pending-high") + assert.equal(first.killed(), false) + } finally { + resolvePendingProxyCallById(id, { kind: "text", text: "cleanup" }) + deleteActiveProcess(high) + deleteClaudeSessionId(high) + rmSync(cwd, { recursive: true, force: true }) + } + }) + + test(`${method}: high -> low -> high replays intervening context without stale resume`, { timeout: 15_000 }, async () => { + const { cwd, modelId, provider, base, options } = fixture() + const high = effortSessionKey(base, "high") + const low = effortSessionKey(base, "low") + try { + for (const [index, effort] of ["high", "low", "high", "high"].entries()) { + options.providerOptions!["claude-code"].reasoningEffort = effort + const previous = getActiveProcess(high) + // Separate model instances must share the same transition boundary. + const model = provider.languageModel(modelId) + const response = await model[method](options) + if ("stream" in response) { + for await (const part of response.stream) { + if (part.type === "error") throw part.error + } + } + const body = JSON.stringify(response.request?.body) + const currentKey = effortSessionKey(base, effort as "high" | "low") + assert.ok(getClaudeSessionId(currentKey), "the fixture must establish a remembered transcript") + if (method === "doStream") assert.ok(getActiveProcess(currentKey), "streaming must leave a reusable process") + if (index === 1) { + assert.equal(getActiveProcess(high), undefined) + assert.equal(getClaudeSessionId(high), undefined) + } + if (index === 2) { + assert.equal(getActiveProcess(low), undefined) + assert.equal(getClaudeSessionId(low), undefined) + assert.match(body, /conversation_history/) + assert.match(body, /low-effort detail to remember/) + assert.equal(getActiveProcess(high)?.cliArgs?.includes("--resume") ?? false, false) + } + if (index === 3 && method === "doStream") { + assert.equal(getActiveProcess(high), previous) + assert.doesNotMatch(body, /conversation_history/) + } + options.prompt.push( + { role: "assistant", content: [{ type: "text", text: effort === "low" ? "low-effort detail to remember" : "high answer" }] }, + { role: "user", content: [{ type: "text", text: `next request ${index}` }] }, + ) + } + } finally { + for (const key of [high, low]) { + await deleteActiveProcessAndWait(key) + deleteClaudeSessionId(key) + } + rmSync(cwd, { recursive: true, force: true }) + } + }) +} + +test("effort changes leave other agent, provider, account, model and conversation contexts alive", { timeout: 15_000 }, async () => { + const { cwd, modelId, provider, base, options } = fixture() + const otherBases = [ + base.replace('"worker"', '"other-agent"'), + base.replace('"claude-code"', '"claude-code-work"'), + base.replace(modelId, `${modelId}@work`), + base.replace(modelId, "claude-opus-5"), + base.replace("::conversation::", "::another-conversation::"), + base.replace("::tools::", "::no-tools::"), + base.replace(cwd, `${cwd}/other`), + ] + const others = otherBases.map((other) => { + const key = effortSessionKey(other, "high") + const fake = fakeActive() + fake.active.lineEmitter.on("line", () => {}) + setActiveProcess(key, fake.active) + setClaudeSessionId(key, "untouched") + return { key, ...fake } + }) + const high = effortSessionKey(base, "high") + setClaudeSessionId(high, "stale-high") + options.providerOptions!["claude-code"].reasoningEffort = "low" + try { + const response = await provider.languageModel(modelId).doStream(options) + for await (const part of response.stream) if (part.type === "error") throw part.error + assert.equal(getClaudeSessionId(high), undefined) + for (const other of others) { + assert.equal(getActiveProcess(other.key), other.active) + assert.equal(getClaudeSessionId(other.key), "untouched") + assert.equal(other.killed(), false) + } + } finally { + await deleteActiveProcessAndWait(effortSessionKey(base, "low")) + deleteClaudeSessionId(effortSessionKey(base, "low")) + deleteClaudeSessionId(high) + for (const other of others) { + deleteActiveProcess(other.key) + deleteClaudeSessionId(other.key) + } + rmSync(cwd, { recursive: true, force: true }) + } +}) diff --git a/test-exit-plan-mode-question.ts b/test-exit-plan-mode-question.ts new file mode 100644 index 0000000..1f14009 --- /dev/null +++ b/test-exit-plan-mode-question.ts @@ -0,0 +1,368 @@ +import { test } from "node:test" +import assert from "node:assert/strict" + +import { + APPROVED_EXIT_PLAN_MODE_MESSAGE, + QUESTION_TOOL_NAME, + clearExitPlanModeQuestions, + consumeExitPlanModeQuestionResult, + createExitPlanModeQuestionCall, + isPlanModeQuestionActive, +} from "./src/plan-mode-question.js" +import { ClaudeCodeLanguageModel } from "./src/claude-code-language-model.js" +import { setOpencodeClient } from "./src/runtime-status.js" +import { deleteClaudeSessionId } from "./src/session-manager.js" + +test("plan-mode bridge stays off unless explicitly opted in", () => { + for (const configured of [undefined, false] as const) { + assert.equal( + isPlanModeQuestionActive({ + configured, + opencodeHasQuestion: true, + compactionMode: false, + }), + false, + ) + } + + assert.equal( + isPlanModeQuestionActive({ + configured: true, + opencodeHasQuestion: true, + compactionMode: false, + }), + true, + ) +}) + +test("plan-mode bridge is gated on opencode having the question tool", () => { + // Emitting a `question` tool-call on a build without the registry entry + // renders `⚙ invalid` and wedges the turn, so the text path must stand. + assert.equal( + isPlanModeQuestionActive({ + configured: true, + opencodeHasQuestion: false, + compactionMode: false, + }), + false, + ) +}) + +test("plan-mode bridge never fires during compaction", () => { + assert.equal( + isPlanModeQuestionActive({ + configured: true, + opencodeHasQuestion: true, + compactionMode: true, + }), + false, + ) +}) + +test("ExitPlanMode creates a native OpenCode question tool-call", () => { + clearExitPlanModeQuestions("session-a") + + const call = createExitPlanModeQuestionCall( + "session-a", + "exit-plan-1", + "1. Inspect\n2. Patch", + "question-1", + ) + + assert.equal(call.toolCallId, "question-1") + assert.equal(call.toolName, QUESTION_TOOL_NAME) + assert.deepEqual(call.input, { + questions: [ + { + header: "Plan approval", + question: "Do you want to proceed with this plan?", + options: [ + { label: "yes", description: "" }, + { label: "no", description: "" }, + ], + multiple: false, + custom: true, + }, + ], + }) + assert.equal(call.text, "\n\n1. Inspect\n2. Patch\n") +}) + +test("question answer yes becomes approval tool_result for the original ExitPlanMode id", () => { + clearExitPlanModeQuestions("session-a") + createExitPlanModeQuestionCall("session-a", "exit-plan-1", "Plan", "question-1") + + const userMessage = consumeExitPlanModeQuestionResult("session-a", [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "question-1", + output: { type: "json", value: ["yes"] }, + }, + ], + } as any, + ]) + + assert.ok(userMessage) + assert.deepEqual(JSON.parse(userMessage), { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "exit-plan-1", + content: APPROVED_EXIT_PLAN_MODE_MESSAGE, + }, + ], + }, + }) + + assert.equal( + consumeExitPlanModeQuestionResult("session-a", [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "question-1", + output: { type: "json", value: ["yes"] }, + }, + ], + } as any, + ]), + null, + ) +}) + +test("opencode's formatted question output approves the original ExitPlanMode call", () => { + clearExitPlanModeQuestions("session-a") + createExitPlanModeQuestionCall("session-a", "exit-plan-1", "Plan", "question-1") + + const userMessage = consumeExitPlanModeQuestionResult("session-a", [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "question-1", + output: { + type: "text", + value: + `User has answered your questions: "Do you want to proceed with this plan?"="yes". ` + + `You can now continue with the user's answers in mind.`, + }, + }, + ], + } as any, + ]) + + assert.equal( + JSON.parse(userMessage!).message.content[0].content, + APPROVED_EXIT_PLAN_MODE_MESSAGE, + ) +}) + +test("question answer no becomes rejection tool_result", () => { + clearExitPlanModeQuestions("session-a") + createExitPlanModeQuestionCall("session-a", "exit-plan-1", "Plan", "question-1") + + const userMessage = consumeExitPlanModeQuestionResult("session-a", [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "question-1", + output: { type: "json", value: ["no"] }, + }, + ], + } as any, + ]) + + const parsed = JSON.parse(userMessage!) + assert.equal(parsed.message.content[0].tool_use_id, "exit-plan-1") + assert.equal(parsed.message.content[0].is_error, true) + assert.match(parsed.message.content[0].content, /tool use was rejected/) + assert.match(parsed.message.content[0].content, /no$/) +}) + +test("custom question text becomes rejection feedback without semantic parsing", () => { + clearExitPlanModeQuestions("session-a") + createExitPlanModeQuestionCall("session-a", "exit-plan-1", "Plan", "question-1") + + const userMessage = consumeExitPlanModeQuestionResult("session-a", [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "question-1", + output: { type: "text", value: "revise step 2 first" }, + }, + ], + } as any, + ]) + + const parsed = JSON.parse(userMessage!) + assert.equal(parsed.message.content[0].is_error, true) + assert.match(parsed.message.content[0].content, /revise step 2 first$/) +}) + +test("opencode's formatted custom answer becomes rejection feedback", () => { + clearExitPlanModeQuestions("session-a") + createExitPlanModeQuestionCall("session-a", "exit-plan-1", "Plan", "question-1") + + const userMessage = consumeExitPlanModeQuestionResult("session-a", [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "question-1", + output: { + type: "text", + value: + `User has answered your questions: "Do you want to proceed with this plan?"="revise step 2 first". ` + + `You can now continue with the user's answers in mind.`, + }, + }, + ], + } as any, + ]) + + const parsed = JSON.parse(userMessage!) + assert.equal(parsed.message.content[0].is_error, true) + assert.match(parsed.message.content[0].content, /revise step 2 first$/) +}) + +test("execution-denied question result becomes rejection feedback", () => { + clearExitPlanModeQuestions("session-a") + createExitPlanModeQuestionCall("session-a", "exit-plan-1", "Plan", "question-1") + + const userMessage = consumeExitPlanModeQuestionResult("session-a", [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "question-1", + output: { type: "execution-denied", reason: "user rejected" }, + }, + ], + } as any, + ]) + + const parsed = JSON.parse(userMessage!) + assert.equal(parsed.message.content[0].is_error, true) + assert.match(parsed.message.content[0].content, /user rejected$/) +}) + +test("question mappings are isolated by session and synthetic question id", () => { + clearExitPlanModeQuestions("session-a") + clearExitPlanModeQuestions("session-b") + createExitPlanModeQuestionCall("session-a", "exit-plan-a", "Plan A", "question-1") + createExitPlanModeQuestionCall("session-b", "exit-plan-b", "Plan B", "question-1") + + const ignored = consumeExitPlanModeQuestionResult("session-a", [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "unknown-question", + output: { type: "json", value: { answers: [["yes"]] } }, + }, + ], + } as any, + ]) + assert.equal(ignored, null) + + const userMessage = consumeExitPlanModeQuestionResult("session-b", [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "question-1", + output: { type: "json", value: ["yes"] }, + }, + ], + } as any, + ]) + + assert.equal(JSON.parse(userMessage!).message.content[0].tool_use_id, "exit-plan-b") +}) + +test("deleting a Claude session clears its pending plan-mode question", () => { + const sessionKey = "session-reset" + createExitPlanModeQuestionCall(sessionKey, "exit-plan-1", "Plan", "question-1") + + deleteClaudeSessionId(sessionKey) + + assert.equal( + consumeExitPlanModeQuestionResult(sessionKey, [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "question-1", + output: { type: "json", value: ["yes"] }, + }, + ], + } as any, + ]), + null, + ) +}) + +test("live tool registry is shared within a turn and refreshed next turn", async () => { + let requests = 0 + setOpencodeClient({ + tool: { + list: async () => { + requests++ + return { + data: + requests === 1 + ? [ + { + id: "question", + description: "Ask the user", + parameters: {}, + }, + ] + : [], + } + }, + }, + }) + + try { + const model = new ClaudeCodeLanguageModel("claude-haiku-4-5", { + provider: "claude-code", + cliPath: "claude", + planModeQuestion: true, + }) + const testModel = model as any + const firstTurn = testModel.createLiveToolInfoLoader() + + assert.deepEqual( + await Promise.all([ + testModel.resolvePlanModeQuestion(false, firstTurn), + testModel.resolvePlanModeQuestion(false, firstTurn), + ]), + [true, true], + ) + assert.equal(requests, 1) + + const nextTurn = testModel.createLiveToolInfoLoader() + assert.equal(await testModel.resolvePlanModeQuestion(false, nextTurn), false) + assert.equal(requests, 2) + } finally { + setOpencodeClient({}) + } +}) diff --git a/test-get-claude-user-message.ts b/test-get-claude-user-message.ts new file mode 100644 index 0000000..1f3c8d4 --- /dev/null +++ b/test-get-claude-user-message.ts @@ -0,0 +1,615 @@ +/** + * Unit tests for getClaudeUserMessage in src/message-builder.ts. + * + * Covers the v0.4.8 fix: tool-role messages (AI SDK V3 shape) must produce + * tool_result content blocks instead of falling through to the "(empty)" + * sentinel — otherwise opencode's outer agent loop hangs after every proxy + * tool call, forcing the user to press "continue". + */ +import { test } from "node:test" +import assert from "node:assert/strict" + +import { + compactConversationHistory, + filterSideQuestionHistory, + getClaudeUserMessage, + shouldStripContextReminders, +} from "./src/message-builder.js" + +const p = (msgs: any[]) => msgs as any + +function parsed(prompt: any) { + return JSON.parse(getClaudeUserMessage(prompt)) +} + +test("tool-role tool-result produces tool_result block, not sentinel", () => { + const out = parsed( + p([ + { role: "user", content: "run bash" }, + { role: "assistant", content: [{ type: "text", text: "ok" }] }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_1", + output: { type: "text", value: "hello from bash" }, + }, + ], + }, + ]), + ) + + const blocks = out.message.content + assert.equal(Array.isArray(blocks), true) + assert.equal(blocks.length, 1) + assert.equal(blocks[0].type, "tool_result") + assert.equal(blocks[0].tool_use_id, "call_1") + // Must NOT be the "(empty)" sentinel. + assert.notEqual(blocks[0].type, "text") +}) + +test("multiple tool-results in single tool-role message all flow through", () => { + const out = parsed( + p([ + { role: "user", content: "do both" }, + { role: "assistant", content: [{ type: "text", text: "running" }] }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_a", + output: { type: "text", value: "a result" }, + }, + { + type: "tool-result", + toolCallId: "call_b", + output: { type: "text", value: "b result" }, + }, + ], + }, + ]), + ) + + const blocks = out.message.content + assert.equal(blocks.length, 2) + assert.deepEqual( + blocks.map((b: any) => [b.type, b.tool_use_id]), + [ + ["tool_result", "call_a"], + ["tool_result", "call_b"], + ], + ) +}) + +test("tool-role without tool-result parts still falls through to sentinel", () => { + const out = parsed( + p([ + { role: "user", content: "x" }, + { role: "assistant", content: [{ type: "text", text: "ok" }] }, + { + role: "tool", + content: [{ type: "something-else" }], + }, + ]), + ) + + // No tool-result extracted → falls through to "(empty)" sentinel path + // (correct behavior, matches hasNewUserContent's symmetry). + const blocks = out.message.content + assert.equal(blocks.length, 1) + assert.equal(blocks[0].type, "text") + assert.equal(blocks[0].text, "(empty)") +}) + +test("mixed user-text + tool-role both flow into the same content array", () => { + const out = parsed( + p([ + { role: "user", content: "first turn" }, + { role: "assistant", content: [{ type: "text", text: "running tool" }] }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_1", + output: { type: "text", value: "tool output" }, + }, + ], + }, + { + role: "user", + content: [{ type: "text", text: "follow-up question" }], + }, + ]), + ) + + const blocks = out.message.content + // Should have both the tool_result and the follow-up text, no sentinel. + const types = blocks.map((b: any) => b.type) + assert.ok(types.includes("tool_result"), `expected tool_result in ${types}`) + assert.ok(types.includes("text"), `expected text in ${types}`) + // No "(empty)" sentinel injected. + const textBlock = blocks.find((b: any) => b.type === "text") + assert.notEqual(textBlock.text, "(empty)") +}) + +// --------------------------------------------------------------------------- +// Compaction mode tests +// --------------------------------------------------------------------------- + +function parsedCompaction(prompt: any) { + return JSON.parse( + getClaudeUserMessage(prompt as any, false, { + compactionMode: true, + }), + ) +} + +test("compaction wraps transcript in tag", () => { + const out = parsedCompaction( + p([ + { role: "user", content: "what's 2+2?" }, + { role: "assistant", content: [{ type: "text", text: "4" }] }, + { + role: "user", + content: [{ type: "text", text: "summarize this conversation" }], + }, + ]), + ) + + const blocks = out.message.content + const textBlock = blocks.find((b: any) => b.type === "text") + assert.ok(textBlock, "expected a text block") + assert.ok( + textBlock.text.includes(""), + "expected transcript wrapper", + ) + assert.ok( + textBlock.text.includes(""), + "expected closing transcript tag", + ) + assert.ok( + !textBlock.text.includes("from a previous session that couldn't be resumed"), + "should not use the fresh-session wrapper text", + ) +}) + +test("compaction transcript includes tool_use input, not just count", () => { + const out = parsedCompaction( + p([ + { role: "user", content: "list files" }, + { + role: "assistant", + content: [ + { type: "text", text: "running ls" }, + { + type: "tool-call", + toolCallId: "call_1", + toolName: "Bash", + input: { command: "ls -la /tmp/specific-path" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "Bash", + output: { + type: "text", + value: "file1.txt\nfile2.txt\nspecific-content-here", + }, + }, + ], + }, + { role: "user", content: "summarize" }, + ]), + ) + + const transcript = out.message.content.find((b: any) => b.type === "text").text + assert.ok( + transcript.includes("tool_use:Bash"), + "expected rendered tool_use with name", + ) + assert.ok( + transcript.includes("ls -la /tmp/specific-path"), + "expected tool input rendered, not placeholder", + ) + assert.ok( + transcript.includes("specific-content-here"), + "expected tool_result content rendered, not placeholder", + ) + // Legacy placeholder text must NOT appear in compaction mode. + assert.ok( + !transcript.includes("[Called 1 tool(s)"), + "should not use legacy placeholder", + ) + assert.ok( + !transcript.includes("[Received 1 tool result(s)]"), + "should not use legacy placeholder", + ) +}) + +test("compaction clips long tool_result with truncation marker", () => { + const longOutput = "x".repeat(15_000) + const out = parsedCompaction( + p([ + { role: "user", content: "do thing" }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "Read", + input: { file: "big.txt" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "Read", + output: { type: "text", value: longOutput }, + }, + ], + }, + { role: "user", content: "summarize" }, + ]), + ) + + const transcript = out.message.content.find((b: any) => b.type === "text").text + assert.ok( + transcript.includes("[truncated"), + "expected truncation marker for over-cap tool_result", + ) + // Bounded: must not contain the full 15k blob. + assert.ok( + transcript.length < 14_000, + `transcript should be capped near 10k chars per tool_result, got ${transcript.length}`, + ) +}) + +test("compaction final user instruction follows the transcript", () => { + const out = parsedCompaction( + p([ + { role: "user", content: "what's up" }, + { role: "assistant", content: [{ type: "text", text: "hi" }] }, + { + role: "user", + content: [ + { + type: "text", + text: "Your task is to summarize the conversation.", + }, + ], + }, + ]), + ) + + const blocks = out.message.content + // Expect: [transcript-text-block, instruction-text-block] + const texts = blocks.filter((b: any) => b.type === "text").map((b: any) => b.text) + assert.equal(texts.length, 2, `expected 2 text blocks, got ${texts.length}`) + assert.ok(texts[0].includes("")) + assert.ok(texts[1].includes("Your task is to summarize")) + // Synthesis instruction must NOT be embedded inside the transcript block. + assert.ok(!texts[0].includes("Your task is to summarize")) +}) + +test("no thinking keyword is appended to the user message", () => { + // Effort reaches the CLI as CLAUDE_CODE_EFFORT_LEVEL at spawn; the message + // itself must carry none of the retired "(ultrathink)"-style hints. + const out = JSON.parse( + getClaudeUserMessage(p([{ role: "user", content: "hello" }]) as any, false), + ) + const texts = out.message.content + .filter((b: any) => b.type === "text") + .map((b: any) => b.text) + .join("\n") + assert.ok(texts.includes("hello")) + assert.ok( + !/\((think( hard(er)?)?|megathink|ultrathink)\)/.test(texts), + "no reasoning keyword may be injected into the message", + ) +}) + +test("AI SDK v4 image part carries its binary in part.image", () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]) + const out = parsed( + p([ + { + role: "user", + content: [ + { type: "text", text: "what is in this screenshot?" }, + { type: "image", image: png, mediaType: "image/png" }, + ], + }, + ]), + ) + + const image = out.message.content.find((b: any) => b.type === "image") + assert.ok(image, "image part must not be dropped") + assert.equal(image.source.media_type, "image/png") + assert.equal(image.source.data, png.toString("base64")) +}) + +test("part.data still wins when part.image is absent", () => { + const out = parsed( + p([ + { + role: "user", + content: [ + { type: "file", data: "aGVsbG8=", mediaType: "image/webp" }, + ], + }, + ]), + ) + + const image = out.message.content.find((b: any) => b.type === "image") + assert.ok(image, "data-carrying file part must still produce an image block") + assert.equal(image.source.media_type, "image/webp") + assert.equal(image.source.data, "aGVsbG8=") +}) + +test("fresh-session and compaction histories exclude aside exchanges, not subsequent work", () => { + const prompt = p([ + { role: "user", content: "main task" }, + { role: "assistant", content: [{ type: "text", text: "main answer" }] }, + { role: "user", content: [{ type: "text", text: "/btw private aside" }] }, + { role: "assistant", content: [{ type: "text", text: "private answer" }] }, + { role: "user", content: "/btw" }, + { role: "assistant", content: [{ type: "text", text: "aside usage" }] }, + { role: "user", content: "ordinary next user" }, + { role: "assistant", content: [{ type: "text", text: "ordinary next answer" }] }, + { role: "user", content: "current instruction" }, + ]) + const original = structuredClone(prompt) + for (const mode of ["fresh-session", "compaction"] as const) { + const transcript = compactConversationHistory(prompt, { mode })! + assert.match(transcript, /main task/) + assert.match(transcript, /main answer/) + assert.match(transcript, /ordinary next user/) + assert.match(transcript, /ordinary next answer/) + assert.doesNotMatch(transcript, /private|aside usage|\/btw|current instruction/) + const message = JSON.parse(getClaudeUserMessage(prompt, true, { compactionMode: mode === "compaction" })) + assert.doesNotMatch(JSON.stringify(message), /private|aside usage|\/btw/) + assert.equal(message.message.content.at(-1).text, "current instruction") + } + assert.deepEqual(prompt, original, "history filtering must not mutate the prompt") +}) + +test("an unanswered aside never removes the following ordinary user or replays in its envelope", () => { + const prompt = p([ + { role: "user", content: "main task" }, + { role: "assistant", content: [{ type: "text", text: "main answer" }] }, + { role: "user", content: "/btw unanswered aside" }, + { role: "user", content: "ordinary next user" }, + ]) + const message = JSON.parse(getClaudeUserMessage(prompt, true)) + assert.doesNotMatch(JSON.stringify(message), /unanswered aside|\/btw/) + assert.equal(message.message.content.at(-1).text, "ordinary next user") + assert.equal(filterSideQuestionHistory(prompt).at(-1), prompt.at(-1)) +}) + +test("aside filtering preserves ordinary /btw mentions, mixed media, tools, and their replies", () => { + const prompt = p([ + { role: "user", content: "explain /btw please" }, + { role: "assistant", content: [{ type: "text", text: "/btw is a command" }] }, + { role: "user", content: [{ type: "text", text: "/btw image question" }, { type: "image", image: "image data" }] }, + { role: "assistant", content: [{ type: "text", text: "image response" }] }, + { role: "tool", content: [{ type: "tool-result", toolCallId: "call", output: { type: "text", value: "tool result" } }] }, + { role: "user", content: "summarize" }, + ]) + assert.deepEqual(filterSideQuestionHistory(prompt), prompt) + const transcript = compactConversationHistory(prompt, { mode: "compaction" })! + assert.match(transcript, /explain \/btw please/) + assert.match(transcript, /image question/) + assert.match(transcript, /image response/) + assert.match(transcript, /tool result/) +}) + +test("consecutive and split aside responses stay excluded until the next user", () => { + const nextUser = { role: "user", content: "main follow-up" } + const nextAnswer = { role: "assistant", content: [{ type: "text", text: "main reply" }] } + const prompt = p([ + { role: "user", content: "/btw first\nsecond line" }, + { role: "assistant", content: [{ type: "reasoning", text: "aside reasoning" }] }, + { role: "assistant", content: [{ type: "text", text: "aside response" }] }, + { role: "user", content: "/btw another" }, + { role: "assistant", content: [{ type: "text", text: "another aside response" }] }, + nextUser, + nextAnswer, + ]) + assert.deepEqual(filterSideQuestionHistory(prompt), [nextUser, nextAnswer]) +}) + +// Issue #29 (@nic-lan): opencode runs some tools itself, notably the `task` +// call a `subtask: true` command dispatches. The resumed CLI session never +// emitted those `tool_use` blocks, so sending a `tool_result` for one is +// orphaned: Claude cannot resolve the id and the payload sitting in the +// envelope is unreachable. The result was a subagent that finished correctly +// while the main session saw no output at all. +const subtaskPrompt = () => + p([ + { role: "user", content: [{ type: "text", text: "Recall what we decided about X." }] }, + { + role: "assistant", + content: [ + { type: "text", text: "Dispatching the subagent." }, + { type: "tool-call", toolCallId: "call_X", toolName: "task", input: { subagent_type: "general" } }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_X", + toolName: "task", + output: { type: "text", value: "We decided X because of Y." }, + }, + ], + }, + { + role: "user", + content: [{ type: "text", text: "Summarize the task tool output above and continue with your task." }], + }, + ]) + +test("a tool result this CLI process never asked for is sent as text, not an orphaned tool_result", () => { + const out = JSON.parse( + getClaudeUserMessage(subtaskPrompt(), false, { cliToolCallIds: new Set() }), + ) + const blocks = out.message.content + assert.equal( + blocks.some((b: any) => b.type === "tool_result"), + false, + "an id the CLI never issued must not be sent back as a tool_result", + ) + const rendered = blocks.filter((b: any) => b.type === "text").map((b: any) => b.text).join("\n") + assert.match(rendered, /We decided X because of Y\./, "the subagent's answer still reaches the model") + assert.match(rendered, //, "and it says what produced it") + assert.ok( + rendered.indexOf("We decided X because of Y.") < rendered.indexOf("Summarize the task tool output above"), + "the output has to precede the instruction that calls it 'above'", + ) +}) + +test("a tool result this CLI process is waiting on is still a real tool_result block", () => { + const out = JSON.parse( + getClaudeUserMessage(subtaskPrompt(), false, { cliToolCallIds: new Set(["call_X"]) }), + ) + const result = out.message.content.find((b: any) => b.type === "tool_result") + assert.ok(result, "the proxy round-trip depends on this block, so the gate must let it through") + assert.equal(result.tool_use_id, "call_X") + assert.match(result.content, /We decided X because of Y\./) +}) + +test("the fresh-session history keeps tool inputs and result bodies", () => { + const history = compactConversationHistory(subtaskPrompt()) + assert.ok(history, "there is prior conversation to render") + assert.match(history!, /We decided X because of Y\./, "the result body survives, not just a count") + assert.match(history!, /\[tool_use:task\(/, "and the call that produced it is named with its input") + assert.doesNotMatch(history!, /Called 1 tool\(s\)/, "the lossy placeholder is gone") +}) + +// --- dcp context reminders ------------------------------------------------- +// +// opencode-dcp anchors `` blocks into message text, so +// each one is re-sent with every message that carries it. The loudest orders +// the model to call `compress`, which under this provider only exists when +// the operator forwards it. Stripping is opt-in and must switch itself off +// the moment the reminder becomes satisfiable. + +const DCP_NUDGE = ` +CRITICAL WARNING: MAX CONTEXT LIMIT REACHED + +You MUST use the \`compress\` tool now. Do not continue normal exploration until compression is handled. +` + +function nudgedPrompt(): any { + return p([ + { + role: "user", + content: [ + { type: "text", text: "explain the broker" }, + // dcp appends its own message marker after a block, which is why the + // strip cannot be anchored to the end of a part. + { type: "text", text: `${DCP_NUDGE}\nmsg_1` }, + ], + }, + ]) +} + +test("dcp reminders survive by default", () => { + const out = JSON.parse(getClaudeUserMessage(nudgedPrompt())) + const text = out.message.content.map((b: any) => b.text ?? "").join("\n") + assert.match(text, /MAX CONTEXT LIMIT REACHED/, "an upgrade must change nothing") +}) + +test("stripContextReminders removes the block and keeps everything else", () => { + const out = JSON.parse( + getClaudeUserMessage(nudgedPrompt(), false, { stripContextReminders: true }), + ) + const text = out.message.content.map((b: any) => b.text ?? "").join("\n") + assert.doesNotMatch(text, /MAX CONTEXT LIMIT REACHED/) + assert.doesNotMatch(text, /dcp-system-reminder/) + assert.match(text, /explain the broker/, "the operator's own message is untouched") + assert.match(text, /msg_1<\/dcp-message-id>/, "trailing metadata survives") +}) + +test("the strip leaves opencode's own blocks alone", () => { + const prompt = p([ + { + role: "user", + content: [ + { type: "text", text: "do the thing" }, + { type: "text", text: "opencode says stay in plan mode" }, + ], + }, + ]) + const out = JSON.parse( + getClaudeUserMessage(prompt, false, { stripContextReminders: true }), + ) + const text = out.message.content.map((b: any) => b.text ?? "").join("\n") + assert.match( + text, + /stay in plan mode/, + "those are opencode's instructions to the model, not an unsatisfiable order", + ) +}) + +test("a message whose only text was a reminder does not take the empty sentinel path", () => { + const prompt = p([ + { role: "user", content: [{ type: "text", text: "first question" }] }, + { role: "assistant", content: [{ type: "text", text: "answered" }] }, + { role: "user", content: [{ type: "text", text: DCP_NUDGE }] }, + ]) + const out = JSON.parse( + getClaudeUserMessage(prompt, false, { stripContextReminders: true }), + ) + assert.doesNotMatch(JSON.stringify(out), /MAX CONTEXT LIMIT REACHED/) + assert.ok(Array.isArray(out.message.content), "still a well-formed user message") +}) + +test("the fresh-session rebuild strips them too, where they all replay at once", () => { + const prompt = p([ + { role: "user", content: [{ type: "text", text: `old turn\n${DCP_NUDGE}` }] }, + { role: "assistant", content: [{ type: "text", text: `sure\n${DCP_NUDGE}` }] }, + { role: "user", content: [{ type: "text", text: "current question" }] }, + ]) + const out = getClaudeUserMessage(prompt, true, { stripContextReminders: true }) + assert.match(out, /old turn/, "the history itself is still rebuilt") + assert.doesNotMatch(out, /MAX CONTEXT LIMIT REACHED/) +}) + +test("stripping switches itself off as soon as compress is reachable", () => { + assert.equal(shouldStripContextReminders({ enabled: true }), true) + assert.equal( + shouldStripContextReminders({ enabled: false }), + false, + "default off", + ) + assert.equal( + shouldStripContextReminders({ enabled: true, proxyTools: ["Task", "Compress"] }), + false, + "the plugin's own compress makes the reminder satisfiable", + ) + assert.equal( + shouldStripContextReminders({ enabled: true, proxyOpencodeTools: ["compress"] }), + false, + "and so does forwarding opencode's", + ) + assert.equal( + shouldStripContextReminders({ enabled: true, proxyTools: ["Task", "Bash"] }), + true, + ) +}) diff --git a/test-has-new-user-content.ts b/test-has-new-user-content.ts new file mode 100644 index 0000000..0e72e52 --- /dev/null +++ b/test-has-new-user-content.ts @@ -0,0 +1,92 @@ +/** + * Unit tests for hasNewUserContent in src/claude-code-language-model.ts. + */ +import { test } from "node:test" +import assert from "node:assert/strict" + +import { hasNewUserContent } from "./src/claude-code-language-model.js" + +const p = (msgs: any[]) => msgs as any + +test("tool-role message with tool-result counts as new content", () => { + assert.equal( + hasNewUserContent( + p([ + { role: "user", content: "go" }, + { role: "assistant", content: [{ type: "text", text: "ok" }] }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "x", + output: { type: "text", value: "done" }, + }, + ], + }, + ]), + ), + true, + ) +}) + +test("assistant-ended prompt still returns false (49345e3 preserved)", () => { + assert.equal( + hasNewUserContent( + p([ + { role: "user", content: "go" }, + { role: "assistant", content: [{ type: "text", text: "ok" }] }, + ]), + ), + false, + ) +}) + +test("empty tool-role content does not falsely return true", () => { + assert.equal( + hasNewUserContent( + p([ + { role: "user", content: "go" }, + { role: "assistant", content: [{ type: "text", text: "ok" }] }, + { role: "tool", content: [] }, + ]), + ), + false, + ) +}) + +test("tool-role without tool-result parts is not new content", () => { + assert.equal( + hasNewUserContent( + p([ + { role: "user", content: "go" }, + { role: "assistant", content: [{ type: "text", text: "ok" }] }, + { role: "tool", content: [{ type: "other" } as any] }, + ]), + ), + false, + ) +}) + +test("trailing user message after tool-result is new content", () => { + assert.equal( + hasNewUserContent( + p([ + { role: "user", content: "go" }, + { role: "assistant", content: [{ type: "text", text: "ok" }] }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "x", + output: { type: "text", value: "done" }, + }, + ], + }, + { role: "user", content: "more" }, + ]), + ), + true, + ) +}) diff --git a/test-logger.ts b/test-logger.ts new file mode 100644 index 0000000..af26413 --- /dev/null +++ b/test-logger.ts @@ -0,0 +1,276 @@ +/** + * Unit tests for the logger module: + * - level threshold (debug < info < notice < warn < error) + * - mode policy (silent vs debug) for TUI routing + * - env-var precedence over config + * - boolean / level parsing edge cases + * + * File-write side effects are exercised by pointing `dir` at a temp dir and + * inspecting the file after each test. + */ +import { test } from "node:test" +import assert from "node:assert/strict" +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { + _resetLoggerForTests, + configureLogger, + getLoggerConfig, + log, +} from "./src/logger.js" + +function captureStderr(): { lines: string[]; restore: () => void } { + const lines: string[] = [] + const original = console.error + console.error = (line: string) => { + lines.push(line) + } + return { + lines, + restore: () => { + console.error = original + }, + } +} + +function withTempDir(): { dir: string; cleanup: () => void; readLog: () => string } { + const dir = mkdtempSync(join(tmpdir(), "opencode-cc-logtest-")) + return { + dir, + readLog() { + const f = join(dir, "plugin.log") + return existsSync(f) ? readFileSync(f, "utf8") : "" + }, + cleanup() { + rmSync(dir, { recursive: true, force: true }) + }, + } +} + +function clearEnv(): void { + delete process.env.OPENCODE_CLAUDE_CODE_LOG_FILE + delete process.env.OPENCODE_CLAUDE_CODE_LOG_DIR + delete process.env.OPENCODE_CLAUDE_CODE_LOG_LEVEL + delete process.env.DEBUG +} + +test("default config: file=false, mode=silent, level=info", () => { + clearEnv() + _resetLoggerForTests() + const c = getLoggerConfig() + assert.equal(c.file, false) + assert.equal(c.mode, "silent") + assert.equal(c.level, "info") + assert.equal(c.dir, null) +}) + +test("level threshold: debug dropped at level=info", () => { + clearEnv() + const tmp = withTempDir() + try { + configureLogger({ file: true, dir: tmp.dir, level: "info", mode: "silent" }) + log.debug("dropped-debug") + log.info("kept-info") + const out = tmp.readLog() + assert.ok(!out.includes("dropped-debug")) + assert.ok(out.includes("kept-info")) + } finally { + tmp.cleanup() + _resetLoggerForTests() + } +}) + +test("level=error drops warn entirely (no file, no TUI)", () => { + clearEnv() + const tmp = withTempDir() + const stderr = captureStderr() + try { + configureLogger({ file: true, dir: tmp.dir, level: "error", mode: "silent" }) + log.warn("dropped-warn") + log.error("kept-error") + const out = tmp.readLog() + assert.ok(!out.includes("dropped-warn"), "warn should not reach file") + assert.ok(out.includes("kept-error"), "error should reach file") + const tui = stderr.lines.join("\n") + assert.ok(!tui.includes("dropped-warn"), "warn should not reach TUI") + assert.ok(tui.includes("kept-error"), "error should reach TUI") + } finally { + stderr.restore() + tmp.cleanup() + _resetLoggerForTests() + } +}) + +test("mode=silent: only warn/error reach TUI", () => { + clearEnv() + const tmp = withTempDir() + const stderr = captureStderr() + try { + configureLogger({ file: true, dir: tmp.dir, level: "debug", mode: "silent" }) + log.info("silent-info") + log.notice("silent-notice") + log.warn("silent-warn") + log.error("silent-error") + const tui = stderr.lines.join("\n") + assert.ok(!tui.includes("silent-info")) + assert.ok(!tui.includes("silent-notice")) + assert.ok(tui.includes("silent-warn")) + assert.ok(tui.includes("silent-error")) + } finally { + stderr.restore() + tmp.cleanup() + _resetLoggerForTests() + } +}) + +test("mode=debug: all emitted levels reach TUI", () => { + clearEnv() + const tmp = withTempDir() + const stderr = captureStderr() + try { + configureLogger({ file: true, dir: tmp.dir, level: "debug", mode: "debug" }) + log.debug("loud-debug") + log.info("loud-info") + log.notice("loud-notice") + log.warn("loud-warn") + log.error("loud-error") + const tui = stderr.lines.join("\n") + assert.ok(tui.includes("loud-debug")) + assert.ok(tui.includes("loud-info")) + assert.ok(tui.includes("loud-notice")) + assert.ok(tui.includes("loud-warn")) + assert.ok(tui.includes("loud-error")) + } finally { + stderr.restore() + tmp.cleanup() + _resetLoggerForTests() + } +}) + +test("file=false: debug/info/notice vanish entirely, warn/error still in TUI", () => { + clearEnv() + _resetLoggerForTests() + const tmp = withTempDir() + const stderr = captureStderr() + try { + configureLogger({ file: false, dir: tmp.dir, level: "debug", mode: "silent" }) + log.info("no-file-info") + log.warn("no-file-warn") + assert.equal(tmp.readLog(), "", "no file should be written") + const tui = stderr.lines.join("\n") + assert.ok(!tui.includes("no-file-info")) + assert.ok(tui.includes("no-file-warn")) + } finally { + stderr.restore() + tmp.cleanup() + _resetLoggerForTests() + } +}) + +test("env var OPENCODE_CLAUDE_CODE_LOG_FILE overrides config", () => { + clearEnv() + process.env.OPENCODE_CLAUDE_CODE_LOG_FILE = "0" + const tmp = withTempDir() + try { + configureLogger({ file: true, dir: tmp.dir, level: "info" }) + log.info("attempted") + assert.equal(tmp.readLog(), "", "env explicit-off should win over config:true") + } finally { + delete process.env.OPENCODE_CLAUDE_CODE_LOG_FILE + tmp.cleanup() + _resetLoggerForTests() + } +}) + +test("env var OPENCODE_CLAUDE_CODE_LOG_LEVEL overrides config", () => { + clearEnv() + process.env.OPENCODE_CLAUDE_CODE_LOG_LEVEL = "warn" + const tmp = withTempDir() + try { + configureLogger({ file: true, dir: tmp.dir, level: "info" }) + log.info("dropped-by-env") + log.warn("kept-by-env") + const out = tmp.readLog() + assert.ok(!out.includes("dropped-by-env")) + assert.ok(out.includes("kept-by-env")) + } finally { + delete process.env.OPENCODE_CLAUDE_CODE_LOG_LEVEL + tmp.cleanup() + _resetLoggerForTests() + } +}) + +test("env var DEBUG=opencode-claude-code sets mode=debug", () => { + clearEnv() + process.env.DEBUG = "opencode-claude-code" + const stderr = captureStderr() + const tmp = withTempDir() + try { + configureLogger({ file: true, dir: tmp.dir, level: "debug", mode: "silent" }) + log.info("piped-to-tui") + const tui = stderr.lines.join("\n") + assert.ok(tui.includes("piped-to-tui"), "DEBUG env should promote mode to debug") + } finally { + stderr.restore() + delete process.env.DEBUG + tmp.cleanup() + _resetLoggerForTests() + } +}) + +test("env var OPENCODE_CLAUDE_CODE_LOG_DIR overrides config dir", () => { + clearEnv() + const tmpEnv = withTempDir() + const tmpCfg = withTempDir() + process.env.OPENCODE_CLAUDE_CODE_LOG_DIR = tmpEnv.dir + try { + configureLogger({ file: true, dir: tmpCfg.dir, level: "info" }) + log.info("env-wins") + assert.ok(tmpEnv.readLog().includes("env-wins"), "env dir should receive the log") + assert.equal(tmpCfg.readLog(), "", "config dir should be ignored") + } finally { + delete process.env.OPENCODE_CLAUDE_CODE_LOG_DIR + tmpEnv.cleanup() + tmpCfg.cleanup() + _resetLoggerForTests() + } +}) + +test("boolean env parsing: 1/true/on/yes → on; 0/false/no/off → off; '' → unset", () => { + clearEnv() + const cases: Array<[string, boolean]> = [ + ["1", true], + ["true", true], + ["on", true], + ["yes", true], + ["0", false], + ["false", false], + ["no", false], + ["off", false], + ] + for (const [v, expected] of cases) { + process.env.OPENCODE_CLAUDE_CODE_LOG_FILE = v + _resetLoggerForTests() + const c = getLoggerConfig() + assert.equal(c.file, expected, `value "${v}" should produce file=${expected}`) + } + // empty string: unset → fall through to default + process.env.OPENCODE_CLAUDE_CODE_LOG_FILE = "" + _resetLoggerForTests() + assert.equal(getLoggerConfig().file, false, "empty string should be treated as unset") + delete process.env.OPENCODE_CLAUDE_CODE_LOG_FILE +}) + +test("invalid OPENCODE_CLAUDE_CODE_LOG_LEVEL is ignored, config wins", () => { + clearEnv() + process.env.OPENCODE_CLAUDE_CODE_LOG_LEVEL = "lolnope" + try { + configureLogger({ file: false, level: "warn" }) + assert.equal(getLoggerConfig().level, "warn", "invalid env should fall through") + } finally { + delete process.env.OPENCODE_CLAUDE_CODE_LOG_LEVEL + _resetLoggerForTests() + } +}) diff --git a/test-process-lifecycle.ts b/test-process-lifecycle.ts new file mode 100644 index 0000000..dab62f0 --- /dev/null +++ b/test-process-lifecycle.ts @@ -0,0 +1,523 @@ +/** + * Process lifetime as opencode sees it, and the events that end a proxied + * call. The plugin listens to the `claude` process, the stream and the + * protocol instead of inferring failure from elapsed time, so a `task` call + * has no deadline; these tests pin the events that release a call instead, + * and for each one they check BOTH registries a call lives in: the proxy + * server's open HTTP request (`pendingCallIds`) and the broker's entry + * (`getPendingProxyCalls`). With no deadline, an entry either of those + * forgets to drop would be permanent. + * + * - the next user message in the chat (a call the previous turn left + * pending is orphaned, and the CLI is told so), + * - an abort, after content and while opencode is running the tool + * (the CLI is interrupted and its parked request is answered), + * - the child exiting, mid-turn or between turns, + * - the session being deleted in opencode, + * - opencode itself exiting (`test-session-manager.ts`, `killAllActiveProcesses`). + * + * A normal result completing a call is pinned in `test-proxy-task.ts`, and + * the late-result recovery for a CLI that hung up on its own request in the + * same file; nothing here changes either. + * + * Usage: + * npx tsx --test test-process-lifecycle.ts + */ +import assert from "node:assert/strict" +import { test } from "node:test" +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { EventEmitter } from "node:events" +import type { ChildProcess } from "node:child_process" +import type { LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider" + +import plugin, { createClaudeCode, extractDeletedSessionId } from "./src/index.js" +import { + createProxyMcpServer, + DEFAULT_PROXY_TOOLS, + SERVER_CLOSED_MESSAGE, + type ProxyMcpServer, + type ProxyToolCall, +} from "./src/proxy-mcp.js" +import { getPendingProxyCalls, onPendingProxyCall, queuePendingProxyCall } from "./src/proxy-broker.js" +import { + deleteActiveProcess, + deleteActiveProcessAndWait, + deleteClaudeSessionId, + getActiveProcess, + getClaudeSessionId, + isIdleProcessEvictionScheduled, + isTurnInFlight, + sessionKey, + setActiveProcess, + setClaudeSessionId, + snapshotActiveProcesses, + type ActiveProcess, +} from "./src/session-manager.js" + +test("extractDeletedSessionId reads the deleted session's own record and nothing else", () => { + const deleted = { type: "session.deleted", properties: { info: { id: "ses_gone" } } } + assert.equal(extractDeletedSessionId(deleted), "ses_gone") + // opencode wraps the bus payload; both shapes are accepted. + assert.equal(extractDeletedSessionId({ payload: deleted }), "ses_gone") + assert.equal(extractDeletedSessionId({ type: "session.updated", properties: { info: { id: "ses_x" } } }), undefined) + assert.equal(extractDeletedSessionId({ type: "session.deleted", properties: { sessionID: "ses_x" } }), undefined) + assert.equal(extractDeletedSessionId({ type: "session.deleted", properties: { info: { id: "" } } }), undefined) + assert.equal(extractDeletedSessionId(undefined), undefined) +}) + +function fakeProcess(onKill: () => void, opencodeSessionID?: string): ActiveProcess { + const proc = new EventEmitter() as ChildProcess + Object.assign(proc, { + exitCode: null, + signalCode: null, + kill() { + onKill() + Object.defineProperty(proc, "exitCode", { configurable: true, value: 0 }) + proc.emit("exit", 0, null) + return true + }, + }) + return { proc, lineEmitter: new EventEmitter(), proxyServer: null, opencodeSessionID } +} + +const TASK_INPUT = { description: "Check the flow", prompt: "Verify it.", subagent_type: "general" } + +/** A real `tools/call` for `task`, authenticated, that stays open until released. */ +function parkTaskRequest(server: ProxyMcpServer): Promise { + return fetch(server.url, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${server.authToken}` }, + body: JSON.stringify({ + jsonrpc: "2.0", id: "parked", method: "tools/call", + params: { name: "task", arguments: TASK_INPUT }, + }), + }).then((response) => response.json()) +} + +test("the event hook releases a deleted session's processes and parked calls, and leaves every other session alone", async () => { + const hooks = await plugin.server({ directory: process.cwd() }) + assert.ok(hooks.event, "the plugin subscribes to bus events") + const stamp = Date.now() + const cwd = `/tmp/lifecycle-${stamp}` + const keyFor = (session: string) => + sessionKey(cwd, `claude-opus-5::tools::${session}::context=["claude-code",null]`) + const killed: string[] = [] + const gone = keyFor("ses_gone") + const kept = keyFor("ses_kept") + const shared = keyFor("default") + // The deleted chat's CLI is parked in a real `task` request on a real + // proxy server, wired to the broker the way the language model wires it. + const server = await createProxyMcpServer(DEFAULT_PROXY_TOOLS.filter((t) => t.name === "task")) + server.calls.on("call", (call: ProxyToolCall) => queuePendingProxyCall(gone, call)) + const goneProcess = fakeProcess(() => killed.push(gone), "ses_gone") + goneProcess.proxyServer = server + setActiveProcess(gone, goneProcess) + setActiveProcess(kept, fakeProcess(() => killed.push(kept), "ses_kept")) + setActiveProcess(shared, fakeProcess(() => killed.push(shared))) + setClaudeSessionId(gone, "claude-gone") + setClaudeSessionId(kept, "claude-kept") + const queued = new Promise((resolve) => server.calls.once("call", () => resolve())) + const request = parkTaskRequest(server) + await queued + assert.equal(server.pendingCallIds().length, 1) + assert.equal(getPendingProxyCalls(gone).length, 1) + try { + await hooks.event!({ event: { type: "session.updated", properties: { info: { id: "ses_gone" } } } }) + assert.deepEqual(killed, [], "only a deletion releases anything") + assert.equal(server.pendingCallIds().length, 1) + + await hooks.event!({ event: { type: "session.deleted", properties: { info: { id: "ses_gone" } } } }) + assert.deepEqual(killed, [gone]) + assert.equal(getActiveProcess(gone), undefined) + assert.equal(getClaudeSessionId(gone), undefined, "a deleted session never resumes") + assert.equal(getPendingProxyCalls(gone).length, 0, "broker entry released") + const answer = await request + assert.equal(answer.result.isError, true) + assert.equal(answer.result.content[0].text, SERVER_CLOSED_MESSAGE) + assert.deepEqual(server.pendingCallIds(), [], "HTTP entry released") + assert.ok(getActiveProcess(kept)) + assert.equal(getClaudeSessionId(kept), "claude-kept") + assert.ok(getActiveProcess(shared), "the shared default bucket is never matched") + + // The session id "default" is the fallback affinity, not a session. + await hooks.event!({ event: { type: "session.deleted", properties: { info: { id: "default" } } } }) + assert.ok(getActiveProcess(shared)) + } finally { + for (const key of [gone, kept, shared]) { + deleteActiveProcess(key) + deleteClaudeSessionId(key) + } + await server.close() + } +}) + +/** A stand-in headless `claude` that answers one turn and stays alive. */ +function fakeAnsweringCli(): { cwd: string; cliPath: string } { + const cwd = mkdtempSync(join(tmpdir(), "opencode-lifecycle-")) + const cliPath = join(cwd, "fake-claude.cjs") + writeFileSync( + cliPath, + `#!/usr/bin/env node +const readline = require("node:readline") +if (process.argv.includes("--version")) { process.stdout.write("2.1.258\\n"); process.exit(0) } +if (process.argv.includes("--help")) { process.stdout.write("Usage: claude [options]\\n"); process.exit(0) } +readline.createInterface({ input: process.stdin }).on("line", () => { + const session_id = "fake-session" + process.stdout.write(JSON.stringify({ type: "system", subtype: "init", session_id }) + "\\n") + process.stdout.write(JSON.stringify({ + type: "assistant", session_id, + message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text: "done" }] }, + }) + "\\n") + process.stdout.write(JSON.stringify({ + type: "result", subtype: "success", session_id, is_error: false, duration_ms: 1, num_turns: 1, + usage: { input_tokens: 1, output_tokens: 1 }, + }) + "\\n") +}) +`, + ) + chmodSync(cliPath, 0o755) + return { cwd, cliPath } +} + +async function completeOneTurn(settings: { idleProcessTimeoutMs?: number }) { + const fake = fakeAnsweringCli() + const modelId = `claude-test-idle-${settings.idleProcessTimeoutMs ?? "default"}` + const sk = sessionKey(fake.cwd, `${modelId}::tools::default::context=["claude-code",null]`) + try { + const model = createClaudeCode({ + cliPath: fake.cliPath, + cwd: fake.cwd, + bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, + proxyTools: [], + autoContinueIncompleteTurns: false, + ...settings, + }).languageModel(modelId) + const response = await model.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "Say done." }] }], + tools: [{ type: "function", name: "bash", description: "Run", inputSchema: { type: "object", properties: {} } }], + } as any) + const parts: any[] = [] + for await (const part of response.stream) parts.push(part) + assert.equal(parts.find((part) => part.type === "finish")?.finishReason.unified, "stop") + // Read-only: `getActiveProcess` counts as reuse and would disarm the timer. + assert.ok( + snapshotActiveProcesses().some((snapshot) => snapshot.sessionKey === sk), + "the worker is retained for the next turn", + ) + return isIdleProcessEvictionScheduled(sk) + } finally { + await deleteActiveProcessAndWait(sk) + deleteClaudeSessionId(sk) + rmSync(fake.cwd, { recursive: true, force: true }) + } +} + +// The timer is armed by a completed turn, which is the caller-facing +// boundary: it is armed only when the option is set, and never for 0 or unset. +test("a completed turn arms idle eviction only when idleProcessTimeoutMs is set", async () => { + assert.equal(await completeOneTurn({}), false) + assert.equal(await completeOneTurn({ idleProcessTimeoutMs: 0 }), false) + assert.equal(await completeOneTurn({ idleProcessTimeoutMs: 900_000 }), true) +}) + +// --- what ends a proxied call -------------------------------------------------- + +/** + * A stand-in `claude` that, on its first turn, narrates, issues one `task` + * proxy call over HTTP and then parks inside it like the real CLI does. It + * records what happens to that HTTP call, answers an `interrupt` control + * request with the CLI's own error result, answers a later user envelope + * with a fresh reply, and in the `exit-*` modes dies while the call is open. + */ +function parkedTaskCli(mode: "park" | "exit-mid-turn" | "exit-between-turns") { + const cwd = mkdtempSync(join(tmpdir(), "opencode-lifecycle-task-")) + const cliPath = join(cwd, "fake-claude.cjs") + const eventsPath = join(cwd, "events.jsonl") + writeFileSync( + cliPath, + `#!/usr/bin/env node +const fs = require("node:fs") +const readline = require("node:readline") +if (process.argv.includes("--version")) { process.stdout.write("2.1.258\\n"); process.exit(0) } +if (process.argv.includes("--help")) { process.stdout.write("Usage: claude [options]\\n"); process.exit(0) } +const args = process.argv.slice(2) +let proxyUrl, proxyHeaders = {} +const configIndex = args.indexOf("--mcp-config") +if (configIndex >= 0) { + for (let index = configIndex + 1; index < args.length && !args[index].startsWith("--"); index++) { + try { + const entry = JSON.parse(fs.readFileSync(args[index], "utf8")).mcpServers?.opencode_proxy + proxyUrl = entry?.url ?? proxyUrl + proxyHeaders = entry?.headers ?? proxyHeaders + } catch {} + } +} +if (!proxyUrl) { process.stderr.write("missing opencode proxy URL\\n"); process.exit(2) } +const mode = ${JSON.stringify(mode)} +const session_id = "fake-session" +const record = (event) => fs.appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify(event) + "\\n") +const emit = (message) => process.stdout.write(JSON.stringify(message) + "\\n") +const result = (extra) => emit({ + type: "result", subtype: "success", session_id, is_error: false, duration_ms: 1, num_turns: 1, + usage: { input_tokens: 1, output_tokens: 1 }, ...extra, +}) +let handled = false +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const envelope = JSON.parse(line) + if (envelope.type === "control_request" && envelope.request?.subtype === "interrupt") { + record({ type: "interrupt" }) + result({ subtype: "error_during_execution", is_error: true, result: "interrupted" }) + return + } + if (envelope.type !== "user") return + if (handled) { + record({ type: "input", envelope }) + emit({ type: "assistant", session_id, message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text: "second answer" }] } }) + result({}) + return + } + handled = true + emit({ type: "system", subtype: "init", session_id }) + emit({ + type: "assistant", session_id, + message: { + role: "assistant", stop_reason: "tool_use", + content: [ + { type: "text", text: "Delegating." }, + { type: "tool_use", id: "claude-proxy-task", name: "mcp__opencode_proxy__task", input: ${JSON.stringify(TASK_INPUT)} }, + ], + }, + }) + fetch(proxyUrl, { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json", ...proxyHeaders }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "task", arguments: ${JSON.stringify(TASK_INPUT)} } }), + }) + .then((response) => response.json()) + .then((body) => record({ type: "http", body })) + .catch((error) => record({ type: "http-error", message: error.message })) + if (mode === "exit-mid-turn") setTimeout(() => process.exit(0), 30) + if (mode === "exit-between-turns") setTimeout(() => process.exit(0), 300) +}) +`, + ) + chmodSync(cliPath, 0o755) + const events = () => + existsSync(eventsPath) + ? readFileSync(eventsPath, "utf8").trim().split("\n").map((line) => JSON.parse(line)) + : [] + return { cwd, cliPath, events } +} + +const TASK_TOOL = { type: "function", name: "task", description: "Delegate", inputSchema: { type: "object", properties: {} } } + +function firstTurn(text = "Delegate the check."): LanguageModelV3CallOptions { + return { prompt: [{ role: "user", content: [{ type: "text", text }] }], tools: [TASK_TOOL] } as any +} + +/** The chat continues with a fresh user message instead of a tool result. */ +function nextUserTurn(): LanguageModelV3CallOptions { + return { + prompt: [ + { role: "user", content: [{ type: "text", text: "Delegate the check." }] }, + { role: "assistant", content: [{ type: "text", text: "Delegating." }] }, + { role: "user", content: [{ type: "text", text: "Never mind, answer directly." }] }, + ], + tools: [TASK_TOOL], + } as any +} + +async function collect(stream: ReadableStream, limitMs = 8_000) { + let timer: ReturnType | undefined + try { + return await Promise.race([ + (async () => { + const parts: LanguageModelV3StreamPart[] = [] + for await (const part of stream) parts.push(part) + return parts + })(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`stream did not finish within ${limitMs}ms`)), limitMs) + }), + ]) + } finally { + clearTimeout(timer) + } +} + +async function eventually(description: string, ready: () => boolean, limitMs = 5_000) { + const deadline = Date.now() + limitMs + while (!ready()) { + assert.ok(Date.now() < deadline, `timed out waiting for ${description}`) + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +function brokerCall(sk: string) { + return new Promise((resolve) => { + const off = onPendingProxyCall(sk, () => { off(); resolve() }) + }) +} + +type Ctx = { + model: ReturnType["languageModel"]> + sk: string + events: () => any[] + /** The proxy server behind the parked call: captured while the process is + * still registered, so its HTTP side can be checked after it is gone. */ + server: () => ProxyMcpServer +} + +async function withParkedTaskCli(mode: Parameters[0], run: (ctx: Ctx) => Promise) { + const fake = parkedTaskCli(mode) + const modelId = `claude-test-lifecycle-${mode}` + const sk = sessionKey(fake.cwd, `${modelId}::tools::default::context=["claude-code",null]`) + let captured: ProxyMcpServer | undefined + try { + const model = createClaudeCode({ + cliPath: fake.cliPath, + cwd: fake.cwd, + bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, + proxyTools: ["Task"], + autoContinueIncompleteTurns: false, + }).languageModel(modelId) + await run({ + model, + sk, + events: fake.events, + server: () => { + captured ??= getActiveProcess(sk)?.proxyServer ?? undefined + assert.ok(captured, "a proxy server is attached to the spawned process") + return captured + }, + }) + } finally { + await deleteActiveProcessAndWait(sk) + deleteClaudeSessionId(sk) + rmSync(fake.cwd, { recursive: true, force: true }) + } +} + +const textOf = (parts: LanguageModelV3StreamPart[]) => + parts.filter((part) => part.type === "text-delta").map((part: any) => part.delta).join("") + +/** Both registries empty, and the CLI's parked request answered with `pattern`. */ +async function assertReleased(ctx: Ctx, pattern: RegExp) { + await eventually("the broker entry to be released", () => getPendingProxyCalls(ctx.sk).length === 0) + await eventually("the HTTP entry to be released", () => ctx.server().pendingCallIds().length === 0) + await eventually("the CLI to record its answered HTTP call", () => ctx.events().some((event) => event.type === "http")) + const http = ctx.events().find((event) => event.type === "http") + assert.equal(http.body.result.isError, true) + assert.match(http.body.result.content[0].text, pattern) +} + +test("a task call the previous turn left pending is released by the next user message, and the CLI is told", { + timeout: 15_000, +}, () => withParkedTaskCli("park", async (ctx) => { + const { model, sk, events } = ctx + const first = await collect((await model.doStream(firstTurn())).stream) + assert.equal(first.filter((part) => part.type === "tool-call").length, 1, "the call reached opencode") + assert.equal((first.find((part) => part.type === "finish") as any)?.finishReason.unified, "tool-calls") + assert.equal(getPendingProxyCalls(sk).length, 1, "nothing on the clock will ever reap this") + assert.equal(ctx.server().pendingCallIds().length, 1) + await new Promise((resolve) => setTimeout(resolve, 150)) + assert.equal(getPendingProxyCalls(sk).length, 1, "still pending: no deadline fired") + assert.equal(isTurnInFlight(getActiveProcess(sk)!), true, "the CLI is parked inside the call") + + // The operator moves on instead of letting opencode deliver a result. + const second = await collect((await model.doStream(nextUserTurn())).stream) + await assertReleased(ctx, /orphaned by a new user turn/) + assert.ok(events().some((event) => event.type === "interrupt"), "the parked turn was interrupted first") + assert.ok(textOf(second).includes("second answer"), textOf(second)) + assert.equal((second.find((part) => part.type === "finish") as any)?.finishReason.unified, "stop") + assert.equal(isTurnInFlight(getActiveProcess(sk)!), false) +})) + +test("an abort after content interrupts the CLI and releases its pending call at once", { + timeout: 15_000, +}, () => withParkedTaskCli("park", async (ctx) => { + const { model, sk, events } = ctx + const abort = new AbortController() + const queued = brokerCall(sk) + const response = await model.doStream({ ...firstTurn(), abortSignal: abort.signal }) + const collecting = collect(response.stream) + await queued + ctx.server() + // The narration already streamed, so this is a mid-turn abort: the CLI is + // sent an interrupt and answers with its own result, on which the turn ends. + abort.abort() + const parts = await collecting + await eventually("the interrupt to reach the CLI", () => events().some((event) => event.type === "interrupt")) + await eventually("the CLI's interrupt result to settle the turn", () => !isTurnInFlight(getActiveProcess(sk)!)) + assert.equal(parts.filter((part) => part.type === "error").length, 0, "an abort is not a crash") + // Released by the abort itself, before any further message arrives. + await assertReleased(ctx, /stream was aborted while proxy tool calls were pending/) + assert.ok(getActiveProcess(sk), "the process stays alive for the next message") + + const second = await collect((await model.doStream(nextUserTurn())).stream) + assert.ok(textOf(second).includes("second answer"), textOf(second)) + assert.equal(getPendingProxyCalls(sk).length, 0) +})) + +test("an abort while opencode is running the tool, with the stream already closed, releases the parked call", { + timeout: 15_000, +}, () => withParkedTaskCli("park", async (ctx) => { + const { model, sk, events } = ctx + const abort = new AbortController() + const first = await collect((await model.doStream({ ...firstTurn(), abortSignal: abort.signal })).stream) + assert.equal((first.find((part) => part.type === "finish") as any)?.finishReason.unified, "tool-calls") + assert.equal(getPendingProxyCalls(sk).length, 1) + assert.equal(ctx.server().pendingCallIds().length, 1) + assert.equal(getActiveProcess(sk)!.lineEmitter.listenerCount("line"), 0, "the tool boundary is detached") + + // opencode is running the subagent; the operator presses Esc. + abort.abort() + await eventually("the interrupt to reach the parked CLI", () => events().some((event) => event.type === "interrupt")) + await assertReleased(ctx, /stream was aborted while opencode was running its proxy tool calls/) + await eventually("the CLI's interrupt result to settle the turn", () => !isTurnInFlight(getActiveProcess(sk)!)) + assert.ok(getActiveProcess(sk), "the process stays alive for the next message") + + const second = await collect((await model.doStream(nextUserTurn())).stream) + assert.ok(textOf(second).includes("second answer"), textOf(second)) +})) + +test("a CLI that dies mid-call ends the turn as an error and releases the call on both sides", { + timeout: 15_000, +}, () => withParkedTaskCli("exit-mid-turn", async (ctx) => { + const { model, sk } = ctx + const queued = brokerCall(sk) + const response = await model.doStream(firstTurn()) + const collecting = collect(response.stream) + await queued + const server = ctx.server() + assert.equal(server.pendingCallIds().length, 1) + const parts = await collecting + const errors = parts.filter((part) => part.type === "error") + assert.equal(errors.length, 1, JSON.stringify(parts.map((part) => part.type))) + assert.match(String((errors[0] as any).error?.message), /exited with code 0/) + assert.equal((parts.find((part) => part.type === "finish") as any)?.finishReason.unified, "error") + assert.equal(getPendingProxyCalls(sk).length, 0, "broker entry released") + await eventually("the HTTP entry to be released", () => server.pendingCallIds().length === 0) + await eventually("the dead child to be forgotten", () => getActiveProcess(sk) === undefined) +})) + +test("a CLI that dies between turns releases the call it left pending on both sides, with no turn attached", { + timeout: 15_000, +}, () => withParkedTaskCli("exit-between-turns", async (ctx) => { + const { model, sk } = ctx + const parts = await collect((await model.doStream(firstTurn())).stream) + assert.equal((parts.find((part) => part.type === "finish") as any)?.finishReason.unified, "tool-calls") + assert.equal(getPendingProxyCalls(sk).length, 1) + const server = ctx.server() + assert.equal(server.pendingCallIds().length, 1) + const process = getActiveProcess(sk)! + assert.equal(process.lineEmitter.listenerCount("line"), 0, "nobody is listening for this process now") + await eventually("the child to exit", () => process.proc.exitCode !== null) + await eventually("its broker entry to be released", () => getPendingProxyCalls(sk).length === 0) + await eventually("its HTTP entry to be released", () => server.pendingCallIds().length === 0) + assert.equal(getActiveProcess(sk), undefined) +})) diff --git a/test-proxy-mcp.ts b/test-proxy-mcp.ts new file mode 100644 index 0000000..a53d51b --- /dev/null +++ b/test-proxy-mcp.ts @@ -0,0 +1,1504 @@ +/** + * Integration tests for src/proxy-mcp.ts — the in-process MCP HTTP server. + * + * These stand up a real `createProxyMcpServer` on an ephemeral port and + * drive it over plain HTTP, so they exercise the actual JSON-RPC framing + * (including the catch-block error envelope). + * + * Usage: + * npx tsx --test test-proxy-mcp.ts + */ +import assert from "node:assert/strict" +import { test } from "node:test" +import * as http from "node:http" +import * as fs from "node:fs" +import { + createProxyMcpServer, + buildProxyTimeoutError, + resolveProxyCallTimeoutMs, + resolveProxyClientCeilingMs, + overlayQuestionProxyDescription, + filterQuestionProxyByOpencodeSupport, + resolveMcpProxyToolDefs, + formatTaskBatchResults, + taskBatchChildToolCallId, + taskBatchInputError, + taskBatchTasks, + TASK_BATCH_TOOL_NAME, + DEFAULT_PROXY_TOOLS, + PROXY_DEFAULT_TIMEOUT_MS, + PROXY_NO_DEADLINE_MS, + MAX_PROXY_TIMEOUT_MS, + SERVER_CLOSED_MESSAGE, + type ProxyMcpServer, + type ProxyToolCall, + type ProxyToolResult, +} from "./src/proxy-mcp.js" + +/** + * Low-level POST. `headers` REPLACES the default header set, so the + * security tests below can omit Authorization, send a foreign Host, add an + * Origin, or use a non-JSON Content-Type. `rawBody` bypasses JSON encoding + * for the malformed-payload case. + */ +function post( + url: string, + body: unknown, + opts: { headers?: Record; rawBody?: string } = {}, +): Promise<{ + status: number + json: any +}> { + return new Promise((resolve, reject) => { + const payload = opts.rawBody ?? JSON.stringify(body) + const req = http.request( + url, + { + method: "POST", + headers: opts.headers ?? { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload).toString(), + }, + }, + (res) => { + const chunks: Buffer[] = [] + res.on("data", (c: Buffer) => chunks.push(c)) + res.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8") + try { + resolve({ status: res.statusCode ?? 0, json: JSON.parse(text) }) + } catch { + resolve({ status: res.statusCode ?? 0, json: text }) + } + }) + }, + ) + req.on("error", reject) + req.write(payload) + req.end() + }) +} + +/** The happy path: a correctly authenticated JSON-RPC POST. */ +function authedPost(srv: ProxyMcpServer, body: unknown) { + const payload = JSON.stringify(body) + return post(srv.url, body, { + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload).toString(), + Authorization: `Bearer ${srv.authToken}`, + }, + }) +} + +async function withServer( + fn: (srv: ProxyMcpServer) => Promise, +): Promise { + const srv = await createProxyMcpServer(DEFAULT_PROXY_TOOLS) + try { + return await fn(srv) + } finally { + await srv.close() + } +} + +// Regression for the 2026-07-04 "malformed result that failed schema +// validation" bug: Claude CLI validates tools/call responses against the +// MCP result schema and rejects JSON-RPC error envelopes. Every tools/call +// error path (broker rejection, error result, unknown tool) must return +// an MCP result with `isError: true`, and must echo the request id. +test("tools/call broker rejection returns an MCP result with isError, echoing the id", async () => { + await withServer(async (srv) => { + // Reject every incoming call immediately, simulating a broker + // rejection (the same path a 10-min timeout takes). + srv.calls.on("call", (call: ProxyToolCall) => { + call.reject(new Error("simulated broker rejection")) + }) + + const res = await authedPost(srv, { + jsonrpc: "2.0", + id: 42, + method: "tools/call", + params: { name: "bash", arguments: { command: "echo hi" } }, + }) + + assert.equal(res.status, 200) + assert.equal(res.json.jsonrpc, "2.0") + assert.equal(res.json.id, 42, "response must echo the request id") + assert.equal(res.json.error, undefined, "must not be a JSON-RPC error envelope") + assert.ok(res.json.result, "expected an MCP result envelope") + assert.equal(res.json.result.isError, true) + assert.match( + res.json.result.content[0].text, + /simulated broker rejection/, + ) + }) +}) + +test("tools/call with kind:error result returns an MCP result with isError", async () => { + await withServer(async (srv) => { + srv.calls.on("call", (call: ProxyToolCall) => { + const result: ProxyToolResult = { + kind: "error", + message: "opencode tool execution failed", + } + call.resolve(result) + }) + + const res = await authedPost(srv, { + jsonrpc: "2.0", + id: "req-7", + method: "tools/call", + params: { name: "bash", arguments: {} }, + }) + + assert.equal(res.json.id, "req-7") + assert.equal(res.json.error, undefined) + assert.equal(res.json.result.isError, true) + assert.match( + res.json.result.content[0].text, + /opencode tool execution failed/, + ) + }) +}) + +test("tools/call for an unknown tool returns an MCP result with isError", async () => { + await withServer(async (srv) => { + const res = await authedPost(srv, { + jsonrpc: "2.0", + id: 99, + method: "tools/call", + params: { name: "nonexistent_tool", arguments: {} }, + }) + assert.equal(res.json.id, 99) + assert.equal(res.json.error, undefined) + assert.equal(res.json.result.isError, true) + assert.match(res.json.result.content[0].text, /Unknown proxy tool/) + }) +}) + +test("tools/call success preserves isError:false and the result text", async () => { + await withServer(async (srv) => { + srv.calls.on("call", (call: ProxyToolCall) => { + call.resolve({ kind: "text", text: "done" }) + }) + const res = await authedPost(srv, { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "bash", arguments: {} }, + }) + assert.equal(res.json.result.isError, false) + assert.equal(res.json.result.content[0].text, "done") + }) +}) + +test("malformed JSON still responds (with null id when unparseable)", async () => { + await withServer(async (srv) => { + // Send invalid JSON so parsing throws before requestId is set. The + // request is otherwise well-formed and authenticated, so it reaches + // the parser rather than being rejected by the entry guards. + const res = await post(srv.url, null, { + rawBody: "{not json", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength("{not json").toString(), + Authorization: `Bearer ${srv.authToken}`, + }, + }) + + // When the body never parsed, null id is the only honest answer and + // is correct JSON-RPC (no request id was ever seen). + assert.equal(res.json.id, null) + assert.ok(res.json.error) + }) +}) + +test("tools/list exposes the default proxy defs", async () => { + await withServer(async (srv) => { + const res = await authedPost(srv, { + jsonrpc: "2.0", + id: 1, + method: "tools/list", + }) + const names = res.json.result.tools.map((t: any) => t.name) + assert.ok(names.includes("question")) + assert.ok(names.includes("task")) + assert.ok(names.includes("bash")) + }) +}) + +// --- per-tool proxy timeouts ------------------------------------------------ + +const MIN = 60 * 1000 + +test("resolveProxyCallTimeoutMs: unknown tool uses the flat 10-min default", () => { + assert.equal( + resolveProxyCallTimeoutMs("edit", undefined, undefined), + PROXY_DEFAULT_TIMEOUT_MS, + ) +}) + +test("resolveProxyCallTimeoutMs: task and task_batch have no deadline by default", () => { + // A subagent runs as long as it runs; the call waits for it. Abandoned + // calls are released by lifecycle events, not by the clock. + assert.equal(resolveProxyCallTimeoutMs("task", undefined, undefined), PROXY_NO_DEADLINE_MS) + assert.equal( + resolveProxyCallTimeoutMs(TASK_BATCH_TOOL_NAME, undefined, undefined), + PROXY_NO_DEADLINE_MS, + ) + assert.equal(PROXY_NO_DEADLINE_MS, 0) +}) + +test("resolveProxyCallTimeoutMs: a 0 override disables a tool's deadline", () => { + assert.equal(resolveProxyCallTimeoutMs("edit", undefined, { edit: 0 }), 0) + assert.equal(resolveProxyCallTimeoutMs("question", undefined, { Question: 0 }), 0) +}) + +test("resolveProxyClientCeilingMs covers the largest effective deadline", () => { + // No overrides: task has no deadline, and the CLI rejects `timeout: 0`, + // so the client ceiling is the largest value it (and Node's timers) accept. + assert.equal(resolveProxyClientCeilingMs(undefined), MAX_PROXY_TIMEOUT_MS) + // Once every unlimited tool has a positive override, the ceiling tracks + // the largest effective deadline so Claude's HTTP MCP client never aborts + // before the broker deadline fires. + assert.equal( + resolveProxyClientCeilingMs({ task: 90 * MIN, task_batch: 90 * MIN }), + 90 * MIN, + ) + // ...and it is the per-tool default that counts when it is the largest. + assert.equal( + resolveProxyClientCeilingMs({ task: 5 * MIN, task_batch: 5 * MIN, bash: 1 * MIN }), + 30 * MIN, + ) + // One unlimited tool is enough to need the maximum: overriding `task` + // alone leaves `task_batch` without a deadline. + assert.equal(resolveProxyClientCeilingMs({ task: 90 * MIN }), MAX_PROXY_TIMEOUT_MS) + // A 0 override on any tool does the same. + assert.equal( + resolveProxyClientCeilingMs({ task: 5 * MIN, task_batch: 5 * MIN, bash: 0 }), + MAX_PROXY_TIMEOUT_MS, + ) + // Absurd values are clamped to Node's timer max; invalid ones are ignored. + assert.equal( + resolveProxyClientCeilingMs({ task: 2 ** 40, task_batch: 2 ** 40 }), + MAX_PROXY_TIMEOUT_MS, + ) + assert.equal( + resolveProxyClientCeilingMs({ task: 5 * MIN, task_batch: 5 * MIN, bash: -1, edit: NaN }), + 30 * MIN, + ) +}) + +test("resolveProxyCallTimeoutMs: user override replaces the default", () => { + assert.equal( + resolveProxyCallTimeoutMs("task", undefined, { task: 5 * MIN }), + 5 * MIN, + ) +}) + +test("resolveProxyCallTimeoutMs: override key is case-insensitive", () => { + // Users configure proxyTools with capitalised names ("Task", "Bash"); the + // override map must match regardless of case. + assert.equal( + resolveProxyCallTimeoutMs("task", undefined, { Task: 7 * MIN }), + 7 * MIN, + ) + assert.equal( + resolveProxyCallTimeoutMs("bash", undefined, { Bash: 9 * MIN }), + 9 * MIN, + ) +}) + +test("resolveProxyCallTimeoutMs: bash input.timeout only ever raises", () => { + // The bash proxy def advertises a `timeout` field; the proxy must not + // undercut a build the caller explicitly asked to run long. + assert.equal( + resolveProxyCallTimeoutMs("bash", { timeout: 25 * MIN }, undefined), + 25 * MIN, + ) + // A smaller input.timeout never lowers the resolved deadline. + assert.equal( + resolveProxyCallTimeoutMs("bash", { timeout: 1000 }, { bash: 5 * MIN }), + 5 * MIN, + ) + // And it raises above an override too. + assert.equal( + resolveProxyCallTimeoutMs("bash", { timeout: 12 * MIN }, { bash: 5 * MIN }), + 12 * MIN, + ) +}) + +test("resolveProxyCallTimeoutMs: invalid overrides are ignored", () => { + // Negative / NaN / Infinity must not replace the default: a misformed + // config entry should never collapse a deadline, nor silently remove one. + assert.equal( + resolveProxyCallTimeoutMs("edit", undefined, { edit: -100 }), + PROXY_DEFAULT_TIMEOUT_MS, + ) + assert.equal( + resolveProxyCallTimeoutMs("edit", undefined, { edit: NaN as any }), + PROXY_DEFAULT_TIMEOUT_MS, + ) + assert.equal( + resolveProxyCallTimeoutMs("edit", undefined, { edit: Infinity }), + PROXY_DEFAULT_TIMEOUT_MS, + ) + assert.equal( + resolveProxyCallTimeoutMs("question", undefined, { question: -1 }), + 30 * MIN, + ) + assert.equal( + resolveProxyCallTimeoutMs("task", undefined, { task: "60" as any }), + PROXY_NO_DEADLINE_MS, + ) +}) + +test("resolveProxyCallTimeoutMs: a bash input.timeout restores a deadline the override disabled", () => { + // The floor only ever raises, and a disabled deadline is the lowest value + // there is, so the caller's own timeout wins over `bash: 0`. + assert.equal( + resolveProxyCallTimeoutMs("bash", { timeout: 30_000 }, { bash: 0 }), + 30_000, + ) + assert.equal(resolveProxyCallTimeoutMs("bash", undefined, { bash: 0 }), 0) +}) + +test("resolveProxyCallTimeoutMs: absurd values are clamped to Node's timer max", () => { + // Node setTimeout overflows past 2^31-1 ms (~24.85 days), firing at ~1ms. + // Both an override and a bash input.timeout above the cap must clamp. + assert.equal( + resolveProxyCallTimeoutMs("task", undefined, { task: 2 ** 33 }), + MAX_PROXY_TIMEOUT_MS, + ) + assert.equal( + resolveProxyCallTimeoutMs("bash", { timeout: 2 ** 33 }, undefined), + MAX_PROXY_TIMEOUT_MS, + ) +}) + +test("buildProxyTimeoutError: generic message keeps the catch-block substrings", () => { + // proxy-mcp's catch block classifies "timed out after" + "waiting for + // opencode to resolve" as expected cleanup (notice, not warn). The Task + // variant must keep both substrings too. + const generic = buildProxyTimeoutError("bash", 600000) + assert.match(generic.message, /timed out after 600000ms/) + assert.match(generic.message, /waiting for opencode to resolve/) + assert.doesNotMatch(generic.message, /wake-up/) +}) + +test("buildProxyTimeoutError: task message warns against scheduling a wake-up", () => { + const task = buildProxyTimeoutError("task", 3600000) + assert.match(task.message, /timed out after 3600000ms/) + assert.match(task.message, /waiting for opencode to resolve/) + assert.match(task.message, /may still be running/) + assert.match(task.message, /wake-up/) +}) + +test("buildProxyTimeoutError: task guidance is case-insensitive on the tool name", () => { + // Config / call sites use mixed casing ("Task"); the matcher lowercases. + const task = buildProxyTimeoutError("Task", 60000) + assert.match(task.message, /wake-up/) + // And a non-task tool with unusual casing stays generic. + const generic = buildProxyTimeoutError("BASH", 60000) + assert.doesNotMatch(generic.message, /wake-up/) +}) + +test("tools/call timeout uses the per-tool override and surfaces the task-specific text", async () => { + // Stand up a server with a tiny Task deadline and never resolve the call, + // so the proxy-mcp timer fires and we see the real error envelope that + // Claude would receive. + const srv = await createProxyMcpServer(DEFAULT_PROXY_TOOLS, { task: 50 }) + try { + // Intentionally do NOT attach a calls listener — let the deadline fire. + const res = await authedPost(srv, { + jsonrpc: "2.0", + id: "timeout-1", + method: "tools/call", + params: { + name: "task", + arguments: { description: "x", subagent_type: "gpt", prompt: "y" }, + }, + }) + assert.equal(res.json.id, "timeout-1") + assert.equal(res.json.result.isError, true) + const text = res.json.result.content[0].text + assert.match(text, /timed out after 50ms/) + assert.match(text, /wake-up/) + } finally { + await srv.close() + } +}) + +test("tools/call bash timeout honours input.timeout over a shorter override", async () => { + // Override says 40ms but the call asks for a 30s bash timeout — the + // effective deadline must be 30s, so the call must NOT time out within a + // short window. Resolve it ourselves to end the test promptly. + const srv = await createProxyMcpServer(DEFAULT_PROXY_TOOLS, { bash: 40 }) + try { + let resolved = false + srv.calls.on("call", (call: ProxyToolCall) => { + // Defer resolution past the 40ms override deadline to prove the + // input.timeout (30s) is what governs. + setTimeout(() => { + resolved = true + call.resolve({ kind: "text", text: "built" }) + }, 120) + }) + const res = await authedPost(srv, { + jsonrpc: "2.0", + id: "bash-1", + method: "tools/call", + params: { name: "bash", arguments: { command: "xcodebuild ...", timeout: 30000 } }, + }) + assert.equal(resolved, true, "call should resolve, not time out") + assert.equal(res.json.result.isError, false) + assert.equal(res.json.result.content[0].text, "built") + } finally { + await srv.close() + } +}) + +// --- question proxy: version gate + description overlay --------------------- + +test("question gets a 30-min default deadline (a human has to read the form)", () => { + assert.equal( + resolveProxyCallTimeoutMs("question", undefined, undefined), + 30 * MIN, + ) +}) + +test("resolveProxyClientCeilingMs is always a positive, timer-safe value", () => { + // The ceiling is written into Claude's --mcp-config entry. It can never be + // 0 (the CLI rejects the server config) and never above Node's timer max, + // whatever the overrides say. + for (const overrides of [undefined, {}, { task: 0 }, { task: 2 ** 40 }, { bash: 1 }]) { + const ceiling = resolveProxyClientCeilingMs(overrides) + assert.ok(ceiling > 0, `ceiling must be positive for ${JSON.stringify(overrides)}`) + assert.ok(ceiling <= MAX_PROXY_TIMEOUT_MS) + assert.ok(ceiling >= 30 * MIN, "never below the longest positive per-tool default") + } +}) + +test("tools/call with no deadline stays pending instead of timing out on the next tick", async () => { + // A zero deadline must mean "no timer", not `setTimeout(fn, 0)`: the + // latter rejects the call immediately with "timed out after 0ms". + const srv = await createProxyMcpServer(DEFAULT_PROXY_TOOLS) + try { + let settled: string | null = null + const callReceived = new Promise((resolve) => srv.calls.once("call", () => resolve())) + const response = authedPost(srv, { + jsonrpc: "2.0", + id: "unlimited-1", + method: "tools/call", + params: { + name: "task", + arguments: { description: "x", subagent_type: "general", prompt: "y" }, + }, + }).then((res) => { + settled = String(res.json.result.content[0].text) + return res + }) + await callReceived + await new Promise((r) => setTimeout(r, 100)) + assert.equal(settled, null, "an unlimited call must not be rejected by a timer") + // Closing the server is one of the lifecycle events that releases it. + await srv.close() + const res = await response + assert.equal(res.json.id, "unlimited-1") + assert.equal(res.json.result.isError, true) + assert.equal(res.json.result.content[0].text, SERVER_CLOSED_MESSAGE) + } finally { + await srv.close() + } +}) + +// --- JSON-only long calls ----------------------------------------------------- +// +// Claude's MCP client used to abandon a silent JSON reply at its own HTTP +// timers (~300 s) whatever the per-tool deadline said. SSE clients got +// immediate headers and keepalive comments in 0.15.0; a client that only +// accepts JSON now gets the same liveness as a chunked JSON body. + +/** POST and hand back the response as soon as its headers arrive. */ +function openPost( + srv: ProxyMcpServer, + body: unknown, + extraHeaders: Record = {}, +): Promise { + const payload = JSON.stringify(body) + return new Promise((resolve, reject) => { + const req = http.request( + srv.url, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload).toString(), + Authorization: `Bearer ${srv.authToken}`, + ...extraHeaders, + }, + }, + resolve, + ) + req.on("error", reject) + req.end(payload) + }) +} + +function readAll(res: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + res.on("data", (c: Buffer) => chunks.push(c)) + res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))) + res.on("error", reject) + }) +} + +test("a JSON-only tools/call gets its headers and keepalive whitespace before the result", async () => { + const srv = await createProxyMcpServer(DEFAULT_PROXY_TOOLS, undefined, undefined, { keepaliveMs: 20 }) + try { + let call: ProxyToolCall | undefined + srv.calls.once("call", (c: ProxyToolCall) => { call = c }) + const res = await openPost(srv, { + jsonrpc: "2.0", + id: "json-keepalive-1", + method: "tools/call", + params: { name: "bash", arguments: { command: "sleep 600" } }, + }) + // Headers are in hand while the call is still pending, with no + // Content-Length: chunked is what lets whitespace precede the result. + assert.equal(res.statusCode, 200) + assert.match(String(res.headers["content-type"]), /^application\/json/) + assert.equal(res.headers["content-length"], undefined) + assert.equal(res.headers["transfer-encoding"], "chunked") + assert.ok(call, "the call reached the broker before the reply finished") + + const chunks: string[] = [] + res.setEncoding("utf8") + res.on("data", (chunk: string) => chunks.push(chunk)) + await new Promise((r) => setTimeout(r, 90)) + assert.ok( + chunks.length > 0 && chunks.every((chunk) => chunk.trim() === ""), + `expected only keepalive whitespace before the result, got ${JSON.stringify(chunks)}`, + ) + + call!.resolve({ kind: "text", text: "late but fine" }) + await new Promise((resolve) => res.once("end", resolve)) + const parsed = JSON.parse(chunks.join("")) + assert.equal(parsed.id, "json-keepalive-1") + assert.equal(parsed.result.isError, false) + assert.equal(parsed.result.content[0].text, "late but fine") + } finally { + await srv.close() + } +}) + +test("a JSON-only tools/call that fails after its headers went out still ends as valid JSON", async () => { + const srv = await createProxyMcpServer(DEFAULT_PROXY_TOOLS, undefined, undefined, { keepaliveMs: 10 }) + try { + srv.calls.once("call", (c: ProxyToolCall) => { + setTimeout(() => c.reject(new Error("simulated late broker rejection")), 40) + }) + const res = await openPost(srv, { + jsonrpc: "2.0", + id: "json-keepalive-err", + method: "tools/call", + params: { name: "bash", arguments: { command: "false" } }, + }) + assert.equal(res.statusCode, 200) + const body = await readAll(res) + assert.match(body, /^\s+\{/, "keepalive whitespace precedes the envelope") + const parsed = JSON.parse(body) + assert.equal(parsed.id, "json-keepalive-err") + assert.equal(parsed.error, undefined, "still an MCP result, never a JSON-RPC error envelope") + assert.equal(parsed.result.isError, true) + assert.match(parsed.result.content[0].text, /simulated late broker rejection/) + } finally { + await srv.close() + } +}) + +test("a JSON-only client that hangs up stops its keepalive and the result is dropped, not thrown", async () => { + const srv = await createProxyMcpServer(DEFAULT_PROXY_TOOLS, undefined, undefined, { keepaliveMs: 10 }) + try { + const callReceived = new Promise((resolve) => srv.calls.once("call", resolve)) + const res = await openPost(srv, { + jsonrpc: "2.0", + id: "json-keepalive-gone", + method: "tools/call", + params: { name: "bash", arguments: { command: "sleep 600" } }, + }) + const call = await callReceived + assert.equal(call.channel?.closed, false) + res.destroy() + await new Promise((r) => setTimeout(r, 50)) + // The reply channel is what the language model reads before answering; + // the entry itself stays so a late result can still be recovered. + assert.equal(call.channel?.closed, true) + // Resolving now must not throw into the server (no write to a dead socket). + call.resolve({ kind: "text", text: "nobody is listening" }) + await new Promise((r) => setTimeout(r, 30)) + } finally { + await srv.close() + } +}) + +test("protocol methods keep the single-shot JSON reply with a Content-Length", async () => { + // Only broker-backed tools/call replies are streamed; initialize and + // tools/list are answered in one write as before. + await withServer(async (srv) => { + for (const body of [ + { jsonrpc: "2.0", id: "init", method: "initialize", params: {} }, + { jsonrpc: "2.0", id: "list", method: "tools/list" }, + ]) { + const res = await openPost(srv, body) + assert.equal(res.statusCode, 200) + assert.ok(res.headers["content-length"], `${body.method} must carry a Content-Length`) + assert.equal(res.headers["transfer-encoding"], undefined) + const parsed = JSON.parse(await readAll(res)) + assert.equal(parsed.id, body.id) + assert.ok(parsed.result) + } + }) +}) + +test("an SSE client still gets the event-stream reply", async () => { + await withServer(async (srv) => { + srv.calls.once("call", (c: ProxyToolCall) => c.resolve({ kind: "text", text: "over sse" })) + const res = await openPost( + srv, + { jsonrpc: "2.0", id: "sse-1", method: "tools/call", params: { name: "bash", arguments: { command: "true" } } }, + { Accept: "application/json, text/event-stream" }, + ) + assert.match(String(res.headers["content-type"]), /^text\/event-stream/) + const body = await readAll(res) + const data = body.split("\n").find((line) => line.startsWith("data: ")) + assert.ok(data, "SSE reply carries the JSON-RPC result as a data line") + assert.equal(JSON.parse(data!.slice(6)).result.content[0].text, "over sse") + }) +}) + +test("filterQuestionProxyByOpencodeSupport drops the def on older opencode", () => { + const tools = DEFAULT_PROXY_TOOLS + assert.ok(tools.some((t) => t.name === "question")) + const kept = filterQuestionProxyByOpencodeSupport(tools, true) + assert.ok(kept.some((t) => t.name === "question")) + const dropped = filterQuestionProxyByOpencodeSupport(tools, false) + assert.equal( + dropped.some((t) => t.name === "question"), + false, + "no registry entry means a forwarded call would render as invalid", + ) + // Only `question` is gated; everything else survives untouched. + assert.ok(dropped.some((t) => t.name === "task")) + assert.ok(dropped.some((t) => t.name === "bash")) +}) + +test("overlayQuestionProxyDescription prefers opencode's live description", () => { + const overlaid = overlayQuestionProxyDescription( + DEFAULT_PROXY_TOOLS, + "LIVE question description from opencode", + ) + const question = overlaid.find((t) => t.name === "question") + assert.ok(question) + assert.ok(question.description.startsWith("LIVE question description")) + // The disambiguation note must survive, it is what tells the model the + // built-in AskUserQuestion is disabled. + assert.ok(question.description.includes("AskUserQuestion is disabled")) +}) + +test("overlayQuestionProxyDescription is a no-op without a live description", () => { + const before = DEFAULT_PROXY_TOOLS.find((t) => t.name === "question") + const after = overlayQuestionProxyDescription( + DEFAULT_PROXY_TOOLS, + undefined, + ).find((t) => t.name === "question") + assert.equal(after?.description, before?.description) +}) + +// --------------------------------------------------------------------------- +// Entry-guard security tests. +// +// This endpoint executes bash/edit/write through opencode's executor, so an +// unauthenticated caller on loopback would have arbitrary command execution +// as the user. These pin every guard in front of the JSON-RPC body parser. +// --------------------------------------------------------------------------- + +const LIST_REQ = { jsonrpc: "2.0", id: 1, method: "tools/list" } + +function jsonHeaders( + payload: string, + extra: Record = {}, +): Record { + return { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload).toString(), + ...extra, + } +} + +test("security: a correctly authenticated request is accepted", async () => { + await withServer(async (srv) => { + const res = await authedPost(srv, LIST_REQ) + assert.equal(res.status, 200) + assert.ok(res.json.result.tools.length > 0) + }) +}) + +test("security: a wrong bearer token of equal length is rejected with 401", async () => { + await withServer(async (srv) => { + // Same length as the real token, so this exercises timingSafeEqual + // rather than the cheap length short-circuit in front of it. + const forged = "0".repeat(srv.authToken.length) + assert.equal(forged.length, srv.authToken.length) + const payload = JSON.stringify(LIST_REQ) + const res = await post(srv.url, LIST_REQ, { + headers: jsonHeaders(payload, { Authorization: `Bearer ${forged}` }), + }) + assert.equal(res.status, 401) + }) +}) + +test("security: a short/garbage bearer token is rejected with 401", async () => { + await withServer(async (srv) => { + const payload = JSON.stringify(LIST_REQ) + const res = await post(srv.url, LIST_REQ, { + headers: jsonHeaders(payload, { Authorization: "Bearer nope" }), + }) + assert.equal(res.status, 401) + }) +}) + +test("security: an absent Authorization header is rejected with 401", async () => { + await withServer(async (srv) => { + const payload = JSON.stringify(LIST_REQ) + const res = await post(srv.url, LIST_REQ, { headers: jsonHeaders(payload) }) + assert.equal(res.status, 401) + }) +}) + +test("security: a foreign Host header is rejected with 403 (DNS rebinding)", async () => { + await withServer(async (srv) => { + const payload = JSON.stringify(LIST_REQ) + const res = await post(srv.url, LIST_REQ, { + headers: jsonHeaders(payload, { + Host: "attacker.example", + Authorization: `Bearer ${srv.authToken}`, + }), + }) + assert.equal(res.status, 403) + }) +}) + +test("security: any Origin header is rejected with 403 (browser context)", async () => { + await withServer(async (srv) => { + const payload = JSON.stringify(LIST_REQ) + const res = await post(srv.url, LIST_REQ, { + headers: jsonHeaders(payload, { + Origin: "https://attacker.example", + Authorization: `Bearer ${srv.authToken}`, + }), + }) + assert.equal(res.status, 403) + }) +}) + +test("security: text/plain is rejected with 415 (CORS simple-request bypass)", async () => { + await withServer(async (srv) => { + // text/plain is a CORS "simple request" content type, so a cross-origin + // page can send it with no preflight. Requiring application/json forces + // a preflight that then fails. + const payload = JSON.stringify(LIST_REQ) + const res = await post(srv.url, LIST_REQ, { + headers: { + "Content-Type": "text/plain", + "Content-Length": Buffer.byteLength(payload).toString(), + Authorization: `Bearer ${srv.authToken}`, + }, + }) + assert.equal(res.status, 415) + }) +}) + +test("security: a Content-Type with charset parameters is still accepted", async () => { + await withServer(async (srv) => { + const payload = JSON.stringify(LIST_REQ) + const res = await post(srv.url, LIST_REQ, { + headers: jsonHeaders(payload, { + "Content-Type": "application/json; charset=utf-8", + Authorization: `Bearer ${srv.authToken}`, + }), + }) + assert.equal(res.status, 200) + }) +}) + +test("security: the 401 path answers without reading the request body", async () => { + await withServer(async (srv) => { + const status = await new Promise((resolve, reject) => { + const req = http.request( + srv.url, + { + method: "POST", + headers: { + "Content-Type": "application/json", + // Declare a large body that we never finish sending, and send + // no Authorization. If the handler read the body before + // authenticating it would block here and no response would + // ever arrive. + "Content-Length": "10000000", + }, + }, + (res) => { + clearTimeout(timer) + res.resume() + resolve(res.statusCode ?? 0) + req.destroy() + }, + ) + const timer = setTimeout(() => { + req.destroy() + reject( + new Error( + "no response while the body was still incomplete — the handler appears to read the body before authenticating", + ), + ) + }, 5000) + req.on("error", () => {}) + req.write("{") // one byte; req.end() is deliberately never called + }) + assert.equal(status, 401) + }) +}) + +test("security: the generated MCP config carries the token, 0600, and never in the URL", async () => { + await withServer(async (srv) => { + const cfgPath = srv.configPath() + const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")) + const entry = cfg.mcpServers[srv.serverName] + + assert.equal(entry.type, "http") + assert.equal(entry.headers.Authorization, `Bearer ${srv.authToken}`) + + // The file now holds a secret, so its mode is load-bearing -- ON POSIX. + // Node does not implement owner/group/other mode bits on Windows, where + // this commonly reads back 0o666 and confidentiality instead depends on + // the inherited ACL of os.tmpdir(). Asserting 0o600 there would be a + // test that cannot pass, and claiming it in the README would be a + // guarantee we do not provide. + if (process.platform !== "win32") { + assert.equal(fs.statSync(cfgPath).mode & 0o777, 0o600) + } + + // A token in the URL would leak into logs and process listings. + assert.ok(!srv.url.includes(srv.authToken)) + assert.ok(!entry.url.includes(srv.authToken)) + }) +}) + +// A rejected request must not leave the connection usable. Without an +// explicit close, a peer can declare a large Content-Length, send one byte, +// take the 401, and hold the socket -- and `server.close()` does NOT reap +// connections that are still sending, so shutdown would block behind an +// unauthenticated caller for Node's five-minute request timeout. +// +// This test deliberately never finishes the body. An earlier version of the +// suite masked the defect by destroying the socket client-side as soon as the +// response arrived, which is exactly the cleanup the server must not depend on. +test("security: rejecting an unauthenticated request does not leave shutdown hostage to an unfinished body", async () => { + const net = await import("node:net") + const srv = await createProxyMcpServer(DEFAULT_PROXY_TOOLS) + const { port } = new URL(srv.url) + + const sock = net.connect({ host: "127.0.0.1", port: Number(port) }) + await new Promise((resolve) => sock.once("connect", () => resolve())) + + // Announce a large body, then send a single byte and stop. + sock.write( + "POST /mcp HTTP/1.1\r\n" + + `Host: 127.0.0.1:${port}\r\n` + + "Content-Type: application/json\r\n" + + "Content-Length: 1048576\r\n" + + "\r\n" + + "{", + ) + + const status = await new Promise((resolve) => { + sock.once("data", (chunk) => resolve(chunk.toString("utf8").split("\r\n")[0])) + }) + assert.match(status, /401/, "the unauthenticated request should be rejected") + + // The body is still unfinished here, on purpose. close() must not hang. + const closed = srv.close().then(() => "closed" as const) + const timedOut = new Promise<"hung">((resolve) => + setTimeout(() => resolve("hung"), 4000).unref(), + ) + assert.equal(await Promise.race([closed, timedOut]), "closed") + + sock.destroy() +}) + +test("security: a client using only the generated config's header is accepted (round-trip)", async () => { + await withServer(async (srv) => { + // Proves config generation and request validation agree: read the + // header out of the file Claude is handed, and use nothing else. + const cfg = JSON.parse(fs.readFileSync(srv.configPath(), "utf8")) + const auth = cfg.mcpServers[srv.serverName].headers.Authorization + const payload = JSON.stringify(LIST_REQ) + const res = await post(srv.url, LIST_REQ, { + headers: jsonHeaders(payload, { Authorization: auth }), + }) + assert.equal(res.status, 200) + assert.ok(res.json.result.tools.length > 0) + }) +}) + +test("security: two servers get distinct tokens, and one's token is rejected by the other", async () => { + const a = await createProxyMcpServer(DEFAULT_PROXY_TOOLS) + const b = await createProxyMcpServer(DEFAULT_PROXY_TOOLS) + try { + assert.notEqual(a.authToken, b.authToken) + const payload = JSON.stringify(LIST_REQ) + const res = await post(b.url, LIST_REQ, { + headers: jsonHeaders(payload, { Authorization: `Bearer ${a.authToken}` }), + }) + assert.equal(res.status, 401) + } finally { + await a.close() + await b.close() + } +}) + +// --------------------------------------------------------------------------- +// SSE reply channel. Claude Code's MCP client aborts a tools/call request +// that has produced no bytes for about five minutes (measured on 2.1.258), +// which is how a long `task` ended up answered to a client that had already +// given up. A client that accepts text/event-stream must get headers and a +// first byte immediately and the JSON-RPC result as the final event. +// --------------------------------------------------------------------------- + +type SseCapture = { + status: number + contentType: string + chunks: Array<{ at: number; text: string }> + done: Promise + destroy(): void +} + +function openSse(srv: ProxyMcpServer, body: unknown): Promise { + const payload = JSON.stringify(body) + return new Promise((resolve, reject) => { + const req = http.request( + srv.url, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload).toString(), + Accept: "application/json, text/event-stream", + Authorization: `Bearer ${srv.authToken}`, + }, + }, + (res) => { + const capture: SseCapture = { + status: res.statusCode ?? 0, + contentType: String(res.headers["content-type"] ?? ""), + chunks: [], + done: new Promise((done) => { + res.on("end", done) + res.on("close", done) + }), + destroy: () => req.destroy(), + } + res.on("data", (chunk: Buffer) => { + capture.chunks.push({ at: Date.now(), text: chunk.toString("utf8") }) + }) + resolve(capture) + }, + ) + req.on("error", reject) + req.write(payload) + req.end() + }) +} + +function lastSseMessage(capture: SseCapture): any { + const text = capture.chunks.map((c) => c.text).join("") + const data = text + .split("\n") + .filter((line) => line.startsWith("data: ")) + .pop() + assert.ok(data, `no data line in SSE body: ${JSON.stringify(text)}`) + return JSON.parse(data.slice("data: ".length)) +} + +test("tools/call answers over SSE when the client accepts it: first byte before the result, envelope last", async () => { + await withServer(async (srv) => { + let pending: ProxyToolCall | null = null + srv.calls.on("call", (call: ProxyToolCall) => { + pending = call + }) + const capture = await openSse(srv, { + jsonrpc: "2.0", + id: 7, + method: "tools/call", + params: { name: "bash", arguments: {} }, + }) + assert.equal(capture.status, 200) + assert.match(capture.contentType, /^text\/event-stream/) + // Headers resolved the request already; the open comment is the first + // byte and must land while the call is still pending. + await new Promise((r) => setTimeout(r, 50)) + assert.ok(pending, "call was queued") + assert.ok(capture.chunks.length >= 1, "a first byte arrived before the result") + assert.match(capture.chunks[0].text, /^: open/) + const resolvedAt = Date.now() + pending!.resolve({ kind: "text", text: "done late" }) + await capture.done + const envelope = lastSseMessage(capture) + assert.equal(envelope.id, 7) + assert.equal(envelope.result.isError, false) + assert.equal(envelope.result.content[0].text, "done late") + assert.ok(capture.chunks[0].at <= resolvedAt) + }) +}) + +test("tools/call over SSE: a broker rejection still arrives as an MCP result with isError", async () => { + await withServer(async (srv) => { + srv.calls.on("call", (call: ProxyToolCall) => { + setTimeout(() => call.reject(new Error("boom")), 20) + }) + const capture = await openSse(srv, { + jsonrpc: "2.0", + id: 8, + method: "tools/call", + params: { name: "bash", arguments: {} }, + }) + await capture.done + const envelope = lastSseMessage(capture) + assert.equal(envelope.id, 8) + assert.equal(envelope.result.isError, true) + assert.equal(envelope.result.content[0].text, "boom") + assert.equal(envelope.error, undefined) + }) +}) + +test("tools/call without event-stream in Accept still gets a plain JSON body", async () => { + await withServer(async (srv) => { + srv.calls.on("call", (call: ProxyToolCall) => { + call.resolve({ kind: "text", text: "json" }) + }) + const payload = JSON.stringify({ + jsonrpc: "2.0", + id: 9, + method: "tools/call", + params: { name: "bash", arguments: {} }, + }) + const res = await post(srv.url, null, { + rawBody: payload, + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload).toString(), + Accept: "application/json", + Authorization: `Bearer ${srv.authToken}`, + }, + }) + assert.equal(res.status, 200) + assert.equal(res.json.result.content[0].text, "json") + }) +}) + +test("a client that drops the request flips the call's channel to closed; a late resolve is harmless", async () => { + await withServer(async (srv) => { + let pending: ProxyToolCall | null = null + srv.calls.on("call", (call: ProxyToolCall) => { + pending = call + }) + const capture = await openSse(srv, { + jsonrpc: "2.0", + id: 10, + method: "tools/call", + params: { name: "task", arguments: {} }, + }) + await new Promise((r) => setTimeout(r, 30)) + assert.ok(pending, "call was queued") + assert.equal(pending!.channel?.closed, false) + capture.destroy() + // The server sees the socket close on the next turn of the loop. + await new Promise((r) => setTimeout(r, 100)) + assert.equal(pending!.channel?.closed, true) + // Resolving now must neither throw nor keep the server from closing. + pending!.resolve({ kind: "text", text: "nobody home" }) + await new Promise((r) => setTimeout(r, 30)) + }) +}) + +// --- task_batch (from @broskees' 68ed142, adapted) -------------------------- +// +// Claude Code emits several proxy tool_use blocks in one assistant message but +// sends the MCP requests one at a time, so two `task` calls in one response +// run serially. `task_batch` is one call the plugin fans out into N opencode +// `task` calls inside one tool boundary, which opencode runs concurrently. + +test("task_batch is a default proxy def that reuses the task input shape", async () => { + const batch = DEFAULT_PROXY_TOOLS.find((t) => t.name === TASK_BATCH_TOOL_NAME) + const task = DEFAULT_PROXY_TOOLS.find((t) => t.name === "task") + assert.ok(batch && task) + const items = (batch!.inputSchema as any).properties.tasks.items + assert.equal(items.properties, (task!.inputSchema as any).properties, "same object: one source of truth for the task fields") + assert.deepEqual(items.required, (task!.inputSchema as any).required) + assert.equal((batch!.inputSchema as any).properties.tasks.minItems, 2) + await withServer(async (srv) => { + const res = await authedPost(srv, { jsonrpc: "2.0", id: 1, method: "tools/list" }) + const names = res.json.result.tools.map((t: any) => t.name) + assert.ok(names.includes(TASK_BATCH_TOOL_NAME)) + }) +}) + +test("task_batch input validation names the first problem", () => { + const good = { description: "d", prompt: "p", subagent_type: "general" } + assert.equal(taskBatchInputError({ tasks: [good, good] }), null) + assert.match(taskBatchInputError(undefined)!, /at least two/) + assert.match(taskBatchInputError({ tasks: [good] })!, /at least two/) + assert.match(taskBatchInputError({ tasks: [good, "nope"] })!, /tasks\[1\] must be an object/) + assert.match(taskBatchInputError({ tasks: [good, { ...good, prompt: 7 }] })!, /tasks\[1\]\.prompt must be a string/) + assert.deepEqual(taskBatchTasks({ tasks: [good, good] }), [good, good]) + assert.deepEqual(taskBatchTasks({ tasks: [good] }), [], "an invalid batch fans out to nothing") + assert.equal(taskBatchChildToolCallId("abc-123", 1), "abc-123_task_1") + assert.match(taskBatchChildToolCallId("abc-123", 0), /^[A-Za-z0-9_-]+$/, "ids survive AI SDK normalisation") +}) + +test("task_batch shares the task deadline and its timeout guidance", () => { + assert.equal( + resolveProxyCallTimeoutMs(TASK_BATCH_TOOL_NAME, undefined, undefined), + resolveProxyCallTimeoutMs("task", undefined, undefined), + ) + assert.equal(resolveProxyCallTimeoutMs("Task_Batch", undefined, { task_batch: 5 * MIN }), 5 * MIN) + const err = buildProxyTimeoutError(TASK_BATCH_TOOL_NAME, 1234) + assert.match(err.message, /timed out after 1234ms waiting for opencode to resolve/) + assert.match(err.message, /the subagents/) + assert.match(err.message, /wake-up/) +}) + +test("tools/call rejects a bad task_batch as an MCP error result without queueing it", async () => { + await withServer(async (srv) => { + const seen: ProxyToolCall[] = [] + srv.calls.on("call", (call: ProxyToolCall) => { seen.push(call) }) + const res = await authedPost(srv, { + jsonrpc: "2.0", + id: "batch-bad", + method: "tools/call", + params: { name: TASK_BATCH_TOOL_NAME, arguments: { tasks: [{ description: "only one", prompt: "p", subagent_type: "general" }] } }, + }) + assert.equal(res.json.id, "batch-bad") + assert.equal(res.json.result.isError, true) + assert.match(res.json.result.content[0].text, /at least two/) + assert.equal(seen.length, 0, "nothing reached the broker") + }) +}) + +test("formatTaskBatchResults labels every child in order and never drops a gap", () => { + const task = (description: string) => ({ description, prompt: "p", subagent_type: "general" }) + const ok = formatTaskBatchResults([ + { task: task("first"), result: { kind: "text", text: "alpha" } }, + { task: task("second"), result: { kind: "text", text: "beta" } }, + ]) + assert.equal(ok.kind, "text") + assert.equal((ok as any).isError, undefined) + assert.equal( + (ok as { text: string }).text, + "## task 1 of 2: first (general)\nalpha\n\n## task 2 of 2: second (general)\nbeta", + ) + const mixed = formatTaskBatchResults([ + { task: task("first"), result: { kind: "error", message: "boom" } }, + { task: task("second"), result: null }, + { task: task("third"), result: { kind: "text", text: "gamma", isError: true } }, + ]) + assert.equal((mixed as any).isError, true) + const text = (mixed as { text: string }).text + assert.match(text, /## task 1 of 3: first \(general\)\n\[error\] boom/) + assert.match(text, /## task 2 of 3: second \(general\)\n\[missing\] opencode returned no result/) + assert.match(text, /## task 3 of 3: third \(general\)\n\[error\] gamma/) +}) + +// --- resolveMcpProxyToolDefs ------------------------------------------- +// +// Discovery source regression. These pin the fix for the option that +// defaulted to true and routed nothing: opencode's tool registry +// (`client.tool.list()` / `GET /experimental/tool`) enumerates built-ins +// plus plugin-declared tools only, so an MCP-shaped id is never in it. The +// AI SDK `tools` array opencode hands `doStream` is downstream of the merge +// that adds MCP tools, so it is the only place a provider plugin sees them. +// Measured live on opencode 1.18.31: registry 14 ids, none MCP; the same +// server's model tool set carried 14 `codebase-memory-mcp_*` entries. + +const modelTool = (name: string, extra: Record = {}) => ({ + type: "function" as const, + name, + description: `desc for ${name}`, + inputSchema: { type: "object", properties: { q: { type: "string" } } }, + ...extra, +}) + +test("MCP tools are discovered from the model tool set, not the registry", () => { + const { defs, coveredServers } = resolveMcpProxyToolDefs({ + serverNames: ["codebase-memory-mcp", "figma"], + tools: [ + // The built-ins opencode's registry route does return. None of these + // may be forwarded: that is what `proxyOpencodeTools` is for. + modelTool("bash"), + modelTool("read"), + modelTool("task"), + modelTool("compress"), + modelTool("codebase-memory-mcp_list_projects"), + modelTool("codebase-memory-mcp_search_graph"), + modelTool("figma_get_metadata"), + ], + }) + assert.deepEqual( + defs.map((def) => def.name), + [ + "codebase-memory-mcp_list_projects", + "codebase-memory-mcp_search_graph", + "figma_get_metadata", + ], + ) + assert.deepEqual([...coveredServers].sort(), ["codebase-memory-mcp", "figma"]) + // The schema travels, so Claude sees the real argument shape. + assert.deepEqual(defs[0].inputSchema, { + type: "object", + properties: { q: { type: "string" } }, + }) + assert.equal(defs[0].description, "desc for codebase-memory-mcp_list_projects") +}) + +test("a registry-shaped tool list yields nothing, which is the bug being fixed", () => { + // Verbatim the 14 ids `GET /experimental/tool/ids` returned on 1.18.31 + // while five MCP servers were connected. + const registryIds = [ + "invalid", "question", "bash", "read", "glob", "grep", "edit", "write", + "task", "webfetch", "todowrite", "websearch", "skill", "apply_patch", + ] + const { defs, coveredServers } = resolveMcpProxyToolDefs({ + serverNames: ["figma", "codebase-memory-mcp", "obsidian"], + tools: registryIds.map((id) => modelTool(id)), + }) + assert.deepEqual(defs, []) + assert.equal(coveredServers.size, 0) +}) + +test("the longest server name wins, so a prefix server cannot steal its tools", () => { + const { defs, coveredServers } = resolveMcpProxyToolDefs({ + serverNames: ["slack", "slack_intl"], + tools: [modelTool("slack_intl_send"), modelTool("slack_send")], + }) + assert.deepEqual(defs.map((def) => def.name), ["slack_intl_send", "slack_send"]) + assert.deepEqual([...coveredServers].sort(), ["slack", "slack_intl"]) +}) + +test("coveredServers names only matched servers, so an unmatched one is not stranded", () => { + // The caller excludes coveredServers from `--mcp-config`. Excluding an + // enabled server with no def would drop it from the direct bridge without + // putting it on the proxy, leaving it reachable by neither route. + const { defs, coveredServers } = resolveMcpProxyToolDefs({ + serverNames: ["figma", "obsidian"], + tools: [modelTool("figma_get_metadata")], + }) + assert.deepEqual(defs.map((def) => def.name), ["figma_get_metadata"]) + assert.deepEqual([...coveredServers], ["figma"]) + assert.equal(coveredServers.has("obsidian"), false) +}) + +test("an empty or absent model tool set resolves to nothing", () => { + assert.deepEqual(resolveMcpProxyToolDefs({ serverNames: ["figma"] }).defs, []) + assert.deepEqual( + resolveMcpProxyToolDefs({ serverNames: ["figma"], tools: [] }).defs, + [], + ) + assert.deepEqual( + resolveMcpProxyToolDefs({ serverNames: [], tools: [modelTool("figma_x")] }).defs, + [], + ) +}) + +test("a name another proxy def already holds is dropped, not duplicated", () => { + const { defs, coveredServers } = resolveMcpProxyToolDefs({ + serverNames: ["figma"], + tools: [modelTool("figma_get_metadata"), modelTool("figma_use")], + taken: new Set(["figma_use"]), + }) + assert.deepEqual(defs.map((def) => def.name), ["figma_get_metadata"]) + assert.deepEqual([...coveredServers], ["figma"]) +}) + +test("non-function and unnamed entries are skipped", () => { + const { defs } = resolveMcpProxyToolDefs({ + serverNames: ["figma"], + tools: [ + { type: "provider", name: "figma_native" } as any, + { type: "function", name: " " } as any, + { type: "function" } as any, + modelTool("figma_ok"), + // A duplicate name must not produce two defs of the same tool. + modelTool("figma_ok"), + ], + }) + assert.deepEqual(defs.map((def) => def.name), ["figma_ok"]) +}) + +// --- the wiring: what actually reaches the spawned `claude` ---------------- +// +// The tests above pin `resolveMcpProxyToolDefs` as a function. These pin the +// consequence, which is where the real risk was: the caller excludes +// `coveredServers` from `--mcp-config`, so a server that contributed no tools +// must still be bridged. Get that wrong and it is dropped from the bridge +// without being added to the proxy, reachable by neither route. A live check +// cannot produce that state on demand (every connected server happened to +// contribute tools), so it is pinned here against a real spawn. + +/** A stand-in `claude` that records its argv and answers one turn. */ +function argvRecordingCli(dir: string, fsMod: typeof import("node:fs"), pathMod: typeof import("node:path"), id: string) { + const cliPath = pathMod.join(dir, `mcp-argv-claude-${id}.cjs`) + const argvPath = pathMod.join(dir, `mcp-argv-${id}.json`) + fsMod.writeFileSync( + cliPath, + `#!/usr/bin/env node +const fs = require("node:fs") +const readline = require("node:readline") +if (process.argv.includes("--version")) { process.stdout.write("2.1.258\\n"); process.exit(0) } +if (process.argv.includes("--help")) { process.stdout.write("Usage: claude [options]\\n"); process.exit(0) } +fs.writeFileSync(${JSON.stringify(argvPath)}, JSON.stringify(process.argv.slice(2))) +readline.createInterface({ input: process.stdin }).on("line", () => { + const session_id = "fake-mcp-argv-session" + process.stdout.write(JSON.stringify({ type: "system", subtype: "init", session_id }) + "\\n") + process.stdout.write(JSON.stringify({ type: "assistant", session_id, message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text: "done" }] } }) + "\\n") + process.stdout.write(JSON.stringify({ type: "result", subtype: "success", session_id, is_error: false, duration_ms: 1, num_turns: 1, usage: { input_tokens: 1, output_tokens: 1 } }) + "\\n") +}) +`, + ) + fsMod.chmodSync(cliPath, 0o755) + return { cliPath, argvPath } +} + +/** + * Spawn one real turn with two enabled MCP servers on disk and a model tool + * set naming only the tools in `modelToolNames`, then return every + * `--mcp-config` payload the CLI was actually given, split into the proxy's + * own config and the bridged one. + */ +async function mcpConfigsForSpawn(servers: string[], modelToolNames: string[]) { + const fsMod = await import("node:fs") + const pathMod = await import("node:path") + const osMod = await import("node:os") + const cryptoMod = await import("node:crypto") + const { createClaudeCode } = await import("./src/index.js") + const { sessionKey, deleteActiveProcessAndWait, deleteClaudeSessionId } = + await import("./src/session-manager.js") + + const id = cryptoMod.randomUUID().slice(0, 8) + const root = fsMod.mkdtempSync(pathMod.join(osMod.tmpdir(), "oc-mcp-argv-")) + const cwd = pathMod.join(root, "project") + fsMod.mkdirSync(cwd, { recursive: true }) + + // Distinct server names per scenario on purpose: the bridged config is + // cached as `mcp-.json` and skipped when the file already exists, and + // the hash covers the merged server set rather than the exclusions, so two + // scenarios sharing a server set would read each other's stale file. + fsMod.mkdirSync(pathMod.join(root, "opencode"), { recursive: true }) + fsMod.writeFileSync( + pathMod.join(root, "opencode", "opencode.json"), + JSON.stringify({ + mcp: Object.fromEntries( + servers.map((name) => [ + name, + { type: "remote", url: `https://${name}.invalid/mcp`, enabled: true }, + ]), + ), + }), + ) + + const saved = { + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + OPENCODE_CONFIG: process.env.OPENCODE_CONFIG, + OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR, + OPENCODE_WORKTREE: process.env.OPENCODE_WORKTREE, + HOME: process.env.HOME, + } + process.env.XDG_CONFIG_HOME = root + process.env.HOME = root + delete process.env.OPENCODE_CONFIG + delete process.env.OPENCODE_CONFIG_DIR + delete process.env.OPENCODE_WORKTREE + + const cli = argvRecordingCli(root, fsMod, pathMod, id) + const modelId = `claude-test-mcp-argv-${id}` + const sk = sessionKey(cwd, `${modelId}::tools::default::context=["claude-code",null]`) + try { + const model = createClaudeCode({ + cliPath: cli.cliPath, + cwd, + proxyOpencodeMcpTools: true, + proxyTools: [], + bridgeOpencodeSkills: false, + autoContinueIncompleteTurns: false, + }).languageModel(modelId) + const response = await model.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "Say done." }] }], + tools: modelToolNames.map((name) => ({ + type: "function", + name, + description: `${name} tool`, + inputSchema: { type: "object", properties: {} }, + })), + } as any) + for await (const _ of response.stream) { /* drain */ } + + const argv = JSON.parse(fsMod.readFileSync(cli.argvPath, "utf8")) as string[] + // `--mcp-config ` is variadic (space-separated), so every + // argument after the flag belongs to it until the next option. + const paths: string[] = [] + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] !== "--mcp-config") continue + for (let j = i + 1; j < argv.length && !argv[j]!.startsWith("--"); j += 1) { + paths.push(argv[j]!) + } + } + let proxyConfigs = 0 + const bridged: string[][] = [] + for (const configPath of paths) { + const names = Object.keys( + (JSON.parse(fsMod.readFileSync(configPath, "utf8")).mcpServers ?? {}) as Record< + string, + unknown + >, + ) + if (names.includes("opencode_proxy")) proxyConfigs += 1 + else bridged.push(names.sort()) + } + return { count: paths.length, proxyConfigs, bridged } + } finally { + await deleteActiveProcessAndWait(sk) + deleteClaudeSessionId(sk) + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + fsMod.rmSync(root, { recursive: true, force: true }) + } +} + +// One test, not two: the helper swaps `XDG_CONFIG_HOME` and `HOME` for the +// duration of a spawn, so two top-level tests doing that can interleave and +// read each other's environment. Kept sequential here instead. +test("a server contributing no tools is still bridged, and a fully covered set needs no bridge", async () => { + // Only alpha is in the model tool set, so only alpha is proxied. beta must + // keep its place in `--mcp-config` or it is reachable by neither route. + const partial = await mcpConfigsForSpawn(["alpha", "beta"], ["alpha_thing"]) + assert.deepEqual(partial, { count: 2, proxyConfigs: 1, bridged: [["beta"]] }) + + // The shape observed live: every connected server contributed tools, the + // bridge returns no path, and the CLI gets a single --mcp-config. + const full = await mcpConfigsForSpawn(["gamma", "delta"], ["gamma_thing", "delta_thing"]) + assert.deepEqual(full, { count: 1, proxyConfigs: 1, bridged: [] }) +}) diff --git a/test-proxy-task.ts b/test-proxy-task.ts new file mode 100644 index 0000000..006ed14 --- /dev/null +++ b/test-proxy-task.ts @@ -0,0 +1,1599 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { EventEmitter } from "node:events" +import type { ChildProcess } from "node:child_process" +import type { + LanguageModelV3CallOptions, + LanguageModelV3StreamPart, +} from "@ai-sdk/provider" +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import plugin, { createClaudeCode } from "./src/index.js" +import { + createProxyMcpServer, + DEFAULT_PROXY_TOOLS, + disallowedToolFlags, + isExpectedCleanupError, + MAX_PROXY_TIMEOUT_MS, + resolveProxyClientCeilingMs, + SERVER_CLOSED_MESSAGE, + type ProxyMcpServer, +} from "./src/proxy-mcp.js" +import { + getPendingProxyCalls, + markPendingProxyCallEmitted, + onPendingProxyCall, + queuePendingProxyCall, + rejectAllPendingProxyCallsForSession, + rejectPendingProxyCallById, + resolvePendingProxyCallById, + type PendingProxyCall, +} from "./src/proxy-broker.js" +import { + deleteActiveProcess, + deleteActiveProcessAndWait, + deleteClaudeSessionId, + getActiveProcess, + isTurnInFlight, + setActiveProcess, + setClaudeSessionId, + bufferUnattendedLine, + type ActiveProcess, + sessionKey, +} from "./src/session-manager.js" + +const TASK_INPUT = { + description: "Inspect provider flow", + prompt: "Verify the provider delegates this task through opencode.", + subagent_type: "general", + task_id: "task-existing", + command: "/delegate", + background: true, +} +const PARALLEL_TASK_INPUT = { + ...TASK_INPUT, + description: "Inspect parallel flow", + task_id: "task-parallel", + background: false, +} + +function modelProxyTools(settings: { proxyTools?: string[] } = {}) { + const provider = createClaudeCode(settings) + const model = provider.languageModel("claude-haiku-4-5") as unknown as { + config: { proxyTools?: string[] } + } + return model.config.proxyTools +} + +function createFakeTaskCli( + mode: + | "normal" + | "race" + | "batch" + | "duplicate" + | "error" + | "abort" + | "followup" + | "late" + | "late-queued" + | "swallow" + | "bookkeeping" + | "bookkeeping-respawn" + | "task_batch", +) { + const cwd = mkdtempSync(join(tmpdir(), "opencode-proxy-task-")) + const cliPath = join(cwd, "fake-claude.cjs") + const eventsPath = join(cwd, "events.jsonl") + const source = `#!/usr/bin/env node +const fs = require("node:fs") +const readline = require("node:readline") + +if (process.argv.includes("--version")) { + process.stdout.write("2.1.142\\n") + process.exit(0) +} + +const args = process.argv.slice(2) +const configIndex = args.indexOf("--mcp-config") +let proxyUrl +let proxyHeaders = {} +if (configIndex >= 0) { + for (let index = configIndex + 1; index < args.length; index++) { + const value = args[index] + if (value.startsWith("--")) break + try { + const config = JSON.parse(fs.readFileSync(value, "utf8")) + const entry = config.mcpServers?.opencode_proxy + proxyUrl = entry?.url ?? proxyUrl + // A real MCP client replays the configured headers on every request; + // the proxy server requires its bearer token, so do the same here. + proxyHeaders = entry?.headers ?? proxyHeaders + } catch {} + } +} + +if (!proxyUrl) { + process.stderr.write("missing opencode proxy URL\\n") + process.exit(2) +} + +const mode = ${JSON.stringify(mode)} +const taskInput = ${JSON.stringify(TASK_INPUT)} +const secondTaskInput = ${JSON.stringify(PARALLEL_TASK_INPUT)} +const assistant = { + type: "assistant", + session_id: "fake-session", + message: { + role: "assistant", + stop_reason: "end_turn", + content: [ + { type: "text", text: "I found the relevant files and will delegate the focused check." }, + ...(mode === "task_batch" + ? [{ + type: "tool_use", + id: "claude-proxy-task-batch", + name: "mcp__opencode_proxy__task_batch", + input: { tasks: [taskInput, secondTaskInput] }, + }] + : [{ + type: "tool_use", + id: "claude-proxy-task", + name: "mcp__opencode_proxy__task", + input: taskInput, + }]), + ...(mode === "batch" + ? [{ + type: "tool_use", + id: "claude-proxy-task-2", + name: "mcp__opencode_proxy__task", + input: secondTaskInput, + }] + : []), + ], + }, +} +const result = { + type: "result", + subtype: "success", + session_id: "fake-session", + duration_ms: 1, + num_turns: 1, + is_error: false, + usage: { input_tokens: 1, output_tokens: 1 }, +} + +function emit(message) { + process.stdout.write(JSON.stringify(message) + "\\n") +} + +function emitAssistant() { + if (mode === "abort") { + emit({ + ...assistant, + message: { + ...assistant.message, + content: assistant.message.content.filter((block) => block.type === "tool_use"), + }, + }) + return + } + if (mode === "normal" || mode === "task_batch") { + emit(assistant) + return + } + + emit({ + type: "stream_event", + session_id: "fake-session", + event: { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }, + }) + emit({ + type: "stream_event", + session_id: "fake-session", + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: "I found the relevant files and will delegate the focused check.", + }, + }, + }) + emit({ + type: "stream_event", + session_id: "fake-session", + event: { type: "content_block_stop", index: 0 }, + }) + emit({ + type: "stream_event", + session_id: "fake-session", + event: { + type: "content_block_start", + index: 1, + content_block: { + type: "tool_use", + id: "claude-proxy-task", + name: "mcp__opencode_proxy__task", + }, + }, + }) + emit({ + type: "stream_event", + session_id: "fake-session", + event: { + type: "content_block_delta", + index: 1, + delta: { + type: "input_json_delta", + partial_json: JSON.stringify(taskInput), + }, + }, + }) + emit({ + type: "stream_event", + session_id: "fake-session", + event: { type: "content_block_stop", index: 1 }, + }) + emit({ + type: "stream_event", + session_id: "fake-session", + event: { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + }, + }) + emit(assistant) +} + +async function callTask(input = taskInput, id = 1, signal, name = "task") { + const response = await fetch(proxyUrl, { + method: "POST", + headers: { + "content-type": "application/json", + accept: recoveryMode ? "application/json, text/event-stream" : "application/json", + ...proxyHeaders, + }, + signal, + body: JSON.stringify({ + jsonrpc: "2.0", + id, + method: "tools/call", + params: { name: name, arguments: input }, + }), + }) + if (recoveryMode) { + record({ type: "http-response", id, status: response.status, contentType: response.headers.get("content-type") }) + } + if (response.headers.get("content-type")?.includes("text/event-stream")) { + const body = await response.text() + const data = body.split("\\n").find((line) => line.startsWith("data: ")) + if (!data) throw new Error("SSE response had no JSON-RPC result") + return JSON.parse(data.slice(6)) + } + return response.json() +} + +const recoveryMode = ["late", "late-queued", "swallow", "bookkeeping", "bookkeeping-respawn"].includes(mode) +const swallowMode = mode === "swallow" || mode.startsWith("bookkeeping") +const eventsPath = ${JSON.stringify(eventsPath)} +function record(event) { + fs.appendFileSync(eventsPath, JSON.stringify(event) + "\\n") +} +function answer(text) { + emit({ + ...assistant, + message: { + role: "assistant", + stop_reason: "end_turn", + content: [{ type: "text", text }], + }, + }) + emit(result) +} +const resumed = args.includes("--resume") +if (recoveryMode) { + emit({ type: "system", subtype: "init", session_id: "fake-session" }) + const promptIndex = args.indexOf("--append-system-prompt-file") + record({ + type: "spawn", + args, + pid: process.pid, + proxyUrl, + resumed, + prompt: promptIndex >= 0 ? fs.readFileSync(args[promptIndex + 1], "utf8") : null, + }) +} +const abandoned = new AbortController() +let secondTaskBody +let lateEnvelopeReceived = false +function finishQueuedTask() { + if (secondTaskBody && lateEnvelopeReceived) { + answer("Fresh answer after queued task: " + secondTaskBody.result.content[0].text) + } +} +if (recoveryMode && !swallowMode) { + // The test signals only after the provider stream has closed on tool-calls. + process.once("SIGUSR2", () => { + abandoned.abort() + record({ type: "abandoned" }) + answer("Unattended narration after the task connection timed out.") + if (mode === "late-queued") { + void callTask(secondTaskInput, 2).then((body) => { + secondTaskBody = body + record({ type: "queued-result", body }) + finishQueuedTask() + }).catch((error) => record({ type: "fixture-error", message: error.message })) + } + }) +} + +let handled = false +readline.createInterface({ input: process.stdin }).on("line", (line) => { + if (recoveryMode) { + const envelope = JSON.parse(line) + record({ type: "input", envelope, resumed }) + if (handled || resumed) { + const content = envelope.message?.content + const isCompletion = envelope.type === "user" && + envelope.message?.role === "user" && Array.isArray(content) && + content.length > 0 && content.every((block) => block.type === "text") && + content.some((block) => block.text.includes("subagent complete")) + if (!isCompletion) { + record({ type: "fixture-error", message: "Expected a plain user completion envelope" }) + return + } + lateEnvelopeReceived = true + if (mode === "bookkeeping-respawn") { + emit({ type: "system", subtype: "status", status: null }) + emit({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "old-call", content: "ack" }] } }) + return + } + if (mode === "late-queued") finishQueuedTask() + // Hold the answer back a little so the test can observe the process + // between the continuation envelope and its terminal result. + else setTimeout(() => answer(resumed ? "Fresh answer after watchdog recovery." : "Fresh answer after late completion."), 250) + return + } + handled = true + emitAssistant() + void callTask(taskInput, 1, abandoned.signal).then((body) => { + // A successful HTTP response alone does not prove the CLI resumed. + record({ type: "swallowed-result", body }) + if (mode.startsWith("bookkeeping")) { + emit({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "old-call", content: "ack" }] } }) + } + }).catch((error) => { + if (!abandoned.signal.aborted) record({ type: "fixture-error", message: error.message }) + }) + return + } + if (handled) return + handled = true + emitAssistant() + if (mode === "abort") { + void callTask().catch(() => {}) + return + } + if (mode === "race") { + emit(result) + setTimeout(() => void callTask().catch(() => {}), 25) + return + } + if (mode === "error") { + emit({ ...result, is_error: true, result: "fake task transport error" }) + return + } + if (mode === "batch") { + void callTask().catch(() => {}) + setTimeout(() => void callTask(secondTaskInput, 2).catch(() => {}), 25) + setTimeout(() => emit(result), 50) + return + } + if (mode === "duplicate") { + void callTask().catch(() => {}) + setTimeout(() => emit(result), 30) + setTimeout(() => emit(result), 40) + return + } + if (mode === "task_batch") { + // One MCP call carrying two tasks; the plugin fans it out and the + // gathered result comes back on this single HTTP response. + void callTask({ tasks: [taskInput, secondTaskInput] }, 1, undefined, "task_batch") + .then((body) => { + emit({ + type: "assistant", + session_id: "fake-session", + message: { + role: "assistant", + stop_reason: "end_turn", + content: [{ + type: "text", + text: "Batch received: " + body.result.content[0].text, + }], + }, + }) + emit({ ...result, num_turns: 2 }) + }) + .catch(() => {}) + setTimeout(() => emit(result), 100) + return + } + if (mode === "followup") { + void callTask() + .then((body) => { + emit({ + type: "assistant", + session_id: "fake-session", + message: { + role: "assistant", + stop_reason: "end_turn", + content: [{ + type: "text", + text: "Parent received: " + body.result.content[0].text, + }], + }, + }) + emit({ ...result, num_turns: 2 }) + }) + .catch(() => {}) + setTimeout(() => emit(result), 100) + return + } + void callTask().catch(() => {}) + setTimeout(() => emit(result), 100) +}) +` + writeFileSync(cliPath, source) + chmodSync(cliPath, 0o755) + return { cliPath, cwd, eventsPath } +} + +async function streamTaskBoundary( + mode: "normal" | "race" | "batch" | "duplicate" | "error" | "task_batch", +) { + const fake = createFakeTaskCli(mode) + const modelId = `claude-test-task-${mode}` + const sk = sessionKey(fake.cwd, `${modelId}::tools::default::context=["claude-code",null]`) + + try { + const model = createClaudeCode({ + cliPath: fake.cliPath, + cwd: fake.cwd, + bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, + proxyTools: ["Task"], + }).languageModel(modelId) + const response = await model.doStream({ + prompt: [ + { + role: "user", + content: [{ type: "text", text: "Delegate the focused provider check." }], + }, + ], + tools: [ + { + type: "function", + name: "task", + description: "Delegate work to an opencode subagent", + inputSchema: { type: "object", properties: {} }, + }, + ], + } as any) + + const parts: any[] = [] + for await (const part of response.stream) parts.push(part) + return { + parts, + pending: getPendingProxyCalls(sk).map((call) => ({ ...call })), + } + } finally { + for (const call of getPendingProxyCalls(sk)) { + resolvePendingProxyCallById(call.toolCallId, { + kind: "text", + text: "test cleanup", + }) + } + deleteActiveProcess(sk) + rmSync(fake.cwd, { recursive: true, force: true }) + } +} + +function assertNativeTaskBoundary( + parts: any[], + pending: any[], + expectedInputs = [TASK_INPUT], +) { + const taskCalls = parts.filter( + (part) => part.type === "tool-call" && part.toolName === "task", + ) + assert.equal(taskCalls.length, expectedInputs.length) + assert.ok(taskCalls.every((call) => call.providerExecuted === false)) + assert.deepEqual( + taskCalls.map((call) => JSON.parse(call.input)), + expectedInputs, + ) + + const finishes = parts.filter((part) => part.type === "finish") + assert.equal(finishes.length, 1) + assert.equal(finishes[0].finishReason.unified, "tool-calls") + + const textIndex = parts.findIndex((part) => part.type === "text-delta") + const taskIndex = parts.indexOf(taskCalls[0]) + assert.ok(textIndex >= 0) + assert.ok(textIndex < taskIndex) + + assert.equal(pending.length, expectedInputs.length) + assert.ok(pending.every((call) => call.toolName === "task")) + assert.deepEqual( + pending.map((call) => call.input), + expectedInputs, + ) +} + +async function postRpc( + srv: ProxyMcpServer, + request: Record, +) { + const response = await fetch(srv.url, { + method: "POST", + headers: { + "content-type": "application/json", + // The proxy endpoint requires the per-server bearer token. + authorization: `Bearer ${srv.authToken}`, + }, + body: JSON.stringify(request), + }) + if (response.status === 204) return { status: 204, body: null } + return { status: response.status, body: await response.json() as any } +} + +function waitForBrokerCalls(sessionKey: string, count: number) { + return new Promise((resolve) => { + const calls: PendingProxyCall[] = [] + const unsubscribe = onPendingProxyCall(sessionKey, (call) => { + calls.push(call) + if (calls.length !== count) return + unsubscribe() + resolve(calls) + }) + }) +} + +/** + * The fake CLI is a real Node process, so its cold start competes with + * whatever else the machine is doing. The old value was 500 ms, described + * in a comment as "ample", and it was not: at load average 5 with dozens of + * other node processes, every recovery test here failed, identically on + * master and on already-released tags, while the same commits were green on + * an idle machine. A test that reports the machine's mood rather than the + * code's behaviour is worse than no test, because it trains you to wave + * failures through. + * + * Everything that waits is derived from this one value so the three cannot + * drift apart again: the longest recovery path deliberately lets TWO + * consecutive watchdog deadlines elapse, so any wait shorter than twice the + * watchdog fails by construction rather than by timing. That is exactly how + * the first attempt at this fix broke: the watchdog was raised on its own + * and a hard-coded 5 s wait then expired mid-test. + */ +const START_WATCHDOG_MS = 2_500 +/** Two watchdog deadlines, plus room for the fixture's own work. */ +const RECOVERY_WAIT_MS = START_WATCHDOG_MS * 2 + 5_000 +/** The per-test cap has to sit above the wait it contains. */ +const RECOVERY_TEST_TIMEOUT_MS = RECOVERY_WAIT_MS + 10_000 + +async function eventually(description: string, ready: () => boolean) { + const deadline = performance.now() + RECOVERY_WAIT_MS + while (!ready()) { + assert.ok(performance.now() < deadline, `Timed out waiting for ${description}`) + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +async function collectRecoveryStream( + stream: ReadableStream, +) { + let timer: ReturnType | undefined + try { + return await Promise.race([ + (async () => { + const parts: LanguageModelV3StreamPart[] = [] + for await (const part of stream) parts.push(part) + return parts + })(), + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new Error( + `Recovery stream did not finish within ${RECOVERY_WAIT_MS}ms`, + ), + ) + }, RECOVERY_WAIT_MS) + }), + ]) + } finally { + clearTimeout(timer) + } +} + +async function exerciseTaskRecovery(mode: "late" | "late-queued" | "swallow" | "bookkeeping" | "bookkeeping-respawn") { + const swallowMode = mode === "swallow" || mode.startsWith("bookkeeping") + const fake = createFakeTaskCli(mode) + const modelId = `claude-test-task-${mode}` + const sk = sessionKey(fake.cwd, `${modelId}::tools::default::context=["claude-code",null]`) + const previousWatchdog = process.env.CLAUDE_CODE_START_WATCHDOG_MS + // Derived, never a literal: see START_WATCHDOG_MS. + process.env.CLAUDE_CODE_START_WATCHDOG_MS = String(START_WATCHDOG_MS) + const events = () => existsSync(fake.eventsPath) + ? readFileSync(fake.eventsPath, "utf8").trim().split("\n").map((line) => JSON.parse(line)) + : [] + const options: LanguageModelV3CallOptions = { + prompt: [{ + role: "user", + content: [{ type: "text", text: "Delegate the focused provider check." }], + }], + tools: [{ + type: "function", + name: "task", + description: "Delegate work to an opencode subagent", + inputSchema: { type: "object", properties: {} }, + }], + } + const addResult = ( + call: Extract, + text: string, + ) => { + options.prompt.push({ + role: "assistant", + content: [{ + type: "tool-call", + toolCallId: call.toolCallId, + toolName: call.toolName, + input: JSON.parse(call.input), + }], + }, { + role: "tool", + content: [{ + type: "tool-result", + toolCallId: call.toolCallId, + toolName: call.toolName, + output: { type: "text", value: text }, + }], + }) + } + + try { + const model = createClaudeCode({ + cliPath: fake.cliPath, + cwd: fake.cwd, + bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, + proxyTools: ["Task"], + autoContinueIncompleteTurns: false, + }).languageModel(modelId) + const firstResponse = await model.doStream(options) + const firstParts = await collectRecoveryStream(firstResponse.stream) + assertNativeTaskBoundary(firstParts, getPendingProxyCalls(sk)) + const taskCall = firstParts.find((part) => part.type === "tool-call")! + const originalProcess = getActiveProcess(sk)! + assert.ok(originalProcess) + assert.equal(originalProcess.lineEmitter.listenerCount("line"), 0) + const originalCall = getPendingProxyCalls(sk)[0] + assert.equal(originalCall.channel?.closed, false) + assert.equal(originalCall.emitted, true) + + if (!swallowMode) { + assert.equal(originalProcess.proc.kill("SIGUSR2"), true) + await eventually("disconnected HTTP channel and buffered terminal result", () => + originalCall.channel?.closed === true && + (originalProcess.unattendedLines ?? []).some((line) => JSON.parse(line).type === "result"), + ) + assert.equal(getPendingProxyCalls(sk)[0].toolCallId, taskCall.toolCallId) + assert.equal(events().filter((event) => event.type === "abandoned").length, 1) + if (mode === "late-queued") { + await eventually("a task queued with no stream listener", () => getPendingProxyCalls(sk).length === 2) + const queued = getPendingProxyCalls(sk)[1] + assert.deepEqual(queued.input, PARALLEL_TASK_INPUT) + assert.notEqual(queued.emitted, true) + assert.equal(queued.channel?.closed, false) + } + } + + addResult(taskCall, "subagent complete") + // The continuation envelope (written directly, or re-sent to the + // watchdog's replacement) asks the CLI for work like any fresh turn, so + // abort, LRU eviction and the idle timer must see the process as busy + // until its result lands. The fixture holds that result back. + if (mode === "late") { + assert.equal(isTurnInFlight(originalProcess), false, "the CLI ended its own turn while unattended") + } + const secondResponse = await model.doStream(options) + if (mode === "late") { + await eventually("recovered continuation marked in flight", () => isTurnInFlight(originalProcess)) + } + if (mode === "swallow") { + // The parked CLI never answered, so the first turn is still in flight; + // what matters is that the watchdog's replacement inherits that. + assert.equal(isTurnInFlight(originalProcess), true) + await eventually("respawned replacement marked in flight", () => { + const current = getActiveProcess(sk) + return current !== undefined && current !== originalProcess && isTurnInFlight(current) + }) + } + const secondParts = await collectRecoveryStream(secondResponse.stream) + if (mode === "late" || mode === "swallow") { + assert.equal(isTurnInFlight(getActiveProcess(sk)!), false, "the fresh result settles the turn") + } + if (mode === "bookkeeping-respawn") { + const errors = secondParts.filter((part) => part.type === "error") + assert.equal(errors.length, 1) + assert.match(String(errors[0].error), /start watchdog timeout/) + assert.equal(secondParts.filter((part) => part.type === "finish").length, 0) + assert.equal(getActiveProcess(sk), undefined) + assert.equal(getPendingProxyCalls(sk).length, 0) + const recorded = events() + assert.equal(recorded.filter((event) => event.type === "spawn").length, 2) + assert.equal(recorded.filter((event) => event.type === "input").length, 2) + assert.equal(recorded.filter((event) => event.type === "swallowed-result").length, 1) + assert.deepEqual(recorded.filter((event) => event.type === "fixture-error"), []) + return + } + const secondText = secondParts + .filter((part) => part.type === "text-delta") + .map((part) => part.delta) + .join("") + if (!swallowMode) { + assert.ok(secondText.startsWith("Unattended narration after the task connection timed out.")) + assert.equal(secondText.split("Unattended narration").length - 1, 1) + } + + let finalParts = secondParts + if (mode === "late-queued") { + assertNativeTaskBoundary(secondParts, getPendingProxyCalls(sk), [PARALLEL_TASK_INPUT]) + const queuedCall = secondParts.find((part) => part.type === "tool-call")! + assert.notEqual(queuedCall.toolCallId, taskCall.toolCallId) + assert.equal(getPendingProxyCalls(sk)[0].emitted, true) + addResult(queuedCall, "queued subagent complete") + const finalResponse = await model.doStream(options) + finalParts = await collectRecoveryStream(finalResponse.stream) + assert.equal( + [...firstParts, ...secondParts, ...finalParts].filter((part) => + part.type === "tool-call" && part.toolCallId === queuedCall.toolCallId, + ).length, + 1, + ) + assert.equal(events().filter((event) => event.type === "queued-result").length, 1) + } + + const finalText = finalParts + .filter((part) => part.type === "text-delta") + .map((part) => part.delta) + .join("") + const expectedAnswer = swallowMode + ? "Fresh answer after watchdog recovery." + : mode === "late-queued" + ? "Fresh answer after queued task: queued subagent complete" + : "Fresh answer after late completion." + assert.ok(finalText.endsWith(expectedAnswer), `Expected fresh completion, received: ${finalText}`) + assert.equal(finalParts.filter((part) => part.type === "tool-call").length, 0) + assert.equal(finalParts.filter((part) => part.type === "error").length, 0) + const finishes = finalParts.filter((part) => part.type === "finish") + assert.equal(finishes.length, 1) + assert.equal(finishes[0].finishReason.unified, "stop") + const answerIndex = finalParts.findIndex((part) => + part.type === "text-delta" && part.delta.includes(expectedAnswer), + ) + assert.ok(answerIndex >= 0 && answerIndex < finalParts.indexOf(finishes[0])) + assert.equal(getPendingProxyCalls(sk).length, 0) + + const recorded = events() + assert.deepEqual(recorded.filter((event) => event.type === "fixture-error"), []) + const httpResponses = recorded.filter((event) => event.type === "http-response") + assert.equal(httpResponses.length, mode === "late-queued" ? 2 : 1) + for (const response of httpResponses) { + assert.equal(response.status, 200) + assert.match(response.contentType, /text\/event-stream/) + } + const inputs = recorded.filter((event) => event.type === "input") + assert.equal(inputs.length, 2, "Only the original prompt and one completion envelope reach stdin") + const completion = inputs[1].envelope + assert.equal(completion.type, "user") + assert.equal(completion.message.role, "user") + assert.ok(completion.message.content.every((block: { type: string }) => block.type === "text")) + const completionText = completion.message.content.map((block: { text: string }) => block.text).join("") + assert.ok(completionText.includes(taskCall.toolCallId)) + assert.ok(completionText.includes("task")) + assert.ok(completionText.includes("subagent complete")) + assert.match(completionText, /do not re-run/i) + assert.doesNotMatch(JSON.stringify(completion), /"tool_result"|"tool_use_id"/) + const spawns = recorded.filter((event) => event.type === "spawn") + if (swallowMode) { + const swallowed = recorded.filter((event) => event.type === "swallowed-result") + assert.equal(swallowed.length, 1) + assert.equal(swallowed[0].body.result.content[0].text, "subagent complete") + assert.equal(recorded.filter((event) => event.type === "abandoned").length, 0) + assert.equal(spawns.length, 2) + assert.equal(inputs[1].resumed, true) + assert.notEqual(spawns[1].pid, spawns[0].pid) + assert.deepEqual(spawns[1].args, [...spawns[0].args, "--resume", "fake-session"]) + assert.equal(spawns[1].proxyUrl, spawns[0].proxyUrl) + assert.ok(spawns[0].prompt) + assert.equal(spawns[1].prompt, spawns[0].prompt) + assert.equal(getActiveProcess(sk)?.proxyServer, originalProcess.proxyServer) + } else { + assert.equal(spawns.length, 1, "A disconnected HTTP call does not require a respawn") + assert.equal(getActiveProcess(sk)?.proc, originalProcess.proc) + } + } finally { + if (previousWatchdog === undefined) delete process.env.CLAUDE_CODE_START_WATCHDOG_MS + else process.env.CLAUDE_CODE_START_WATCHDOG_MS = previousWatchdog + rejectAllPendingProxyCallsForSession(sk, new Error("test cleanup")) + await deleteActiveProcessAndWait(sk) + deleteClaudeSessionId(sk) + rmSync(fake.cwd, { recursive: true, force: true }) + } +} + +test("late Task result replays unattended narration without finishing before the fresh answer", { + timeout: RECOVERY_TEST_TIMEOUT_MS, +}, () => exerciseTaskRecovery("late")) + +test("Task queued while unattended is emitted exactly once and resolved on the following turn", { + timeout: RECOVERY_TEST_TIMEOUT_MS, +}, () => exerciseTaskRecovery("late-queued")) + +test("silently swallowed HTTP Task result recovers through a resumed completion envelope", { + timeout: RECOVERY_TEST_TIMEOUT_MS, +}, () => exerciseTaskRecovery("swallow")) + +test("tool-result bookkeeping does not disarm the recovery watchdog", { + timeout: RECOVERY_TEST_TIMEOUT_MS, +}, () => exerciseTaskRecovery("bookkeeping")) + +test("bookkeeping-only output after respawn still reaches the second watchdog deadline", { + timeout: RECOVERY_TEST_TIMEOUT_MS, +}, () => exerciseTaskRecovery("bookkeeping-respawn")) + +for (const ordering of ["buffered-terminal", "delayed-terminal", "close-after-resolution"] as const) { + test(`recovery consumes each completion once: ${ordering}`, async () => { + const cwd = process.cwd() + const modelId = `claude-test-recovery-${ordering}` + const sk = sessionKey(cwd, `${modelId}::tools::default::context=["claude-code",null]`) + const writes: string[] = [] + const proc = Object.assign(new EventEmitter(), { + stdin: { write: (line: string) => { writes.push(line); return true } }, + kill: () => true, + }) as unknown as ChildProcess + const active: ActiveProcess = { proc, lineEmitter: new EventEmitter(), unattendedLines: [] } + const terminal = { type: "result", session_id: "recovery-session", is_error: false } + const emit = (message: unknown) => active.lineEmitter.emit("line", JSON.stringify(message)) + const options: LanguageModelV3CallOptions = { + tools: [{ type: "function", name: "task", inputSchema: { type: "object" } }], + prompt: [{ role: "user", content: [{ type: "text", text: "Delegate." }] }], + } + const appendResult = (id: string, text: string) => { + options.prompt.push({ + role: "assistant", + content: [{ type: "tool-call", toolCallId: id, toolName: "task", input: {} }], + }, { + role: "tool", + content: [{ type: "tool-result", toolCallId: id, toolName: "task", output: { type: "text", value: text } }], + }) + } + const firstId = `${ordering}-A` + const secondId = `${ordering}-B` + const channel = { closed: ordering !== "close-after-resolution" } + let resolutions = 0 + try { + setActiveProcess(sk, active) + setClaudeSessionId(sk, "recovery-session") + queuePendingProxyCall(sk, { + id: firstId, toolName: "task", input: {}, channel, + resolve: () => { + resolutions++ + if (ordering === "close-after-resolution") queueMicrotask(() => { channel.closed = true }) + }, + reject: () => {}, + }) + markPendingProxyCallEmitted(firstId) + appendResult(firstId, "completion A") + const model = createClaudeCode({ + cwd, cliPath: process.execPath, bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, proxyTools: [], autoContinueIncompleteTurns: false, + }).languageModel(modelId) + if (ordering !== "close-after-resolution") { + // This call arrived while opencode executed A, before A's old terminal. + queuePendingProxyCall(sk, { + id: secondId, toolName: "task", input: {}, channel: { closed: true }, + resolve: () => { resolutions++ }, reject: () => {}, + }) + const boundary = await model.doStream(options) + const parts = await collectRecoveryStream(boundary.stream) + assert.deepEqual(parts.filter((part) => part.type === "tool-call").map((part) => part.toolCallId), [secondId]) + assert.equal(writes.length, 0) + assert.equal(active.pendingProxyCompletions?.size, 1) + if (ordering === "buffered-terminal") bufferUnattendedLine(active, JSON.stringify(terminal)) + appendResult(secondId, "completion B") + } + const response = await model.doStream(options) + const collected = collectRecoveryStream(response.stream) + await eventually("tool results resolved", () => getPendingProxyCalls(sk).length === 0) + if (ordering !== "buffered-terminal") { + assert.equal(writes.length, 0) + emit(terminal) + } + await eventually("one recovery envelope", () => writes.length === 1) + assert.equal(active.lineEmitter.listenerCount("line"), 1, "Old terminal must not finish the recovered stream") + assert.equal(active.pendingProxyCompletions?.size, 0) + const completion = JSON.parse(writes[0]).message.content[0].text as string + assert.equal(completion.split(firstId).length - 1, 1) + assert.ok(completion.includes("completion A")) + if (ordering !== "close-after-resolution") { + assert.equal(completion.split(secondId).length - 1, 1) + assert.ok(completion.includes("completion B")) + } + emit({ type: "assistant", message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text: "Fresh recovered answer." }] } }) + emit(terminal) + const parts = await collected + assert.equal(parts.filter((part) => part.type === "finish").length, 1) + assert.equal(parts.filter((part) => part.type === "error" || part.type === "tool-call").length, 0) + assert.ok(parts.some((part) => part.type === "text-delta" && part.delta === "Fresh recovered answer.")) + assert.equal(writes.length, 1, "The fresh terminal must not submit stale recovery again") + assert.equal(resolutions, ordering === "close-after-resolution" ? 1 : 2) + } finally { + rejectAllPendingProxyCallsForSession(sk, new Error("test cleanup")) + deleteActiveProcess(sk) + deleteClaudeSessionId(sk) + } + }) +} + +test("default provider proxies Task through opencode", () => { + assert.deepEqual(modelProxyTools(), [ + "Bash", + "Edit", + "Write", + "WebFetch", + "Task", + ]) +}) + +test("explicit proxyTools overrides preserve custom selection and empty opt-out", () => { + assert.deepEqual(modelProxyTools({ proxyTools: ["Task"] }), ["Task"]) + assert.deepEqual(modelProxyTools({ proxyTools: [] }), []) +}) + +test("opencode provider registration defaults Task without overriding proxyTools", async () => { + const hooks = await plugin.server({}) + assert.equal("tool" in hooks, false) + + const defaults: any = {} + await hooks.config?.(defaults) + assert.deepEqual(defaults.provider["claude-code"].options.proxyTools, [ + "Bash", + "Edit", + "Write", + "WebFetch", + "Task", + ]) + + const explicit: any = { + provider: { + "claude-code": { + options: { proxyTools: [] }, + }, + }, + } + await hooks.config?.(explicit) + assert.deepEqual(explicit.provider["claude-code"].options.proxyTools, []) +}) + +test("parent and child calls retain distinct opencode session affinity", async () => { + const hooks = await plugin.server({}) + const parentOutput: any = {} + const childOutput: any = {} + + await hooks["chat.params"]?.( + { + sessionID: "session-parent", + agent: "build", + model: { providerID: "claude-code" } as any, + }, + parentOutput, + ) + await hooks["chat.params"]?.( + { + sessionID: "session-child", + agent: "general", + model: { providerID: "claude-code" } as any, + }, + childOutput, + ) + + assert.equal(parentOutput.options.opencodeSessionID, "session-parent") + assert.equal(childOutput.options.opencodeSessionID, "session-child") + assert.notEqual( + parentOutput.options.opencodeSessionID, + childOutput.options.opencodeSessionID, + ) +}) + +test("Task proxy schema matches current opencode TaskTool fields", () => { + const task = DEFAULT_PROXY_TOOLS.find((tool) => tool.name === "task") + assert.ok(task) + + const properties = task.inputSchema.properties as Record< + string, + Record + > + assert.deepEqual(Object.keys(properties).sort(), [ + "background", + "command", + "description", + "prompt", + "subagent_type", + "task_id", + ]) + assert.equal(properties.background.type, "boolean") + assert.deepEqual(task.inputSchema.required, [ + "description", + "prompt", + "subagent_type", + ]) +}) + +test("proxy MCP initializes, lists Task, and resolves it through the broker", async () => { + const task = DEFAULT_PROXY_TOOLS.find((tool) => tool.name === "task") + assert.ok(task) + assert.deepEqual(disallowedToolFlags([task]), ["Agent"]) + + const brokerSession = `proxy-http-${Date.now()}` + const server = await createProxyMcpServer([task]) + const forwardCall = (call: any) => queuePendingProxyCall(brokerSession, call) + server.calls.on("call", forwardCall) + try { + const generatedConfig = JSON.parse(readFileSync(server.configPath(), "utf8")) + // The client-side ceiling written into --mcp-config tracks the largest + // effective server-side deadline, so Claude's remote-HTTP MCP client + // never aborts before the broker does. Task has no deadline, and the CLI + // rejects `timeout: 0`, so the ceiling is the largest supported value. + assert.equal( + generatedConfig.mcpServers.opencode_proxy.timeout, + resolveProxyClientCeilingMs(undefined), + ) + assert.equal(resolveProxyClientCeilingMs(undefined), MAX_PROXY_TIMEOUT_MS) + assert.ok(generatedConfig.mcpServers.opencode_proxy.timeout > 0) + + const initialized = await postRpc(server, { + jsonrpc: "2.0", + id: "initialize-1", + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "integration-test", version: "1.0.0" }, + }, + }) + assert.equal(initialized.body.id, "initialize-1") + assert.equal(initialized.body.result.serverInfo.name, "opencode_proxy") + + const notification = await postRpc(server, { + jsonrpc: "2.0", + method: "notifications/initialized", + }) + assert.equal(notification.status, 204) + + const listed = await postRpc(server, { + jsonrpc: "2.0", + id: "list-1", + method: "tools/list", + }) + assert.equal(listed.body.id, "list-1") + assert.deepEqual( + listed.body.result.tools.map((tool: any) => tool.name), + ["task"], + ) + + const brokerCalls = waitForBrokerCalls(brokerSession, 1) + const callResponse = postRpc(server, { + jsonrpc: "2.0", + id: "task-1", + method: "tools/call", + params: { name: "task", arguments: TASK_INPUT }, + }) + const [call] = await brokerCalls + + assert.equal(call.toolName, "task") + assert.deepEqual(call.input, TASK_INPUT) + assert.equal(getPendingProxyCalls(brokerSession)[0].toolCallId, call.toolCallId) + assert.equal( + resolvePendingProxyCallById(call.toolCallId, { + kind: "text", + text: "subagent complete", + }), + true, + ) + + const completed = await callResponse + assert.equal(completed.body.id, "task-1") + assert.equal(completed.body.result.content[0].text, "subagent complete") + assert.equal(getPendingProxyCalls(brokerSession).length, 0) + } finally { + server.calls.off("call", forwardCall) + rejectAllPendingProxyCallsForSession(brokerSession, new Error("test cleanup")) + await server.close() + } +}) + +test("cleanup rejections classify as notice-level, unknown errors as warn", () => { + assert.equal(isExpectedCleanupError(SERVER_CLOSED_MESSAGE), true) + assert.equal( + isExpectedCleanupError( + "Proxy tool 'task' timed out after 1800000ms waiting for opencode to resolve the call", + ), + true, + ) + assert.equal( + isExpectedCleanupError( + "Pending proxy call 'task' (call-1) was orphaned by a new user turn; rejecting", + ), + true, + ) + assert.equal( + isExpectedCleanupError( + "Provider stream was aborted before pending proxy calls were emitted", + ), + true, + ) + assert.equal(isExpectedCleanupError("ECONNRESET"), false) + assert.equal(isExpectedCleanupError("Unexpected token in JSON"), false) +}) + +test("closing the server rejects a pending call with the cleanup message", async () => { + const task = DEFAULT_PROXY_TOOLS.find((tool) => tool.name === "task") + assert.ok(task) + + const server = await createProxyMcpServer([task]) + const callReceived = new Promise((resolve) => { + server.calls.once("call", () => resolve()) + }) + const callResponse = postRpc(server, { + jsonrpc: "2.0", + id: "close-1", + method: "tools/call", + params: { name: "task", arguments: TASK_INPUT }, + }) + await callReceived + await server.close() + + const rejected = await callResponse + assert.equal(rejected.body.id, "close-1") + // tools/call failures are MCP results with isError, never JSON-RPC error + // envelopes (Claude CLI rejects those as schema-invalid). + assert.equal(rejected.body.result.isError, true) + assert.equal(rejected.body.result.content[0].text, SERVER_CLOSED_MESSAGE) + assert.equal(isExpectedCleanupError(rejected.body.result.content[0].text), true) +}) + +test("parallel proxy calls preserve success and error correlation", async () => { + const task = DEFAULT_PROXY_TOOLS.find((tool) => tool.name === "task") + assert.ok(task) + + const brokerSession = `proxy-batch-${Date.now()}` + const server = await createProxyMcpServer([task]) + const forwardCall = (call: any) => queuePendingProxyCall(brokerSession, call) + server.calls.on("call", forwardCall) + try { + const inputs = [ + { ...TASK_INPUT, description: "Successful batch call" }, + { ...TASK_INPUT, description: "Tool error batch call" }, + { ...TASK_INPUT, description: "Rejected batch call" }, + ] + const brokerCalls = waitForBrokerCalls(brokerSession, inputs.length) + const responses = inputs.map((input, index) => + postRpc(server, { + jsonrpc: "2.0", + id: `batch-${index}`, + method: "tools/call", + params: { name: "task", arguments: input }, + }), + ) + const calls = await brokerCalls + assert.equal(getPendingProxyCalls(brokerSession).length, inputs.length) + + const byDescription = new Map( + calls.map((call) => [call.input.description, call]), + ) + for (const input of inputs) { + assert.deepEqual(byDescription.get(input.description)?.input, input) + } + const successful = byDescription.get("Successful batch call")! + const toolError = byDescription.get("Tool error batch call")! + const rejected = byDescription.get("Rejected batch call")! + + rejectPendingProxyCallById( + rejected.toolCallId, + new Error("broker call rejecting as orphaned by test"), + ) + resolvePendingProxyCallById(successful.toolCallId, { + kind: "text", + text: "batch complete", + }) + resolvePendingProxyCallById(toolError.toolCallId, { + kind: "error", + message: "subagent failed", + }) + + const [successResponse, toolErrorResponse, rejectedResponse] = + await Promise.all(responses) + assert.equal(successResponse.body.id, "batch-0") + assert.equal(successResponse.body.result.content[0].text, "batch complete") + assert.equal(toolErrorResponse.body.id, "batch-1") + assert.equal(toolErrorResponse.body.result.isError, true) + assert.equal( + toolErrorResponse.body.result.content[0].text, + "subagent failed", + ) + assert.equal(rejectedResponse.body.id, "batch-2") + assert.equal(rejectedResponse.body.result.isError, true) + assert.equal( + rejectedResponse.body.result.content[0].text, + "broker call rejecting as orphaned by test", + ) + assert.equal(getPendingProxyCalls(brokerSession).length, 0) + } finally { + server.calls.off("call", forwardCall) + rejectAllPendingProxyCallsForSession(brokerSession, new Error("test cleanup")) + await server.close() + } +}) + +test("normal text plus Task result closes on native tool boundary", async () => { + const result = await streamTaskBoundary("normal") + assertNativeTaskBoundary(result.parts, result.pending) +}) + +test("result before delayed Task call still closes on native tool boundary", async () => { + const result = await streamTaskBoundary("race") + assertNativeTaskBoundary(result.parts, result.pending) +}) + +test("parallel Task calls drain in one native tool boundary", async () => { + const result = await streamTaskBoundary("batch") + assertNativeTaskBoundary(result.parts, result.pending, [ + TASK_INPUT, + PARALLEL_TASK_INPUT, + ]) +}) + +// task_batch (from @broskees' 68ed142, adapted): the CLI serialises MCP +// calls, so one batch call is the only way two subagents run at once. The +// plugin fans it out as child `task` calls in one stream finish and gathers +// their results back onto the parent id on the next turn. +test("task_batch fans out into child task calls and gathers their results onto the parent", { + timeout: 15_000, +}, async () => { + const fake = createFakeTaskCli("task_batch") + const modelId = "claude-test-task-batch" + const sk = sessionKey(fake.cwd, `${modelId}::tools::default::context=["claude-code",null]`) + const tools = [{ + type: "function", + name: "task", + description: "Delegate work to an opencode subagent", + inputSchema: { type: "object", properties: {} }, + }] + const firstPrompt = [{ + role: "user", + content: [{ type: "text", text: "Run both checks at the same time." }], + }] + try { + const model = createClaudeCode({ + cliPath: fake.cliPath, + cwd: fake.cwd, + bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, + proxyTools: ["Task"], + }).languageModel(modelId) + + const firstResponse = await model.doStream({ prompt: firstPrompt, tools } as any) + const firstParts: any[] = [] + for await (const part of firstResponse.stream) firstParts.push(part) + + const pending = getPendingProxyCalls(sk) + assert.equal(pending.length, 1, "one broker entry: the parent batch") + assert.equal(pending[0].toolName, "task_batch") + assert.equal(pending[0].emitted, true) + const parent = pending[0].toolCallId + + const children = firstParts.filter((part) => part.type === "tool-call") + assert.deepEqual( + children.map((call) => [call.toolCallId, call.toolName, call.providerExecuted]), + [[`${parent}_task_0`, "task", false], [`${parent}_task_1`, "task", false]], + "N ordinary opencode task calls, ids derived from the parent", + ) + assert.deepEqual(children.map((call) => JSON.parse(call.input)), [TASK_INPUT, PARALLEL_TASK_INPUT]) + assert.deepEqual( + firstParts.filter((part) => part.type === "tool-input-start").map((part) => [part.id, part.toolName]), + [[`${parent}_task_0`, "task"], [`${parent}_task_1`, "task"]], + "opencode learns each child's name from its own input-start", + ) + const finishes = firstParts.filter((part) => part.type === "finish") + assert.equal(finishes.length, 1) + assert.equal(finishes[0].finishReason.unified, "tool-calls", "both children in ONE tool boundary is what makes them concurrent") + + // opencode runs both children as one step and hands back both results. + const secondResponse = await model.doStream({ + prompt: [ + ...firstPrompt, + { + role: "assistant", + content: children.map((call) => ({ + type: "tool-call", + toolCallId: call.toolCallId, + toolName: "task", + input: JSON.parse(call.input), + })), + }, + { + role: "tool", + content: [ + { type: "tool-result", toolCallId: `${parent}_task_0`, toolName: "task", output: { type: "text", value: "alpha done" } }, + { type: "tool-result", toolCallId: `${parent}_task_1`, toolName: "task", output: { type: "text", value: "beta done" } }, + ], + }, + ], + tools, + } as any) + const secondParts: any[] = [] + for await (const part of secondResponse.stream) secondParts.push(part) + + const text = secondParts.filter((part) => part.type === "text-delta").map((part) => part.delta).join("") + assert.equal( + text, + "Batch received: ## task 1 of 2: Inspect provider flow (general)\nalpha done\n\n## task 2 of 2: Inspect parallel flow (general)\nbeta done", + "the CLI gets one labelled result for its one call", + ) + assert.equal(secondParts.filter((part) => part.type === "tool-call").length, 0, "nothing re-emitted") + const secondFinish = secondParts.filter((part) => part.type === "finish") + assert.equal(secondFinish.length, 1) + assert.equal(secondFinish[0].finishReason.unified, "stop") + assert.equal(getPendingProxyCalls(sk).length, 0, "the parent resolved") + } finally { + rejectAllPendingProxyCallsForSession(sk, new Error("test cleanup")) + deleteActiveProcess(sk) + rmSync(fake.cwd, { recursive: true, force: true }) + } +}) + +test("proxyTools Task brings task_batch along, once", () => { + const names = (list: string[]) => + ((createClaudeCode({ proxyTools: list }).languageModel("claude-haiku-4-5") as any).resolvedProxyTools() as { name: string }[]).map((t) => t.name) + assert.deepEqual(names(["Task"]), ["task", "task_batch"]) + assert.deepEqual(names(["Task", "task_batch", "TASK"]), ["task", "task_batch"]) + assert.deepEqual(names(["Bash"]), ["bash"], "only task carries the companion") +}) + +test("duplicate Claude results still produce one native Task completion", async () => { + const result = await streamTaskBoundary("duplicate") + assertNativeTaskBoundary(result.parts, result.pending) +}) + +test("error result does not wait for a missing proxy call", async () => { + const result = await streamTaskBoundary("error") + assert.equal(result.pending.length, 0) + assert.equal( + result.parts.filter((part) => part.type === "tool-call").length, + 0, + ) + const finishes = result.parts.filter((part) => part.type === "finish") + assert.equal(finishes.length, 1) + assert.equal(finishes[0].finishReason.unified, "stop") +}) + +test("immediate abort rejects a buffered Task call", async () => { + const fake = createFakeTaskCli("abort") + const modelId = "claude-test-task-abort" + const sk = sessionKey(fake.cwd, `${modelId}::tools::default::context=["claude-code",null]`) + const abortController = new AbortController() + const brokerCalls = waitForBrokerCalls(sk, 1) + + try { + const model = createClaudeCode({ + cliPath: fake.cliPath, + cwd: fake.cwd, + bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, + proxyTools: ["Task"], + }).languageModel(modelId) + const response = await model.doStream({ + abortSignal: abortController.signal, + prompt: [ + { + role: "user", + content: [{ type: "text", text: "Delegate without narration." }], + }, + ], + tools: [ + { + type: "function", + name: "task", + description: "Delegate work to an opencode subagent", + inputSchema: { type: "object", properties: {} }, + }, + ], + } as any) + const partsPromise = (async () => { + const parts: any[] = [] + for await (const part of response.stream) parts.push(part) + return parts + })() + + await brokerCalls + assert.equal(getPendingProxyCalls(sk).length, 1) + abortController.abort() + + const parts = await partsPromise + assert.equal( + parts.filter((part) => part.type === "tool-call").length, + 0, + ) + assert.equal(getPendingProxyCalls(sk).length, 0) + } finally { + rejectAllPendingProxyCallsForSession(sk, new Error("test cleanup")) + deleteActiveProcess(sk) + rmSync(fake.cwd, { recursive: true, force: true }) + } +}) + +test("parent tool-result turn defers MCP hot reload and continues the same Claude process", { + timeout: 10_000, +}, async () => { + const fake = createFakeTaskCli("followup") + const modelId = "claude-test-task-followup" + const sk = sessionKey(fake.cwd, `${modelId}::tools::default::context=["claude-code",null]`) + const configPath = join(fake.cwd, "opencode.json") + + mkdirSync(join(fake.cwd, ".git")) + writeFileSync( + configPath, + JSON.stringify({ + mcp: { + changing: { + type: "local", + command: ["node", "first-server.cjs"], + }, + }, + }), + ) + + try { + const model = createClaudeCode({ + cliPath: fake.cliPath, + cwd: fake.cwd, + bridgeOpencodeMcp: true, + proxyOpencodeMcpTools: false, + proxyTools: ["Task"], + }).languageModel(modelId) + const tools = [ + { + type: "function", + name: "task", + description: "Delegate work to an opencode subagent", + inputSchema: { type: "object", properties: {} }, + }, + ] + const firstPrompt = [ + { + role: "user", + content: [{ type: "text", text: "Delegate the focused provider check." }], + }, + ] + const firstResponse = await model.doStream({ + prompt: firstPrompt, + tools, + } as any) + const firstParts: any[] = [] + for await (const part of firstResponse.stream) firstParts.push(part) + + const taskCall = firstParts.find( + (part) => part.type === "tool-call" && part.toolName === "task", + ) + assert.ok(taskCall) + assert.equal(taskCall.providerExecuted, false) + assert.equal(getPendingProxyCalls(sk).length, 1) + + let unmatchedRejected = false + const unmatchedToolCallId = "parallel-task-still-running" + queuePendingProxyCall(sk, { + id: unmatchedToolCallId, + toolName: "task", + input: { + description: "Parallel sibling", + prompt: "Keep running until a later tool-result turn.", + subagent_type: "explore", + }, + resolve() {}, + reject() { + unmatchedRejected = true + }, + }) + // This sibling was already dispatched by an earlier opencode turn. + markPendingProxyCallEmitted(unmatchedToolCallId) + assert.equal(getPendingProxyCalls(sk).length, 2) + + writeFileSync( + configPath, + JSON.stringify({ + mcp: { + changing: { + type: "local", + command: ["node", "second-server.cjs"], + }, + }, + }), + ) + + const secondResponse = await model.doStream({ + prompt: [ + ...firstPrompt, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: taskCall.toolCallId, + toolName: "task", + input: taskCall.input, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: taskCall.toolCallId, + toolName: "task", + output: { type: "text", value: "subagent complete" }, + }, + ], + }, + ], + tools, + } as any) + const secondParts: any[] = [] + for await (const part of secondResponse.stream) secondParts.push(part) + + const continuationText = secondParts + .filter((part) => part.type === "text-delta") + .map((part) => part.delta) + .join("") + assert.equal(continuationText, "Parent received: subagent complete") + const finishes = secondParts.filter((part) => part.type === "finish") + assert.equal(finishes.length, 1) + assert.equal(finishes[0].finishReason.unified, "stop") + assert.equal(unmatchedRejected, false) + assert.deepEqual( + getPendingProxyCalls(sk).map((call) => call.toolCallId), + [unmatchedToolCallId], + ) + } finally { + rejectAllPendingProxyCallsForSession(sk, new Error("test cleanup")) + deleteActiveProcess(sk) + rmSync(fake.cwd, { recursive: true, force: true }) + } +}) diff --git a/test-respawn.ts b/test-respawn.ts new file mode 100644 index 0000000..b96afe3 --- /dev/null +++ b/test-respawn.ts @@ -0,0 +1,366 @@ +/** + * Regressions for the reused-process respawn path in src/session-manager.ts. + * + * These cover the pure helpers (`appendResumeIfNeeded`) and the + * undefined-when-no-active-process branch of `respawnActiveProcess`, plus + * real Node fixtures that check the respawned child's launch configuration. + * + * Usage: + * npx tsx --test test-respawn.ts + */ +import assert from "node:assert/strict" +import { once } from "node:events" +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { test } from "node:test" + +import { + appendResumeIfNeeded, + deleteActiveProcessAndWait, + getActiveProcess, + getClaudeSessionId, + isTurnInFlight, + noteTurnStarted, + respawnActiveProcess, + setClaudeSessionId, + deleteClaudeSessionId, + spawnClaudeProcess, + bufferUnattendedLine, + takeUnattendedLines, + type ActiveProcess, +} from "./src/session-manager.js" +import { EventEmitter } from "node:events" +import type { ChildProcess } from "node:child_process" +import { createClaudeCode } from "./src/index.js" + +test("unattended output is capped by line count and UTF-8 bytes, including oversized single lines", () => { + const active: ActiveProcess = { proc: {} as ChildProcess, lineEmitter: new EventEmitter() } + for (let index = 0; index < 501; index++) bufferUnattendedLine(active, String(index)) + assert.equal(active.unattendedLines?.length, 500) + assert.equal(active.unattendedLines?.[0], "1") + assert.equal(takeUnattendedLines(active).dropped, 1) + bufferUnattendedLine(active, "\u00e9".repeat(1_100_000)) + assert.deepEqual(takeUnattendedLines(active), { lines: [], dropped: 1 }) + assert.deepEqual(takeUnattendedLines(active), { lines: [], dropped: 0 }) +}) + +function createNodeFixture() { + const cwd = mkdtempSync(join(tmpdir(), "opencode-respawn-")) + const cliPath = join(cwd, "fixture.cjs") + const configPath = join(cwd, "mcp.json") + const promptPath = join(cwd, "system.txt") + writeFileSync(configPath, JSON.stringify({ mcpServers: { preserved: { marker: "original MCP config" } } })) + writeFileSync(promptPath, "original appended system prompt") + writeFileSync(cliPath, ` +const fs = require("node:fs") +const readline = require("node:readline") +const args = process.argv.slice(2) +const value = (flag) => args[args.indexOf(flag) + 1] +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const envelope = JSON.parse(line) + if (envelope.lines) { + process.stdout.write(envelope.lines.join("\\n") + "\\n") + return + } + process.stdout.write(JSON.stringify({ + args, + envelope, + cwd: process.cwd(), + effort: process.env.CLAUDE_CODE_EFFORT_LEVEL, + config: JSON.parse(fs.readFileSync(value("--mcp-config"), "utf8")), + prompt: fs.readFileSync(value("--append-system-prompt-file"), "utf8"), + }) + "\\n") +}) +process.stdout.write("ready\\n") +`) + const args = [ + cliPath, + "--mcp-config", configPath, + "--append-system-prompt-file", promptPath, + "--model", "claude-haiku-4-5", + ] + return { cwd, args, configPath, promptPath } +} + +test("appendResumeIfNeeded: no-op when no claude session id is known", () => { + const sk = `sk-noid-${Date.now()}` + deleteClaudeSessionId(sk) + const args = ["--print", "--model", "claude-fable-5"] + assert.deepEqual(appendResumeIfNeeded(sk, args), args) +}) + +test("appendResumeIfNeeded: appends --resume when a conversation id is known", () => { + const sk = `sk-withid-${Date.now()}` + setClaudeSessionId(sk, "claude-conv-123") + try { + const args = ["--print", "--model", "claude-fable-5"] + assert.deepEqual(appendResumeIfNeeded(sk, args), [ + "--print", + "--model", + "claude-fable-5", + "--resume", + "claude-conv-123", + ]) + } finally { + deleteClaudeSessionId(sk) + } +}) + +test("appendResumeIfNeeded: does not append when --session-id is already present", () => { + const sk = `sk-hasarg-${Date.now()}` + setClaudeSessionId(sk, "claude-conv-456") + try { + const args = ["--print", "--session-id", "claude-conv-already"] + assert.deepEqual(appendResumeIfNeeded(sk, args), args) + } finally { + deleteClaudeSessionId(sk) + } +}) + +test("appendResumeIfNeeded: does not append when --resume is already present", () => { + const sk = `sk-hasresume-${Date.now()}` + setClaudeSessionId(sk, "claude-conv-457") + try { + const args = ["--print", "--resume", "claude-conv-already"] + assert.deepEqual(appendResumeIfNeeded(sk, args), args) + } finally { + deleteClaudeSessionId(sk) + } +}) + +test("appendResumeIfNeeded: does not mutate the input array", () => { + const sk = `sk-immutable-${Date.now()}` + setClaudeSessionId(sk, "claude-conv-789") + try { + const args = ["--print"] + const snapshot = [...args] + appendResumeIfNeeded(sk, args) + assert.deepEqual(args, snapshot) + } finally { + deleteClaudeSessionId(sk) + } +}) + +// The start watchdog respawns in the middle of a turn and re-sends its +// envelope at once. Turn state is keyed by ActiveProcess, so without the +// handoff abort, LRU eviction and the idle timer all read the busy +// replacement as idle (@broskees' b719497). +test("respawnActiveProcess preserves an in-flight turn on the replacement", async () => { + const sk = `sk-inflight-${Date.now()}` + const args = ["-e", "setInterval(() => {}, 1000)"] + const old = spawnClaudeProcess(process.execPath, args, process.cwd(), sk) + noteTurnStarted(old) + try { + const replacement = respawnActiveProcess(sk, process.execPath, args, process.cwd()) + assert.ok(replacement) + assert.notEqual(replacement, old) + assert.equal(isTurnInFlight(replacement), true) + assert.equal(getActiveProcess(sk), replacement) + } finally { + await deleteActiveProcessAndWait(sk) + deleteClaudeSessionId(sk) + } +}) + +test("respawnActiveProcess leaves an idle replacement idle", async () => { + const sk = `sk-idle-respawn-${Date.now()}` + const args = ["-e", "setInterval(() => {}, 1000)"] + spawnClaudeProcess(process.execPath, args, process.cwd(), sk) + try { + const replacement = respawnActiveProcess(sk, process.execPath, args, process.cwd()) + assert.ok(replacement) + assert.equal(isTurnInFlight(replacement), false) + } finally { + await deleteActiveProcessAndWait(sk) + deleteClaudeSessionId(sk) + } +}) + +test("respawnActiveProcess: returns undefined when no active process exists for the key", () => { + const sk = `sk-empty-${Date.now()}` + // No setActiveProcess(spawnClaudeProcess(...)) was done for this key, so + // there is nothing to respawn; the watchdog treats this as "give up". + assert.equal( + respawnActiveProcess(sk, "/usr/bin/env", ["--print"], process.cwd()), + undefined, + ) +}) + +test("respawn preserves the original CLI args, config and prompt on a real child", { + timeout: 10_000, +}, async () => { + const fixture = createNodeFixture() + const sk = `respawn-${fixture.cwd}` + const original = spawnClaudeProcess( + process.execPath, + fixture.args, + fixture.cwd, + sk, + undefined, + "original-mcp-hash", + fixture.promptPath, + false, + "high", + ) + try { + assert.deepEqual( + await once(original.lineEmitter, "line", { signal: AbortSignal.timeout(5_000) }), + ["ready"], + ) + assert.deepEqual(original.cliArgs, fixture.args) + const completions: NonNullable = new Map([ + ["pending-task", { + call: { sessionKey: sk, toolCallId: "pending-task", toolName: "task", input: {}, channel: { closed: false } }, + result: { kind: "text", text: "completed once" }, + recoveryRequired: false, + }], + ]) + original.pendingProxyCompletions = completions + setClaudeSessionId(sk, "existing-fixture-session") + const originalExit = once(original.proc, "close", { signal: AbortSignal.timeout(5_000) }) + // A reattached doStream turn has no freshly built args. Respawn must use + // the original process's args, not launch Node (or Claude) with just resume. + const replacement = respawnActiveProcess(sk, process.execPath, [], fixture.cwd) + assert.ok(replacement) + assert.equal(replacement.pendingProxyCompletions, completions) + assert.equal(original.pendingProxyCompletions, undefined) + assert.notEqual(replacement.proc.pid, original.proc.pid) + assert.deepEqual( + await once(replacement.lineEmitter, "line", { signal: AbortSignal.timeout(5_000) }), + ["ready"], + ) + await originalExit + + const envelope = { + type: "user", + message: { role: "user", content: [{ type: "text", text: "The task completed; continue from its result." }] }, + } + const reply = once(replacement.lineEmitter, "line", { signal: AbortSignal.timeout(5_000) }) + replacement.proc.stdin!.write(JSON.stringify(envelope) + "\n") + const [line] = await reply + const received = JSON.parse(line) + assert.deepEqual(received.args, [...fixture.args.slice(1), "--resume", "existing-fixture-session"]) + assert.deepEqual(received.envelope, envelope) + assert.deepEqual(received.config, JSON.parse(readFileSync(fixture.configPath, "utf8"))) + assert.equal(received.prompt, "original appended system prompt") + assert.equal(received.effort, "high") + assert.equal(replacement.mcpHash, "original-mcp-hash") + assert.equal(replacement.systemPromptFile, fixture.promptPath) + assert.equal(getClaudeSessionId(sk), "existing-fixture-session") + assert.equal(getActiveProcess(sk), replacement) + assert.deepEqual(replacement.cliArgs, [...fixture.args, "--resume", "existing-fixture-session"]) + } finally { + await deleteActiveProcessAndWait(sk) + deleteClaudeSessionId(sk) + rmSync(fixture.cwd, { recursive: true, force: true }) + } +}) + +/** + * Drive one doStream turn against a fake CLI that answers with a partial text + * block, writes to stderr, and then exits non-zero without ever emitting the + * terminal `result` line. + */ +async function streamCrashingTurn(options: { exitDelayMs: number; abort?: boolean }) { + const cwd = mkdtempSync(join(tmpdir(), "opencode-crash-")) + const cliPath = join(cwd, "fake-claude.cjs") + writeFileSync( + cliPath, + `#!/usr/bin/env node +const readline = require("node:readline") +if (process.argv.includes("--version")) { + process.stdout.write("2.1.258\\n") + process.exit(0) +} +let answered = false +readline.createInterface({ input: process.stdin }).on("line", () => { + if (answered) return + answered = true + process.stdout.write(JSON.stringify({ + type: "assistant", + session_id: "fake-session", + message: { role: "assistant", content: [{ type: "text", text: "Half an answ" }] }, + }) + "\\n") + process.stderr.write("fatal: the CLI ran out of memory\\n") + setTimeout(() => process.exit(3), ${options.exitDelayMs}) +}) +`, + ) + chmodSync(cliPath, 0o755) + + const controller = new AbortController() + try { + const model = createClaudeCode({ + cliPath, + cwd, + bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, + proxyTools: [], + }).languageModel("claude-test-crash") + const response = await model.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "Answer briefly." }] }], + tools: [ + { + type: "function", + name: "bash", + description: "Run a command", + inputSchema: { type: "object", properties: {} }, + }, + ], + ...(options.abort ? { abortSignal: controller.signal } : {}), + } as any) + + const parts: any[] = [] + for await (const part of response.stream) { + parts.push(part) + // Abort as soon as the partial answer lands, before the child dies. + if (options.abort && part.type === "text-delta") controller.abort() + } + return parts as any[] + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +} + +// A child that dies mid-turn used to finish the stream with reason `stop` and +// empty usage, so a crashed CLI read as a short but successful answer. The +// stderr tail retained on the ActiveProcess is usually the only record of why. +test("a child that dies without a result ends the turn as an error", async () => { + const parts = await streamCrashingTurn({ exitDelayMs: 40 }) + + const errors = parts.filter((part) => part.type === "error") + assert.equal( + errors.length, + 1, + `expected one error part, got ${JSON.stringify(parts.map((part) => part.type))}`, + ) + const message = String((errors[0] as any).error?.message ?? "") + assert.match(message, /exited with code 3/) + assert.match(message, /ran out of memory/) + + const finish = parts.find((part) => part.type === "finish") as any + assert.ok(finish, "the stream must still finish") + assert.equal(finish.finishReason.unified, "error") + + // The partial answer the CLI did produce is still delivered. + assert.ok( + parts.some( + (part) => part.type === "text-delta" && String(part.delta).includes("Half an answ"), + ), + ) +}) + +// An abort is not a crash: the operator asked for it, and the CLI may well +// exit before the interrupt's own `result` line lands. +test("an aborted turn is not reported as a crash", async () => { + const parts = await streamCrashingTurn({ exitDelayMs: 300, abort: true }) + + assert.deepEqual( + parts.filter((part) => part.type === "error"), + [], + "an abort must not surface as a child crash", + ) + const finish = parts.find((part) => part.type === "finish") as any + if (finish) assert.notEqual(finish.finishReason.unified, "error") +}) diff --git a/test-result-fallback.ts b/test-result-fallback.ts new file mode 100644 index 0000000..5d118f3 --- /dev/null +++ b/test-result-fallback.ts @@ -0,0 +1,144 @@ +/** + * The wire-inactivity watchdog as opencode sees it. A CLI that produces output + * and then goes quiet without a `result` used to close the turn on a log line + * alone, so the reply simply stopped. The note is what says why. + * + * The fake `claude` here never sends a terminal `result` and keeps stdin open, + * so the only thing that can end the stream is the fallback timer. + * + * Usage: npx tsx --test test-result-fallback.ts + */ +import assert from "node:assert/strict" +import { test } from "node:test" +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { STREAM_TIMEOUT_MARKER, formatStreamTimeoutNote } from "./src/cli-events.js" +import { createClaudeCode } from "./src/index.js" +import { filterSideQuestionHistory } from "./src/message-builder.js" +import { deleteActiveProcess, sessionKey } from "./src/session-manager.js" + +/** Replays a fixed line sequence, then stays alive and silent forever. */ +function createSilentFakeCli(lines: unknown[]) { + const cwd = mkdtempSync(join(tmpdir(), "opencode-result-fallback-")) + const cliPath = join(cwd, "fake-claude.cjs") + const source = `#!/usr/bin/env node +const readline = require("node:readline") + +if (process.argv.includes("--version")) { + process.stdout.write("2.1.263\\n") + process.exit(0) +} + +const LINES = ${JSON.stringify(lines)} +const rl = readline.createInterface({ input: process.stdin }) +let answered = false +rl.on("line", () => { + if (answered) return + answered = true + for (const line of LINES) process.stdout.write(JSON.stringify(line) + "\\n") +}) +` + writeFileSync(cliPath, source) + chmodSync(cliPath, 0o755) + return { cliPath, cwd } +} + +const init = { type: "system", subtype: "init", session_id: "fake-session", tools: ["Read"] } + +const text = (body: string) => ({ + type: "stream_event", + session_id: "fake-session", + event: { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: body } }, +}) + +async function streamParts(lines: unknown[], fallbackMs: number): Promise { + const fake = createSilentFakeCli(lines) + const modelId = "claude-test-result-fallback" + const sk = sessionKey( + fake.cwd, + `${modelId}::tools::default::context=["claude-code",null]`, + ) + const previous = process.env.CLAUDE_CODE_RESULT_FALLBACK_MS + process.env.CLAUDE_CODE_RESULT_FALLBACK_MS = String(fallbackMs) + try { + const model = createClaudeCode({ + cliPath: fake.cliPath, + cwd: fake.cwd, + bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, + proxyTools: [], + }).languageModel(modelId) + + const response = await model.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "go" }] }], + tools: [ + { + type: "function", + name: "read", + description: "Read a file", + inputSchema: { type: "object", properties: {} }, + }, + ], + } as any) + + const parts: any[] = [] + for await (const part of response.stream) parts.push(part) + return parts + } finally { + if (previous === undefined) delete process.env.CLAUDE_CODE_RESULT_FALLBACK_MS + else process.env.CLAUDE_CODE_RESULT_FALLBACK_MS = previous + deleteActiveProcess(sk) + rmSync(fake.cwd, { recursive: true, force: true }) + } +} + +test("a CLI that goes silent after content says so, and the stream closes", async () => { + const parts = await streamParts([init, text("half an ans")], 400) + + const notes = parts.filter( + (part) => part.type === "text-delta" && part.delta.includes(STREAM_TIMEOUT_MARKER), + ) + assert.equal(notes.length, 1, "exactly one stream-timeout note") + assert.match(notes[0].delta, /went silent for 1s/) + assert.match(notes[0].delta, /closed without a result/) + + // Its own text part, which is what makes the transcript strip exact. + const noteIndex = parts.indexOf(notes[0]) + assert.equal(parts[noteIndex - 1].type, "text-start") + assert.equal(parts[noteIndex + 1].type, "text-end") + + // The model's own text is untouched. + const body = parts + .filter((part) => part.type === "text-delta") + .map((part) => part.delta) + .join("") + assert.match(body, /half an ans/) + + // The stream really ended rather than hanging until the test timeout. + assert.ok(parts.some((part) => part.type === "finish")) +}) + +test("the note is stripped from a rebuilt transcript", () => { + const prompt = [ + { role: "user", content: [{ type: "text", text: "go" }] }, + { + role: "assistant", + content: [ + { type: "text", text: "half an ans" }, + { type: "text", text: formatStreamTimeoutNote(60_000) }, + ], + }, + ] as any + + const filtered = filterSideQuestionHistory(prompt) + assert.equal(filtered.length, 2) + assert.deepEqual((filtered[1] as any).content, [{ type: "text", text: "half an ans" }]) +}) + +test("the note rounds the silence to whole seconds, never to zero", () => { + assert.match(formatStreamTimeoutNote(60_000), /60s/) + assert.match(formatStreamTimeoutNote(5_000), /5s/) + assert.match(formatStreamTimeoutNote(10), /1s/) +}) diff --git a/test-session-affinity.ts b/test-session-affinity.ts new file mode 100644 index 0000000..0666c94 --- /dev/null +++ b/test-session-affinity.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { resolveSessionAffinity } from "./src/claude-code-language-model.js" + +function makeProviderOptions( + providerKey: string, + sessionID: string, +): Record { + return { [providerKey]: { opencodeSessionID: sessionID } } +} + +test("resolveSessionAffinity returns header value (exact case)", () => { + const headers = { "x-session-affinity": "ses_abc123" } + assert.equal(resolveSessionAffinity(headers, undefined, "claude-code"), "ses_abc123") +}) + +test("resolveSessionAffinity returns header value (uppercase key)", () => { + const headers = { "X-Session-Affinity": "ses_ABC" } + assert.equal(resolveSessionAffinity(headers, undefined, "claude-code"), "ses_ABC") +}) + +test("resolveSessionAffinity returns header value (mixed-case key)", () => { + const headers = { "X-SESSION-AFFINITY": "ses_mixed" } + assert.equal(resolveSessionAffinity(headers, undefined, "claude-code"), "ses_mixed") +}) + +test("resolveSessionAffinity returns providerOptions value when header is absent (no headers arg)", () => { + const providerOptions = makeProviderOptions("claude-code", "ses_fromProvider") + assert.equal(resolveSessionAffinity(undefined, providerOptions, "claude-code"), "ses_fromProvider") +}) + +test("resolveSessionAffinity returns providerOptions value when headers object is empty", () => { + const providerOptions = makeProviderOptions("claude-code", "ses_fromProvider2") + assert.equal(resolveSessionAffinity({}, providerOptions, "claude-code"), "ses_fromProvider2") +}) + +test("resolveSessionAffinity returns providerOptions value when header key is missing", () => { + const headers = { "content-type": "application/json" } + const providerOptions = makeProviderOptions("claude-code", "ses_noAffinityHeader") + assert.equal(resolveSessionAffinity(headers, providerOptions, "claude-code"), "ses_noAffinityHeader") +}) + +test("resolveSessionAffinity uses custom providerKey to read providerOptions", () => { + const providerOptions = { "my-custom-provider": { opencodeSessionID: "ses_custom" } } + assert.equal(resolveSessionAffinity(undefined, providerOptions, "my-custom-provider"), "ses_custom") +}) + +test("resolveSessionAffinity falls back to claude-code key when own providerKey not found", () => { + const providerOptions = { "claude-code": { opencodeSessionID: "ses_canonicalFallback" } } + assert.equal(resolveSessionAffinity(undefined, providerOptions, "my-custom-provider"), "ses_canonicalFallback") +}) + +test("resolveSessionAffinity prefers header over providerOptions when both present", () => { + const headers = { "x-session-affinity": "ses_fromHeader" } + const providerOptions = makeProviderOptions("claude-code", "ses_fromProvider") + assert.equal(resolveSessionAffinity(headers, providerOptions, "claude-code"), "ses_fromHeader") +}) + +test("resolveSessionAffinity prefers header even when providerOptions has a different value", () => { + const headers = { "X-Session-Affinity": "ses_header_wins" } + const providerOptions = makeProviderOptions("claude-code", "ses_should_lose") + assert.equal(resolveSessionAffinity(headers, providerOptions, "claude-code"), "ses_header_wins") +}) + +test('resolveSessionAffinity returns "default" when both header and providerOptions are absent', () => { + assert.equal(resolveSessionAffinity(undefined, undefined, "claude-code"), "default") +}) + +test('resolveSessionAffinity returns "default" when headers is empty and providerOptions is undefined', () => { + assert.equal(resolveSessionAffinity({}, undefined, "claude-code"), "default") +}) + +test('resolveSessionAffinity returns "default" when header value is empty string', () => { + const headers = { "x-session-affinity": "" } + assert.equal(resolveSessionAffinity(headers, undefined, "claude-code"), "default") +}) + +test('resolveSessionAffinity returns "default" when providerOptions has empty opencodeSessionID', () => { + const providerOptions = { "claude-code": { opencodeSessionID: "" } } + assert.equal(resolveSessionAffinity(undefined, providerOptions, "claude-code"), "default") +}) + +test('resolveSessionAffinity returns "default" when providerOptions has no opencodeSessionID field', () => { + const providerOptions = { "claude-code": { opencodeAgent: "default" } } + assert.equal(resolveSessionAffinity(undefined, providerOptions, "claude-code"), "default") +}) + +test('resolveSessionAffinity returns "default" when providerOptions bag is missing entirely', () => { + const providerOptions = { "other-provider": { opencodeSessionID: "ses_wrong" } } + assert.equal(resolveSessionAffinity(undefined, providerOptions, "claude-code"), "default") +}) diff --git a/test-session-manager.ts b/test-session-manager.ts new file mode 100644 index 0000000..ca659c9 --- /dev/null +++ b/test-session-manager.ts @@ -0,0 +1,717 @@ +import assert from "node:assert/strict" +import { EventEmitter, once } from "node:events" +import { setTimeout as delay } from "node:timers/promises" +import { test } from "node:test" +import { spawn, type ChildProcess } from "node:child_process" +import { + buildCliArgs, + DEFAULT_IDLE_PROCESS_TIMEOUT_MS, + deleteActiveProcess, + deleteActiveProcessAndWait, + deleteActiveProcessesForSession, + deleteClaudeSessionId, + describeChildCrash, + ensureProcessExitCleanup, + evictIfNeeded, + getActiveProcess, + getClaudeSessionId, + isIdleProcessEvictionScheduled, + killAllActiveProcesses, + MAX_ACTIVE_PROCESSES, + MAX_CLAUDE_SESSION_ENTRIES, + resolveIdleProcessTimeoutMs, + retainStderr, + scheduleIdleProcessEviction, + noteTurnStarted, + noteTurnLine, + isTurnInFlight, + awaitTurnIdle, + interruptTurn, + setActiveProcess, + setClaudeSessionId, + spawnClaudeProcess, + type ActiveProcess, +} from "./src/session-manager.js" +import { getPendingProxyCalls, queuePendingProxyCall } from "./src/proxy-broker.js" +import { + applyTaskCreateToolResult, + applyTaskCreateToolUse, + getLedger, +} from "./src/todo-ledger.js" +import { + createProxyMcpServer, + DEFAULT_PROXY_TOOLS, + SERVER_CLOSED_MESSAGE, + type ProxyToolCall, +} from "./src/proxy-mcp.js" + +function fakeActiveProcess(options: { exitOn: NodeJS.Signals; delayMs: number }): { + activeProcess: ActiveProcess + signals: NodeJS.Signals[] +} { + const proc = new EventEmitter() as ChildProcess + const signals: NodeJS.Signals[] = [] + Object.assign(proc, { + exitCode: null, + signalCode: null, + kill(signal: NodeJS.Signals = "SIGTERM") { + signals.push(signal) + if (signal === options.exitOn) { + setTimeout(() => { + Object.defineProperty(proc, "signalCode", { + configurable: true, + value: signal, + }) + proc.emit("exit", null, signal) + }, options.delayMs) + } + return true + }, + }) + + return { + activeProcess: { + proc, + lineEmitter: new EventEmitter(), + proxyServer: null, + }, + signals, + } +} + +test("deleteActiveProcessAndWait waits for the old session owner", async () => { + const key = "wait-for-session-owner" + const { activeProcess, signals } = fakeActiveProcess({ + exitOn: "SIGTERM", + delayMs: 25, + }) + setActiveProcess(key, activeProcess) + setClaudeSessionId(key, "claude-session") + + let settled = false + const pending = deleteActiveProcessAndWait(key, { + exitTimeoutMs: 200, + forceExitTimeoutMs: 100, + }).then((result) => { + settled = true + return result + }) + + await new Promise((resolve) => setTimeout(resolve, 5)) + assert.equal(settled, false) + assert.equal(await pending, true) + assert.deepEqual(signals, ["SIGTERM"]) + assert.equal(getActiveProcess(key), undefined) + assert.equal(getClaudeSessionId(key), "claude-session") + deleteClaudeSessionId(key) +}) + +test("deleteActiveProcessAndWait escalates before reusing a session ID", async () => { + const key = "force-session-owner-exit" + const { activeProcess, signals } = fakeActiveProcess({ + exitOn: "SIGKILL", + delayMs: 5, + }) + setActiveProcess(key, activeProcess) + + assert.equal( + await deleteActiveProcessAndWait(key, { + exitTimeoutMs: 5, + forceExitTimeoutMs: 100, + }), + true, + ) + assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]) +}) + +test("buildCliArgs resumes a remembered session with --resume", () => { + const key = "resume-args" + setClaudeSessionId(key, "11111111-1111-4111-8111-111111111111") + try { + const args = buildCliArgs({ sessionKey: key, skipPermissions: true }) + assert.equal( + args[args.indexOf("--resume") + 1], + "11111111-1111-4111-8111-111111111111", + ) + assert.equal(args.includes("--session-id"), false) + } finally { + deleteClaudeSessionId(key) + } +}) + +test("buildCliArgs skips --resume while the session owner is alive", () => { + const key = "resume-args-live" + setClaudeSessionId(key, "22222222-2222-4222-8222-222222222222") + const { activeProcess } = fakeActiveProcess({ exitOn: "SIGTERM", delayMs: 0 }) + setActiveProcess(key, activeProcess) + try { + const args = buildCliArgs({ sessionKey: key, skipPermissions: true }) + assert.equal(args.includes("--resume"), false) + assert.equal(args.includes("--session-id"), false) + } finally { + deleteActiveProcess(key) + deleteClaudeSessionId(key) + } +}) + +test("a resume failure on stderr clears the remembered session ID", async () => { + const key = "resume-error-stderr" + setClaudeSessionId(key, "purged-session") + spawnClaudeProcess( + process.execPath, + [ + "-e", + "console.error('No conversation found with session ID: purged-session'); setInterval(() => {}, 1000)", + ], + process.cwd(), + key, + ) + try { + const deadline = Date.now() + 2000 + while (getClaudeSessionId(key) !== undefined && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + assert.equal(getClaudeSessionId(key), undefined) + } finally { + deleteActiveProcess(key) + deleteClaudeSessionId(key) + } +}) + +test("an exiting stale process cannot delete its replacement", async () => { + const key = "stale-process-exit" + const first = spawnClaudeProcess( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + process.cwd(), + key, + ) + const replacementProc = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"]) + const replacement: ActiveProcess = { + proc: replacementProc, + lineEmitter: new EventEmitter(), + proxyServer: null, + } + + try { + setActiveProcess(key, replacement) + first.proc.kill() + await once(first.proc, "exit") + assert.equal(getActiveProcess(key), replacement) + } finally { + deleteActiveProcess(key) + deleteClaudeSessionId(key) + } +}) + +// Idle timeout tests by @bernardofortes (a5f723a). +function fakeIdleProcess(onKill: () => void): ActiveProcess { + return { + proc: { + kill() { + onKill() + return true + }, + } as ActiveProcess["proc"], + lineEmitter: new EventEmitter(), + } +} + +test("idle process is evicted after the configured timeout", async () => { + const key = `idle-eviction-${Date.now()}` + const sessionId = "f8dccdd4-4785-4bd9-8520-7a5993a71f78" + let kills = 0 + setActiveProcess(key, fakeIdleProcess(() => kills++)) + setClaudeSessionId(key, sessionId) + + scheduleIdleProcessEviction(key, 10) + await delay(30) + + assert.equal(kills, 1) + assert.equal(getActiveProcess(key), undefined) + assert.deepEqual( + buildCliArgs({ sessionKey: key, skipPermissions: false }).slice(-2), + ["--resume", sessionId], + ) + deleteClaudeSessionId(key) +}) + +test("reusing a process cancels its idle eviction", async () => { + const key = `idle-reuse-${Date.now()}` + let kills = 0 + const process = fakeIdleProcess(() => kills++) + setActiveProcess(key, process) + + scheduleIdleProcessEviction(key, 10) + assert.equal(getActiveProcess(key), process) + await delay(30) + + assert.equal(kills, 0) + assert.equal(getActiveProcess(key), process) + deleteActiveProcess(key) +}) + +test("idle eviction is off unless set, and an explicit value arms it", () => { + assert.equal(DEFAULT_IDLE_PROCESS_TIMEOUT_MS, 0) + assert.equal(resolveIdleProcessTimeoutMs(undefined), 0) + assert.equal(resolveIdleProcessTimeoutMs(0), 0) + assert.equal(resolveIdleProcessTimeoutMs(900_000), 900_000) + + const key = `idle-default-${Date.now()}` + setActiveProcess(key, fakeIdleProcess(() => {})) + try { + scheduleIdleProcessEviction(key, resolveIdleProcessTimeoutMs(undefined)) + assert.equal(isIdleProcessEvictionScheduled(key), false, "unset arms nothing") + scheduleIdleProcessEviction(key, resolveIdleProcessTimeoutMs(900_000)) + assert.equal(isIdleProcessEvictionScheduled(key), true) + scheduleIdleProcessEviction(key, resolveIdleProcessTimeoutMs(0)) + assert.equal(isIdleProcessEvictionScheduled(key), false, "0 disarms") + } finally { + deleteActiveProcess(key) + } +}) + +// A recovered continuation, an auto-continue or a late tool result can put a +// process back to work after the turn that armed the timer completed. +test("the idle timer spares a process that is mid-turn and re-arms instead", async () => { + const key = `idle-in-flight-${Date.now()}` + let kills = 0 + const ap = fakeIdleProcess(() => kills++) + setActiveProcess(key, ap) + try { + scheduleIdleProcessEviction(key, 10) + noteTurnStarted(ap) + await delay(30) + assert.equal(kills, 0, "a busy process is never evicted by the clock") + assert.equal(isIdleProcessEvictionScheduled(key), true, "re-armed for the next window") + noteTurnLine(ap, JSON.stringify({ type: "result", subtype: "success" })) + await delay(30) + assert.equal(kills, 1, "evicted once the turn settled and the window lapsed") + assert.equal(isIdleProcessEvictionScheduled(key), false) + } finally { + deleteActiveProcess(key) + } +}) + +test("timeouts above Node's maximum delay do not evict immediately", async () => { + const key = `idle-overflow-${Date.now()}` + let kills = 0 + const process = fakeIdleProcess(() => kills++) + setActiveProcess(key, process) + + scheduleIdleProcessEviction(key, 2_147_483_648) + await delay(10) + + assert.equal(kills, 0) + assert.equal(getActiveProcess(key), process) + deleteActiveProcess(key) +}) + +// Turn lifecycle and abort interrupt (adapted from @broskees' 68ed142). +function fakeTurnProcess(): { ap: ActiveProcess; writes: string[] } { + const writes: string[] = [] + const ap: ActiveProcess = { + proc: { + stdin: { + writable: true, + write(chunk: string) { + writes.push(chunk) + return true + }, + }, + } as unknown as ActiveProcess["proc"], + lineEmitter: new EventEmitter(), + } + return { ap, writes } +} + +test("a turn is in flight from the envelope write until the terminal result line", async () => { + const { ap } = fakeTurnProcess() + assert.equal(isTurnInFlight(ap), false) + noteTurnStarted(ap) + assert.equal(isTurnInFlight(ap), true) + noteTurnLine(ap, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "result" }] } })) + assert.equal(isTurnInFlight(ap), true, "a content line that merely mentions result does not settle") + noteTurnLine(ap, "not json \"result\"") + assert.equal(isTurnInFlight(ap), true) + const idle = awaitTurnIdle(ap, 1_000) + noteTurnLine(ap, JSON.stringify({ type: "result", subtype: "success" })) + assert.equal(isTurnInFlight(ap), false) + assert.equal(await idle, true) +}) + +test("interruptTurn writes an interrupt control request and waits for the result", async () => { + const { ap, writes } = fakeTurnProcess() + assert.equal(await interruptTurn(ap), true, "nothing in flight is already idle, nothing written") + assert.deepEqual(writes, []) + + noteTurnStarted(ap) + const pending = interruptTurn(ap, 1_000) + assert.equal(writes.length, 1) + const request = JSON.parse(writes[0]!) + assert.equal(request.type, "control_request") + assert.equal(request.request.subtype, "interrupt") + assert.ok(request.request_id) + noteTurnLine(ap, JSON.stringify({ type: "result", subtype: "error_during_execution", is_error: true })) + assert.equal(await pending, true) +}) + +test("interruptTurn reports false when the CLI never answers", async () => { + const { ap } = fakeTurnProcess() + noteTurnStarted(ap) + assert.equal(await interruptTurn(ap, 20), false) + assert.equal(isTurnInFlight(ap), true, "still in flight; the next turn's guard will retry") +}) + +test("the interactive transport is never marked in flight", () => { + const { ap } = fakeTurnProcess() + ap.asideTransport = { cliPath: "claude", interactive: true } + noteTurnStarted(ap) + assert.equal(isTurnInFlight(ap), false) +}) + +function captureStderr(): { lines: string[]; restore: () => void } { + const lines: string[] = [] + const original = console.error + console.error = (line: unknown) => { + lines.push(String(line)) + } + return { lines, restore: () => { console.error = original } } +} + +// The child's stdin is its own emitter, so `proc.on("error", ...)` does not +// cover it. A write that lands after the child died raises EPIPE there, and +// an 'error' event on a stream with no listener throws: inside opencode's own +// process, not ours. The EPIPE itself is delivered whenever libuv gets around +// to failing the queued write, so the event is emitted here directly; the +// contract under test is that something is listening for it. +test("an error on a dead child's stdin is logged, not thrown", async () => { + const key = `stdin-error-${Date.now()}` + const captured = captureStderr() + const ap = spawnClaudeProcess( + process.execPath, + ["-e", "process.stdin.destroy(); setInterval(() => {}, 1000)"], + process.cwd(), + key, + ) + const stdin = ap.proc.stdin! + try { + await delay(100) + noteTurnStarted(ap) + // The write a real turn makes. It must not throw synchronously either. + stdin.write(JSON.stringify({ type: "user", pad: "x".repeat(100_000) }) + "\n") + stdin.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" })) + } finally { + captured.restore() + deleteActiveProcess(key) + deleteClaudeSessionId(key) + } + assert.ok( + captured.lines.some((line) => line.includes("claude process stdin error")), + `expected a logged stdin error, got: ${captured.lines.join(" | ")}`, + ) + assert.ok( + captured.lines.some((line) => line.includes('"code":"EPIPE"')), + "the logged error should name the errno the write failed with", + ) + assert.equal( + isTurnInFlight(ap), + false, + "a write that never reached the CLI leaves no turn to wait for", + ) +}) + +function fillActiveProcesses(prefix: string, killed: string[]): { + keys: string[] + processes: ActiveProcess[] +} { + const keys: string[] = [] + const processes: ActiveProcess[] = [] + for (let index = 0; index < MAX_ACTIVE_PROCESSES; index++) { + const key = `${prefix}-${index}` + const ap = fakeIdleProcess(() => killed.push(key)) + keys.push(key) + processes.push(ap) + setActiveProcess(key, ap) + } + return { keys, processes } +} + +// Killing a process mid-turn truncates that answer silently: the close +// handler finishes the stream and the operator sees half a reply. +test("LRU eviction picks the oldest idle process, not the oldest process", () => { + const killed: string[] = [] + const { keys, processes } = fillActiveProcesses(`lru-guard-${Date.now()}`, killed) + try { + noteTurnStarted(processes[0]!) + noteTurnStarted(processes[1]!) + evictIfNeeded() + assert.deepEqual(killed, [keys[2]]) + assert.equal(getActiveProcess(keys[0]!), processes[0]) + assert.equal(getActiveProcess(keys[1]!), processes[1]) + assert.equal(getActiveProcess(keys[2]!), undefined) + } finally { + for (const key of keys) deleteActiveProcess(key) + } +}) + +test("LRU eviction kills nothing while every process is mid-turn", () => { + const killed: string[] = [] + const { keys, processes } = fillActiveProcesses(`lru-busy-${Date.now()}`, killed) + const captured = captureStderr() + let killedDuringEviction: string[] = [] + try { + for (const ap of processes) noteTurnStarted(ap) + evictIfNeeded() + killedDuringEviction = [...killed] + } finally { + captured.restore() + for (const key of keys) deleteActiveProcess(key) + } + assert.deepEqual(killedDuringEviction, []) + assert.ok( + captured.lines.some((line) => line.includes("every claude process is mid-turn")), + `expected a warning about the skipped eviction, got: ${captured.lines.join(" | ")}`, + ) +}) + +test("the process cap is 16 and the LRU never exceeds it while an idle victim exists", () => { + assert.equal(MAX_ACTIVE_PROCESSES, 16) +}) + +// A `task` call has no deadline, so once its proxy server is gone nothing +// else would ever reap its broker entry. +test("deleting a process rejects the broker calls its proxy server can no longer answer", async () => { + const key = `detach-rejects-${Date.now()}` + let serverClosed = false + const ap: ActiveProcess = { + ...fakeIdleProcess(() => {}), + proxyServer: { async close() { serverClosed = true } } as unknown as ActiveProcess["proxyServer"], + } + setActiveProcess(key, ap) + let rejection: Error | undefined + const settled = new Promise((resolve) => { + queuePendingProxyCall(key, { + id: `call-${key}`, + toolName: "task", + input: {}, + resolve: () => resolve(), + reject: (error) => { rejection = error; resolve() }, + }) + }) + assert.equal(getPendingProxyCalls(key).length, 1) + deleteActiveProcess(key) + await settled + assert.equal(serverClosed, true) + assert.equal(getPendingProxyCalls(key).length, 0) + assert.equal(rejection?.message, SERVER_CLOSED_MESSAGE) +}) + +test("deleteActiveProcessesForSession releases every process and remembered id of one session only", async () => { + const stamp = Date.now() + const keyFor = (session: string, model = "claude-opus-5", scope = "tools") => + scope === "compaction" + ? `/tmp/proj-${stamp}::${model}::compaction::${session}` + : `/tmp/proj-${stamp}::${model}::${scope}::${session}::context=["claude-code",null]` + const killed: string[] = [] + const register = (key: string, opencodeSessionID?: string) => { + const { activeProcess } = fakeActiveProcess({ exitOn: "SIGTERM", delayMs: 0 }) + activeProcess.proc.kill = ((signal?: NodeJS.Signals) => { + killed.push(key) + Object.defineProperty(activeProcess.proc, "exitCode", { configurable: true, value: 0 }) + activeProcess.proc.emit("exit", 0, signal ?? null) + return true + }) as typeof activeProcess.proc.kill + if (opencodeSessionID) activeProcess.opencodeSessionID = opencodeSessionID + setActiveProcess(key, activeProcess) + } + const a1 = keyFor("ses_A") + const a2 = keyFor("ses_A", "claude-haiku-4-5", "compaction") + const aEffort = `${keyFor("ses_A")}::effort=high` + const b = keyFor("ses_B") + const shared = keyFor("default") + register(a1, "ses_A") + register(a2) + register(aEffort, "ses_A") + register(b, "ses_B") + register(shared) + setClaudeSessionId(a1, "claude-a1") + setClaudeSessionId(b, "claude-b") + // An idle-evicted process keeps its session id for a resume; a deleted + // session must drop that too. + const aEvicted = keyFor("ses_A", "claude-sonnet-5") + setClaudeSessionId(aEvicted, "claude-a-evicted") + try { + assert.deepEqual(deleteActiveProcessesForSession("default"), [], "the shared bucket is never matched") + assert.deepEqual(deleteActiveProcessesForSession(""), []) + const released = deleteActiveProcessesForSession("ses_A") + assert.deepEqual(released.sort(), [a1, a2, aEffort, aEvicted].sort()) + assert.deepEqual(killed.sort(), [a1, a2, aEffort].sort()) + assert.equal(getActiveProcess(a1), undefined) + assert.equal(getActiveProcess(a2), undefined) + assert.equal(getActiveProcess(aEffort), undefined) + assert.ok(getActiveProcess(b), "another session's process survives") + assert.ok(getActiveProcess(shared), "the shared bucket survives") + assert.equal(getClaudeSessionId(a1), undefined) + assert.equal(getClaudeSessionId(aEvicted), undefined) + assert.equal(getClaudeSessionId(b), "claude-b") + assert.deepEqual(deleteActiveProcessesForSession("ses_A"), [], "idempotent") + } finally { + for (const key of [a1, a2, aEffort, b, shared, aEvicted]) { + deleteActiveProcess(key) + deleteClaudeSessionId(key) + } + } +}) + +test("killAllActiveProcesses is synchronous, releases parked calls on both sides, and the exit hook is armed once", async () => { + const stamp = Date.now() + const killed: string[] = [] + const keys = [`exit-a-${stamp}`, `exit-b-${stamp}`] + for (const key of keys) setActiveProcess(key, fakeIdleProcess(() => killed.push(key))) + setClaudeSessionId(keys[0]!, "claude-exit-a") + // A real proxy server holding a real `task` request, wired to the broker + // the way the language model wires it. opencode going away must release + // the HTTP side and the broker entry, not just kill the child. + const server = await createProxyMcpServer(DEFAULT_PROXY_TOOLS.filter((t) => t.name === "task")) + server.calls.on("call", (call: ProxyToolCall) => queuePendingProxyCall(keys[1]!, call)) + const parked: ActiveProcess = { ...fakeIdleProcess(() => killed.push(keys[1]!)), proxyServer: server } + setActiveProcess(keys[1]!, parked) + const queued = new Promise((resolve) => server.calls.once("call", () => resolve())) + const request = fetch(server.url, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${server.authToken}` }, + body: JSON.stringify({ + jsonrpc: "2.0", id: "parked", method: "tools/call", + params: { name: "task", arguments: { description: "d", prompt: "p", subagent_type: "general" } }, + }), + }).then((response) => response.json() as Promise) + await queued + assert.deepEqual(server.pendingCallIds().length, 1) + assert.equal(getPendingProxyCalls(keys[1]!).length, 1) + try { + assert.deepEqual(killAllActiveProcesses().sort(), keys.sort()) + assert.deepEqual(killed.sort(), keys.sort(), "killed before the call returned") + assert.equal(getPendingProxyCalls(keys[1]!).length, 0, "broker entry released synchronously") + const answer = await request + assert.equal(answer.result.isError, true) + assert.equal(answer.result.content[0].text, SERVER_CLOSED_MESSAGE) + assert.deepEqual(server.pendingCallIds(), [], "HTTP entry released") + assert.equal(getActiveProcess(keys[0]!), undefined) + assert.equal(getClaudeSessionId(keys[0]!), "claude-exit-a", "ids are left alone at exit") + assert.deepEqual(killAllActiveProcesses(), []) + + const before = process.listenerCount("exit") + const armed = ensureProcessExitCleanup() + const afterFirst = process.listenerCount("exit") + assert.equal(ensureProcessExitCleanup(), false, "a second call never adds a listener") + assert.equal(process.listenerCount("exit"), afterFirst) + assert.equal(afterFirst - before, armed ? 1 : 0) + } finally { + for (const key of keys) { + deleteActiveProcess(key) + deleteClaudeSessionId(key) + } + } +}) + +test("retained stderr keeps the newest 2 KB", () => { + const ap = fakeIdleProcess(() => {}) + retainStderr(ap, "x".repeat(3_000)) + retainStderr(ap, "the tail that matters") + assert.equal(ap.lastStderr!.length, 2 * 1024) + assert.ok(ap.lastStderr!.endsWith("the tail that matters")) +}) + +test("describeChildCrash names the exit code, the signal, and the stderr tail", () => { + const exited = describeChildCrash(3, null, " fatal: out of memory\n") + assert.match(exited, /exited with code 3/) + assert.match(exited, /fatal: out of memory/) + assert.match(describeChildCrash(null, "SIGKILL", undefined), /killed by SIGKILL/) + assert.doesNotMatch( + describeChildCrash(null, "SIGKILL", undefined), + /Last stderr/, + "no stderr, no empty section", + ) + assert.match(describeChildCrash(null, null, undefined), /closed its output/) +}) + +test("a child that dies keeps its stderr for the crash report", async () => { + const key = `crash-stderr-${Date.now()}` + const ap = spawnClaudeProcess( + process.execPath, + [ + "-e", + "process.stderr.write('fatal: claude ran out of memory\\n'); setTimeout(() => process.exit(3), 30)", + ], + process.cwd(), + key, + ) + try { + await once(ap.proc, "exit") + await delay(20) + assert.match(ap.lastStderr ?? "", /fatal: claude ran out of memory/) + const message = describeChildCrash( + ap.proc.exitCode, + ap.proc.signalCode, + ap.lastStderr, + ) + assert.match(message, /exited with code 3/) + assert.match(message, /fatal: claude ran out of memory/) + } finally { + deleteActiveProcess(key) + deleteClaudeSessionId(key) + } +}) + +test("the claude session store is capped, and eviction takes the ledger with it", () => { + const total = MAX_CLAUDE_SESSION_ENTRIES + 10 + const keys = Array.from({ length: total }, (_, i) => `cap-session-${i}`) + + // The first key's ledger must go when the id does: an orphaned ledger is + // exactly the leak the cap exists to stop. + applyTaskCreateToolUse("cap-claude-0", "tu-1", { subject: "Write tests" }) + applyTaskCreateToolResult("cap-claude-0", "tu-1", "Task #1 created") + assert.equal(getLedger("cap-claude-0").length, 1) + + try { + for (const [index, key] of keys.entries()) { + setClaudeSessionId(key, `cap-claude-${index}`) + } + + // Oldest first. Earlier tests may leave their own idle keys ahead of + // these, which only evicts more of the early ones, never the recent ones. + assert.equal(getClaudeSessionId(keys[0]), undefined) + assert.equal(getClaudeSessionId(keys[5]), undefined) + assert.deepEqual(getLedger("cap-claude-0"), []) + for (const key of keys.slice(-20)) { + assert.ok(getClaudeSessionId(key), `${key} should have survived the cap`) + } + } finally { + for (const key of keys) deleteClaudeSessionId(key) + } +}) + +test("the claude session cap never takes a key that still has a process", () => { + const busyKey = "cap-session-busy" + const { activeProcess } = fakeActiveProcess({ exitOn: "SIGTERM", delayMs: 0 }) + setActiveProcess(busyKey, activeProcess) + setClaudeSessionId(busyKey, "cap-claude-busy") + + const keys = Array.from( + { length: MAX_CLAUDE_SESSION_ENTRIES + 10 }, + (_, i) => `cap-session-after-${i}`, + ) + try { + for (const [index, key] of keys.entries()) { + setClaudeSessionId(key, `cap-claude-after-${index}`) + } + // It is the oldest key in the map and would be the first to go on age + // alone; the busy check is the only thing keeping it. + assert.equal(getClaudeSessionId(busyKey), "cap-claude-busy") + } finally { + for (const key of keys) deleteClaudeSessionId(key) + deleteActiveProcess(busyKey) + deleteClaudeSessionId(busyKey) + } +}) diff --git a/test-side-question.ts b/test-side-question.ts new file mode 100644 index 0000000..a838f5a --- /dev/null +++ b/test-side-question.ts @@ -0,0 +1,635 @@ +import assert from "node:assert/strict" +import type { LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider" +import type { ChildProcess } from "node:child_process" +import { EventEmitter, getEventListeners } from "node:events" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { PassThrough } from "node:stream" +import { test } from "node:test" +import { setImmediate } from "node:timers/promises" +import { cliSupportsSideQuestion, type CliVersion } from "./src/cli-version.js" +import { createClaudeCode, registerSideQuestionCommand } from "./src/index.js" +import type { OpenCodeConfig } from "./src/opencode-types.js" +import { + deleteActiveProcessAndWait, + deleteClaudeSessionId, + getActiveProcess, + getClaudeSessionId, + sessionKey, +} from "./src/session-manager.js" +import { + collectSideQuestionHistory, + dispatchSideQuestionResponse, + isSideQuestionPending, + parseSideQuestion, + requestSideQuestion, + SIDE_QUESTION_USAGE, +} from "./src/side-question.js" + +const cliVersion: CliVersion = { major: 2, minor: 1, patch: 258, raw: "2.1.258" } +const options = { cliVersion, timeoutMs: 1_000 } + +function fakeProcess() { + const proc = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + killed: false, + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + }) + const activeProcess = { + proc: proc as unknown as ChildProcess, + lineEmitter: new EventEmitter(), + } + const writes: { type: string; request_id: string; request?: unknown }[] = [] + proc.stdin.on("data", (chunk: Buffer) => { + assert.ok(chunk.toString().endsWith("\n")) + writes.push(JSON.parse(chunk.toString())) + }) + const skipped: string[] = [] + const receive = (message: unknown): boolean => { + const line = typeof message === "string" ? message : JSON.stringify(message) + if (dispatchSideQuestionResponse(activeProcess, line)) return true + if (!activeProcess.lineEmitter.emit("line", line)) skipped.push(line) + return false + } + const answer = (response = "pong", synthetic = false): boolean => receive({ + type: "control_response", + response: { + subtype: "success", + request_id: writes[0].request_id, + response: { response, synthetic }, + }, + }) + const assertClean = (signal?: AbortSignal): void => { + assert.deepEqual(activeProcess.lineEmitter.eventNames(), []) + assert.deepEqual(proc.eventNames(), []) + assert.equal(proc.stdin.listenerCount("error"), 0) + assert.equal(isSideQuestionPending(activeProcess), false) + if (signal) assert.equal(getEventListeners(signal, "abort").length, 0) + } + return { activeProcess, proc, writes, skipped, receive, answer, assertClean } +} + +test("parses only a complete latest all-text /btw user message", () => { + const cases = [ + ["/btw what changed?", "what changed?"], + [" /btw\twhy? ", "why?"], + ["/btw first line\nsecond line", "first line\nsecond line"], + ["/btw", ""], + ["/btw \n\t", ""], + [[{ type: "text", text: "/btw" }, { type: "text", text: "more\ncontext" }], "more\ncontext"], + ] as const + for (const [content, question] of cases) { + assert.deepEqual(parseSideQuestion([{ role: "user", content }]), { question }) + } + for (const content of [ + "normal question", "mention /btw here", "/btwhatever", "/btw?", "/BTW question", + [{ type: "text", text: "/btw question" }, { type: "image", image: "aGVsbG8=" }], + [{ type: "text", text: "/btw question" }, { type: "file", data: "data" }], + [{ type: "text", text: "/btw question" }, { type: "tool-result", toolCallId: "id" }], + [{ type: "text", text: 42 }], [null], [], null, + ]) { + assert.equal(parseSideQuestion([{ role: "user", content }]), null) + } + assert.equal(parseSideQuestion([]), null) + assert.equal(parseSideQuestion([ + { role: "user", content: "/btw old question" }, + { role: "assistant", content: "old answer" }, + { role: "user", content: "ordinary next user" }, + ]), null) + for (const role of ["assistant", "tool", "system"]) { + assert.equal(parseSideQuestion([ + { role: "user", content: "/btw old question" }, + { role, content: "/btw not a new user question" }, + ]), null) + } +}) + +test("drops opencode's appended system-reminder parts from the question", () => { + // Shape measured live on opencode 1.18.29: the typed text and the reminder + // arrive as two separate text parts on the same user message. + const reminder = + "\n# Plan Mode - System Reminder\n\nCRITICAL: Plan mode ACTIVE" + + " - you are in READ-ONLY phase.\n" + const asked = parseSideQuestion([{ + role: "user", + content: [ + { type: "text", text: "/btw What fruit did I ask for? One word.\n\n" }, + { type: "text", text: reminder }, + ], + }]) + assert.deepEqual(asked, { question: "What fruit did I ask for? One word." }) + + // A bare /btw must still look empty so the usage text renders. + assert.deepEqual( + parseSideQuestion([{ + role: "user", + content: [{ type: "text", text: "/btw\n\n" }, { type: "text", text: reminder }], + }]), + { question: "" }, + ) + + // More than one appended block, and surrounding whitespace, are both handled. + assert.deepEqual( + parseSideQuestion([{ + role: "user", + content: [ + { type: "text", text: "/btw why?" }, + { type: "text", text: `\n${reminder}\n` }, + { type: "text", text: reminder }, + ], + }]), + { question: "why?" }, + ) + + // A block is removed wherever it sits, including inside a part that also + // carries real text, which is kept. + assert.deepEqual( + parseSideQuestion([{ + role: "user", + content: [{ type: "text", text: `/btw why?\n${reminder}\nand also this` }], + }]), + { question: "why?\n\nand also this" }, + ) + + // Measured live: opencode-dcp appends its own marker after the closing tag, + // so an end-anchored check would leave the whole reminder in the question. + assert.deepEqual( + parseSideQuestion([{ + role: "user", + content: [ + { type: "text", text: "/btw why?" }, + { type: "text", text: `${reminder}\n\nm0003` }, + ], + }]), + { question: "why?\n\n\nm0003" }, + ) + + // Reminder-only content never becomes a side question of its own. + assert.equal( + parseSideQuestion([{ role: "user", content: [{ type: "text", text: reminder }] }]), + null, + ) +}) + +test("gates the protocol at the oldest measured CLI version", () => { + assert.equal(cliSupportsSideQuestion(null), false) + assert.equal(cliSupportsSideQuestion({ ...cliVersion, patch: 257 }), false) + assert.equal(cliSupportsSideQuestion({ ...cliVersion, minor: 0, patch: 999 }), false) + assert.equal(cliSupportsSideQuestion(cliVersion), true) + assert.equal(cliSupportsSideQuestion({ ...cliVersion, patch: 259 }), true) + assert.equal(cliSupportsSideQuestion({ ...cliVersion, minor: 2, patch: 0 }), true) + assert.equal(cliSupportsSideQuestion({ ...cliVersion, major: 3, minor: 0, patch: 0 }), true) +}) + +test("registers /btw without choosing an agent/model or replacing user commands", () => { + const config: OpenCodeConfig = {} + registerSideQuestionCommand(config) + assert.deepEqual(config.command?.btw, { + template: "/btw $ARGUMENTS", + description: "Ask a side question in the live Claude Code session without changing its context", + }) + const ownCommand = { template: "custom $ARGUMENTS", agent: "plan", model: "user/model" } + const ownConfig: OpenCodeConfig = { command: { btw: ownCommand, other: { template: "other" } } } + const before = structuredClone(ownConfig) + registerSideQuestionCommand(ownConfig) + assert.deepEqual(ownConfig, before) + assert.equal(ownConfig.command?.btw, ownCommand) +}) + +test("empty /btw returns usage without writing or requiring protocol support", async () => { + const fake = fakeProcess() + assert.deepEqual(await requestSideQuestion(fake.activeProcess, " \n", { cliVersion: null }), { + response: SIDE_QUESTION_USAGE, + synthetic: true, + }) + assert.deepEqual(fake.writes, []) + fake.assertClean() +}) + +test("sends only the native request and resolves its matching response", async () => { + const fake = fakeProcess() + const controller = new AbortController() + const pending = requestSideQuestion(fake.activeProcess, " ping ", { + ...options, abortSignal: controller.signal, + }) + assert.equal(isSideQuestionPending(fake.activeProcess), true) + assert.equal(fake.activeProcess.lineEmitter.listenerCount("line"), 0) + assert.equal(fake.writes.length, 1) + assert.match(fake.writes[0].request_id, /^[0-9a-f-]{36}$/) + assert.deepEqual(fake.writes[0], { + type: "control_request", + request_id: fake.writes[0].request_id, + request: { subtype: "side_question", question: "ping" }, + }) + assert.equal(fake.answer(), true) + assert.deepEqual(await pending, { response: "pong", synthetic: false }) + controller.abort() + assert.equal(fake.writes.length, 1, "success must remove the abort handler") + fake.assertClean(controller.signal) +}) + +test("unrelated stdout and progress remain buffered, not consumed by the helper", async () => { + const fake = fakeProcess() + const pending = requestSideQuestion(fake.activeProcess, "ping", options) + const unrelated = [ + "not json", "null", "[]", + JSON.stringify({ type: "assistant", message: { content: "main output" } }), + JSON.stringify({ type: "control_request", request_id: "permission", request: { subtype: "can_use_tool" } }), + JSON.stringify({ type: "system", subtype: "control_request_progress", request_id: fake.writes[0].request_id, status: "started" }), + JSON.stringify({ type: "control_response", response: { subtype: "error", request_id: "unrelated", error: "other failure" } }), + JSON.stringify({ type: "control_response", response: null }), + ] + for (const line of unrelated) assert.equal(fake.receive(line), false) + assert.equal(isSideQuestionPending(fake.activeProcess), true) + assert.deepEqual(fake.skipped, unrelated) + fake.answer() + await pending + assert.equal(fake.answer("late duplicate"), false) + fake.assertClean() +}) + +test("only explicit history is forwarded and synthetic results are preserved", async () => { + const fake = fakeProcess() + const history = [{ question: "earlier aside", response: "earlier response" }] + const pending = requestSideQuestion(fake.activeProcess, "follow-up", { ...options, history }) + assert.deepEqual(fake.writes[0].request, { subtype: "side_question", question: "follow-up", history }) + fake.answer("local answer", true) + assert.deepEqual(await pending, { response: "local answer", synthetic: true }) + fake.assertClean() +}) + +test("CLI error and malformed success responses reject and clean up", async () => { + for (const response of [ + { subtype: "error", error: "side questions unavailable" }, + { subtype: "success", response: { response: 123, synthetic: false } }, + { subtype: "success", response: { response: "missing synthetic" } }, + { subtype: "unexpected" }, + ]) { + const fake = fakeProcess() + const pending = requestSideQuestion(fake.activeProcess, "ping", options) + assert.equal(fake.receive({ + type: "control_response", + response: { ...response, request_id: fake.writes[0].request_id }, + }), true) + await assert.rejects(pending, /side questions unavailable|invalid \/btw response/) + assert.equal(fake.writes.length, 1) + fake.assertClean() + } +}) + +test("abort cancels only the matching request and leaves the process alive", async () => { + const fake = fakeProcess() + const controller = new AbortController() + const pending = requestSideQuestion(fake.activeProcess, "ping", { ...options, abortSignal: controller.signal }) + const rejection = assert.rejects(pending, { name: "AbortError" }) + controller.abort() + await rejection + await setImmediate() + assert.deepEqual(fake.writes[1], { type: "control_cancel_request", request_id: fake.writes[0].request_id }) + assert.equal(fake.proc.killed, false) + assert.equal(fake.proc.stdin.writableEnded, false) + assert.equal(fake.answer("too late"), false) + fake.assertClean(controller.signal) + + const alreadyAborted = fakeProcess() + await assert.rejects(requestSideQuestion(alreadyAborted.activeProcess, "ping", { + ...options, abortSignal: controller.signal, + }), { name: "AbortError" }) + assert.equal(alreadyAborted.writes.length, 0) + alreadyAborted.assertClean(controller.signal) +}) + +test("timeout cancels and clears listeners", async () => { + const fake = fakeProcess() + await assert.rejects(requestSideQuestion(fake.activeProcess, "ping", { + ...options, timeoutMs: 10, + }), /timed out after 10ms/) + await setImmediate() + assert.equal(fake.writes.length, 2) + assert.deepEqual(fake.writes[1], { type: "control_cancel_request", request_id: fake.writes[0].request_id }) + fake.assertClean() +}) + +test("process/stdout close and errors reject without cancelling a dead process", async () => { + for (const [target, event] of [ + ["proc", "exit"], ["proc", "close"], ["proc", "error"], + ["lineEmitter", "close"], ["lineEmitter", "error"], ["stdin", "error"], + ] as const) { + const fake = fakeProcess() + const pending = requestSideQuestion(fake.activeProcess, "ping", options) + const rejection = assert.rejects(pending, /closed before answering|broken pipe/) + const emitter = target === "proc" ? fake.proc + : target === "stdin" ? fake.proc.stdin : fake.activeProcess.lineEmitter + emitter.emit(event, new Error("broken pipe")) + await rejection + assert.equal(fake.writes.length, 1) + fake.assertClean() + } +}) + +test("a running main turn does not block /btw; only a simultaneous side question is refused", async () => { + const fake = fakeProcess() + // A streaming turn keeps a `line` listener attached. Claude Code answers a + // side question concurrently with the turn, so the request goes out anyway. + const onLine = (): void => {} + fake.activeProcess.lineEmitter.on("line", onLine) + const during = requestSideQuestion(fake.activeProcess, "ping", options) + assert.equal(fake.writes.length, 1) + assert.equal(fake.writes[0].type, "control_request") + + await assert.rejects(requestSideQuestion(fake.activeProcess, "second", options), /current \/btw/) + assert.equal(fake.writes.length, 1) + assert.equal(fake.answer(), true, "the response must be routed by request id, not to the turn's listener") + assert.equal((await during).response, "pong") + assert.equal(fake.activeProcess.lineEmitter.listenerCount("line"), 1, "the turn's listener is untouched") + fake.activeProcess.lineEmitter.off("line", onLine) + fake.assertClean() +}) + +test("collectSideQuestionHistory pairs earlier /btw questions with their answers and drops the current one", () => { + const reminder = "\nplan mode\n" + const prompt = [ + { role: "user", content: [{ type: "text", text: "normal turn" }] }, + { role: "assistant", content: [{ type: "text", text: "normal answer" }] }, + { role: "user", content: [{ type: "text", text: "/btw first?" }, { type: "text", text: reminder }] }, + { role: "assistant", content: [{ type: "text", text: "one" }, { type: "text", text: "more" }] }, + { role: "user", content: "/btw" }, + { role: "assistant", content: SIDE_QUESTION_USAGE }, + { role: "user", content: "/btw unanswered?" }, + { role: "user", content: "/btw second?" }, + { role: "assistant", content: "two" }, + { role: "user", content: [{ type: "text", text: "/btw current?" }] }, + ] + assert.deepEqual(collectSideQuestionHistory(prompt), [ + { question: "first?", response: "one\nmore" }, + { question: "second?", response: "two" }, + ]) + assert.deepEqual(collectSideQuestionHistory([{ role: "user", content: "/btw only?" }]), []) + const many = Array.from({ length: 25 }, (_, index) => [ + { role: "user", content: `/btw q${index}` }, + { role: "assistant", content: `a${index}` }, + ]).flat() + many.push({ role: "user", content: "/btw now?" }) + const capped = collectSideQuestionHistory(many) + assert.equal(capped.length, 20) + assert.equal(capped[0].question, "q5") + assert.equal(capped[19].question, "q24") +}) + +test("interactive, old/unknown CLI, dead processes, and invalid deadlines never receive a request", async () => { + for (const override of [ + { interactive: true }, { cliVersion: null }, { cliVersion: { ...cliVersion, patch: 257 } }, + { timeoutMs: 0 }, { timeoutMs: NaN }, { timeoutMs: Infinity }, { timeoutMs: 2 ** 31 }, + ]) { + const fake = fakeProcess() + await assert.rejects(requestSideQuestion(fake.activeProcess, "ping", { ...options, ...override })) + assert.equal(fake.writes.length, 0) + fake.assertClean() + } + for (const property of [{ killed: true }, { exitCode: 0 }, { signalCode: "SIGTERM" }, { stdout: null }, { stdin: null }]) { + const fake = fakeProcess() + Object.assign(fake.proc, property) + await assert.rejects(requestSideQuestion(fake.activeProcess, "ping", options), /requires/) + assert.equal(fake.writes.length, 0) + assert.deepEqual(fake.activeProcess.lineEmitter.eventNames(), []) + assert.equal(isSideQuestionPending(fake.activeProcess), false) + } +}) + +test("a synchronous write failure cleans up and preserves existing error listeners", async () => { + const fake = fakeProcess() + const onError = (): void => {} + fake.proc.on("error", onError) + fake.proc.stdin.write = () => { throw new Error("write failed") } + await assert.rejects(requestSideQuestion(fake.activeProcess, "ping", options), /write failed/) + assert.deepEqual(fake.proc.listeners("error"), [onError]) + fake.proc.off("error", onError) + fake.assertClean() +}) + +test("cancel write errors cannot escape after abort/timeout cleanup", async () => { + for (const synchronous of [true, false]) { + const fake = fakeProcess() + const controller = new AbortController() + const pending = requestSideQuestion(fake.activeProcess, "ping", { ...options, abortSignal: controller.signal }) + if (synchronous) { + fake.proc.stdin.write = () => { throw new Error("cancel write failed") } + } else { + fake.proc.stdin._write = (_chunk, _encoding, callback) => { + callback(new Error("cancel write failed")) + } + } + const rejection = assert.rejects(pending, { name: "AbortError" }) + controller.abort() + await rejection + await setImmediate() + fake.assertClean(controller.signal) + } +}) + +function createSideQuestionCli() { + const cwd = mkdtempSync(join(tmpdir(), "opencode-side-question-")) + const cliPath = join(cwd, "fake-claude.cjs") + const eventsPath = join(cwd, "events.jsonl") + writeFileSync(eventsPath, "") + writeFileSync(cliPath, `#!/usr/bin/env node +const fs = require("node:fs") +const readline = require("node:readline") +const record = (event) => fs.appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify({ ...event, pid: process.pid }) + "\\n") +const emit = (message) => process.stdout.write(JSON.stringify(message) + "\\n") +if (process.argv.includes("--version")) { + record({ type: "version" }) + process.stdout.write("2.1.258\\n") + process.exit(0) +} +if (process.argv.includes("--help")) { + process.stdout.write("--plugin-dir \\n") + process.exit(0) +} +record({ type: "spawn" }) +let turns = 0 +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const envelope = JSON.parse(line) + record({ type: "input", envelope }) + if (envelope.type === "control_request" && envelope.request?.subtype === "side_question") { + emit({ + type: "control_response", + response: { + subtype: "success", + request_id: envelope.request_id, + response: { response: "Native aside after turn " + turns, synthetic: false }, + }, + }) + return + } + if (envelope.type !== "user") throw new Error("Unexpected fixture input") + turns++ + emit({ + type: "assistant", + session_id: "fake-side-question-session", + message: { + role: "assistant", + stop_reason: "end_turn", + content: [{ type: "text", text: "Normal answer " + turns }], + }, + }) + emit({ + type: "result", + subtype: "success", + session_id: "fake-side-question-session", + is_error: false, + usage: { input_tokens: 11, output_tokens: 7 }, + }) +}) +`, { mode: 0o755 }) + + const modelId = "claude-test-side-question" + const sk = sessionKey(cwd, `${modelId}::tools::default::context=["claude-code",null]`) + const model = createClaudeCode({ + cliPath, + cwd, + bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, + proxyTools: [], + interactive: false, + autoContinueIncompleteTurns: false, + }).languageModel(modelId) + const options: LanguageModelV3CallOptions = { + prompt: [], + tools: [{ type: "function", name: "read", inputSchema: { type: "object", properties: {} } }], + } + return { + sk, + events: () => readFileSync(eventsPath, "utf8").trim().split("\n").filter(Boolean).map((line) => + JSON.parse(line) as { + type: string + pid: number + envelope?: { type: string; request_id?: string; request?: unknown } + }, + ), + async turn(text: string) { + options.prompt.push({ role: "user", content: [{ type: "text", text }] }) + const response = await model.doStream({ ...options, abortSignal: AbortSignal.timeout(5_000) }) + const parts: LanguageModelV3StreamPart[] = [] + for await (const part of response.stream) parts.push(part) + const answer = parts.filter((part) => part.type === "text-delta").map((part) => part.delta).join("") + options.prompt.push({ role: "assistant", content: [{ type: "text", text: answer }] }) + return { parts, answer } + }, + async cleanup() { + await deleteActiveProcessAndWait(sk) + deleteClaudeSessionId(sk) + rmSync(cwd, { recursive: true, force: true }) + }, + } +} + +test("provider /btw uses native control response between normal turns on the same CLI process", { + timeout: 20_000, +}, async () => { + const fake = createSideQuestionCli() + try { + const first = await fake.turn("Start the main conversation.") + assert.equal(first.answer, "Normal answer 1") + const active = getActiveProcess(fake.sk) + assert.ok(active) + const sessionId = getClaudeSessionId(fake.sk) + assert.ok(sessionId) + assert.equal(active.lineEmitter.listenerCount("line"), 0) + + const aside = await fake.turn("/btw What changed?") + assert.equal(aside.answer, "Native aside after turn 1") + assert.deepEqual(aside.parts.map((part) => part.type), [ + "stream-start", "text-start", "text-delta", "text-end", "finish", + ]) + const textParts = aside.parts.filter((part) => + part.type === "text-start" || part.type === "text-delta" || part.type === "text-end", + ) + assert.equal(new Set(textParts.map((part) => part.id)).size, 1) + const finish = aside.parts.find((part) => part.type === "finish")! + assert.deepEqual(finish.finishReason, { unified: "stop", raw: "stop" }) + assert.deepEqual(finish.providerMetadata, { + "claude-code": { path: "side-question", synthetic: false, usageUnavailable: true }, + }) + assert.equal(finish.usage.inputTokens.total, 0) + assert.equal(finish.usage.outputTokens.total, undefined) + assert.deepEqual(finish.usage.raw, {}) + assert.equal(getActiveProcess(fake.sk), active) + assert.equal(getClaudeSessionId(fake.sk), sessionId) + assert.equal(isSideQuestionPending(active), false) + assert.equal(active.lineEmitter.listenerCount("line"), 0) + assert.deepEqual(active.unattendedLines, [], "The control response must not enter the normal replay buffer") + + const next = await fake.turn("Continue the main conversation.") + assert.equal(next.answer, "Normal answer 2") + for (const turn of [first, next]) { + assert.deepEqual(turn.parts.filter((part) => part.type === "error"), []) + const finishes = turn.parts.filter((part) => part.type === "finish") + assert.equal(finishes.length, 1) + assert.equal(finishes[0].finishReason.unified, "stop") + assert.equal(finishes[0].usage.inputTokens.total, 11) + assert.equal(finishes[0].usage.outputTokens.total, 7) + } + assert.equal(getActiveProcess(fake.sk), active) + assert.equal(getClaudeSessionId(fake.sk), sessionId) + const events = fake.events() + assert.equal(events.filter((event) => event.type === "spawn").length, 1) + const inputs = events.filter((event) => event.type === "input") + assert.deepEqual(inputs.map((event) => event.pid), [active.proc.pid, active.proc.pid, active.proc.pid]) + assert.deepEqual(inputs.map((event) => event.envelope?.type), ["user", "control_request", "user"]) + const control = inputs[1].envelope! + assert.match(control.request_id!, /^[0-9a-f-]{36}$/) + assert.deepEqual(control, { + type: "control_request", + request_id: control.request_id, + request: { subtype: "side_question", question: "What changed?" }, + }) + const users = inputs.filter((event) => event.envelope?.type === "user") + assert.match(JSON.stringify(users[0].envelope), /Start the main conversation/) + assert.match(JSON.stringify(users[1].envelope), /Continue the main conversation/) + assert.doesNotMatch(JSON.stringify(users), /\/btw|What changed\?|Native aside/) + } finally { + await fake.cleanup() + } +}) + +test("provider /btw without a live session answers with a readable explanation without spawning", async () => { + const fake = createSideQuestionCli() + try { + const { parts, answer } = await fake.turn("/btw What changed?") + assert.match(answer, /needs a live Claude Code session.*Send a normal message/) + assert.deepEqual(parts.map((part) => part.type), ["stream-start", "text-start", "text-delta", "text-end", "finish"]) + assert.equal(getActiveProcess(fake.sk), undefined) + assert.equal(getClaudeSessionId(fake.sk), undefined) + assert.deepEqual(fake.events(), []) + } finally { + await fake.cleanup() + } +}) + +test("provider empty /btw renders usage without a live session or CLI invocation", async () => { + const fake = createSideQuestionCli() + try { + const { parts, answer } = await fake.turn("/btw \n\t") + assert.equal(answer, SIDE_QUESTION_USAGE) + assert.deepEqual(parts.map((part) => part.type), [ + "stream-start", "text-start", "text-delta", "text-end", "finish", + ]) + const finish = parts.find((part) => part.type === "finish")! + assert.equal(finish.finishReason.unified, "stop") + assert.deepEqual(finish.providerMetadata, { + "claude-code": { path: "side-question", synthetic: true, usageUnavailable: true }, + }) + assert.equal(finish.usage.inputTokens.total, 0) + assert.equal(finish.usage.outputTokens.total, undefined) + assert.deepEqual(finish.usage.raw, {}) + assert.equal(getActiveProcess(fake.sk), undefined) + assert.equal(getClaudeSessionId(fake.sk), undefined) + assert.deepEqual(fake.events(), []) + } finally { + await fake.cleanup() + } +}) diff --git a/test-skill-bridge.ts b/test-skill-bridge.ts new file mode 100644 index 0000000..5a06405 --- /dev/null +++ b/test-skill-bridge.ts @@ -0,0 +1,424 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" +import * as crypto from "node:crypto" +import { + SKILL_PLUGIN_NAME, + buildSkillPluginDir, + bundledSkillsDir, + discoverBundledSkills, + discoverOpencodeSkills, + registerBundledSkillPath, + resolveSkillPluginDirs, +} from "./src/skill-bridge.js" +import { + buildCliArgs, + deleteActiveProcessAndWait, + deleteClaudeSessionId, + sessionKey, +} from "./src/session-manager.js" +import { createClaudeCode } from "./src/index.js" + +/** + * Skill names are prefixed so a stray `~/.opencode/skills` on the machine + * running the suite can't collide with the fixtures. + */ +const P = "zz-fixture-" + +function makeSkill(root: string, name: string, body = "# body\n"): void { + const dir = path.join(root, name) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync( + path.join(dir, "SKILL.md"), + `---\nname: ${name}\ndescription: fixture ${name}\n---\n\n${body}`, + ) +} + +/** Run `fn` with a scratch tree and env isolated from the real machine. */ +async function withFixture( + fn: (paths: { cwd: string; projectSkills: string; globalSkills: string }) => T, +): Promise> { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "skill-bridge-test-")) + const cwd = path.join(base, "workspace") + const projectSkills = path.join(cwd, ".opencode", "skills") + const xdg = path.join(base, "xdg") + const globalSkills = path.join(xdg, "opencode", "skills") + fs.mkdirSync(projectSkills, { recursive: true }) + fs.mkdirSync(globalSkills, { recursive: true }) + + const prevXdg = process.env.XDG_CONFIG_HOME + const prevConfigDir = process.env.OPENCODE_CONFIG_DIR + const prevHome = process.env.HOME + process.env.HOME = base + process.env.XDG_CONFIG_HOME = xdg + delete process.env.OPENCODE_CONFIG_DIR + try { + return await fn({ cwd, projectSkills, globalSkills }) + } finally { + if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = prevXdg + if (prevConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR + else process.env.OPENCODE_CONFIG_DIR = prevConfigDir + if (prevHome === undefined) delete process.env.HOME + else process.env.HOME = prevHome + fs.rmSync(base, { recursive: true, force: true }) + } +} + +const fixtures = (skills: { name: string }[]) => + skills.filter((s) => s.name.startsWith(P)) + +/** + * A stand-in `claude` whose `--help` output is under the test's control, so + * the flag probe is deterministic and never touches the real binary. Its + * path is unique per call, which also defeats the probe's per-path cache. + */ +function fakeCli(base: string, help: string, exitCode = 0): string { + const file = path.join(base, `fake-claude-${crypto.randomUUID()}.cjs`) + fs.writeFileSync( + file, + `#!/usr/bin/env node +if (process.argv.includes("--help")) { process.stdout.write(${JSON.stringify(help)}); process.exit(${exitCode}) } +process.exit(0) +`, + ) + fs.chmodSync(file, 0o755) + return file +} + +const skillNames = (dir: string) => + fs.readdirSync(path.join(dir, "skills")).sort() + +test("discovers skills from both project and global roots", async () => { + await withFixture(({ cwd, projectSkills, globalSkills }) => { + makeSkill(projectSkills, `${P}local`) + makeSkill(globalSkills, `${P}global`) + + const found = fixtures(discoverOpencodeSkills(cwd)) + assert.deepEqual( + found.map((s) => s.name), + [`${P}global`, `${P}local`], + "results are sorted by name", + ) + }) +}) + +test("a project skill shadows a global skill of the same name", async () => { + await withFixture(({ cwd, projectSkills, globalSkills }) => { + makeSkill(projectSkills, `${P}dup`, "project wins\n") + makeSkill(globalSkills, `${P}dup`, "global loses\n") + + const found = fixtures(discoverOpencodeSkills(cwd)) + assert.equal(found.length, 1, "the name is claimed exactly once") + assert.ok( + found[0]!.dir.startsWith(path.resolve(cwd)), + `expected the project copy to win, got ${found[0]!.dir}`, + ) + }) +}) + +test("directories without a SKILL.md are ignored", async () => { + await withFixture(({ cwd, projectSkills }) => { + fs.mkdirSync(path.join(projectSkills, `${P}empty`), { recursive: true }) + fs.mkdirSync(path.join(projectSkills, ".hidden"), { recursive: true }) + makeSkill(projectSkills, `${P}real`) + + const found = fixtures(discoverOpencodeSkills(cwd)) + assert.deepEqual( + found.map((s) => s.name), + [`${P}real`], + ) + }) +}) + +test("staged plugin dir carries a manifest and one entry per skill", async () => { + await withFixture(({ cwd, projectSkills }) => { + makeSkill(projectSkills, `${P}alpha`, "alpha body\n") + makeSkill(projectSkills, `${P}beta`) + + const skills = fixtures(discoverOpencodeSkills(cwd)) + const dir = buildSkillPluginDir(skills) + assert.ok(dir, "expected a staged plugin dir") + + const manifest = JSON.parse( + fs.readFileSync(path.join(dir!, ".claude-plugin", "plugin.json"), "utf8"), + ) + assert.equal(manifest.name, SKILL_PLUGIN_NAME) + assert.ok(manifest.description, "manifest needs a description") + + // The skill must be readable through the staged tree, whether it was + // linked (posix) or copied (windows fallback). + const staged = path.join(dir!, "skills", `${P}alpha`, "SKILL.md") + assert.match(fs.readFileSync(staged, "utf8"), /alpha body/) + assert.deepEqual( + fs.readdirSync(path.join(dir!, "skills")).sort(), + [`${P}alpha`, `${P}beta`], + ) + }) +}) + +test("staging is reused for an identical skill set and rekeyed when it changes", async () => { + await withFixture(({ cwd, projectSkills }) => { + makeSkill(projectSkills, `${P}one`) + const first = buildSkillPluginDir(fixtures(discoverOpencodeSkills(cwd))) + const again = buildSkillPluginDir(fixtures(discoverOpencodeSkills(cwd))) + assert.equal(first, again, "same set must not restage") + + makeSkill(projectSkills, `${P}two`) + const grown = buildSkillPluginDir(fixtures(discoverOpencodeSkills(cwd))) + assert.notEqual(first, grown, "a changed set must get its own dir") + }) +}) + +test("no skills means no plugin dir", () => { + assert.equal(buildSkillPluginDir([]), null) +}) + +// --- the bundled skill ------------------------------------------------------- +// +// The package ships `skills/claude-code-plugin/SKILL.md`, the skill a model +// uses to configure this plugin. It is always bridged, because a Claude-routed +// turn only sees Claude's native Skill tool; the user's own skills stay behind +// `bridgeOpencodeSkills`. + +test("finds the bundled skill relative to the source module", () => { + const dir = bundledSkillsDir() + assert.ok(dir, "skills/ must exist next to src/ and dist/") + assert.equal(path.basename(dir!), "skills") + const bundled = discoverBundledSkills() + assert.deepEqual(bundled.map((s) => s.name), ["claude-code-plugin"]) + assert.ok(fs.existsSync(path.join(bundled[0]!.dir, "SKILL.md"))) +}) + +test("registerBundledSkillPath adds the directory to skills.paths exactly once", () => { + const config: { skills?: { paths?: string[] } } = {} + assert.equal(registerBundledSkillPath(config), true) + assert.deepEqual(config.skills?.paths, [bundledSkillsDir()]) + assert.equal(registerBundledSkillPath(config), false, "idempotent") + assert.equal(config.skills?.paths?.length, 1) + + // A user's own entries are kept, and a differently written spelling of the + // same directory is recognised as already present. + const withUser = { skills: { paths: ["~/my-skills", `${bundledSkillsDir()}/../skills`] } } + assert.equal(registerBundledSkillPath(withUser), false) + assert.equal(withUser.skills.paths.length, 2) +}) + +test("resolveSkillPluginDirs stages only the bundled skill when the user bridge is off", async () => { + await withFixture(async ({ cwd, projectSkills }) => { + makeSkill(projectSkills, `${P}off`) + const dirs = await resolveSkillPluginDirs({ + cwd, + cliPath: fakeCli(path.dirname(cwd), "--plugin-dir Load a plugin"), + enabled: false, + }) + assert.equal(dirs.length, 1, "the bundled skill is bridged regardless of the opt-in") + assert.deepEqual(skillNames(dirs[0]!), ["claude-code-plugin"], "the user's skill is not") + }) +}) + +test("resolveSkillPluginDirs stages user skills next to the bundled one when enabled", async () => { + await withFixture(async ({ cwd, projectSkills }) => { + makeSkill(projectSkills, `${P}on`) + const dirs = await resolveSkillPluginDirs({ + cwd, + cliPath: fakeCli(path.dirname(cwd), "--plugin-dir Load a plugin"), + enabled: true, + }) + assert.equal(dirs.length, 1) + assert.deepEqual(skillNames(dirs[0]!), ["claude-code-plugin", `${P}on`]) + }) +}) + +test("a user skill named like the bundled one wins, so it can be overridden", async () => { + await withFixture(async ({ cwd, projectSkills }) => { + makeSkill(projectSkills, "claude-code-plugin", "# user override\n") + const dirs = await resolveSkillPluginDirs({ + cwd, + cliPath: fakeCli(path.dirname(cwd), "--plugin-dir "), + enabled: true, + }) + assert.equal(dirs.length, 1) + const staged = fs.realpathSync(path.join(dirs[0]!, "skills", "claude-code-plugin")) + assert.equal(staged, fs.realpathSync(path.join(projectSkills, "claude-code-plugin"))) + }) +}) + +test("resolveSkillPluginDirs degrades to no-op when the CLI lacks --plugin-dir", async () => { + await withFixture(async ({ cwd, projectSkills }) => { + makeSkill(projectSkills, `${P}unsupported`) + for (const cliPath of [ + fakeCli(path.dirname(cwd), "Usage: claude [options]\n --model "), + fakeCli(path.dirname(cwd), "--plugin-dir", 1), + "/nonexistent/claude-binary", + ]) { + const dirs = await resolveSkillPluginDirs({ cwd, cliPath, enabled: true }) + assert.deepEqual(dirs, [], `an unsupporting or unprobeable CLI must not get the flag: ${cliPath}`) + } + }) +}) + +test("the flag probe closes the child's stdin, so a binary that reads it still exits", async () => { + await withFixture(async ({ cwd }) => { + // Sits on stdin like the suite's fake CLIs do; without EOF it would hang + // until the probe's 5 s timeout. + const file = path.join(path.dirname(cwd), "stdin-reader.cjs") + fs.writeFileSync( + file, + `#!/usr/bin/env node +require("node:readline").createInterface({ input: process.stdin }).on("close", () => { + process.stdout.write("--plugin-dir"); + process.exit(0) +}) +`, + ) + fs.chmodSync(file, 0o755) + const started = Date.now() + const dirs = await resolveSkillPluginDirs({ cwd, cliPath: file, enabled: false }) + assert.ok(Date.now() - started < 4000, "must not wait out the probe timeout") + assert.equal(dirs.length, 1) + }) +}) + +// --- the spawn itself ----------------------------------------------------------- +// +// Helper coverage above proves the pieces exist; these prove the `claude` +// that actually gets spawned carries `--plugin-dir`, on both headless paths, +// with the user's skills by default and without them on the explicit opt-out. + +/** + * A stand-in `claude` that records its argv, advertises `--plugin-dir` in + * `--help` (or not), and answers one turn with a text reply so both + * `doStream` and `doGenerate` complete. + */ +function recordingCli(dir: string, help: string): { cliPath: string; argvPath: string } { + const cliPath = path.join(dir, `recording-claude-${crypto.randomUUID()}.cjs`) + const argvPath = path.join(dir, `argv-${crypto.randomUUID()}.json`) + fs.writeFileSync( + cliPath, + `#!/usr/bin/env node +const fs = require("node:fs") +const readline = require("node:readline") +if (process.argv.includes("--version")) { process.stdout.write("2.1.258\\n"); process.exit(0) } +if (process.argv.includes("--help")) { process.stdout.write(${JSON.stringify(help)}); process.exit(0) } +fs.writeFileSync(${JSON.stringify(argvPath)}, JSON.stringify(process.argv.slice(2))) +readline.createInterface({ input: process.stdin }).on("line", () => { + const session_id = "fake-session" + process.stdout.write(JSON.stringify({ type: "system", subtype: "init", session_id }) + "\\n") + process.stdout.write(JSON.stringify({ + type: "assistant", session_id, + message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text: "done" }] }, + }) + "\\n") + process.stdout.write(JSON.stringify({ + type: "result", subtype: "success", session_id, is_error: false, duration_ms: 1, num_turns: 1, + usage: { input_tokens: 1, output_tokens: 1 }, + }) + "\\n") +}) +`, + ) + fs.chmodSync(cliPath, 0o755) + return { cliPath, argvPath } +} + +const pluginDirsIn = (argv: string[]) => + argv.reduce((acc, arg, i) => { + if (arg === "--plugin-dir") acc.push(argv[i + 1]!) + return acc + }, []) + +const CALL = { + prompt: [{ role: "user", content: [{ type: "text", text: "Say done." }] }], + tools: [{ type: "function", name: "bash", description: "Run", inputSchema: { type: "object", properties: {} } }], +} as any + +async function spawnArgsFor( + transport: "doStream" | "doGenerate", + settings: { bridgeOpencodeSkills?: boolean }, + help = "--plugin-dir Load a plugin", +): Promise { + return withFixture(async ({ cwd, projectSkills }) => { + makeSkill(projectSkills, `${P}spawned`) + const cli = recordingCli(path.dirname(cwd), help) + const modelId = `claude-test-skills-${transport}` + const sk = sessionKey(cwd, `${modelId}::tools::default::context=["claude-code",null]`) + try { + const model = createClaudeCode({ + cliPath: cli.cliPath, + cwd, + bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, + proxyTools: [], + autoContinueIncompleteTurns: false, + ...settings, + }).languageModel(modelId) + if (transport === "doStream") { + const response = await model.doStream(CALL) + for await (const _ of response.stream) { /* drain */ } + } else { + const result = await model.doGenerate(CALL) + assert.equal(result.finishReason.unified, "stop") + } + return JSON.parse(fs.readFileSync(cli.argvPath, "utf8")) as string[] + } finally { + await deleteActiveProcessAndWait(sk) + deleteClaudeSessionId(sk) + } + }) +} + +test("createClaudeCode leaves the user's skills unbridged unless asked", () => { + const configOf = (settings: Record) => + (createClaudeCode(settings).languageModel("claude-haiku-4-5") as any).config + assert.equal(configOf({}).bridgeOpencodeSkills, false) + assert.equal(configOf({ bridgeOpencodeSkills: true }).bridgeOpencodeSkills, true) + assert.equal(configOf({ bridgeOpencodeSkills: false }).bridgeOpencodeSkills, false) +}) + +for (const transport of ["doStream", "doGenerate"] as const) { + test(`${transport} with bridgeOpencodeSkills: true spawns claude with --plugin-dir carrying the user's skills`, async () => { + const argv = await spawnArgsFor(transport, { bridgeOpencodeSkills: true }) + const dirs = pluginDirsIn(argv) + assert.equal(dirs.length, 1, `expected one --plugin-dir in ${argv.join(" ")}`) + assert.deepEqual(skillNames(dirs[0]!), ["claude-code-plugin", `${P}spawned`]) + }) + + test(`${transport} stages only the bundled skill by default`, async () => { + const argv = await spawnArgsFor(transport, {}) + const dirs = pluginDirsIn(argv) + assert.equal(dirs.length, 1) + assert.deepEqual(skillNames(dirs[0]!), ["claude-code-plugin"]) + }) + + test(`${transport} passes no --plugin-dir to a CLI whose --help does not know the flag`, async () => { + const argv = await spawnArgsFor(transport, { bridgeOpencodeSkills: true }, "Usage: claude [options]\n --model ") + assert.equal(argv.includes("--plugin-dir"), false, argv.join(" ")) + }) +} + +test("buildCliArgs repeats --plugin-dir per directory", () => { + const args = buildCliArgs({ + sessionKey: "sk-plugin-dirs", + skipPermissions: true, + includeSessionResume: false, + pluginDirs: ["/tmp/a", "/tmp/b"], + }) + const flags = args.reduce((acc, arg, i) => { + if (arg === "--plugin-dir") acc.push(args[i + 1]!) + return acc + }, []) + assert.deepEqual(flags, ["/tmp/a", "/tmp/b"]) +}) + +test("buildCliArgs omits --plugin-dir when there is nothing to bridge", () => { + for (const pluginDirs of [undefined, [] as string[]]) { + const args = buildCliArgs({ + sessionKey: "sk-no-plugin-dirs", + skipPermissions: true, + includeSessionResume: false, + pluginDirs, + }) + assert.ok(!args.includes("--plugin-dir")) + } +}) diff --git a/test-spawn-env.ts b/test-spawn-env.ts new file mode 100644 index 0000000..d38ffc6 --- /dev/null +++ b/test-spawn-env.ts @@ -0,0 +1,156 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { claudeSpawnEnv, cliEffortLevel } from "./src/session-manager.js" +import { CLI_HYGIENE_ENV_VARS, cliHygieneEnv } from "./src/cli-version.js" +import { interactiveSpawnEnv } from "./src/claude-session-bun.js" + +/** Every hygiene var absent, which is the ordinary case for a user shell. */ +const noHygieneVars = Object.fromEntries( + CLI_HYGIENE_ENV_VARS.map((name) => [name, undefined]), +) as Record + +function withEnv( + vars: Record, + fn: () => T, +): T { + const previous: Record = {} + for (const key of Object.keys(vars)) { + previous[key] = process.env[key] + if (vars[key] === undefined) delete process.env[key] + else process.env[key] = vars[key] + } + try { + return fn() + } finally { + for (const key of Object.keys(vars)) { + if (previous[key] === undefined) delete process.env[key] + else process.env[key] = previous[key] + } + } +} + +test("claudeSpawnEnv passes ANTHROPIC_API_KEY through by default", () => { + withEnv( + { ANTHROPIC_API_KEY: "sk-test", ANTHROPIC_AUTH_TOKEN: "tok-test" }, + () => { + const env = claudeSpawnEnv() + assert.equal(env.ANTHROPIC_API_KEY, "sk-test") + assert.equal(env.ANTHROPIC_AUTH_TOKEN, "tok-test") + }, + ) +}) + +test("claudeSpawnEnv strips API key/token when ignoreAnthropicApiKey is true", () => { + withEnv( + { ANTHROPIC_API_KEY: "sk-test", ANTHROPIC_AUTH_TOKEN: "tok-test" }, + () => { + const env = claudeSpawnEnv({ ignoreAnthropicApiKey: true }) + assert.equal("ANTHROPIC_API_KEY" in env, false) + assert.equal("ANTHROPIC_AUTH_TOKEN" in env, false) + }, + ) +}) + +test("claudeSpawnEnv with ignore flag leaves other env vars intact", () => { + withEnv({ ANTHROPIC_API_KEY: "sk-test", PATH: process.env.PATH }, () => { + const env = claudeSpawnEnv({ ignoreAnthropicApiKey: true }) + assert.equal("ANTHROPIC_API_KEY" in env, false) + assert.equal(env.PATH, process.env.PATH) + assert.equal(env.TERM, "xterm-256color") + }) +}) + +test("claudeSpawnEnv exports a requested effort as CLAUDE_CODE_EFFORT_LEVEL", () => { + withEnv({ CLAUDE_CODE_EFFORT_LEVEL: undefined }, () => { + assert.equal(claudeSpawnEnv({ effort: "xhigh" }).CLAUDE_CODE_EFFORT_LEVEL, "xhigh") + assert.equal(claudeSpawnEnv({ effort: "max" }).CLAUDE_CODE_EFFORT_LEVEL, "max") + assert.equal("CLAUDE_CODE_EFFORT_LEVEL" in claudeSpawnEnv(), false) + }) +}) + +test("claudeSpawnEnv maps the provider's minimal onto the CLI's low", () => { + withEnv({ CLAUDE_CODE_EFFORT_LEVEL: undefined }, () => { + assert.equal(cliEffortLevel("minimal"), "low") + assert.equal(claudeSpawnEnv({ effort: "minimal" }).CLAUDE_CODE_EFFORT_LEVEL, "low") + }) +}) + +test("a requested effort wins over a shell-level CLAUDE_CODE_EFFORT_LEVEL", () => { + withEnv({ CLAUDE_CODE_EFFORT_LEVEL: "low" }, () => { + assert.equal(claudeSpawnEnv({ effort: "max" }).CLAUDE_CODE_EFFORT_LEVEL, "max") + // No request-level effort: the shell value passes through untouched. + assert.equal(claudeSpawnEnv().CLAUDE_CODE_EFFORT_LEVEL, "low") + }) +}) + +// CLI hygiene. Both names were verified against the Claude Code 2.1.263 bundle; +// the point is to stop the CLI autoupdating out from under the version +// `detectCliVersion` cached, which several flag gates are keyed on. + +test("the hygiene list is the two vars verified against the CLI bundle", () => { + assert.deepEqual( + [...CLI_HYGIENE_ENV_VARS], + ["DISABLE_AUTOUPDATER", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"], + ) +}) + +test("claudeSpawnEnv disables the autoupdater and non-essential traffic", () => { + withEnv(noHygieneVars, () => { + const env = claudeSpawnEnv() + assert.equal(env.DISABLE_AUTOUPDATER, "1") + assert.equal(env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, "1") + }) +}) + +test("claudeSpawnEnv never overrides a hygiene var the user set", () => { + withEnv( + { + DISABLE_AUTOUPDATER: "0", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "", + }, + () => { + const env = claudeSpawnEnv() + // "0" is how the CLI is told to keep autoupdating; we must not stomp it. + assert.equal(env.DISABLE_AUTOUPDATER, "0") + // An empty string reads as off to the CLI, so it is a real choice too. + assert.equal(env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, "") + }, + ) +}) + +test("cliHygieneEnv fills only the vars missing from the inherited env", () => { + assert.deepEqual(cliHygieneEnv({}), { + DISABLE_AUTOUPDATER: "1", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + }) + assert.deepEqual(cliHygieneEnv({ DISABLE_AUTOUPDATER: "0" }), { + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + }) + assert.deepEqual( + cliHygieneEnv({ + DISABLE_AUTOUPDATER: "1", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + }), + {}, + ) +}) + +test("the interactive transport gets the same hygiene as the headless spawn", () => { + withEnv(noHygieneVars, () => { + const env = interactiveSpawnEnv({ configDir: "/tmp/cfg" }) + for (const name of CLI_HYGIENE_ENV_VARS) { + assert.equal(env[name], "1", `interactive spawn is missing ${name}`) + } + // The env it already built is untouched. + assert.equal(env.CLAUDE_CONFIG_DIR, "/tmp/cfg") + assert.equal(env.TERM, "xterm-256color") + }) +}) + +test("the interactive transport also respects a hygiene var the user set", () => { + withEnv({ DISABLE_AUTOUPDATER: "0" }, () => { + const env = interactiveSpawnEnv({ configDir: "/tmp/cfg" }) + assert.equal(env.DISABLE_AUTOUPDATER, "0") + assert.equal(env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, "1") + }) +}) diff --git a/test-startup-diagnostics.ts b/test-startup-diagnostics.ts new file mode 100644 index 0000000..459cfe9 --- /dev/null +++ b/test-startup-diagnostics.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" +import { test } from "node:test" +import { claudeCodeProviders } from "./src/index.js" +import { resolveSpawnCwdFrom } from "./src/runtime-status.js" +import { + collectStartupDiagnostics, + describeSpawnCwd, + detectOpencodeVersion, + pickOpencodeVersion, + pluginVersion, + resetOpencodeVersionProbe, +} from "./src/startup-diagnostics.js" + +test("pluginVersion reads the real package manifest", () => { + const version = pluginVersion() + assert.match(version, /^\d+\.\d+\.\d+/) +}) + +test("describeSpawnCwd reports which branch resolveSpawnCwd would take", () => { + assert.deepEqual(describeSpawnCwd("/pinned", "/live", "/captured"), { + resolved: "/pinned", + source: "configured", + }) + assert.deepEqual(describeSpawnCwd(undefined, "/live/dir", "/captured"), { + resolved: "/live/dir", + source: "process", + }) + // The macOS GUI-launch fingerprint from issue #4: process.cwd() is "/". + assert.deepEqual(describeSpawnCwd(undefined, "/", "/captured/dir"), { + resolved: "/captured/dir", + source: "captured", + }) + assert.deepEqual(describeSpawnCwd(undefined, "/", undefined), { + resolved: "/", + source: "unresolved", + }) +}) + +test("describeSpawnCwd never disagrees with resolveSpawnCwd", () => { + const cases: Array<[string | undefined, string, string | undefined]> = [ + ["/pinned", "/live", "/captured"], + [undefined, "/live/dir", "/captured"], + [undefined, "/", "/captured/dir"], + [undefined, "/", undefined], + ] + for (const [configured, live, captured] of cases) { + assert.equal( + describeSpawnCwd(configured, live, captured).resolved, + resolveSpawnCwdFrom(configured, live, captured), + ) + } +}) + +test("pickOpencodeVersion probes known shapes and degrades to undefined", () => { + assert.equal(pickOpencodeVersion({ app: { version: "1.17.0" } }), "1.17.0") + assert.equal(pickOpencodeVersion({ version: "1.17.0" }), "1.17.0") + assert.equal(pickOpencodeVersion({ app: {} }), undefined) + assert.equal(pickOpencodeVersion({ app: { version: "" } }), undefined) + assert.equal(pickOpencodeVersion(undefined), undefined) + assert.equal(pickOpencodeVersion("nope"), undefined) +}) + +test("claudeCodeProviders keeps only this plugin's providers", () => { + const providers = claudeCodeProviders({ + "claude-code": { options: { cliPath: "claude" } }, + "claude-code-work": { options: { account: "work" } }, + anthropic: { options: { cliPath: "not-ours" } }, + "github-copilot": {}, + }) + assert.deepEqual(Object.keys(providers).sort(), [ + "claude-code", + "claude-code-work", + ]) +}) + +test("collectStartupDiagnostics summarizes account providers", () => { + const diagnostics = collectStartupDiagnostics( + { + "claude-code-work": { + options: { + account: "work", + cliPath: "/tmp/claude-work", + cwd: "/pinned/dir", + proxyTools: ["Bash", "Task"], + }, + }, + "claude-code-personal": { + options: { account: "personal", cliPath: "/tmp/claude-personal" }, + }, + }, + "1.17.0", + ) + + assert.equal(diagnostics.opencode, "1.17.0") + assert.equal(diagnostics.claudeCliPath, "/tmp/claude-work") + assert.deepEqual(diagnostics.accounts, ["work", "personal"]) + assert.deepEqual(diagnostics.proxyTools, ["Bash", "Task"]) + assert.deepEqual(diagnostics.cwd, { + resolved: "/pinned/dir", + source: "configured", + }) + assert.deepEqual(diagnostics.providers, [ + "claude-code-work", + "claude-code-personal", + ]) + assert.ok(Array.isArray(diagnostics.mcpServers)) +}) + +test("collectStartupDiagnostics falls back when options are absent", () => { + const diagnostics = collectStartupDiagnostics({ "claude-code": {} }) + + assert.equal(diagnostics.claudeCliPath, "claude") + assert.deepEqual(diagnostics.accounts, []) + assert.deepEqual(diagnostics.proxyTools, []) + assert.equal(diagnostics.cwd.source, "process") + // No opencode version handed in and none in the env → explicit "unknown", + // never a fabricated number. + if (!process.env.OPENCODE_VERSION) { + assert.equal(diagnostics.opencode, "unknown") + } +}) + +test("collectStartupDiagnostics reports interactive transport from env", () => { + const previous = process.env.CLAUDE_CODE_INTERACTIVE_TRANSPORT + try { + delete process.env.CLAUDE_CODE_INTERACTIVE_TRANSPORT + assert.equal( + collectStartupDiagnostics({ "claude-code": {} }).interactiveTransport, + false, + ) + assert.equal( + collectStartupDiagnostics({ + "claude-code": { options: { interactive: true } }, + }).interactiveTransport, + true, + ) + process.env.CLAUDE_CODE_INTERACTIVE_TRANSPORT = "1" + assert.equal( + collectStartupDiagnostics({ "claude-code": {} }).interactiveTransport, + true, + ) + } finally { + if (previous === undefined) delete process.env.CLAUDE_CODE_INTERACTIVE_TRANSPORT + else process.env.CLAUDE_CODE_INTERACTIVE_TRANSPORT = previous + } +}) + +test("detectOpencodeVersion reads the version from the opencode binary", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-version-probe-")) + const fake = path.join(dir, "opencode") + fs.writeFileSync(fake, '#!/bin/sh\necho "1.18.5"\n') + fs.chmodSync(fake, 0o755) + try { + resetOpencodeVersionProbe() + assert.equal(await detectOpencodeVersion(fake), "1.18.5") + // Cached: a second call with a different path reuses the first probe. + assert.equal(await detectOpencodeVersion("/nonexistent/opencode"), "1.18.5") + } finally { + resetOpencodeVersionProbe() + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test("detectOpencodeVersion refuses to report a non-opencode execPath", async () => { + try { + // Running from source means execPath is Bun; reporting Bun's version as + // opencode's would be actively misleading, so the probe declines. + resetOpencodeVersionProbe() + assert.equal(await detectOpencodeVersion("/opt/homebrew/bin/bun"), undefined) + } finally { + resetOpencodeVersionProbe() + } +}) + +test("detectOpencodeVersion returns undefined when the binary fails", async () => { + try { + resetOpencodeVersionProbe() + assert.equal(await detectOpencodeVersion("/nonexistent/dir/opencode"), undefined) + } finally { + resetOpencodeVersionProbe() + } +}) diff --git a/test-subagent-hint.ts b/test-subagent-hint.ts new file mode 100644 index 0000000..832d5b7 --- /dev/null +++ b/test-subagent-hint.ts @@ -0,0 +1,305 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { SUBAGENT_DISPATCH_HINT, QUESTION_PROXY_HINT } from "./src/claude-code-language-model.js" +import { + DEFAULT_PROXY_TOOLS, + extractAgentTypeList, + overlayTaskProxyDescription, + overlayQuestionProxyDescription, + filterQuestionProxyByOpencodeSupport, + disallowedToolFlags, + TASK_PROXY_NOTE, + TASK_BATCH_PROXY_NOTE, + TASK_BATCH_TOOL_NAME, + QUESTION_PROXY_NOTE, + type ProxyToolDef, +} from "./src/proxy-mcp.js" + +// Regression guard for the 2026-07-04 "subagents only write todos" report: +// opencode's @-mention hint says "call the task tool with subagent: X", and +// models resolved that to Claude Code's native TaskCreate (a todo tool), +// created a todo, and narrated a dispatch that never happened. The system +// hint must name the exact proxy tool, the ToolSearch recovery path for +// deferred tools, and explicitly defuse the TaskCreate near-miss. +test("subagent dispatch hint names the tool and defuses TaskCreate", () => { + assert.match(SUBAGENT_DISPATCH_HINT, /mcp__opencode_proxy__task/) + assert.match(SUBAGENT_DISPATCH_HINT, /ToolSearch/) + assert.match(SUBAGENT_DISPATCH_HINT, /select:mcp__opencode_proxy__task/) + assert.match(SUBAGENT_DISPATCH_HINT, /TaskCreate/) + assert.match(SUBAGENT_DISPATCH_HINT, /todo list/i) + assert.match(SUBAGENT_DISPATCH_HINT, /subagent_type/) + // The "don't grep configs to verify agents" guard (opus burned ~8 tool + // calls doing exactly that before dispatching). + assert.match(SUBAGENT_DISPATCH_HINT, /config files/i) +}) + +test("static task proxy def carries the disambiguation note", () => { + const task = DEFAULT_PROXY_TOOLS.find((t) => t.name === "task") + assert.ok(task, "task def missing from DEFAULT_PROXY_TOOLS") + assert.ok(task!.description.includes(TASK_PROXY_NOTE)) + assert.match(task!.description, /TaskCreate/) +}) + +// Shape of opencode's live `task` description: generic delegation advice +// first, the agent list LAST. Claude Code truncates long MCP descriptions, so +// overlaying the whole thing buries the list in the cut region — which is what +// made haiku guess `general-purpose`/`code-reviewer` and fail every dispatch +// (live check 2026-07-26). Only the list is kept, and it goes first. +const LIVE_TASK_DESCRIPTION = [ + "Launch a new agent to handle complex, multistep tasks autonomously.", + "", + "When NOT to use the Task tool:", + "- If you want to read a specific file path, use Read instead", + "", + "Usage notes:", + "1. Launch multiple agents concurrently whenever possible", + "", + "Available agent types and the tools they have access to:", + "- explore: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase. Specify a thoroughness level.", + "- glm: GLM 5.2", +].join("\n") + +test("extractAgentTypeList keeps the agent names and drops the preamble", () => { + const list = extractAgentTypeList(LIVE_TASK_DESCRIPTION)! + assert.ok(list, "no list extracted") + assert.match(list, /subagent_type/) + assert.match(list, /- explore:/) + assert.match(list, /- glm: GLM 5\.2/) + // opencode's generic advice is not carried over. + assert.ok(!list.includes("When NOT to use")) + assert.ok(!list.includes("Usage notes")) + // Long blurbs are trimmed with an ellipsis so the block stays small. + assert.match(list, /…/) +}) + +test("extractAgentTypeList declines when there is no parsable list", () => { + assert.equal(extractAgentTypeList(undefined), undefined) + assert.equal(extractAgentTypeList(" "), undefined) + assert.equal(extractAgentTypeList("Launch a new agent. No list here."), undefined) + // Heading present but no entries under it. + assert.equal( + extractAgentTypeList("Available agent types and the tools they have access to:"), + undefined, + ) +}) + +test("overlayTaskProxyDescription front-loads the agent list", () => { + const out = overlayTaskProxyDescription(DEFAULT_PROXY_TOOLS, LIVE_TASK_DESCRIPTION) + const task = out.find((t) => t.name === "task")! + // The list must come first: it has to survive Claude Code truncating the + // tail of a long MCP tool description. + assert.match(task.description.split("\n")[0], /subagent_type/) + assert.match(task.description, /- explore:/) + assert.ok(task.description.endsWith(TASK_PROXY_NOTE)) + // Budget guard for the same truncation: the whole description stays small. + assert.ok( + task.description.length < 1600, + `task description too long to survive truncation: ${task.description.length}`, + ) + // Other defs untouched (same object references). + const bashIn = DEFAULT_PROXY_TOOLS.find((t) => t.name === "bash")! + const bashOut = out.find((t) => t.name === "bash")! + assert.equal(bashOut, bashIn) + // Source array not mutated. + const original = DEFAULT_PROXY_TOOLS.find((t) => t.name === "task")! + assert.ok(!original.description.includes("subagent_type values")) +}) + +test("overlayTaskProxyDescription is a no-op without a usable description", () => { + assert.deepEqual( + overlayTaskProxyDescription(DEFAULT_PROXY_TOOLS, undefined), + DEFAULT_PROXY_TOOLS, + ) + assert.deepEqual( + overlayTaskProxyDescription(DEFAULT_PROXY_TOOLS, " "), + DEFAULT_PROXY_TOOLS, + ) + // Live description with no agent list: keep the static def rather than + // pasting opencode's preamble in front of it. + assert.deepEqual( + overlayTaskProxyDescription(DEFAULT_PROXY_TOOLS, "Launch a new agent."), + DEFAULT_PROXY_TOOLS, + ) +}) + +// --- question proxy: static def, live overlay, version gate ---------- + +test("static question proxy def is present and carries the disambiguation note", () => { + const question = DEFAULT_PROXY_TOOLS.find((t) => t.name === "question") + assert.ok(question, "question def missing from DEFAULT_PROXY_TOOLS") + assert.ok(question!.description.includes(QUESTION_PROXY_NOTE)) + // Schema must mirror opencode's Prompt struct: questions[].{question,header,options,multiple?}. + assert.equal(question!.inputSchema.type, "object") + const props = question!.inputSchema.properties as Record + assert.ok(props.questions, "questions property missing") + assert.deepEqual(question!.inputSchema.required, ["questions"]) + const item = props.questions.items.properties + assert.deepEqual( + Object.keys(item).sort(), + ["header", "multiple", "options", "question"], + ) + assert.deepEqual(item.options.items.required, ["label", "description"]) +}) + +test("overlayQuestionProxyDescription prepends live description, keeps the note", () => { + const live = + "Use this tool when you need to ask the user questions during execution." + const out = overlayQuestionProxyDescription(DEFAULT_PROXY_TOOLS, live) + const question = out.find((t) => t.name === "question")! + assert.ok(question.description.startsWith(live)) + assert.ok(question.description.endsWith(QUESTION_PROXY_NOTE)) + // Other defs untouched (same object references). + const bashIn = DEFAULT_PROXY_TOOLS.find((t) => t.name === "bash")! + const bashOut = out.find((t) => t.name === "bash")! + assert.equal(bashOut, bashIn) + // task def untouched too — overlay is question-scoped. + const taskOut = out.find((t) => t.name === "task")! + assert.ok(!taskOut.description.includes(live)) + // Source array not mutated. + const original = DEFAULT_PROXY_TOOLS.find((t) => t.name === "question")! + assert.ok(!original.description.includes("Use this tool")) +}) + +test("overlayQuestionProxyDescription is a no-op without a live description", () => { + assert.deepEqual( + overlayQuestionProxyDescription(DEFAULT_PROXY_TOOLS, undefined), + DEFAULT_PROXY_TOOLS, + ) + assert.deepEqual( + overlayQuestionProxyDescription(DEFAULT_PROXY_TOOLS, " "), + DEFAULT_PROXY_TOOLS, + ) + // Only-blank live must not blow away the static note-backed description. + const out = overlayQuestionProxyDescription(DEFAULT_PROXY_TOOLS, " ") + const question = out.find((t) => t.name === "question")! + assert.ok(question.description.includes(QUESTION_PROXY_NOTE)) +}) + +test("filterQuestionProxyByOpencodeSupport drops the def when unsupported", () => { + // Older opencode builds lack the `question` registry entry; keeping the + // def would render a forwarded call as `⚙ invalid`. + const out = filterQuestionProxyByOpencodeSupport(DEFAULT_PROXY_TOOLS, false) + assert.ok(!out.some((t) => t.name === "question")) + // Other defs preserved (bash/task/etc. untouched). + assert.ok(out.some((t) => t.name === "bash")) + assert.ok(out.some((t) => t.name === "task")) + assert.equal(out.length, DEFAULT_PROXY_TOOLS.length - 1) +}) + +test("filterQuestionProxyByOpencodeSupport keeps the def when supported", () => { + assert.deepEqual( + filterQuestionProxyByOpencodeSupport(DEFAULT_PROXY_TOOLS, true), + DEFAULT_PROXY_TOOLS, + ) + // Works on a filtered subset too. + const subset: ProxyToolDef[] = [ + DEFAULT_PROXY_TOOLS.find((t) => t.name === "question")!, + DEFAULT_PROXY_TOOLS.find((t) => t.name === "bash")!, + ] + assert.deepEqual( + filterQuestionProxyByOpencodeSupport(subset, true), + subset, + ) +}) + +test("filterQuestionProxyByOpencodeSupport is a no-op when no question def is present", () => { + const noQuestion = DEFAULT_PROXY_TOOLS.filter((t) => t.name !== "question") + assert.deepEqual( + filterQuestionProxyByOpencodeSupport(noQuestion, false), + noQuestion, + ) +}) + +// Critical regression guard: the spawn site must compute --disallowedTools +// from the POST-FILTER proxy list, not the pre-filter one. When the +// version gate drops `question` (older opencode without the registry +// entry), AskUserQuestion must NOT be disabled — otherwise the native +// tool is gone AND the proxy replacement is absent, leaving the model +// unable to ask questions at all. This test pins the invariant by +// simulating the exact filter-then-flag sequence the spawn site runs. +test("version gate + disallowedToolFlags: dropping question also drops AskUserQuestion disable", () => { + // A config that proxies question alongside the standard tools. + const resolved = [ + DEFAULT_PROXY_TOOLS.find((t) => t.name === "bash")!, + DEFAULT_PROXY_TOOLS.find((t) => t.name === "question")!, + ] + + // Supported opencode: question stays → AskUserQuestion is disabled. + const supported = filterQuestionProxyByOpencodeSupport(resolved, true) + assert.ok(supported.some((t) => t.name === "question")) + const supportedFlags = disallowedToolFlags(supported) + assert.ok(supportedFlags.includes("AskUserQuestion")) + + // Unsupported opencode: question is dropped → AskUserQuestion must NOT + // be in the disallowed list, so the deny/markdown fallback path stays + // reachable. The pre-filter array would still have it — the bug. + const unsupported = filterQuestionProxyByOpencodeSupport(resolved, false) + assert.ok(!unsupported.some((t) => t.name === "question")) + const unsupportedFlags = disallowedToolFlags(unsupported) + assert.ok(!unsupportedFlags.includes("AskUserQuestion")) + // Sanity: bash is still disabled in both cases. + assert.ok(unsupportedFlags.includes("Bash")) +}) + +test("no empty proxy server: combined list is empty when all defs are filtered out", () => { + // proxyTools: ["Question"] on unsupported opencode → the version gate + // drops the only def, leaving an empty array. The spawn site must treat + // this as "no proxy" (null), not start a server with zero tools. + const onlyQuestion = [DEFAULT_PROXY_TOOLS.find((t) => t.name === "question")!] + const filtered = filterQuestionProxyByOpencodeSupport(onlyQuestion, false) + assert.equal(filtered.length, 0) + // The caller checks combinedList.length > 0 — pin that an empty filtered + // array is indeed length 0, not truthy-but-empty. + assert.equal(filtered.length > 0, false) +}) + +// Regression guard for the 2026-07-05 haiku test: the model's reasoning +// correctly identified mcp__opencode_proxy__question but then emitted a +// tool call for bare `question` (stripping the MCP prefix), which +// opencode rejected as "Model tried to call unavailable tool 'question'". +// The hint must name the exact full tool name and explicitly forbid the +// bare short name. +test("question proxy hint names the exact MCP tool and defuses bare 'question'", () => { + assert.match(QUESTION_PROXY_HINT, /mcp__opencode_proxy__question/) + assert.match(QUESTION_PROXY_HINT, /select:mcp__opencode_proxy__question/) + // Must explicitly warn against calling bare `question`. + assert.match(QUESTION_PROXY_HINT, /Do NOT call bare `question`/) + // Must mention that AskUserQuestion is disabled. + assert.match(QUESTION_PROXY_HINT, /AskUserQuestion/) + assert.match(QUESTION_PROXY_HINT, /disabled/i) +}) + +// --- task_batch: the concurrency path (from @broskees' 68ed142) ---------------- + +test("subagent dispatch hint names task_batch as the way to run subagents concurrently", () => { + assert.match(SUBAGENT_DISPATCH_HINT, /mcp__opencode_proxy__task_batch/) + assert.match(SUBAGENT_DISPATCH_HINT, /one at a time|serially/) + assert.match(SUBAGENT_DISPATCH_HINT, /`tasks` array/) + // The single-subagent tool is still named in full, first. + assert.ok( + SUBAGENT_DISPATCH_HINT.indexOf("mcp__opencode_proxy__task`") < SUBAGENT_DISPATCH_HINT.indexOf("mcp__opencode_proxy__task_batch"), + ) +}) + +test("task and task_batch point at each other and both disable only Agent", () => { + const task = DEFAULT_PROXY_TOOLS.find((t) => t.name === "task")! + const batch = DEFAULT_PROXY_TOOLS.find((t) => t.name === TASK_BATCH_TOOL_NAME)! + assert.match(task.description, /task_batch/) + assert.ok(batch.description.endsWith(TASK_BATCH_PROXY_NOTE)) + assert.match(batch.description, /one at a time/) + assert.deepEqual(disallowedToolFlags([task, batch]), ["Agent"], "the CLI's own Agent is disabled once, not twice") +}) + +test("the agent-list overlay lands on task_batch too, within the truncation budget", () => { + const out = overlayTaskProxyDescription(DEFAULT_PROXY_TOOLS, LIVE_TASK_DESCRIPTION) + const batch = out.find((t) => t.name === TASK_BATCH_TOOL_NAME)! + assert.match(batch.description.split("\n")[0], /subagent_type/) + assert.match(batch.description, /- explore:/) + assert.ok(batch.description.endsWith(TASK_BATCH_PROXY_NOTE)) + assert.ok( + batch.description.length < 1600, + `task_batch description too long to survive truncation: ${batch.description.length}`, + ) + const task = out.find((t) => t.name === "task")! + assert.ok(task.description.length < 1600, `task description too long: ${task.description.length}`) +}) diff --git a/test-todo-ledger.ts b/test-todo-ledger.ts new file mode 100644 index 0000000..75c52c2 --- /dev/null +++ b/test-todo-ledger.ts @@ -0,0 +1,188 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { + _resetAllLedgersForTests, + applyTaskCreateToolResult, + applyTaskCreateToolUse, + applyTaskUpdate, + clearLedger, + getLedger, + MAX_LEDGER_SESSIONS, +} from "./src/todo-ledger.js" + +test("empty ledger for new sessionId", () => { + _resetAllLedgersForTests() + assert.deepEqual(getLedger("s-empty"), []) +}) + +test("TaskCreate tool_use stashes pending; ledger stays empty until result", () => { + _resetAllLedgersForTests() + applyTaskCreateToolUse("s1", "tu-1", { subject: "Write tests" }) + assert.deepEqual(getLedger("s1"), []) +}) + +test("TaskCreate tool_result commits entry with parsed claude id and returns full list", () => { + _resetAllLedgersForTests() + applyTaskCreateToolUse("s2", "tu-1", { subject: "Write tests" }) + const list = applyTaskCreateToolResult("s2", "tu-1", "Task #1 created successfully: Write tests") + assert.deepEqual(list, [{ id: "1", content: "Write tests", status: "pending" }]) + assert.deepEqual(getLedger("s2"), [{ id: "1", content: "Write tests", status: "pending" }]) +}) + +test("TaskCreate tool_result with unknown tool_use_id returns null and does not mutate", () => { + _resetAllLedgersForTests() + applyTaskCreateToolUse("s3", "tu-1", { subject: "Write tests" }) + const list = applyTaskCreateToolResult("s3", "tu-unknown", "Task #1 created successfully") + assert.equal(list, null) + assert.deepEqual(getLedger("s3"), []) +}) + +test("TaskCreate tool_result with malformed text returns null and drops pending", () => { + _resetAllLedgersForTests() + applyTaskCreateToolUse("s4", "tu-1", { subject: "Write tests" }) + const list = applyTaskCreateToolResult("s4", "tu-1", "unrelated output text") + assert.equal(list, null) + assert.deepEqual(getLedger("s4"), []) +}) + +test("multiple TaskCreate calls accumulate in insertion order", () => { + _resetAllLedgersForTests() + applyTaskCreateToolUse("s5", "tu-a", { subject: "First" }) + applyTaskCreateToolResult("s5", "tu-a", "Task #1 created successfully") + applyTaskCreateToolUse("s5", "tu-b", { subject: "Second" }) + applyTaskCreateToolResult("s5", "tu-b", "Task #2 created successfully") + applyTaskCreateToolUse("s5", "tu-c", { subject: "Third" }) + applyTaskCreateToolResult("s5", "tu-c", "Task #3 created successfully") + assert.deepEqual( + getLedger("s5").map((t) => `${t.id}:${t.content}`), + ["1:First", "2:Second", "3:Third"], + ) +}) + +test("TaskUpdate flips status and preserves content", () => { + _resetAllLedgersForTests() + applyTaskCreateToolUse("s6", "tu-1", { subject: "Write tests" }) + applyTaskCreateToolResult("s6", "tu-1", "Task #1 created successfully") + const list = applyTaskUpdate("s6", { taskId: "1", status: "in_progress" }) + assert.deepEqual(list, [{ id: "1", content: "Write tests", status: "in_progress" }]) +}) + +test("TaskUpdate with subject overrides content", () => { + _resetAllLedgersForTests() + applyTaskCreateToolUse("s7", "tu-1", { subject: "Old" }) + applyTaskCreateToolResult("s7", "tu-1", "Task #1 created successfully") + applyTaskUpdate("s7", { taskId: "1", subject: "New" }) + assert.deepEqual(getLedger("s7"), [{ id: "1", content: "New", status: "pending" }]) +}) + +test("TaskUpdate(status='deleted') removes the entry", () => { + _resetAllLedgersForTests() + applyTaskCreateToolUse("s8", "tu-1", { subject: "Keep" }) + applyTaskCreateToolResult("s8", "tu-1", "Task #1 created successfully") + applyTaskCreateToolUse("s8", "tu-2", { subject: "Drop" }) + applyTaskCreateToolResult("s8", "tu-2", "Task #2 created successfully") + const list = applyTaskUpdate("s8", { taskId: "2", status: "deleted" }) + assert.deepEqual(list, [{ id: "1", content: "Keep", status: "pending" }]) +}) + +test("TaskUpdate for unknown taskId returns null without crashing", () => { + _resetAllLedgersForTests() + const list = applyTaskUpdate("s9", { taskId: "99", status: "completed" }) + assert.equal(list, null) + assert.deepEqual(getLedger("s9"), []) +}) + +test("TaskUpdate with invalid status is ignored (status unchanged, no crash)", () => { + _resetAllLedgersForTests() + applyTaskCreateToolUse("s10", "tu-1", { subject: "Stay pending" }) + applyTaskCreateToolResult("s10", "tu-1", "Task #1 created successfully") + const list = applyTaskUpdate("s10", { taskId: "1", status: "nonsense" }) + assert.deepEqual(list, [{ id: "1", content: "Stay pending", status: "pending" }]) +}) + +test("two sessionIds are isolated", () => { + _resetAllLedgersForTests() + applyTaskCreateToolUse("alpha", "tu-1", { subject: "Alpha-1" }) + applyTaskCreateToolResult("alpha", "tu-1", "Task #1 created successfully") + applyTaskCreateToolUse("beta", "tu-1", { subject: "Beta-1" }) + applyTaskCreateToolResult("beta", "tu-1", "Task #1 created successfully") + assert.deepEqual(getLedger("alpha"), [{ id: "1", content: "Alpha-1", status: "pending" }]) + assert.deepEqual(getLedger("beta"), [{ id: "1", content: "Beta-1", status: "pending" }]) +}) + +test("clearLedger wipes one session, leaves others intact", () => { + _resetAllLedgersForTests() + applyTaskCreateToolUse("keep", "tu-1", { subject: "Keep me" }) + applyTaskCreateToolResult("keep", "tu-1", "Task #1 created successfully") + applyTaskCreateToolUse("toss", "tu-1", { subject: "Toss me" }) + applyTaskCreateToolResult("toss", "tu-1", "Task #1 created successfully") + clearLedger("toss") + assert.deepEqual(getLedger("toss"), []) + assert.deepEqual(getLedger("keep"), [{ id: "1", content: "Keep me", status: "pending" }]) +}) + +test("subject fallback: empty subject → description → '(no subject)'", () => { + _resetAllLedgersForTests() + applyTaskCreateToolUse("fb1", "tu-1", { subject: "", description: "Has desc" }) + applyTaskCreateToolResult("fb1", "tu-1", "Task #1 created successfully") + assert.equal(getLedger("fb1")[0]?.content, "Has desc") + + applyTaskCreateToolUse("fb2", "tu-1", { subject: " ", description: " " }) + applyTaskCreateToolResult("fb2", "tu-1", "Task #1 created successfully") + assert.equal(getLedger("fb2")[0]?.content, "(no subject)") + + applyTaskCreateToolUse("fb3", "tu-1", undefined) + applyTaskCreateToolResult("fb3", "tu-1", "Task #1 created successfully") + assert.equal(getLedger("fb3")[0]?.content, "(no subject)") +}) + +test("regex tolerates spacing variants (Task #N / Task N / Task#N)", () => { + _resetAllLedgersForTests() + applyTaskCreateToolUse("rx", "tu-a", { subject: "A" }) + assert.ok(applyTaskCreateToolResult("rx", "tu-a", "Task #7 created successfully")) + applyTaskCreateToolUse("rx", "tu-b", { subject: "B" }) + assert.ok(applyTaskCreateToolResult("rx", "tu-b", "Task 8 created")) + applyTaskCreateToolUse("rx", "tu-c", { subject: "C" }) + assert.ok(applyTaskCreateToolResult("rx", "tu-c", "Task#9 created successfully")) + assert.deepEqual( + getLedger("rx").map((t) => t.id), + ["7", "8", "9"], + ) +}) + +test("stale pendingCreates are pruned on next applyTaskCreateToolUse", async () => { + _resetAllLedgersForTests() + const realNow = Date.now + let fakeNow = 1_000_000 + Date.now = () => fakeNow + + try { + applyTaskCreateToolUse("ttl", "tu-stale", { subject: "Stale" }) + fakeNow += 120_000 + applyTaskCreateToolUse("ttl", "tu-fresh", { subject: "Fresh" }) + const list = applyTaskCreateToolResult("ttl", "tu-stale", "Task #1 created successfully") + assert.equal(list, null, "stale tool_use should have been pruned before result arrived") + const freshList = applyTaskCreateToolResult("ttl", "tu-fresh", "Task #2 created successfully") + assert.deepEqual(freshList, [{ id: "2", content: "Fresh", status: "pending" }]) + } finally { + Date.now = realNow + } +}) + +test("the ledger map is capped, dropping the oldest session first", () => { + _resetAllLedgersForTests() + const total = MAX_LEDGER_SESSIONS + 10 + for (let i = 0; i < total; i++) { + applyTaskCreateToolUse(`cap-${i}`, "tu-1", { subject: `Task ${i}` }) + applyTaskCreateToolResult(`cap-${i}`, "tu-1", `Task #${i + 1} created`) + } + + // The first ten are gone; the rest are all still there, so the cap sheds + // in insertion order rather than clearing the map. + for (let i = 0; i < 10; i++) { + assert.deepEqual(getLedger(`cap-${i}`), [], `cap-${i} should have been evicted`) + } + for (let i = 10; i < total; i++) { + assert.equal(getLedger(`cap-${i}`).length, 1, `cap-${i} should have survived`) + } +}) diff --git a/test-tool-block-index.ts b/test-tool-block-index.ts new file mode 100644 index 0000000..4865b07 --- /dev/null +++ b/test-tool-block-index.ts @@ -0,0 +1,158 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { createClaudeCode } from "./src/index.js" +import { deleteActiveProcess, sessionKey } from "./src/session-manager.js" + +// Claude CLI restarts content-block indices at 0 on every assistant message, +// and one turn holds several of them (tool_use -> tool_result -> answer). +// This fake emits a tool_use at index 0, then reuses index 0 for the answer +// text in the next message, which is the exact shape that made a subagent's +// `task` call report "Tool execution aborted" while the child answered fine. +function createFakeIndexReuseCli() { + const cwd = mkdtempSync(join(tmpdir(), "opencode-block-index-")) + const cliPath = join(cwd, "fake-claude.cjs") + const source = `#!/usr/bin/env node +const readline = require("node:readline") + +if (process.argv.includes("--version")) { + process.stdout.write("2.1.142\\n") + process.exit(0) +} + +const emit = (value) => process.stdout.write(JSON.stringify(value) + "\\n") +const event = (value) => + emit({ type: "stream_event", session_id: "fake-session", event: value }) + +const rl = readline.createInterface({ input: process.stdin }) +let answered = false +rl.on("line", () => { + if (answered) return + answered = true + + emit({ type: "system", subtype: "init", session_id: "fake-session" }) + + // Assistant message 1: tool_use occupies block index 0. + event({ type: "message_start", message: { role: "assistant" } }) + event({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "toolu_probe", name: "Read" }, + }) + event({ + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"file_path":"/tmp/probe.json"}' }, + }) + event({ type: "content_block_stop", index: 0 }) + + // Claude ran Read itself and reports the result. + emit({ + type: "user", + session_id: "fake-session", + message: { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "toolu_probe", content: "probe file body" }, + ], + }, + }) + + // Assistant message 2: the answer text REUSES block index 0. + event({ type: "message_start", message: { role: "assistant" } }) + event({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }) + event({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "PROBE-OK" }, + }) + event({ type: "content_block_stop", index: 0 }) + event({ type: "message_delta", delta: { stop_reason: "end_turn" } }) + + emit({ + type: "result", + subtype: "success", + session_id: "fake-session", + is_error: false, + result: "PROBE-OK", + }) +}) +` + writeFileSync(cliPath, source) + chmodSync(cliPath, 0o755) + return { cliPath, cwd } +} + +async function streamIndexReuse() { + const fake = createFakeIndexReuseCli() + const modelId = "claude-test-block-index" + const sk = sessionKey( + fake.cwd, + `${modelId}::tools::default::context=["claude-code",null]`, + ) + + try { + const model = createClaudeCode({ + cliPath: fake.cliPath, + cwd: fake.cwd, + bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, + proxyTools: [], + }).languageModel(modelId) + + const response = await model.doStream({ + prompt: [ + { role: "user", content: [{ type: "text", text: "Read the probe file." }] }, + ], + // Presence of tools is what selects the real streaming path; without it + // doStream falls through to the no-tools title stub. + tools: [ + { + type: "function", + name: "read", + description: "Read a file", + inputSchema: { type: "object", properties: {} }, + }, + ], + } as any) + + const parts: any[] = [] + for await (const part of response.stream) parts.push(part) + return parts + } finally { + deleteActiveProcess(sk) + rmSync(fake.cwd, { recursive: true, force: true }) + } +} + +test("a reused content-block index does not re-emit a completed tool call", async () => { + const parts = await streamIndexReuse() + + const toolCalls = parts.filter( + (part) => part.type === "tool-call" && part.toolCallId === "toolu_probe", + ) + // Without the toolCallMap.delete(idx) at content_block_stop this is 2: the + // answer text's block_stop in message 2 finds the stale message-1 entry at + // the same index. opencode then holds a second part for a callID it already + // completed, never gets a result for it, and aborts it at stream end. + assert.equal( + toolCalls.length, + 1, + `expected exactly one tool-call for toolu_probe, got ${toolCalls.length}`, + ) + + const toolResults = parts.filter( + (part) => part.type === "tool-result" && part.toolCallId === "toolu_probe", + ) + assert.equal(toolResults.length, 1) + + // The answer text still comes through, and the turn still ends cleanly. + const text = parts + .filter((part) => part.type === "text-delta") + .map((part) => part.delta) + .join("") + assert.match(text, /PROBE-OK/) +}) diff --git a/test-tool-mapping.ts b/test-tool-mapping.ts new file mode 100644 index 0000000..65ad891 --- /dev/null +++ b/test-tool-mapping.ts @@ -0,0 +1,170 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { + _resetAllLedgersForTests, + applyTaskCreateToolResult, + getLedger, +} from "./src/todo-ledger.js" +import { + mapTool, + isWebSearchTool, + isWebSearchHandledByCli, + singleQuoteForShell, +} from "./src/tool-mapping.js" +import { execFileSync } from "node:child_process" + +test("WebSearch with default routing is skipped, not forwarded (no opencode registry entry)", () => { + for (const route of [undefined, "claude" as const, "disabled" as const]) { + const result = mapTool("WebSearch", { query: "anthropic pricing" }, { webSearch: route }) + assert.equal(result.skip, true, `route=${route} should skip`) + assert.equal(result.executed, true, `route=${route} runs inside Claude CLI`) + assert.equal(result.name, "WebSearch") + assert.deepEqual(result.input, { query: "anthropic pricing" }) + } +}) + +test("WebSearch routed to an opencode tool is forwarded for opencode to execute", () => { + const result = mapTool( + "web_search", + { query: "anthropic pricing", extra: "dropped" }, + { webSearch: "websearch_web_search_exa" }, + ) + assert.equal(result.skip, undefined) + assert.equal(result.executed, false) + assert.equal(result.name, "websearch_web_search_exa") + assert.deepEqual(result.input, { query: "anthropic pricing" }) +}) + +test("isWebSearchTool / isWebSearchHandledByCli helpers", () => { + assert.equal(isWebSearchTool("WebSearch"), true) + assert.equal(isWebSearchTool("web_search"), true) + assert.equal(isWebSearchTool("WebFetch"), false) + assert.equal(isWebSearchHandledByCli(undefined), true) + assert.equal(isWebSearchHandledByCli("claude"), true) + assert.equal(isWebSearchHandledByCli("disabled"), true) + assert.equal(isWebSearchHandledByCli("websearch_web_search_exa"), false) +}) + +test("Read-only Claude CLI Task* tools are still skipped, not forwarded", () => { + for (const name of ["TaskList", "TaskGet", "TaskStop"]) { + const result = mapTool(name, { foo: "bar" }) + assert.equal(result.skip, true, `${name} should be skipped`) + assert.equal(result.executed, true, `${name} should be marked executed`) + assert.equal(result.name, name, `${name} should preserve the original name for logging`) + } +}) + +test("TaskCreate without sessionId falls back to skip (preserves pre-ledger safety)", () => { + _resetAllLedgersForTests() + const result = mapTool("TaskCreate", { subject: "x" }) + assert.equal(result.skip, true) + assert.equal(result.executed, true) + assert.equal(result.name, "TaskCreate") +}) + +test("TaskUpdate without sessionId falls back to skip", () => { + _resetAllLedgersForTests() + const result = mapTool("TaskUpdate", { taskId: "1", status: "in_progress" }) + assert.equal(result.skip, true) + assert.equal(result.executed, true) + assert.equal(result.name, "TaskUpdate") +}) + +test("TaskCreate tool_use with sessionId stashes pending and returns skip (no emission yet)", () => { + _resetAllLedgersForTests() + const result = mapTool( + "TaskCreate", + { subject: "Write tests" }, + { sessionId: "tm-1", toolUseId: "tu-1" }, + ) + assert.equal(result.skip, true) + assert.deepEqual(getLedger("tm-1"), [], "ledger remains empty until tool_result commits") +}) + +test("TaskUpdate with sessionId emits todowrite when task is known", () => { + _resetAllLedgersForTests() + mapTool("TaskCreate", { subject: "Step one" }, { sessionId: "tm-2", toolUseId: "tu-1" }) + applyTaskCreateToolResult("tm-2", "tu-1", "Task #1 created successfully") + + const result = mapTool( + "TaskUpdate", + { taskId: "1", status: "in_progress" }, + { sessionId: "tm-2" }, + ) + assert.equal(result.skip, undefined) + assert.equal(result.executed, false) + assert.equal(result.name, "todowrite") + assert.deepEqual(result.input, { + todos: [{ id: "1", content: "Step one", status: "in_progress", priority: "medium" }], + }) +}) + +test("TaskUpdate with sessionId returns skip when task id is unknown to the ledger", () => { + _resetAllLedgersForTests() + const result = mapTool( + "TaskUpdate", + { taskId: "999", status: "completed" }, + { sessionId: "tm-3" }, + ) + assert.equal(result.skip, true) + assert.equal(result.executed, true) + assert.equal(result.name, "TaskUpdate") +}) + +test("TaskOutput is still surfaced as a bash call (not internalized)", () => { + const result = mapTool("TaskOutput", { content: "hello" }) + assert.equal(result.skip, undefined) + assert.equal(result.executed, false) + assert.equal(result.name, "bash") + assert.ok(typeof result.input?.command === "string") + assert.ok(result.input.command.includes("hello")) +}) + +// Issue #27: the payload is model-controlled and opencode really runs the +// command, so anything the shell expands inside it is executed while the +// operator sees something that reads like a print. +test("TaskOutput payloads are not expanded by the shell", () => { + const payloads = [ + "X$(id -u)Y", + "X`id -u`Y", + "X${HOME}Y", + "it's got a quote", + 'and a "double" quote', + "semi; echo pwned", + ] + + for (const content of payloads) { + const command = mapTool("TaskOutput", { content }).input.command as string + const printed = execFileSync("bash", ["-c", command], { + encoding: "utf8", + env: { ...process.env, HOME: "/should-not-appear" }, + }) + assert.equal( + printed, + `TASK OUTPUT: ${content}\n`, + `payload must reach the operator verbatim: ${content}`, + ) + } +}) + +test("singleQuoteForShell survives an embedded single quote", () => { + const quoted = singleQuoteForShell("a'b") + const printed = execFileSync("bash", ["-c", `printf '%s' ${quoted}`], { + encoding: "utf8", + }) + assert.equal(printed, "a'b") +}) + +test("Pre-existing internal tools still skip", () => { + for (const name of ["ToolSearch", "Agent", "AskFollowupQuestion"]) { + const result = mapTool(name) + assert.equal(result.skip, true, `${name} should remain skipped`) + } +}) + +test("TodoWrite path is unaffected by the Task* ledger additions", () => { + const result = mapTool("TodoWrite", { todos: [{ id: "1", content: "x", status: "pending" }] }) + assert.equal(result.skip, undefined) + assert.equal(result.executed, false) + assert.equal(result.name, "todowrite") +}) diff --git a/test-turn-stats.ts b/test-turn-stats.ts new file mode 100644 index 0000000..7a88904 --- /dev/null +++ b/test-turn-stats.ts @@ -0,0 +1,142 @@ +/** + * Per-turn cost and cache stats: the pure formatter, the `turnStats` option + * default, and the strip that keeps the footer out of a rebuilt transcript. + * + * Usage: npx tsx --test test-turn-stats.ts + */ +import assert from "node:assert/strict" +import { test } from "node:test" +import { createClaudeCode } from "./src/index.js" +import { filterSideQuestionHistory } from "./src/message-builder.js" +import { + TURN_STATS_MARKER, + extractTurnStats, + formatCost, + formatDuration, + formatTokens, + formatTurnStatsBlock, + formatTurnStatsLine, + turnStatsLogPayload, +} from "./src/turn-stats.js" +import type { ClaudeStreamMessage } from "./src/types.js" + +const fullResult: ClaudeStreamMessage = { + type: "result", + subtype: "success", + total_cost_usd: 0.01234, + duration_ms: 4234, + duration_api_ms: 3900, + num_turns: 2, + usage: { + input_tokens: 1234, + output_tokens: 812, + cache_read_input_tokens: 45_120, + cache_creation_input_tokens: 2048, + }, + modelUsage: { "claude-opus-5": { inputTokens: 1234, outputTokens: 812 } }, + permission_denials: [{ tool_name: "Bash", tool_use_id: "toolu_1" }], +} + +test("extractTurnStats keeps everything the result line carries", () => { + const stats = extractTurnStats(fullResult) + assert.equal(stats.costUsd, 0.01234) + assert.equal(stats.durationMs, 4234) + assert.equal(stats.durationApiMs, 3900) + assert.equal(stats.numTurns, 2) + assert.equal(stats.inputTokens, 1234) + assert.equal(stats.outputTokens, 812) + assert.equal(stats.cacheReadTokens, 45_120) + assert.equal(stats.cacheWriteTokens, 2048) + assert.deepEqual(stats.modelUsage, { + "claude-opus-5": { inputTokens: 1234, outputTokens: 812 }, + }) + assert.equal(stats.permissionDenials?.length, 1) +}) + +test("the footer reads as one compact line", () => { + assert.equal( + formatTurnStatsLine(extractTurnStats(fullResult)), + `${TURN_STATS_MARKER} $0.0123 · 4.2 s · 2 CLI turns · in 1.2k · out 812 · cache read 45.1k · cache write 2.0k · 1 permission denial`, + ) +}) + +test("rounding keeps the digits that carry information", () => { + assert.equal(formatCost(0.0001234), "$0.0001") + assert.equal(formatCost(0), "$0.0000") + assert.equal(formatCost(12.3456), "$12.35") + assert.equal(formatCost(-1), "$0.00") + + assert.equal(formatDuration(430), "0.4 s") + assert.equal(formatDuration(4234), "4.2 s") + assert.equal(formatDuration(95_000), "1m 35s") + + assert.equal(formatTokens(0), "0") + assert.equal(formatTokens(812), "812") + assert.equal(formatTokens(1234), "1.2k") + assert.equal(formatTokens(1_500_000), "1.5M") +}) + +test("missing and zero fields are dropped, not printed as zeroes", () => { + const line = formatTurnStatsLine( + extractTurnStats({ + type: "result", + subtype: "success", + total_cost_usd: 0.002, + duration_ms: 900, + num_turns: 1, + usage: { + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }), + ) + assert.equal(line, `${TURN_STATS_MARKER} $0.0020 · 0.9 s · 1 CLI turn · in 10 · out 5`) + assert.equal(line!.includes("cache"), false) + assert.equal(line!.includes("denial"), false) +}) + +test("a result with no usable numbers produces no footer at all", () => { + assert.equal(formatTurnStatsLine(extractTurnStats({ type: "result" })), null) + assert.equal(formatTurnStatsBlock(extractTurnStats({ type: "result" })), null) +}) + +test("the log payload is emitted whether or not the footer is", () => { + const payload = turnStatsLogPayload(extractTurnStats(fullResult)) + assert.equal(payload.costUsd, 0.01234) + assert.equal(payload.durationApiMs, 3900) + assert.equal(payload.permissionDenials, 1) + assert.deepEqual(payload.modelUsage, { + "claude-opus-5": { inputTokens: 1234, outputTokens: 812 }, + }) + const empty = turnStatsLogPayload(extractTurnStats({ type: "result" })) + assert.equal(empty.costUsd, null) + assert.equal(empty.permissionDenials, 0) +}) + +test("the footer is stripped from a transcript rebuilt for the CLI", () => { + const footer = formatTurnStatsBlock(extractTurnStats(fullResult))! + const prompt = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [ + { type: "text", text: "the answer" }, + { type: "text", text: footer }, + ], + }, + ] as any + + const filtered = filterSideQuestionHistory(prompt) + assert.equal(filtered.length, 2) + assert.deepEqual((filtered[1] as any).content, [{ type: "text", text: "the answer" }]) +}) + +test("turnStats is off unless the provider option asks for it", () => { + assert.equal((createClaudeCode({})("claude-sonnet-5") as any).config.turnStats, false) + assert.equal( + (createClaudeCode({ turnStats: true })("claude-sonnet-5") as any).config.turnStats, + true, + ) +}) diff --git a/test-unattended-replay.ts b/test-unattended-replay.ts new file mode 100644 index 0000000..0d8f7aa --- /dev/null +++ b/test-unattended-replay.ts @@ -0,0 +1,127 @@ +/** + * Regression for the unattended-stdout replay path in + * src/claude-code-language-model.ts. When a reused Claude CLI subprocess + * emitted output while no turn was listening (e.g. the previous turn's + * stream already closed), that output is replayed as narration at the start + * of the next turn. The replay loop must reuse a single open text block + * across all replayed deltas — like the live streaming path does — instead + * of opening a fresh block per delta, which shreds the message mid-word. + * + * Usage: + * npx tsx --test test-unattended-replay.ts + */ +import assert from "node:assert/strict" +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { test } from "node:test" + +import { createClaudeCode } from "./src/index.js" + +function createFixture() { + const cwd = mkdtempSync(join(tmpdir(), "opencode-unattended-replay-")) + const cliPath = join(cwd, "fake-claude.cjs") + writeFileSync( + cliPath, + `#!/usr/bin/env node +const readline = require("node:readline") +if (process.argv.includes("--version")) { + process.stdout.write("2.1.258\\n") + process.exit(0) +} +let turn = 0 +readline.createInterface({ input: process.stdin }).on("line", () => { + turn++ + if (turn === 1) { + process.stdout.write(JSON.stringify({ + type: "assistant", + session_id: "fake-session", + message: { role: "assistant", content: [{ type: "text", text: "First answer." }] }, + }) + "\\n") + process.stdout.write(JSON.stringify({ type: "result", subtype: "success", session_id: "fake-session" }) + "\\n") + // Written after this turn's stream has already closed on the plugin + // side — nobody is listening, so this becomes "unattended" output that + // the next turn must replay. + setTimeout(() => { + process.stdout.write(JSON.stringify({ type: "content_block_delta", delta: { type: "text_delta", text: "ver" } }) + "\\n") + process.stdout.write(JSON.stringify({ type: "content_block_delta", delta: { type: "text_delta", text: "ification call timed out (likely a st" } }) + "\\n") + process.stdout.write(JSON.stringify({ type: "content_block_delta", delta: { type: "text_delta", text: "alled permission prompt)" } }) + "\\n") + }, 200) + return + } + process.stdout.write(JSON.stringify({ + type: "assistant", + session_id: "fake-session", + message: { role: "assistant", content: [{ type: "text", text: "Second answer." }] }, + }) + "\\n") + process.stdout.write(JSON.stringify({ type: "result", subtype: "success", session_id: "fake-session" }) + "\\n") + process.exit(0) +}) +`, + ) + chmodSync(cliPath, 0o755) + return { cwd, cliPath } +} + +async function runTurn(model: any, prompt: any[]) { + const response = await model.doStream({ + prompt, + tools: [ + { + type: "function", + name: "bash", + description: "Run a command", + inputSchema: { type: "object", properties: {} }, + }, + ], + } as any) + const parts: any[] = [] + for await (const part of response.stream) parts.push(part) + return parts +} + +test("a reused process's unattended output replays as a single text block, not one per delta", { + timeout: 10_000, +}, async () => { + const fixture = createFixture() + try { + const model = createClaudeCode({ + cliPath: fixture.cliPath, + cwd: fixture.cwd, + bridgeOpencodeMcp: false, + proxyOpencodeMcpTools: false, + proxyTools: [], + }).languageModel("claude-test-unattended-replay") + + await runTurn(model, [{ role: "user", content: [{ type: "text", text: "First message." }] }]) + // Give the fake CLI time to emit its between-turns output while nobody + // is listening, before the next turn attaches a new listener. + await new Promise((resolve) => setTimeout(resolve, 500)) + + // Prior conversation turns must be present, or doStream treats this as a + // brand new session and tears down the still-running process before + // reusing it — deleteActiveProcess(sk) is unconditional otherwise. + const parts = await runTurn(model, [ + { role: "user", content: [{ type: "text", text: "First message." }] }, + { role: "assistant", content: [{ type: "text", text: "First answer." }] }, + { role: "user", content: [{ type: "text", text: "Second message." }] }, + ]) + + const replayFragments = ["ver", "ification call timed out", "alled permission prompt"] + const replayDeltas = parts.filter( + (part) => part.type === "text-delta" && replayFragments.some((fragment) => String(part.delta).includes(fragment)), + ) + assert.equal(replayDeltas.length, replayFragments.length, "expected all three replayed fragments to show up as deltas") + + const replayIds = new Set(replayDeltas.map((part) => part.id)) + assert.equal(replayIds.size, 1, `expected every replayed delta to share one text block id, got ${replayIds.size}`) + + const [replayId] = replayIds + const startsForReplayBlock = parts.filter((part) => part.type === "text-start" && part.id === replayId) + const endsForReplayBlock = parts.filter((part) => part.type === "text-end" && part.id === replayId) + assert.equal(startsForReplayBlock.length, 1, "the replay block must open exactly once") + assert.equal(endsForReplayBlock.length, 1, "the replay block must close exactly once") + } finally { + rmSync(fixture.cwd, { recursive: true, force: true }) + } +})