Repository navigation
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.
Activity
github-actions commentedon Aug 24, 2026
This issue might be a duplicate of existing issues. Please check:
session.hook("context")API — reports that mutations to existing message parts are unexpectedly persisted to session storage (a different symptom of the same hook's broken contract around what mutations reach the model vs. storage)session.synthetic) that external plugins lack — may be root-cause adjacent to the synthetic injection no-op you observedIf your issue is specifically about the zero-delivery from
ctx.event.subscribe(no events fired at all despite the SSE endpoint streaming them), that aspect doesn't appear to have a dedicated existing report and is worth keeping open on its own.andreyz commentedon Aug 24, 2026
Related opencode issues discovered in a tracker/web sweep:
2.0) — the closest relation:session.hook("context")mutations of existing message parts are persisted back into session storage (by part id). Its author notes opencode's own plan-mode plugin "splices a synthetic user message intot.messages" — i.e. the documented/intended contract is that the mutated array is the dispatch payload. Worth noting: our probe only ever appended new messages / new parts; we never tried mutating an existing part's text in place (which is what Context hook: mutations of existing message parts are persisted back into session storage (by part id) #43731 says has real server-side effect). That distinction may be the key to a working injection path and is worth a targeted follow-up test.tool.execute.beforemutations tooutput.argsdo not propagate to tool execution. Evidence that "hook mutation not propagating" is a known, being-fixed class of V2 bug, not unique to the context hook.2.0) — design decision on how synthetic prompts enter the Session inbox; defines the intended synthetic-message semantics we found filtered out of the model prompt.tui.prompt.appendanalog foropencode run); the same capability gap (injecting context before dispatch) from the headless side.background.extend()cannot inject messages into active session loops; another manifestation of injection-into-session being unavailable.So the pattern across V2 is: event delivery to plugin subscribers and hook-mutation propagation to the dispatched request are both unreliable as of beta-18050, while the public SSE endpoint and the session store behave differently from the plugin channel (and from each other).
andreyz commentedon Aug 25, 2026
Follow-up: the #43731-style hypothesis (mutate an existing text part in place, the shape that report says persists into server storage) was tested and does not reach the model either on beta-18050.
Probe: in
session.hook("context"), locate the lastrole:"user"message, mutate its existingcontent[].type==="text"part by appending a token, log before/after. The mutation fired (textLen 32 -> 49, user msg idmsg_03798f7f…), but:lastUserwas a different id (msg_037996…), andGET /api/session/{id}/context) shows the challenge user message asmsg_037996…with the unmutated text — the mutated object never appears in any user message of the projection.Conclusion: the
contexthook'smessagesarray is a per-dispatch working copy whose message objects have their own ids that diverge from the dispatch transcript. No mutation — appended messages, appended parts, or in-place text edits — propagates to the engine that builds the prompt. The model's prompt is assembled from a source the hook never touches.fix(v1.8.1-P2+P3): OpenCode plugins use documented API; installer sel…
okmanideep commentedon Sep 10, 2026
Confirmed on macOS with
opencode2 v0.0.0-beta-17519.I built a local idle-notification plugin under
~/.config/opencode/plugins/. The V2 plugin loaded successfully and appeared inGET /api/plugin, butctx.event.subscribe()never delivered asession.statusevent during multiple completed agent turns.This documented event-subscription approach did not work:
The plugin’s
setup()did run, andafplay /System/Library/Sounds/Glass.aiffworked from that process, so this was not a plugin-loading or macOS-audio issue. The legacy V1 event path was also ineffective in this V2 session.Working workaround:
contextfires reliably, and the detachedctx.session.wait()resolves when the complete agent turn becomes idle. This allowed the notification to work without relying on the silent event stream.This supports the report’s core finding: accepted V2 plugins can have a functional
setup()and hooks whilectx.event.subscribe()remains a silent no-op. A stable session-settled/idle plugin hook would avoid requiring this workaround.OpenJawCode commentedon Sep 11, 2026
Current-version datapoint (beta-19271, Linux arm64, headless service) confirming this defect class with a minimal plugin:
ctx.session.hook("context")pushes one static text part tosystem(no DB, no other hooks).systemconfirmed as an array before the push.So on 19271/headless: hook fires, mutation succeeds, content still absent from the effective prompt. Same fires-but-dropped shape as reported here for 18050. Happy to provide the minimal plugin source if useful.
naschpitz commentedon Oct 25, 2026
Same defect on stable
v2.1.4(Linux x86-64, headlessserve --service)Our plugin channel is provably dead — zero deliveries, not intermittent.
Setup.
@opencode/plugin2.0.28, V2{ id, setup }plugin auto-discovered from the config plugins dir. It subscribes withfor await (const ev of ctx.event.subscribe({ signal }))and the loop body writes a log line unconditionally.Evidence.
GET /api/pluginreports the pluginsource: local,state: active, and the server log shows it loaded (and reloaded on edit) many times.mkdirSync+appendFileSync) has never been created. The loop body never ran once, in 1 h 14 min of lifetime while several sessions were active.GET /api/eventon the same server streamed 17 distinct event types in a 25 s sample (session.step.started,session.reasoning.*,session.step.streamed/ended,session.tool.called/success/progress,session.usage.updated,shell.created/exited/deleted, …). 56 idle transitions across 15 sessions in that window produced zero notifications.session.idleis declared in the shipped schema (session-status-event,durability: "ephemeral") but never appears on the SSE stream either — worth stating which names plugins are guaranteed to receive.Impact. The plugin exists to catch a parent agent that ends its turn after announcing a dispatch without making the tool call. It never fired, so the session stayed idle for 2 min 02 s until a human typed at it. We have not worked around it locally — the guardrail is simply missing.
Not covered here. We never reached the
ctx.session.syntheticpath (the handler never ran), so no evidence either way on injection.opencodejr commentedon Oct 28, 2026
Thanks for the detailed probe. I couldn't reproduce this on the latest release, v2.2.0. I couldn't test
0.0.0-beta-18050because that build isn't published on npm. I ran a probe plugin like yours against a simulated model and checked what was actually sent to the provider:session.hook("context"): the message pushed intomessagesand the part pushed intosystemboth showed up in every model request after the hook ran.systemgoing back to its original length at the next dispatch is by design: it's rebuilt for every request, so the hook has to add its part each time.ctx.session.synthetic: the message was in the model request from the next dispatch on, including the next turn. It isn't in the request whose hook created it, because that request's messages are already built; push the text intomessagestoo if you need it right away. Callingsyntheticwithoutresume: falsealso wakes the session again by default. That likely explains the repeated dispatches and the growing message count you saw.ctx.event.subscribe: it takes options and returns an async iterable rather than taking a handler.subscribe(type, handler)is silently ignored. This delivered every event:session.inbox.*,session.step.*,session.text.*and so on.session.next.*and the@opencode-ai/sdk1.18.21 types are from v1, so they won't match a v2 server.If it still fails on v2.2.0 or a current beta with the iterable form of
subscribe, a reply with your current version and probe code would help. A request body captured through asession.hook("http.request")hook that is missing your token would help most.rekram1-node commentedon Oct 28, 2026
make sure you are using v2 plugins w/ v2 opencode
opencodejr commentedon Oct 31, 2026
I'm closing this for now because we haven't heard back. On v2.2.0 the plugin API delivered everything we tested:
session.hook("context")reached the model on every request after the hook ran.ctx.session.syntheticreached the model from the next request on.ctx.event.subscribe({ types })delivered every event once its result was looped over withfor await.If you still see this on v2.2.0 or a newer beta, please reopen or comment. Your version and probe code would help, and most of all a request body captured with
session.hook("http.request")that is missing your token. Thanks for the detailed report.