Repository navigation
Open
Labels
Description
Summary
A plugin's setup() runs once per client connection in the same server process. Every ctx.event.subscribe() loop therefore receives every event N times (N = live connections). Plugins with event-driven side effects silently multiply their actions N×, and the plugins guide does not mention multi-instance semantics at all.
Environment
- opencode version: 2.0.30
- OS: Darwin 27.0.0 (darwin arm64)
- Terminal: desktop app (Electron) + TUI
- Shell: /bin/zsh
- Install/channel: latest
- Active plugins: local plugins under
~/.config/opencode/plugins/— notably a Pushover notifier listening tosession.execution.succeeded|failed|interruptedandpermission.asked
Reproduction
- Create a local plugin that logs on every terminal event:
export default Plugin.define({
id: "echo-test",
async setup(ctx) {
const id = Math.random().toString(36).slice(2);
console.log("instance", id);
void (async () => {
for await (const event of ctx.event.subscribe({})) {
if (event?.type === "session.execution.succeeded") {
console.log("event received by", id);
}
}
})();
},
});- Open the desktop app and let a few client connections stay alive (TUI / web / background clients all count).
- Finish a turn in any session.
Expected Behavior
- Either one plugin instance per process (side effects fire once per event), or the plugins guide documents that
setup()runs once per connection and shows the recommended cross-instance coordination pattern.
Actual Behavior
- Server log shows repeated
loading pluginlines — one per connection, each with a distincthttp.spanID, all in the same run:
... msg="loading plugin" id=/…/plugins/opencode-notify entrypoint=file:///… http.span=4027083 role=server
... msg="loading plugin" id=/…/plugins/opencode-notify entrypoint=file:///… http.span=4871341 role=server
... msg="loading plugin" id=/…/plugins/opencode-notify entrypoint=file:///… http.span=4031979 role=server
... (one per live connection)
- A per-instance diagnostic ledger (each instance writes its own storage key on receipt) shows ~5 instances starting within 14 ms and all receiving the same event in the same millisecond:
{"at":…,"instance":"fe1ng5l0","phase":"received","eventKey":"session.execution.failed:2221"}
{"at":…,"instance":"j0p04b96","phase":"received","eventKey":"session.execution.failed:2221"}
{"at":…,"instance":"gsr8ois8","phase":"received","eventKey":"session.execution.failed:2221"}
{"at":…,"instance":"xi6gpsxh","phase":"received","eventKey":"session.execution.failed:2221"}
{"at":…,"instance":"r6rzkmot","phase":"received","eventKey":"session.execution.failed:2221"}
- The duplicate count tracks the live connection count over time (2×–5× observed across one session).
- For a notification plugin this means 2–5 identical push notifications per event — every "agent finished its turn" pinged the user's phone up to 5 times.
Additional Context
- Plugin lifecycle is fine: cleanup functions run per instance on reload and the
AbortSignalpassed toctx.event.subscribe({ signal })is honored — this is concurrent instantiation, not a leak. - Per-instance in-memory state cannot dedup across instances; only process-global state (e.g. a claim map on
globalThis) can coordinate. That is the workaround we shipped and verified (one sender elected per event across ~5 instances). permission.askedis ephemeral and carries no durable seq, which makes external dedup harder — a correlation ID on ephemeral events would help side-effect plugins.- Suggested docs fix: a "Multi-instance semantics" section in the plugins guide. Suggested product fix: an opt-in process-singleton mode (e.g.
Plugin.define({ id, singleton: true })) or an official cross-instance coordination API.
Activity
opencode-agent commentedon Oct 17, 2026
Thanks for the detailed report. I reproduced this on 2.0.30, and it still happens on the latest
dev(512165c).In my testing the multiplier is the number of loaded project directories (locations) in the server process, not the number of client connections:
setup()and on eachsession.createdfromctx.event.subscribe({}), and ran oneopencode serve.setup()ran 3 times, and all 3 instances received that samesession.created.From the code, plugins are started once per location, but
ctx.event.subscribe()gives each instance the events from every location. That's why a notifier fires once per open project. YourglobalThisclaim map works as a workaround for now.The script below reproduces it without a model.
Related: #54140 (instances pile up across config reloads, with the same N× symptom but a different cause) and #51003 (MCP servers started once per loaded directory).
Reproduction script (repro.sh)
Run it:
bash repro.shherrtreas commentedon Nov 5, 2026
Another effect of this, seen on 2.0.29 and 2.2.5: a session's hooks (
session.prompt,session.title,tool.execute.*) fire only in the plugin instance for the session's own location, but its events reach every instance. A plugin that pairs hooks with events therefore misjudges other locations' sessions. In our case, an instance that never saw thesession.titlehook took a generated title for a name the person chose.Our workaround is to ignore events whose
location(onsession.createdandsession.moved, or fromsession.get) isn't the instance's ownctx.location, compared exactly, which is how 2.0.29 keys instances. The fixes in #56346 and #57186 would make that unnecessary.