Multiplexer backends
cafleet hosts every coding-agent member inside a terminal-multiplexer pane.
The multiplexer is abstracted behind the Multiplexer Protocol, 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 matrix
Where the two backends differ, behavior by behavior:
TmuxError and HerdrError are both subclasses of MultiplexerError. Each
behavior's full contract stays under its own heading below.
Backend selection
Every call site resolves its backend through one shared resolver 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.
The outcomes of that order:
The four override-set combinations collapse into the first two rows because an explicit override wins outright, whatever the environment holds.
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 the per-backend
subclasses in the backend matrix. 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, which adds a fourth wake trigger:
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. No DB column
backs the native status; the last-seen state lives only in the running loop's
memory. See Monitoring.
Synchronized monitor wake and fixed direct ping
The monitor loop remains one fixed-cadence
scan → one synchronized watcher wake → sleep path on both backends. It never
calls send_poll_trigger and never keystrokes a watched pane. unacked is
appended only as an annotation after interval, durable stall-check, and native
done trigger construction; a stale delivery cannot create a due row.
send_wake_trigger receives every due target plus the Director descriptor and
emits a pure trigger — the due list, the Director descriptor, and one
pointer sentence naming the monitoring member's role protocol; no protocol
clauses. Each rendered entry includes a sanitized name and sanitized
coding_agent=<claude|codex|opencode> so the monitoring member applies the
target's overlay cues; an entry or Director descriptor whose coding_agent is
not a supported backend name fails the wake closed. tmux and herdr emit the
payload byte-identically:
Example:
The monitoring member may invoke the existing cafleet member ping at most
once per confirmed quiet ordinary member, after two byte-identical stall-check
captures. The primitive is unchanged on both backends: Esc, a literal target
cafleet message poll, then Enter (a pending-placement target skips the
keystroke and succeeds). It cannot carry arbitrary text and, per the
monitoring member's role protocol, never targets the Director or the
monitoring member itself.
Anything needing Director attention travels as a plain per-event
cafleet message send to the Director — the same persisted queue and
Esc-safeguarded inline-preview path every fleet message uses.
Access mechanism
Each backend's access mechanism is in the backend matrix —
no new dependency either way; 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, per the backend matrix.
On herdr, <dir> is the invoking process's current working directory. herdr's own
inheritance is not relied upon because herdr spawns /bin/sh instead of the
passwd login shell when SHELL is unset
(herdr discussion #1517).
An unresolvable cwd fails the spawn loudly with HerdrError; there is no
fallback directory.
Delete-time pane layout
Closing a member pane leaves the two backends asymmetric on layout reflow, per the backend matrix.
herdr's kill_pane reads the target pane's tab (herdr pane get) before the
close, runs herdr pane close, then rebalances. The scoping comes from the
layout read itself: the killed pane is gone, so the rebalance picks a pane
still open in that tab (herdr pane list) and anchors the geometry read on it
(herdr pane layout --pane <surviving>), which returns that tab's layout
regardless of which tab or pane holds focus. When no pane remains in the tab,
there is nothing to rebalance and the step is skipped. 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. Any HerdrError during
the rebalance is swallowed: a layout failure never fails member delete — the
pane is closed and the member deregistered regardless.
Prompt dispatch (send_prompt)
cafleet member prompt delivers its keystrokes through the multiplexer
interface's send_prompt(target_pane_id, text, shell = false) operation.
Both backends validate fail-fast: text empty after strip →
send_prompt: text may not be empty; the original text containing \n or
\r → send_prompt: text may not contain newlines (raised as the backend's
native error type, TmuxError / HerdrError). The shell flag controls both
the payload prefix and the Esc safeguard; the per-backend payloads are in the
backend matrix. herdr's plain form mirrors
send_poll_trigger's esc-then-run shape.
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; the contract — one Esc-safeguarded submit of the
whole 2-line payload — is identical on both, and the per-backend realization is
in the backend matrix.
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 monitoring member's Director messages ride this same ordinary path — a
plain cafleet message send per event, with no monitor-specific delivery
state.
The Esc safeguard
Where a path leads with Esc, the mechanism is the same: it presses Escape,
lets the pane settle ~0.1 s, then types the payload and Enter.
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.