Skip to content

fix(opencode): a tool launch's session-URL notice never wakes an idle root - #1734

Merged
backnotprop merged 1 commit into
mainfrom
fix/opencode-subagent-notice
Oct 6, 2026
Merged

backnotprop merged 1 commit into
mainfrom
fix/opencode-subagent-notice

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Found in the 0.28.5 smoke, OpenCode 2 with the plannotator tool turned on.

Bug

A background subagent (subagent with background: true) opens a review with the tool. Its "Plannotator session ready: " notice was posted into the ROOT session, which is idle at that point. The pending steer row then got promoted on its own as a model turn the next time the root woke up. The model answered the URL line, and that exchange stayed in every later request. The same thing happens for a child session created through the API (parentID). Decisions were always delivered correctly. Foreground subagents were not affected because the root is mid-turn while it waits on them.

Cause

runPlannotatorTool hands the launch the root session (which is correct for the decision), and runNativeCommand posted the notice into that same session. With alwaysQueue, the queued decision promotes one row at a time, so the steer notice ahead of it ran as its own turn. Because it was then the last message after an assistant reply, dropSessionUrlNotices kept it on purpose.

Fix

  • A tool launch now posts its notice into the CALLING session (the subagent's own session, or the main session for a direct call), and only while the tool call is still open. runPlannotatorTool closes that window the moment it answers. The caller is therefore always mid-turn when the notice arrives, so the notice is promoted inside that turn and dropped from the model's context, exactly like a main-session tool call today.
  • If the server comes up after the tool already answered "starting", no notice is posted. That URL is still in list and in the log.
  • createV2BridgeClient gains notice: { sessionID, open }. Co-promotion is now only attempted for a notice in the decision's own session.
  • The decision still goes to the root, queued. Slash commands are unchanged: same session, same steer co-promotion (opencode v2: Plan annotations get queued after "Plannotator session ready:" message #1515).
  • The AGENTS.md OpenCode 2 tool section is updated.

Tests

  • plannotator-tool.test.ts, through the real launch path with a stub CLI:
    • a subagent's notice lands in its own session, and the decision goes to the root with queue
    • a main-session call still posts into its own session
    • a server that is ready only after the "starting" answer posts no notice
    • The subagent test and the late-ready test fail with the fix removed.
  • native-commands.test.ts: a notice in another session never steers the decision, and a closed window posts nothing.
  • bun test in apps/opencode-plugin: 342 pass. bun run typecheck passes. The touched opencode files also typecheck under strict tsc. build:review + build:hook + build:opencode succeed.

Live check (OpenCode 2.0.22, fake LLM, sandboxed HOME/XDG/data dir, local registry)

Run Plugin Notice row Model turn answering the notice
child session of an idle root 0.28.5 build root yes (ECHO: Plannotator session ready: …)
child session of an idle root this branch child none
background subagent this branch subagent none; decision delivered to root
main-session tool call this branch main (unchanged) none

In my harness the background-subagent run did not reproduce the leak on the 0.28.5 build. The root had already finished its turn before the notice arrived, so the notice was co-promoted with the subagent's completion. The smoke's leak needs that race. The child-session case reproduces it every time, and it is the same code path.

… root

On OpenCode 2 a plannotator tool call from a background subagent (or an
API-created child session) posted its session-ready notice into the ROOT
session, which was idle. The pending steer row was then promoted alone as
a model turn when the root next woke, and the model answered the URL line.

Tool launches now post the notice into the calling session, and only while
the tool call is still open, so it is always promoted inside the caller's
running turn and dropped from the model context as before. The decision
still goes to the root, queued. Slash commands are unchanged (#1515).
@backnotprop
backnotprop merged commit 06261c7 into main Oct 6, 2026
28 checks passed
backnotprop added a commit that referenced this pull request Oct 17, 2026
…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>
backnotprop added a commit that referenced this pull request Oct 17, 2026
…running turn (#1968)

* Inbox: say a sent reply is queued, and Send now delivers it into the 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>

* Inbox: keep the plannotator_inbox description under Claude Code's 2,048 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>

* Inbox: the sessions route's asker answer now carries connection

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

* Inbox: the phone list test reads the Sent row's new wording

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

* Inbox Send now: decide at the append, never into an Ask turn, confirm 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>

* Inbox Send now: OpenCode 2 never shows the model a reply twice; Queued 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>

* Inbox Send now: OpenCode 2 never gives up a steer mid-run, and a repeat 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).

* Inbox e2e: the 👍 line reads the session's connection (when its session is back, then is working, no Send now)

* Inbox: a claim the mod cannot use at once is given back, so the person'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.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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