Skip to content
This repository was archived by the owner on Apr 26, 2026. It is now read-only.

feat: selective tool proxying, session isolation, MCP bridging, and streaming fixes#16

Open
khalilgharbaoui wants to merge 156 commits into
unixfox:masterfrom
khalilgharbaoui:master
Open

feat: selective tool proxying, session isolation, MCP bridging, and streaming fixes#16
khalilgharbaoui wants to merge 156 commits into
unixfox:masterfrom
khalilgharbaoui:master

Conversation

@khalilgharbaoui

@khalilgharbaoui khalilgharbaoui commented Apr 24, 2026

Copy link
Copy Markdown

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 proxyTools option 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:

  • An embedded MCP server starts on 127.0.0.1 (random port) when proxyTools is configured.
  • For each proxied tool, --disallowedTools <ToolName> is passed to the CLI.
  • Claude calls the MCP proxy tool instead → the plugin emits a client-executed tool-call to opencode → opencode runs the real tool with its native permission checks → the result flows back to Claude.
  • Non-proxied tools (Read, Glob, Grep, etc.) remain fully native to Claude CLI for performance.

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). The x-session-affinity header 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 (via cwd, OPENCODE_CONFIG, OPENCODE_CONFIG_DIR, $XDG_CONFIG_HOME/opencode) and translates its mcp block into Claude CLI's --mcp-config format. Local servers get type: \"stdio\", remote servers get type: \"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 (default true), mcpConfig (extra paths), strictMcpConfig.

4. Streaming correctness fixes

  • Empty content sentinel (0736306, 5def53c): replaced \"(continue)\" with \"(empty)\" so the model doesn't interpret the sentinel as an instruction to resume the previous turn.
  • Tool-execution semantics (33cb03a): TodoWrite and WebSearch are now forwarded as client-executed (not provider-executed), so opencode's todo UI and search results populate correctly.
  • Object-shaped tools (c665524): opencode sometimes passes tools as an object map rather than an array; the scope classifier now handles both.
  • CLI error surfacing (09db874): if Claude returns only a result message with error text (rate limit, auth failure), it's now emitted as visible text instead of a blank turn.
  • Control request handling (70badf9): can_use_tool control requests get immediate control_response replies with configurable allow/deny policy, preventing stream deadlocks.
  • Per-iteration usage (6d126c3, refined in 4af2a96): uses usage.iterations[-1] instead of cumulative totals and computes inputTokens.total = noCache + cacheRead + cacheWrite, preventing inflated context estimates and fixing cache-aware token accounting.
  • Per-block text emission (6d126c3, refined in 4af2a96): each text content block gets its own text-start/delta/text-end lifecycle so partial text is preserved on stream abort.
  • Result fallback timing (6d126c3, refined in 4af2a96, tightened in ce5701c): a 5-second timeout closes the stream gracefully if the CLI emits content but never sends a result event. 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.
  • Anthropic cache metadata (4af2a96): emits providerMetadata.anthropic.cacheCreationInputTokens so OpenCode can display cache write tokens correctly.
  • Lazy cwd resolution (ce3eb26): provider init no longer freezes process.cwd(), so each request resolves cwd at call time.

Other improvements

  • AI SDK v3 compatibility (0ae354c)
  • Reasoning effort levels (93d610c): --thinking-effort passthrough for low/medium/high/xhigh/max
  • Image input support (93d610c, hardened in 4af2a96): base64 image parts forwarded to Claude CLI, plus MIME allowlist, robust data URI parsing, and early rejection of unsupported remote URL images
  • --permission-mode passthrough (ea27f17)
  • Windows compatibility (6d126c3): shell: process.platform === \"win32\" on both spawn sites so claude.cmd works on Windows
  • Comprehensive README rewrite with architecture diagrams, config reference, and proxy documentation

Relationship 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:

Open PR Author What it does How this PR addresses it
#6 @simonseo Stream finish handling + effort passthrough + session scoping by effort We fix stream finish (result fallback timer, per-block text), pass --thinking-effort, and scope sessions by x-session-affinity header. We intentionally keep our variant-based effort approach rather than model-suffix ergonomics.
#9 @nbalzotti Windows shell:true for .cmd spawn Included in 6d126c3 — same fix on both spawn sites.
#12 @Aptul9 AI SDK V3 migration, per-iteration usage, per-block text, result fallback timer, providerExecuted flag, empty content We independently implemented all of these and then tightened the last details in 4af2a96: V3 spec (0ae354c), lastIterationUsage via iterations[-1] (6d126c3), cache-aware totals + noCache (4af2a96), per-block text emission (6d126c3), smarter fallback timing + abort grace (4af2a96), providerExecuted (33cb03a, refined in 4af2a96), empty content sentinel (0736306, 5def53c).
#13 @Aptul9 Image support in user messages Included in 93d610c; hardened in 4af2a96 with supported MIME allowlist, robust data URI parsing, and remote URL rejection.
#15 @waveywaves --effort flag via provider option Included in 93d610c — reasoning effort passthrough.

