A lane manager tried to message the hub and got this back:

bridge returned 409: not sent: a Cursor / VS Code tab (pid 4712) is running this session live; type there so it is not forked

msg hub "..." exited 1 and delivered nothing, because the hub was open in a Cursor tab. The live proof from an earlier ticket had only covered a recipient the bridge can type into. A Cursor tab was never exercised.

Where the 409 comes from

The path is session/msg.py send(), then handoff_spawn.post_message, then POST /api/bridge/:sid/message. The refusal is in the cockpit’s server/_core/index.ts, where host.kind === "foreign" becomes refuseSend(409).

The classification is in server/sessionHost.ts. Claude Code’s session registry lists a live claude.exe with entrypoint claude-vscode and no --remote-control. Only --remote-control daemons are classed “console”, which can be typed into. Every other live host is refused, because resuming it would fork a transcript a person is using.

So it was not a bug in the bridge. The fix went on the sender’s side, with no bridge change.

The inbox fallback

On that one 409, msg now writes the message to a per-session inbox under STATE_ROOT/msg-inbox/<sid8>/ and exits 3 with INBOXED ... state=queued. It is never a silent drop. Any other 409 or failure still exits 1. A new session/msg_inbox.py drains the inbox from the recipient’s own hooks. UserPromptSubmit injects the message into the next turn as context. Stop holds the current turn open once so the session reads it.

We proved the drain on a session we could reach. We queued a TEST message to our own sid, and the Stop hook delivered it at the end of the turn and moved it into delivered/:

[msg_inbox_stop] A message arrived for you while this turn was running.
TEST self-drain probe: reply SELF-DRAIN-SEEN

That session ran in a terminal, though, so it did not show delivery into a live Cursor tab. The hub’s 3 messages stayed in its inbox because it had not had a turn since the fix shipped. Later the hub’s own transcript showed them.

The send that never counted

The inbox path shipped, and the next problem came from a lane manager. Their msg hub returned INBOXED at 1:57 pm, and hourly-watch flagged that seat as missed from 1:58 pm on.

Hourly-watch decides whether a seat reported from STATE_ROOT/hourly_watch/sent.jsonl. last_sends() reads it at lib/hourly_watch.py:69, and watch(), which starts at line 151, uses it. Only hourly_watch.record_send() (line 55) writes it, and its one caller was the normal bridge path in session/msg.py, after the bridge delivered. Our new INBOXED branch returned exit 3 before reaching that call.

The live state showed it:

  • The recipient’s delivered/ directory held a message from that lane manager’s session sent at about 1:55 pm.
  • sent.jsonl had no line from that session at all. Its newest line was at 1:54 pm, from a different sid.
  • alerts.jsonl showed hourly-watch escalating that seat at 1:32 pm for “no hourly in 1 h 49 min”.

A seat that had reported was flagged as missed. Hourly-watch needed no change, because it never looked at delivery state, only at whether a send was logged.

The fix is in session/msg.py. A new _record_report(sender, target) wraps hourly_watch.record_send. It replaces the inline call on the normal path, with the same state != "failed" condition, and the INBOXED branch calls it too. The seam test sends through a stubbed foreign 409, then runs hw.watch and asserts it sends no missed ping. It goes red when the inbox-side record is removed.

Reviews and what is still unproven

A codex review and two manus rounds ran on the two commits. One manus finding said the 409-to-inbox fallback races the target session’s Stop hook. Applied fixes included hex-only sid directories and an atomic waiter write. We also added a test that two hooks draining at once deliver a message only once, and a pid-reuse check so a recycled process ID no longer makes a dead waiter look alive.

A subsessions --wait now leaves a record, and a resumed session gets one line with the exact command to re-arm any waiter that died. That line is covered by unit tests only. No live resume with an armed waiter was run.