# 15 — Fleet API

HTTP-поверхность демона для **операторских клиентов**: дашбордов, menu-bar-приложений, скриптов, будущих веб-гейтвеев. Отдаёт три вещи: **снапшот** состояния флота, **живой поток событий** и **команды** поверх существующих управляющих verb'ов. Всё, что нужно клиенту класса `iapeer list`, — без парсинга CLI-вывода.

Это **нормативный контракт**: клиенты fleet API (SwiftBar-плагины, нативные приложения, веб-страницы) пишутся по этому документу, а не по исходникам демона. Правила «Обязанности клиента» ниже — несущие: клиент, который их пропустит, сломается на будущем обновлении демона.

## Поверхность и транспорт

- Базовый путь: **`/fleet/v1`**, обычный HTTP + JSON (+ SSE для событий). Это *не* MCP: MCP (`/mcp`) — агентская поверхность и намеренно несёт единственный инструмент; fleet-клиенты — GUI и скрипты, потребляющие HTTP нативно.
- Обслуживается на **обоих** слушателях демона:
  - unix-сокет `~/.iapeer/state/iapeer/router.sock` — `curl --unix-socket ~/.iapeer/state/iapeer/router.sock http://iapeer/fleet/v1/snapshot`
  - TCP loopback — `http://127.0.0.1:8765/fleet/v1/snapshot`
- **Обнаружение:** читайте `~/.iapeer/state/iapeer/router.json`. Демон, отдающий этот API, объявляет там `"fleet": 1` — рядом с активными адресами `sock`/`tcp` и версией демона. Нет ключа `fleet` ⇒ демон без fleet API ⇒ клиент обязан деградировать (скрыться или уйти на CLI).
- **Аутентификация:** если демон настроен с bearer-токеном (`IAPEER_BEARER_TOKEN`), каждый fleet-запрос обязан нести `Authorization: Bearer <токен>` — тот же гейт, что у MCP, проверяется до диспатча. Без настроенного токена поверхность открыта локальному пользователю (тот же класс доверия, что CLI: loopback + same-uid сокет).
- **Без CORS-заголовков, намеренно.** Страница в браузере не должна уметь дёргать loopback-команды флота кросс-доменно. Веб-гейтвей — отдельное будущее решение с обязательной аутентификацией.

## Обязанности клиента (нормативно)

1. **Неизвестные виды событий ОБЯЗАНЫ игнорироваться.** Словарь `ev` растёт без смены версии (например, будущие `approval-request` / `approval-resolved`). Клиент, падающий на неизвестном `ev`, сломан по построению.
2. **Неизвестные JSON-поля ОБЯЗАНЫ игнорироваться.** Снапшот и каждый ответ развиваются **аддитивно** внутри `/fleet/v1`: новые поля появляются, существующие сохраняют смысл и тип. Удаление или смена типа означали бы `/fleet/v2`.
3. **События — at-least-once.** После SSE-переподключения (и вокруг ротации логов) клиент может увидеть событие дважды. SSE `id:` — epoch-ms события; используйте его (плюс равенство полей) для дедупликации на уровне отображения. Точного replay-курсора нет; после reconnect перечитайте снапшот и продолжайте с живых событий.
4. **Снапшот — состояние; события — сигналы изменений.** Целевой цикл: получить `/snapshot` один раз, подписаться на `/events`, перечитывать `/snapshot` по приходу события (дёшево; всплески коалесцируйте). Не восстанавливайте состояние флота из одних событий.
5. **Возрасты рендерит клиент.** `last_active_ms` — epoch-метка; тикающее «4m ago» рисуйте сами, а не поллингом снапшота каждую секунду.

## GET /fleet/v1/snapshot

Полное состояние флота. Строки пиров производит **та же in-process функция, что рендерит `iapeer list`** — совпадение с CLI обеспечено по построению.

```json
{
  "api": 1,
  "version": "0.4.63",
  "ts": "2026-07-05T18:00:00.000Z",
  "host": {
    "version": "0.4.63",
    "pid": 12345,
    "startedAt": "2026-07-05T09:00:00.000Z",
    "uptimeSecs": 32400,
    "memory": { "provider": "iapeer-memory", "version": "1.2.3", "heartbeatAgeSecs": 12 },
    "voice":  { "provider": "voice-connect", "version": "0.3.1", "heartbeatAgeSecs": null },
    "fda": true
  },
  "peers": [
    {
      "personality": "boris",
      "description": "Напарник Артура по мультиагентной системе",
      "intelligence": "artificial",
      "default_runtime": "claude",
      "cwd": "/Users/me/.iapeer/peers/boris",
      "runtimes": [
        { "runtime": "claude", "status": "live", "attached": true },
        { "runtime": "codex",  "status": "asleep" }
      ],
      "last_active_runtime": "claude",
      "last_active_ms": 1751731100000,
      "attached": true,
      "launchd_managed": false,
      "wake_policy": "warm"
    }
  ],
  "approvals": [
    { "id": "a1", "personality": "boris", "runtime": "claude", "kind": "circuit-breaker",
      "tool": "dangerous-rm", "summary": "rm -rf /tmp/x",
      "content": "cmd=\"rm -rf /tmp/x\" target=\"/tmp/x\"", "createdMs": 1751731100000, "expiresMs": 1751731400000 }
  ]
}
```

