Skip to content

Inbox: say a sent reply is queued, and Send now delivers it into the running turn - #1968

Merged
backnotprop merged 16 commits into
mainfrom
inbox-queued-send-now
Oct 17, 2026
Merged

backnotprop merged 16 commits into
mainfrom
inbox-queued-send-now

Conversation

@backnotprop

@backnotprop backnotprop commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

What the person sees

The owner's report: after replying in the Inbox, the reply said "Saved for Claude Code. It sees it when it checks.", which reads as if it were dropped, or as if the agent had to poll for it. The line under the person's last reply (or New message / compose message) now says where it is, from what the Inbox knows about the session's connection right now:

State Line
Waking agent, working Queued · Claude Code is working; it gets this when its turn ends. with a Send now button
Waking agent, idle Sending to Claude Code… (lands within seconds)
After Send now Sending to Claude Code now…
Delivered Delivered to Claude Code, 5:12 PM (unchanged)
Agent that only reads the thread (MCP, OpenCode 1) Saved for Claude Code. It sees it when it next reads this thread.
Session ended / Not delivered unchanged

The Sent row in the list reads "Queued for ; it gets this as its next turn." for a waking agent (the row's additive sent.wakes), "Saved for . It sees it when it next reads this thread." otherwise.

Can a Claude Code plugin steer into a running turn?

Yes, but not with $.prompt.submit. From the engine's own API (2.1.301 types and the plugin-authoring reference): $.prompt.submit "queues a prompt that starts a turn of its own once the session is idle (never folded into a running turn)". $.session.append({ message: { type: 'user', content } }) "REALLY appends: a user-role row … the model reads it … The row is in the list at once and in a running turn's requests from the loop's next top." So Send now uses $.session.append, no interrupt needed. The engine harness (claude plugin test) confirms the mod's call is accepted and stored as a user row.

What Send now does per host

  • Server: POST /api/inbox/messages/:id/send-now (same-origin, serverSession) writes deliver_now_at on the message. Refused when the recipient's connection does not poll now or cannot deliver mid-turn (409 send_now_unavailable), when delivered (409 already_delivered), or for a message that goes to no session (422 not_addressed). The bridge command carries now: true only on a poll that says send_now; a held poll answers at once. A connection that could not steer posts send_now_failed, which clears the mark (the line reads Queued again). On the device door as contract 7.58.
  • Order: once a reply carries now, the connection delivers the waiting replies at once, oldest first, so an urgent reply never overtakes an older one to the same session.
  • Claude Code mod: send_now (poll and state events) is true only while a reply could go in now, so while this session's own Ask question holds it Send now is not offered and a pressed one reads Queued. Decided right before the append, after the fresh check, lease and claim. Into the running main turn as one user row ($.session.append) plus a transcript notice, never into this session's own "Ask this session" turn (running or queued: the reply waits for idle there). A turn that ended before the append gets the reply as a turn; one that ended or changed during it gets one short pointer turn at once; otherwise the row is tied to its turn id, and a turn that ends with no model request after it gets the pointer. Advertised only on an engine at least 2.1.301 (from $.session.version(): claude plugin validate refuses typeof $.session.append), and no more after an engine refuses a row.
  • Pi: pi.sendUserMessage(text, { deliverAs: "steer" }), delivered when Pi starts the message (message_start).
  • OpenCode 2: session.prompt({ delivery: "steer" }), delivered only on session.inbox.delivered for that row id (or session.inbox.delivery.changed to queue). If the run ends first and the row is not delivered within 3 s, after 60 s, or at once if the event stream ends, it is sent as a queued turn ending with "If this same reply () already appears above, it is a repeat; answer it once." Because OpenCode cannot withdraw the steer row and promotes it with that turn (fix(opencode): a tool launch's session-URL notice never wakes an idle root #1734), the plugin's context/generate hooks drop the later of the two copies from every model request. Offered only while the event stream runs.
  • OpenCode 1 and older connections: never send the poll field send_now, so Send now is never offered.
  • Nothing is ever aborted. Claim-once holds: the same claim folder and lease as a normal delivery.
  • The line under the reply also reads Queued for a message sent without the wake mark (the stdio shim inside Claude Code) while its session's connection polls.

A bug this turned up

The owner's "Claude is busy constantly" had a second cause: every hook hears a subagent's turn.start, the mod counted it as the session's running turn, and its end was filtered out at turn.complete. So once a background subagent started, the session read as busy for good and Inbox replies never went in. PlannotatorMod.onTurnStart now ignores subagent turns. Proved in the engine harness (fails without the fix) and in controller.test.ts.

Tests

  • packages/server/inbox-send-now.test.ts: routes, guards, the poll's now, a held poll answering on Send now, sent.wakes, and agent-link's steer for Pi/OpenCode (busy → steer, idle → deliver at once, no steer → refused).
  • apps/hook/hooks/mod/inbox.test.ts 12 to 17: the mod against a real Inbox (busy session, Send now → one user row, no turn, Delivered; the row landing as the turn ended → one pointer turn; the turn ending just before the append (the reviewer's repro) → a turn, no row; the turn ending during the append → the pointer at once; this session's Ask question running or queued → nothing until idle; oldest first). send-now-engine.test.ts: the version gate.
  • apps/opencode-plugin/inbox-v2-wake.test.ts: the steer counted delivered only on its session.inbox.delivered, and the queued fallback. apps/pi-extension/inbox-send-now.test.ts: the steer, delivered on message_start.
  • apps/hook/tests/register.test.ts (engine harness): the row through $.session.append; an engine older than 2.1.301 never offers it; a background subagent's turn no longer holds a reply.
  • packages/inbox/deliveryLine.test.ts, DOM packages/inbox/sendNow.test.tsx (added to the test.yml DOM list).
  • tests/e2e/inbox-send-now.spec.ts on the compiled binary (desktop light/dark and a phone); inbox-compose, inbox-ended-session, inbox-new-message specs updated to the new lines and pass.

Screenshots (light, dark, phone): .product/drafts/inbox-queued-send-now/ in the owner's checkout.

Docs: AGENTS.md (Questions and Send, the mod's Inbox paragraph, Ask this session's busy, agent connections, server table), plannotator inbox --help, the marketing reference page, and the mobile contract (7.58).

backnotprop and others added 9 commits October 16, 2026 17:41
…running turn

The line under the person's reply said "Saved for Claude Code. It sees it
when it checks." while the reply waited for the session to go idle, which
read as dropped. It now says where the reply is, from the session's
connection: "Queued · Claude Code is working; it gets this when its turn
ends." with Send now, "Sending to Claude Code…" while idle, and the
truthful "Saved for … It sees it when it next reads this thread." for an
agent that only reads the thread.

Send now (POST /api/inbox/messages/:id/send-now, door 7.58) marks the reply
deliver_now_at; the bridge command carries now: true. The Claude Code mod
puts it into the running turn as a user row with $.session.append (a
plugin's $.prompt.submit never folds into a running turn), with one short
follow-up turn if the turn ended before any request carried it; Pi steers
(deliverAs: "steer"); OpenCode 2 prompts with delivery: "steer".
Connections advertise it with the poll field send_now.

Also fixes the mod counting a subagent's turn.start as the session's turn:
its end was never counted, so a background subagent left the session busy
for good and Inbox replies never went in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…48 characters

The Send now sentence in the tool's wake note pushed it to 2,122; the wake
text and plannotator inbox --help already say it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	tests/e2e/playwright.config.ts
… steers

Review fixes:
- The mod decides into-the-turn right before the append, after the fresh
  check, lease and claim: a turn that ended meanwhile gets the reply as a
  turn; a turn that ended or changed during the append gets the pointer turn
  at once; rows are tied to their turn id (several per turn).
- Never into this session's own Ask this session turn, running or queued
  (steerableTurn); the reply waits for idle there.
- Send now is advertised only on an engine whose API has $.session.append
  (2.1.301+, from $.session.version(): validate refuses testing the noun).
- OpenCode 2 counts a steer delivered only on session.inbox.delivered for its
  row; if the run ends first, it is sent queued after a grace. Pi tested on
  message_start.
- The server hands `now` only to a poll that said send_now; a refused steer
  posts send_now_failed and the line reads queued again; urgent delivery is
  oldest first; the line reads Queued for a shim-sent message whose session
  polls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	apps/hook/hooks/mod/inbox-contract.ts
#	apps/hook/hooks/mod/inbox.test.ts
#	apps/hook/server/inbox-help.ts
#	packages/server/inbox.ts
#	packages/shared/inbox/connection.ts
#	tests/helpers/inbox-world.ts
…d while an Ask holds it

- The fallback queued turn ends with a repeat line, and the plugin's context
  and generate hooks drop the later of the steer row and its fallback from
  every model request (OpenCode promotes the steer row with the queued turn,
  #1734, and cannot withdraw it).
- session.inbox.delivery.changed to queue for the steer row counts as
  delivered: no fallback.
- The steer waits at most 60 s, and the fallback goes at once when the event
  stream ends.
- The mod's send_now (poll and state events) is true only while a reply could
  go in now: while this session's own Ask question holds it, Send now is not
  offered and a pressed one reads Queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	apps/hook/tests/register.test.ts
# Conflicts:
#	AGENTS.md
#	apps/hook/hooks/mod/inbox-contract.ts
#	apps/hook/hooks/mod/inbox.test.ts
#	packages/shared/inbox/connection.ts
…at never empties the last message

- OpenCode 2: no cap while the run goes on (a long tool call no longer sends
  the fallback while OpenCode will still promote the steer row); a lost end
  event is checked with session.wait, and after the event stream ends the row
  waits for idle, then counts as delivered when session.context holds it.
- dropInboxDuplicates replaces a later copy that is its message's whole
  content with a stable pointer instead of removing it, so a request never
  ends on an assistant message.
- Pi and OpenCode never steer into the session's own Ask question (canSteer),
  and InboxWake's state events carry send_now, as the mod's do.
- The mod keeps alive fresh for every claim it holds while it waits.
- Pi: a steer Esc moved into the editor is taken back out before the reply
  goes in as a follow-up.
- The 👍 line uses the reply line's honest Queued / Sending words from the
  session's connection (no Send now for an ack).
…n's process delivers it; OpenCode 2 sends no steer fallback into a deleted session or during shutdown

The Claude Code mod kept a claim it won but could not use (a turn started
right after the claim, its own Ask question, a refused row) and refreshed its
alive while not the lease holder, so with the lease moved to another process
nobody delivered the reply. It now gives the claim back (INBOX_RELEASE_SCRIPT,
only while it is its own and neither delivered nor reported), and a process
that lost the claim takes the reply back as pending once the folder is gone.

OpenCode 2: session.deleted settles that session's steer rows (their watch
stops) and dispose settles them as abandoned: no fallback is sent; the wake
gives its claim back and the reply is picked up at the next start.
@backnotprop
backnotprop merged commit 2e51b45 into main Oct 17, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant