Skip to content

Plugin host API: expose inbox cancel and a busy query to plugins #56115

Open

Description

@Jiajun0413

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:

  1. 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.

  2. 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.

Activity

  1. opencode-agent commented on Oct 16, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed write-up!

    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.

    Related:

    Maintainers will consider this.

  2. Jiajun0413 commented on Oct 18, 2026

    @Jiajun0413
    Author

    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.

  3. iceteaSA commented on Oct 19, 2026

    @iceteaSA

    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.

  4. Dante-dan commented on Oct 20, 2026

    @Dante-dan
    Contributor

    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?

  5. Jiajun0413 commented on Oct 20, 2026

    @Jiajun0413
    Author

    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:

    1. 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.
    2. 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.

    Steer delivery: agree it stays out of scope here.

  6. iceteaSA commented on Oct 20, 2026

    @iceteaSA

    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.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.

  7. Dante-dan commented on Oct 20, 2026

    @Dante-dan
    Contributor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions