docs/adr/0161-a-fleet-seat-reads-its-mail-instead-of-its-keyboard.md

ADR 0161: A fleet seat reads its mail instead of its keyboard

Status: proposed (2026-09-06), for James to accept with the first message that lands as a channel event. Amends ADR 0135 (how send reaches a persona's pane) and extends ADR 0152 (the head's outbox and channel) to every Claude Code seat in the fleet.

Context

A message to a fleet agent — a DM from the app, or a group-chat turn — reaches its pane as keystrokes: herdr pane send-text writes the text into the pty and a second command presses Enter. The pty is the same stdin the operator's keyboard feeds. Nothing on either side can see the harness's composer, so a message that arrives while the operator is half-way through typing in that pane is appended to their draft and the Enter submits the mixture. Herdr cannot guard this: agent prompt refuses a blocked pane, but a draft leaves the pane idle and no metadata field exposes it.

Claude Code sessions already have an input that is not the keyboard. Its peer tools (SendMessage) and its channels research preview both deliver text as an inbound event that starts or queues a turn, with the composer untouched. The head seat uses exactly this: clankie mcp long-polls the seat outbox and pushes each wake, watch, or escalation as a notifications/claude/channel event (ADR 0152). Worker seats had no such door.

Decision

Every fleet seat has a mailbox, and a Claude Code seat reads it through a channel bridge of its own. The pty is the fallback, not the lane.

  • One mailbox per seat. The service keeps a SeatOutbox per seat id (the herdr terminal id sendToSeat already takes), created on first use. The outbox is the head's type, with bound meaning what it says: a poller is parked, or one returned within a two-second grace that covers the bridge's re-poll gap. A taken event is delivered only when the bridge comes back for more; a bridge that never returns within the grace settles it unbound and the caller runs the pty. A newer parked poll supersedes an older one, so a resumed session cannot have its mail taken by a stale one.
  • The pty is the fallback. The seat sender tries the mailbox first and types into the pane only when no bridge is bound. A Codex or pi seat, or a Claude Code pane launched without the channel, behaves exactly as before.
  • A message is its own event kind. OperatorSeatEventKind gains message; the event carries the conversation and a source of operator (a DM) or room (a group-chat turn). The head never receives one.
  • The bridge is channel-only. clankie mcp --seat serves no tools and no reply: a worker answers in its own transcript, which the transcript projection already harvests into the thread, so the reply path does not move. It identifies itself by HERDR_PANE_ID, which herdr sets in the pane and Claude Code passes to its MCP servers; the service resolves the pane to the seat. It polls with the operator bearer from the broker, like the head bridge, and reads only its own pane's mailbox.
  • 404 is an early state. The bridge starts before herdr has classified the harness, so unknown_seat is retried quietly rather than treated as a failure.
  • The hire path loads the channel. Channels are a per-launch opt-in with no persistent setting, and the server: form of the flag binds only a server in Claude Code's persisted config, never one handed over with --mcp-config (probed 2026-09-06: the harness starts the process but reports no MCP server configured with that name). So a seat hired from the app for the claude harness gets clankie-seat registered once at user scope (claude mcp add -s user clankie-seat -- clankie mcp --seat) and is started with --dangerously-load-development-channels server:clankie-seat. That flag stops every launch at a "Loading development channels" dialog before the TUI is usable; the hire path reads the pane, recognizes that dialog and nothing else, confirms its preselected "local development" option, and waits for the agent to settle. The approved form, --channels server:clankie-seat, starts without the dialog but then rejects a server: entry as not on the allowlist, so the development flag is the one that binds. A pane the operator opens by hand gets the mailbox only if launched with the flag; otherwise it keeps the pty lane.
  • The bridge polls only when the channel is bound. A user-scope registration means every Claude Code session on the machine spawns the bridge, including ones started without the flag, whose harness would drop each notification on the floor while the mailbox read as bound — a black hole with no pty fallback. The bridge therefore reads its parent process's argv and polls only when that launch named server:clankie-seat to a channels flag; otherwise it serves an empty channel and never binds.

Consequences

  • A DM or room turn to a channel-loaded Claude Code seat never touches what the operator is typing there. The transcript, the thread, and the room's harvest are unchanged.
  • The fleet-wide SeatSender contract carries the conversation and source, so a mailbox event can say where it came from.
  • A pane moved across workspaces loses its mailbox until the harness is relaunched: herdr stamps HERDR_PANE_ID at spawn and a public pane id is per workspace, so the bridge keeps polling an id herdr no longer resolves and the seat falls back to the pty. Polling by the stable terminal id is the follow-up.
  • The route is a new power on the operator bearer: any process holding it can dequeue any seat's mailbox, where ADR 0135 only let it send. Every such process is the owner's on the owner's machine, which is why the head outbox accepted the same bearer; a per-seat poll token minted at hire is the follow-up that closes it.
  • A message queued but not yet taken when the service restarts is typed into the pty by the fallback, the one window where the draft is still at risk.
  • Room harvest is unchanged and unchanged means the pre-existing weakness stays: awaitSeatReply takes the seat's next agent sentence from any thread, so a preamble before a tool call, or a concurrent DM reply, can be read as the room's answer. Correlating the waiter to the mailbox event is the follow-up.
  • Herdr reports a Codex session after the pane's first turn, so reading the rollout file is the interim; the session herdr reports is its replacement.
  • A Codex seat takes its message through codex queue --thread <session>, which the harness runs as its own user turn with the composer untouched (probed 2026-09-06). Herdr reports no session for Codex, so the service reads it off the rollout file the running codex process holds open, rollout-<timestamp>-<uuid>.jsonl, via the pane's foreground process and lsof. A pane that has never spoken has no rollout yet and keeps the pty lane; so does any failure to resolve or queue.
  • pi and Grok seats still take keystrokes. Extending delivery to them waits on those harnesses growing an out-of-band input.