ATC (Air Traffic Control) — the single operator-visible fleet supervisor, in
exactly one mode: `lite` (no LLM: deterministic scan, known-transient retry
policy, safety caps, evidence) or `full` (the persistent orchestrating brain).
Full mode is a plain `ainb` session (Claude or Codex) running a generated
policy, woken on a heartbeat (default 15 min) built from the LLM-free `fleet needs`
read — which is itself HOOKS-PRIMARY (event-sourced): ATC learns ASK/WAIT/IDLE
from the materialized `curre...
Installs into .claude/skills of the current project.
Are you the author of Atc?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/stevengonsalvez-atc)
---
name: ainb-fleet:atc
description: |
ATC (Air Traffic Control) — the single operator-visible fleet supervisor, in
exactly one mode: `lite` (no LLM: deterministic scan, known-transient retry
policy, safety caps, evidence) or `full` (the persistent orchestrating brain).
Full mode is a plain `ainb` session (Claude or Codex) running a generated
policy, woken on a heartbeat (default 15 min) built from the LLM-free `fleet needs`
read — which is itself HOOKS-PRIMARY (event-sourced): ATC learns ASK/WAIT/IDLE
from the materialized `current_state` table, not by scraping panes, with a
tmux fallback for non-Claude agents. `atc setup` installs the global Claude
Code hooks into ~/.claude/settings.json. It auto-clears the safe/blocked
sessions via the fleet verbs (confident ASK → broadcast; ERR → continue within
a retry cap) and escalates the uncertain ones to the phone bridge. Supersedes
the `daemon` skill for managed fleets. Use to provision / inspect / tear down
an ATC instance, or to switch which controller owns the fleet.
version: "0.1.0"
user-invocable: true
triggers:
- ainb-fleet:atc
- fleet atc
- air traffic control
- fleet brain
- conductor
- watch the fleet
- auto-clear and escalate
- lite mode
- full mode
- supervisor mode
allowed-tools:
- Bash
---
# 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.
```bash
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
```bash
# 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
```bash
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:
```bash
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
```bash
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)
```bash
# 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.
```bash
# 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`](../../../../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.md`
policy, 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
`setup` and wired into the heartbeat, upgrades ATC to event-driven for any
child spawned with `ainb 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. See
[`docs/atc-plumbing.md`](../../../../docs/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 `teardown`
to remove both.
- `--no-heartbeat` provisions 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).