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 156 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. |
Port claudeSession to Bun.spawn({terminal}) so the plugin can drive interactive claude in-process (subscription path) without node-pty or a node sidecar. Multi-turn ClaudeSession + askOnce, JSONL-tail capture, stop_reason completion. e2e green vs real claude (3-message chat, context retained, prompt-cache reuse). Not wired into doStream yet.
Gated by CLAUDE_CODE_INTERACTIVE_TRANSPORT (self-healing on Bun.Terminal). When on, doStream drives the interactive claude TUI over Bun native ConPTY + JSONL-tail instead of headless --print stream-json, keeping calls on the subscription path. ClaudeSession.tailTurn re-emits transcript records; claude-session-wrapper adapts it to the ActiveProcess contract and synthesizes a result line so the existing finish branch runs unchanged. Headless stays the default. Also fix an orphan tool-result for skipped internal tools on the non-partial branch (register toolCallsById only inside !skip, mirroring the streaming path). Verified e2e: multi-turn text + built-in tool + MCP via doStream, and a real opencode run.
tailTurn reported only the final assistant record's output_tokens, undercounting multi-record (tool) turns. Sum output across all records this turn; keep input/cache from the last record (full context); patch iterations[last] since toUsage prefers it. Verified: a tool turn reports 422 = transcript sum across 4 records; input = last-record full context.
Read the interactive flag from provider options (provider.claude-code.options.interactive / interactiveBypass) in addition to the env var, so the opencode GUI app - which does not inherit User-scope env vars - can enable it via config. Falls back to CLAUDE_CODE_INTERACTIVE_TRANSPORT.
ask() and tailTurn() checked this.exited before reading the JSONL transcript, so a final assistant record flushed in the same poll tick as process exit was dropped (turn errored, or returned a null stop_reason). Read the transcript first; react to exit only when no new lines remain.
A large/multi-line bracketed paste collapses into a "[Pasted text]" placeholder in the Claude TUI. The old code pressed Enter after a fixed 200ms delay, but for a big paste ConPTY is still draining bytes then, so the \r lands inside the still-open paste and is silently dropped. The turn never submits and the call hangs until turnTimeoutMs (120s). Replace the blind delay+Enter in both ask() and tailTurn() with submitTurn(): press Enter, poll the JSONL transcript for growth past the cursor (turn accepted on first record write), and resend Enter until accepted, up to submitMaxRetries. Condition-based instead of timing-based, so robust to paste size; polling growth also avoids a stray Enter once the turn is in flight. Ports the fix already applied to the node-pty session. typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Interactive transport now appends only the plugin's own CLI/AGENTS/ continuation prompt, not opencode's forwarded system prompt, which can trip Claude Code's third-party-app usage gate on subscription accounts. Headless --print is unchanged. Document interactive fresh-session hang as a known issue.
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.