# 16 — Трей (дашборд флота в меню-баре)

Дашборд флота в меню-баре macOS — список пиров `iapeer list`, отрисованный как плагин
[SwiftBar](https://github.com/swiftbar/SwiftBar). Это **первый внешний клиент
[Fleet API](15-fleet-api.md)**: он находит демон через `router.json`, читает
`/fleet/v1/snapshot`, следит за `/fleet/v1/events` и шлёт команды через
`/fleet/v1/peers/<peer>/<cmd>` — целиком по HTTP, против нормативного контракта, без знания
внутренностей демона. Если демон и контракт разойдутся, трей — второй потребитель, который
это почувствует (первый — сам демон, который снапшот и пишет).

Упаковка: трей поставляется **вместе с фундаментом** — это ещё одно «лицо» демона, тот же
класс, что TUI `iapeer list`, а не отдельный проект. `iapeer install` кладёт файл плагина
(инертен, пока нет SwiftBar); `iapeer tray install` — разовая активация, которая доустановит
SwiftBar.app, если его нет.

## Что видно

- **Меню-бар:** глиф-антенна + число пиров с живой сессией. Когда есть ожидающие запросы
  **human-approval**, меню-бар вместо этого показывает **красный бейдж `N.circle.fill`** (iOS-look —
  красный круг с числом), чтобы владелец сразу видел ожидающий запрос. Если ни один объявленный
  адрес демона не отвечает — красный треугольник (демон недоступен; чисто API-дашборд иначе не
  покажет сломанный демон — встроенный TUI держит прямой путь чтения для этого случая, см.
  [15](15-fleet-api.md)).
- **Выпадашка:** шапка хоста (`iapeer <версия> · up <аптайм>`) и хартбиты провайдеров памяти
  и голоса, затем строка на пира, **двумя группами**: блок `🔒` **под launchd** (человеческий
  канал + служебная инфра — некликабельные статус-строки) вынесен **наверх единым непрерывным
  блоком, перед первым рабочим агентом**, независимо от live/asleep — так бар с одного взгляда
  отделяет «служебное, не трогать» от рабочих агентов. Рабочие агенты идут следом. Внутри
  **каждой** группы порядок: живой → спит → остановлен, затем по алфавиту.
  - `<peer>  <рантайм><глиф> …  <возраст>` с той же семантикой глифов, что в CLI —
    `●` живой · `○` спит · `✕` остановлен — зелёный, если хоть один рантайм живой. Бейджи:
    `👤` подключён человек, `🔒` под launchd (H4 read-only), `🛡` пир в режиме **gated**
    human-approval (docs/17; `yolo` — дефолт флота, без бейджа: голая строка читается как yolo),
    `⏳N` глубина очереди эфемерного пира.
  - **Каждый пир — ПОДМЕНЮ** — один единый список (без отдельной секции Manage; строки пиров и
    параллельный Manage-список всех пиров раньше были видимым дублем). Клик по пиру РАСКРЫВАЕТ
    его; действия — дочерние пункты, по порядку:
    - **Attach** (только агент-пиры) — ПЕРВЫЙ пункт. Запускает `iapeer tray attach-term <peer>`:
      пишет per-peer `.command`-лаунчер и `open`'ит его — `open <файл>.command` отдаёт файл
      Terminal.app **без запроса Automation/Accessibility** (намеренно обходит родной
      `terminal=true` SwiftBar, чей запуск Terminal бьёт `Cmd-T` кейстроком через System Events и
      молча не срабатывает без права Accessibility). Attach теперь = раскрыть-и-Attach (два клика) —
      плата за единый список без дубля.
    - **Lifecycle** — Stop / Start / New / Interrupt / Refresh / Compact, каждое —
      `POST /fleet/v1/peers/<peer>/<cmd>` через `iapeer tray cmd`. (Пункта Wake нет — путь клика по
      пиру и так будит спящего перед аттачем, отдельный Wake избыточен.)
    - **Переключатель approval-режима** (только агент-пиры, docs/17): читает текущий режим и флипает
      локальным verb'ом `iapeer approval-mode <peer> <mode>`, применяется на **следующей свежей
      сессии** пира (клик никогда не респавнит живую сессию). Трение асимметрично весу безопасности:
      `yolo → gated` (добавляет human-approval) — один безопасный тап; `gated → yolo` (СНИМАЕТ
      периметр approval) на уровень глубже за явным красным ⚠ confirm, чтобы не сбросить случайным
      кликом.

    **Launchd-инфра-пиры (telegram / notifier, `🔒`)** не pty-аттачатся — их подменю несёт **без
    Attach** и **без approval-переключателя** (всегда yolo), только lifecycle-команды (Stop / Start
    сервиса и т.д.).
- **Ожидающие подтверждения (docs/17):** когда очередь непуста, **вверху выпадашки** каждый
  ожидающий запрос показан **раскрытым, без доп. кликов** — заголовок (`<peer> · <tool>`),
  **дословное содержимое действия** (команда / диф / план, моноширинно, усечено для меню; ПОЛНОЕ
  содержимое — в `iapeer approvals`), затем пункты **Allow** / **Deny** прямо под ним. Клик запускает
  `iapeer tray approve|deny <id>` в фоне → брокер резолвит → стрим ре-рендерит (бейдж гаснет). Пир с
  ожидающим запросом **выделен в списке флота** (префикс `⚠`, красный счётчик `🔴N`, строка красная),
  чтобы владелец видел, *какой* пир ждёт. Канал **always-on** — очередь отражается всегда, когда
  непуста, независимо от прочего; пустая очередь — ни секции, ни бейджа. (Настоящая *пульс*-анимация в
  статичном SwiftBar-меню невозможна — highlight + бейдж это её эквивалент.)

Список обновляется сам: плагин **streamable** — подписан на поток событий и перечитывает
снапшот на каждое изменение (пробуждение пира, стоп/старт оператором, смерть), склеивая
всплески. Хартбит держит возрасты свежими и самозалечивает пропущенное событие; поток
переподключается через рестарты демона. Каждый выдаваемый блок меню предваряется сепаратором
`~~~` (дефолтный leading-режим SwiftBar = замена), включая первый — иначе ручной Refresh,
который `terminate()`'ит и заново `invoke()`'ит плагин НЕ очищая удерживаемый SwiftBar контент,
пришил бы перезапущенный блок и отрисовал бы весь дашборд дважды.

## Verb'ы

| Verb | Что делает |
|---|---|
| `iapeer tray install [--plugin-only]` | Доустановит SwiftBar.app, если его нет (санкция владельца), нацелит его на каталог плагинов, запишет плагин, запустит + обновит **и зарегистрирует LaunchAgent-супервизор tray-хоста** (автозапуск при логине + перезапуск после смерти). `--plugin-only` пишет только файл плагина (без приложения, запуска и автозапуска — так делает foundation-install). Идемпотентно. |
| `iapeer tray uninstall` | Удалит файл плагина, обновит SwiftBar **и снесёт LaunchAgent-супервизор tray-хоста**. **Флот не трогает** — демон, TUI и доставка живут; SwiftBar.app остаётся (может держать другие плагины). |
| `iapeer tray render [--stream]` | Печатает вывод плагина SwiftBar. `--stream` — streamable-цикл, который крутит плагин; одноразовая форма — поллинг-фолбэк и тест-поверхность. |
| `iapeer tray cmd <command> <peer> [runtime]` | POST fleet-команды (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — lifecycle-пункты меню зовут это. |
| `iapeer tray approve <id>` · `iapeer tray deny <id> [reason]` | Резолв ожидающего human-approval (docs/17) — `POST /fleet/v1/approvals/<id>/(approve\|deny)` через тот же unix-first fleet-клиент. Пункты Allow/Deny меню зовут это; инвариант единой очереди — резолв виден каждому каналу (CLI, telegram). |
| `iapeer tray status` | Только чтение: жив ли fleet API (из `router.json`), установлен ли SwiftBar и где его каталог плагинов, на месте ли файл плагина, **зарегистрирован ли супервизор tray-хоста** и **видима ли иконка в менюбаре прямо сейчас** (персистентное состояние `NSStatusItem VisibleCC` — ground truth, который читают первым при жалобе «иконка пропала»; экран не нужен). Ничего не чинит. |

## Как устроено

Файл плагина SwiftBar (`iapeer.10s.sh` в каталоге плагинов) — тонкий шим: метаданные SwiftBar
(`<swiftbar.type>streamable</swiftbar.type>`) плюс одна строка —
`exec "<~/.local/bin/iapeer>" tray render --stream`. Вся отрисовка, стриминг и команды живут
в скомпилированном бинаре (типизировано, покрыто тестами); `.sh` стабилен и логики не несёт,
поэтому `iapeer update` его не переписывает.

Каталог плагинов: уже настроенный `PluginDirectory` SwiftBar уважается (плагин ставится в
каталог пользователя); иначе — выделенный `~/.iapeer/tray/plugins/`, и SwiftBar нацеливается на
него через `defaults write com.ameba.SwiftBar PluginDirectory` — обычный строковый default,
который SwiftBar читает при запуске (он не sandboxed и не хранит security-scoped bookmark),
поэтому окно выбора папки пропускается.

Супервизия tray-хоста: активация трея регистрирует пользовательский LaunchAgent
`~/Library/LaunchAgents/com.agfpd.iapeer.tray.plist` (RunAtLoad **и KeepAlive**), который
крутит крошечный watchdog-цикл `/bin/sh`: раз в 5 с, если процесса SwiftBar нет — `open -g -b
com.ameba.SwiftBar` (на уже работающем приложении это no-op-активация — гонко-безопасно, дубль
инстанса невозможен; при отсутствии SwiftBar.app — бэкофф 5 мин). Так SwiftBar — а с ним
fleet-дашборд и бейдж аппрувов — сам поднимается и после перезагрузки, И за секунды после
краша или случайного kill. Трей — поверхность видимости флота для владельца; молчаливо
мёртвый SwiftBar прячет состояние всей системы, поэтому его жизнь супервизируется, а не
«запустили и забыли». Намеренное следствие: закрытый руками SwiftBar тоже перезапустится —
штатный выключатель это `iapeer tray uninstall`. launchd НЕ должен KeepAlive'ить сам `open`
(тот завершается сразу, как SwiftBar поднят — KeepAlive на нём это respawn-шторм); KeepAlive
сторожит долгоживущий процесс цикла. **LaunchAgent** выбран намеренно, не «Login Item» через
System Events и не SMAppService: Login Item требует, чтобы вызывающий процесс держал
Automation-TCC на System Events (грант атрибутируется тому родителю, что запустил verb —
хрупко в headless / `iapeer update` контексте), а SMAppService умеет регистрировать только
хелпер, который несёт *само* целевое приложение (сторонний SwiftBar так из CLI не
зарегистрировать). LaunchAgent не требует TCC и переиспользует идемпотентный plist-жизненный
цикл foundation: plist байт-стабилен (пишется только при изменении), несёт sentinel владения
`com.iapeer.managed` и bootstrap'ится/пересоздаётся undead-safe — повторная активация не
плодит дубль, а foundation-install (`iapeer update`) апгрейдит plist уже зарегистрированного
агента на месте (байты изменились → пере-cycle). `tray uninstall` делает bootout и удаляет
plist (только наш файл с sentinel — чужой plist на этом лейбле не трогается). Лейбл живёт в
собственном namespace foundation `com.agfpd.*`, никогда не `com.iapeer.<personality>` (флот
persistent-пиров), поэтому H4-страж launchd-owned пиров не может принять его за пира.

Авторизация — как у Fleet API: на open-local хосте bearer не нужен; если демон настроен с
`IAPEER_BEARER_TOKEN`, окружение плагина тоже должно его нести.

Конфиг: `IAPEER_TRAY_HEARTBEAT_MS` настраивает каденцию хартбита (по умолчанию 15 с, зажато
1 с…10 мин).

## Видимость иконки (почему стрим пишет квантами по 512 байт)

SwiftBar v2.0.1 декодирует **каждый чанк пайпа независимо** (`RunScript.swift`:
`String(data: availableData, encoding: .utf8)` на каждый readability-коллбек). Граница
чанка внутри многобайтового UTF-8-символа даёт `nil`; `StreamablePlugin` трактует `nil`
как «очисти контент», а `MenuBarItem._updateMenu` отвечает на пустой контент `hide()` —
`NSStatusItem` исчезает из менюбара. Хуже: у айтема есть `autosaveName`, и AppKit
**персистит** скрытость (`NSStatusItem VisibleCC <plugin>` = 0 в defaults SwiftBar) —
иконка остаётся скрытой через рестарты SwiftBar, пока какой-нибудь следующий апдейт не
позовёт `show()`.

Меню-блок трея — десятки килобайт текста, насыщенного эмодзи и бокс-глифами; один
`write(2)` доезжает до читателя чанками по заполнению пайп-буфера (замерено живьём:
16384+16384+2400 — границы побайтово произвольны), поэтому при статичном флоте одна и та
же плохая граница повторялась каждый хартбит и иконка была невидима часами (инцидент
владельца 13.07.2026, детерминированно воспроизведён probe-плагином, разрезавшим эмодзи
между двумя write).

Класс закрыт двумя слоями:

1. **Атомарные UTF-8-кванты** — `tray render --stream` пишет вывод кусками ≤512 байт
   (`PIPE_BUF`: записи не больше этого размера в пайп атомарны, всё-или-ничего), каждый
   заканчивается на границе UTF-8-символа. В пайп-буфере тогда лежат только целые
   кванты, и любой чанк, который SwiftBar может увидеть, — валидный UTF-8: nil-декод
   невозможен по построению.
2. **Штамп свежести** — каждый отрендеренный блок (и каждый блок daemon-down) несёт в
   футере часы `HH:MM:SS`, так что соседние emit'ы всегда различаются и SwiftBar гоняет
   свой update-путь (начинающийся с `show()`) на каждом хартбите. Любая скрытая иконка —
   чем бы её ни скрыло (случайный ⌘-drag, залипший персистентный флаг `VisibleCC`,
   будущая причуда SwiftBar) — восстанавливается за один хартбит (≤15 с), а не висит
   невидимой, пока флот статичен.

## Обязанности клиента

Поскольку трей — клиент Fleet API, он соблюдает нормативные правила из
[15 — Fleet API §Client obligations](15-fleet-api.md): неизвестные виды `ev` и неизвестные
поля JSON игнорируются (аддитивная эволюция), события at-least-once (снапшот — состояние,
события — подсказки об изменении), а отсутствие маркера `fleet: 1` в `router.json` означает
«нет fleet API» — трей деградирует в состояние «демон недоступен», а не зондирует эндпоинты.

## Границы

- **Снос GUI ≠ снос флота.** Удаление плагина (или выход из SwiftBar) оставляет демон, флот
  и CLI/TUI полностью рабочими — дашборд опционален.
- **Attach — не команда API**, это терминальная передача, которую выполняет клиент.
- Нативное приложение, питомец-оверлей и кнопки подтверждений — более поздние фазы (нативное
  Swift-приложение `iapeer-tray` рассматривается, только если версии на SwiftBar не хватит);
  все они — тоже тонкие клиенты того же Fleet API.
