Multiplexer backends¶
cafleet hosts every coding-agent member inside a terminal-multiplexer pane.
The multiplexer is abstracted behind the Multiplexer Protocol
(cafleet.multiplexer.base), so the spawn, keystroke-delivery, capture, and
teardown paths are backend-neutral. Two backends ship today: tmux and
herdr (herdr.dev). Both satisfy the same Protocol, so
every member * path behaves identically regardless of which one is active.
Pane ids are treated as opaque strings end to end — tmux ids look like %7,
herdr ids look like w1:p1; cafleet stores and passes them verbatim and never
parses them.
The pane is also cafleet's only push channel: message delivery stays pull-based
(recipients drain the persisted queue with cafleet message poll), and the
broker keystrokes a best-effort inline preview into the recipient's pane after
persisting a message — see Push notifications.
Backend selection¶
Every call site resolves its backend through resolve_multiplexer()
(API reference) rather than hardcoding one. Resolution
precedence:
- Explicit override. If
CAFLEET_MULTIPLEXERis set, it must name a supported backend (tmuxorherdr); an unknown value fails loudly. - Auto-detect from the environment.
HERDR_ENVtruthy signals a herdr session;TMUXset signals a tmux session. - Ambiguity is a hard error. Both
HERDR_ENVandTMUXset → error (setCAFLEET_MULTIPLEXERto disambiguate). Neither set → error (run cafleet inside a tmux or herdr session, or setCAFLEET_MULTIPLEXER). Exactly one present → that backend.
Auto-detect (an unset CAFLEET_MULTIPLEXER) is the default: absence is a valid,
well-defined state. cafleet doctor reports the resolved backend and its
identifiers (see CLI options).
Error taxonomy¶
Backend failures share a base MultiplexerError, with TmuxError and
HerdrError as backend-specific subclasses. CLI boundaries catch
MultiplexerError, so both backends' failures are handled uniformly while each
backend keeps its own message text.
Native agent-state (herdr only)¶
herdr natively tracks each agent's lifecycle state
(working/blocked/done/idle/unknown), exposed through a separate
optional capability Protocol, AgentStateAware, that only the herdr backend
implements — the base Multiplexer Protocol stays clean and tmux implements
nothing new.
On the herdr backend the monitor loop point-reads each watched member's native
agent status per tick and flags it due when the status transitions into done
— the sole wake-on-status state (_WAKE_ON_STATUS = ("done",)) — in addition
to the interval and stall-check triggers. A transition into blocked is
recorded but never flags a wake: a blocked member is awaiting a user answer and
must not be woken about. On the tmux backend the capability is absent, so
members come due by interval and stall-check only. No DB column backs the
native status; the
last-seen state lives only in the running loop's memory. See
Monitoring.
Access mechanism¶
The herdr backend uses the herdr CLI exclusively (subprocess), mirroring how
the tmux backend shells out to tmux — no new Python dependency; the binary is
expected on PATH. herdr also exposes a JSON unix-socket API whose only unique
capability is a push event stream; that would require a persistent connection
and a concurrent reader that cafleet's synchronous scan → wake → sleep
monitor loop does not have, so the socket stream is a deliberately-deferred
optimization.
Pane spawn working directory¶
A member pane spawned by cafleet member create starts in the invoking
process's working directory (the Director's pane cwd). Each backend realizes
this differently:
- tmux relies on the multiplexer's builtin cwd inheritance — a new pane starts in the splitting pane's working directory.
- herdr receives the member-create process's cwd explicitly: every
herdr pane splitcarries--cwd <dir>, where<dir>isos.getcwd()of the invoking process. herdr's own inheritance is not relied upon because herdr spawns/bin/shinstead of the passwd login shell whenSHELLis unset (herdr discussion #1517). An unresolvable cwd fails the spawn loudly withHerdrError; there is no fallback directory.
Delete-time pane layout¶
Closing a member pane leaves the two backends asymmetric on layout reflow:
- tmux issues a bare
kill-paneand relies on the multiplexer's native auto-fit — the remaining panes reclaim the freed space with no explicit layout step. - herdr has no native reflow, so
kill_panerestores the layout itself: it reads the target pane's tab (herdr pane get) before the close, runsherdr pane close, then rebalances best-effort, scoped to that tab. With ≥ 2 members remaining, the member column is re-equalized to equal heights (the same invariant the create path enforces); after the last member is deleted, the Director pane is explicitly restored to full tab width when the layout read shows a residual right split; a single remaining member needs no resize. The rebalance silently skips on unexpected layout shapes and whenever the focused-tab layout reports a different tab than the killed pane's — it never resizes an unrelated tab. AnyHerdrErrorduring the rebalance is swallowed: a layout failure never failsmember delete— the pane is closed and the member deregistered regardless.
Push notifications¶
CAFleet's delivery model is pull-based: recipients discover messages via
cafleet message poll. To cut latency, the broker keystrokes a 2-line inline
preview into the recipient's pane immediately after persisting a message, so
the recipient's coding agent consumes it as a fresh user-turn input:
The keystroke is dispatched through the resolved backend's
send_inline_preview helper — tmux realizes it with send-keys, herdr with
pane send-text + pane send-keys; the contract (one Esc-safeguarded submit
of the whole 2-line payload) is identical on both.
%%{init: {'theme': 'default', 'sequence': {'actorFontSize': 18, 'messageFontSize': 16, 'noteFontSize': 16, 'wrap': true, 'width': 180}}}%%
sequenceDiagram
autonumber
participant Sender
participant Broker
participant DB as SQLite
participant Pane
participant Recipient
Sender->>Broker: cafleet message send --to-member-id <recipient-id> --text <body>
Broker->>DB: INSERT messages (status=input_required)
Broker->>DB: SELECT placement.mux_pane_id
DB-->>Broker: pane_id
Broker->>Pane: keystroke inline preview
Pane-->>Recipient: text appears as user-turn input
Recipient->>DB: message ack → status=completed
The recipient pane is resolved from member_placements by member_id alone,
so Member → Director notifications work automatically. The recipient acks via
cafleet message ack --message-id <message_id> once it has consumed the message.
Body truncation in the preview (… at CAFLEET_MAX_TEXT_LEN codepoints) is
documented in CLI options.
The Esc safeguard¶
The preview keystroke leads with Esc (send_inline_preview is called
with esc_first=True): it presses Escape, lets the pane settle ~0.1 s, then
types the payload and Enter, so a recipient parked on a pending
permission-approval prompt has that prompt dismissed before the trailing
Enter lands. The same Esc-safeguarded path serves message send and
message broadcast. Two related keystroke paths differ:
cafleet member pinginjectsEsc→ a literalcafleet message pollcommand →Enter(thesend_poll_triggerhelper, alsoesc_first=True) — the manual re-poke for a pane that missed an inline preview.- The monitor loop's wake nudge targets only the monitoring member's own pane,
which is never parked on a permission prompt, so it does not lead with
Esc(see Monitoring).
Design principles¶
- Best-effort: the message queue remains the sole source of truth; a failed push leaves the message available for normal polling.
- Self-send skip: when sender == recipient, the notification is suppressed.
- Silent failure: missing placements, null
mux_pane_id, dead panes, and an absent multiplexer binary all result in no notification — no exceptions propagate to the caller. - No multiplexer env var required: the keystroke targets the pane by id
(tmux
send-keys -t <pane>, herdrpane send-*), which works from any process on the same host as long as the multiplexer's server is reachable.
Response annotations¶
Unicast responses include a top-level notification_sent boolean. Broadcast
responses expose recipients (the real recipient count) and delivered (how
many recipient panes were successfully triggered) as top-level wrapper fields.
Neither count is persisted — they live only in the broker return value.