The message queue

August 6, 2026 · View on GitHub

Every agent runs a real CLI in a real PTY. That CLI has exactly one input line, and you are not the only one who wants it: you type into the terminal directly, and the harness also has messages of its own to deliver. This document is the contract for who gets that line and when.

Two different queues get called "the queue". They are not the same thing:

NameLives inHolds
MD queuethe harness (zustand store, per agent)messages parked by Munder Difflin until that agent's terminal is free
Claude queueinside Claude Code itselftext Claude Code has accepted but not started on yet

Everything below is about the MD queue. The harness cannot see the Claude queue and never reasons about it.


1. One gate

One place types automatic messages into a live agent's PTY: the drain loop, useHive.ts effect #4. Everything that wants to reach a running agent enqueues into the MD queue and lets the drain decide when.

(The single exception is the god agent's boot sequence, useHive.ts:284, which writes its remote-control command and orientation prompt directly. That PTY was spawned milliseconds earlier and is covered by the boot-grace window, so there is no user draft it could land on.)

composer / Slack ingress ─┐
inbox nudge (effect #3) ──┼──▶ enqueueMessage(agentId, text) ──▶ MD queue ──▶ drain (#4) ──▶ PTY
scheduled /compact (#6) ──┘

This is load-bearing. When the inbox nudge wrote straight into the terminal, it was a second writer with its own idea of when the prompt was free — and its text landed on top of a half-written line, so the nudge and the user's sentence were submitted together as one garbled prompt. Routing it through the queue means one loop owns every "is this terminal free?" decision, and the nudge needs no prompt logic of its own.

The drain runs on every store change (debounced 200 ms, so a burst of PTY output coalesces) plus a 3 s backstop tick.

2. What the drain checks

The front of an agent's MD queue is delivered only when all of these hold:

ConditionWhy
agent status is idledon't interrupt a running turn
auto-delivery not paused — or the message was manually releasedfloor-wide switch (Command Center); see below
past the boot grace windowthe CLI is still painting its banner
isTerminalAutomationSafe(ptyId)the user owns the prompt — see below
4.5 s since the last delivery to this agentback-to-back sends jam the TUI

Manual release (v0.3.5). While floor-wide auto-delivery is paused, every queued row shows a send now link. Clicking it sets manual: true on that message and moves it to the front of its queue. The drain then bypasses only the pause check for that message — every other condition in this table still applies, so a manually released message waits for idle, respects your draft/picker, and is acknowledged the same way. The pause gate holds everything else untouched.

Delivery is acknowledged only after both PTY writes (the text, then the submit) have succeeded. A failure leaves the message visible in the queue and it retries.

3. The user owns the prompt

isTerminalAutomationSafe is the gate that protects your typing. It refuses delivery while any of these is true (terminalAutomation.ts):

BlockSet byCleared by
exitedthe PTY diedrespawn
pickeryou submitted a bare /model-style command that opens a menuan Enter / Escape / Ctrl-C typed into that terminal, or expiry
draftyou have unsubmitted text on the promptsubmitting or clearing it, or expiry
settlinga short repaint window after the line was freedtime

Both picker and draft expire after 30 minutes (STALE_PICKER_MS, STALE_INPUT_MS). The expiry exists because both flags are inferred, not reported — a picker closed some way we can't observe, or a draft flag left set by a TUI that swallowed keys, would otherwise wedge that agent's MD queue for the rest of the session.

Two rules about what happens when a block expires:

  • Automation never erases your text. Expiry means the queued message is typed after whatever is on the line; the two fuse into one prompt. An earlier version sent Ctrl-U first, which silently destroyed real drafts that had merely been left alone for a minute.
  • Automation never closes your menu. We do not send Escape at a picker. You may have opened it deliberately and stepped away; taking it down to make room for a queued message is not the harness's call, and we cannot verify that Escape worked anyway. The composer has a button that does it — because then you asked.

Both windows are long on purpose. Treating a live draft as abandoned is the expensive mistake; leaving a queued message parked a while longer is the cheap one.

4. Reading the prompt instead of modelling it

inputDirty is inferred by counting keystrokes in term.onData. That model drifts: a TUI that eats keys for its own UI leaves the count above zero while the prompt is visibly empty — a phantom draft, which blocks the MD queue against text that does not exist.

xterm already holds the rendered screen, so promptLineHasText reads it directly (term.buffer.active, the row at baseY + cursorY, stripped of prompt chrome).

It is deliberately one-directional: the buffer read can only clear a draft, never invent one.

  • screen says empty ⇒ believe it, drop the block
  • screen says text, or cannot be read ⇒ fall back to the keystroke count

The asymmetry is the point, because the two mistakes don't cost the same. A wrong "empty" opens the gate and fuses a queued message onto what you're writing. A wrong "has text" only parks that message until the draft expires. So the cheap mistake is the one we allow to happen.

One case where the screen is not evidence at all: inputDirty is set the moment you press a key, but the character only reaches xterm's buffer after the PTY echoes it back. Inside that gap the buffer is still showing the previous state, so a read of a freshly started draft would return "empty" — the expensive direction. A read within ECHO_GRACE_MS (1 s) of the last keystroke therefore returns "don't know" and clears nothing.

5. Seeing why nothing is being delivered

A held MD queue used to look exactly like an idle agent with nothing to do. The typing badge (--cth-status-typing, label "your draft") fixes that: it renders on agent cards and in the fullscreen roster whenever hasTerminalDraft(ptyId) is true.

It is not an agent status and is never stored on the agent — the PTY parser owns that field and would overwrite it. It is derived at render from the same draft detection the gate uses, so the badge is reporting the same draft the gate sees.

It does not apply the 30-minute expiry the gate applies. Past that window the gate starts delivering while the badge still reads "your draft" — which is honest: your text really is still sitting on the prompt. The badge answers "is my text there", not "is the queue blocked".

6. Where the code lives

FileRole
src/renderer/src/components/terminalAutomation.tspure policy — blocks, expiry windows. No DOM, fully unit-tested (test/terminal-automation.test.cjs)
src/renderer/src/components/terminalPool.tsthe pooled xterm per PTY; buffer reads, latches, isTerminalAutomationSafe, hasTerminalDraft
src/renderer/src/hooks/useHive.tseffect #3 inbox nudge (enqueues), effect #4 drain (the one writer), effect #6 scheduled /compact
src/renderer/src/store/store.tsthe MD queues themselves + agent persistence