OpenCode with prompt-loop byte identity + cache-aligned compaction + Gemini empty parts fix + vim keybindings + tool use/result fix + MCP auto-reconnect + instance state partition + cache thinking-skip + per-step retry cap + jitter
This repository layers a small set of local patches onto upstream OpenCode and builds a single binary automatically for 4 platforms.
Prompt caching is now upstream. The big
caching.patch(formerly fetched at build time from opencode-cached, PR #5422) was dropped on 2026-06-02. UpstreamapplyCachingalready anchors the conversation cache breakpoint on the moving tail (non-system.slice(-2)), so the fork patch was redundant — and the fork's own unmerged variant had actually introduced an anchor regression. The only behavior upstream lacks (not marking a trailing reasoning/thinking block as the cache breakpoint) is preserved as the small localcache-thinking-skip.patch, section 8 below.opencode-cachedhas been archived. Seedocs/plans/2026-06-02-paring-back-opencode-cached-caching.mdin the workstation repo for the analysis.
1. Prompt-Loop Byte Identity (PR #25367)
Stored locally as patches/prompt-loop-cache.patch. Caches the conversation array across prompt-loop iterations so tool-call continuations preserve byte identity. This targets the flat-cache-read + growing-uncached-input pattern where repeated tool-loop calls keep paying full input price for the same growing tail.
Captured from PR head 810aaffd44472f6e6d1accff53048f9e2009e41c.
2. Cache-Aligned Compaction (PR #25100)
Stored locally as patches/cache-aligned-compaction.patch. Rationale: compaction request construction was provider-independent uncached work; aligning compaction with normal prompt-loop context lets future compactions reuse prefix cache where provider/model conditions allow. Applied after prompt-loop-cache.patch and before existing vim/tool/MCP patches.
Captured from PR #25100 head 972380a75249b01a424010e8bc0453e15a3a14c2.
3. Gemini Empty Parts Fix (PR #28669, Issue #17519)
Stored locally as patches/gemini-empty-parts.patch. Vertex/Gemini rejects any
contents[] entry with parts: [] with Unable to submit request because it must include at least one parts field. This patch fixes that on two independent code
paths:
-
Native runtime (
packages/llm/src/protocols/gemini.ts): pads completely empty Gemini user, assistant, or tool messages with an empty-string text part before serialization. This is the PR #28669 fix, extended to cover an empty tool-message regression. NOTE: the native runtime gate (session/llm/native-runtime.ts) only admitsopenai/anthropic/opencode*providers, so Gemini never actually reaches this path today — this hunk tracks PR #28669 and is future-proofing if upstream ever routes Gemini natively. -
AI SDK runtime (
packages/opencode/src/provider/transform.ts): this is the path Gemini actually uses (google-vertex/googlevia@ai-sdk/google).ProviderTransform.message()→normalizeMessages()now drops empty text/reasoning parts and any resulting empty message for@ai-sdk/google/@ai-sdk/google-vertex, exactly like the pre-existing@ai-sdk/anthropicand@ai-sdk/amazon-bedrockblocks. The@ai-sdk/googleconverter drops empty-text parts itself (part.text.length === 0 ? undefined : ...), so a turn whose only content is an empty part — e.g. the empty "structural separator" text part that Anthropic adaptive-thinking turns persist betweenstep-startboundaries, replayed cross-model into a Gemini compaction request — serializes toparts: []and 400s the whole request. This is the intermittent compaction failure tracked in Issue #17519; the originalpackages/llmhunk alone never fixed it because Gemini doesn't use that path.
4. Vim Keybindings (PR #12679)
Stored locally as patches/vim.patch. Adds optional vim motions to the prompt input. Disabled by default -- enable with tui.vim: true or toggle from the command palette.
Supported motions:
- Mode switching:
i I a A o O S,cc,cw,Esc - Motions:
h j k l,w b e,W B E,0 ^ $ - Deletes:
x,dd,dw - Session navigation:
gg/G - Scrolling:
Ctrl+e/y/d/u/f/b Enterin normal mode submits
5. Tool Use/Result Mismatch Fix (PR #16751)
Stored locally as patches/tool-fix.patch. Fixes the widespread tool_use ids were found without tool_result blocks error (#16749) that corrupts sessions when stream errors cause lost step boundaries. Injects synthetic step-start boundaries at message reconstruction time to prevent interleaved tool_use/text in assistant messages that the Anthropic API rejects.
6. MCP Auto-Reconnect (Issue #15247)
Stored locally as patches/mcp-reconnect.patch. Automatically reconnects remote MCP servers when the server restarts and the session becomes stale. Without this patch, callTool fails at the transport layer with "Session not found" / HTTP 404 errors, requiring a manual MCP toggle (ctrl+p) or full OpenCode restart.
The patch wraps remote MCP tool execution with a try/catch that detects transport-level errors (stale sessions, connection refused, etc.), closes the stale client, creates a fresh transport + client, refreshes tool definitions, and retries the call once.
Stored locally as patches/instance-state-partition.patch. Decouples InstanceBootstrap and Project from InstanceLayer to allow test overrides, and ensures that the app-runtime, worktrees, and HTTP server share the same memoized layer dependencies (e.g., memoMap). This prevents state partitioning and sync communication failures across concurrent instance runners.
8. Cache Thinking-Skip (Issue #17883)
Stored locally as patches/cache-thinking-skip.patch. This is the only
caching-related patch that survived the 2026-06-02 drop of the big caching.patch
(see the note at the top of this section). Upstream applyCaching marks the
conversation cache breakpoint on msg.content[msg.content.length - 1] — blindly the
last content block. When the last block is a reasoning/redacted-reasoning
(thinking) block, Anthropic rejects the request with HTTP 400 because cache_control
isn't allowed on thinking blocks. This bites whenever adaptive reasoning is on (Opus
4.7+/4.8). The patch makes the breakpoint scan backwards to the last cacheable
content block instead, skipping trailing reasoning and tool-approval pseudo-blocks.
It's a ~15-line change to applyCaching in provider/transform.ts. Tracked upstream
as Issue #17883; when upstream
fixes it, this patch can be dropped.
Stored locally as patches/retry-cap.patch. OpenCode core's per-step LLM retry
policy (packages/opencode/src/session/retry.ts policy()) had no attempt
ceiling: Schedule.fromStepWithMetadata terminated only when retryable()
stopped classifying the error as retryable. A step hitting a persistently
retryable condition (mid-stream overloaded/exhausted under HTTP 200,
ECONNRESET/header-timeout, or a post-stream finish-step throw) re-issued the
entire model stream — a fresh, billable provider request — every ≤30s
forever. Because OpenCode records one assistant message (DB "step-start") per
step but re-calls the model on every retry, one step mapped to dozens-to-thousands
of successful Vertex calls. This was the root cause of the 2026-06 gemini-3.5-flash
cost surge (~35× amplification of successful calls; ~$1.5–2.5k wasted in one
overnight window). Full RCA: workstation
docs/investigations/2026-06-05-vertex-gemini-surge/lgtm-retry-rootcause.md.
The patch makes two changes to session/retry.ts:
- Attempt cap:
policy()now stops afterMAX_RETRIES = 8retries (if (!retry || meta.attempt > MAX_RETRIES) return Cause.done(...)), turning an unbounded 30s-interval loop into a bounded burst of at most 1 initial + 8 = 9 stream issues before the error propagates. - Backoff jitter:
delay()applies downward jitter (RETRY_JITTER_RATIO = 0.2) to the no-header exponential backoff so concurrent stuck sessions don't synchronize their 30s re-issues (thundering herd against one quota). Downward-only keeps the 30s ceiling a true upper bound; explicitretry-after/retry-after-mshints are honored exactly and never jittered.
Coverage in test/session/retry.test.ts: the cap is proven both by driving the
schedule directly (Schedule.toStepWithMetadata halts after MAX_RETRIES) and via
Effect.retry (a persistently-failing effect runs exactly MAX_RETRIES + 1 times);
the delay test asserts the jittered band instead of exact values. Sunset signal:
upstream adding an attempt ceiling to the retry schedule.
- eager-input-streaming.patch (Issue #23541) — DROPPED on 2026-06-05 during the v1.16.2 rebase. v1.16.0+
ProviderTransform.options()now setstoolStreaming = falseupstream for@ai-sdk/google-vertex/anthropicand non-claude@ai-sdk/anthropic, which covers our usage; the local patch became redundant. - caching.patch (PR #5422, via opencode-cached) — DROPPED on 2026-06-02. Upstream
applyCachingalready implements the moving-tail conversation anchor (non-system.slice(-2)) that was the ~$500/day win, so the1100-line fork patch was redundant; the fork's own unmerged variant (PR #5422) had even introduced a stuck-anchor regression. A/B testing confirmed upstream matches the fork on Vertex (0% uncached input, low cache-write, no tool-prefix busting), so$27/day) on a workload that is 92–96% sub-5-minute turns. The single behavior worth keeping — skipping reasoning/thinking blocks at the cache breakpoint — survives assortTools+ the dedicated tool breakpoint were not load-bearing for our toolset, and the 1h-TTL tiering was marginal (cache-thinking-skip.patch(section 8). The sibling repoopencode-cachedwas archived. Full analysis:docs/plans/2026-06-02-paring-back-opencode-cached-caching.md(workstation repo). - bus-eager-subscribe.patch (PR #27959) — DROPPED on 2026-05-25 when this repo cut over from v1.15.0 to v1.15.10. PR #27959 (
fix(bus): acquire PubSub subscription eagerly) was merged upstream on 2026-05-18 and shipped in v1.15.5. Any opencode release >= v1.15.5 contains the fix natively. - Bus instance context fix (PR #28051) — never had its own patch in this repo, but the closely-related bug it fixes (sync events publishing on the wrong bus runtime, partitioning
message.updatedfromsession.idleacross plugin instances) was the root cause of dropped Telegram stop notifications throughout April/May 2026. The load-bearing fix is actually PR #27825 (fix(sync): publish events on injected project bus), with #27757, #28051, and #28187 as supporting changes. ALL of these are in v1.15.5+. Seedocs/plans/2026-05-22-bus-fix-investigation-HANDOFF.mdanddocs/plans/2026-05-25-28051-verification-report.mdin the pigeon repo for the full investigation chain. - prefill-fix.patch — DROPPED on 2026-05-28 when this repo cut over to v1.15.12. v1.15.12's release notes line "Used the persisted session directory for existing-session requests" corresponds to an upstream implementation of the same fix in
packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts:planRequest'sLocalplan construction now doesdirectory: session?.directory || defaultDirectory(request, url), which closes the multi-cwd race our patch did by routing session-bound requests to the session's canonical directory. Upstream's implementation collapses the choice into the existingdirectoryfield rather than threading our richersessionDirectoryvalue throughRequestPlan.Local+WorkspaceRouteContext+InstanceContextMiddleware, but the net behavior matches. Seedocs/plans/2026-05-15-prefill-fix-redesign-{plan,question,answer}.mdfor the original v1.15.0 redesign anddocs/plans/2026-04-21-opencode-prefill-fix-design.md(workstation repo) for the root-cause analysis.
curl -sL https://github.com/johnnymo87/opencode-patched/releases/latest/download/opencode-linux-arm64.tar.gz | tar xz
sudo mv bin/opencode /usr/local/bin/
opencode --versioncurl -sL https://github.com/johnnymo87/opencode-patched/releases/latest/download/opencode-linux-x64.tar.gz | tar xz
sudo mv bin/opencode /usr/local/bin/
opencode --versioncurl -sL https://github.com/johnnymo87/opencode-patched/releases/latest/download/opencode-darwin-arm64.zip -o opencode.zip
unzip opencode.zip
sudo mv bin/opencode /usr/local/bin/
opencode --versioncurl -sL https://github.com/johnnymo87/opencode-patched/releases/latest/download/opencode-darwin-x64.zip -o opencode.zip
unzip opencode.zip
sudo mv bin/opencode /usr/local/bin/
opencode --versionSee the workstation repo for Nix integration example.
Timing Chain (every 8 hours):
(upstream anomalyco/opencode publishes a release)
:01 opencode-patched/sync-upstream -- detects new upstream release directly
|-> builds v{VER}-patched -- applies prompt-loop-cache + cache-aligned-compaction + gemini-empty-parts + vim + tool fix + mcp reconnect + instance-state-partition + cache-thinking-skip + retry-cap patches, publishes
:01 opencode-patched/sync-vim-pr -- checks PR #12679 for changes
:01 opencode-patched/sync-tool-fix-pr -- checks PR #16751 for changes
:02 workstation/update-opencode-patched -- updates Nix config, opens PR
Until 2026-06-02 this chain had an extra upstream hop: opencode-cached/sync-upstream
built a v{VER}-cached release (applying the big caching.patch), and
opencode-patched/sync-cached watched that. With caching.patch dropped and
opencode-cached archived, sync-cached.yml was replaced by sync-upstream.yml,
which watches anomalyco/opencode releases directly.
- Clone upstream OpenCode at the release tag
- Apply the local patches in order:
prompt-loop-cache.patch,cache-aligned-compaction.patch,gemini-empty-parts.patch,vim.patch,tool-fix.patch,mcp-reconnect.patch,instance-state-partition.patch,cache-thinking-skip.patch,retry-cap.patch(seepatches/apply.sh) - Build with Bun for 4 platforms (linux/darwin x arm64/x64)
- Publish release as
v{VERSION}-patched
The patches modify mostly different areas of the codebase:
- Prompt-loop cache:
app/vite.js,session/prompt.ts - Cache-aligned compaction:
session/prompt.ts - Gemini empty parts:
packages/llm/src/protocols/gemini.ts,packages/llm/test/provider/gemini.test.ts,packages/opencode/src/provider/transform.ts,packages/opencode/test/provider/transform.test.ts - Vim:
cli/cmd/tui/component/vim/*,cli/cmd/tui/component/prompt/index.tsx,cli/cmd/tui/app.tsx,cli/cmd/tui/config/tui-schema.ts - Tool fix:
session/message-v2.ts,test/session/message-v2.test.ts - MCP reconnect:
mcp/index.ts - Instance state partition:
effect/app-runtime.ts,project/instance-layer.ts,server/routes/instance/httpapi/server.ts,server/server.ts,worktree/index.ts(and corresponding tests) - Cache thinking-skip:
provider/transform.ts(theapplyCachingbreakpoint loop) - Per-step retry cap + jitter:
session/retry.ts,test/session/retry.test.ts
The file touched by more than one patch is provider/transform.ts (gemini-empty-parts in normalizeMessages(), cache-thinking-skip in applyCaching()) and session/prompt.ts (prompt-loop-cache + cache-aligned-compaction). The overlapping patches modify disjoint regions and apply cleanly in the documented order.
Each patch is owned by a specific repo. Do not edit a patch in the wrong repo.
| Patch | Owned by | Upstream PR guide |
|---|---|---|
prompt-loop-cache.patch |
this repo (patches/prompt-loop-cache.patch) |
PR #25367 |
cache-aligned-compaction.patch |
this repo (patches/cache-aligned-compaction.patch) |
PR #25100 |
gemini-empty-parts.patch |
this repo (patches/gemini-empty-parts.patch) |
PR #28669 |
vim.patch |
this repo (patches/vim.patch) |
PR #12679 |
tool-fix.patch |
this repo (patches/tool-fix.patch) |
PR #16751 |
mcp-reconnect.patch |
this repo (patches/mcp-reconnect.patch) |
Issue #15247 |
instance-state-partition.patch |
this repo (patches/instance-state-partition.patch) |
local (upstream PR pending burn-in) |
cache-thinking-skip.patch |
this repo (patches/cache-thinking-skip.patch) |
Issue #17883 |
retry-cap.patch |
this repo (patches/retry-cap.patch) |
local (no upstream PR yet) |
When an upstream PR is merged, the corresponding patch can be dropped. (The big
caching.patch formerly lived in the sibling repo opencode-cached; it was dropped
and that repo archived on 2026-06-02 — see the note at the top of "Patches Included".)
The build fails and creates a GitHub issue automatically. This blocks publication.
The patch targets applyCaching in packages/opencode/src/provider/transform.ts. If
upstream refactors that function, re-derive the hunk: the patch replaces the blind
const lastContent = msg.content[msg.content.length - 1] breakpoint pick with a
backward scan to the last cacheable block (skipping reasoning,
redacted-reasoning, and tool-approval-* blocks).
- Check whether upstream has fixed Issue #17883; if yes, remove
patches/cache-thinking-skip.patchand updatepatches/apply.sh - If absent, re-derive the backward-scan hunk against the new
applyCaching - Review, commit, push
- Re-trigger:
gh workflow run build-release.yml --field version=X.Y.Z
The build fails and creates a GitHub issue automatically. This blocks publication.
Use PR #25367 as the behavioral guide when refreshing. The patch should preserve prompt-loop message byte identity across tool-call continuations while forcing full reloads after compaction, subtasks, and overflow recovery.
- Check whether upstream already has the fix; if yes, remove
patches/prompt-loop-cache.patchand updatepatches/apply.sh - If the fix is absent, regenerate from the PR:
gh pr diff 25367 --repo anomalyco/opencode > patches/prompt-loop-cache.patch - Review, commit, push
- Re-trigger:
gh workflow run build-release.yml --field version=X.Y.Z
The build fails and creates a GitHub issue automatically. This blocks publication.
Maintenance note: refresh from PR #25100 if it drifts; drop when upstream includes it. Use PR #25100 as the behavioral guide. The patch should align compaction requests with normal prompt-loop context.
- Check whether upstream already has the fix; if yes, remove
patches/cache-aligned-compaction.patchand updatepatches/apply.sh - If the fix is absent, regenerate from the PR:
gh pr diff 25100 --repo anomalyco/opencode > patches/cache-aligned-compaction.patch - Review, commit, push
- Re-trigger:
gh workflow run build-release.yml --field version=X.Y.Z
The build fails and creates a GitHub issue automatically. This blocks publication.
The patch has two hunks on two code paths (see section 4 above); a refresh must keep both unless upstream has fixed the corresponding path.
packages/llm/src/protocols/gemini.ts(native runtime): use PR #28669 as the behavioral guide. Pads empty user/assistant/tool messages with an empty-string text part.packages/opencode/src/provider/transform.ts(AI SDK runtime, the path Gemini actually uses): a@ai-sdk/google/@ai-sdk/google-vertexblock innormalizeMessages()that drops empty text/reasoning parts and resulting-empty messages, mirroring the sibling@ai-sdk/anthropic/@ai-sdk/amazon-bedrockblocks. Tracked by Issue #17519. Behavioral check: runbun test test/provider/transform.test.ts -t "gemini empty parts"frompackages/opencode.
- Check whether upstream already handles empty Gemini parts on both paths.
- If a path is fixed upstream: drop that hunk; if both are fixed, remove
patches/gemini-empty-parts.patchand updatepatches/apply.sh. - If absent: refresh the failing hunk (regenerate the
packages/llmhunk from PR #28669, keep the empty tool-message regression coverage; re-derive thetransform.tshunk against the post-cache-aligned-compaction baseline). - Review, commit, push.
- Re-trigger:
gh workflow run build-release.yml --field version=X.Y.Z
The build fails and creates a GitHub issue automatically. This blocks publication.
Use PR #12679 as the behavioral guide when rebasing: the PR defines the intended vim motions and config surface. Port only behavior still missing upstream; drop anything already present.
- Fetch the PR as a behavioral reference:
gh pr diff 12679 --repo anomalyco/opencode > /tmp/vim-pr-12679.patch - Rebase
patches/vim.patchonto the new upstream, using the PR diff as the source of truth for intended behavior - Review, commit, push
- Re-trigger:
gh workflow run build-release.yml --field version=X.Y.Z
sync-vim-pr.yml checks every 8 hours whether PR #12679's raw diff matches patches/vim.patch.
If the hashes differ, it opens a GitHub issue labeled patch-drift.
Drift does not mean the build is broken. The build continues to use the committed
patches/vim.patch as-is. The build/release workflow is the source of truth for whether
publication is blocked. The drift issue is a prompt to review what changed upstream and
decide whether to adopt it.
The build fails and creates a GitHub issue automatically. This blocks publication.
Use PR #16751 as the behavioral guide when refreshing. If the upstream release already includes the fix (verify by running the regression test), drop patches/tool-fix.patch entirely rather than refreshing it.
- Check whether upstream already has the fix: run the regression test from PR #16751 against a plain upstream checkout
- If fix is present upstream: remove
patches/tool-fix.patchand updatepatches/apply.sh - If fix is absent: regenerate from the PR:
gh pr diff 16751 --repo anomalyco/opencode > patches/tool-fix.patch - Review, commit, push
- Re-trigger:
gh workflow run build-release.yml --field version=X.Y.Z
sync-tool-fix-pr.yml checks every 8 hours whether PR #16751's raw diff matches patches/tool-fix.patch.
If the hashes differ, it opens a GitHub issue labeled patch-drift.
Drift does not mean the build is broken. The build continues to use the committed
patches/tool-fix.patch as-is. The build/release workflow is the source of truth for whether
publication is blocked. The drift issue is a prompt to review what changed upstream and
decide whether to adopt it.
The build fails and creates a GitHub issue automatically. This blocks publication.
- Review the upstream changes to
packages/opencode/src/mcp/index.ts - Regenerate or manually update
patches/mcp-reconnect.patch - Review, commit, push
- Re-trigger:
gh workflow run build-release.yml --field version=X.Y.Z
Sunset: This patch can be dropped when issue #15247 is resolved upstream. Unlike the other patches, this one has no upstream PR to track -- it is original work. If an upstream PR appears, add a sync workflow for it.
The build fails and creates a GitHub issue automatically. This blocks publication.
- Review the upstream changes to
packages/opencode/src/effect/app-runtime.ts,packages/opencode/src/project/instance-layer.ts,packages/opencode/src/server/server.ts, andpackages/opencode/src/worktree/index.ts. - Regenerate or manually update
patches/instance-state-partition.patch. - Review, commit, push.
- Re-trigger:
gh workflow run build-release.yml --field version=X.Y.Z
Monthly automated check (check-sunset.yml) monitors all upstream PRs/issues:
- Any PR merged (or tracked issue closed): Drop the corresponding patch from
apply.sh - All merged: Switch workstation to upstream OpenCode, archive this repo (
opencode-cachedwas already archived 2026-06-02)
- OpenCode: anomalyco/opencode
- Prompt-loop cache PR: PR #25367 by @BYK
- Cache-aligned compaction PR: PR #25100
- Gemini empty parts PR: PR #28669
- Vim PR: PR #12679 by @leohenon
- Tool fix PR: PR #16751 by @altendky
- MCP reconnect: Issue #15247 -- original patch
- Instance state partition: original patch
- Cache thinking-skip: Issue #17883 -- original patch (formerly part of the now-dropped
caching.patch/ PR #5422 by @ormandj)
MIT (same as upstream OpenCode)