---
title: "Bridge 409 for Session Running in claude-vscode Entrypoint"
canonical: https://dxdev.com/blog/refused-send-to-live-tab-inbox/
datePublished: 2026-10-22
---
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](/blog/2026-09-26_hub-doorway-sub-sessions/) 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.
