ainb fleet:atc — Air Traffic Control
ATC is the orchestrating brain that ties the fleet verbs together on a schedule
with a policy. It is a real ainb run Claude session reading a generated
CLAUDE.md, woken every N minutes by an OS timer. On each heartbeat it pulls
the LLM-free ainb fleet needs --format json read, auto-clears the safe
cases itself, and pages you (via the phone bridge) only for the calls that
genuinely need a human.
hooks ─▶ events.jsonl ─▶ notifyd ─▶ current_state (ASK/ERR/WAIT/IDLE/RUNNING/DONE)
│ (materialized per session)
OS timer ──[HEARTBEAT]──▶ ATC session ──reads──▶ ainb fleet needs --format json
│ (hooks-primary, tmux fallback)
├─ auto-clear ─▶ ainb fleet broadcast (answer / continue)
└─ escalate ───▶ phone bridge ─▶ your phone
How ATC learns a session is blocked — HOOKS-PRIMARY
The fleet needs read ATC runs each heartbeat is event-sourced, not pane-
scraped. Claude Code hooks append lifecycle events to events.jsonl; notifyd
ingests them into a SQLite current_state table that materializes the latest
stage per session (ASK / ERR / WAIT / IDLE / RUNNING / DONE). ATC reads that
materialized state as the PRIMARY source — so it learns ASK/WAIT/IDLE/ERR from
hooks, the instant they fire, without scraping panes. The tmux pane scan is
only the fallback for sessions the hooks don't cover: non-Claude agents
(Codex/Gemini fire no Claude hooks) and any session not yet materialized.
ainb fleet atc setup is what installs those global hooks into
~/.claude/settings.json (the --no-hooks flag skips it). The consumer skills
(needs, fleet-needs, standup) do NOT install hooks — they only read the
materialized state. So provisioning ATC is also what turns the whole fleet
hooks-primary; without it (or without atc setup's hook install), fleet needs
falls back to the live pane scan everywhere and still works.
One supervisor, one mode
ATC is the single operator-visible supervisor for a fleet, and it is in exactly
one mode at a time. The mode is persisted in meta.json, and both
controllers re-read it from disk before every send — so the losing one stands
down by itself, rather than relying on having been stopped in time.
┌──────────────────────────────────────────────┐
meta.json ─▶│ mode ──▶ owner ──▶ the ONLY sender │
└──────────────────────────────────────────────┘
lite ─▶ lite scanner no LLM · known transient ERR only · caps · reports
full ─▶ full heartbeat scheduled beat ─▶ LLM session ─▶ triage + coordinate
| lite | full | |
|---|---|---|
| LLM | none | one brain session, woken every N minutes |
| ERR (known transient) | auto-continue inside the retry cap |
same, decided by the brain |
| ASK / WAIT / IDLE | reported, never answered | triaged, answered, escalated |
| fleet coordination | none | yes |
| token spend | zero | every beat |
| provider | irrelevant (no brain) | must be one ainb can drive |
Both modes spend the same continue_counts ledger in
heartbeat-state.json, so switching never hands a permanently-broken session a
fresh set of retries.
ainb fleet atc mode tower # report the mode, provider, owner
ainb fleet atc mode tower --set lite # switch (explicit, persistent)
ainb fleet atc mode tower --set full --provider codex
The switch persists the mode BEFORE it stops anything, then dismantles the
outgoing controller and starts the incoming one. On the Daemons screen (d),
the ATC row offers the same switch and prints this help inline.
Full-mode providers
claude (default) and codex today. The list is read off each provider's
declared capability, not hard-coded: a provider ainb cannot both host a resident
session for AND inject a heartbeat into is refused, never provisioned as a
brain that would silently never wake. Codex gets its policy in AGENTS.md,
which is the file it actually reads.
Copilot and Antigravity are not offered yet — they implement no ATC control, so
--provider copilot fails with the list of what does work rather than
pretending.
This replaces ainb fleet daemon
Lite mode IS the standalone daemon's loop, moved inside ATC's safety rules: the
same scan, but capped, ledgered, and unable to run beside the heartbeat.
ainb fleet daemon still exists and still refuses to start against a fleet ATC
owns in either mode.
Provision
# Default: 15-min heartbeat, 60-min idle-pause, spawns the session + installs
# the OS timer (launchd on macOS, systemd --user timer on Linux).
ainb fleet atc setup tower
# Tune the cadence.
ainb fleet atc setup tower --interval 10 --idle-pause 30
# Provision files only (no OS timer, no session) — useful for inspection / CI.
ainb fleet atc setup tower --no-spawn --no-heartbeat
# Provision straight into lite mode (no LLM, no brain session, no timer).
ainb fleet atc setup tower --mode lite
# Full mode on Codex instead of Claude.
ainb fleet atc setup tower --provider codex
setup CARRIES an existing instance's mode and provider forward. It is what
ainb daemon atc start re-runs to respawn a dead session, so rebuilding them
from defaults would flip a deliberately-lite fleet back to a paying brain.
setup is idempotent: re-running re-renders CLAUDE.md + meta.json and
re-installs the timer, but never clobbers the accumulated state.json /
task-log.md. It writes to ~/.agents-in-a-box/atc/<name>/:
| file | role |
|---|---|
CLAUDE.md |
the rendered policy ATC reads (verbs, playbooks, safety, memory) |
meta.json |
{name, mode, provider, heartbeat_enabled, heartbeat_interval_min, idle_pause_min} |
AGENTS.md |
the same policy under the name a Codex brain reads (codex provider only) |
state.json |
ATC's durable machine state (retry counts, escalations) — survives compaction |
task-log.md |
ATC's human-readable running log |
Inspect
ainb fleet atc status tower # one instance: meta + timer + session liveness
ainb fleet atc list # all provisioned instances
ainb --format json fleet atc status tower # JSON for tooling
Repair a broken heartbeat
When status reports program MISSING (...) or list shows BROKEN, the unit
names a binary that no longer resolves on the PATH the timer carries (a
cargo-to-homebrew move, a changed PATH). Rebuild just the scheduler:
ainb fleet atc repair tower # rewrite the unit against the current PATH
ainb fleet atc repair tower --dry-run # report what it would do, write nothing
AINB_BIN=/opt/homebrew/bin/ainb ainb fleet atc repair tower # pin an explicit path
repair READS meta.json and never writes it, so a customised
--interval / --idle-pause and a disabled heartbeat all survive. That is why
it exists instead of "just re-run setup": setup rebuilds meta.json from
defaults, rewrites CLAUDE.md and the settings.json hooks, and spawns a
session.
It refuses (non-zero exit, nothing written) when the rebuilt unit still could not fire, so it never overwrites a working unit with a dead one. If the daemon takes the heartbeat it removes the local timer, and if it does not it clears any stale daemon registration, so exactly one scheduler ever fires.
--dry-run writes nothing and is safe as a health gate, with one property
stated precisely: it is never greener than a real run, rather than
bit-identical to one. Whether a real run hands the heartbeat to the daemon
depends on registration SUCCEEDING, which a read-only preview cannot determine
(registration also fails on generation conflicts, proto skew, and store
errors). So dry-run does not guess: it reports daemon_registered /
daemon_unregistered as null and previews the local-timer path, which is the
conservative branch and the one that can refuse. A green --dry-run therefore
implies a real run would not hit the refusal gate; a red one may still be
repairable if the daemon would have taken it.
The JSON surface carries activation_skipped, so a run under
AINB_TIMER_SKIP_ACTIVATION=1 (which writes the unit but never loads it) cannot
be mistaken for a live heartbeat by an agent or CI job reading the payload.
Tear down
ainb fleet atc teardown tower # remove the timer + kill the session (keeps state/log)
ainb fleet atc teardown tower --purge # also delete the instance dir
Teardown is idempotent and safe when nothing is installed — it removes the OS timer cleanly, so there is no stale launchd/systemd unit left behind.
The heartbeat (internal)
# This is what the OS timer runs every N minutes — you do not call it by hand.
ainb fleet atc heartbeat tower
It builds the [HEARTBEAT ...] nudge from ainb fleet needs --no-enrich --format json (cheap, 0-token) and tmux-sends it into the ATC session, so ATC
spends tokens only deciding, not scanning. When the fleet is quiet it sends
a one-line idle ping; when the session is gone it no-ops harmlessly until
teardown.
Event-driven mode (the plumbing)
setup also installs an event-driven lifecycle hook set into
~/.claude/settings.json (skip with --no-hooks). With it, a child session
spawned via ainb run --parent <atc-name> commits its completion to ATC's
durable inbox the instant its turn ends — so ATC learns about a finished
child without waiting for the next heartbeat. On each heartbeat ATC drains its
inbox first and prepends those completions to the nudge (a pending completion
even overrides idle-pause). The poll-mode fleet needs scan stays as the
always-on fallback, so ATC behaves identically whether or not children have the
hooks — the plumbing is a pure drop-in enhancement.
# Spawn a child that reports completions back to ATC instance "tower":
ainb run --repo . --worktree --parent tower -p "fix the failing tests"
# Inspect / drain a parent's inbox directly (debug):
ainb fleet atc inbox peek tower
ainb fleet atc inbox drain tower
Full design + on-disk formats: docs/atc-plumbing.md.
The policy (what ATC decides)
The generated CLAUDE.md encodes a conservative, escalate-on-uncertainty
policy. Per signal:
| signal | ATC action |
|---|---|
| ASK (blocked on a question) | answer via broadcast only if confident; ambiguous / opinionated / irreversible → ESCALATE |
| ERR (API error) | send continue via broadcast, capped at 3 auto-continues per session; cap reached + still erroring → ESCALATE |
| IDLE (turn finished, silent) | leave it (work likely done); only nudge if mid-orchestration |
WAIT (WAITING: marker) |
unblock via broadcast if it is waiting on another session; waiting on you → ESCALATE |
ATC never auto-runs destructive actions (delete / force-push / merge / deploy / spend / touch secrets) — those always escalate.
Escalation
ATC's voice to you is the phone bridge (plugins/ainb-fleet/bridge/). It raises
a call by emitting a self-contained line the bridge surfaces:
NEED: <session> — <decision required> — <options>
Supersedes ainb fleet daemon
For any fleet ATC manages, it replaces the standalone daemon skill: the
daemon's blind 5-second regex auto-continue becomes ATC's ERR playbook, now
with the retry cap the daemon never had. Do not run ainb fleet daemon
against sessions ATC manages — they would race. The daemon skill remains in
place for unmanaged one-off recovery, marked superseded.
Safety model + limitations
- Safety is prompt-enforced. ATC's conservatism lives in the
CLAUDE.mdpolicy, not in a hard sandbox. The default is deliberately to escalate on any uncertainty; a wrong autonomous action is costlier than an extra page. - Poll-mode latency (mitigated by the plumbing). In pure poll-mode ATC
reacts at its next heartbeat, not the instant a session blocks. The plumbing
(hooks → status files → durable inboxes → Stop-hook drain), installed by
setupand wired into the heartbeat, upgrades ATC to event-driven for any child spawned withainb run --parent: completions arrive the instant the child finishes. It is a drop-in enhancement, not a prerequisite — ATC still works standalone in poll-mode for any session without the hooks. Seedocs/atc-plumbing.md. - tmux fire-and-forget. Like the rest of the fleet, sends have no ACK; ATC confirms effect on the next heartbeat read.
Caveats
- The heartbeat timer and the ATC session are independent: tearing down only the
session leaves the timer firing into a dead pane (it no-ops). Use
teardownto remove both. --no-heartbeatprovisions without a timer — you can drive heartbeats manually (ainb fleet atc heartbeat <name>) for testing.- One ATC instance manages the whole host fleet; run multiple named instances only if you deliberately want to partition (they will each see all sessions).