- 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 insrc/claude-code-language-model.ts. src/message-builder.tsowns AI-SDK prompt → Claude CLI stream-json message conversion, including/compacttranscript rendering.src/session-manager.tsowns Claude CLI process reuse, session ids, LRU eviction, and CLI arg construction.src/cli-version.tsgates optional CLI flags. Do not pass newly-added Claude CLI flags unconditionally.src/claude-session-bun.ts+src/claude-session-wrapper.tsown the experimental interactive transport (from PR #10): the interactiveclaudeTUI under Bun's native PTY, prompts typed via bracketed paste, output tailed from the session JSONL transcript. Opt-in viainteractive: true/CLAUDE_CODE_INTERACTIVE_TRANSPORT=1; headless--printstays the default.- One package serves opencode 1.x and 2.x. The default export carries both
server(1.x) andsetup(2.x).src/v2.tsis the 2.x entrypoint,src/opencode-v2-types.tsa hand-written mirror of the 2.x API slice we use,src/v2-client.tsanswers the live-state calls in 1.x's response shapes, andsrc/host-tools.tsrenames tool calls into 2.x's vocabulary. Measurements, sandbox and phases:V2.md. - Build output is
dist/, is gitignored, and is rebuilt by CI. Do not commitdist/.
-
Keep
skills/claude-code-plugin/SKILL.mdcurrent in the same change whenever options, defaults, env vars, model ids, proxy tools, agent configuration or troubleshooting behavior change. This bundled skill is the agent-facing configuration reference, not a generated file.test-configure-skill.tschecks identifier coverage against source; reviewers must still verify defaults, precedence, safety and recipes.test-skill-bridge.tscovers native delivery and opencode skill-path registration. Keepskillsin the npm package files list and verify built-package discovery when changing layout. -
Typecheck:
npm run typecheck(tsc --noEmit). -
Test suite:
npm test. The script enumerates test files explicitly — when adding atest-*.tsfile you MUST add it topackage.json'stestscript or it silently never runs (this had drifted:test-config-models.tsandtest-ask-user-question.tswere 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 todist/). -
Before release, run:
npm run typecheck && npm test && npm run build. -
There is no lockfile. CI uses Node 24 and runs
npm install, thennpm run build.
- Never run
npm publishmanually. 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
publishjob hasid-token: write, upgrades npm (npm install -g npm@latest; OIDC needs npm >= 11.5.1), and runsnpm publish --access publicwith noNODE_AUTH_TOKEN. The trusted publisher is configured on npmjs.com and must match repokhalilgharbaoui/opencode-claude-code-plugin+ workflow filenamepublish.yml. The legacyNPM_TOKENsecret is unused (it expired ~2026-05-25, which silently failed the 0.6.0/0.6.1 publishes withnpm error 404on 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), thengit push origin master --follow-tags. npm versioncreates the version commit and annotatedv*tag. Prior release commit/tag messages arev0.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. - From the maintainer's Mac a fresh release looks unpublished for its first hours, and that is Aikido, not npm. The machine runs Aikido Endpoint Protection (Appical's org
org-2542, managed alongside Intune). Its network system extensioncom.aikido.endpoint.proxy.l4intercepts TLS toregistry.npmjs.org(the certificate curl receives is issued by a hex-named intermediate under "Aikido Endpoint Protection Root CA - org-2542") and enforces a minimum package age: it strips versions younger than the policy from the package document thatnpm installreads, resetslatestto the newest version it allows, and blocks their tarballs, while the per-version document and the-/package/<pkg>/dist-tagsendpoint pass through. Aikido's own event log listed exactly the versions that looked missing on 2026-09-23 (this plugin's 0.25.0 to 0.27.0,@opencode/cli2.0.12 to 2.0.15 as "Published too Recently",@ai-sdk/provider4.0.18, npm 12.1.0). Every symptom that day was that filter:npm view <pkg>@latestsaid 0.24.0 here while CI saw 0.27.0,npm install <pkg>@0.27.0failed withETARGET, opencode 2'splugins: ["…@0.26.0"]failed withNpmInstallFail, and the V2 sandbox got 403s on@opencode/cli2.0.12 to 2.0.14 tarballs. A day went into diagnosing it as a stuck npm record, with a support ticket drafted and a "registry-wide outage" theory resting on other packages that looked the same, measured from the same machine. The comparison across packages was the tell that got misread: an outage that hits every recent release alike is what a local age filter looks like. Rules: verify a publish from thePublish packagestep's own tail (+ <pkg>@<version>) and cross-check the version document'sshasumagainst itsnpm notice shasum:line; never judge a release by the package document,npm viewor an install from this machine; and before calling anything a registry fault, read Aikido's event log. The one real propagation delay seen was brief: on v0.26.0 the version document answered 404 for about 3.5 minutes after publishing. A real auth failure looks nothing like this: it fails the step with a 404 on the PUT, so read thePublish packagestep's own tail rather than the job's conclusion, and notegh run view --log | grep publishis useless there because the job is namedpublishand matches every line. - 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
@latestspec once and freezes the concrete version into~/.cache/opencode/packages/@khalilgharbaoui/opencode-claude-code-plugin@latest/(itspackage.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@latestthen 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.mdupdated when adding public options, env vars, required CLI versions, or behavior users can observe.
- An abort at a proxy tool boundary is usually opencode, not the operator (fixed 2026-09-23, after the maintainer saw "Rejected again even though the command already ran" from an agent nobody had interrupted). opencode 1.18.32 aborts the provider signal of every step that ends in tool calls, roughly a second after the finish, while it is running the tool: measured 348 of 938 proxied calls that day, and every call in a plugin-only scratch config, so it is opencode's own behaviour and not another plugin's. The boundary branch of the abort handler took that as the operator pressing stop, rejected the in-flight calls, answered the CLI's parked HTTP request with "the user doesn't want to proceed", interrupted the turn, and then delivered the real result on the next turn through the issue-#29 text path. Cost: one wasted round trip per tool call, plus a model repeatedly told it had been refused.
AbortSignal.reasonis the sameAbortError: The operation was aborted.either way (measured on both), so the tell is opencode's own session status, polled briefly because an abort can land before the map updates (settleSessionRunState,src/runtime-status.ts). Only a positivebusykeeps the call;unknown(no SDK client, no status route, a failed read, or the no-affinitydefaultsession) releases exactly as before, so opencode 2 and the offline tests are never left worse off than they were. Live-verified both ways on 1.18.32: a routine two-command turn now logskeeping pending callswith zero rejections and the tool completes, while a real abort during asleep 40still releases in about 1.4 s and interrupts the CLI. Test:test-process-lifecycle.ts, which fails with the condition stubbed out. - A proxy deadline must not count the operator's time on a permission prompt (fixed 2026-09-23, the second "rejected when I never rejected" report, on 0.26.1). The 10-minute
bashdeadline kept running while opencode'sexternal_directoryprompt waited: opencode's own part timings showed three calls at 24, 34 and 10.5 minutes, each rejected at 10. Claude then moved on and issued a new call, and the approval, once given, arrived as a late tool result for an id the broker had already dropped, so the fresh-turn path rendered it as text and its orphan sweep cancelled Claude's new call, which is the "rejected" the model narrated. Both timers (the HTTP handler's and the broker's) now go througharmProxyDeadlineinproxy-mcp.ts, which asks a registeredProxyDeadlineGuardwhen the deadline passes; the language model registersisProxyCallStillServed, which keeps a call only when it wasemittedto opencode and opencode's session status readsbusy, and then rechecks everyPROXY_DEADLINE_RECHECK_MS(60 s). Measured on 1.18.32 before building on it: the session readsbusyon every poll while a permission prompt is open and idle once it is answered (andGET /permissionlists the prompt with our proxy call id astool.callID, a more precise signal kept in reserve). No guard, a throwing guard,unknownstatus or thedefaultaffinity all end the call at its deadline exactly as before. Known limit: a user who sets a finitetaskdeadline also lowers the CLI's own MCP client ceiling (resolveProxyClientCeilingMs), which an extension can outlast; the CLI then hangs up and the late-result recovery path delivers. Tests:test-broker.ts(guard, recheck, throw, cancel) andtest-process-lifecycle.ts(end to end through a parked fake CLI; fails with the guard unregistered). - The account failover form never switched, from the day it shipped (0.24.0 to 0.26.1). opencode's
questiontool returns one sentence, not the answer:User has answered your questions: "<question>"="<answer>". You can now continue with the user's answers in mind.(read out of the 1.18.32 binary; a blank answer isUnanswered, a dismissal is a tool errorThe user dismissed this question).unwrapOpencodeQuestionResultinplan-mode-question.tsonly recognised the plan-approval question, so every failover pick reachedclassifyas the whole sentence and was declined asunrecognised answer; the log shows it for every pick on 2026-09-23. It now takes the question text (stored on the pending entry) and matches it whole, because the failover question quotes the account name and a split on quotes lands in the wrong place; without one it splits on the last"=". Two more defects in the same path: a restart between the form and the answer lost the in-memory pending entry, soconsumeAccountFailoverAnswernow takes afallback(the form this model would offer now) and applies it to the newest message only; and on the turn after a form, the answer reached Claude as a stray<opencode_tool_result>, sogetClaudeUserMessagestrips the dialog up front, completing invariant 6 below. The offline tests had fed bare labels ("work"), one of them under a comment claiming the unwrapper handled opencode's sentence; the end-to-end tests now feed the real sentence and fail without the fix. Still not live-verified with a real usage limit; the parsing is now proven against the real output format, not against a real limit. conversation_resetis announced and clears index-keyed bookkeeping, and deliberately does NOT replay history (added 2026-09-23). Schema on Claude Code 2.1.280:{type:"conversation_reset", new_conversation_id, uuid, session_id}, "emitted by /clear, plan-mode exit, and fresh-session flows". Measured by driving the real CLI over stream-json: an ordinary turn emits none (so there is no note on every chat), and a/clearuser message emits one carrying the oldsession_id, then asystem/initwith a new session id that is notnew_conversation_id, after which Claude answered it could not recall the conversation. So the Claude session id needs nothing special: the init that follows updates it through the existingsetClaudeSessionId. The line handler clearstoolCallMap,reasoningIds,reasoningStartedandtextBlockIndices, because block indices restart and a stale entry re-emits a finished tool call (the 2026-09-06 family; the stream test fails with the clears removed), and writes a▌ **claude code reset:**note (CONVERSATION_RESET_MARKER, registered inPLUGIN_NOTE_MARKERS). A replay of opencode's history on the next turn was designed and rejected: every known trigger is a clear someone asked for, and replaying would silently undo it. Pending proxy calls are not touched: the CLI cannot emit a reset while it is parked inside one. A frame without a stringnew_conversation_idis ignored, as the CLI's own adapter drops it. Not yet handled from the same schema:tombstone(a message the CLI removed from its transcript, such as a partial orphan after a streaming fallback).- An account that cannot serve is read from the
errorkind on the CLI's own failure reply, never from its text (added 2026-09-23 after theappicallogin expired and the only thing on screen was the CLI's "Failed to authenticate: OAuth session expired and could not be refreshed", naming no account and no fix). Claude Code 2.1.280's schema putserrorat the top level of anassistantmessage ({type:"assistant", message, parent_tool_use_id, error?, uuid}), with the kindsauthentication_failed,oauth_org_not_allowed,account_on_hold,verification_required,billing_error,rate_limit,overloaded,invalid_request,model_not_found,server_error,unknownand more; the CLI's own status line mapsauthentication_failedto "login required".accountBlockKindaccepts only the five account-level kinds:rate_limithas its own path, and the request-level kinds would fail the same way on any account. On a failed result the stream writes a▌ **claude account:**note (ACCOUNT_BLOCK_MARKER, registered inPLUGIN_NOTE_MARKERS) withloginCommandFor(configDir), setsresultFailureso the turn finishes as an error (the CLI labels that resultsuccesswithis_error: true, so nothing else did), and opens the same switch form as a limit with the question worded bydescribeAccountBlock. NoresetsAt, so a switch holds until opencode restarts. Measured shape (72-character text,stop_reason: stop_sequence, turn failing in about 40 ms) is what the fake CLI intest-account-failover.tsreplays; both end-to-end tests fail with the stream wiring removed. Not yet seen live after the fix: it needs an account whose login has actually expired.
- opencode 1.18 calls a dual export's
setuptoo, not onlyserver. A build that flipped a process-wide "V2 mode" at the top ofsetupmade the maintainer's real 1.18.32 emitshellfor every proxied call, and 1.x rejected each one as an unavailable tool. So nothing about the host major is process-wide: the tool vocabulary rides on each model (ClaudeCodeConfig.hostApi, set only by the V2sdkhook inresolveSdkSettings), andsetupregisters nothing unlessisOpencodeV2Contextsees V2'sprovider,aisdkandsessiondomains and no 1.xapp.version. The V1 regression probe runs the real 1.x binary on the builtdist/with a scratchXDG_CONFIG_HOME; run it after any entrypoint change, because unit tests could not see this. - The model path is the
aisdksdkhook, andpackageis only a label. V2 never imports it:AISDK.languagechecks theaisdk:prefix, takes the SDK object from thesdkhooks and the model fromsdk.languageModel(modelID ?? id). The website's plugin docs describe providers as metadata only and omit theaisdkdomain; the shipped.d.tsis what is true. - A plugin's provider transform cannot see the config's provider settings. V2 applies the config file's provider block through its own
opencode.config.providerplugin after plugin transforms, so config overrides plugin defaults and our seed record lackedaccounts.planV2Providerstherefore reads the seed from the same on-disk layers the MCP bridge reads (opencodeConfigLayers, shared, never duplicated) plus the plugin's ownoptions. Per-call settings need none of this: thesdkhook'soptionshold the merged result, and they win over the planned copy. - V2 renamed tools and fields, and an unknown tool name is an error there.
src/host-tools.tsrewrites the stream atdoStream's edge anddoGenerate's content, only for a V2 model:bashtoshell(nodescription),tasktosubagent(subagent_typetoagent,task_idtosessionID),filePathtopathforedit/write/read; it dropstodowrite,plan_enter,plan_exitandnotebookedit, which V2 does not have. Nothing reads an incoming tool name, only ids, so translating outbound only is enough. The table was read off each tool's registerednameand input schema in@opencode/core/dist/tool/plugin/*.js; re-read it after a V2 bump. - Session and agent reach the model as headers.
session.hook("model.request")setsx-session-affinity(what the language model already reads) andx-opencode-agent, withkind: "compaction" | "title"mapped onto those agent names.resolveOpencodeAgentreads providerOptions first, so 1.x is unchanged. - V2 sends its title request with the whole tool set, so 1.x's "no tools means title" test missed it and every new V2 session spawned a second
claude.isTitleRequestalso accepts thetitleagent, for V2 models only. - The live-state client is a shim (
src/v2-client.ts):mcp.status,tool.list(with V2'ssubagentaliased astaskand an agent list built fromagent.list(), because V2'ssubagentdescription names no agents) andsession.get(location.directoryplussubpathasdirectory). Anything V2 cannot answer is left out so its caller takes the no-client path. The agent registry is still built by V1'sbuildAgentRegistryfrom disk, never fromagent.list(), which includes built-ins that must not be rewritten. /btwand the doctor are real commands that send V1's template text throughsession.prompt./btwis alwaysdelivery: "queue": steered into a running turn it would swallow the turn's continuation. Answering inside a running turn is not ported; V2's plugin session domain has no status route to detect the turn.- Sandbox and probe discipline.
~/opencode-v2-sandbox/env.shredirects all four XDG dirs andTMPDIR; always--standalone(otherwise V2 starts its shared background service); neveropencode service start, which may register a login item. Aserveneeds HTTP Basic with useropencodeand the password printed in its log. A configured plugin path must be a directory, resolved as<dir>/serveror<dir>/index, so a local checkout is<repo>/dist. V2 reads 1.x'spluginkey as well as its ownplugins, and reads~/.opencode/,~/.claudeand~/.agentsas config and skill layers exactly as it would outside the sandbox.
This correction supersedes the historical claims below that native-provider failures proved an upstream TUI rendering regression, or that enabling the question bridges must wait for PR #36603. The user's clean Omarchy installation reportedly works. On this Mac, global ~/.config/opencode/plugins/notify.ts awaited notifyQuestionIfNeeded inside tool.execute.before; its backend awaited alerter exit, which defaults to waiting for dismissal indefinitely. A real question notification child started at exactly the failed question's timestamp and remained alive. opencode awaits before-hooks before calling the native question tool, so the request/form could not exist yet. Native providers still run global hooks and were not a plugin-free control. A no-inference test importing the real notification plugin failed with a pending simulated alerter and passed after the hook dispatched notification delivery without awaiting it, with rejection handling and deduplication preserved. Local regression test: ~/.config/opencode/tests/notify-question.test.ts. For future failures, GET /question on the same server/workspace separates pre-tool blocking (absent request) from event/session/rendering issues (present request). #36604 remains open for detach/reattach; #36603 is closed unmerged, not a fix to wait on. Do not remove the user's no-question-tool preference without their approval.
Both round-trips are now verified, so every "blocked upstream, leave it off" line in the bullets below is history, not current advice. After the restart, the maintainer authorized one native question call in the Mac TUI and answered it: the form rendered and the answer came back. The "Question" proxy was then verified end to end on plugin 0.18.0, Claude Code 2.1.258, opencode 1.18.29 through a headless opencode serve with a scratch config (proxyTools: ["Question"], permission.question: "allow", account appical, haiku): plugin.log shows question proxy version gate {"opencodeHasQuestion":true,"kept":true} then proxy-mcp tool call received {"toolName":"question"}, GET /question listed one pending request for the session, POST /question/{id}/reply with a random token completed the single question tool part, and Claude's final answer was that token, which it could only have obtained through the tool result. Probe script: /var/folders/.../opencode/verify-provider-question.mjs (scratch, not in the repo). The headless probes answered over HTTP, not by clicking, so the last join was closed separately: with "Question" added to the maintainer's own proxyTools and opencode relaunched, a two-question mcp__opencode_proxy__question call rendered as a real form in his TUI and both answers came back into the turn. Model to form to answer to model, in the actual terminal. "Question" stays out of DEFAULT_PROXY_TOOL_NAMES anyway: enabling it disables Claude's AskUserQuestion via --disallowedTools, and that swap is the operator's call, not a silent upgrade. planModeQuestion is still unverified, for a different reason than before: its delivery surface now works, but nobody has driven an actual ExitPlanMode approval through it. That is the test to run before promoting it.
-
The
chat.paramshook tags opencode's active agent (default,compaction,title, etc.) into provider options. Write tooutput.optionsat the top level. opencode wraps that bag under the provider id later. Do not pre-nest underoutput.options[providerID], or the model seesproviderOptions[id][id]. -
Reasoning effort is a spawn-time env var (
CLAUDE_CODE_EFFORT_LEVEL, set inclaudeSpawnEnvand the interactive session's env), not message text. Claude Code 2.1.x only recognises theultrathinkkeyword, so the old per-level keywords were silently inert. Because the var is fixed per process, effort is part of the session key (::effort=<level>); a respawn reads it back fromActiveProcess.effort. Compaction spawns never carry it. -
Per-agent model override (
src/agent-models.ts) swaps the model NAME only, never the provider. The account lives in the provider (claude-code-<account>→CLAUDE_CONFIG_DIR) and in the@<account>marker on the id, so the override reattaches that marker:claude-fable-5-1@workbecomesclaude-opus-5@work. Dropping the marker would silently move the work to the default account. Three guards keep it from surprising anyone, and none of them are optional:defaultSubagentModelis unset by default, so an upgrade changes no existing behaviour; only agents the plugin discovered (config.agententries, markdown inagents/) are eligible, so opencode's built-ins stay out of the path orexplorequietly becomes an Opus agent; and an unknown model id is refused rather than spawned. The effective model is part of the session key in BOTHdoGenerateanddoStream, otherwise an overridden subagent shares aclaudeprocess with its caller. The plugin defines no agents of its own on purpose: a provider plugin injecting opinionated agents (with their own permission blocks) into every user's@menu is not its job. -
An agent's declared
reasoningEffortbeats the effort the call arrived with (resolveAgentEffort, applied in bothdoGenerateanddoStream). opencode resolves one effort per session and a subagent inherits it, which fails in the expensive direction: a parent onmaxsilently dispatches every worker atmax, so a four-fix mechanical lane runs at the costliest setting there is and eats a weekly Opus cap. Declaring nothing keeps the inherited value, an unknown level is refused rather than forwarded (the CLI rejects it), and compaction is exempt because its summary needs the whole budget. Effort is part of the session key, so changing it respawns rather than reusing a process started at the old level. -
/compactmust not fall through the no-tools title stub. It is detected viaopencodeAgent === "compaction", runs throughdoStream, uses a fresh short-lived Claude CLI process, skips MCP/proxy/tool wiring, and defaults toclaude-haiku-4-5. -
Compaction model precedence is:
CLAUDE_CODE_COMPACTION_MODELenv var, thencompactionModelprovider option, then defaultclaude-haiku-4-5. -
Opus 4.7 omits thinking summaries by default. The plugin asks for summaries with
--thinking-display summarized, but only whensrc/cli-version.tsconfirms 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 explicitCLAUDE_CODE_SHOW_THINKING_SUMMARIESvalues. The same rule governs the CLI hygiene vars below. -
Every spawned child gets
DISABLE_AUTOUPDATER=1andCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1, and the reason is the version cache, not tidiness.detectCliVersionresolves once percliPathand caches that answer for the life of the opencode process, and three flag gates read it:--thinking-display summarized,--plugin-dir, and fast mode via--settings. If the CLI autoupdates underneath a long-running opencode, the cached version stops describing the binary actually being spawned, so a gated flag can be passed to a CLI that rejects it or withheld from one that supports it; swapping the binary mid-session is a plain correctness hazard besides. Both names were read out of the 2.1.263 Mach-O (rg -a, the same technique the CLI stream-event gotcha records), not assumed:DISABLE_AUTOUPDATERis parsed byhQ()as an update blocker, andCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFICis read byI()as"essential-traffic"and returned bydxe()as a second, independent update blocker, so the two overlap on purpose. Anthropic's own runner setsDISABLE_AUTOUPDATER:"1"on the children it spawns, which is the same use this is.cliHygieneEnvlives insrc/cli-version.tsrather thansession-manager.tsbecause it exists to protect that module's cache, and becausesrc/claude-session-bun.tsneeds it too:cli-version.tspulls in onlylogger.ts, so the interactive module stays cheap to import (it is also driven directly bye2e-claude-session-bun.ts). It fills gaps only and never overwrites, soDISABLE_AUTOUPDATER=0in the user's shell keeps the autoupdater and an empty string stays empty, which both vars read as off; that is the escape hatch, and it is why this needs no provider option. The interactive env moved out of theBun.spawnliteral intointeractiveSpawnEnvpurely so a test can reach it without a PTY. Tests:test-spawn-env.ts(7 cases; 4 fail withcliHygieneEnvstubbed to{}, the other 3 assert the constant or the absence of an override). -
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
reasoningfield) live in opencode's native Anthropic/vLLM runtime, which this plugin deliberately bypasses by routing through theclaudeCLI. There is nothing to "switch to" — the plugin implements reasoning itself (reasoning variants → thinking keyword +--thinking/--thinking-displayflags →thinking_deltaforwarding), and any model defined withreasoning: true(includingclaude-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.tsdefineModel, via themultiplierfield): haiku 1×, sonnet 3×, opus 5× (Opus 5.5: 4×, its fast mode 8×), 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 offid.test-config-models.tsasserts the suffixed names — update both if the format changes. -
Fast mode's
-fastmodel ids are OURS, not Anthropic's.claude-opus-5-fast/claude-opus-4-8-fastare registry entries this plugin invents;src/models.tsparseModelIdstrips the marker before--modeland turns it into--settings '{"fastMode":true}'. Do not "fix" this by passing the id through: Anthropic's real-fastnames are retired (claude-opus-4-6-fastsilently falls back to standard,claude-opus-4-7-fasthard-errors). There is no--fastflag.--settingsis the only headless opt-in because the CLI's SDK gate reads the flagSettings layer specifically (if (le() && Ui() && !flagSettings.fastMode) return "sdk_opt_in_required"), so afastModein the user's own settings.json does nothing for a--printrun. Only Opus 4.8 / 5 / 5.5 qualify (the CLI matches on the name containingopus-4-8/opus-5, whichclaude-opus-5-5also satisfies; 2.1.280's baked model catalog listsfast_modefor it outright and carries a dedicated $8/$40 fast table keyed on the exact id); registering a fast entry for any other model would show a fast price on a standard-speed turn. Verified live against 2.1.245 on 2026-08-30.--settingstakes one value, so the interactive wrapper mergespermissionsandfastModeinto a single payload rather than pushing the flag twice. -
Fast mode fails soft, so the downgrade must warn, not notice. An ineligible account returns
fast_mode_state: "off"with a reason and runs at standard speed with no error, while the picker still advertises 10×.reportFastModeStateuseslog.warndeliberately: insrc/logger.tsonly warn/error are alwaysStderr, so a NOTICE would be invisible outside debug mode and defeat the point. Deduped per reason per process, because the blockers are account-level and would otherwise fire on every respawn. Maintainer's own account reportsextra_usage_disabled(fix:/usage-credits), so the on-state path is unverified in production: only the opt-in plumbing and the downgrade path have live evidence. Superseded 2026-09-23: the on-state is now live-verified. One turn onclaude-code-default/claude-opus-5-5-fastthrough opencode 1.18.32 on Claude Code 2.1.280 loggedfast mode active {"state":"on"}and answered, so the account has since become eligible. That turn billed at the fast rate; a probe of it is not free. -
Sonnet 5 is on standard pricing ($3/M in, $15/M out,
sonnetCost, multiplier 3×) as of 2026-09-01, when its introductory $2/$10 period ended. Thesonnet5Costconstant is gone; do not reintroduce it, and do not "correct" the 3× suffix back to 2× from an older README or screenshot. -
Costs in
src/models.tsare dollars per MILLION tokens, the unit opencode and models.dev use (~/.cache/opencode/models.jsonhasclaude-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" the1e-6form.opusCostis 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. Fable/Mythos 5.1 keep those input/output rates but use a separately published $0.25/M cache-read rate, not 5.0's $1/M. Opus 5.5 (opus55Cost, $4/$20, cache read $0.20 = 0.05× input, cache write $5) and its fast table (opus55FastCost, exactly double) were both read out of the Claude Code 2.1.280 binary (tier_4_20_cache_read_0_20and the exact-id fast entry), and the model needs that CLI or newer: the API refuses it from 2.1.278 with a 400 naming the floor. Every one of those figures was independently confirmed against Anthropic's published pricing page before merge, including the footnote that Opus 5.5 alone prices cache hits at 0.05× input rather than the usual 0.1×, which is the whole reason it cannot shareopusCost. 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 publishedlimit, audited against the Anthropic models + pricing docs on 2026-07-26 (the placeholderoutput: 16_384is gone; do not reintroduce it). Two classes of drift were corrected:claude-sonnet-4-5andclaude-opus-4-5claimed 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 (haiku2025-10-01, sonnet-4-52025-09-29, opus-4-52025-11-01).test-config-models.tspins all fourteen non-fast-model limits, with fast-model limits pinned separately, 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.experimentalOver200Kfields 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 sendsspeed: "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 insrc/models.tsrecords 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--printpath) 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-20250514retire (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 400Third-party apps now draw from your extra usage...corresponds to arate_limit_eventwith{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. -
AGENTS.mdmust not reach the model twice (buildAppendedSystemPrompt, cherry-picked from @HeikoAtGitHub's25260a4, absorbed 2026-09-06). opencode forwards~/.config/opencode/AGENTS.mdinside its own system prompt under anInstructions from:header, and this plugin also read it from disk and appended it, so every turn paid for both copies (visible in any plugin-driven session's own system prompt). The disk copy is now pushed only when the forwardedextraSystemContentdoes not already contain it; no match keeps the old behaviour, so the interactive transport (which forwards nothing) never loses it. Live-verified: one copy in a 63 KB appended prompt. Test intest-compaction-model.ts. -
Abort sends the CLI an
interruptcontrol request (interruptTurninsession-manager.ts, adapted from @broskees'68ed142, absorbed 2026-09-06). The CLI runs one turn per process and closing our stream told it nothing: an aborted turn ran to completion, billed, executed tools, and its late output plus staleresultlanded in the next turn (Joseph measured ~7,500 characters generated after abort).noteTurnStartedmarks the process in flight at every stdin write that asks for work (fresh envelope, auto-continue, watchdog re-send), the terminalresultline clears it inside therlhandler inspawnClaudeProcess(not a permanentlineEmitterlistener:listenerCount("line") === 0is what routes unattended lines to the buffer and what/btwreads as busy, so a permanent listener would break both), the abort handler sends{type:"control_request", request:{subtype:"interrupt"}}, and a new turn that finds the previous one in flight interrupts it first with a 5 s cap, except tool-result turns where the CLI is legitimately parked in a proxy call. The interactive transport is never marked in flight (its stdin is a TUI). Live-verified on 2.1.258: abort mid-webfetch,interrupt sent for aborted turn {idle:true}, next turn clean in 8.5 s. Tests:test-session-manager.ts. -
idleProcessTimeoutMs(cherry-picked from @bernardofortes'a5f723a, absorbed 2026-09-06, resolved by hand onto the current tree because his base predated the--resumerename and the respawn rework; the commit is still his). Off unless set (DEFAULT_IDLE_PROCESS_TIMEOUT_MSis 0, resolved byresolveIdleProcessTimeoutMsat thecompleteResultcall site so unset and0both arm nothing; @broskees' fork-parity PR #36 proposed 30 minutes by default and that was reverted at merge, since it changes when a resumed chat pays for a fresh--resumespawn and that is the user's call. The helper stays so a default can be revisited in one line). Timer armed incompleteResultaftercleanupTurn, so the clock starts when a turn finishes, not at spawn; cancelled bygetActiveProcess/setActiveProcess/detachActiveProcess/spawn/exit, unref'd, and it deletes only if the same process object is still registered so a respawn cannot be killed by its predecessor's timer. A process foundturnInFlightwhen it fires (recovered continuation, auto-continue, late tool result) is re-armed, never killed, the same rule the LRU cap follows. Session id survives, so the next turn resumes. Tests:test-session-manager.ts,test-process-lifecycle.ts. -
The child's stdin needs its own
errorlistener, andproc.on("error")is not it. Every write that asks the CLI for work (fresh envelope, auto-continue, the watchdog re-send,interruptTurn) can land after the child died, and anerrorevent on a stream with no listener throws inside opencode's process, not the child's.spawnClaudeProcessattaches a baselineproc.stdin?.on("error", ...)next to the process one; it logs at WARN with the errno and callssettleTurn, because no terminalresultis ever coming for a write that never arrived. It deliberately does not end the turn: the child is gone, so the readlineclosefollows and the turn's close handler reports it. Note EPIPE is delivered whenever libuv gets round to failing the queued write (measured: hundreds of ms, sometimes only once the child is killed), so the regression test emits the event directly; the contract under test is that something is listening. The interactive shim'sstdinis a plain object withwrite/endand no emitter, so it cannot emiterrorand needs nothing. Test:test-session-manager.ts. -
LRU eviction must never take a process that is mid-turn.
evictIfNeededdeleted the oldest of 16 outright, and the evicted turn's close handler then finished with reasonstopand no error, so a user with many open chats saw an answer silently truncated. It now walks insertion order (which is LRU) for the first process withturnInFlight !== true, and when every process is busy it evicts nothing and warns, letting the map exceed the cap for a moment rather than killing live work. Do not "restore" the one-liner. The cap is 8 (was 16; the fork's figure, adopted in the fork-parity PR): the idle timer above does the real work and this is the backstop for a burst of chats inside one idle window. Tests:test-session-manager.ts(both branches). -
A deleted opencode session releases everything at once, and host exit kills what is left. The plugin's
eventhook (index.ts) acts onsession.deletedonly, reading the id fromproperties.info.id(extractDeletedSessionId), and callsdeleteActiveProcessesForSession: every process whoseopencodeSessionIDtag or session-key affinity segment (describeSessionKey(key).session, which covers effort and compaction keys) matches is killed, its proxy server closed, and, unlike idle eviction, its Claude session id, plan-mode questions, todo ledger and compression summary are dropped, because a deleted session never resumes. The"default"affinity is the shared fallback bucket and is never matched.ensureProcessExitCleanuparms a singleprocess.once("exit")that runs the synchronouskillAllActiveProcesses, guarded so repeated plugin initialisation never stacks listeners.detachActiveProcessalso rejects the broker's pending calls for the key once it closed the proxy server: nothing can answer them any more, and ataskcall has no deadline that would otherwise reap the entry. Tests:test-session-manager.ts,test-process-lifecycle.ts. -
A child that closes without a
resultis an error, not astop. The doStream close handler finished the stream withtoFinishReason("stop")and empty usage, so a crashed CLI read as a short but successful answer. It now emits anerrorpart (consistent with the other error paths in that file) plusfinishReason: "error", built bydescribeChildCrash(exitCode, signal, lastStderr). Three things hold it together: stderr was debug-only and clipped to 200 chars, soretainStderrkeeps a 2 KB tail on the ActiveProcess (lastStderr, newest wins) as the only record of why;proc.exitCodeis usually stillnullwhen stdout hits EOF, so the crash branch waits up toCHILD_EXIT_STATUS_GRACE_MS(250 ms) for theexitevent rather than reporting a bare "closed its output"; and an abort is exempt (autoContinueState.aborted), since the operator asked for it and the CLI may exit before the interrupt's own result lands. The path where aresultdid arrive is untouched, and auto-continue is unaffected because it only runs fromcompleteResult(isErroralready returns{continue:false, reason:"error"}). Tests:test-respawn.ts(fake CLI, crash and abort),test-session-manager.ts(retention cap, message shape). -
Skill bridge is opt-in (
bridgeOpencodeSkills,src/skill-bridge.tswritten by @broskees in68ed142, absorbed 2026-09-06; his fork-parity PR #36 proposed on-by-default and that was reverted at merge, see the follow-up commit). opencode and Claude share the<name>/SKILL.mdformat but not the roots, so opencode advertised skills the CLI'sSkilltool could not find. The bridge stages a throwaway plugin dir (skills-<hash>underpluginTmpDir, linked, copy fallback for Windows) and passes--plugin-dir; the flag has no version marker sodetectCliSupportsFlagprobesclaude --help(cached). Deliberately off by default: every bridged skill is also in the system prompt opencode forwards, so a big skill set is paid for twice per turn by every user;bridgeOpencodeSkills: trueopts the user's skills in, and the bundled skill is staged regardless. Live-verified viaOPENCODE_CONFIG=<copy with the option>on a temp project: 4 skills bridged,Skillcall rendered as opencode'sskilltool, token returned. Wired into the headlessdoStreamspawn,doGenerate's direct spawn, and the interactive spawn (pluginDirsonspawnInteractiveProcess, appended byinteractiveExtraArgs); compaction's lean spawn never stages it, and the--helpprobe keeps the flag off a CLI that does not know it on every path. Tests:test-skill-bridge.ts(including the real argv of a spawned fake CLI on both headless paths),test-claude-session-wrapper.ts. -
The bridge's roots were wrong, and the sentence above used to say so. It previously read "Only
~/.config/opencode/skillsand.opencode/skillsare roots;~/.agents/skillsis not opencode's, so those are not bridged." Both halves are false, read out of theopencodebinary's own loader rather than inferred:var bA=".claude",xA=".agents",GA="skills/**/SKILL.md",SA="{skill,skills}/**/SKILL.md", and opencode's bundled config docs nameOPENCODE_DISABLE_EXTERNAL_SKILLS/OPENCODE_DISABLE_CLAUDE_CODE_SKILLSas the switches that "skip the external skill scans under~/.claude/and~/.agents/". So opencode reads.opencode/,.claude/and.agents/walking up from the workspace, its own config dirs under{skill,skills}, and globally~/.claude/skillsand~/.agents/skills.skillRoots()covered only the.opencodequarter of that, so everything a user kept in~/.agents/skillswas advertised by opencode in the forwarded system prompt and answeredUnknown skillfrom a Claude turn. Discovery now covers every one of them, honours both kill switches, and a skill is keyed by thename:its frontmatter declares (declaredSkillName, directory basename as the fallback), because that is the name opencode advertises and therefore the one the model will call. -
Expanding the roots is what made the bridge collide with Claude's own, so
bridgeSkipNativeSkillsshipped with it and is ON by default.~/.claude/skillsand the project's.claude/skillsare read by both runtimes, so bridging them hands one skill to a single session twice: two names and two descriptions in the prompt, every turn, with no way to tell which copy aSkill()call reaches.discoverNativeClaudeSkillsscans what the spawn will actually load, which is the account's ownCLAUDE_CONFIG_DIR(accountConfigDirPathinaccounts.ts, added for exactly this) rather than a hardcoded~/.claude, plus the project scope and every installed plugin'sskills/fromplugins/installed_plugins.json.dropNativelyLoadedSkillsthen matches on three identities in order: the resolved realpath (a symlink into a shared root is one skill, which is the Appical marketplace's symlink install), a SHA-256 ofSKILL.md(byte-identical copies in two roots are one skill, whatever they are called on disk), and finally the declared name. A name collision whose content differs is never silently dropped: Claude's native copy wins, because it is the one the CLI resolves regardless of what we stage, and the bridge logs a WARN naming both paths so the loser is findable. Measured against the maintainer's real machine before shipping: 114 opencode-discovered skills against 118 native (100 user, 18 plugin), 14 bridged, 100 skipped as 99same-copyplus 1name-taken. That one isherdr, genuinely divergent at 12600 bytes native against 10553 bridged, and it was live in the session that built this, which is what proves the whole thing is not theoretical. Off by default would have meant shipping a known duplicate, so the flag inverts thebridgeOpencodeSkillsconvention deliberately;bridgeSkipNativeSkills: falseis the escape hatch and reinstates every duplicate. Live-verified 2026-09-23 on opencode 1.18.32 with Claude Code 2.1.280, in a scratchXDG_CONFIG_HOMEwith onlybridgeOpencodeSkills: trueset:plugin.logshowedskills claude code already loads; left unbridged {"count":100}andbridged opencode skills into claudenaming the~/.agents/skillsset, and a haiku turn asked to loadgit-archeology(a skill only reachable through the new roots) made a completedskilltool call and answered with that file's first heading. Before this branch the same call answeredUnknown skill. Re-measured on the merged tree the same day, both accounts: 14 bridged, 100 skipped (99same-copy, 1name-taken:herdr). -
Two forks independently named the 5-minute proxy wall's timer, which the 0.15.0 note above says not to claim without evidence: @broskees (
68ed142) measured a hard 301 s and attributes it to undici'sheadersTimeoutandbodyTimeout(300 s each) behind Nodefetchin the CLI's MCP client; @HeikoAtGitHub (42f426d) measured 293 to 296 s plus a separate 300 s MCP-idle timer and, like 0.15.0, fixed it with SSE plus progress notifications. Treat 300 s undici as the working explanation; the 0.15.0 fix already covers it. -
Do not wait for
message_stopto drain proxy calls. @broskees'a44a2dc: draining only at that boundary deadlocked two ordinary Bash calls until their timeouts fired in succession, because the CLI blocks inside the MCP call before emitting it. Our broker drains as calls arrive; keep it that way. -
Sweep the forks more often than once a quarter. @galvani fixed the stale
toolCallMapre-emission on 2026-05-25 (2238ed0) with the same log signature that took until 2026-09-06 to find here. The sweep is cheap: clone, add every fork as a remote,git cherry origin/master <branch>per branch (patch-id equivalence, so absorbed cherry-picks do not show), read the bodies of what is left. -
signature_deltais expected encrypted thinking metadata. Ignore it quietly; do not treat it as an error. -
WebSearchwith the default"claude"routing must NOT be forwarded as a tool-call part. opencode has noWebSearchregistry 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 whenproviderExecuted: true— users saw⚙ invalidrows on every CLI-internal web search (fixed after v0.8.0).mapToolreturnsskip: truefor it, and both tool_use sites inclaude-code-language-model.tsrender the query as a> **Web search:** …text line instead (gated byisWebSearchTool+isWebSearchHandledByClifromtool-mapping.ts). Explicit opencode-tool routing (webSearch: "<tool>") still forwards withexecuted: false. Tests intest-tool-mapping.ts. -
tool-input-deltaparts must only be forwarded for tool calls whosetool-input-startwas actually emitted. opencode's AI SDK bridge (packages/opencode/src/session/llm/ai-sdk.ts) resolves delta/end names viastate.toolNames[event.id] ?? "unknown"; a delta for an unseen id creates a permanently-pending part withtool: "unknown"that the TUI renders as⚙ unknown. Skipped tools (ToolSearch, TaskCreate/TaskUpdate, CLI-internal WebSearch, AskUserQuestion, ExitPlanMode, proxy tools) streaminput_json_deltalike any other tool_use, so the streaming site inclaude-code-language-model.tsgates delta forwarding on astartedflag set only when the input-start part went out (fixed after v0.8.1). Keep accumulatinginputJsonunconditionally — 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--printClaude Code exposes noAgent/Taskdispatch tool of its own (checked through CLI 2.1.211), so thetaskproxy is the only path — but the CLI does exposeTaskCreate, 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:overlayTaskProxyDescriptioninproxy-mcp.tsfront-loads opencode's live agent-type list onto thetaskproxy def, andSUBAGENT_DISPATCH_HINTgoes into the appended system prompt namingmcp__opencode_proxy__taskas 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 forgeneral-purpose, thendefault, thencode-reviewer— Claude Code's own agent names — and every dispatch died withUnknown agent type, after which it grepped~/.config/opencode/opencode.jsonand answered the question itself. Fix:extractAgentTypeListkeeps 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) leavemcp__opencode_proxy__taskinvisible whileTaskCreatestays visible, which is the worst case for this confusion — the maintainer hit exactly that during the v0.10.0 smoke test.TASK_PROXY_NOTEmust keep describing the real deadline (none by default; a positiveproxyToolTimeoutMsadds one) andbackgroundmode; jknlsn's original said 10 minutes, which predated the per-tool timeouts, and a later version said 60. Only wired intodoStream's spawn path —doGeneratehas no proxy wiring at all, so it deliberately has no hint. Tests:test-subagent-hint.ts. -
task_batchis the only way to run two subagents at once, because the CLI serialises MCP calls (from @broskees'68ed142, adapted 2026-09-06, his design). Measured before building it, not assumed: haiku asked for two parallelmcp__opencode_proxy__bashsleeps emitted both tool_use blocks in one assistant message (samemessage.id, 275 ms apart), yet the second MCP request reached the proxy 7 ms after the first resolved, 8 s later. So "call task twice" is serial by construction and no amount of prompting fixes it.task_batch(proxy-mcp.ts) is one MCP call whosetasksarrayfinishWithToolCallsfans out as Ntasktool-calls in the same stream finish, ids${parent}_task_${i}(taskBatchChildToolCallId), which opencode runs concurrently as one step;extractPendingProxyResultForCallgathers the children's results back onto the parent id (formatTaskBatchResults, labelled in order) and resolves the one broker call. Invariants: (1) it rides along withtaskinresolvedProxyTools, soproxyTools: ["Task"]gets both and nobody has to know it exists; it disables the same built-in (Agent), deduped. (2) The batch is validated in thetools/callhandler before it is queued (taskBatchInputError), as an MCPisErrorresult, since a bad batch has nothing to fan out and a broker entry for it would only time out. (3) A partial set of child results still resolves the parent, with the gap written into the text as[missing]: returning null there would send the turn down the fresh-envelope path, which rejects the parent as orphaned and renders the children as text, the worst of both. opencode hands all of a step's results to the next call together, so partial is theoretical. (4) TheTASK_PROXY_NOTE, the batch def's note, andSUBAGENT_DISPATCH_HINTall name it, because the model has to be told the serial behaviour exists to prefer the batch. The fork's "unlimited by default" task deadline (dd494a8) was first left out as contradicting the then-documented 60-minute contract, and adopted in the fork-parity PR; see the deadline gotcha below for the lifecycle that releases an abandoned call instead. Tests:test-proxy-mcp.ts(def, validation, deadline, formatter),test-subagent-hint.ts,test-proxy-task.ts(fake-CLI fan-out and the two-turn gather). Live-verified 2026-09-06 on Claude Code 2.1.258 + opencode 1.18.29 (haiku, twogeneralsubagents):plugin.logshows exactly oneproxy-mcp tool call receivedwithtoolName: task_batchand zero plaintaskcalls, the parent holds twotasktool parts with ids<parent>_task_0/_task_1that started 13 ms apart and overlapped for their whole 5.6 s / 5.8 s runs, two child sessions exist, and the final answer quoted both subagents' tokens. That overlap is the fingerprint: if the two child intervals ever stop overlapping, the fan-out has silently become serial again. -
toolCallMapis keyed by content-block index and MUST be deleted atcontent_block_stop. Claude CLI restarts block indices at 0 on every assistant message, and one turn routinely holds several (tool_use -> tool_result -> answer,numTurns: 2). The entry was never deleted, unlike its neighboursreasoningIdsandtextBlockIndices, so message 2's answer-text block at index 0 hit message 1's stale tool_use entry and re-emitted atool-callfor an id opencode had already completed. That second part never receives atool-result, so opencode aborts it at stream end withTool execution aborted/interrupted: true, and opencode'stasktool turns that abort intoSubagent failed (task_id: ...)even though the child answered correctly and finished withstop. Diagnosed live 2026-09-06 on 0.15.0: three probes, deterministic — a subagent using any provider-executed tool failed, a subagent using no tools returned fine. The plugin log is the tell: twotool call completelines with the sameid, the second ~2 ms after the final text ends. This was NOT a 0.15.0 regression (aborted parts go back to at least 2026-08-16) and it silently produced the long-standing background noise of⚙ abortedrows in the main lane too; it only became a hard failure through thetasktool. Do not "tidy" the delete away. Test:test-tool-block-index.ts, which fails withgot 2without it. -
Claude CLI emits internal tools (
Agent,ToolSearch,AskFollowupQuestion,TaskList,TaskGet,TaskStop) that have no opencode registry entry. They live inCLAUDE_INTERNAL_TOOLSinsrc/tool-mapping.tsand must be skipped, not forwarded. Forwarding them surfaces⚙ invalidtool rows in opencode.TaskOutputis the exception: it stays mapped to abash echoso the result is visible.TaskCreateandTaskUpdateare NOT in this set — they route through the todo ledger (see next gotcha). -
proxy-mcp
tools/callresponses MUST be MCP results ({ result: { content, isError } }), never JSON-RPC error envelopes. Claude CLI validates everytools/callresponse 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 insrc/proxy-mcp.tsnow return results withisError: true: unknown tool,result.kind === "error"(merged into the success path), and the outercatchwhenrequestMethod === "tools/call". Non-tools/callmethods (initialize, tools/list) and unparseable requests still use JSON-RPC errors, which is spec-correct.requestId/requestMethodare hoisted above the try so the catch can echo them — do not regress toid: 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 — checkgh api /repos/khalilgharbaoui/opencode-claude-code-plugin/security-advisories/GHSA-3mxm-w7gf-3c5x --jq .cve_idand, 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 atext/plainCORS simple request.createProxyMcpServernow mints a 256-bit bearer token per server, hands it to Claude in theheadersblock of the generated--mcp-config(that file is0600, which is now load-bearing), and rejects every request that fails one of four guards, in this order:Hostmust equal the bound127.0.0.1:<port>authority (DNS rebinding),Originmust be absent,Content-Typemust beapplication/json(forces a preflight that then fails, closing the simple-request hole), and the bearer token must match undertimingSafeEqual. All four run beforereadBody, so an unauthenticated peer cannot stream a body into memory, andreject()setsConnection: closeand destroys the socket so a slow unauthenticated body cannot holdserver.close()open. Three consequences to remember: (1)authTokenmust never be logged or put in the URL — the log line inreject()deliberately reports onlyhasAuthorization, 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 anOriginwould 403 every call — that is exactly whyreject()logs a reason at NOTICE; (3) anything in-repo that drives the endpoint over HTTP has to authenticate, which is whytest-proxy-mcp.tshasauthedPostandtest-compress-tool.tsthreadssrv.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 answeredPOST /mcpwith 200 and 145-byte MCP configs (noheadersblock) while the freshly started one answered 401 with a 272-byte config. That probe (lsof -nP -iTCP -sTCP:LISTEN | grep opencode, then an unauthenticatedinitialize, 401 = patched, 200 = stale) is the check to run after any security release, and it is in the README security section for users. -
proxyOpencodeMcpToolsread the wrong registry, and the prefix rule was never the bug. It defaulted totrueand routed nothing, becauseresolvedProxyMcpToolsdiscovered candidates fromclient.tool.list()(GET /experimental/tool), and opencode's tool registry does not contain MCP tools. Measured twice on 1.18.31, once on the maintainer's real config with five servers connected and once on a scratch config with one, both times returning only built-ins plus plugin-declared tools (invalid, question, bash, read, glob, grep, edit, write, task, webfetch, todowrite, websearch, skill, apply_patch, plusgemini_quota, quota_status, compresswhere those plugins are loaded);GET /experimental/tool/idsdocuments itself as "all tool IDs (including built-in and dynamically registered)" and is no better. Waiting 25 s changed nothing, so it is not a startup race, and no loose substring of a connected server name matched either, so it was never a naming-scheme problem. The full SDK route list indist/gen/sdk.gen.jshas exactly two tool routes and no other surface carries tool names, and neither the v1Hookssurface nor the v2PluginContext(agent,aisdk,catalog,command,integration,plugin,reference,skill) has an MCP-tool domain.- Where they actually are: the model tool set, which is downstream of the registry. In opencode's own bundle the session assembles
ofromToolRegistry.toolsfirst, then adds the MCP resources tool, then doesfor (let [k, W] of Object.entries(yield* d.tools())) { ... o[k] = b } return o, whered.tools()isMCP.tools()andkisMCP.toolName(server, tool). So MCP tools join the same dict the built-ins are in, after the registry has been read, which is why a registry query cannot see them and whytool.definitioncannot either. That dict is what reaches the provider, so thetoolsargument ofdoStreamis the only place a provider plugin can discover them.resolveMcpProxyToolDefsinproxy-mcp.tsnow reads it. The<server>/<server>_<tool>prefix match is unchanged and was always correct: live names look likecodebase-memory-mcp_list_projects, hyphens in the server name and all. - The default went
truetofalse, and that changed no behaviour. The option was inert, so leaving it on while repairing discovery would have silently moved every user's MCP traffic off the direct bridge that is carrying it today. Turning it on is now the operator's call, consistent withproxyOpencodeTools, the compress tool and the skill bridge.excludeServersalso narrowed from "every enabled server" to only the servers a def was actually built for: it was safe while the resolution was always null, but excluding a server with no def would drop it from--mcp-configwithout putting it on the proxy, reachable by neither route. - Enabling it is not enough on its own, and this cost a probe to find. Claude Code merges its own user-scope MCP config with the
--mcp-configthis plugin writes. Withcodebase-memory-mcpin both, excluding it from our config changed nothing: Claude calledmcp__codebase-memory-mcp__list_projectsthrough its own child, the log showsmapping MCP toolandexecuted: true, and the proxy was never touched. AddingstrictMcpConfig: truefixed it. Both README and SKILL.md say to pair the two; a user reporting "I enabled it and nothing routes" is almost certainly hitting this. - Live-verified 2026-09-19 on opencode 1.18.31 + Claude Code 2.1.263, scratch
XDG_CONFIG_HOMEand scratch cwd (oneplugin ready, providers["claude-code"]):routing opencode MCP tools through the proxylisted all 14codebase-memory-mcp_*tools whileGET /experimental/tool/idson the same server listed none of them,proxy-mcp server startedcarried them as defs,proxy-mcp tool call received {"toolName":"codebase-memory-mcp_list_projects"}fired, and the stored transcript holds acompletedtool part with 3521 characters of real output followed by the model's answer. One wrinkle to expect and not misread: opencode aborted the provider stream at that tool boundary (abort between proxy tool boundaries) and the result reached the model through the issue-#29 text path (rendering opencode-side tool result as text), the same shape AGENTS.md already records for dcp'scompress. The turn completed and the tool part rendered correctly. Probe scripts were scratch under/tmp/ocprobe, not in the repo. - Re-verified across every connected server before release (2026-09-19, on a copy of the maintainer's real config so all eight enabled servers were in play,
plugin readyasserted exactly once).GET /mcpreported five connected (alwasiyyah-errors,codebase-memory-mcp,figma,furno-postgres,obsidian) and three failed (postgres,postgres-alwasiyyah,slack), and the discovery line covered exactly those five, 60-odd tools, with nono MCP tool was foundwarning. The two-call structure is worth knowing before reading a log: thebridged opencode MCP config {"excluded":[]}line is the hot-reload probe (:2671), which deliberately passes no exclusions, while the spawn's own call (:2967) is the one carryingcoveredServers. With every bridged server covered,mcp-bridge.ts:580returns no path, which is why the argv then holds a single--mcp-configpointing at the proxy and no bridged file, and why only one bridge line is ever logged. A partially covered set takes the other branch and still writes a bridged config for the uncovered servers, which is the stranding fix doing its job; that case cannot be produced on demand live, so it is a unit test. - The bridged config file is content-addressed, and that is load-bearing.
finishBridgewritesmcp-<digest>.jsononly when the file is absent, and the digest is now taken over the file body. It used to behash, which covers the merged opencode config and notexcludeServers, so two calls differing only in exclusions collided on one filename and the first writer won. The hot-reload probe (:2671) always runs first with no exclusions, so the spawn's own exclusions never reached disk: a server routed through the proxy stayed in the bridged config as well and Claude could reach it both ways, which is the double execution the option exists to prevent. Only the partially covered case was affected, because full coverage returns early withpath: ""before touching a file, which is exactly why the live five-server probe looked clean and a test was needed to find it. The returnedhashis unchanged, since drift detection wants to track the config rather than the exclusions. Test: the wiring test below fails when the filename goes back tomcp-${hash}.json. --mcp-config <configs...>is variadic, sobuildCliArgspushing the flag once followed by every path (args.push("--mcp-config", ...filtered)) is correct and must not be "fixed" into a repeated flag. Anything parsing that argv has to read every argument after the flag until the next option; a parser that reads onlyargv[i + 1]silently sees one path and misses the rest.- This is still why
proxyOpencodeTools(PR #38) takes an explicit allowlist keyed on registry ids rather than extending the server-prefix rule: a plugin-declared tool belongs to no MCP server, so no prefix rule can ever reach it.
- Where they actually are: the model tool set, which is downstream of the registry. In opencode's own bundle the session assembles
-
A call with no deadline reports itself, because nothing else will (
PROXY_STALL_WARNING_MSinsrc/proxy-broker.ts, 5 minutes, repeating). Removing thetask/task_batchdeadline was right on correctness and wrong on visibility: a wedged subagent went from "fails after 60 minutes" to "silent forever", with the operator as the only detector. The heartbeat restores the visibility half without restoring a killer: it never ends a call, it logs one line naming tool, call id, elapsed,emitted,channelClosedand what will end it. Four things hold it together. It is armed only whendeadlineMs === PROXY_NO_DEADLINE_MS, since a deadline-bearing call already reports itself and a 5-minute build is not a stall. It is WARN for the same reasonreportFastModeStateis: only warn/error are alwaysStderr insrc/logger.ts, so a NOTICE would be invisible outside debug mode and the line would exist for nobody. It isunref'd, so a heartbeat can never hold opencode's process open. And every removal site now goes throughclearPendingTimers(pending)rather than clearingtimerby hand, because a pending call holds two timers and an interval left running against a deleted entry is a leak that repeats forever. Deliberately not done: no warning from the proxy-mcp HTTP side, which holds its own timer for the same call and would double every line. The deadline-bearing half followed immediately, and the reason is worth keeping: the original claim, that "a deadline already reports the call", was true only in the sense that it reports it by killing it, so the first signal is the failure.PROXY_DEADLINE_WARNING_FRACTION(0.6) fires one notice at 60% of the deadline withremainingMsand theproxyToolTimeoutMshint, one-shot because the rejection speaks next, andPROXY_DEADLINE_WARNING_MIN_MS(60 s) skips short deadlines where the notice and the rejection would land together. Found by hitting it: twowrite/bashproxy calls were rejected at their 10-minute deadline while the work was actually succeeding, with no prior signal, and the session had to infer it from silence. Tests: five more intest-broker.ts; only the substantive one fails when the arm condition is stubbed, since three assert absence. Tests:test-broker.ts(repeat, both stop paths, the deadline-bearing case, the0seam), three of which fail with the arm condition stubbed tofalse. -
A proxied call ends on an event, not on a clock, and the tests pin each event. This is the rationale behind the no-deadline
taskdefault, not a bigger timer: the plugin listens to the child process, the stdout stream and the control protocol, so it never has to infer from elapsed time that a subagent failed. The events, each with the regression that proves the call is released: opencode's result resolves it (test-proxy-task.ts"proxy MCP initializes, lists Task, and resolves it through the broker"); an abort rejects the turn's pending calls at once and interrupts the CLI, whether it lands before content (test-proxy-task.ts"immediate abort rejects a buffered Task call"), after content (test-process-lifecycle.ts"an abort after content…"), or while opencode is running the tool with the stream already closed on its boundary, where the signal fires on a closed stream and the handler acts only if no later turn has attached to the process (test-process-lifecycle.ts"an abort while opencode is running the tool…"; before the fork-parity PR that abort did nothing and the call waited for the next message); the next user message rejects the previous turn's calls as orphaned and the CLI's HTTP request gets the error result (test-process-lifecycle.ts"a task call the previous turn left pending…"); the child dying mid-turn ends the turn as an error and rejects its calls, and the child dying between turns rejects them fromspawnClaudeProcess's exit handler with no turn attached (test-process-lifecycle.ts, bothexit-*modes; the between-turns case was a real gap before the fork-parity PR, covered only by the 60-min timer); a deleted session and host exit reject them throughdetachActiveProcess(test-process-lifecycle.tsevent hook test,test-session-manager.tskillAllActiveProcesses); and a CLI that hung up on its own request keeps its entry for late-result recovery (test-proxy-task.tsrecovery modes), as does a watchdog respawn (test-respawn.ts, completions carried to the replacement). Every terminal-event test asserts both registries, the proxy server's open HTTP requests (ProxyMcpServer.pendingCallIds(), read-only) and the broker's entries (getPendingProxyCalls), not merely thatkill()ran or one promise rejected: with no deadline, an entry either side forgets to drop is permanent. What no event covers is a child that is alive and silent, which is what the start and inactivity watchdogs are for; they are unchanged and are not proxy deadlines. Keepalives are about the CLI's HTTP client, not the tool. Do not describe this change as "removing timeouts"; describe it as listening. -
Proxy call deadlines are per-tool, not flat.
resolveProxyCallTimeoutMs(toolName, input, overrides)insrc/proxy-mcp.tsis the single resolver consumed by BOTH the proxy-mcp HTTP handler (:478area) 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 (taskandtask_batchnone,PROXY_NO_DEADLINE_MS= 0;question30 min) →proxyToolTimeoutMsconfig override (case-insensitive; positive replaces,0disables, negative/NaN ignored) → forbashonly,max(resolved, input.timeout)so the proxy never undercuts a build the caller explicitly asked to run long (the bash def advertises atimeoutfield; ignoring it forced a model tonohupxcodebuild 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; thetaskvariant 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 flatPROXY_CALL_TIMEOUT_MSconstant is gone; do not reintroduce it. The one remaining flat value isresolveProxyClientCeilingMs(overrides)— thetimeoutwritten into Claude's--mcp-configentry 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 (defaults with overrides applied) so the client never gives up before the broker, and it isMAX_PROXY_TIMEOUT_MSwhenever any tool has no deadline, because the CLI rejectstimeout: 0in the MCP config (fork measurement,dd494a8). A deadline of 0 means no timer: both the HTTP handler and the broker guard theirsetTimeoutondeadlineMs > 0(the broker'stimeris nullable), sincesetTimeout(fn, 0)would reject the call on the next tick. What releases an unlimited call instead is the existing lifecycle: the next user turn's orphan sweep, an abort before content, the child closing, the process being deleted (which now also rejects the broker's entries for the key, see the deleted-session gotcha), and the late-result recovery path for a client that hung up. That last one is why the fork's immediate client-disconnect cancellation (CLIENT_GONE_MESSAGE,calls.emit("cancel")) was not taken: it deleted the entry the recovery machinery needs to deliver a latetaskresult as a continuation. Config is read once at opencode startup like the rest of the proxy block, soproxyToolTimeoutMschanges need a full restart./claude-code-doctorprints a 0 deadline asnone. Tests:test-proxy-mcp.ts,test-broker.ts,test-doctor.ts. -
Reused-process start watchdog. A reused
claude --printchild can go silent on stdout after a fresh-turn envelope write — seen after a very long proxy-blockedtaskcall resumed successfully (the per-tool timeout fix let the block return instead of ending the turn, which is what previously masked this). The doStreamarmStartWatchdog()(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, envCLAUDE_CODE_START_WATCHDOG_MS) it respawns the child viarespawnActiveProcess(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 originalcliArgs) and appends--resumeso the conversation resumes transparently (--session-idwould be rejected with "already in use" once a transcript exists — see the--resumegotcha; adapted during absorption on top of PR #18). The replacement inherits the old process's in-flight marker (turnWasInFlightread before the swap,noteTurnStarted(replacement)after; @broskees'b719497), anddeliverPendingCompletionscallsnoteTurnStartedbefore its own write, so a recovered continuation is busy for abort, LRU eviction, the idle timer and the next turn's quiesce; before that handoff every one of them read the working replacement as idle. Still no permanentlineEmitterlistener for it:listenerCount("line") === 0is load-bearing for the unattended buffer and/btw. 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 +deleteActiveProcessso the next opencode turn spawns fresh.cliArgsis hoisted to doStream scope so the watchdog (which lives outside the non-interactiveelsespawn 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/TaskUpdatefamily into opencode's full-listtodowriteso the opencode todo panel populates during multi-step Claude work. State lives insrc/todo-ledger.ts, keyed by Claude CLI session id, cleared viaclearLedgerfromdeleteClaudeSessionIdinsession-manager.ts. TaskCreate stashes pending bytool_use_idon tool_use and commits on tool_result (parsed via/Task\s*#?\s*(\d+)\s+created/i); TaskUpdate mutates in place. WithoutsessionIdinMapToolOptions, both fall back to{skip: true}to preserve safety for callers that haven't been threaded. Tests live intest-todo-ledger.tsandtest-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'stask.ts:197injectstodowrite: falseinto the tools dict for subagents that don't have the rule, so the ledger's synthetic todowrites surface as⚙ invalidin the subagent's stream. Built-ingeneraldenies 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 thetodotable and parts withtool="todowrite"appear in theparttable for the subagent's session id. Todos then render inline in the subagent's session view (navigate viasession.child.next), not the parent's. Empirically confirmed 2026-05-16 via subagentses_1d16d3bb4ffeOI5QUWZzBKDsSL. -
Verified compatible with opencode v1.18.31 (re-audited 2026-09-19:
1.18.29 → 1.18.31is byte-identical in both packages apart from theversionfield, 69 plugin files and 79 sdk files compared each side, so every finding below still stands verbatim). Fetch the tarballs fromregistry.npmjs.orgwithcurlrather thannpm pack: on a slow linknpm packof four packages exceeded a 300 s timeout twice, while the direct tarball fetch took seconds. Previously audited at v1.18.29 (2026-09-07, by diffing the published packages 1.18.18 → 1.18.29).@opencode-ai/pluginis byte-identical apart frompackage.json, so every v1 hook we implement is unchanged, includingchat.params, whose output still carriesoptions: Record<string, …>at the top level (the "do not pre-nest under providerID" gotcha still holds). SDK v1 (dist/gen/*) is byte-identical too:McpStatusis still the same five variants, soenabled: status === "connected"inmcp-bridge.tsstays correct, and the v1Modeltype did not move. The entire delta is in v2, which we do not use: providerchunkTimeoutwidened tonumber | false, its andheadersTimeout's docs now name a 300000 ms default,GlobalUpgradeData.body.targetbecame required, and anupgradedoc string was reworded. Nothing to change in the plugin; the 1.18.5 audit notes below still stand in full.src/opencode-types.tsis not a copy of any single upstream type, so do not "fix" it by pasting one in. ItsOpenCodeModelblends two schemas:release_date, and the flat models.dev-shaped provider config entry, come from the v1 config schema, while nestedcapabilitieswithinterleavedmatches the v2 runtimeModel. v1's own runtimeModelhas none ofinterleaved,release_date,family,variantsorlimit.input. The blend is what opencode actually accepts from theprovider.models()hook, confirmed empirically: models resolve and sessions run on 1.18.29 (live probes, 2026-09-06). Non-load-bearing but worth knowing: v2 documenting 300000 ms as the ambient timeout default is consistent with the 300 s proxy wall, though that wall is in the Claude CLI's MCP client, not opencode's fetch, so it is corroboration and not proof.
-
Earlier audit, opencode v1.18.18 (2026-08-20, by diffing the published packages:
@opencode-ai/plugin1.18.5 vs 1.18.18 is byte-identical apart frompackage.json, and the only@opencode-ai/sdktype change iscapabilities.interleavedwidening —reasoning_detailsbecamereasoning_textand bare strings/booleans are accepted.src/opencode-types.tswas updated to match; we passinterleaved: 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.5type surface, plus a liveopencode runturn on that binary). Nothing we depend on broke, because the plugin does not import opencode's types at all —src/opencode-types.tsis a hand-written structural mirror, so drift is silent and has to be audited deliberately. Findings worth remembering:- The v1
Hookssurface is unchanged where we touch it:config,provider: { id, models(provider, ctx) },chat.params(output still hasoptions: Record<string, any>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,PluginContextwithaisdk/catalog/agent/skill/commandhooks). It is additive; v1Pluginis still the documented entry. Migration is optional — tracked in issue #24, do not start it casually. PluginInputgainedserverUrl: URL,$: BunShell,worktree,experimental_workspace. Still no version field (see the diagnostics gotcha).McpStatusis still the same 5 variants, soenabled: status === "connected"inmcp-bridge.tsremains correct.- The model schema (
sdk/v2Model) gained optionalcost.tiers({ tier: { type: "context", size } }) andcost.experimentalOver200K, andcapabilities.interleavedgained afield: "reasoning"variant. All optional, so ourdefineModeloutput still validates. Long-context pricing for the1_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/compactdetection and auto-continue nudge),experimental.chat.system.transform,chat.headers,permission.ask. - CLI flags changed:
opencode runno longer accepts-aas shorthand for--agent(spell it out in smoke tests), and gained--variant,--thinking,--auto,--pure,--fork,--attach. - Superseded as of PR #39: this line used to read "opencode's
toolsargument todoStreamis still intentionally unused". It is read now, and it is the only place a provider plugin can see opencode's MCP tools, because they join the model tool set afterToolRegistryhas been enumerated (see theproxyOpencodeMcpToolsgotcha above for the bundle evidence).resolveMcpProxyToolDefsreads it for exactly that purpose and nothing else: Claude CLI is still only offered its own built-ins plus whatever reaches it through--mcp-configor the proxy, so opencode-native tools liketask_statusstill never reach the model and still need nomapToolentry. Do not "tidy away" the read on the strength of the old sentence. - Re-audit at the next opencode minor bump. The
opencodefield in the startup block names the running version, so an audit starts by reading that.
- The v1
-
cwdresolution at spawn must stay lazy.opencodeProjectDirectorycaptured fromPluginInput.directorylives inruntime-status.tsand is consumed viaresolveSpawnCwd()at spawn time only as a fallback whenprocess.cwd()is unusable (/). Do NOT bake the captured value intomergedOptions.cwdduring provider registration inindex.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 intest-cwd-resolution.ts.- Serve mode gets a tier between the pin and
process.cwd(): the session's owndirectory(resolveSpawnCwdForSessioninruntime-status.ts, cherry-picked from @galvani's9e02ce4, absorbed 2026-09-06). Inopencode serve/ web UI / OpenChamber one long-lived server handles many projects andprocess.cwd()is the server's launch dir, which is "usable", so it won and everyclaudespawned there.GET /session/{id}carriesdirectory; it is fetched per call (no cache, a workspace switch can change it) keyed by the affinity id, and any failure falls back to the old resolution so the TUI path is unchanged. Live-verified: server launched from/tmp, session created with?directory=<project>, spawn logcwd= the project's realpath.describeSpawnCwdfor the startup block still mirrors the synchronous order only; the session tier is per call and cannot be described at init.
- Serve mode gets a tier between the pin and
-
AskUserQuestionis auto-denied incontrolRequestBehaviorForTool(so the headless CLI can't self-answer an empty TTY) and rendered to the operator as markdown viaformatAskUserQuestion. The deny message (denyMessageForTool/ASK_USER_QUESTION_DENY_MESSAGEinclaude-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 viadenyMessageForToolintest-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 latchAutoContinueState.sawAskUserQuestion, set whenformatAskUserQuestionrenders, makesshouldAutoContinueIncompleteTurnreturn{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 intest-auto-continue.ts. -
The
AskUserQuestionfallback 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 AskUserQuestionvalidates silently, while a bogus name printsmatches no known tool), but the tool is not offered to the model under--print— a direct "list every tool you can call" returnsAgent, Bash, Edit, Read, ReportFindings, Skill, ToolSearch, Workflow, Write, andToolSearch select:AskUserQuestionreturns nothing. It reads as a TUI-only affordance the headless surface no longer presents. Consequence: withQuestionoff (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 expectformatAskUserQuestionor 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
questionTUI form never renders, so an enabledQuestioncosts you the workingAskUserQuestionfallback 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) theparttable shows everyquestioncallcompletedthrough 2026-04-25 and every one since 2026-05-18 aborted, i.e. an opencode regression somewhere in v1.14.24…v1.15.5 (noteThe user dismissed this questionis a different, healthy error — it means the form rendered); (c) a--pure(no-plugin) headlessopencode servedrives the whole server path green — tool blocks,question.askedpublishes,GET /questionlists it,POST /question/{id}/replycompletes the tool with the answer and emitsquestion.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 promoteQuestiontoward 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 inDEFAULT_PROXY_TOOL_NAMES(src/index.ts) — enabling it disables Claude's built-inAskUserQuestionvia--disallowedToolsand 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)--disallowedToolsis computed from the post-filter proxy list (enrichedProxy), neverresolvedProxy—filterQuestionProxyByOpencodeSupportdrops the def on opencode builds without aquestionregistry entry, and computing from the pre-filter list would disableAskUserQuestionwhile its replacement is absent, leaving the model with no question path at all. (2)QUESTION_PROXY_HINTmust name the FULLmcp__opencode_proxy__question: haiku strips the MCP prefix and calls barequestion, which opencode renders as⚙ invalid(same near-miss family as TaskCreate vs the task proxy). (3)questiongets a 30-min default inPROXY_PER_TOOL_DEFAULT_TIMEOUT_MSbecause it blocks on a human reading a form; the flat 10-min ceiling rejected calls mid-answer.fetchLiveToolInfodoes ONEclient.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 hasquestion); a build lacking it takes the fallback silently, which thequestion proxy version gatelog line makes visible. Tests:test-proxy-mcp.ts,test-cli-args.ts,test-subagent-hint.ts,test-ask-user-question.ts. -
planModeQuestioncannot fire on the headless transport, andpermissionMode: "plan"is inert at the defaultskipPermissions. Both measured on CLI 2.1.258, 2026-09-06. The bridge keys on anExitPlanModetool_use, and headless--printdoes not offer that tool: asked for its tool list in plan mode the model returnedAgent, Bash, Edit, ListAgents, Read, ReportFindings, ScheduleWakeup, Skill, ToolSearch, Workflow, Write, said "I'm unable to exit plan mode from within the tool set available to me" when asked to work, and a full probe through the plugin (planModeQuestion: true,skipPermissions: false, opencode 1.18.29) logged noExitPlanModeat all while the model asked for approval in prose and its blockedwriteproduced no file.--disallowedTools ExitPlanModestill validates silently where a bogus name warns, so the name is known and this is headless dormancy, exactly theAskUserQuestionshape above — do not read "the bridge did not fire" as a plugin bug without re-running those probes. The separate trap is now fixed:buildCliArgsdrops--dangerously-skip-permissionswhenpermissionModeis"plan". It used to push both independently and the CLI lets the skip flag win, sopermissionMode: "plan"at the defaultskipPermissions: truegave no plan mode whatsoever: verified by writing a file, unprompted, in a plan-mode run, where the same request without the skip flag was refused. Plan mode is a capability restriction rather than a prompt policy, so it wins; every other mode governs prompting, which is exactly what the skip flag is for, and still passes both. Live-verified after the fix through a full plugin probe at default settings:--permission-mode planpresent, skip flag absent, requested file never created. Do not "restore symmetry" by making the flag unconditional again. The honest remainder, whichwarnIfPlanModeCannotExitinindex.tsstates once per process at WARN: nothing releases plan mode mid-session, so an enforced plan mode is a one-way door out of which the only exit is editing config and restarting opencode. The CLI does still write its own plan markdown under~/.claude*/plans/, which is its feature and outside the workspace. Probe scripts:/var/folders/.../opencode/verify-plan-mode-question.mjsandverify-plan-enforced.mjs(scratch, not in the repo). Tests:test-cli-args.ts. -
Plan-mode approval bridge (
src/plan-mode-question.ts, absorbed from @CollieIsCute's8c5b583with authorship preserved, issue #21) is opt-in viaplanModeQuestionand off by default, for the same reason the question proxy is: it delivers through opencode'squestionform, 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:ExitPlanModestops being rendered as**Do you want to proceed with this plan?** (yes/no)text and instead ends the turn ontool-callswith a syntheticquestiontool-call, then the operator's answer is turned back into atool_resultfor the originalExitPlanModetool_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 thattool_result, so a "yes" typed as ordinary prose never actually unlocks it. Invariants: (1) the gate isisPlanModeQuestionActive(config + live registry hasquestion+ 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-eventcontent_block_stopand whole-assistant-message), so a change to one needs the same change to its twin; all four keep the legacy text path verbatim in theelse. (3)clearExitPlanModeQuestions(sk)runs whereverdeleteClaudeSessionId/deleteActiveProcessdo, or a stale pending id outlives its session and the next answer is routed to a dead tool_use. (4)finishReasonmust betool-calls(not the usual unconditionalstop) 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 withpermissionMode: "plan"and is not verified; it cannot be while the form is broken. -
Compress proxy tool (
src/compression-store.ts+ thecompressdef inproxy-mcp.ts, reimplemented from @flupkede's4ac319f/5b4ee5don their unmergedfeature/compress-toolbranch, credit theirs). Opt-in viaproxyTools: [..., "Compress"], deliberately absent fromDEFAULT_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 interceptedtools/callis answered in-process (no broker entry, no deadline, no permission prompt). Five invariants:- 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). - 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.clearCompressionis called only from the!hasPriorConversationbranch (a new opencode conversation), plus a 32-entry cap in the store. Regression test: "summary survives the session reset that the compress call triggers". - The reset runs inside
doStream'sstart(), afteruserMsgandincludeHistoryContextwere resolved against the still-live session. That ordering is what makes it a real reset:includeHistoryContextstays false, so the fresh child gets this turn's message plus the summary in its system prompt and nothing else. Move the reset earlier andcompactConversationHistorywould replay the whole opencode conversation, which is the opposite of compressing. - It is skipped when
hasMatchedPendingResults— evicting a child whose tool results are arriving this turn would deliver atool_resultto a process that never issued thetool_use. The mark is not consumed, so it fires on the next turn instead. CLAUDE_CLI_COMPRESS_NOTEreplacesCLAUDE_CLI_CONTEXT_NOTEonly whencompressis in the post-overlay proxy list (enrichedProxy), and it spells out the fullmcp__opencode_proxy__compressfor the same reasonQUESTION_PROXY_HINTdoes. The default note still tells the model compress does not exist, which stays true fordoGenerate(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.
- Interceptor results go out through
-
Two different tools want the MCP name
compress, and the precedence is deliberate (proxyOpencodeTools+resolveProxyOpencodeToolDefsinproxy-mcp.ts).resolvedProxyMcpToolsforwards an opencode tool only when its id matches an enabled MCP server (<server>or<server>_<tool>), so a tool another opencode plugin declares directly belongs to no server andif (!matchedServer) continuedrops it. opencode-dcp'scompressis exactly that, which is why dcp's "MAX CONTEXT LIMIT REACHED ... You MUST use thecompresstool now" reminders were unobeyable under this provider: the tool is inclient.tool.list()(confirmed on 1.18.31, alongsidequestion,task,skill,gemini_quota,quota_status) and was simply never offered.proxyOpencodeToolsis the explicit allowlist that forwards it, empty by default, and never automatic because a forwarded tool executes in opencode with the calling agent's permissions. The collision is resolved twice, at two layers, and both are load-bearing: (1) at def level,takenholds the names already claimed byenrichedProxyand the MCP defs, so a forwardedcompressis dropped with a WARN rather than becoming a second def of the same name; (2) at interceptor level,ensureProxyServertakes an explicitinterceptCompressflag instead of keying ontools.some(t => t.name === "compress"). Layer 2 is the one a def-level check cannot see and the one that actually bit: with only the forwarded def present there is nothing to collide with, and the old name-keyed condition would have answered opencode's tool with the plugin's in-process reset ("Summary stored...") while opencode never saw the call. The plugin's own tool wins when both are configured, because it is named explicitly inproxyToolsand it manages the window that overflows here.buildAppendedSystemPromptfollows the same precedence and has a third note variant (CLAUDE_CLI_OPENCODE_COMPRESS_NOTE) that says the forwarded tool compresses opencode's transcript and not the Claude session: reusing the plugin's note would tell the model its context had been discarded when it has not. Live-verified 2026-09-19 on CLI 2.1.263 + opencode 1.18.31 with dcp loaded:forwarding opencode tools through the proxy {"tools":["compress"]}, proxy started withtools: ["bash","compress"],proxy-mcp tool call received {"toolName":"compress"}, queued through the normal broker, and dcp really ran (Compressed 3 messages into [Compressed conversation section]). The wrinkle to expect: dcp's compress rewrites opencode's message history mid-turn, so opencode aborts the provider stream at that tool boundary (abort between proxy tool boundaries; releasing pending calls) and the result reaches the model on the next step through the issue #29 text path (rendering opencode-side tool result as text). The turn completes and nothing leaks, but do not read that abort as a regression. Collision verified live in the same session: WARN emitted, exactly onecompressin the server's tool list, and the interceptor answered. Tests:test-compress-tool.ts(forwarding, unknown name, unreachable registry, both collision layers, note selection).- Probing any of this live needs a scratch
XDG_CONFIG_HOME, not justOPENCODE_CONFIG. opencode merges thepluginarray with the user's global config, so a scratch config still loads the parent checkout's copy of this plugin and its provider registration can win. The symptom is silent and cost three paid runs: the option is visibly present inGET /configprovider options, yet the model behaves like a build without it, because the language model came from the other copy. Assertplugin readyappears exactly once inplugin.log. Two smaller traps in the same family: a leftoveropencode.jsonin a parent directory of the probe's cwd beatsOPENCODE_CONFIG, so give each probe its own cwd; and an account provider's model id carries the marker (claude-haiku-4-5@appical), where a bare id 500s as an opaqueUnknownError. stripContextReminders(message-builder.ts) is the other half, also off by default. dcp anchors its nudges into message text (lib/messages/inject/utils.tsappends to an existing text part or splices a synthetic one), not into the system prompt, so each is re-sent with every message that carries it; all of them are wrapped in<dcp-system-reminder>. The strip runs once at the top ofgetClaudeUserMessage, which is why the fresh-session rebuild and the/compacttranscript get it for free instead of each needing a flag. Three rules: it is matched wherever the block sits, because dcp appends<dcp-message-id>after one and an end-anchored check would miss it (the same trap the/btwstrip hit in production); emptied parts are kept as empty strings rather than dropped, since a nudge can be a message's only text part and removing it could leave a user message with no content at all; and it must never touch opencode's own<system-reminder>blocks, which are opencode's instructions to the model.shouldStripContextRemindersturns it off as soon ascompressis named in either list, resolved from config alone so it is answerable before the spawn block (userMsgis built well ahead of it) and so a configured-but-unregistered name errs toward keeping the reminder. Tests:test-get-claude-user-message.ts.
- Probing any of this live needs a scratch
-
Account failover is ON by default, and the thing that makes that safe is that the pick is the consent (
src/account-failover.ts). With more than one account configured, a usage limit ends the turn on opencode's nativequestionform (the same mechanism as the plan-mode bridge:finishWithQuestionCallemitstool-input-start+tool-calland finishes ontool-calls, and the answer arrives on the NEXTdoStreamas atool-resultwith the same id) instead of the rate-limit error. Nothing moves until an account is picked, and an unanswered form waits at zero cost. Seven things hold it together and none is optional:- Detection is two exact signals, never "an error".
isAccountLimitErrorfires on arate_limit_eventthatisRateLimitRejectedaccepts, or on one ofACCOUNT_LIMIT_PATTERNS(the two texts this file already records). A generic 4xx opening this form would silently move where usage is billed, which is the one failure mode that would be worse than the error it replaces. The rate-limit branch parses the event separately fromreportRateLimitEvent, which dedupes per process and returns null on a repeat: the second rejection in a session is still a rejection this turn must act on.statusis the verdict andoverageStatusis not.isRateLimitRejectedused to acceptoverageStatus: "rejected"on its own, and on an org with extra usage disabled that is the steady state of every served request: measured on CLI 2.1.280 (2026-09-23) as{status: "allowed", overageStatus: "rejected", overageDisabledReason: "org_level_disabled"}on a turn that answered normally. The result was a false▌ rate limit: rejectedline and, becausecompleteResultdid not check the outcome, the failover form replacing a good answer. Nowallowed/allowed_warningalways wins, an overage rejection counts only when no status came with it, and the form additionally requiresmsg.is_error === true. Tests:test-cli-events.tsandtest-account-failover.ts(served-turn fixture), both failing without the fix. - The override is keyed on the LIMITED ACCOUNT, not the session. A rate limit is a property of the account, so one pick covers every session on it and a subagent follows its parent for free. That is also why
isAccountFailoverQuestionActiverefuses child sessions (fetchSessionParentIdinruntime-status.ts, off the sameGET /session/{id}asfetchSessionDirectory): a form in a subagent session is one nobody is looking at. --resumecan never cross accounts, because transcripts live under the account's ownCLAUDE_CONFIG_DIR. A switch is therefore always a fresh session with the thread replayed: the prologue drops the active process and the Claude session id whenActiveProcess.cliPathdiffers from the resolved one, which is what makesincludeHistoryContexttrue, and is equally what switches back once the override expires. The comparison is guarded onactive?.cliPath &&so the interactive shim, which carries no path, is never dropped by it.- The
@accountsuffix must come off the model id.parseModelIdkeeps it on purpose (the source account's own wrapper strips it), but a failover spawn goes through a different wrapper, or the bare binary fordefault, and--model claude-opus-5@appicalis rejected outright.resolveFailoverSpawnstrips it;parseModelIdis called onfailover.modelId, not oneffectiveModelId. - A reset time that is not in the future degrades to "until opencode restarts". Found by the fake-CLI test, not by reasoning: with
untilbehindnow(clock skew, a staleresetsAt),resolveAccountOverridedeleted the override on the very next read, so the switch the operator had just authorised was undone before it ran and the turn re-hit the same limit and asked again.setAccountOverrideclamps it. - The dialog must never be replayed. The synthetic
questiontool-call and itstool-resultcarryaccount_failover_ids Claude never issued or saw, sostripAccountFailoverPartsis called fromfilterSideQuestionHistory(both transcript rebuild paths) and frombuildFailoverContinuationPrompt.FAILOVER_MARKERis registered inPLUGIN_NOTE_MARKERSand the note is enqueued as its own text part, the same rule every▌line follows. A message left with no content after the strip is dropped rather than replayed empty. doGeneratetakes the override with no dialog of its own. A title or no-tools call must not ask anything, but it must follow the account the conversation moved to, or it quietly bills the limited one. Excluded entirely: compaction (its answer would have nowhere to go) and the interactive transport (TUI stdin, no proxy server).clearAccountFailoverQuestionsis wired intodeleteClaudeSessionIdnext toclearExitPlanModeQuestions.ExitPlanModeQuestionCallis now an alias of the sharedQuestionToolCall, andunwrapToolOutput/collectAnswerStringsare exported rather than copied. Tests:test-account-failover.ts(23, including the negative detection cases and a fake CLI that answers differently depending on theCLAUDE_CONFIG_DIRit was reached through, which is how the routing, the stripped--modeland the<conversation_history>replay are asserted). Not live-verified: the whole path is offline only, so the first real-account run is the one that proves it.
- Detection is two exact signals, never "an error".
-
ignoreAnthropicApiKey(added 0.9.1, issue #9 secondary ask from @Aptul9): a strayANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKENin 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 isclaudeSpawnEnv({ ignoreAnthropicApiKey })insession-manager.ts; the headlessdoGenerate/doStreamspawns and the interactive transport (ClaudeSessionOptions.ignoreAnthropicApiKey→ inline env block inclaude-session-bun.ts) all thread it through. Default off so deliberate API-key users are unaffected.warnIfAnthropicApiKeyinindex.tslogs 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):spawnInteractiveProcessreturns anActiveProcess-shaped shim so doStream's line handler, session reuse, and eviction work unchanged. Key invariants: (1) doStream writes stream-json user envelopes tostdin.write;decodeUserEnvelopeconverts them to typed plain text — text blocks joined,tool_resultrendered 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 staysubtype: "error_during_execution", is_error: true— do not "clean it up" toend_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 forwardedextrapayload can trigger Claude Code's third-party-app usage gate on subscription accounts.interactiveSystemPrompt: falseis diagnostic-only and drops even the plugin prompt. (4) There is nocan_use_toolcontrol channel in the TUI — permissions are pre-allowed via--settings '{"permissions":{"allow":[...]}}': MCP wildcards always derived from the live bridge config, built-ins frominteractiveAllowTools(default Bash/Edit/Write/Read/WebFetch). Do NOT pass--permission-mode bypassPermissionsin 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 configuredcliPath, not plainclaude; account providers rely on wrapper scripts like~/.cache/opencode-claude-code-plugin/claude-<account>to strip@accountmodel suffixes and setCLAUDE_CONFIG_DIR. The JSONL tail path must use the sameconfigDir(~/.claude-<account>for account providers), otherwise opencode hangs while Claude writes transcripts elsewhere. (6) TheBun.Terminalcapability gate falls back to headless silently. (7) Compaction always takes the headless path. Turn timeout default is 30 min (turnTimeoutMsinclaude-session-bun.ts). Offline tests:test-claude-session-wrapper.ts; live verification needs a Bun-run opencode withinteractive: true. -
Startup diagnostics (
src/startup-diagnostics.ts, roadmap #3): oneNOTICE: claude-code plugin readyblock emitted once per process from theconfighook inindex.ts, replacing the older "registered claude-code provider(s)" notices. Fields: plugin version, opencode version,claudeClipath+version,cwdwith the branch that won (configured|process|captured|unresolved—capturedis the issue-#4 macOS-GUI fingerprint), provider ids, accounts,proxyTools, enabled MCP servers, interactive-transport flag,anthropicApiKeyInEnv. It is fire-and-forget (claude --versionis async, 5s timeout, cached) and every field is wrapped so diagnostics can never break provider registration.describeSpawnCwdintentionally mirrorsresolveSpawnCwd's priority order and a test asserts they never disagree — change both together. The MCP list is the disk-only merge (mergeOpencodeMcp, split out ofbridgeOpencodeMcpso 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. Theopencodefield is resolved bydetectOpencodeVersion(): the plugin runs inside opencode's process, soprocess.execPathis the opencode binary and<execPath> --versionis the only reliable source (cached, 5s timeout, guarded on the basename containing "opencode" so abun runfrom source reports "unknown" instead of Bun's version). It is only spawned when the plugin input andOPENCODE_VERSIONgave 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 (PluginInputhas no version field, the SDK client'sappnamespace is still onlylog+agents, and the server exposes no/versionroute — the route list insdk.gen.jshas none). To see the block:OPENCODE_CLAUDE_CODE_LOG_FILE=1 opencodethen 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.
These rules supersede the older lifetime-cache and process-cleanup wording in the question-proxy and plan-mode notes above:
-
createLiveToolInfoLoader()shares one lazyclient.tool.list()request within adoStreamturn. A later turn creates a fresh loader, anddoGeneratefetches 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 throughdeleteClaudeSessionId(). -
Auto-continue never fires on current Claude Code CLI. Measured 2026-08-19 from
~/.local/share/opencode-claude-code/plugin.log: 53 decisions stopped atreason: "end-turn"withattempts: 0, 12 aterror, and nothing else. Re-measured 2026-09-20 against CLI 2.1.263 and the conclusion holds, but two sentences of the original are wrong and are corrected here. The window was 2026-09-19T20:13Z to 2026-09-20T01:29Z (the log rotates at ~5 MB, which is what bounds it), 275 decision lines, of which only 47 are production: the other 228 carry a/var/folders/cwd or aclaude-test-*model id and are the suite's own fake CLIs. Split them before reading a tally or the picture is badly wrong. Production: 34end-turn(stop_reason: end_turn), 13error(stop_reason: stop_sequence), zero continuations, every lineattempts: 0, and nomax_tokensanywhere, so the truncation branch still has no production mileage. The two corrections: (1)stop_sequenceis a stop reason this record never mentioned and it is now a third of production decisions, and it lands onreason: "error"becausesnapshot.isErroris checked before the stop-reason guard. (2) "The CLI always emits astop_reason" is not strictly true on 2.1.263. An interrupted turn'sresultcarriesstop_reason: nullwithsubtype: "error_during_execution", measured directly (see the abort probe below). The conclusion survives anyway, and for a reason worth keeping: that same result hasis_error: true, and theisErrorcheck short-circuits above the guard, so the heuristic is still never consulted. The onlystopReason: nulllines that reach the heuristic in the whole log come from the fake CLI intest-unattended-replay.ts, which is what the fallback is for. Also re-checked on 2.1.263 and not found: any sign that the CLI continues itself, which would risk our nudge doubling up on a turn. A plain-pturn emits exactly one terminalresult(num_turns: 1), and an interrupted turn emits exactly one errorresultand then stays silent until a new user message. The 515 realreplaying stdout the child emitted between turnslines are not this: that is the known proxy-detach path (PR #35), not unprompted continuation. The stall and server-error cases the backlog claims were not reproducible cheaply and are therefore not established either way. Abort was re-verified live on 2.1.263 by driving the real CLI over stream-json stdin with exactlyinterruptTurn's payload:control_responsesubtype: "success"came back in 1 ms (now carryingstill_queued: [], matching the newinterrupt_receipt_v1/interrupt_cancel_queued_v1capabilities thatsystem/initadvertises), the turn ended 331 ms later, only 14 characters streamed after the interrupt, and the same process answered the next turn cleanly withstop_reason: end_turn. Unparsed-but-forwarded stream events as of 2.1.263, reported and deliberately not implemented in that lane: the CLI's SDK allowlist forwardstool_progress,tool_use_summary,prompt_suggestion,conversation_resetandcommand_lifecycle, none of whichsrc/cli-events.tsreads;systemalso hashook_started/hook_responsesubtypes (seen live),compact_metadatagained an optionalcumulative_dropped_tokens, andinitnow carriesagents,capabilities,plugins,skillsandslash_commandsamong others.conversation_resetis the one worth a look first, since a reset would invalidate thetoolCallMapand pending-proxy bookkeeping./goaland/loopare advertised ininit.slash_commandsunder--printon 2.1.263 (159 commands listed), which confirms the backlog's claim that far; neither was executed, deliberately, because/loopcan loop. The original reasoning, as corrected above: the CLI emits astop_reasonon every turn that is not aborted, andshouldAutoContinueIncompleteTurntreats anystop_reasonas authoritative (v0.4.17), so the keyword heuristic below that guard (looksLikeFinalAnswer/looksLikeQuestion/looksLikeBlockerand the whole v0.4.10 to v0.4.15 idiom list) is dead code in practice, andautoContinueIncompleteTurns: "smart"behaves asoff. @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 apackage-lock.jsonthis repo deliberately does not have. The narrow change has since been made, and not the way that sentence originally proposed.isTruncationStopReason(max_tokens, plusmax_output_tokensas a defensive alias) now returns{continue: true, reason: "truncated"}, bounded by the attempt and elapsed rails, while every otherstop_reasonstays authoritative. It could not simply "fall through to the heuristic": the common truncation case is one long prose answer with no tool or reasoning activity, which dies at theno-activitygate a few lines below, so truncation had to be authoritative in the opposite direction instead. It runs at the defaultautoContinueIncompleteTurns: "smart", which is what makes it reachable at all given everything else about that setting behaves as off. Do not delete the heuristic either: it is the fallback for CLIs that omitstop_reason. Tests:test-auto-continue.ts(five cases, all failing with the branch stubbed out).- A compaction turn must never be nudged, and truncation-continue is what made that reachable.
AUTO_CONTINUE_PROMPTsays "Do not summarize; keep working", the exact inverse of a/compactturn's job, and continuation reopens the same stream instead of closing it, so the non-summary text would be appended to what opencode stores as the session summary.doStreambuildsautoContinueStateinline and passedself.config.autoContinueIncompleteTurnsstraight through with nocompactionModeterm, which was harmless only while everystop_reasonreturnedcontinue:false.autoContinueEnabledFor(compactionMode, configured)now gates it, exported purely so the wiring is testable rather than only the pure decision function. Bounded at 8 attempts either way, so the pre-fix worst case was an inflated and corrupted summary, not a hang. Found by a subagent review of the truncation change, not by the test suite, which had no compaction case at all.
- A compaction turn must never be nudged, and truncation-continue is what made that reachable.
-
opencode's
tool.definition,experimental.session.compactingandexperimental.compaction.autocontinuehooks were evaluated on 1.18.29 and deliberately NOT adopted (issue #24).tool.definitionfires only inside opencode's ownToolRegistry.tools, over built-ins plus filesystem/plugin-declared tools; MCP tools are not in that registry, and the MCP assembly path triggers onlytool.execute.before/after. So it cannot reach the proxy defs this plugin serves to the Claude CLI, and it could not do the job anyway: opencode appendsdescribeTask's agent list after the hook returns, which is the exact orderingoverlayTaskProxyDescriptionexists to control against Claude Code's description truncation. Its input is{toolID}alone, with no session/provider scope, so any edit would reshape tools for every provider in the user's opencode. The compaction hooks are a prompt-authoring hook and a veto on opencode's post-compaction synthetic turn; neither is registered here, so they cannot interact with this plugin's auto-continue, and they operate on a different boundary regardless (an opencode turn versus a CLI turn inside one opencode turn).experimental.session.compactingwould also be strictly worse for detection thanopencodeAgent === "compaction", which is available synchronously per call and drives the model override, effort exemption, session key and lean spawn. -
SUPERSEDED 2026-09-23, read
V2.mdinstead. The bullet below was right about the surface it measured and wrong about what that surface was. It read@opencode-ai/plugin/v2inside opencode 1.x and concluded that v2 was an additive alternative API with no payoff. v2 is a new major of opencode itself: V1 and V2 share theopencodecommand, are not installed side by side, the installer replaces the V1 binary, and the migration guide states plainly that V1 plugin implementations do not run in V2. So this is not an optional migration, it is the day this plugin stops existing. The specific claim that killed it is also now false against the shipped package:@opencode/plugin@2.0.11(a different package from@opencode-ai/plugin) does carry anaisdkdomain on the plugin context, and itslanguagehook takes aLanguageModelV3, which is exactly whatClaudeCodeLanguageModelalready is, against the same@ai-sdk/provider3.x we already depend on. The website's plugin docs do not mention that domain and describe providers as metadata only; the.d.tsin the tarball is what settles it. Dual V1/V2 support from one package is officially documented, andsrc/index.ts's default export is already the required object shape. Plan, probes and phases:V2.md. -
v2 plugin API: do not migrate, and the reload that exists is not the one we want (tracker is issue #31, checked on 1.18.29; #24 is closed and is not the tracker any more).
Reloadis{ reload: () => Promise<void> }(dist/v2/promise/registration.d.ts), andcatalog,agent,command,integration,referenceandskillcarry it whileaisdkdoes not (dist/v2/promise/context.d.ts). So model metadata can be re-transformed at runtime throughCatalogHooks = Hooks<{transform: CatalogDraft}>, but the model implementation path cannot. That does not touch this plugin's actual pain point: provider options are captured atcreateClaudeCode()and baked into eachClaudeCodeLanguageModel, andcatalog.reload()re-runs a catalog transform rather than re-readingprovider.claude-code.options. The restart requirement is opencode's config loading, not the plugin API. Even the metadata win is nil here, sincesrc/models.tsis a static registry that only changes on package upgrade, which requires a restart anyway. v1 is not deprecated: all five@deprecatedmarkers indist/index.d.tsare unrelated (auth-promptcondition→when, and theAuthOuathResulttypo alias). -
Every
▌-led text part the plugin writes must be registered inPLUGIN_NOTE_MARKERS(src/message-builder.ts). The/btwaside started this, and there are now five more: the turn-stats footer (TURN_STATS_MARKER), the CLI self-compaction note, the rate-limit rejection line, the failed-result-subtype line, and the doctor report. None of them was ever Claude's output or ever in Claude's context, so a transcript rebuilt for a fresh CLI process must not hand any of them back as something the model said. The strip is part-level and anchored at the start for the same reason the aside's is: it matchestext.trimStart().startsWith(marker)on a whole text part, so every one of these has to be enqueued as its own text part (startTextBlock()before it) rather than appended to the model's block.filterSideQuestionHistoryalso now drops the/claude-code-doctoruser message and its reply, the same way it drops a/btwpair; it kept its/btw-era name because it is called from both transcript rebuild paths and renaming it would touch code two other lanes are in. Tests:test-turn-stats.ts,test-doctor.ts. -
turnStatsis off by default and must never fire on a compaction turn or a failed one (src/turn-stats.ts, consumed indoStream'sresultbranch). A footer on a/compactturn would be appended to what opencode stores as the session summary, the same trap the auto-continue compaction fix documents above; a footer on a failed turn puts the bill where the error belongs. The numbers are the turn totals (msg.usagedirectly), deliberately not the last-iteration figurestoUsageprefers:toUsagefeeds opencode's context gauge, where summing iterations inflates the window and triggers premature compaction, while a cost line has to matchtotal_cost_usd, which is cumulative. The same numbers go tolog.infowhatever the option is set to, because the footer is a display preference and the numbers are diagnostics.permission_denialsreachesproviderMetadataas names and ids only: a denial carries atool_inputon the wire that can be a whole file-write payload, which is whyClaudeStreamMessagedeliberately does not declare that field. Tests:test-turn-stats.ts,test-cli-events-stream.ts. -
The four CLI stream events in
src/cli-events.tswere read out of the CLI's own zod schemas, not guessed (2.1.263,rg -aover~/.local/share/claude/versions/<v>; the binary is a Mach-O bundle and the schemas are in it as plain text). Confirmed shapes:{type:"rate_limit_event", rate_limit_info:{status:"allowed"|"allowed_warning"|"rejected", rateLimitType?, resetsAt?, utilization?, isUsingOverage?, overageStatus?, overageResetsAt?, overageDisabledReason?}};{type:"system", subtype:"compact_boundary", compact_metadata:{trigger:"manual"|"auto", pre_tokens, post_tokens?}}(the stream schema sayscompact_metadata, the CLI's own transcript reader sayscompactMetadata, and both are parsed because both exist in the binary);system/initcarryingapiKeySource,permissionMode,model,tools[],mcp_servers[{name,status}],claude_code_version; andresultcarryingmodelUsage(per-model numeric counters) andpermission_denials. Re-run those greps before changing a parser, and keep the parsing defensive anyway: a diagnostic that throws is worse than one that stays quiet. Every reporter dedupes once per identity per process (_resetRateLimitReports/_resetSystemInitReportsare the test seams, same shape as_resetFastModeWarnings), because a rejected rate limit and a failed MCP server both repeat on every respawn. Levels follow thesrc/logger.tsrule: WARN for anything the user must act on, since only warn/error are alwaysStderr. -
apiKeySourceis the field that tells you pay-as-you-go billing is happening, andprocess.envis not.warnIfAnthropicApiKeyinindex.tssees only the env-var route; the CLI also takes a key from its own settings scopes (user,project,org) and from anapiKeyHelper, andAPI_KEY_SOURCESincli-events.tstreats everything exceptoauth(the subscription) andnoneas a key in effect. That is also why the warning text branches onignoreAnthropicApiKey: with the option already on, the env vars are stripped from the spawn, so a key still in effect did not come from the environment and recommending the option again would be wrong. -
A failed CLI tool needs
isError: trueon thetool-resultstream part, not just error text in the output. Measured in opencode's own bundle: its AI SDK bridge doesif (V.isError) enqueue({type:"tool-error", ..., error: V.result}) else enqueue({type:"tool-result", ...}), so without the flag a failedReadwas forwarded as a successful tool result whose output happened to be an error message. AI SDK v3 has notool-errorstream part for a provider to emit directly (LanguageModelV3ToolResultwithisErroris the only route), so do not go looking for one. The source isblock.is_erroron the CLI'stool_resultcontent block, which is why that field is now declared onClaudeStreamMessage.message.content[]. -
A
resultwith a failing subtype finishes as{unified:"error", raw:<subtype>}, and that is a deliberate widening oftoFinishReason's two-value vocabulary. It used to be an unconditionalstop, so opencode recordederror_max_turnsas an ordinary reply. Checked against opencode's bundle before shipping: it validates the finish reason against the standard enum and falls back to"unknown", so"error"is accepted and nothing branches on it destructively. This is the with-result case only; a CLI that dies without emitting aresultat all is a different failure with its own handling. -
/claude-code-doctoris answered by the plugin with no CLI inference (src/doctor.ts, branch indoStreamimmediately above the/btwaside branch, registered byregisterDoctorCommandinindex.ts). Four things to keep true: (1) the command name has no space in it, because opencode invokes/<key>and takes everything after the first space as$ARGUMENTS, soclaude-code doctorwould be the commandclaude-codewith an argument; (2) it never overwrites a user-defined command of that name, same guard as/btw, and unlike/btwthere is no hook to gate because the language model answers the message the template produces; (3) nothing secret may enter the report, meaning no proxyauthToken, noANTHROPIC_API_KEYvalue, no system prompt, and no pending call'sinput(a test asserts the report matches no credential-shaped string); (4) the loopback auth self-check postsinitializeonly, nevertools/call, because atools/callprobe would execute something.formatDoctorReportis pure andgatherDoctorReportis the live half, which is what lets a test pin the whole report against a fixed object. It reads providers throughlastDiagnosticsProviders()instartup-diagnostics.ts, recorded before that module's once-per-process log guard so an account expansion's second call wins. -
snapshotActiveProcessesandsnapshotPendingProxyCallsare read-only views added for the doctor. Neither touches eviction, the child'sclose/exithandler, or stdin.ActiveProcess.startedAtis set inspawnClaudeProcess's object literal purely so the report can show an age;lastStderris read through an optional property access and is never written here, so the report works whether or not another change adds that field.
-
The
testscript forcesOPENCODE_CLAUDE_CODE_LOG_FILE=0, and that is not tidiness. The maintainer's shell exportsOPENCODE_CLAUDE_CODE_LOG_FILE=1, and every test process inherits it, so each run appended fixture lines and fakeplugin readyblocks to the live~/.local/share/opencode-claude-code/plugin.log: 1,732 fixture lines by 2026-09-23, and entries such as"opencode":"2.0.11","claudeCli":{"path":"/opt/claude"}fromtest-v2-entrypoint.ts, which another session read as a broken V2 install sharing the machine. Measured with the switch: 695 of 695 pass and zero lines reach the live log. Tests that exercise file logging set it explicitly. A tally of old log lines still has to drop the/var/foldersandclaude-test-*entries written before this change. -
Never pipe
npm testintogrepinside an&&chain. The pipeline exits with grep's status, not the test runner's, so a red suite reads as green and the chain continues. This is not hypothetical: on 2026-09-19 it carried anpm version minorand a tag push through five failing tests, and v0.21.0 published before anyone knew. Redirect and check instead:npm test > /tmp/run.log 2>&1; echo "EXIT=$?", then grep the file. -
The fake-CLI recovery tests in
test-proxy-task.tsare timing-sensitive and everything they wait on is derived fromSTART_WATCHDOG_MS. The fixture is a real Node process, so its cold start competes with the machine. The old 500 ms budget (whose comment claimed it was "ample") failed every recovery test at load average 5 with dozens of node processes around, identically on master and on already-released tags, which is what proves such a failure is the machine talking and not a regression. Diagnose it that way before touching code: run the same file at the last known-green tag, and if it fails there too, the code is exonerated. Do not raise one of the three waits on its own; the longest path lets two consecutive watchdog deadlines elapse, so a hard-coded wait under twice the watchdog fails by construction. That is exactly how the first attempt at this fix broke.
-
Version 0.15.0 proxy recovery: SSE
tools/callreplies send headers immediately plus 15-second comments, while preserving the existing authentication guards and per-tool deadlines. A real Claude 2.1.258 call held for 390 seconds completed successfully; the previous single-shot response timed out before delivery. Do not claim a specific underlying timer without fresh evidence. A JSON-only client now gets the same liveness (openJsonStream, from @broskees'68ed142): headers flushed at once, chunked body, whitespace on the samePROXY_KEEPALIVE_MScadence, envelope last, so the body is still one valid JSON-RPC response on success and on error. Only broker-backed calls stream;initialize,tools/list, unknown tools, bad batches and interceptors keep the single-shotContent-Lengthreply, and nothing is flushed before the four guards ran.createProxyMcpServer's fourth argument (keepaliveMs) is a test seam.ActiveProcess.pendingProxyCompletionsretains resolved results and shared channel references until continuation settles. Both live and buffered terminal boundaries must consume abandoned completions once, and respawn must preserve the map and original CLI args. Bookkeeping-only stdout must not disarm the start watchdog. Tests:test-proxy-task.ts,test-proxy-mcp.ts,test-respawn.ts. -
Native
/btw(0.15.0):src/side-question.tsusescontrol_request.request.subtype: "side_question", with the answer atcontrol_response.response.response.response. The gate is CLI >= 2.1.258 (oldest measured), idle headless process only. Route matching replies throughdispatchSideQuestionResponsebefore ordinary stdout buffering. Never send the aside as a user envelope, spawn a different model, or promise a concurrent opencode overlay. Command registration preserves user definitions. History filtering excludes aside exchanges from fresh-process and compaction transcripts. The CLI response has no usage stats. Tests:test-side-question.ts,test-get-claude-user-message.ts.scripts/live-probe.tsis opt-in paid inference, not part ofnpm test.- The aside question must be stripped of opencode's
<system-reminder>blocks (SYSTEM_REMINDER_BLOCKinsrc/side-question.ts). opencode appends them as extra text parts on the same user message, andparseSideQuestionContentjoins every text part, so without the strip the reminder travels with the aside. Measured live on opencode 1.18.29 (2026-09-06): a 35-character question was sent as 1,599 characters, and a bare/btwwas never empty, soSIDE_QUESTION_USAGEwas unreachable and the model answered "I don't see a question in your message" instead. The plan-mode reminder is the worst case (1,523 chars of "READ-ONLY phase / STRICTLY FORBIDDEN"), which is exactly the content most likely to steer an aside. Strip wherever the block sits, not by matching a whole part or anchoring at the end: a harness may append trailing metadata after the closing tag (opencode-dcp adds<dcp-message-id>), and the first attempt at this fix usedendsWith("</system-reminder>"), passed its unit test, and still did nothing in production for exactly that reason. Only this parse strips reminders; normal turns must keep forwarding them, since they are opencode's instructions to the model. Live-verified after the fix by asking the aside its own word count: 17, matching the question alone. /btwis asked early and kept in the conversation (src/btw-command.ts, after 0.15.1). Two designs were rejected live before this one. 0.15.x left the aside in the main lane, so a/btwtyped mid-turn was "Queued" and then refused by the idle guard. The next attempt answered it in a child session with a toast, which the maintainer rejected on UX: the toast vanished before it could be read and the child session was not where anyone looked. What holds now rests on measured facts, re-check them before changing it: (1) opencode's TUI sendssession.commandimmediately, busy or not (packages/tui/src/component/prompt/index.tsx), socommand.execute.beforefires at once; the resulting user message is what gets queued. (2) opencode's loop exits only whenlastAssistant.parentID === lastUser.id(session/prompt.tsrunLoop), so any message added to a busy session,noReplyincluded, becomes the turn's next step, and that step is also the one carrying the results of the tools opencode just ran. Answering the aside there swallowed the turn's own continuation: measured live, turn 2's "finished" never appeared. (3) Claude Code answersside_questionwhile the main loop is blocked (2.1.258: 2.3 s into a 35 s held tool call). So the hook finds the process by opencode session id (findActiveProcessBySessionId, fed by theopencodeSessionID/asideTransporttags doStream writes on every non-compaction turn), sends theside_questionimmediately, remembers the promise per session (rememberSideQuestionAnswer), toasts the answer when it arrives if the session was busy, and then holds the command untilclient.session.status()reports the session idle before returning, so opencode creates the/btwmessage only after the turn is completely over and runs it as a fresh turn. That turn hits the aside branch inclaude-code-language-model.ts, which takes the remembered answer (takeSideQuestionAnswer) or asks the now idle process, and emits it as the assistant reply at 0 tokens;filterSideQuestionHistorykeeps the pair out of Claude's prompt, andcollectSideQuestionHistoryfeeds earlier pairs to follow-ups. Three traps: the remembered answer is matched by prefix, not equality, because opencode-dcp appends<dcp-message-id>to the message text (an exact match missed live and the turn re-asked into the single-flight guard); busy must come fromsession.status, not the process's line-listener count, because the listener is detached while opencode runs a tool; and holding the route is fine because opencode already keeps the command route open for a queued prompt (34 s observed) and the TUI's call is fire-and-forget. The hook only intercepts whenregisterSideQuestionCommandreturned true, so a user-definedbtwcommand keeps opencode's normal behaviour. A no-process/btwanswers withBTW_NO_SESSION_MESSAGEas text, not an error. Tests:test-btw-command.ts(hook incl. the held return and the give-up timeout, answer store, history fetch, fake-CLI end to end),test-side-question.ts.- Both lookups the hook makes are racy the instant
/btwis typed, and losing either race puts the "Queued" bubble straight back. Reported live 2026-09-06 ("if i do the /btw too soo it still gets queued") and confirmed inplugin.log:btw: no live claude process for sessionat 14:04:06, then the same question at 14:04:28 found a process and was answered concurrently. Cause: doStream tags the process (opencodeSessionID/asideTransport) only where it attaches its line listener, which is after the whole spawn path, so on a conversation's first turn there is a multi-second window with nothing to ask; the hook fell through, and the message it let past is exactly what opencode queues. The same shape applies tosession.status, where a session that opencode has not registered yet is absent from the map and therefore reads as idle, so a single early read says "not busy" and the hold is skipped. SowaitForAsideProcesspolls for the process while the session is busy (giving up afterSPAWN_WAIT_MAX_MS, 30 s, because the running turn may belong to another provider and then no process is ever coming), andsettleSessionBusykeeps re-reading status forBUSY_SETTLE_MS(1.5 s) before it will conclude idle. Two ordering rules hold this together: the settle runs concurrently with the request, never before it, or an idle/btwwould wait out the settle window before being asked at all; andanswer.catch(() => undefined)goes on immediately, because the settle spans timer ticks and a fast failure (dead process, interactive transport) would otherwise surface as an unhandled rejection in opencode's own process before the real handlers are attached. The suite caught that second one, so do not remove it as dead code. - The answer is written into the running turn's own reply, and only falls back to the toast plus a held message. The toast was the delivery while a turn ran, and the maintainer rejected it twice for the same reason ("the notification is too short and is gone right away", then "can you also add it printed to the main thread"): a toast expires, and the held
/btwpair could not land until the turn was over. SodoStreamregisters anAsideSinkper conversation (registerAsideSink(affinity, ...), unregistered incleanupTurn) that enqueues one finished text block into the live stream, anddeliverAsideInlineuses it; on success the hook throwsBtwHandledErrorso opencode never creates the/btwmessage at all, since the answer is already in the transcript. Four things this rests on: (1) the sink is keyed byaffinity, which is the opencode session id, the same keytakeSideQuestionAnsweruses. (2)registerAsideSinkreturns an unregister that only deletes its own sink, because a turn's cleanup runs after the next turn has already registered. (3) A turn is a run of streams, not one: every proxy tool call ends the stream (finishWithPendingProxyCalls) and opencode opens the next one with the result, so an answer arriving in that gap has nothing to write to.deliverAsideInlinetherefore retries forINLINE_WAIT_MAX_MS(20 s) while the session stays busy and only then falls back to the toast plus the held message, which is still the whole point of keeping that path. (4) The block is its own text part led byINLINE_ASIDE_MARKER(▌ **btw:**), which is what letsfilterSideQuestionHistorystrip it exactly when a transcript is rebuilt: an aside was never Claude's output and was never in its context. Do not merge it into the model's own text block, and do not match the marker mid-part; the strip is part-level for a reason. Live-verified 2026-09-06 on Claude 2.1.258 + opencode 1.18.29:/btwtyped 15 s into a 35 s webfetch, block written 1.4 s later inside that turn's assistant message, no/btwmessage in the transcript, turn still delivered its own "finished". Note the command route answers HTTP 500 on the drop, as it does for everyBtwHandledError; the TUI'ssession.commandcall is fire-and-forget and swallows it. Tests:test-btw-command.ts(sink ownership, marker strip, and a fake-CLI turn held open by theSLOWkeyword that the aside is written into).- The aside's left bar is a literal
▌the plugin emits, NOT a markdown blockquote. Asked for "a subtle green border around the full response", and the blockquote answer shipped first and was wrong; it was replaced after the maintainer reported "the theme is picked but nothing green shows up", which is exactly what the source predicts. Read@opentui/core'ssrc/renderables/Markdown.tsbefore touching this (npm pack @opentui/core, the sourcemap carries the TS): a blockquote does get a real left border (createBlockquoteRenderable→BoxRenderablewithborder: ["left"],paddingLeft: 1), butgetBlockquoteBorderColor()reads theconcealscope, falling back todefault, whiletheme.markdownBlockQuote/markup.quotecolours only the quoted text. So the one key a theme could plausibly change is the one that does not paint the bar, there is no per-block override, and a custom theme is the operator's config anyway (opencode resolvestheme.themeas-is, with no merge over a base, so it means copying a whole theme).barEveryLinetherefore prefixes every line, blank ones as a bare▌, so the bar runs the full height.- The bar is not coloured, and the search for a green one is closed. Correction to an earlier note here: assistant text is not run through
strip-ansi. That call sites atpackages/tui/src/routes/session/index.tsx:2051,2349apply to tool output;TextPartpasses the text straight into<markdown>. ANSI is still useless, for a better reason: OpenTUI renders markdown through tree-sitter into its own buffer with its own colours, so escape bytes print literally and corrupt width measurement. Colour in that renderer comes only from syntax scopes, and there is no scope for a plain character in a paragraph:`x`ismarkup.raw,**x**ismarkup.strong, a blockquote border isconceal(Markdown.tsrenderInlineToken). Every one of those is theme-wide, so painting the bar green would repaint all inline code, or all bold, or all blockquotes, across every message, and needs a whole copied theme to do it. The only zero-config green is a colour emoji, which the maintainer refused outright ("no emoji please") and which contradicts the original ask for something subtle. So the bar stays a plain▌. Do not re-open this without a new opencode rendering feature. Two facts hold the rendering together, both from the same file: OpenTUI renders a paragraph fromtoken.rawverbatim, so line breaks survive and nothing reflows; and blockquote content goes throughcreateMarkdownCodeRenderable(token.text, …)rather than being re-parsed, so the old shape never rendered nested markdown either and dropping it costs nothing.INLINE_ASIDE_MARKERmust stay the leading characters aftertrimStart()orfilterSideQuestionHistorystops stripping the block;LEGACY_INLINE_ASIDE_MARKERSkeeps the old> **btw:**blocks strippable in conversations that predate the change, and a test covers it.
- The bar is not coloured, and the search for a green one is closed. Correction to an earlier note here: assistant text is not run through
- No toast ever carries the answer ("so we can get rid of the notification now?", once the inline block worked). Every path that produces an answer now puts it in the conversation, inline or as the held pair, so announcing it as well was duplicate delivery of the worse copy: a toast expires, which is the complaint that started this whole redesign. Removed with it:
answerToastMessage/answerToastDurationand theANSWER_TOAST_*sizing constants,BTW_BUSY_TOAST_MESSAGE(the "answering alongside the turn" notice, which the inline block obsoletes) andBTW_IN_FLIGHT_TOAST_MESSAGE(a second/btwstill gets asked when the turn ends, so its answer lands too). The two that stay are exactly the paths where nothing reaches the conversation because the message is dropped: a bare/btw(SIDE_QUESTION_USAGE) andBTW_TURN_TOO_LONG_MESSAGEafter the 30 minute hold gives up. That is the rule to apply to any new toast here: if the conversation gets the content, do not also toast it.showToastitself stays, and so does thetui.showToastreceiver-binding care in it (a detachedconst show = client.tui.showToastthrows, since the SDK method readsthis._client). - A receipt block goes into the turn the moment the question is sent ("there should be some feedback of them actually having sent it also in the main", once the toast was gone).
formatInlineAsideAsk(question)writes the question on the marker line with the note on its own bar line beneath it, through the samedeliverAsideInline, and the answer handler awaits that promise before writing the answer, so a receipt can never land under the answer it announces. The receipt quotes the question in full and the answer block repeats it, and that duplication is deliberate. It went question-less first, on the reasoning that the answer block carries the question anyway; the maintainer asked for it back ("maybe we should see: ▌ btw: <the text you actually sent here> sent to Claude on the side"), and the ask is right: the prompt box clears on submit and the/btwmessage is dropped, so with no question in the receipt nothing on screen ever says what was sent. A 240-characterRECEIPT_QUESTION_MAXwas added and then removed for the same reason ("i want the question to hold the full untruncated question"): once the receipt is the only readback, eliding it means a long aside can be read nowhere, and it also produced the odd shape the maintainer spotted, a short copy in the receipt above a full copy in the answer. The reason the answer block must keep its own copy is measured, not stylistic: the model keeps streaming its own text between the two (receipt in the assistant message before the tool part, answer in the one after, 13 s later), so a headerless answer arriving after that reads as orphaned. Keeping the answer block's existing shape and marker is also what means nothing new has to be stripped: a continuation marker ("▌\n") was written and then deleted for exactly that reason.- Updating the receipt in place when the answer lands is not available, do not try again without new evidence. Asked for directly ("maybe dont want a new block when answer comes in instead update the original"). Two blockers, both checked rather than assumed on 2026-09-06: opencode's SDK exposes no part or message update route (
/session/{id}/message/{messageID}is read-only, and the full route list has nothing else), and the AI SDK stream has no replace-text event, so the only way to grow a block is to keep its text part open and append deltas to the same id. That is ruled out by stream lifetime: a turn is a run of streams, every proxy tool call ends one, and the receipt lands in the stream before the tool part while the answer arrives in the one after, so the part is already closed and drained. What is true, and is the part worth keeping if this ever becomes possible: opencode's bridge resolves ids explicitly (currentTextID(state, event.id)insession/llm/ai-sdk.ts) and every delta this plugin emits carries an explicit id, so two concurrently open text parts are protocol-legal. The blocker is the stream boundary, not the id model.startTextBlock()here is single-slot and would also have to stop closing the aside's part. Only sent whilebusy, since an idle/btwgets its own message a moment later anyway. Live-verified 2026-09-06 on haiku: receipt 0.3 s after the command, answer 13 s later,/btwmessage dropped, turn still delivered its own reply.
- Updating the receipt in place when the answer lands is not available, do not try again without new evidence. Asked for directly ("maybe dont want a new block when answer comes in instead update the original"). Two blockers, both checked rather than assumed on 2026-09-06: opencode's SDK exposes no part or message update route (
- Probing this live needs a turn that is genuinely still running, which took three wasted paid runs to get right.
POST /session/:id/message?async=truestill blocks until the turn finishes on opencode 1.18.29, so a/btwfired after it "returns" is measured against an idle session and silently exercises the wrong path (busy:falsein the log is the tell); background the curl instead. opencode'swebfetchalso times out well before 30 s, so a stall server has to sleep under that (12 s works) or the tool errors and the turn ends early. And the provider ids areclaude-code-default/claude-code-appical, never a bareclaude-code, which fails as an opaqueUnknownErrorfrom the message route.
- The aside's left bar is a literal
- Both lookups the hook makes are racy the instant
- The aside question must be stripped of opencode's
-
A
tool_resultmay only be sent back for an id THIS CLI process issued (cliToolCallIdsongetClaudeUserMessage, issue #29 from @nic-lan). opencode runs some tools on its own behalf, notably thetaskcall asubtask: truecommand dispatches, and the resumed CLI session never emitted thosetool_useblocks. Sending atool_resultfor one is orphaned: Claude cannot resolve the id, so the payload, which is right there in the envelope, is unreachable. Reported as "the result is lost"; measured offline on master, the 613-character subagent answer was physically present as{"type":"tool_result","tool_use_id":"call_X",...}and simply unusable. Unmatched ids now render as<opencode_tool_result tool="...">…</opencode_tool_result>text before the trailing user message, which is what makes opencode's own synthetic "Summarize the task tool output above" instruction true. Two things hold this together and both are load-bearing: (1) the gate cannot break the proxy round-trip, because the envelope is not how proxy results are delivered.proc.stdin.write(userMsg)is the fresh-turn path only; when a pending proxy call has a matching tool-result, doStream returns before that write (hasMatchedPendingResults) and the broker resolves the call directly, and on the write path any still-pending call is being rejected as orphaned a few lines above. So a tool-result reaching a written envelope is an opencode-side one by construction. (2)cliToolCallIdsis read fromgetPendingProxyCalls(sk)beforeuserMsgis built, in bothdoStreamanddoGenerate, because there is anawaitin between;doGeneratepasses an empty set, since it has no proxy wiring and therefore issued no calls at all. Omitting the option keeps the old unconditional block, so a forgotten call site degrades to the status quo rather than hanging the CLI. Live-verified 2026-09-06 on Claude Code 2.1.258 + opencode 1.18.29 through a headlessopencode serve: a project command withsubtask: true(.opencode/command/<name>.md,agent: general) whose subagent answers with a secret token and no tools; the parent's "Summarize the task tool output above" turn quoted the token back, andplugin.logshowedrendering opencode-side tool result as textfor the real opencode call id (tool: task, 154 chars) at the moment of the command. That log line is the fingerprint to look for if this ever regresses; the unit tests alone had already passed for two fixes that did nothing in production that day. -
The fresh-session history fallback must render tool content, not count it. Second half of issue #29, and worse than reported:
compactConversationHistory'sfresh-sessionmode filtered touser/assistant, so atool-role message was dropped entirely and even[Received N tool result(s)]never appeared. All that survived a subagent was[Called 1 tool(s): task]. It now includestoolroles and usesrenderMessageContentForCompaction, the same serializer/compactalready used, so[tool_use:name(input)]and[tool_result:name]plus the clipped body survive. Do not "simplify" this back to placeholders; the whole point is that this path is what a fresh CLI process gets when the prior session id is gone. -
opencode 2 entrypoint (
planV2Providers,configuredSeedSettings,resolveSdkSettings,isOpencodeV2Context,createV2Setupagainst a fake V2 context incl. commands, event cleanup and the bundled skill, the title stub on a V2 model):test-v2-entrypoint.ts. The V1-shaped client shim:test-v2-client.ts. The V2 tool vocabulary (translateToolForHost, the per-stream part translator):test-host-tools.ts. None of these replace the two live probes in V2.md, one per major. -
Prompt/message conversion or compaction transcript behavior:
test-get-claude-user-message.ts. Also owns issue #29: the orphaned-tool_use_idgate (both branches, allowed and degraded) and the fresh-session history keeping tool inputs and result bodies. Each of those three tests fails with the corresponding fix reverted. -
Claude CLI arg construction / version-gated flags:
test-cli-args.ts. Also owns fast mode:parseModelId,cliSupportsFastMode, the--settingsopt-in, andreportFastModeState's log levels. -
Tool name/input mapping (
mapTool,CLAUDE_INTERNAL_TOOLS):test-tool-mapping.ts. -
Content-block index reuse across assistant messages within one turn (stale
toolCallMapentry re-emitting a completed tool call, which breaks subagenttaskresults):test-tool-block-index.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 + bashinput.timeout+ task-timeout wake-up note). -
MCP tool discovery for
proxyOpencodeMcpTools(resolveMcpProxyToolDefs: the model tool set as the source, the registry-shaped list resolving to nothing, longest-server-prefix,coveredServersnot stranding an unmatched server, name collisions):test-proxy-mcp.ts. Five of those seven fail if discovery is stubbed back to finding nothing; the two that still pass are the two asserting absence. -
The consequence of that discovery, at the argv a real spawn receives (a partially covered server set still bridges the uncovered server; a fully covered one passes only the proxy config):
test-proxy-mcp.ts. It is one test rather than two because the helper swapsXDG_CONFIG_HOME/HOMEfor the duration of a spawn, and it uses a distinct server set per scenario because the bridged file is content-addressed. It fails if the bridged filename goes back to being keyed on the config hash. -
Reused-process respawn (
appendResumeIfNeeded,respawnActiveProcessundefined-branch and in-flight handoff):test-respawn.ts; the recovered continuation being marked in flight, through a real turn:test-proxy-task.ts(lateandswallowmodes). -
Process lifetime as opencode sees it (
session.deletedthrough the plugin'seventhook,extractDeletedSessionId, the idle timer armed by a real turn with no option set) and what ends a proxied call (next user message, abort, child exit mid-turn and between turns, with a fake CLI that parks inside ataskcall and records what its HTTP request got):test-process-lifecycle.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, session-directory tier):test-cwd-resolution.ts. -
Turn lifecycle and abort interrupt (
noteTurnStarted,noteTurnLine,interruptTurn), idle eviction (scheduleIdleProcessEviction, the 30-minute default, the in-flight re-arm), the 8-process cap,deleteActiveProcessesForSession,killAllActiveProcesses,ensureProcessExitCleanup, broker rejection on detach:test-session-manager.ts. -
Skill bridge (
discoverOpencodeSkills,buildSkillPluginDir,resolveSkillPluginDirs,--plugin-dirinbuildCliArgs, the default-oncreateClaudeCodewiring, and the argv of a real spawned fake CLI ondoStreamanddoGenerate):test-skill-bridge.ts; the interactive--plugin-dir(interactiveExtraArgs):test-claude-session-wrapper.ts. -
AGENTS.mddedup against the forwarded system prompt:test-compaction-model.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. -
Account failover (limit detection incl. its negative cases, the account-scoped override and its expiry clamp,
resolveFailoverSpawnfor a default vs a named target, the form, every answer classification, the transcript strip and the continuation prompt, plus a fake CLI driving a realdoStreamthrough ask / switch / stop):test-account-failover.ts. -
Compress tool (proxy interceptor path, compression store, compress vs default runtime note), plus
resolveProxyOpencodeToolDefsand both layers of thecompressname collision:test-compress-tool.ts. -
dcp reminder stripping (
stripContextReminderBlocks,stripContextReminders,shouldStripContextReminders, and that it is off by default):test-get-claude-user-message.ts. -
Config-path model metadata injection (
configModelsForProvider):test-config-models.ts. -
Interactive transport (
decodeUserEnvelope,spawnInteractiveProcessshim shape):test-claude-session-wrapper.ts. -
Spawn-env API-key stripping (
claudeSpawnEnvwith/withoutignoreAnthropicApiKey):test-spawn-env.ts. -
Startup diagnostics (
collectStartupDiagnostics,describeSpawnCwd,detectOpencodeVersion,claudeCodeProviders):test-startup-diagnostics.ts. -
Per-turn cost/cache stats (
extractTurnStats,formatTurnStatsLine, theturnStatsdefault, the transcript strip):test-turn-stats.ts. -
CLI stream-event parsers and their once-per-process dedup (
parseRateLimitEvent,describeRateLimit,parseSystemInit,apiKeySourceWarning,parseCompactBoundary,describeResultFailure):test-cli-events.ts. -
The same events as opencode sees them, through a fake CLI and a real
doStream(failedtool_resultcarryingisError, failing result subtype finishing as an error, footer gated onturnStats, rate-limit and compaction notes):test-cli-events-stream.ts. -
The wire-inactivity watchdog's visible note (
formatStreamTimeoutNote,CLAUDE_CODE_RESULT_FALLBACK_MS, its own text part, the transcript strip), through a fake CLI that produces output and then never sends aresult:test-result-fallback.ts. -
/claude-code-doctor(report formatter against a fixed report, command-registration guard,checkProxyAuth, transcript strip,describeSessionKey):test-doctor.ts.
Current state (refreshed 2026-07-26 after the fork/PR sweep):
- ✅ Per-tool proxy timeouts — absorbed from @jknlsn's fork (
84f3db9, authorship preserved) in v0.10.0:proxyToolTimeoutMsconfig, per-tool defaults (task60 min then; no deadline since the fork-parity PR, see the deadline gotcha), bashinput.timeoutfloor. 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. - ✅ 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).proxyToolsconfig remains the escape hatch; subagents needpermission.task. - ✅ Startup diagnostics / doctor log — landed as
src/startup-diagnostics.ts(claude-code plugin readyNOTICE, see the gotcha above). - ✅ Subagent todo docs + config example — README "Subagent todos" section: worked
multistepagent block withpermission.todowrite: allow, why it is load-bearing,session.child.nextnavigation, and the sqlite queries that prove the todos landed. - ✅ Retired 2026-09-06 with issue #4, closed as resolved-pending-feedback (no retest reported in the 2.5 weeks after the ping). The tier-two fix, a per-request/current-project query instead of
process.cwd(), was never built and should not be unless #4 is reopened with evidence. The startup-diagnosticscwdbranch is the fingerprint to ask for:capturedmeans this bug,process/configuredmeans it resolved normally. - ✅ ExitPlanMode approval bridge, absorbed from @CollieIsCute's
8c5b583(authorship preserved) behind the opt-inplanModeQuestionflag (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'squestionform) is still broken upstream, so the live approval round-trip is unverified and the flag stays off. Re-test when #36603 merges.
Update 2026-09-23: no open issues. #31 was closed as completed once opencode 2 support shipped in v0.26.0 (#44); its "not planned" verdict rested on reading 1.x's v2-compat API rather than opencode 2 itself, and its closing comment records why. The remaining V2 live checks live in TODO.md, not in an issue. What follows is the 2026-09-19 state, kept for its evidence. Open work, re-checked 2026-09-19: only #31, the v2 plugin API migration tracker, and it is explicitly not planned (see the v2 gotcha above for the evidence and the checklist of what would change the answer). #24 is closed: its long-context-cost-tiers item was not-applicable, and tool.definition plus both compaction hooks were evaluated on 1.18.29 and skipped, shipped in v0.18.3 via PR #30. #24 had been carrying the v2-migration tracker role, which is why #31 exists; do not reopen #24 for it. #29 (@nic-lan, subtask/task tool results lost across the CLI resume boundary) is closed: fixed in dc3368c, live-verified, shipped as v0.15.4 on 2026-09-06 (see the cliToolCallIds gotcha above). Nothing else is open, and there are no open PRs. #22 (Sonnet 5 standard-pricing bump) landed on its 2026-09-01 date. #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, so 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-09-19, all 20 forks, every branch, by patch-id; gh api repos/<owner>/<repo>/forks for the list, then one remote per fork and git cherry origin/master <branch>): three forks had pushed since the previous sweep and both open PRs were theirs. Merged: @nic-lan's PR #35 (fix/replay-single-text-block, squash 4aad4c7), unattended stdout replayed as one text block instead of one per delta, with a fake-CLI regression test that fails without the fix; @broskees' PR #36 (feature/fork-reliability-parity, merge commit 9866f02 keeping his ff2edf0), every terminal event releasing a proxied call on both the broker and the HTTP side, session.deleted and host-exit cleanup, respawn keeping turnInFlight, idle timer re-arming on a busy process, JSON-only keepalive, skill bridge on doGenerate and interactive, and no default deadline for task/task_batch, which was declined in the previous sweep (dd494a8) and accepted now because the lifecycle release is what makes a wall clock redundant. Of his four proposed default changes only that one stayed; dfb82d5 restored bridgeOpencodeSkills: false, idle eviction off unless set, and the 16-process cap, and the PR comment says why. HeikoAtGitHub/master and broskees/master carried nothing new beyond that PR (Heiko's 8 remain the submit_plan/workstream product work declined below; broskees' b796c71 Fable 5.1 and Sonnet 5 pricing had already landed here independently). Every other fork's remaining git cherry output is either a stale copy of an origin branch (disable-thinking, feature/claude-code-accounts, sonnet-5-standard-pricing) or was resolved in an earlier sweep.
Previous sweep (2026-09-06, all 19 forks, every branch, by patch-id): absorbed this round, authorship preserved, credited in the README Credits table: @galvani 9e02ce4 (serve-mode cwd), @HeikoAtGitHub 25260a4 (AGENTS.md dedup), @bernardofortes a5f723a (idle timeout), and from @broskees' 68ed142 the abort interrupt, the skill bridge, and (after the premise was re-measured live) task_batch (three commits under his authorship, adapted). Deliberately not taken: @HeikoAtGitHub's other 13 commits (submit_plan for Plannotator, a private "workstream" contract system, repo_policy_scope: fork-specific product work); @broskees' ae48773 (commits dist/, against policy), his one-turn guard (in via interrupt) and his parallel idle sweep as such (its 30-minute figure and 8-process cap were proposed again as defaults in his fork-parity PR #36 and reverted at merge, see the 2026-09-19 sweep below; his immediate client-disconnect cancellation was not taken either, see the deadline gotcha); @galvani's 7b7841f (drops --thinking-display summarized, which we set on purpose; its other two fixes were already here). Earlier 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.toImageBlockacceptedtype: "image"parts but never readpart.image, where v4 puts the binary, so pasted screenshots were dropped with a "file part without data" warning. Two regression tests intest-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 as of 2026-09-19 (after the PR #35 and #36 merges): nothing open has a user-visible payoff. The one follow-up those merges create is the proxy-call stall warning: with no task deadline a wedged subagent is silent until something releases it, so a periodic WARN naming tool and call id is the next reliability item, tracked in the maintainer's Future Features note. #29 and #24 both shipped. The only open issue is #31, the v2 migration tracker, which is deliberately parked; pick it up only when one of its checklist triggers fires, not because an opencode bump happened. Note that PR #15's narrow half did eventually land (truncation-continue, v0.18.2), and that it introduced the compaction regression fixed in v0.18.3, which is the argument for a compaction case in any future auto-continue change. The PRs that used to need a decision are all resolved: #25 (@CNQQC, cost units off by 1e6) merged, #23 (own draft) and #15 (@JWebCoder, auto-continue stopReason short-circuit) closed, the latter for the reason in the auto-continue gotcha above.
Both deferred items were approved and are done. What they are waiting on now:
- anomalyco/opencode#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
completedquestion 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-boundarycompletedis our own headlessPOST /question/{id}/replytest, which is what isolates the fault to the TUI render step. Re-test thequestionproxy andplanModeQuestionwhen this moves — both stay off until then. - Issue #4 — @jessielaf pinged for a retest, with the startup-diagnostics
cwdbranch (capturedis 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.