# Ainb Fleet:atc

> 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.

- Skill: `stevengonsalvez/ainb-fleet-atc` (Agent Skill)
- Install (CLI): `npx skillmds@latest add stevengonsalvez/ainb-fleet-atc`
- Raw SKILL.md: https://api.skillmd.com/api/skills/stevengonsalvez/ainb-fleet-atc/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: stevengonsalvez (https://skillmd.com/u/stevengonsalvez)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/stevengonsalvez/ainb-fleet-atc

---


# 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).

