feat: selective tool proxying, session isolation, MCP bridging, and streaming fixes - #16
feat: selective tool proxying, session isolation, MCP bridging, and streaming fixes#16khalilgharbaoui wants to merge 214 commits into
Conversation
- Read providerOptions[provider].reasoningEffort and inject the corresponding Claude Code thinking keyword (think / think hard / think harder / megathink / ultrathink) into the outgoing user message. Enables a low/medium/high/xhigh/max effort selector when declared as variants on a model in opencode.json. - Convert AI SDK v3 file/image content parts with image/* mediaType into Claude's image content blocks. Supports URL, data URL, raw base64 string, and Uint8Array/Buffer sources. - Use a single-space placeholder for the empty-content fallback so cache_control markers never land on an empty text block (the Anthropic API rejects that combination).
The Claude CLI rejects a zero-block user message with 400, so we send a placeholder when no text/image/tool-result survives filtering. The prior "(continue)" string was being read as an instruction by the model, causing it to resume its previous response. Swap to "." — non-whitespace (so Anthropic's API accepts it) and minimally directive. Also add a log.warn to observe how often this path fires.
- Always report finishReason "stop" on CLI result messages — tools ran internally, so "tool-calls" made opencode loop trying to execute them again (and fed empty content back to the CLI, triggering the sentinel fallback repeatedly). - Drop forwarded tool_result for tools we reported as providerExecuted:false so opencode's own execute path runs instead of being short-circuited. - Route TodoWrite through opencode (executed: false) so Todo.Service and the UI widget get populated.
…te convention
"." was non-whitespace but still directive-feeling; the model could read
it as a continuation cue. Switch to "(empty)", which matches the
parenthetical keyword pattern this file already uses for reasoning effort
("(think)", "(megathink)", etc.) — the model reliably treats those as
out-of-band metadata rather than content.
Adds `mcpConfig` (string | string[]) and `strictMcpConfig` (boolean) to provider settings so users can point Claude CLI at the same MCP servers their opencode config references, instead of maintaining two separate MCP configs. Also corrects the "one session per directory per model" README entry — separate opencode instances are separate Node processes with separate plugin state, so they don't literally share a CLI process; what they can share is the CLI's own filesystem state under `.claude/`.
…de CLI Session keying now includes the `x-session-affinity` header opencode sets on LLM calls to third-party providers, so two chats in the same cwd+model get separate CLI processes instead of stomping on each other. Adds an LRU cap of 16 live subprocesses so session-affinity keying doesn't accumulate processes unboundedly. Adds `bridgeOpencodeMcp` (default true): discovers opencode config via OPENCODE_CONFIG, OPENCODE_CONFIG_DIR, walk-up from cwd, and XDG; parses JSONC; translates opencode's `mcp` schema (type-discriminated, single `command: string[]`, `environment`) to Claude CLI's `--mcp-config` shape (`mcpServers`, separate `command`/`args`, `env`); writes a temp scratch file and passes it through on spawn. Precedence: global < project < OPENCODE_CONFIG_DIR < OPENCODE_CONFIG, matching opencode's merge order. User-supplied `mcpConfig` entries stack on top of the bridged file. Remaining known limitation (permission UI bypass) restated honestly in README — a real fix requires a plugin-level permission.ask bridge via Claude CLI's --permission-prompt-tool, which is out of scope here.
…mer, Windows spawn - Use usage.iterations[-1] instead of cumulative totals to prevent inflated context size estimates and premature compaction - Emit text-start/delta/end per content block instead of one pair per turn so partial text is preserved on abort - Add 5s fallback timer that closes the stream if CLI emits content but never sends a result event (session-reuse edge case) - Add shell: process.platform === 'win32' on both spawn sites so claude.cmd works on Windows
|
@khalilgharbaoui would you consider publishing to npm yourself? this repo is likely dead. I'm looking forward to use your fixes but I couldn't use it locally (clone + build). |
Publish maintained fork under a scoped npm name. Resets version to 0.1.0 since this is a new package on the registry. - package.json: scoped name, author, publishConfig.access=public, repo URL - src/index.ts: PACKAGE_NPM and plugin id - src/models.ts: NPM constant used in default model api.npm - jsr.json: scope updated - README: title, fork attribution, npm install + all config snippets
Keep the original 'Claude Code' product name (vs the dropped 'code' or invented 'cli' suffix) and use a scoped fork pattern so the relationship to the upstream unixfox/opencode-claude-code-plugin stays legible.
- Correct model IDs (claude-haiku-4-5, claude-sonnet-4-5/4-6, claude-opus-4-5/4-6/4-7) instead of the haiku/sonnet/opus aliases inherited from the upstream README. - Show the minimum config (just "npm") up front; move the full options block into a reference section so users don't think they have to redeclare models. - Document the proxy MCP architecture, MCP bridge discovery order, session keying with x-session-affinity, plan mode handling, and the recent fixes (empty text block drop, lazy cwd, per-iteration usage, fallback timer).
- Recommend the simpler 'plugin: [...]' form as the primary config; the plugin's config hook self-registers the provider, so a separate provider.claude-code.npm block isn't needed. - Fix the proxy-tools table: only Edit (not MultiEdit) is disabled when 'Edit' is in proxyTools; call out the MultiEdit gap explicitly. - Note that only bash/edit/write/webfetch are valid proxyTools values; anything else is silently ignored. ci: set NODE_AUTH_TOKEN on publish step Required for npm publish to authenticate via NPM_TOKEN; without it the workflow runs but auth fails.
|
@emreycolakoglu yep — I went ahead and published a maintained fork. It is on npm now, no clone/build needed:
Just add it to the Includes the fixes from this PR plus a couple of regressions I hit afterwards (empty-text-block 400s, variant selection on model pick, lazy cwd resolution). Issues / PRs welcome over on the fork. |
Route ExitPlanMode through opencode's native `question` tool: render the plan, end the turn on `tool-calls`, then feed the operator's answer back to the CLI as the `tool_result` for the original ExitPlanMode tool_use. That tool_result is what actually unlocks plan mode; a "yes" typed as ordinary prose never does. Absorbed from CollieIsCute's fork (8c5b583) with authorship preserved, per their go-ahead on issue #21. Maintainer adaptations on top of the original commit: - Gated behind a new `planModeQuestion` option, default off. opencode's question form does not currently render (anomalyco/opencode#36604), so an ungated bridge would trade a working text prompt for a hang. All four ExitPlanMode sites keep the legacy text path in the `else`. - Gated on the live registry too (`isPlanModeQuestionActive`): emitting a `question` tool-call on a build without that entry renders as invalid and wedges the turn. - Gate resolved in the doStream/doGenerate prologue, since the branches run in a synchronous line handler and a reused process never reaches the spawn block. - `fetchLiveToolInfo` memoized via `liveToolInfoOnce()` so the plan-mode gate shares the single `tool.list()` fetch with the proxy overlays; unresolved fetches are not memoized. - Surfaced in the startup diagnostics block. - Dropped the fork's unrelated package.json changes (`prepare` script, tsx version), kept the test-script entry. Closes #21.
Parse opencode's real question tool result wrapper so a plan approval answer maps back to the ExitPlanMode tool_use instead of being read as free text. Replace the model-lifetime registry memo with a per-turn loader so a later turn sees runtime tool changes. Clear pending approvals centrally from deleteClaudeSessionId, and drop the stray prepare lifecycle script (CI builds explicitly before publish).
Screenshots and pasted images from opencode were silently dropped because toImageBlock() read part.data ?? part.url ?? part.source?.data but AI SDK v4 ImagePart stores the binary in part.image. Fix: add part.image as the first candidate in the lookup chain. All existing type branches (string/Uint8Array/Buffer/URL) already handle the values that part.image can hold. (cherry picked from commit 60a6e9a)
Add two regression tests for toImageBlock: a v4 part.image payload must survive (fails without flupkede's fix) and a data-carrying file part must keep working. Update the fork sweep note with why the two compress commits are held: JSON-RPC error envelope on tools/call, unconditional context-note rewrite, restart racing pending tool results, and a prompt that overstates how much context the restart actually drops.
Reimplements flupkede's compress branch. Claude calls mcp__opencode_proxy__compress with a summary; the plugin answers it in-process through a new interceptor map on the proxy MCP server, then resets the Claude session at the start of the next turn so the fresh child carries only that summary. Fixes four defects in the original: interceptor failures now return an MCP result with isError instead of a JSON-RPC envelope the CLI rejects, the summary survives the deleteClaudeSessionId that the reset itself calls, the reset defers while a turn is delivering tool results, and the runtime note only advertises the tool when it is actually enabled. Off by default, like Question. Tests in test-compress-tool.ts.
TaskOutput is displayed by running a real bash call, and only `"` was
escaped, so `$(...)`, backticks and `${...}` in the model-controlled
payload were executed while the operator saw a command that reads like a
print. Wrap the payload as one single-quoted word and print it with
printf, which also avoids echo's shell-dependent backslash handling.
Reported by @tkszeler in #27, with the printf fix they suggested. Tests
run the generated command through bash for six payload shapes.
proxyTools derives --disallowedTools from a literal name map, so a Claude built-in with no proxy equivalent has no off switch: NotebookEdit today, and anything added after this release. Add extraDisallowedTools, merged with the proxy-implied set and the WebSearch case by one resolver. Also stop resolvedProxyTools swallowing unknown names. A typo used to leave the matching built-in enabled and unmediated with no signal, and a wholly unrecognised list disabled proxying entirely. Reported by @tkszeler in #26. The NotebookEdit proxy they also suggest is not included: it needs a matching opencode registry entry to forward to, which is unverified.
opencode and models.dev express `cost.input` / `cost.output` /
`cost.cache_read` / `cost.cache_write` in dollars per MILLION tokens —
opencode divides by 1e6 itself when multiplying a cost by a token count.
models.dev's own entry for the same model reads
`anthropic/claude-haiku-4-5 -> {"input": 1, "output": 5, "cache_read":
0.1, "cache_write": 1.25}`.
The constants here were written as per-token dollars (1e-6 for Haiku
input), so every session cost opencode reported came out exactly
1,000,000x too low — effectively always $0.00. Token counts, including
the cache read/write split, were already correct; only the dollar amount
was wrong.
Verified end-to-end against opencode 1.18.12 with a real Haiku 4.5 turn
(10 input / 62 output / 10,583 cache write / 15,973 cache read):
before: $0.00000002 (reported / actual = 0.000001)
after: $0.01514605 (reported / actual = 1.000000)
The `(N×)` multiplier suffix on display names is unaffected — it is
derived from the input/output price ratios, which are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The in-process proxy MCP server binds an HTTP listener on 127.0.0.1 and
exposes tools that opencode executes, including bash, edit and write. The
handler accepted any POST to /mcp that parsed as JSON-RPC 2.0: no
authentication, no Origin or Host validation, and no Content-Type check.
The generated MCP config carried only {type, url}, so there was no shared
secret at all.
Any local process could therefore drive the endpoint, and because
Content-Type was unvalidated a cross-origin page could send a CORS
"simple request" with text/plain and get blind execution after finding
the port. Queued calls are drained and executed without correlation to a
model request, so an injected call runs as though the model had asked
for it.
Mint a 256-bit token per server, hand it to Claude in the headers block
of the generated MCP config (the CLI replays configured headers on every
request), and require it on every inbound call. Reject a foreign Host to
defeat DNS rebinding, reject any Origin, and require application/json so
cross-origin callers are forced into a preflight that fails. All guards
run before the body is read, so an unauthenticated peer cannot stream an
unbounded body into memory. The token is compared with timingSafeEqual
and is kept out of the URL and out of every log line.
test-proxy-task.ts drives the proxy MCP server two ways, and both were unauthenticated once the endpoint began requiring a bearer token. postRpc now takes the server rather than a bare URL so it can send the Authorization header. The fake Claude CLI already parsed the generated --mcp-config to find the proxy URL, so it now reads the headers block from that same entry and replays it on each call, which is what a real MCP client does. That makes these tests exercise the full round trip: config generation, client replay, and server validation.
Two review findings from a cross-family pass. Rejected requests ended the response but left the connection usable. A peer could 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 blocked behind an unauthenticated caller for Node's five-minute request timeout. All five reject paths now go through one helper that sets Connection: close and tears the socket down once the response has flushed. The new regression deliberately never finishes its body. An earlier version of the suite would have masked this, because it destroyed the socket client-side as soon as the response arrived -- exactly the cleanup the server must not depend on. Mutation-checked: with only the Connection: close and teardown removed it fails at 4s instead of passing at 4ms. The 0600 mode assertion is now POSIX-gated. Node implements no owner/group/other mode bits on Windows, where it commonly reads back 0666 and confidentiality rests on the inherited ACL of os.tmpdir() instead, so asserting it there tested nothing and claiming it in the PR would have promised a guarantee this patch does not provide. Comments at the Host and Origin guards were corrected too: the exact-Host check blocks DNS rebinding, NOT a page posting directly to the loopback port, which sends exactly the expected Host.
The Host, Origin and Content-Type guards are measured properties of the Claude CLI we spawn rather than spec guarantees, so a client-side change would 403 every proxy call with no other symptom. Report the reason at NOTICE, carrying no header values. Also authenticate the compress tests and write the invariants down.
Summary
This PR brings 18 commits that address the three known limitations listed in the original README and add significant new functionality. The changes fall into four areas:
1. Selective Tool Proxy — route dangerous tools through opencode's permission system
The headline feature. Claude CLI normally executes tools (Bash, Edit, Write, WebFetch) internally, bypassing opencode's permission UI entirely. This PR adds a
proxyToolsoption that selectively disables Claude's built-in tools and replaces them with equivalent MCP proxy tools hosted by an in-process HTTP server.How it works:
127.0.0.1(random port) whenproxyToolsis configured.--disallowedTools <ToolName>is passed to the CLI.tool-callto opencode → opencode runs the real tool with its native permission checks → the result flows back to Claude.New files:
src/proxy-mcp.ts(MCP server),src/proxy-broker.ts(pause/resume broker).Supported proxy tools:
Bash,Edit,Write,WebFetch.Config:
{ "options": { "proxyTools": ["Bash", "Edit", "Write", "WebFetch"] } }2. Session isolation — no more cross-chat interference
Sessions are now keyed by
(cwd, model, x-session-affinity)instead of just(cwd, model). Thex-session-affinityheader is set by opencode on LLM calls to third-party providers, so two simultaneous chats in the same project get separate CLI processes. An LRU cap (16 processes) prevents subprocess accumulation.3. MCP config auto-bridging — one config, not two
The plugin now auto-discovers
opencode.json/opencode.jsonc(viacwd,OPENCODE_CONFIG,OPENCODE_CONFIG_DIR,$XDG_CONFIG_HOME/opencode) and translates itsmcpblock into Claude CLI's--mcp-configformat. Local servers gettype: \"stdio\", remote servers gettype: \"http\", disabled servers are skipped. This means MCP servers configured in opencode are automatically available to Claude CLI without maintaining a separate~/.claude/settings.json.New file:
src/mcp-bridge.ts.Config overrides:
bridgeOpencodeMcp(defaulttrue),mcpConfig(extra paths),strictMcpConfig.4. Streaming correctness fixes
0736306,5def53c): replaced\"(continue)\"with\"(empty)\"so the model doesn't interpret the sentinel as an instruction to resume the previous turn.33cb03a):TodoWriteandWebSearchare now forwarded as client-executed (not provider-executed), so opencode's todo UI and search results populate correctly.c665524): opencode sometimes passes tools as an object map rather than an array; the scope classifier now handles both.09db874): if Claude returns only aresultmessage with error text (rate limit, auth failure), it's now emitted as visible text instead of a blank turn.70badf9):can_use_toolcontrol requests get immediatecontrol_responsereplies with configurable allow/deny policy, preventing stream deadlocks.6d126c3, refined in4af2a96): usesusage.iterations[-1]instead of cumulative totals and computesinputTokens.total = noCache + cacheRead + cacheWrite, preventing inflated context estimates and fixing cache-aware token accounting.6d126c3, refined in4af2a96): each text content block gets its owntext-start/delta/text-endlifecycle so partial text is preserved on stream abort.6d126c3, refined in4af2a96, tightened ince5701c): a 5-second timeout closes the stream gracefully if the CLI emits content but never sends aresultevent. The timer is now only armed on assistant text without tool use, abort starts a grace period instead of closing immediately, and the non-streaming path now honors proxied tools consistently.4af2a96): emitsproviderMetadata.anthropic.cacheCreationInputTokensso OpenCode can display cache write tokens correctly.ce3eb26): provider init no longer freezesprocess.cwd(), so each request resolves cwd at call time.Other improvements
0ae354c)93d610c):--thinking-effortpassthrough for low/medium/high/xhigh/max93d610c, hardened in4af2a96): base64 image parts forwarded to Claude CLI, plus MIME allowlist, robust data URI parsing, and early rejection of unsupported remote URL images--permission-modepassthrough (ea27f17)6d126c3):shell: process.platform === \"win32\"on both spawn sites soclaude.cmdworks on WindowsRelationship to other open PRs
This PR subsumes or addresses the core concerns of several other open PRs. We developed these independently and discovered many of the same issues:
--thinking-effort, and scope sessions byx-session-affinityheader. We intentionally keep our variant-based effort approach rather than model-suffix ergonomics.shell:truefor.cmdspawn6d126c3— same fix on both spawn sites.providerExecutedflag, empty content4af2a96: V3 spec (0ae354c),lastIterationUsageviaiterations[-1](6d126c3), cache-aware totals +noCache(4af2a96), per-block text emission (6d126c3), smarter fallback timing + abort grace (4af2a96),providerExecuted(33cb03a, refined in4af2a96), empty content sentinel (0736306,5def53c).93d610c; hardened in4af2a96with supported MIME allowlist, robust data URI parsing, and remote URL rejection.--effortflag via provider option93d610c— reasoning effort passthrough.PR #4 is only partially addressed here. Commit
ce3eb26adopts the safe cross-platform piece by resolvingcwdlazily per request instead of freezingprocess.cwd()at provider initialization. We intentionally did not adopt the desktop-specific SQLite/session lookup fallback, request-optionsessionID/cwdplumbing, or hard-coded path logic from#4, so#4remains distinct draft work for desktop-specific cwd recovery.PR #14 is independent and useful, but it's a standalone migration utility rather than a runtime plugin improvement.
Issues addressed
inputTokens.totalcrash): fixed by AI SDK v3 usage rewrite in0ae354cand the cache-aware usage refinements in4af2a96.0736306,5def53c,09db874,c665524,a663266, and6d126c3/4af2a96.Commits (chronological)
0ae354cfix: make claude-code provider compatible with AI SDK v393d610cfeat: add reasoning effort levels and image input support0736306fix: use neutral sentinel instead of "(continue)" for empty user content33cb03afix: correct tool-execution semantics for opencode-hosted tools5def53cfix: use "(empty)" sentinel matching provider's parenthetical meta-note conventionea27f17feat: expose --mcp-config passthrough and fix known-limitations wording1941685feat: fix session sharing and auto-bridge opencode MCP config to Claude CLI70badf9feat: handle Claude control-request permissions in stream-json mode09db874fix: surface CLI error text from stream-json result messagesc665524fix: detect object-shaped tools when choosing stream scopea663266fix: emit Claude-compatible MCP transport types in bridge4145493feat: proxy Bash through opencode tools and permissions9230421feat: proxy Edit and Write through opencode tools820cc22feat: proxy WebFetch through opencode tools and permissions6d126c3fix: per-iteration usage, per-block text emission, result fallback timer, Windows spawn4af2a96fix: refine usage accounting, text emission, fallback timing, and image handlingce5701cfix: honor proxied tools in doGenerate and tighten fallback handlingce3eb26fix: resolve cwd lazily per requestTest plan
tsc --noEmitpassestsupbuild passesopencode run \"hi\" -m claude-code/claude-sonnet-4-6returns visible output (or explicit rate-limit text, not blank)mcp__opencode_proxy__bash, opencode executes, result flows backmcp__opencode_proxy__edit, opencode executes file diffmcp__opencode_proxy__write, opencode writes filebash: askpermission rule: opencode'spermission.askedfires, auto-rejected in headless modex-session-affinityheaders get separate CLI processes\"(empty)\"sentinel, not blank or\"(continue)\"429responses surface visible error text instead of blank turnusage.iterations[-1]used when present, falls back to cumulativeinputTokens.totalincludes cache read/write,noCacheis populatedshell: truegated onprocess.platform === \"win32\"process.cwd()Breaking changes
None. All new features are opt-in via config. Default behavior is unchanged from upstream.
Known limitations
Bash,Edit,Write, andWebFetchare supported. More can be added when opencode gains matching built-in executors.can_use_toolcontrol requests for built-in tools. The selective proxy approach works around this entirely.