Заметки по полям:

- `runtimes[].status` — `live` (сессия поднята) · `asleep` (разбудится по требованию) · `stopped` (операторский стоп-флаг; демон будить не станет). Та же семантика глифов, что в CLI: `●` / `○` / `✕`.
- `runtimes[].attached` — присутствует (`true`) только когда к этой живой hosted-сессии подключён **человек-оператор**.
- `attached` — любой рантайм с подключённым человеком (удобство уровня пира).
- `launchd_managed` — жизненным циклом пира владеет launchd (H4): демон его не будит/не усыпляет, а команды wake/stop/start соответствующе защищены.
- `wake_policy` — `warm` (по умолчанию: пробуждение по сообщению, idle-reap, resume) или `ephemeral` (stateless-воркер: серийная очередь задач, die-after-reply).
- `queue_depth` — только у ephemeral-пиров: ожидающие задачи в серийных очередях пира.
- `host.memory` / `host.voice` — провайдерские слоты (`null` = слот пуст); `heartbeatAgeSecs: null` при непустом слоте = провайдер объявлен, но его демон не шлёт heartbeat.
- `host.fda` — Full Disk Access бинаря: `true` / `false` / `null` (неопределимо).
- Сам факт ответа демона **и есть** сигнал здоровья: булева `healthy` нет. Дашборд, не достучавшийся ни до одного объявленного адреса, обязан рисовать демона лежачим (поэтому же дашборд, питающийся только API, недостаточен, когда сломан сам демон — встроенный TUI сохраняет прямой in-process путь чтения ровно на этот случай).
- **LLM-модель пира отсутствует намеренно**: эффективная модель живой сессии — не наблюдаемый факт реестра (статические пины запуска существуют, но выдавать пин за «модель» — врать при рантаймном автопереключении). Станет наблюдаемой — появится аддитивным полем.
- `approvals` (top-level, docs/17-approval) — очередь ожидающих human-approval, ТЕ ЖЕ item'ы, что отдаёт `GET /fleet/v1/approvals`, в снапшоте — чтобы клиент, уже перефетчащий `/snapshot` на каждое событие, рендерил очередь без доп. вызова. **АДДИТИВНО + ОПУСКАЕТСЯ при пустой очереди** — клиент ОБЯЗАН трактовать отсутствие как пустую очередь (то же правило, что `approval_mode`). Поля item: `id` (broker-id, цель `POST /fleet/v1/approvals/<id>/(approve|deny)`), `personality` + `runtime` (чьё действие), `kind` (`tool`|`plan`|`question`|`circuit-breaker`), `tool` (имя инструмента / breaker'а), `summary` (однострочник для бейджа), `content` (ПОЛНОЕ дословное действие — команда / диф / план; крит №7), `createdMs` / `expiresMs`. Item брокера — суперсет: неизвестные лишние поля (`title`, `approvers`) ОБЯЗАНЫ игнорироваться (обязательство 2).

## GET /fleet/v1/peers/&lt;personality&gt;

Карточка одного пира: тот же объект, что строка снапшота, плюс его недавняя история.

```json
{ "api": 1, "version": "0.4.63", "ts": "…", "peer": { …строка снапшота… },
  "events": [ { "src": "lifecycle", "ev": "wake", "ts": "…", "personality": "boris", "mode": "resume" } ] }
```

`events` — новейшие ≤50 событий, касающихся пира (матч по personality / полям идентичности `<runtime>-<personality>`), слитые по времени из всех логов, старые первыми. `404` для незарегистрированного пира.

## GET /fleet/v1/events — SSE-поток

`text/event-stream` событий флота, источник — tail-follow **долговременных логов** демона (`lifecycle.log`, `delivery.log`, `exits.log`). Эти файлы пишут *все* участвующие процессы — демон, CLI-verb'ы (attach/stop/wake), pty-супервизоры, — поэтому поток покрывает события, которые внутридемонная шина бы пропустила.

```
GET /fleet/v1/events?replay=50
```

- `replay=N` (0…500, по умолчанию 0) — выдать новейшие N исторических событий перед переходом в live.
- Каждое событие:

  ```
  event: wake
  id: 1751731100123
  data: {"src":"lifecycle","ev":"wake","ts":"2026-07-05T18:00:00.123Z","personality":"boris","mode":"resume"}
  ```

  `data` — полностью разобранная строка лога: `src` (`lifecycle` | `delivery` | `exits`) + `ev` + все logfmt-поля с раскодированными кавычками. `id` = epoch-ms события.
- Комментарий `: connected` отмечает переход от replay к live; комментарии `: hb` каждые ~15 с держат соединение.
- Латентность: живые события всплывают в пределах ~0.5 с (поллинг хвоста лога).

