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..9c8c218 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,141 @@ +# 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 + +- 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 + +- 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]`. +- `/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. +- 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. +- **ACTION DUE 2026-09-01: bump Sonnet 5 to standard pricing.** `claude-sonnet-5` currently ships introductory pricing ($2/M in, $10/M out, `sonnet5Cost`, multiplier 2×) which expires 2026-08-31. From September 1: switch it to `sonnetCost` ($3/$15), multiplier 3×, update the README model table + pricing paragraph and the `test-config-models.ts` assertions (name suffix becomes `(3×)`, cost fields change). The plan is to have an open PR staged with this change and merge it just before Sept 1. +- **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. 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 eleven limits, 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. +- `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 (60 min, `proxyToolTimeoutMs`) and `background` mode; jknlsn's original said 10 minutes, which predated the per-tool timeouts. 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`. +- 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. +- 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` 60 min) → `proxyToolTimeoutMs` config override (case-insensitive) → 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 so the client never gives up before the broker. Config is read once at opencode startup like the rest of the proxy block, so `proxyToolTimeoutMs` changes need a full restart. Tests: `test-proxy-mcp.ts`, `test-broker.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 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.18** (re-checked 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`. + - Unchanged rationale: opencode's `tools` argument to `doStream` is still intentionally unused — Claude CLI only sees its own built-ins plus MCP servers bridged via `--mcp-config`, so opencode-native tools like `task_status` never reach the model and need no `mapTool` entry. + - 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`. +- `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`. +- 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**. + +- `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. The CLI always emits a `stop_reason`, 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–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 worth making, if anyone picks it up: let `max_tokens` fall through to the heuristic, since truncation is the one stop reason that does not mean "finished", while `end_turn`/`stop_sequence` stay authoritative. Do not delete the heuristic either — it is the fallback for CLIs that omit `stop_reason`. + +## Tests To Touch When Editing + +- Prompt/message conversion or compaction transcript behavior: `test-get-claude-user-message.ts`. +- Claude CLI arg construction / version-gated flags: `test-cli-args.ts`. +- Tool name/input mapping (`mapTool`, `CLAUDE_INTERNAL_TOOLS`): `test-tool-mapping.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). +- Reused-process respawn (`appendSessionIdIfNeeded`, `respawnActiveProcess` undefined-branch): `test-respawn.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): `test-cwd-resolution.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`. +- Compress tool (proxy interceptor path, compression store, compress vs default runtime note): `test-compress-tool.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`. + +## 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), 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. Workspace-switch cwd tier-two fix. If Jessie reports v0.4.21+ still fails in desktop workspace switching, add a per-request/current-project query instead of relying on `process.cwd()`. Do not build unless issue #4 confirms it is still broken. +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 is tracked in issues: #22 (Sonnet 5 standard-pricing bump, merge just before 2026-09-01) and #24 (opencode 1.18.5 surface: v2 plugin API, `tool.definition`, compaction hooks — its long-context-cost-tiers item is **closed as not-applicable**, see the pricing gotcha above). #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 — 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-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: #22 is on the calendar; #24's remaining items have no user-visible payoff today; #5 / issue #4 wait on a bug report. Open PRs still need a decision: **#25** (@CNQQC, cost units off by 1e6; small, self-contained, tests updated), #23 (own draft, calendar-gated), #15 (@JWebCoder, auto-continue stopReason short-circuit). #26 and #27 are the only *new* substantive work. + +## 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..c0538f0 100644 --- a/README.md +++ b/README.md @@ -1,179 +1,814 @@ -# 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. +An [opencode](https://opencode.ai) plugin that wraps the **Claude Code CLI** (`claude`) and routes model traffic through it instead of the Anthropic HTTP API. You get to use opencode's UI, agents, MCP, and permission system while authenticating and billing through whichever method `claude` is logged into (Pro/Max plan, Bedrock, Vertex, or API key). + +> 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. + +--- + +## TL;DR + +```bash +# 1. Make sure `claude` is installed and logged in +claude --version + +# 2. Add this to your opencode.json +``` + +```json +{ + "plugin": ["@khalilgharbaoui/opencode-claude-code-plugin"] +} +``` + +That's it. Restart opencode, pick a `claude-code` model, done. + +The plugin self-registers the `claude-code` provider, all current Claude Code models (Haiku 4.5, Sonnet 4.5/4.6, Opus 4.5/4.6/4.7/4.8, Fable 5, Mythos 5) with reasoning variants (`low` / `medium` / `high` / `xhigh` / `max`), and sensible defaults for tool proxying. You don't need to write a `provider` block at all unless you want to override something. + +--- ## Prerequisites -- [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 +- [opencode](https://opencode.ai) installed +- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated (`claude` on your `$PATH`) +- Node 18+ / Bun + +## Install + +### From npm (recommended) -## Installation +```bash +npm install @khalilgharbaoui/opencode-claude-code-plugin +``` + +Then add it to `opencode.json` as shown in the TL;DR. ### 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"] +} +``` + +--- + +## Models + +The plugin auto-registers the following. They appear in the model picker without any extra config. + +| 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 | 2×* | +| `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-5` | Claude Opus 5 | 1M | 128,000 | low/medium/high/xhigh/max | 5× | +| `claude-fable-5` | Claude Fable 5 | 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` is Mythos-class like Fable 5 but without safety classifiers, and is **limited availability via [Project Glasswing](https://anthropic.com/glasswing)**. It's registered unconditionally; if your Claude account lacks access, `claude --model claude-mythos-5` just errors. Use `claude-fable-5` (generally available) 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 5 / Mythos 5 $10/$50 = 10×), so **Fable 5 and Mythos 5 cost 2× Opus 5**. Sonnet 5's `2×` uses its introductory $2/$10 pricing through August 31, 2026; standard $3/$15 pricing begins September 1. 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. + +The model ID is passed straight through to `claude --model`, so anything Claude Code accepts works. + +### 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 + +This plugin drives Claude Code headlessly (Agent SDK > `claude --print`) +check out this page for updated information about billing: https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan + +--- ## 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`. + +### 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 + } + } + } +} +``` + +| Option | Type | Default | Description | +|---|---|---|---| +| `cliPath` | string | `process.env.CLAUDE_CLI_PATH ?? "claude"` | Path to the `claude` binary. | +| `accounts` | string[] | – | Optional account list. `default` is implicit. Expands into `Claude Code (Default)`, `Claude Code (Personal)`, etc. | +| `cwd` | string | `process.cwd()` | Working directory for the spawned CLI. Resolved **lazily per request**, so opencode's project switching works. | +| `skipPermissions` | boolean | `true` | Pass `--dangerously-skip-permissions` to `claude`. Ignored when `proxyTools` is set — the proxy handles permissions through opencode instead. | +| `permissionMode` | `acceptEdits` \| `auto` \| `bypassPermissions` \| `default` \| `dontAsk` \| `plan` | – | Forwarded to `claude --permission-mode`. | +| `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` | – | Per-tool proxy call deadline in ms, keyed by proxy tool name (`bash`, `task`, …). Defaults: 10 min flat, `task` → 60 min. For `bash`, the call's own `input.timeout` is honoured on top (`max(resolved, input.timeout)`). See [Selective tool proxy](#selective-tool-proxy). | +| `planModeQuestion` | boolean | `false` | Route `ExitPlanMode` approval through opencode's native `question` tool instead of a text "(yes/no)" prompt. Off because opencode's question form is currently broken upstream. 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`. | +| `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-change-june-15-2026-agent-sdk-credit). | +| `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. Env: `CLAUDE_CODE_INTERACTIVE_TRANSPORT=1`. See [Interactive transport](#interactive-transport-experimental). | +| `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. | + +### 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-change-june-15-2026-agent-sdk-credit) 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____*`. +- 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's 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. +- `/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` | +| `"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. + +**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. -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: +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. -- **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. +### Closing a tool with no proxy -### Tool handling +`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: -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`. +```json +"options": { + "extraDisallowedTools": ["NotebookEdit"] +} +``` -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 +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. -### Permissions +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. -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. +### Context compression -### Stream sequencing +`"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`: -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` +```json +"options": { + "proxyTools": ["Bash", "Edit", "Write", "WebFetch", "Task", "Compress"] +} +``` -## Package structure +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. +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. + +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. + +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 + +### 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" + } + } + } +} ``` -## Development +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 `claude --dangerously-skip-permissions` is NOT applied to proxied tools). +- 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. + +### Per-tool proxy timeouts + +Every proxied tool call has a deadline: if opencode hasn't resolved it (run the underlying tool and returned a result) within that many milliseconds, the call is rejected and Claude receives a timeout error. Deadlines are resolved per tool, most-specific layer winning: + +1. flat default — 10 min (matches Claude CLI's own Bash ceiling) +2. per-tool default — **`task`: 60 min**, **`question`: 30 min**, everything else: 10 min +3. your `proxyToolTimeoutMs` override (case-insensitive key) +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)`) + +The `task` and `question` defaults are deliberately generous. Subagents routinely run 20–40 min, and a question can sit on a slow operator; under the old flat 10-minute ceiling the proxy fired mid-call, Claude believed its dispatch had failed, and the subagent's eventual result was dropped (the parent turn had already ended on the timeout error). If a `task` call *does* time out, 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. + +```json +"options": { + "proxyTools": ["Bash", "Edit", "Write", "WebFetch", "Task"], + "proxyToolTimeoutMs": { "Task": 5400000, "bash": 1800000 } +} +``` + +--- + +## 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 (Ctrl+C)** → stream closes, process stays alive for the next message in that chat. +- **Cap**: 16 active processes, LRU eviction. + +--- + +## 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. + +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. + +> **Leave this off for now.** It depends on the same opencode `question` form that is [broken upstream](#with-question-in-proxytools-currently-blocked-upstream--leave-it-off): with it on, a plan approval hangs until you interrupt the turn. On opencode builds with no `question` registry entry at all the plugin silently keeps the text path (look for `plan-mode question gate` in the log). Re-test when [anomalyco/opencode#36603](https://github.com/anomalyco/opencode/pull/36603) merges. + +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` (currently blocked upstream — leave it off) + +> **Known upstream breakage (opencode 1.15.x through at least 1.18.5).** opencode's `question` TUI form does not render, so the tool blocks until you interrupt the turn. This is not specific to this plugin: native providers hit it identically, and a `--pure` headless server drives the same question end to end successfully (`question.asked` → `GET /question` → `POST /question/{id}/reply` → tool completes), which isolates the fault to the TUI. Tracked upstream as [anomalyco/opencode#36604](https://github.com/anomalyco/opencode/issues/36604) with fix [PR #36603](https://github.com/anomalyco/opencode/pull/36603) (unmerged). Until that lands, enabling `"Question"` trades the working fallback below for a hang. The instructions here describe the intended behavior for when it is fixed. + +Add `"Question"` to `proxyTools` and grant `permission.question: allow` to the calling agent. Claude's built-in `AskUserQuestion` is disabled via `--disallowedTools`, and the plugin exposes `mcp__opencode_proxy__question` in its place. 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"] +} +``` + +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. + +--- + +## 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 variants + +Each model exposes `low` / `medium` / `high` / `xhigh` / `max` variants. Picking one injects the corresponding Claude CLI thinking keyword (e.g. `(ultrathink)` for `max`) into the user message. Compaction calls skip this injection so the full output budget goes to the summary. + +### 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. + +| Env var | Effect | +|---|---| +| `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. 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). + +**Recommended dev setup** — capture audit trail to disk, keep TUI quiet: + +```jsonc +"@khalilgharbaoui/opencode-claude-code-plugin": { + "logging": { "file": true } +} ``` -### Debug logging +**Full firehose for deep debugging** (every DEBUG stream event captured): + +```jsonc +"logging": { "file": true, "level": "debug" } +``` -Set `DEBUG=opencode-claude-code` to enable verbose logging to stderr: +**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). +- **`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. + +### Default behavior (no config, no env) + +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. + +## 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: automatic strategies and slash commands work, autonomous model-driven compression does not. -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. +| 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. | +| Autonomous model-driven `compress` tool calls | ❌ Not supported | DCP registers `compress` as an opencode-native tool. Claude CLI only sees its own built-ins and MCP-bridged servers, so the model never sees `compress`. The plugin prepends a runtime note instructing Claude to ignore any system instruction that asks it to call `compress`/`distill`/`prune`. | -To proceed after reviewing the plan: -1. Switch to **build mode** using `Tab` -2. Enter `yes` (or `no` to reject) into the prompt +Workaround for autonomous compression: trigger it manually with `/dcp compress` whenever you'd want the model to call it. Full autonomous support would require exposing `compress` as an MCP-bridged tool, which is upstream of this plugin. + +--- ## 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. +- No streaming of tool inputs as they're being constructed (Anthropic's `input_json_delta`); the plugin emits them once complete. +- 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 a 60-minute proxy deadline** (configurable via [`proxyToolTimeoutMs`](#per-tool-proxy-timeouts)). A ceiling covering the longest configured deadline is written into Claude's generated HTTP MCP configuration so long-running opencode subagents are not cut off by Claude's 60-second default. For independent longer work, 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: + +``` +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 +``` -## Publishing +For runtime gotchas, the v1.15.0 audit waterline, and the release flow, 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 (requires `NPM_TOKEN` secret in the repo settings — use a classic automation token so 2FA isn't required at workflow time). + +## Star History + + + + + + Star History Chart + + ## License -MIT +MIT. See [LICENSE](./LICENSE). + +Original work © `unixfox`. Fork modifications © Khalil Gharbaoui. 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..da7dac0 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.13.2", "description": "Claude Code CLI provider plugin for opencode", + "author": "Khalil Gharbaoui", "type": "module", "main": "dist/index.js", "module": "dist/index.js", @@ -18,15 +19,18 @@ "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" }, "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 +43,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/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/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/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..aff3cc0 100644 --- a/src/claude-code-language-model.ts +++ b/src/claude-code-language-model.ts @@ -1,29 +1,717 @@ 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 type { + ClaudeCodeConfig, + ControlRequestBehavior, + ClaudeStreamMessage, + ReasoningEffort, +} from "./types.js" +import { mapTool, isWebSearchTool, isWebSearchHandledByCli } from "./tool-mapping.js" +import { applyTaskCreateToolResult } from "./todo-ledger.js" import { getClaudeUserMessage } from "./message-builder.js" +import { + QUESTION_TOOL_NAME, + consumeExitPlanModeQuestionResult, + createExitPlanModeQuestionCall, + isPlanModeQuestionActive, +} from "./plan-mode-question.js" +import { bridgeOpencodeMcp, type RuntimeMcpStatus } from "./mcp-bridge.js" +import { + getRuntimeMcpStatus, + fetchOpencodeToolList, + resolveSpawnCwd, +} from "./runtime-status.js" import { getActiveProcess, + setActiveProcess, spawnClaudeProcess, buildCliArgs, setClaudeSessionId, getClaudeSessionId, deleteClaudeSessionId, deleteActiveProcess, + deleteActiveProcessAndWait, + respawnActiveProcess, + claudeSpawnEnv, + isClaudeThinkingDisabled, sessionKey, } 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, + DEFAULT_PROXY_TOOLS, + overlayTaskProxyDescription, + overlayQuestionProxyDescription, + filterQuestionProxyByOpencodeSupport, + PROXY_TOOL_PREFIX, + type ProxyMcpServer, + type ProxyToolCall, + type ProxyToolDef, + type ProxyToolInterceptor, + type ProxyToolResult, +} from "./proxy-mcp.js" +import { + getPendingProxyCalls, + 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 + +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 +} + +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 +} -export class ClaudeCodeLanguageModel implements LanguageModelV2 { - readonly specificationVersion = "v2" +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) { + 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 }], + }, + }) +} + +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 one tool: \`mcp__opencode_proxy__task\`. + +- 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.` + +/** + * 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 `compress` is in the resolved proxy list for this spawn. */ + compressEnabled?: 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()}`, + ) + } + parts.push( + options.compressEnabled ? CLAUDE_CLI_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) + + if (globalAgents) parts.push(globalAgents) + if (workspaceAgents && workspaceAgents !== globalAgents) parts.push(workspaceAgents) + if (globalAgents || workspaceAgents) 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 + } +} + +export class ClaudeCodeLanguageModel implements LanguageModelV3 { + readonly specificationVersion = "v3" readonly modelId: string private readonly config: ClaudeCodeConfig @@ -38,12 +726,536 @@ 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" = "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 unknown: string[] = [] + for (const n of names) { + const def = defsByName.get(String(n).toLowerCase()) + if (def) picked.push(def) + else unknown.push(String(n)) + } + // 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-bridged tools so they go + * through the in-process proxy instead of being bridged into Claude CLI's + * `--mcp-config`. Direct bridging causes double execution because both + * Claude CLI's own MCP child and opencode hold their own connection to + * the same server; routing through the proxy keeps a single execution + * site (opencode). Returns null when the feature is disabled, the SDK + * client is unavailable, or no MCP servers are configured. + */ + private async resolvedProxyMcpTools( + allEnabledServerNames: string[], + ): Promise { + if (this.config.proxyOpencodeMcpTools === false) return null + if (this.config.bridgeOpencodeMcp === false) return null + if (allEnabledServerNames.length === 0) return null + + const items = await fetchOpencodeToolList( + this.config.provider, + this.modelId, + this.config.cwd, + ) + if (!items || items.length === 0) return null + + // opencode names MCP tools `_`. Match the + // longest server name prefix first so e.g. `slack_intl_*` resolves to + // server `slack_intl` not `slack`. + const serversByLengthDesc = [...allEnabledServerNames].sort( + (a, b) => b.length - a.length, + ) + const out: ProxyToolDef[] = [] + const seen = new Set() + for (const item of items) { + const matchedServer = serversByLengthDesc.find( + (name) => item.id === name || item.id.startsWith(`${name}_`), + ) + if (!matchedServer) continue + if (seen.has(item.id)) continue + seen.add(item.id) + out.push({ + name: item.id, + description: item.description ?? "", + inputSchema: + item.parameters && typeof item.parameters === "object" + ? item.parameters + : { type: "object", properties: {} }, + }) + } + return out.length > 0 ? out : null + } + + /** + * 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, + } + } + + /** 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, + ): Promise { + const timeoutOverrides = this.config.proxyToolTimeoutMs + const interceptors = new Map() + if (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 + } + + /** + * 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 +1279,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 +1342,117 @@ 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>> { + const warnings: SharedV3Warning[] = [] + const cwd = resolveSpawnCwd(this.config.cwd) const scope = this.requestScope(options as any) - const sk = sessionKey(cwd, `${this.modelId}::${scope}`) + const affinity = this.sessionAffinity(options) + const sk = sessionKey(cwd, `${this.modelId}::${scope}::${affinity}`) + + // 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 !== false && + 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,27 +1469,81 @@ 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, + } + } + 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) - - // doGenerate always spawns a fresh process, never reuse session ID + const reasoningEffort = this.getReasoningEffort(options.providerOptions) + const userMsg = + consumeExitPlanModeQuestionResult(sk, options.prompt as any) ?? + getClaudeUserMessage(options.prompt, includeHistoryContext, reasoningEffort) + + // 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(this.config.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 cliArgs = buildCliArgs({ sessionKey: sk, skipPermissions: this.config.skipPermissions !== false, includeSessionId: false, model: this.modelId, + permissionMode: this.config.permissionMode, + mcpConfig: this.effectiveMcpConfig(cwd, undefined, runtimeStatus).paths, + strictMcpConfig: this.config.strictMcpConfig, + disallowedTools: + this.config.webSearch === "disabled" ? ["WebSearch"] : undefined, + appendSystemPromptFile: systemPromptFile, + ...this.thinkingCliOptions(), + cliVersion, }) log.info("doGenerate starting", { @@ -200,9 +1559,18 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { const proc = spawn(this.config.cliPath, cliArgs, { cwd, stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, TERM: "xterm-256color" }, + env: claudeSpawnEnv({ + ignoreAnthropicApiKey: this.config.ignoreAnthropicApiKey, + }), + shell: process.platform === "win32", }) + if (systemPromptFile) { + proc.on("exit", () => { + void unlink(systemPromptFile).catch(() => {}) + }) + } + const rl = createInterface({ input: proc.stdout! }) let responseText = "" @@ -214,6 +1582,20 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { usage?: ClaudeStreamMessage["usage"] } = {} 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,10 +1604,31 @@ 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) { @@ -233,7 +1636,11 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } } - if (msg.type === "assistant" && msg.message?.content) { + 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 +1649,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 +1666,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 +1693,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 +1724,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 +1764,26 @@ 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, usage: msg.usage, } + cleanup() resolve({ ...resultMeta, text: responseText, @@ -336,6 +1797,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { }) rl.on("close", () => { + cleanup() resolve({ ...resultMeta, text: responseText, @@ -346,6 +1808,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { proc.on("error", (err) => { log.error("process error", { error: err.message }) + cleanup() reject(err) }) @@ -356,7 +1819,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { proc.stdin?.write(userMsg + "\n") }) - const content: LanguageModelV2Content[] = [] + const content: LanguageModelV3Content[] = [] if (result.thinking) { content.push({ @@ -375,17 +1838,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 +1882,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,25 +1908,68 @@ 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() + options: LanguageModelV3CallOptions, + ): Promise>> { + const warnings: SharedV3Warning[] = [] + const cwd = resolveSpawnCwd(this.config.cwd) const cliPath = this.config.cliPath const skipPermissions = this.config.skipPermissions !== false const scope = this.requestScope(options as any) - const sk = sessionKey(cwd, `${this.modelId}::${scope}`) - - if (scope === "no-tools") { + const affinity = this.sessionAffinity(options) + 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() + : this.modelId + const sk = compactionMode + ? sessionKey(cwd, `${effectiveModelId}::compaction::${affinity}`) + : sessionKey(cwd, `${this.modelId}::${scope}::${affinity}`) + 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" && !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) @@ -453,12 +1981,8 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { controller.enqueue({ type: "text-end", id: textId }) controller.enqueue({ type: "finish", - finishReason: "stop", - usage: { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - }, + finishReason: toFinishReason("stop"), + usage: toUsage({ input_tokens: 0, output_tokens: 0 }), providerMetadata: { "claude-code": { synthetic: true, @@ -476,86 +2000,943 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } } - const hasPriorConversation = - options.prompt.filter((m) => m.role === "user" || m.role === "assistant") - .length > 1 + // 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: "" } } } + } + + 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) + } + + const hasExistingSession = !!getClaudeSessionId(sk) + const hasActiveProcess = !!getActiveProcess(sk) + const includeHistoryContext = + !hasExistingSession && !hasActiveProcess && hasPriorConversation + + const reasoningEffort = this.getReasoningEffort(options.providerOptions) + 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 }) + } + const userMsg = + exitPlanModeQuestionResult ?? + getClaudeUserMessage(options.prompt, includeHistoryContext, reasoningEffort, { + compactionMode, + }) + 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 previousPendingProxyCalls = compactionMode + ? [] + : getPendingProxyCalls(sk) + const previousPendingProxyMatches: Array<{ + call: PendingProxyCall + result: ProxyToolResult | null + }> = previousPendingProxyCalls.map((call) => ({ + call, + result: this.extractPendingProxyResult(options.prompt, call.toolCallId), + })) + 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(this.config.cliPath), + ]) + + log.info("doStream starting", { + cwd, + 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 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 + + 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", + ) + } + const ap = spawnInteractiveProcess({ + cwd, + cliPath, + configDir: self.config.configDir, + model: effectiveModelId, + mcpConfigPaths: mcp.paths, + permissionsAllow: allow, + systemPromptFile, + ignoreAnthropicApiKey: self.config.ignoreAnthropicApiKey, + }) + 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: effectiveModelId, + permissionMode: self.config.permissionMode, + 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 proxyMcpTools = await self.resolvedProxyMcpTools( + discovery.allEnabledServerNames, + ) + const excludeServers: ReadonlySet | undefined = proxyMcpTools + ? new Set(discovery.allEnabledServerNames) + : 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 + const liveToolInfo = + taskProxyEnabled || questionProxyEnabled + ? 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. + const combinedList = [ + ...(enrichedProxy ?? []), + ...(proxyMcpTools ?? []), + ] + const combinedProxyTools: ProxyToolDef[] | null = + combinedList.length > 0 ? combinedList : null + + if (!proxyServer && combinedProxyTools) { + proxyServer = await self.ensureProxyServer(combinedProxyTools, sk) + } + + // 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: + enrichedProxy?.some((t) => t.name === "compress") ?? false, + compressionSummary: getCompressionSummary(sk), + }, + ) + cliArgs = buildCliArgs({ + sessionKey: sk, + skipPermissions, + model: self.modelId, + permissionMode: self.config.permissionMode, + mcpConfig: mcp.paths, + strictMcpConfig: self.config.strictMcpConfig, + disallowedTools: allDisallowed.length > 0 ? allDisallowed : undefined, + appendSystemPromptFile: systemPromptFile, + ...self.thinkingCliOptions(), + 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, + ) + proc = ap.proc + lineEmitter = ap.lineEmitter + activeProcess = ap + } + } + + 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 + } + } + + const reasoningIds = new Map() + const reasoningStarted = new Map() + let hadThinkingTextFromStream = false + + let turnCompleted = false + let controllerClosed = false + let pendingProxyUnsubscribe: (() => void) | null = null + let resultFallbackTimer: ReturnType | null = null + let pendingResultCompletion: (() => void) | null = null + let hasReceivedContent = 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: 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). + const startResultFallback = (delayMs = 60_000) => { + clearFallbackTimer() + if (!hasReceivedContent || controllerClosed) return + resultFallbackTimer = setTimeout(() => { + if (controllerClosed) return + log.warn("result fallback timer fired — closing stream without result event", { + delayMs, + }) + 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) 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 { + proc.stdin?.write(userMsg + "\n") + log.debug("re-sent user message after respawn", { + textLength: userMsg.length, + }) + } catch (err) { + log.error("failed to re-send envelope after respawn", { + error: err instanceof Error ? err.message : String(err), + }) + } + startWatchdog = setTimeout( + onStartWatchdogFire, + START_WATCHDOG_MS, + ) + } + const armStartWatchdog = () => { + clearStartWatchdog() + if (controllerClosed) return + startWatchdog = setTimeout(onStartWatchdogFire, START_WATCHDOG_MS) + } + + 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 + usage?: ClaudeStreamMessage["usage"] + } = {} + + // 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 + for (const call of calls) { + controller.enqueue({ + type: "tool-input-start", + id: call.toolCallId, + toolName: call.toolName, + } as any) + controller.enqueue({ + type: "tool-call", + toolCallId: call.toolCallId, + toolName: call.toolName, + input: JSON.stringify(call.input), + providerExecuted: false, + } as any) + skipResultForIds.add(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 {} + } + + const finishWithExitPlanQuestion = ( + call: ReturnType, + ) => { + 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) + } - // New session — clear any stale state from a previous session - if (!hasPriorConversation) { - deleteClaudeSessionId(sk) - deleteActiveProcess(sk) - } + const noteResultBoundaryCall = (): boolean => { + if (!pendingResultCompletion) return false + if (drainTimer) clearTimeout(drainTimer) + drainTimer = setTimeout(settleResultBoundary, DRAIN_QUIET_MS) + return true + } - const hasExistingSession = !!getClaudeSessionId(sk) - const hasActiveProcess = !!getActiveProcess(sk) - const includeHistoryContext = - !hasExistingSession && !hasActiveProcess && hasPriorConversation + const noteVisibleText = (text: string) => { + visibleTextSinceContinue += text + lastVisibleTextSinceContinue += text + } - const userMsg = getClaudeUserMessage(options.prompt, includeHistoryContext) + const resetLastVisibleTextBlock = () => { + lastVisibleTextSinceContinue = "" + } - log.info("doStream starting", { - cwd, - model: this.modelId, - textLength: userMsg.length, - includeHistoryContext, - hasActiveProcess, - }) + const noteReasoning = () => { + hadReasoningSinceContinue = true + } - const cliArgs = buildCliArgs({ - sessionKey: sk, - skipPermissions, - model: this.modelId, - }) + const noteToolActivity = () => { + hadToolActivitySinceContinue = true + } - const stream = new ReadableStream({ - start(controller) { - let activeProcess = getActiveProcess(sk) - let proc: import("child_process").ChildProcess - let lineEmitter: import("events").EventEmitter + const noteProxyActivity = () => { + hadProxyActivitySinceContinue = true + } - 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 resetAutoContinueWindow = () => { + visibleTextSinceContinue = "" + lastVisibleTextSinceContinue = "" + hadReasoningSinceContinue = false + hadToolActivitySinceContinue = false + hadProxyActivitySinceContinue = false + lastStopReason = null } - controller.enqueue({ type: "stream-start", warnings }) + const completeResult = (msg: ClaudeStreamMessage) => { + if (controllerClosed) 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, + }) + } + + 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() + 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, + }) - const textId = generateId() - let textStarted = false + 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: toFinishReason("stop"), + usage: toUsage(msg.usage), + providerMetadata: { + "claude-code": { + ...resultMeta, + ...(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() - 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() + // First stdout line means the child is alive and responding — + // disarm the start watchdog (covers the "no output at all" gap). + clearStartWatchdog() + 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 (handleControlRequest(msg, proc)) { + return + } log.debug("stream message", { type: msg.type, @@ -582,43 +2963,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) } 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 +3039,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 +3059,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,6 +3114,11 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { reasoningStarted.delete(idx) } + if (textBlockIndices.has(idx)) { + endTextBlock() + textBlockIndices.delete(idx) + } + const tc = toolCallMap.get(idx) if (tc) { let parsedInput: any = {} @@ -701,63 +3126,83 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { 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, + }) + finishWithExitPlanQuestion(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 +3210,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { name: tc.name, input: parsedInput, }) + if (!executed) skipResultForIds.add(tc.id) controller.enqueue({ type: "tool-call", @@ -784,25 +3230,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 +3355,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, + }) + finishWithExitPlanQuestion(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 +3474,67 @@ 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) { controller.enqueue({ type: "tool-result", toolCallId: block.tool_use_id, @@ -949,6 +3546,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { }, providerExecuted: true, } as any) + noteToolActivity() log.info("tool result emitted", { toolUseId: block.tool_use_id, name: toolCall.name, @@ -961,9 +3559,29 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { // result - end of conversation turn if (msg.type === "result") { + clearFallbackTimer() + if (msg.session_id) { setClaudeSessionId(sk, msg.session_id) } + + // 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, + }) + } + resultMeta = { sessionId: msg.session_id, costUsd: msg.total_cost_usd, @@ -980,45 +3598,48 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { turnCompleted = true - if (textStarted) { - controller.enqueue({ type: "text-end", id: textId }) + endTextBlock() + + 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 } - for (const [idx, reasoningId] of reasoningIds) { - if (reasoningStarted.get(idx)) { - controller.enqueue({ - type: "reasoning-end", - id: reasoningId, - } as any) - } + 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 } - 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, - }, - }) - - controllerClosed = true - lineEmitter.off("line", lineHandler) - lineEmitter.off("close", closeHandler) - - try { - controller.close() - } catch {} + completeResult(msg) } } catch (e) { log.debug("failed to parse line", { @@ -1031,22 +3652,32 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { const closeHandler = () => { log.debug("readline closed") if (controllerClosed) return - controllerClosed = true - lineEmitter.off("line", lineHandler) - lineEmitter.off("close", closeHandler) - if (textStarted) { - controller.enqueue({ type: "text-end", id: textId }) + // 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 } + controllerClosed = true + cleanupTurn() + endTextBlock() controller.enqueue({ type: "finish", - finishReason: "stop", - usage: { - inputTokens: undefined, - outputTokens: undefined, - totalTokens: undefined, - }, + finishReason: toFinishReason("stop"), + usage: toUsage(), providerMetadata: { - "claude-code": resultMeta, + "claude-code": { + ...resultMeta, + ...(compactionMode + ? { compactionModel: effectiveModelId } + : {}), + }, }, }) try { @@ -1054,42 +3685,195 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } catch {} } - 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 + 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 {} + } + + 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) { options.abortSignal.addEventListener("abort", () => { - if (!turnCompleted) { + autoContinueState.aborted = true + if (turnCompleted || controllerClosed) return + + if (!hasReceivedContent) { log.info( - "abort signal received mid-turn, keeping process alive", + "abort signal received before content, closing stream immediately", { cwd }, ) - } - if (!controllerClosed) { + if ( + drainBuffer.length > 0 || + getPendingProxyCalls(sk).length > 0 + ) { + rejectAllPendingProxyCallsForSession( + sk, + new Error( + "Provider stream was aborted before pending proxy calls were emitted", + ), + ) + drainBuffer.length = 0 + } 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 }, + ) + // 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) { + log.info("resolving pending proxy call from tool result prompt", { + sessionKey: sk, + toolCallId: call.toolCallId, + toolName: call.toolName, + }) + resolvePendingProxyCallById(call.toolCallId, result) + } else { + log.info( + "leaving unmatched parallel proxy call pending", + { + sessionKey: sk, + toolCallId: call.toolCallId, + toolName: call.toolName, + }, + ) + } + } + 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. 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..c6db18c --- /dev/null +++ b/src/claude-session-bun.ts @@ -0,0 +1,536 @@ +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" + +/** + * 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 + 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 +} + +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" + > + > & + Pick< + ClaudeSessionOptions, + | "cliPath" + | "configDir" + | "model" + | "settingSources" + | "extraArgs" + | "ignoreAnthropicApiKey" + > + + 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, + 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: { + ...process.env, + CLAUDE_CONFIG_DIR: this.o.configDir, + TERM: "xterm-256color", + ...(this.o.ignoreAnthropicApiKey + ? { ANTHROPIC_API_KEY: undefined, ANTHROPIC_AUTH_TOKEN: undefined } + : {}), + }, + 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..ab380a4 --- /dev/null +++ b/src/claude-session-wrapper.ts @@ -0,0 +1,261 @@ +import { EventEmitter } from "node:events" +import { unlink } from "node:fs/promises" +import { ClaudeSession } from "./claude-session-bun.js" +import type { ActiveProcess } from "./session-manager.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 + /** Bridged Claude `--mcp-config` file paths (from effectiveMcpConfig). */ + mcpConfigPaths?: 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 +} + +/** + * 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. + */ +export function spawnInteractiveProcess( + opts: InteractiveSpawnOptions, +): ActiveProcess { + const extraArgs: string[] = [] + if (opts.mcpConfigPaths && opts.mcpConfigPaths.length > 0) { + extraArgs.push( + "--mcp-config", + ...opts.mcpConfigPaths, + "--strict-mcp-config", + ) + } + if (opts.permissionsAllow && opts.permissionsAllow.length > 0) { + extraArgs.push( + "--settings", + JSON.stringify({ permissions: { allow: opts.permissionsAllow } }), + ) + } + 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) + } + + 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, + }) + log.info("prepared interactive claude session", { + cwd: opts.cwd, + cliPath: opts.cliPath ?? "claude", + configDir: session.configDir, + model: opts.model, + 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-version.ts b/src/cli-version.ts new file mode 100644 index 0000000..17d4f8e --- /dev/null +++ b/src/cli-version.ts @@ -0,0 +1,91 @@ +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>() + +/** + * 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 }) +} + +/** + * `--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. */ +export function _clearCache(): void { + 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/index.ts b/src/index.ts index 8e74f47..812efab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,26 +1,130 @@ -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 { 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 { cleanupStaleUnscopedInstall } from "./cleanup-stale.js" +import { configureLogger, log } from "./logger.js" +import { + 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 + +// `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", +] + +// 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.", + ) + } } 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) 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, + 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, + 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, + interactive: settings.interactive, + interactiveBypass: settings.interactiveBypass, + interactiveAllowTools: settings.interactiveAllowTools, + interactiveSystemPrompt: settings.interactiveSystemPrompt, }) } @@ -28,14 +132,343 @@ 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 + 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, + }, + // 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, + }, + 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 +} + +const server: OpenCodePlugin = async (input) => { + cleanupStaleUnscopedInstall() + + 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) => { + config.provider ??= {} + + 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, + ) + }, + // No `event` hook: MCP config drift is detected at turn start by the + // hot-reload check in `claude-code-language-model.ts`, which respawns + // claude safely between turns. Eviction on `global.disposed` would kill + // an in-flight stream and abort the user's current turn. + 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. + "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 { 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..5d8f3b0 --- /dev/null +++ b/src/mcp-bridge.ts @@ -0,0 +1,634 @@ +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) + const outPath = path.join( + pluginTmpDir(), + `mcp-${hash}.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..a89c7b1 100644 --- a/src/message-builder.ts +++ b/src/message-builder.ts @@ -1,13 +1,211 @@ -import type { LanguageModelV2 } from "@ai-sdk/provider" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { log } from "./logger.js" +import type { ReasoningEffort } from "./types.js" -type Prompt = Parameters[0]["prompt"] +type Prompt = Parameters[0]["prompt"] + +const THINKING_KEYWORDS: Record = { + minimal: null, + low: "think", + medium: "think hard", + high: "think harder", + xhigh: "megathink", + max: "ultrathink", +} + +export function reasoningKeyword(effort?: ReasoningEffort): string | null { + if (!effort) return null + return THINKING_KEYWORDS[effort] ?? null +} + +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 for when we start - * a fresh Claude CLI session but want to preserve conversation context. + * Compact conversation history into a context summary. + * + * - mode "fresh-session" (default): legacy behavior. Filters to + * user/assistant only, clips each message at 2000 chars, drops tool + * payloads to placeholders. Used when starting a fresh CLI session + * that lost its prior session id. + * - 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): string | null { +export function compactConversationHistory( + prompt: Prompt, + opts: { mode?: "fresh-session" | "compaction" } = {}, +): string | null { + const mode = opts.mode ?? "fresh-session" + + if (mode === "compaction") { + return buildCompactionHistory(prompt) + } + const conversationMessages = prompt.filter( (m) => m.role === "user" || m.role === "assistant", ) @@ -60,16 +258,99 @@ 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), the + * wrapper framing tells the model this is the authoritative thread, and + * the reasoning keyword is suppressed so the full output budget goes + * toward the summary. */ export function getClaudeUserMessage( prompt: Prompt, includeHistoryContext: boolean = false, + reasoningEffort?: ReasoningEffort, + opts: { compactionMode?: boolean } = {}, ): string { + const compactionMode = opts.compactionMode === true const content: any[] = [] - if (includeHistoryContext) { + 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", { @@ -101,29 +382,48 @@ Now continuing with the current message: for (const msg of messages) { if (msg.role === "user") { 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, + }) } + } else if (part.type === "tool-result") { + const p = part as any + content.push({ + type: "tool_result", + tool_use_id: p.toolCallId, + content: getToolResultText(p), + }) + } + } + } + } 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") { + const p = part as any content.push({ type: "tool_result", tool_use_id: p.toolCallId, - content: resultText, + content: getToolResultText(p), }) } } @@ -132,15 +432,40 @@ 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)" }], }, }) } + // Reasoning keyword is a Claude CLI hint that triggers extended thinking. + // For compaction we want the full output budget to go to the summary + // itself, not internal reasoning — so skip injection. + if (!compactionMode) { + const keyword = reasoningKeyword(reasoningEffort) + if (keyword) { + const lastTextPart = [...content].reverse().find((p) => p.type === "text") + if (lastTextPart) { + lastTextPart.text = lastTextPart.text + ? `${lastTextPart.text}\n\n(${keyword})` + : `(${keyword})` + } else { + content.push({ type: "text", text: `(${keyword})` }) + } + log.debug("injected reasoning keyword", { effort: reasoningEffort, keyword }) + } + } + return JSON.stringify({ type: "user", message: { diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..687cd9a --- /dev/null +++ b/src/models.ts @@ -0,0 +1,260 @@ +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. Sonnet 5 is temporarily + // 2x during its launch-price period through August 31, 2026. 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 } +// Introductory pricing through August 31, 2026. Standard pricing from September +// 1 is the same $3/M input and $15/M output as the other Sonnet models. +const sonnet5Cost = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 } +// 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 } + +/** + * 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: sonnet5Cost, + multiplier: 2, + 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", + }), + "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-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", + }), + // Mythos 5 shares Fable 5's capabilities and pricing without the safety + // classifiers; limited availability via Project Glasswing. `claude --model + // claude-mythos-5` simply errors for accounts without access, so it's 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", + }), +} diff --git a/src/opencode-types.ts b/src/opencode-types.ts new file mode 100644 index 0000000..c6b2892 --- /dev/null +++ b/src/opencode-types.ts @@ -0,0 +1,141 @@ +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. + 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 = { + provider?: Record< + string, + { + name?: string + npm?: string + env?: string[] + options?: Record + models?: Record + } + > +} + +/** + * 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 `global.disposed`). + */ +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. Optional; this plugin + // doesn't currently subscribe — MCP config drift is handled at turn start. + event?: (input: { event: OpenCodeEvent }) => Promise + "chat.params"?: ( + input: OpenCodeChatParamsInput, + output: OpenCodeChatParamsOutput, + ) => 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..aaabef4 --- /dev/null +++ b/src/plan-mode-question.ts @@ -0,0 +1,234 @@ +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" + +export interface ExitPlanModeQuestionCall { + 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 +} + +/** + * Whether to bridge `ExitPlanMode` into opencode's native `question` tool + * this turn. + * + * Opt-in (`planModeQuestion`) because opencode's question form does not + * currently render (anomalyco/opencode#36604), so an enabled bridge hangs the + * turn until the operator interrupts, where the text path still works. + * Gated on the live registry because emitting a `question` tool-call on a + * build without that entry renders `⚙ invalid` and wedges the turn just the + * same. 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 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 + } +} + +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 +} + +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..4ce2046 --- /dev/null +++ b/src/proxy-broker.ts @@ -0,0 +1,187 @@ +import { EventEmitter } from "node:events" +import { + buildProxyTimeoutError, + resolveProxyCallTimeoutMs, + type ProxyToolCall, + type ProxyToolResult, +} from "./proxy-mcp.js" +import { log } from "./logger.js" + +export interface PendingProxyCall { + sessionKey: string + toolCallId: string + toolName: string + input: Record +} + +type InternalPending = PendingProxyCall & { + createdAt: number + timer: ReturnType + resolve(result: ProxyToolResult): void + reject(error: Error): void +} + +// 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, +): 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) { + clearTimeout(previous.timer) + 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, + ) + + const timer = setTimeout(() => { + const current = pendingByCallId.get(call.id) + if (!current) return + pendingByCallId.delete(call.id) + indexRemove(current.sessionKey, call.id) + 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) + + const pending: InternalPending = { + sessionKey, + toolCallId: call.id, + toolName: call.toolName, + input: call.input, + createdAt: Date.now(), + timer, + 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 +} + +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 +} + +export function resolvePendingProxyCallById( + toolCallId: string, + result: ProxyToolResult, +): boolean { + const pending = pendingByCallId.get(toolCallId) + if (!pending) return false + pendingByCallId.delete(toolCallId) + indexRemove(pending.sessionKey, toolCallId) + clearTimeout(pending.timer) + 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) + clearTimeout(pending.timer) + 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..91efbd7 --- /dev/null +++ b/src/proxy-mcp.ts @@ -0,0 +1,1111 @@ +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`. MCP spec + * also supports SSE streaming, but Claude's HTTP transport accepts single + * JSON responses for short-lived tool calls, so we keep it simple. + */ + +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 + close(): Promise +} + +export interface ProxyToolDef { + /** Raw name as seen by Claude once proxied: the MCP exposed tool name. */ + name: string + description: string + inputSchema: Record +} + +export interface ProxyToolCall { + id: string + toolName: string + input: Record + resolve: (result: ProxyToolResult) => void + reject: (err: Error) => void +} + +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 + +// Per-tool default deadlines, keyed by lowercase proxy tool name. `task` +// dispatches an opencode subagent that routinely runs 20-40 min; the old +// flat ceiling fired mid-subagent, made Claude believe its dispatch had +// failed, and (because the proxy had already returned a timeout error) the +// late subagent result was dropped on the floor -- the operator had to +// nudge "please check now, it seems the task succeeded" (@jknlsn, live +// session ses_0cfc0da6, 2026-07-05). +// +// `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: 60 * 60 * 1000, // 60 min + 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`) + * 3. user override via `proxyToolTimeoutMs` config (case-insensitive key) + * 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. + * Finally clamped to `MAX_PROXY_TIMEOUT_MS` to stay within Node's timer range. + */ +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 (typeof ov === "number" && ov > 0) 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) +} + +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 the flat default, per-tool defaults, and user overrides. + * (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 + for (const v of Object.values(PROXY_PER_TOOL_DEFAULT_TIMEOUT_MS)) { + if (v > ms) ms = v + } + if (overrides) { + for (const v of Object.values(overrides)) { + if (typeof v === "number" && v > ms) ms = v + } + } + 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") { + return new Error( + base + + " (the subagent). 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 is the ONLY tool that dispatches 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. Task calls" + + " get a 60-minute proxy deadline by default (configurable via" + + " proxyToolTimeoutMs)." + +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, 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") +} + +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: { + 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", + }, + }, + required: ["description", "prompt", "subagent_type"], + }, + }, + { + 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, +): Promise { + const calls = new EventEmitter() + const pending = new Map() + + // 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 + 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 + } + + // 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, + }) + + let timer: ReturnType | null = null + const result = await new Promise( + (resolve, reject) => { + const entry: ProxyToolCall = { + id: callId, + toolName, + input, + resolve, + reject, + } + pending.set(callId, entry) + const deadlineMs = resolveProxyCallTimeoutMs( + toolName, + input, + timeoutOverrides, + ) + 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) + }) + + writeToolCallResult(res, requestId, result) + 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 { + writeJson(res, { + jsonrpc: "2.0", + id: requestId, + result: { + content: [{ type: "text", text: errorMessage }], + isError: true, + }, + }) + } 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 + }, + 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"], + // `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 +} + +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, +): void { + const text = result.kind === "error" ? result.message : result.text + const isError = result.kind === "error" || result.isError === true + writeJson(res, { + jsonrpc: "2.0", + id: requestId ?? null, + result: { + content: [{ type: "text", text }], + isError, + }, + }) +} + +function writeJson(res: ServerResponse, body: unknown): void { + 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..f9ac644 --- /dev/null +++ b/src/runtime-status.ts @@ -0,0 +1,168 @@ +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 }> + } +} + +let opencodeClient: OpencodeClient | null = null + +export function setOpencodeClient(client: unknown): void { + if (client && typeof client === "object") { + opencodeClient = client as 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. Live `process.cwd()` when it's a real directory. Restores the lazy + * resolution that lets opencode's project-aware behavior (chdir on + * workspace switch, project-per-shell on terminal launch) flow + * through without restarting the plugin. + * 3. Captured project directory from plugin init. Rescues macOS GUI + * launches where `process.cwd()` is `/`. + * 4. 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, +): string { + if (configured) return configured + if (isUsableDirectory(live)) return live + return captured ?? live +} + +/** + * 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..df47e86 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -1,33 +1,181 @@ import { spawn, type ChildProcess } from "node:child_process" import { createInterface } from "node:readline" import { EventEmitter } from "node:events" +import { unlink } from "node:fs/promises" import { log } from "./logger.js" +import type { ProxyMcpServer } from "./proxy-mcp.js" +import { clearLedger } from "./todo-ledger.js" +import { clearExitPlanModeQuestions } from "./plan-mode-question.js" +import { + cliSupportsThinking, + cliSupportsThinkingDisplay, + type CliVersion, +} from "./cli-version.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 } -// Keyed by cwd - one active process per working directory +// 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() - -// Map cwd -> Claude CLI session ID for session reuse const claudeSessions = new Map() +// 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. +const MAX_ACTIVE_PROCESSES = 16 +const PROCESS_EXIT_TIMEOUT_MS = 1_500 +const PROCESS_FORCE_EXIT_TIMEOUT_MS = 500 + +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) + ) +} + +export function claudeSpawnEnv(opts?: { + ignoreAnthropicApiKey?: boolean +}): Record { + const env: Record = { + ...process.env, + TERM: "xterm-256color", + } + + // 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) + } +} + +function evictIfNeeded(): void { + while (activeProcesses.size >= MAX_ACTIVE_PROCESSES) { + const oldestKey = activeProcesses.keys().next().value + if (!oldestKey) break + log.info("evicting LRU claude process", { sessionKey: oldestKey }) + deleteActiveProcess(oldestKey) + } +} + export function getActiveProcess(key: string): ActiveProcess | undefined { - return activeProcesses.get(key) + const ap = activeProcesses.get(key) + if (ap) touch(key) + return ap } export function setActiveProcess(key: string, ap: ActiveProcess): void { activeProcesses.set(key, ap) } -export function deleteActiveProcess(key: string): void { +function detachActiveProcess(key: string): ActiveProcess | undefined { const ap = activeProcesses.get(key) - if (ap) { - ap.proc.kill() - activeProcesses.delete(key) - } + if (!ap) return undefined + activeProcesses.delete(key) + void ap.proxyServer?.close() + return ap +} + +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 { @@ -39,6 +187,9 @@ export function setClaudeSessionId(key: string, sessionId: string): void { } export function deleteClaudeSessionId(key: string): void { + clearExitPlanModeQuestions(key) + const claudeSessionId = claudeSessions.get(key) + if (claudeSessionId) clearLedger(claudeSessionId) claudeSessions.delete(key) } @@ -47,13 +198,19 @@ export function spawnClaudeProcess( cliArgs: string[], cwd: string, sessionKey: string, + proxyServer?: ProxyMcpServer | null, + mcpHash?: string | null, + systemPromptFile?: string, + ignoreAnthropicApiKey?: boolean, ): ActiveProcess { + evictIfNeeded() log.info("spawning new claude process", { cliPath, cliArgs, cwd, sessionKey }) const proc = spawn(cliPath, cliArgs, { cwd, stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, TERM: "xterm-256color" }, + env: claudeSpawnEnv({ ignoreAnthropicApiKey }), + shell: process.platform === "win32", }) const lineEmitter = new EventEmitter() @@ -66,13 +223,30 @@ export function spawnClaudeProcess( lineEmitter.emit("close") }) - const ap: ActiveProcess = { proc, lineEmitter } + const ap: ActiveProcess = { + proc, + lineEmitter, + proxyServer: proxyServer ?? null, + mcpHash, + systemPromptFile, + } 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 }) + }) + 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) activeProcesses.delete(sessionKey) + if (ownsSessionKey && code !== 0 && code !== null) { log.info("process exited with error, clearing session", { code, sessionKey, @@ -85,35 +259,144 @@ export function spawnClaudeProcess( const stderr = data.toString() log.debug("stderr", { data: stderr.slice(0, 200) }) + // "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`). + * + * 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 + 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 {} + return spawnClaudeProcess( + cliPath, + appendResumeIfNeeded(sessionKey, cliArgs), + cwd, + sessionKey, + old.proxyServer, + old.mcpHash, + old.systemPromptFile, + ignoreAnthropicApiKey, + ) +} + export function buildCliArgs(opts: { sessionKey: string skipPermissions: boolean includeSessionId?: boolean model?: string + permissionMode?: string + mcpConfig?: string | string[] + strictMcpConfig?: boolean + disallowedTools?: string[] + appendSystemPromptFile?: string + thinking?: "enabled" | "disabled" + thinkingDisplay?: "summarized" | "omitted" + cliVersion?: CliVersion | null }): string[] { - const { sessionKey, skipPermissions, includeSessionId = true, model } = opts + const { + sessionKey, + skipPermissions, + includeSessionId = true, + model, + permissionMode, + mcpConfig, + strictMcpConfig, + disallowedTools, + appendSystemPromptFile, + thinking, + thinkingDisplay, + cliVersion, + } = opts const args = [ + "--print", "--output-format", "stream-json", "--input-format", "stream-json", + "--include-partial-messages", "--verbose", ] @@ -121,13 +404,56 @@ 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 (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) + } + if (skipPermissions) { args.push("--dangerously-skip-permissions") } diff --git a/src/startup-diagnostics.ts b/src/startup-diagnostics.ts new file mode 100644 index 0000000..4393839 --- /dev/null +++ b/src/startup-diagnostics.ts @@ -0,0 +1,238 @@ +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 + +/** + * 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 { + 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 +} 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..bfe0d3b --- /dev/null +++ b/src/todo-ledger.ts @@ -0,0 +1,133 @@ +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 +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) + } + return ledger +} + +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/types.ts b/src/types.ts index 89ab498..369e56a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,15 +1,327 @@ +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 + providerID?: string skipPermissions?: boolean + permissionMode?: PermissionMode + mcpConfig?: string | string[] + strictMcpConfig?: boolean + bridgeOpencodeMcp?: boolean + controlRequestBehavior?: ControlRequestBehavior + controlRequestToolBehaviors?: Record + controlRequestDenyMessage?: string + proxyTools?: string[] + 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: opencode's question form is currently + * broken upstream, so enabling this trades a working text prompt for a + * silent hang. 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 + 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 & {}) + 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[] 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[] + + /** + * 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` → + * 60 min (subagents routinely run 20–40 min); `question` → 30 min + * (operator AFK). Setting a key here replaces the default for that tool. + * + * 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. + */ + 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. + * + * Two reasons it is opt-in. opencode's `question` form does not currently + * render (upstream anomalyco/opencode#36604), so an enabled bridge hangs + * the turn until the operator interrupts; and older opencode builds have + * no `question` registry entry at all, in which case the plugin silently + * keeps the text path. See the plan-mode gotcha in AGENTS.md. + */ + 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 + + /** + * 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 `--session-id` is preserved 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`. With both layers configured for the same MCP server, + * direct bridging causes each tool invocation to execute twice — once by + * Claude CLI's own MCP child process and once by opencode. Routing through + * the proxy keeps a single execution site (opencode) while preserving the + * tool-call/result surface in opencode's UI and its permission prompts. + * + * Defaults to `true`. Set to `false` to restore the prior direct-bridge + * behavior (Claude CLI executes MCP tools itself; opencode also re-executes + * — accept the duplication if you need Claude to invoke the tool without + * an opencode round-trip). + */ + 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 +330,25 @@ export interface ClaudeCodeProviderSettings { export interface ClaudeStreamMessage { type: string subtype?: string + request_id?: 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 @@ -50,7 +381,6 @@ export interface ClaudeStreamMessage { total_cost_usd?: number duration_ms?: number duration_api_ms?: number - request_id?: string id?: string result?: string is_error?: boolean @@ -61,6 +391,12 @@ export interface ClaudeStreamMessage { 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-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..1170e0d --- /dev/null +++ b/test-auto-continue.ts @@ -0,0 +1,618 @@ +/** + * 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 } 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("v0.4.17 max_tokens stop_reason stops via protocol signal", () => { + // v0.4.17: ANY stop_reason value is authoritative. max_tokens is the + // model signaling a stop (it was cut off but the protocol said stop). + const result = shouldAutoContinueIncompleteTurn( + state(), + snap({ + text: "Working on it", + hadReasoning: true, + hadToolActivity: true, + stopReason: "max_tokens", + }), + ) + assert.deepEqual(result, { continue: false, reason: "max-tokens" }) +}) + +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" }) +}) 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..14bdf4f --- /dev/null +++ b/test-broker.ts @@ -0,0 +1,287 @@ +/** + * 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, + type PendingProxyCall, +} from "./src/proxy-broker.js" +import type { ProxyToolCall, 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: 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")) +}) diff --git a/test-claude-session-wrapper.ts b/test-claude-session-wrapper.ts new file mode 100644 index 0000000..9ff3a5c --- /dev/null +++ b/test-claude-session-wrapper.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict" +import * as path from "node:path" +import { test } from "node:test" +import { + decodeUserEnvelope, + 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() +}) + +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..f3fd242 --- /dev/null +++ b/test-cli-args.ts @@ -0,0 +1,315 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { + buildCliArgs, + claudeSpawnEnv, + isClaudeThinkingDisabled, +} from "./src/session-manager.js" +import { + cliSupportsThinking, + cliSupportsThinkingDisplay, +} from "./src/cli-version.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) +}) + +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({}), []) +}) diff --git a/test-compaction-model.ts b/test-compaction-model.ts new file mode 100644 index 0000000..8cbe98d --- /dev/null +++ b/test-compaction-model.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict" +import { mkdtempSync, readFileSync, rmSync, unlinkSync } 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 }) + } +}) diff --git a/test-compress-tool.ts b/test-compress-tool.ts new file mode 100644 index 0000000..81a40ef --- /dev/null +++ b/test-compress-tool.ts @@ -0,0 +1,245 @@ +/** + * 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, + type ProxyMcpServer, + 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/) +}) diff --git a/test-config-models.ts b/test-config-models.ts new file mode 100644 index 0000000..f80b171 --- /dev/null +++ b/test-config-models.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { configModelsForProvider } 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 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 (2×)") + 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: 2, + output: 10, + cache_read: 0.2, + cache_write: 2.5, + }) + + 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)) +}) + +// 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-mythos-5", + ]) { + 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") +}) diff --git a/test-cwd-resolution.ts b/test-cwd-resolution.ts new file mode 100644 index 0000000..8a0bb7f --- /dev/null +++ b/test-cwd-resolution.ts @@ -0,0 +1,117 @@ +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("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-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..f021495 --- /dev/null +++ b/test-get-claude-user-message.ts @@ -0,0 +1,379 @@ +/** + * 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 { getClaudeUserMessage } 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, undefined, { + 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("compaction suppresses reasoning keyword injection", () => { + const out = JSON.parse( + getClaudeUserMessage( + p([ + { role: "user", content: "anything" }, + { role: "assistant", content: [{ type: "text", text: "ok" }] }, + { role: "user", content: [{ type: "text", text: "summarize" }] }, + ]) as any, + false, + "max", + { compactionMode: true }, + ), + ) + const texts = out.message.content + .filter((b: any) => b.type === "text") + .map((b: any) => b.text) + .join("\n") + assert.ok( + !texts.includes("(ultrathink)"), + "reasoning keyword should be suppressed in compaction mode", + ) +}) + +test("non-compaction call still injects reasoning keyword", () => { + const out = JSON.parse( + getClaudeUserMessage( + p([{ role: "user", content: "hello" }]) as any, + false, + "max", + ), + ) + const texts = out.message.content + .filter((b: any) => b.type === "text") + .map((b: any) => b.text) + .join("\n") + assert.ok( + texts.includes("(ultrathink)"), + "reasoning keyword should still be injected for normal turns", + ) +}) + +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=") +}) 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-proxy-mcp.ts b/test-proxy-mcp.ts new file mode 100644 index 0000000..788dfc6 --- /dev/null +++ b/test-proxy-mcp.ts @@ -0,0 +1,708 @@ +/** + * 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, + DEFAULT_PROXY_TOOLS, + PROXY_DEFAULT_TIMEOUT_MS, + MAX_PROXY_TIMEOUT_MS, + 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 defaults to 60 min", () => { + assert.equal(resolveProxyCallTimeoutMs("task", undefined, undefined), 60 * MIN) +}) + +test("resolveProxyClientCeilingMs covers the largest deadline", () => { + // No overrides: ceiling is the biggest per-tool default (task, 60 min). + assert.equal(resolveProxyClientCeilingMs(undefined), 60 * MIN) + // Overrides above the defaults raise the ceiling so Claude's HTTP MCP + // client never aborts before the broker deadline fires. + assert.equal(resolveProxyClientCeilingMs({ task: 90 * MIN }), 90 * MIN) + // Overrides below the defaults do not lower it. + assert.equal(resolveProxyClientCeilingMs({ bash: 1 * MIN }), 60 * MIN) + // Absurd values are clamped to Node's timer max. + assert.equal( + resolveProxyClientCeilingMs({ task: 2 ** 40 }), + MAX_PROXY_TIMEOUT_MS, + ) +}) + +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", () => { + // 0 / negative / NaN must not replace the default — a misformed config + // entry should never collapse the deadline. + assert.equal( + resolveProxyCallTimeoutMs("task", undefined, { task: 0 }), + 60 * MIN, + ) + assert.equal( + resolveProxyCallTimeoutMs("task", undefined, { task: -100 }), + 60 * MIN, + ) + assert.equal( + resolveProxyCallTimeoutMs("task", undefined, { task: NaN as any }), + 60 * MIN, + ) +}) + +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 covers the longest per-tool default", () => { + // The ceiling is written into Claude's --mcp-config entry; if it were + // below task's 60 min the client would abort before the broker resolved. + assert.ok(resolveProxyClientCeilingMs(undefined) >= 60 * MIN) +}) + +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() + } +}) diff --git a/test-proxy-task.ts b/test-proxy-task.ts new file mode 100644 index 0000000..b28d0cf --- /dev/null +++ b/test-proxy-task.ts @@ -0,0 +1,936 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { + chmodSync, + 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, + resolveProxyClientCeilingMs, + SERVER_CLOSED_MESSAGE, + type ProxyMcpServer, +} from "./src/proxy-mcp.js" +import { + getPendingProxyCalls, + onPendingProxyCall, + queuePendingProxyCall, + rejectAllPendingProxyCallsForSession, + rejectPendingProxyCallById, + resolvePendingProxyCallById, + type PendingProxyCall, +} from "./src/proxy-broker.js" +import { deleteActiveProcess, 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", +) { + const cwd = mkdtempSync(join(tmpdir(), "opencode-proxy-task-")) + const cliPath = join(cwd, "fake-claude.cjs") + 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." }, + { + 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") { + 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) { + const response = await fetch(proxyUrl, { + method: "POST", + headers: { "content-type": "application/json", ...proxyHeaders }, + body: JSON.stringify({ + jsonrpc: "2.0", + id, + method: "tools/call", + params: { name: "task", arguments: input }, + }), + }) + return response.json() +} + +let handled = false +readline.createInterface({ input: process.stdin }).on("line", () => { + 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 === "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 } +} + +async function streamTaskBoundary( + mode: "normal" | "race" | "batch" | "duplicate" | "error", +) { + const fake = createFakeTaskCli(mode) + const modelId = `claude-test-task-${mode}` + const sk = sessionKey(fake.cwd, `${modelId}::tools::default`) + + 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) + }) + }) +} + +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 (task's 60-min default here), so + // Claude's remote-HTTP MCP client never aborts before the broker does. + assert.equal( + generatedConfig.mcpServers.opencode_proxy.timeout, + resolveProxyClientCeilingMs(undefined), + ) + assert.equal(resolveProxyClientCeilingMs(undefined), 60 * 60 * 1000) + + 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, + ]) +}) + +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`) + 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`) + 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 + }, + }) + 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..4177b27 --- /dev/null +++ b/test-respawn.ts @@ -0,0 +1,89 @@ +/** + * Unit tests 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`. The + * full respawn spawns a real child and is exercised live by the doStream + * start-watchdog, not here. + * + * Usage: + * npx tsx --test test-respawn.ts + */ +import assert from "node:assert/strict" +import { test } from "node:test" + +import { + appendResumeIfNeeded, + respawnActiveProcess, + setClaudeSessionId, + deleteClaudeSessionId, +} from "./src/session-manager.js" + +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) + } +}) + +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, + ) +}) 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..d002942 --- /dev/null +++ b/test-session-manager.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict" +import { EventEmitter, once } from "node:events" +import { test } from "node:test" +import { spawn, type ChildProcess } from "node:child_process" +import { + buildCliArgs, + deleteActiveProcess, + deleteActiveProcessAndWait, + deleteClaudeSessionId, + getActiveProcess, + getClaudeSessionId, + setActiveProcess, + setClaudeSessionId, + spawnClaudeProcess, + type ActiveProcess, +} from "./src/session-manager.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) + } +}) diff --git a/test-spawn-env.ts b/test-spawn-env.ts new file mode 100644 index 0000000..7a42ccd --- /dev/null +++ b/test-spawn-env.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { claudeSpawnEnv } from "./src/session-manager.js" + +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") + }) +}) 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..0984bc0 --- /dev/null +++ b/test-subagent-hint.ts @@ -0,0 +1,268 @@ +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, + 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) +}) diff --git a/test-todo-ledger.ts b/test-todo-ledger.ts new file mode 100644 index 0000000..9ad4aec --- /dev/null +++ b/test-todo-ledger.ts @@ -0,0 +1,169 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { + _resetAllLedgersForTests, + applyTaskCreateToolResult, + applyTaskCreateToolUse, + applyTaskUpdate, + clearLedger, + getLedger, +} 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 + } +}) 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") +})