anomalyco / anomalyco/opencode
plugins: event.subscribe delivers no events; context-hook and synthetic injections never reach the model prompt (beta 18050)
@rekram1-node is already working on this.
Since Aug 24, 2026.
- Dominant language
- TypeScript
- Stars
- 209k
- Forks
- 27.5k
- PR merge metrics
- PR metrics pending
Description
Summary
On beta channel server 0.0.0-beta-18050, the V2 plugin API cannot deliver context into a session's model prompt by any documented mechanism: ctx.event.subscribe registers cleanly but delivers zero events (all three call forms), ctx.session.hook("context") fires on every dispatch but mutations to messages/system never reach the model, and ctx.session.synthetic creates a message that appears in /session/{id}/context yet is excluded from the actual model prompt. Plugin authors following the V2 plugin docs get silent no-ops.
Environment
- opencode version:
opencode1.18.21; server reports0.0.0-beta-18050(GET /api/health) — installed via~/.opencode/bin/opencode2 serve --service - OS: Darwin 25.5.0 (arm64), kernel xnu-12377.121.10
- Terminal: tmux 3.x,
TERM=tmux-256color,COLORTERM=truecolor - Shell: /bin/zsh
- Install/channel: beta (version string
0.0.0-beta-18050,channel=beta) - Active plugins (auto-discovered under
~/.config/opencode/plugins/):vault-heartbeat.ts,vault-surface.ts(both V2{ id, setup }shape, load cleanly),moshi-hooks.ts(V1 shape — correctly rejected by the loader withSchemaError(Missing key at ["default"]["setup"])), plus a headroom package entrypoint (also rejected, V1). The rejection path itself works as expected; the problem is entirely in the accepted V2 plugins' runtime behavior.
Reproduction
Drop this probe plugin at ~/.config/opencode/plugins/probe.ts (auto-discovered and reloaded on change):
import { appendFileSync } from "node:fs"
const LOG = "/tmp/opencode-probe.log"
const log = (s: string) => { try { appendFileSync(LOG, new Date().toISOString() + " " + s + "\n") } catch {} }
export default {
id: "probe",
setup: async (ctx: any) => {
const h = (label: string) => (ev: any) => log(`EV[${label}] ${ev?.type ?? "?"}`)
for (const arg of ["session.next.prompted", "*", h("catchall")]) {
try { ctx.event.subscribe(arg as any, h(String(arg))); log("SUB OK " + String(arg)) } catch (e: any) { log("SUB FAIL " + String(arg) + " " + (e?.message ?? "")) }
}
await ctx.session.hook("context", async (ev: any) => {
log(`HOOK sess=${ev?.sessionID} msgs=${Array.isArray(ev?.messages) ? ev.messages.length : "?"} sysLen=${Array.isArray(ev?.system) ? ev.system.length : "?"}`)
try { ev.messages.push({ role: "user", content: [{ type: "text", text: "PROBE-TOKEN-A" }] }); log("HOOK messages push OK") } catch (e: any) { log("HOOK push FAIL " + (e?.message ?? "")) }
try { ev.system.push({ type: "text", text: " PROBE-TOKEN-B" }); log("HOOK system push OK") } catch (e: any) { log("HOOK sys FAIL " + (e?.message ?? "")) }
try { await ctx.session.synthetic?.({ sessionID: ev.sessionID, text: "PROBE-TOKEN-C" }); log("HOOK synthetic OK") } catch (e: any) { log("HOOK synth FAIL " + (e?.message ?? "")) }
})
},
}
- Start opencode beta, open a session, send any non-empty prompt (e.g. "Repeat any token you can see.").
- Read
/tmp/opencode-probe.logafter the turn. - Check
GET /api/session/{sessionID}/contextfor the synthetic message, and note what the model actually saw.
Observed on 2026-08-24:
EV[...]lines: none ever — across ~20 model dispatches and multiple keystrokes, all three subscription forms registered OK and delivered nothing. Meanwhile an authenticatedGET /api/event(SSE) from outside the plugin does stream events — but with a vocabulary absent from the shipped types:session.inbox.enqueued,session.text.delta,session.step.started,session.tool.called,session.reasoning.*,session.execution.*; nosession.next.*event ever appears, andsession.inbox.enqueuedis not in@opencode-ai/sdk1.18.21's generated types at all.HOOKfires on every dispatch with payload{ sessionID, agent, model, system, messages, tools }.messagesare OpenAI-shaped{ id, role, content: [{ type: "text", text }] };systemis an array of{ type: "text", text }parts.- Mutations: pushing into
messagespersists in the array between dispatches (151 → 152 → 153 … 220 → 222) but the pushed messages never appear in the model's prompt; pushing intosystemis reverted between dispatches (len 2 → 3, next fire reports 2). ctx.session.syntheticreturns a created message, andGET /api/session/{id}/contextincludes it — yet the token never appears in the model's prompt on the same turn or the next turn (canary-token tests were consistently negative across six probe rounds while the model could see every other part of its context).
Expected Behavior
Per the V2 plugin docs (opencode.ai/v2/docs/build/plugins): ctx.event should "subscribe to the current public server event stream" and receive events; ctx.session.hook("context") "mutates system, messages, and the tools record immediately before model dispatch" — so those mutations should be visible to the dispatched model request; and the runtime session.next.* vocabulary should match the installed SDK types / docs.
Actual Behavior
All three documented injection paths are silent no-ops on this build:
ctx.event.subscribedelivers no events to plugins (registered handlers never invoked), despite the public SSE endpoint streaming events with a different, undocumented vocabulary (session.inbox.enqueuedfor an incoming user prompt).ctx.session.hook("context")runs on every dispatch, but mutations tomessagesnever reach the model and mutations tosystemare reverted before the next dispatch — the payload appears to be a per-dispatch working copy that the provider layer ignores/rebuilds.ctx.session.synthetic(andPOST /api/session/{id}/synthetic) persists a message that shows in/session/{id}/contextbut is filtered out of the model's actual prompt, same-turn and next-turn.
Combined impact: a plugin cannot inject context into the model's prompt on this build. Because plugin authors are told to fail silent rather than break the prompt, every one of these paths fails invisibly — the "registration succeeded, delivery dead" shape is precisely what makes it undetectable except by a canary-token experiment.
Additional Context
- Frequency: consistent — reproduced every time across six probe rounds and ~20 dispatches on 2026-08-24. Not intermittent.
- Event/type drift appears bidirectional: server emits names absent from the shipped SDK types (
session.inbox.enqueued) and never emits documented names (session.next.*,session.createdin the plugin stream). This makes typed subscriptions untrustworthy even setting aside the zero-delivery issue. - The plugin loader, V2
{ id, setup }acceptance, file-watch reload, and the schema rejection of V1 shapes all work as documented — only the runtime delivery/mutation paths are broken. - Workarounds that do not help: wildcard and catchall subscription forms; awaiting the synthetic call inside the hook (same-turn timing not the issue — the message is simply excluded from the prompt); mutating
messageswith parts-array content; appending tosystem(reverted). - Working fallback in our case: pull-based context via the session's own MCP tooling (works fine); the blocker is specifically the push/injection direction.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.