Shield against stale reasoning.encrypted_content after OpenCode restarts and idle gaps.
Note
If this plugin saved your old chats, consider supporting the author and checking out my other projects:
Reasoning models behind the Responses API return an opaque crypto blob (metadata.openai.reasoningEncryptedContent +
metadata.openai.itemId) that is bound to the caller/key that issued it. OpenCode persists it in session parts. When
the blob goes stale, the next request replays it and the upstream rejects the whole request:
reasoning 'encrypted_content' was not issued to this callerinvalid_encrypted_content
Two confirmed triggers (opencode/muse-spark-1.3-contributor-free via Zen):
- Server-process restart - the Zen pool serves the next request from a different caller/key, so every pre-restart blob is unverifiable.
- Short upstream TTL - blobs expire within minutes of idle time, so even blobs born in the live process go stale mid-session. Manually rewinding the chat ("reset to this point") confirms it: the rewind drops the stale tail and the next message succeeds, until fresh blobs expire again.
Before every model request (experimental.chat.messages.transform), it drops the crypto blob and itemId from
reasoning parts that are EITHER older than the current server-process boot time OR older than a configurable max age
(default 3 minutes). The human-readable textsummary always stays - the model re-derives reasoning from history
(documented stateless fallback). Fresh blobs are never touched, so the live reasoning cache keeps working.
The default export is a { id, setup, server } object (same shape as opencode-prune-images): the 1.18.x loader
resolves server directly (v1 path). A function-style default export is not usable here - the legacy scan requires
every module export to be a plugin function, otherwise loading fails with Plugin export is not a function.
| Variable | Default | Meaning |
|---|---|---|
OPENCODE_SANITIZE_REASONING |
on | off turns every hook into a no-op (kill-switch). |
OPENCODE_SANITIZE_REASONING_MAX_AGE_MINUTES |
3 |
Max blob age in minutes (fractional allowed, e.g. 0.5 = 30s). off, 0 or negative disables the age rule (restart-only mode). Unset/empty/unparsable falls back to the default. |
- Fail-open: any exception leaves the payload untouched.
- Conservative match:
type === "reasoning"+ non-empty string blob + finite numerictime.start+ (start < bootTsOR age over max-age). Anything ambiguous is left alone. - Minimal diff: only the two metadata keys are removed; part id, text and the rest of metadata stay intact.
- Zero writes: no DB, no files, no network. Only the in-memory request payload is mutated.
- Observable:
loadedandstripped N ...lines go tostderr, which lands inopencode.log- check there that the plugin is active.
Point OpenCode at this plugin in ~/.config/opencode/opencode.json:
{
"plugin": [
"file:///Users/foobar/projects/opencode-sanitize-reasoning/index.ts"
]
}Then fully restart OpenCode Desktop so the plugin loads.
npm install
npm test # node --test index.test.ts
npm run typecheck # tsc --noEmitManual acceptance:
- Restart the Desktop -> continue a previously broken old chat from the first message; look for
stripped N stale reasoning blob(s)in the log. - Idle 5+ minutes in a working chat -> the next message succeeds on the first try (age rule strips the expired blob).
- Rapid message chain -> no
strippedentries in the log (live cache intact). OPENCODE_SANITIZE_REASONING=off-> behavior identical to no plugin.
Remove the plugin line from plugin[] in opencode.json and restart the Desktop.