Текущий словарь `ev` (растёт со временем — см. обязанность 1): `wake`, `supervise` (с `action`: `reaped-idle` = парковка, `reaped-ephemeral`, классификация `dead`→fresh/resume, `eager-orphan-fresh`, `skipped-error`…), `stopped` / `started` (verb'ы stop/start — оператор изменил ✕/○-состояние пира; `action` — `stopped`/`started` для warm-рантаймов, `bootout`/`bootstrap` для always-on, с `reason` при неудаче), `delivery`, `topic-note`, `ephemeral-drain`, `attach` / `attach-end`, `hosted-deliver`, `pty-host-failed`, `supervise-error`, `composer-queue-failed-notify`, `memory-provision`, `session-exit` (из `exits.log`: `cause=child-exit` с `dead_status`/`dead_signal`, когда процесс сессии умер сам; `cause=shutdown-sigterm`/`shutdown-sigint`, когда супервизор был погашен — reap/stop/new), `supervisor-uncaught` (пережитый сбой супервизора, оставленный как форензика; сессия продолжает работать).

Как выглядят главные состояния на потоке: **пробуждение** пира — `ev=wake`; **парковка в сон** — `ev=supervise action=reaped-idle`; операторский **stop/start** — `ev=stopped` / `ev=started`; **смерть** — `ev=session-exit` (немедленно, от супервизора), затем классификация демона на его следующем проходе.

## Команды

`POST /fleet/v1/peers/<personality>/<команда>` с необязательным JSON-телом `{ "runtime": "claude", "topic": "…" }` (оба опциональны; `topic` использует только `wake`). Команды — это **существующие управляющие verb'ы, вызванные in-process**: каждый гвард (H4 read-only для launchd-пиров, fleet-guard чужих плистов, защита от crash-loop, сериализация пробуждений) живёт внутри самих verb'ов и через API не обходится.

| Команда | Семантика verb'а | Успех |
|---|---|---|
| `wake` | поднять пира (без сообщения; fresh-vs-resume решает демон) | `200` + исход, `502` + `status:"FAILED"` |
| `stop` | поставить стоп-флаг / bootout always-on пира | `200` + `outcomes[]`, `502` при неудачном bootout или отказе на чужом плисте |
| `start` | снять стоп-флаг (warm-пиры становятся пробуждаемыми; сессия не запускается) / bootstrap always-on | `200` / `502`, зеркально `stop` |
| `new` | **безусловный** свежий рестарт (аварийный рычаг) | `200` + `action:"fresh"` |
| `refresh` | ленивая мягкая перезагрузка: fresh на следующем естественном пробуждении, без kill | `200` + `outcomes[]` |
| `interrupt` | прервать зависший/зациклившийся turn (Escape в живую сессию) | `200` / `502` |
| `compact` | компактифицировать резюмируемый диалог (честный отказ, когда его нет) | `200` + `action:"compacted"` / `502` |

`POST /fleet/v1/send` — доставить сообщение тем же маршрутом, что инструмент `send_to_peer` (wake-on-miss, очереди, delivery-лог — семантика идентична):

```json
{ "personality": "boris", "message": "…", "runtime": "claude", "topic": "…", "attachments": ["/tmp/report.pdf"], "from": "nova" }
```

`from` опционален: полный `<runtime>-<personality>` или голая personality (её default-рантайм). По умолчанию — **единственный natural-intelligence пир** реестра: сообщение из GUI принадлежит человеку. При нуле или нескольких natural-пирах запрос отклоняется (`400`) с указанием передать `from`. `attachments`, если передан, — тот же массив абсолютных путей источников, что и у MCP-инструмента; общий путь `routeSend` копирует их в inbox получателя до принятия.

Не входит в *командный* API, намеренно: **attach** (передача терминала — клиент открывает системный терминал с `iapeer attach <peer>`), реестровые/хостовые **admin-verb'ы** (`create`/`remove`/`update`/`uninstall` — добавятся, когда появится клиент с реальной потребностью). **Брокер human-approval** теперь живёт под `/fleet/v1/approvals*` (`GET` очередь / один item, `POST /<id>/(approve|deny)`) с видами SSE `approval-request` / `approval-resolved` и полем `approvals` в снапшоте выше — полный контракт в **docs/17-approval**.

Ошибки — всегда `{"error": "…"}` с честным статусом: `400` некорректный запрос, `401` требуется bearer, `404` неизвестный пир/эндпоинт, `405` не тот метод, `502` сам verb упал, `500` внутренняя.

## Совместимость

- `/fleet/v1` развивается **аддитивно**; `api: 1` в каждом снапшоте. Ломающие изменения означают `/fleet/v2`, поднятый рядом.
- Маркер capability в `router.json` (`fleet: 1`) — способ feature-detect: его отсутствие клиент обязан трактовать как «fleet API нет», а не прощупывать эндпоинты.
