feat: selective tool proxying, session isolation, MCP bridging, and streaming fixes - #16
khalilgharbaoui wants to merge 300 commits into
Conversation
|
@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). |
|
@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. |
The 0.3.0 short-circuit only looked for text and tool-result parts, so an image-only user turn (image attached, no text) was treated as empty and dropped to a synthetic stop response. Image and file parts also count as fresh user input.
Warnings such as MCP config parse failures and dropped image parts were only emitted when DEBUG=opencode-claude-code, hiding real problems from users running the plugin normally.
The HTTP handler awaited resolution forever. If the broker chain broke between turns or opencode quit mid-call the Claude subprocess sat idle waiting for a tool result that would never arrive. 10 min matches Claude CLI's hard upper bound for Bash.
The 5-second result fallback wording was carried over from before 0.2.6 reworked the timer into a 60s wire-inactivity watchdog with a 5s abort-grace path.
Bridged-MCP config and proxy-MCP config were written to /tmp with shared filenames and never deleted. Multiple opencode processes could race on the same path, and files leaked across runs. Now each plugin instance writes into /tmp/opencode-claude-code-<pid>/ which is rm'd in a process exit handler. The proxy server also unlinks its own config in close() so cleanup happens as soon as the subprocess dies.
Claude CLI dispatches all tool_use blocks in an assistant message in parallel (e.g. two bash calls in one turn). The proxy broker tracked a single pending call per session, so the second call was rejected and Claude saw spurious tool errors. Re-key the broker by toolCallId with a sessionKey reverse index. Buffer pending calls in the language model and drain after a short quiet window so every parallel call lands in one tool-calls stream finish. Resolve each call by id from the next-turn prompt; reject orphans so claude CLI's HTTP handlers do not hang. Reject session-wide on subprocess close/error. Adds test-broker.ts with multi-call queue/resolve/reject coverage.
…sult
Two bugs caused MCP servers to disappear from Claude CLI's view and proxy
tool calls to time out:
1. {env:VAR} placeholders not substituted in bridged MCP config.
translateServer wrote the raw spec.environment / spec.headers through
to the Claude CLI --mcp-config file, so any server using opencode's
interpolation syntax received the literal string '{env:VAR}' as its
credential value. Servers that validate credentials at startup
(slack-mcp-server) crashed before exposing tools; servers that defer
validation (github-mcp-server) registered fine but every API call
401'd. Now substitute placeholders from process.env in both env maps
and HTTP headers, matching what opencode does when it spawns MCPs
itself.
2. Drain race when Claude CLI emits result with a pending proxy call.
If the 100ms drain timer hadn't fired yet (or Claude CLI abandoned
the HTTP request after an internal timeout), the call sat in the
broker for the full 10-minute timeout, surfacing as a hard 2-minute
'operation timed out' to the SDK caller. Now drain through the normal
tool-calls flow at the turn-result boundary if anything is buffered,
and reject orphans so proxy-mcp returns to the caller immediately.
Adds optional system-prompt hint (multiStepContinuation, default true) encouraging Claude to complete multi-step tasks in one turn instead of pausing for user confirmation between subtasks. Each opencode turn boundary requires the user to press 'continue' to resume, so for multi-step work this reduces friction. Respects the design principle from 49345e3 (short-circuit empty turns): plugin still defers entirely to Claude's stop_reason; the hint nudges model behavior without overriding turn-end signals.
The smart auto-continuation heuristic was evaluating final-answer keywords (done|implemented|updated|summary|...) against the full accumulated text of every assistant turn since the last continue. Mid -task narration like 'Implementing now. Updated the search index.' hit those keywords reliably and short-circuited the auto-continue to 'final-answer' — STOP — even though the next text block was a mid-task pause and the user still expected more work. Track lastVisibleText separately: reset on each new text content_block start, append on text deltas. Pass it through AutoContinueSnapshot. Final-answer detection now considers only the most recent text block, which is the actual candidate end-of-turn sentence. Question / blocker detection still uses the accumulated text — a question raised earlier in the turn should still block auto-continue. Also add file-based logging at $XDG_DATA_HOME/opencode-claude-code/ plugin.log (defaults to ~/.local/share/opencode-claude-code/plugin.log) so NOTICE/WARN/ERROR are observable without depending on DEBUG=opencode-claude-code or stderr redirection. Auto-continue decisions are now NOTICE level (always emitted, both to stderr and file) instead of INFO (debug-only). Tests: 54 passing (+3 new for the last-block / accumulated split).
opencode delivers proxy MCP tool results in AI-SDK V3 tool-role messages. hasNewUserContent only inspected user/assistant roles, so turns carrying only a tool-result short-circuited to finishReason stop and forced the user to press continue after every proxy tool call. Now treats tool-role messages with any tool-result part as new content. Verified pattern in plugin.log: every wall was message_stop -> drain (tool-calls) -> 'doStream short-circuit: no new user content' -> [user pressed continue].
v0.4.7 fixed hasNewUserContent to detect tool-role tool-results, but getClaudeUserMessage still only iterated msg.role === 'user' and dropped tool-role messages. Result: the gate let the prompt through but the message builder emitted the '(empty)' sentinel, so Claude CLI saw a no-op turn and ended it — forcing the user to press 'continue' between every proxy tool call. Symmetric fix: when iterating recent messages, also extract tool-result parts from tool-role messages. Matches hasNewUserContent's shape so the two functions agree on where to find tool-results. Tests: 4 new in test-get-claude-user-message.ts cover tool_result emission, multiple results per message, sentinel fallback when a tool-role message has no tool-result parts, and mixed user+tool content. All 63 unit tests pass.
NOTICE was emitting to console.error (alwaysStderr=true), which opencode's TUI captures and renders as a UI warning bubble. That meant 'auto-continuation stopped reason: final-answer' — the normal happy-path log line after every successful turn — produced a yellow warning in the UI after each task. Reserve console output for warn/error (genuine problems). NOTICE remains always-on in the plugin.log file, so observability of auto-continue decisions and startup events is preserved without UI noise.
All four changes push the heuristic toward STOP — the safe failure direction. Adds three regex extensions and one threshold change. No behavior changes on the CONTINUE side; no new helper functions called from the hot path. Tweak 2 — Question regex picks up indirect offers: let me know if|let me know whether|let me know what|if you'd like| if you want to|tell me if|tell me which|tell me whether| say go|say yes|push back|sign off|sounds good|sounds right| your call|your move|up to you|ready to ship|happy to proceed|... Tweak 3 — Blocker regex picks up intent-equivalents to 'requires your': needs your|needs you to|action required Tweak 4 — Final-answer length floor lowered 40 → 30 chars so short clean completions like 'Task is now completely done. Pushed.' match. Tweak 5 — '?' anywhere in the last block (was: endsWith only). Catches long answers that pose a question mid-text then list options and end with a period. FP risk on inline code (`result?.value`) accepted — cost is one extra continue press in the safe direction. Validated against 32-case sim corpus: 22/32 baseline → 28/32 candidate. Zero false positives. Real fires (today's 03:31:16 'say go or push back' and earlier 02:48:11 'if you want to') flip from FP to clean stops. Tweak 1 (mid-task continuation override of completion-keyword detection) prototyped in sim/eval-candidate.ts but NOT shipped — would widen auto-continue (unsafe direction), and zero G-class fires observed in real plugin.log. Sim infrastructure committed under sim/ as permanent regression bench.
Three headless-transport lifecycle fixes: - baseline error listener on the child's stdin, so a write after the child died is logged instead of throwing inside opencode - LRU eviction picks the oldest idle process and skips the round when every process is mid-turn, instead of truncating a live answer - a child that closes without a terminal result ends the turn as an error with its exit status and retained stderr tail, not as a stop
task and task_batch no longer have a default deadline. Every way a call can end is observed and released on both the broker and the open HTTP request: opencode's result, an abort on any of its three paths, the next user message, the child exiting mid-turn or between turns, the chat being deleted, or opencode exiting. A positive proxyToolTimeoutMs still adds a wall-clock backstop; 0 now means "no deadline" consistently. Also: session.deleted hook, host-exit sweep, respawn keeps the in-flight marker, idle timer re-arms on a busy worker, skill bridge on doGenerate and interactive spawns, JSON-only keepalive. Defaults changed on purpose: bridgeOpencodeSkills true, idleProcessTimeoutMs 30 min, process cap 8.
opencode-dcp declares `compress` directly rather than through an MCP server, so the automatic MCP routing skipped it and the model could never obey dcp's "you MUST use the compress tool now" reminders. `proxyOpencodeTools` is an explicit allowlist, empty by default, that forwards such a tool through the existing broker. `stripContextReminders` removes the reminders when no compress tool is reachable. The `compress` name collision is resolved at both layers: the forwarded def loses a contested name, and the in-process interceptor is registered only for the plugin's own def, not for any def that happens to be called compress.
proxyOpencodeMcpTools defaulted to true and routed nothing. Discovery read client.tool.list(), which enumerates opencode's tool registry: built-ins plus plugin-declared tools, never MCP ones. opencode merges MCP tools into the model's tool set after the registry is read, so the `tools` argument of doStream is the only place a provider plugin can see them. resolveMcpProxyToolDefs reads that instead; the server-name prefix match is unchanged and was always correct. Default goes true to false, which changes no behaviour because the option was inert. Leaving it on would have silently moved every user's MCP traffic off the direct bridge that carries it today. excludeServers now names only the servers a def was built for, so an enabled server with no def is not dropped from --mcp-config without being put on the proxy.
Set DISABLE_AUTOUPDATER=1 and CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 on every spawned claude, on both the headless and interactive paths, and only where the user has not set the var themselves. detectCliVersion caches one version per cliPath for the life of the opencode process, and --thinking-display summarized, --plugin-dir and fast mode are all gated on it. A CLI that updates itself mid-session leaves those gates describing a binary that is no longer running. Both names were read out of the 2.1.263 bundle rather than assumed. Also records a 2026-09-20 re-measurement of auto-continue and abort against that CLI in AGENTS.md.
* Offer another account when one hits its limit * Escape the NUL that made the module binary
* Correct the plan-mode bridge's dormancy note * Say why a silent CLI closed the turn * Bound the serve-mode session and ledger maps
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.