PR #4 is only partially addressed here. Commit ce3eb26 adopts the safe cross-platform piece by resolving cwd lazily per request instead of freezing process.cwd() at provider initialization. We intentionally did not adopt the desktop-specific SQLite/session lookup fallback, request-option sessionID/cwd plumbing, or hard-coded path logic from #4, so #4 remains 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


Commits (chronological)

  1. 0ae354c fix: make claude-code provider compatible with AI SDK v3
  2. 93d610c feat: add reasoning effort levels and image input support
  3. 0736306 fix: use neutral sentinel instead of "(continue)" for empty user content
  4. 33cb03a fix: correct tool-execution semantics for opencode-hosted tools
  5. 5def53c fix: use "(empty)" sentinel matching provider's parenthetical meta-note convention
  6. ea27f17 feat: expose --mcp-config passthrough and fix known-limitations wording
  7. 1941685 feat: fix session sharing and auto-bridge opencode MCP config to Claude CLI
  8. 70badf9 feat: handle Claude control-request permissions in stream-json mode
  9. 09db874 fix: surface CLI error text from stream-json result messages
  10. c665524 fix: detect object-shaped tools when choosing stream scope
  11. a663266 fix: emit Claude-compatible MCP transport types in bridge
  12. 4145493 feat: proxy Bash through opencode tools and permissions
  13. 9230421 feat: proxy Edit and Write through opencode tools
  14. 820cc22 feat: proxy WebFetch through opencode tools and permissions
  15. 6d126c3 fix: per-iteration usage, per-block text emission, result fallback timer, Windows spawn
  16. 4af2a96 fix: refine usage accounting, text emission, fallback timing, and image handling
  17. ce5701c fix: honor proxied tools in doGenerate and tighten fallback handling
  18. ce3eb26 fix: resolve cwd lazily per request

Test plan

  • tsc --noEmit passes
  • tsup build passes
  • opencode run \"hi\" -m claude-code/claude-sonnet-4-6 returns visible output (or explicit rate-limit text, not blank)
  • Proxy Bash: Claude calls mcp__opencode_proxy__bash, opencode executes, result flows back
  • Proxy Edit: Claude calls mcp__opencode_proxy__edit, opencode executes file diff
  • Proxy Write: Claude calls mcp__opencode_proxy__write, opencode writes file
  • Proxy WebFetch: proxied MCP tool is exposed and wired through the same selective proxy path
  • Proxy with bash: ask permission rule: opencode's permission.asked fires, auto-rejected in headless mode
  • MCP bridge: opencode MCP config translated to Claude CLI format (local → stdio, remote → http, disabled → skipped)
  • Session isolation: two chats with different x-session-affinity headers get separate CLI processes
  • Empty content: whitespace-only messages produce \"(empty)\" sentinel, not blank or \"(continue)\"
  • Rate-limit: 429 responses surface visible error text instead of blank turn
  • Per-iteration usage: usage.iterations[-1] used when present, falls back to cumulative
  • Cache-aware input totals: inputTokens.total includes cache read/write, noCache is populated
  • Windows: shell: true gated on process.platform === \"win32\"
  • Image handling: supported MIME types accepted, malformed data URIs and remote URLs rejected early
  • Lazy cwd resolution: provider no longer freezes init-time process.cwd()

Breaking changes

None. All new features are opt-in via config. Default behavior is unchanged from upstream.

Known limitations

  • Proxy tool set: only Bash, Edit, Write, and WebFetch are supported. More can be added when opencode gains matching built-in executors.
  • Non-proxied tools bypass opencode permissions: Read, Glob, Grep, etc. remain native to Claude CLI for performance.
  • Claude upstream bug #34046: Claude CLI does not emit can_use_tool control requests for built-in tools. The selective proxy approach works around this entirely.

khalil and others added 14 commits April 24, 2026 17:52
- 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
@emreycolakoglu

Copy link
Copy Markdown

@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.
@khalilgharbaoui

Copy link
Copy Markdown
Author

@emreycolakoglu yep — I went ahead and published a maintained fork. It is on npm now, no clone/build needed:

Just add it to the plugin array in your opencode.json — the README has the up-to-date install/config and a few quirks worth knowing about (selective tool proxying, MCP bridge discovery order, MultiEdit pass-through, plan mode handling). Worth a quick read before wiring it up.

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.

khalilgharbaoui and others added 30 commits May 30, 2026 23:50
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.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants