You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Plugin host API: expose inbox cancel and a busy query to plugins #56115
Plugin host API: expose inbox cancel and a busy query to plugins
Context. Plugins that construct parent notifications for background children currently burn agent turns on stale signals. Concrete case: alvinunreal/oh-my-opencode-slim#1504 — a child permission ask resolved by an external auto-responder still produces a queued parent wake that cannot be retracted, so the parent wakes and spends a turn on an already-resolved ask. We fixed the plugin-side policy, but the remaining fix (retracting a queued wake by ID, and not waking a busy parent) needs two primitives that already exist in v2 core but are not exposed on the plugin host ctx:
session.inbox.cancel(inboxID) — SessionInbox.cancel exists (packages/core/src/session/inbox.ts:230, pre-delivery only; already-delivered → LifecycleConflict) and is served over HTTP (DELETE /api/session/:sessionID/inbox/:inboxID), but the plugin host session domain (packages/core/src/plugin/host.ts:504-525) exposes no inbox operations. A plugin that records the inbox/message ID returned by its own prompt/synthetic admission could then retract a queued notice the moment the underlying request resolves.
An authoritative busy query — execution.isActive and the GET /api/session/active listing exist, but plugin ctx offers no authoritative query — only event-tracked heuristics or a racing wait. One authoritative check before admitting a wake would prevent waking a busy parent.
Use case. Stop spending orchestrator turns on child permission asks that were resolved externally after a fixed settle window; retract queued stale wakes by ID instead of layering settle timers and heuristics in plugin land. (Per-call resume:false admission on ctx.session.prompt/synthetic already helps here and is in active use.)
Boundary note. Mechanism belongs in the host (it owns inbox state and execution busy); policy stays in the plugin. Both are thin exposures of existing internals — no new core behavior needed. Checked against v2 pinned commit dbd9b18f3d7b4ef3e346eafd91613896c9e3c864.
To restate the request: the plugin host ctx.session should expose two things core already has. One is cancelling a queued, not-yet-delivered inbox item by ID (SessionInbox.cancel, already served over DELETE /api/session/:sessionID/inbox/:inboxID). The other is an authoritative check for whether a session is busy (execution.isActive / GET /api/session/active). With these, a plugin could take back stale parent wakes and avoid waking a busy parent, instead of relying on settle timers and guesses.
I checked the current dev code. The plugin session domain in packages/core/src/plugin/host.ts has no inbox operations and no busy query, so a plugin can't do this today. One possible shape would be ctx.session.inbox.cancel({ sessionID, inboxID }) plus ctx.session.active({ sessionID }), with prompt/synthetic returning the inbox ID that a plugin would later cancel.
Separately from inbox.cancel and the busy query, a third primitive would compound them: an out-of-band steer delivery at the next tool boundary of a running turn, instead of queueing a new wake. This seems to already exist internally — SessionInbox.steer (packages/core/src/session/inbox.ts:240) with Promotable = "input" | "steer" promoting steers at a step boundary mid-work — it just isn't exposed on the plugin host ctx (same gap as the two APIs in this issue: packages/core/src/plugin/host.ts registers no inbox operations).
Our orchestrator plugin (oh-my-opencode-slim; cf. alvinunreal/oh-my-opencode-slim#1504) burns one full parent turn per child completion today. inbox.cancel + the busy query only avoid the wasted turns; steer would reclaim even the necessary ones at near-zero cost. I realize this touches turn semantics and is a larger ask than the two APIs here; happy to file it as its own issue if you'd rather keep this one scoped. Plugin-side traces available if useful.
This issue also includes the session execution state query.
The plugin session domain has no active operation. The host has one. packages/client/src/effect/api/api.ts:1306 lists readonly active: SessionActiveOperation<E>, and api.ts:226 gives the output as { readonly [x: Session.ID]: { readonly type: "running" } }.
The plugin domain at packages/plugin/src/promise/session.ts:153 selects create, get, remove, switchAgent, switchModel, prompt, generate, command, compact, synthetic, interrupt, update, move, wait, and context. It does not select active.
A plugin can subscribe to the session.execution.started and session.execution.succeeded events, but it cannot query the current state. Please add active (or an equivalent status query) to the plugin session domain together with the busy query.
A small exposure looks sufficient for cancellation: the generated prompt/synthetic outputs already return inbox records with id, so that ID can be passed through without a new response field. cancel and promotion share the per-session mutex; if delivery wins, the existing lifecycle conflict should remain visible to the plugin rather than becoming a successful no-op.
One contract detail matters for the busy query: active/isActive describe process-local execution ownership, including interruption cleanup. A query followed by admission is still two operations; exposing it cannot guarantee “only admit while idle.” I'd keep the first change to inbox.cancel plus the existing active snapshot, document that race, and keep conditional admission and steer delivery separate. Would that scope fit the intended API?
Yes — that scope fits both halves, including iceteaSA's point that active
and inbox.cancel land on the plugin session domain, not just the host
surface.
Cancel is the load-bearing retraction gap. I maintain the v2
orchestrator-wake path in oh-my-opencode-slim
(alvinunreal/oh-my-opencode-slim#1504). Our child-input wake flow on v2:
settle window (250 ms batching hysteresis) → per-delivery revalidation (ask
still open + event-tracked parent-busy guard) → prompt/synthetic
admission with delivery: "queue" → the host delivers at the next idle
boundary. Two residuals survive every local guard:
An external resolver (e.g. a UI auto-responder) settling the ask between
revalidation and host delivery leaves the queued wake unaddressable. On
one production install ~43% of asks resolved after the settle window
(measured by a user in slim#1504 over Oct 11-16 logs) — an upper bound
for the population that can no longer be pruned after admission. We see
the resolution arrive (permission.replied, or the child going
terminal) but have no retract.
Queued synthetic admissions resume the parent at the idle boundary by
design (matching v1 semantics), so a delivered-then-stale wake is a full
parent turn.
So the pass-through cancel Dante-dan described — the inbox.cancel half
iceteaSA folded into the same domain ask — covers the scenario with zero
new plumbing: our wake already mints its inbox ID at admission (v2 keys the
inbox row by the same SessionMessage.ID that Session.synthetic honors
via input.id), so retracting is one call with the ID we already hold.
Two contract details worth pinning:
Typed conflict beats the HTTP surface. Today the session.inbox.cancel
handler swallows Session.InboxConflictError into a 204 while the
neighboring session.inbox.update handler surfaces the same conflict
class as a 409 (both in packages/server/src/handlers/session.ts), so
over HTTP a plugin cannot distinguish "retracted" from "already
delivered" on cancel (the domain should call Session.cancelInbox
directly, not the HTTP route). If the plugin-domain exposure keeps the
conflict typed, it becomes the only surface where the
delivered-vs-retracted distinction survives — that distinction is exactly
what decides whether we keep or drop our local bookkeeping key.
Race documentation: agreed. The active snapshot would replace the
guess at the single admission decision — a conservative check, not a
two-step atomicity claim; our event-tracked guard stays only as a cheap
backstop for hosts without the new surface. We do not need conditional
admission in the first change — the deliverer is the host's, and our
local re-validation already covers ask-resolved-before-admission.
On the busy half: the v2 plugin contract exposes domain objects only
(nothing in ctx reaches a client surface — packages/plugin/src/promise/plugin.ts),
so the authoritative active/busy query iceteaSA described is not callable
from plugins today, and the session-domain exposure is what gives plugins
that query. Our event-tracked isParentActiveByEvents guard exists
precisely because there is nothing authoritative to call.
Our gap is queued notices. A notice admitted with delivery: "queue" can wait a long time before the idle boundary. When a later event supersedes it in that window, we cannot retract it today, so the stale notice still costs a full turn. inbox.cancel on the plugin session domain, with the ID that prompt/synthetic already return, closes that gap.
We agree with Jiajun0413 on the typed conflict: when delivery already happened, the plugin must see that result, not a successful no-op, because it decides whether we keep or drop our own record. Also agreed on documenting the active race. We would use active as a snapshot, not as a guarantee.
The queue window makes the cancellation requirement clear. I'd expose ctx.session.inbox.cancel({ sessionID, inboxID }) by calling sessions.cancelInbox directly in the plugin host, and add active plus a cancel-only inbox member to both plugin domain contracts. Selecting the entire client inbox domain would also expose operations outside this agreed scope. active() can retain the existing map-shaped snapshot, with the plugin checking its parent ID.
One bookkeeping detail needs care: a typed conflict means the cancellation did not remove a pending item, not necessarily “this item was delivered.” In current projectCancelled, a missing pending row produces the same conflict after delivery, after an earlier cancellation, or for an unknown ID. mutatePending translates that into Session.InboxConflictError for an existing session. Plugins should retain any separate delivery evidence when deciding what to do with their bookkeeping key. Your HTTP observation also holds at this revision: cancel explicitly catches that error, while update uses the conflict-preserving helper. A direct domain binding avoids inheriting that cancellation policy.
The acceptance cases I'd pin down are: cancellation wins before promotion and the queued notice never arrives; promotion wins and cancellation exposes the conflict; a second cancellation or unknown ID also exposes the existing conflict rather than claiming delivery; and both Effect and Promise plugin surfaces preserve the failure instead of converting it to successful void. The active snapshot remains advisory and steer stays separate. These are proposed checks from source inspection, not executed test results.
Activity
opencode-agent commentedon Oct 16, 2026
Thanks for the detailed write-up!
To restate the request: the plugin host
ctx.sessionshould expose two things core already has. One is cancelling a queued, not-yet-delivered inbox item by ID (SessionInbox.cancel, already served overDELETE /api/session/:sessionID/inbox/:inboxID). The other is an authoritative check for whether a session is busy (execution.isActive/GET /api/session/active). With these, a plugin could take back stale parent wakes and avoid waking a busy parent, instead of relying on settle timers and guesses.I checked the current
devcode. The pluginsessiondomain inpackages/core/src/plugin/host.tshas no inbox operations and no busy query, so a plugin can't do this today. One possible shape would bectx.session.inbox.cancel({ sessionID, inboxID })plusctx.session.active({ sessionID }), withprompt/syntheticreturning the inbox ID that a plugin would later cancel.Related:
session.idleis never emitted, which is part of why plugins have to track busy state from eventsMaintainers will consider this.
Jiajun0413 commentedon Oct 18, 2026
Separately from
inbox.canceland the busy query, a third primitive would compound them: an out-of-band steer delivery at the next tool boundary of a running turn, instead of queueing a new wake. This seems to already exist internally —SessionInbox.steer(packages/core/src/session/inbox.ts:240) withPromotable = "input" | "steer"promoting steers at a step boundary mid-work — it just isn't exposed on the plugin host ctx (same gap as the two APIs in this issue:packages/core/src/plugin/host.tsregisters no inbox operations).Our orchestrator plugin (oh-my-opencode-slim; cf. alvinunreal/oh-my-opencode-slim#1504) burns one full parent turn per child completion today.
inbox.cancel+ the busy query only avoid the wasted turns; steer would reclaim even the necessary ones at near-zero cost. I realize this touches turn semantics and is a larger ask than the two APIs here; happy to file it as its own issue if you'd rather keep this one scoped. Plugin-side traces available if useful.iceteaSA commentedon Oct 19, 2026
This issue also includes the session execution state query.
The plugin session domain has no
activeoperation. The host has one.packages/client/src/effect/api/api.ts:1306listsreadonly active: SessionActiveOperation<E>, andapi.ts:226gives the output as{ readonly [x: Session.ID]: { readonly type: "running" } }.The plugin domain at
packages/plugin/src/promise/session.ts:153selectscreate,get,remove,switchAgent,switchModel,prompt,generate,command,compact,synthetic,interrupt,update,move,wait, andcontext. It does not selectactive.A plugin can subscribe to the
session.execution.startedandsession.execution.succeededevents, but it cannot query the current state. Please addactive(or an equivalent status query) to the plugin session domain together with the busy query.Dante-dan commentedon Oct 20, 2026
A small exposure looks sufficient for cancellation: the generated
prompt/syntheticoutputs already return inbox records withid, so that ID can be passed through without a new response field.canceland promotion share the per-session mutex; if delivery wins, the existing lifecycle conflict should remain visible to the plugin rather than becoming a successful no-op.One contract detail matters for the busy query:
active/isActivedescribe process-local execution ownership, including interruption cleanup. A query followed by admission is still two operations; exposing it cannot guarantee “only admit while idle.” I'd keep the first change toinbox.cancelplus the existingactivesnapshot, document that race, and keep conditional admission and steer delivery separate. Would that scope fit the intended API?Jiajun0413 commentedon Oct 20, 2026
Yes — that scope fits both halves, including iceteaSA's point that
activeand
inbox.cancelland on the plugin session domain, not just the hostsurface.
Cancel is the load-bearing retraction gap. I maintain the v2
orchestrator-wake path in oh-my-opencode-slim
(alvinunreal/oh-my-opencode-slim#1504). Our child-input wake flow on v2:
settle window (250 ms batching hysteresis) → per-delivery revalidation (ask
still open + event-tracked parent-busy guard) →
prompt/syntheticadmission with
delivery: "queue"→ the host delivers at the next idleboundary. Two residuals survive every local guard:
revalidation and host delivery leaves the queued wake unaddressable. On
one production install ~43% of asks resolved after the settle window
(measured by a user in slim#1504 over Oct 11-16 logs) — an upper bound
for the population that can no longer be pruned after admission. We see
the resolution arrive (
permission.replied, or the child goingterminal) but have no retract.
design (matching v1 semantics), so a delivered-then-stale wake is a full
parent turn.
So the pass-through cancel Dante-dan described — the
inbox.cancelhalficeteaSA folded into the same domain ask — covers the scenario with zero
new plumbing: our wake already mints its inbox ID at admission (v2 keys the
inbox row by the same
SessionMessage.IDthatSession.synthetichonorsvia
input.id), so retracting is one call with the ID we already hold.Two contract details worth pinning:
session.inbox.cancelhandler swallows
Session.InboxConflictErrorinto a 204 while theneighboring
session.inbox.updatehandler surfaces the same conflictclass as a 409 (both in
packages/server/src/handlers/session.ts), soover HTTP a plugin cannot distinguish "retracted" from "already
delivered" on cancel (the domain should call
Session.cancelInboxdirectly, not the HTTP route). If the plugin-domain exposure keeps the
conflict typed, it becomes the only surface where the
delivered-vs-retracted distinction survives — that distinction is exactly
what decides whether we keep or drop our local bookkeeping key.
activesnapshot would replace theguess at the single admission decision — a conservative check, not a
two-step atomicity claim; our event-tracked guard stays only as a cheap
backstop for hosts without the new surface. We do not need conditional
admission in the first change — the deliverer is the host's, and our
local re-validation already covers ask-resolved-before-admission.
On the busy half: the v2 plugin contract exposes domain objects only
(nothing in
ctxreaches a client surface —packages/plugin/src/promise/plugin.ts),so the authoritative
active/busy query iceteaSA described is not callablefrom plugins today, and the session-domain exposure is what gives plugins
that query. Our event-tracked
isParentActiveByEventsguard existsprecisely because there is nothing authoritative to call.
Steer delivery: agree it stays out of scope here.
iceteaSA commentedon Oct 20, 2026
That scope fits our use too.
Our gap is queued notices. A notice admitted with
delivery: "queue"can wait a long time before the idle boundary. When a later event supersedes it in that window, we cannot retract it today, so the stale notice still costs a full turn.inbox.cancelon the plugin session domain, with the ID thatprompt/syntheticalready return, closes that gap.We agree with Jiajun0413 on the typed conflict: when delivery already happened, the plugin must see that result, not a successful no-op, because it decides whether we keep or drop our own record. Also agreed on documenting the
activerace. We would useactiveas a snapshot, not as a guarantee.Dante-dan commentedon Oct 20, 2026
The queue window makes the cancellation requirement clear. I'd expose
ctx.session.inbox.cancel({ sessionID, inboxID })by callingsessions.cancelInboxdirectly in the plugin host, and addactiveplus a cancel-only inbox member to both plugin domain contracts. Selecting the entire clientinboxdomain would also expose operations outside this agreed scope.active()can retain the existing map-shaped snapshot, with the plugin checking its parent ID.One bookkeeping detail needs care: a typed conflict means the cancellation did not remove a pending item, not necessarily “this item was delivered.” In current
projectCancelled, a missing pending row produces the same conflict after delivery, after an earlier cancellation, or for an unknown ID.mutatePendingtranslates that intoSession.InboxConflictErrorfor an existing session. Plugins should retain any separate delivery evidence when deciding what to do with their bookkeeping key. Your HTTP observation also holds at this revision:cancelexplicitly catches that error, while update uses the conflict-preserving helper. A direct domain binding avoids inheriting that cancellation policy.The acceptance cases I'd pin down are: cancellation wins before promotion and the queued notice never arrives; promotion wins and cancellation exposes the conflict; a second cancellation or unknown ID also exposes the existing conflict rather than claiming delivery; and both Effect and Promise plugin surfaces preserve the failure instead of converting it to successful
void. The active snapshot remains advisory and steer stays separate. These are proposed checks from source inspection, not executed test results.