@agfpd/iapeer 0.4.63 → 0.4.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -94,3 +94,15 @@ Commands an agent runs in its own session (require `PEER_IDENTITY` in the enviro
94
94
  | `run-infra <name> <runtime>` | An infrastructure peer's entry point; launched from its launchd plist. |
95
95
 
96
96
  These two commands are invoked by launchd, not the operator directly.
97
+
98
+ ## Tray (menu-bar dashboard)
99
+
100
+ | Command | What it does |
101
+ |---|---|
102
+ | `tray install [--plugin-only]` | Install the SwiftBar fleet dashboard: install SwiftBar.app when absent (owner-sanctioned), write the plugin, launch + refresh. `--plugin-only` writes just the plugin file. Idempotent. |
103
+ | `tray uninstall` | Remove the plugin file + refresh SwiftBar. Never touches the fleet; leaves SwiftBar.app installed. |
104
+ | `tray render [--stream]` | Print the SwiftBar plugin output (`--stream` = the streamable loop the plugin runs). |
105
+ | `tray cmd <command> <peer> [runtime]` | POST a fleet command (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — the menu's lifecycle actions. |
106
+ | `tray status` | Read-only: fleet API reachability, SwiftBar install + plugin dir, plugin file presence. |
107
+
108
+ The tray is the first external [Fleet API](15-fleet-api.md) client — see [16 — Tray](16-tray.md).
@@ -106,9 +106,9 @@ GET /fleet/v1/events?replay=50
106
106
  - A comment `: connected` marks the transition from replay to live; `: hb` comments every ~15 s keep the connection alive.
107
107
  - Latency: live events surface within ~0.5 s (log-tail poll).
108
108
 
109
- Current `ev` vocabulary (grows over time — see obligation 1): `wake`, `supervise` (with `action`: `reaped-idle` = parked, `reaped-ephemeral`, `dead`→fresh/resume classification, `eager-orphan-fresh`, `skipped-error`…), `delivery`, `topic-note`, `ephemeral-drain`, `attach` / `attach-end`, `hosted-deliver`, `pty-host-failed`, `supervise-error`, `composer-queue-failed-notify`, `memory-provision`, `session-exit` (from `exits.log`, with `dead_status`/`dead_signal`).
109
+ Current `ev` vocabulary (grows over time — see obligation 1): `wake`, `supervise` (with `action`: `reaped-idle` = parked, `reaped-ephemeral`, `dead`→fresh/resume classification, `eager-orphan-fresh`, `skipped-error`…), `stopped` / `started` (the stop/start verbs — an operator changed a peer's ✕/○ state; `action` is `stopped`/`started` for warm runtimes, `bootout`/`bootstrap` for always-on, with `reason` on failure), `delivery`, `topic-note`, `ephemeral-drain`, `attach` / `attach-end`, `hosted-deliver`, `pty-host-failed`, `supervise-error`, `composer-queue-failed-notify`, `memory-provision`, `session-exit` (from `exits.log`: `cause=child-exit` with `dead_status`/`dead_signal` when the session's process died on its own, `cause=shutdown-sigterm`/`shutdown-sigint` when the supervisor was torn down — reap/stop/new), `supervisor-uncaught` (a survivable supervisor fault left as forensics; the session keeps serving).
110
110
 
111
- What the headline states look like on the stream: a peer **waking** is `ev=wake`; a peer **parked to sleep** is `ev=supervise action=reaped-idle`; a **death** is `ev=session-exit` (immediately, from the supervisor) followed by the daemon's classification on its next pass.
111
+ What the headline states look like on the stream: a peer **waking** is `ev=wake`; a peer **parked to sleep** is `ev=supervise action=reaped-idle`; an operator **stop/start** is `ev=stopped` / `ev=started`; a **death** is `ev=session-exit` (immediately, from the supervisor) followed by the daemon's classification on its next pass.
112
112
 
113
113
  ## Commands
114
114
 
@@ -0,0 +1,88 @@
1
+ # 16 — Tray (menu-bar fleet dashboard)
2
+
3
+ A macOS menu-bar dashboard of the fleet — the peer list of `iapeer list` rendered as a
4
+ [SwiftBar](https://github.com/swiftbar/SwiftBar) plugin. It is the **first external
5
+ client of the [Fleet API](15-fleet-api.md)**: it discovers the daemon via `router.json`,
6
+ reads `/fleet/v1/snapshot`, follows `/fleet/v1/events`, and issues commands over
7
+ `/fleet/v1/peers/<peer>/<cmd>` — all over HTTP, against the normative contract, with no
8
+ knowledge of daemon internals. If the daemon and the contract ever diverge, the tray is
9
+ the second consumer that feels it (the first being the daemon itself, which wrote the
10
+ snapshot).
11
+
12
+ Packaging: the tray ships **with the foundation** — it is another face of the daemon, the
13
+ same class as the `iapeer list` TUI, not a separate project. `iapeer install` drops the
14
+ plugin file (inert until SwiftBar is present); `iapeer tray install` is the one-time
15
+ activation that also installs SwiftBar.app when it is absent.
16
+
17
+ ## What you see
18
+
19
+ - **Menu bar:** an antenna glyph + the count of peers with a live session. When no
20
+ advertised daemon address answers, a red warning triangle instead (the daemon is down —
21
+ a purely API-fed dashboard cannot show a broken daemon any other way; the built-in TUI
22
+ keeps its direct read path for that case, see [15](15-fleet-api.md)).
23
+ - **Dropdown:** a host header (`iapeer <version> · up <uptime>`) and the memory/voice
24
+ provider heartbeats, then one row per peer:
25
+ - `<peer> <runtime><glyph> … <age>` with the same glyph semantics as the CLI —
26
+ `●` live · `○` asleep · `✕` stopped — green when any runtime is live. Badges: `👤`
27
+ a human is attached, `🔒` launchd-managed (H4 read-only), `⏳N` an ephemeral peer's
28
+ queue depth.
29
+ - **Click a peer → Terminal.app running `iapeer attach <peer>`.** Attach is a terminal
30
+ handoff the API deliberately keeps client-side (a PTY inside a GUI is out of scope);
31
+ the same row's submenu carries the lifecycle actions (Wake / Stop / Start / New /
32
+ Interrupt / Refresh / Compact), each a `POST /fleet/v1/peers/<peer>/<cmd>` via
33
+ `iapeer tray cmd`.
34
+
35
+ The list updates itself: the plugin is **streamable** — it subscribes to the event stream
36
+ and re-fetches the snapshot on every change (a peer waking, an operator stop/start, a
37
+ death), coalescing bursts. A ~15 s heartbeat keeps ages fresh and self-heals a missed
38
+ event; the stream reconnects across daemon restarts. No manual refresh needed (there is a
39
+ Refresh item anyway).
40
+
41
+ ## Verbs
42
+
43
+ | Verb | What it does |
44
+ |---|---|
45
+ | `iapeer tray install [--plugin-only]` | Install SwiftBar.app when absent (owner-sanctioned), point it at the plugin dir, write the plugin, launch + refresh. `--plugin-only` writes just the plugin file (no app, no launch — what the foundation install does). Idempotent. |
46
+ | `iapeer tray uninstall` | Remove the plugin file and refresh SwiftBar. **Never touches the fleet** — the daemon, TUI and delivery keep running; SwiftBar.app is left installed (it may host other plugins). |
47
+ | `iapeer tray render [--stream]` | Print the SwiftBar plugin output. `--stream` is the streamable loop the plugin runs; the one-shot form is the poll fallback and the test surface. |
48
+ | `iapeer tray cmd <command> <peer> [runtime]` | POST a fleet command (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — the menu's lifecycle actions call this. |
49
+ | `iapeer tray status` | Read-only: is the fleet API up (from `router.json`), is SwiftBar installed and where is its plugin dir, is the plugin file present. Repairs nothing. |
50
+
51
+ ## How it is wired
52
+
53
+ The SwiftBar plugin file (`iapeer.10s.sh` in the plugin dir) is a thin shim: SwiftBar
54
+ metadata (`<swiftbar.type>streamable</swiftbar.type>`) plus one line —
55
+ `exec "<~/.local/bin/iapeer>" tray render --stream`. All rendering, streaming and
56
+ command logic lives in the compiled binary (typed, tested); the `.sh` is stable and
57
+ carries no logic, so an `iapeer update` never has to rewrite it.
58
+
59
+ Plugin directory: an already-configured SwiftBar `PluginDirectory` is respected (the
60
+ plugin is installed into the user's own dir); otherwise a dedicated
61
+ `~/.iapeer/tray/plugins/`, and SwiftBar is pointed at it via
62
+ `defaults write com.ameba.SwiftBar PluginDirectory` — a plain-string default SwiftBar
63
+ reads on launch (it is not sandboxed and stores no security-scoped bookmark), so the
64
+ folder picker is skipped.
65
+
66
+ Auth follows the Fleet API: on an open-local host no bearer is needed; when the daemon is
67
+ configured with `IAPEER_BEARER_TOKEN`, the plugin's environment must carry it too.
68
+
69
+ Config: `IAPEER_TRAY_HEARTBEAT_MS` tunes the heartbeat cadence (default 15 s, clamped
70
+ 1 s…10 min).
71
+
72
+ ## Client obligations
73
+
74
+ Because the tray is a Fleet API client, it obeys the normative rules in
75
+ [15 — Fleet API §Client obligations](15-fleet-api.md): unknown `ev` kinds and unknown
76
+ JSON fields are ignored (additive evolution), events are at-least-once (the snapshot is
77
+ the state, events are change hints), and a missing `fleet: 1` marker in `router.json`
78
+ means "no fleet API" — the tray degrades to the daemon-down state rather than probing
79
+ endpoints.
80
+
81
+ ## Boundaries
82
+
83
+ - **GUI teardown ≠ fleet teardown.** Removing the plugin (or quitting SwiftBar) leaves the
84
+ daemon, the fleet and the CLI/TUI fully working — the dashboard is an optional face.
85
+ - **Attach is not an API command** — it is a terminal handoff the client performs.
86
+ - Native app, pet overlay and approval buttons are later phases (a native `iapeer-tray`
87
+ Swift app is considered only if the SwiftBar version proves insufficient); all would be
88
+ further thin clients of the same Fleet API.
package/docs/README.md CHANGED
@@ -77,6 +77,7 @@ The sections are layered: from "install and use" to "understand the architecture
77
77
  - [13 — Architecture and contract](13-architecture-and-contract.md) — for plugin and runtime-package developers
78
78
  - [14 — Configuration and logs](14-configuration-and-logs.md) — environment variables, logs, diagnostics
79
79
  - [15 — Fleet API](15-fleet-api.md) — the daemon's HTTP surface for operator clients (dashboards, tray apps, scripts): snapshot, event stream, commands
80
+ - [16 — Tray](16-tray.md) — the macOS menu-bar fleet dashboard (SwiftBar plugin), the first external Fleet API client
80
81
 
81
82
  ## Status
82
83
 
@@ -106,9 +106,9 @@ GET /fleet/v1/events?replay=50
106
106
  - Комментарий `: connected` отмечает переход от replay к live; комментарии `: hb` каждые ~15 с держат соединение.
107
107
  - Латентность: живые события всплывают в пределах ~0.5 с (поллинг хвоста лога).
108
108
 
109
- Текущий словарь `ev` (растёт со временем — см. обязанность 1): `wake`, `supervise` (с `action`: `reaped-idle` = парковка, `reaped-ephemeral`, классификация `dead`→fresh/resume, `eager-orphan-fresh`, `skipped-error`…), `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`, с `dead_status`/`dead_signal`).
109
+ Текущий словарь `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` (пережитый сбой супервизора, оставленный как форензика; сессия продолжает работать).
110
110
 
111
- Как выглядят главные состояния на потоке: **пробуждение** пира — `ev=wake`; **парковка в сон** — `ev=supervise action=reaped-idle`; **смерть** — `ev=session-exit` (немедленно, от супервизора), затем классификация демона на его следующем проходе.
111
+ Как выглядят главные состояния на потоке: **пробуждение** пира — `ev=wake`; **парковка в сон** — `ev=supervise action=reaped-idle`; операторский **stop/start** — `ev=stopped` / `ev=started`; **смерть** — `ev=session-exit` (немедленно, от супервизора), затем классификация демона на его следующем проходе.
112
112
 
113
113
  ## Команды
114
114
 
@@ -0,0 +1,83 @@
1
+ # 16 — Трей (дашборд флота в меню-баре)
2
+
3
+ Дашборд флота в меню-баре macOS — список пиров `iapeer list`, отрисованный как плагин
4
+ [SwiftBar](https://github.com/swiftbar/SwiftBar). Это **первый внешний клиент
5
+ [Fleet API](15-fleet-api.md)**: он находит демон через `router.json`, читает
6
+ `/fleet/v1/snapshot`, следит за `/fleet/v1/events` и шлёт команды через
7
+ `/fleet/v1/peers/<peer>/<cmd>` — целиком по HTTP, против нормативного контракта, без знания
8
+ внутренностей демона. Если демон и контракт разойдутся, трей — второй потребитель, который
9
+ это почувствует (первый — сам демон, который снапшот и пишет).
10
+
11
+ Упаковка: трей поставляется **вместе с фундаментом** — это ещё одно «лицо» демона, тот же
12
+ класс, что TUI `iapeer list`, а не отдельный проект. `iapeer install` кладёт файл плагина
13
+ (инертен, пока нет SwiftBar); `iapeer tray install` — разовая активация, которая доустановит
14
+ SwiftBar.app, если его нет.
15
+
16
+ ## Что видно
17
+
18
+ - **Меню-бар:** глиф-антенна + число пиров с живой сессией. Если ни один объявленный адрес
19
+ демона не отвечает — красный треугольник (демон недоступен; чисто API-дашборд иначе не
20
+ покажет сломанный демон — встроенный TUI держит прямой путь чтения для этого случая, см.
21
+ [15](15-fleet-api.md)).
22
+ - **Выпадашка:** шапка хоста (`iapeer <версия> · up <аптайм>`) и хартбиты провайдеров памяти
23
+ и голоса, затем строка на пира:
24
+ - `<peer> <рантайм><глиф> … <возраст>` с той же семантикой глифов, что в CLI —
25
+ `●` живой · `○` спит · `✕` остановлен — зелёный, если хоть один рантайм живой. Бейджи:
26
+ `👤` подключён человек, `🔒` под launchd (H4 read-only), `⏳N` глубина очереди эфемерного
27
+ пира.
28
+ - **Клик по пиру → Terminal.app с `iapeer attach <peer>`.** Attach — терминальная передача,
29
+ которую API намеренно оставляет клиенту (PTY внутри GUI вне рамок); подменю той же строки
30
+ несёт lifecycle-действия (Wake / Stop / Start / New / Interrupt / Refresh / Compact),
31
+ каждое — `POST /fleet/v1/peers/<peer>/<cmd>` через `iapeer tray cmd`.
32
+
33
+ Список обновляется сам: плагин **streamable** — подписан на поток событий и перечитывает
34
+ снапшот на каждое изменение (пробуждение пира, стоп/старт оператором, смерть), склеивая
35
+ всплески. Хартбит ~15 с держит возрасты свежими и самозалечивает пропущенное событие; поток
36
+ переподключается через рестарты демона. Ручной refresh не нужен (пункт Refresh всё же есть).
37
+
38
+ ## Verb'ы
39
+
40
+ | Verb | Что делает |
41
+ |---|---|
42
+ | `iapeer tray install [--plugin-only]` | Доустановит SwiftBar.app, если его нет (санкция владельца), нацелит его на каталог плагинов, запишет плагин, запустит + обновит. `--plugin-only` пишет только файл плагина (без приложения и запуска — так делает foundation-install). Идемпотентно. |
43
+ | `iapeer tray uninstall` | Удалит файл плагина и обновит SwiftBar. **Флот не трогает** — демон, TUI и доставка живут; SwiftBar.app остаётся (может держать другие плагины). |
44
+ | `iapeer tray render [--stream]` | Печатает вывод плагина SwiftBar. `--stream` — streamable-цикл, который крутит плагин; одноразовая форма — поллинг-фолбэк и тест-поверхность. |
45
+ | `iapeer tray cmd <command> <peer> [runtime]` | POST fleet-команды (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — lifecycle-пункты меню зовут это. |
46
+ | `iapeer tray status` | Только чтение: жив ли fleet API (из `router.json`), установлен ли SwiftBar и где его каталог плагинов, на месте ли файл плагина. Ничего не чинит. |
47
+
48
+ ## Как устроено
49
+
50
+ Файл плагина SwiftBar (`iapeer.10s.sh` в каталоге плагинов) — тонкий шим: метаданные SwiftBar
51
+ (`<swiftbar.type>streamable</swiftbar.type>`) плюс одна строка —
52
+ `exec "<~/.local/bin/iapeer>" tray render --stream`. Вся отрисовка, стриминг и команды живут
53
+ в скомпилированном бинаре (типизировано, покрыто тестами); `.sh` стабилен и логики не несёт,
54
+ поэтому `iapeer update` его не переписывает.
55
+
56
+ Каталог плагинов: уже настроенный `PluginDirectory` SwiftBar уважается (плагин ставится в
57
+ каталог пользователя); иначе — выделенный `~/.iapeer/tray/plugins/`, и SwiftBar нацеливается на
58
+ него через `defaults write com.ameba.SwiftBar PluginDirectory` — обычный строковый default,
59
+ который SwiftBar читает при запуске (он не sandboxed и не хранит security-scoped bookmark),
60
+ поэтому окно выбора папки пропускается.
61
+
62
+ Авторизация — как у Fleet API: на open-local хосте bearer не нужен; если демон настроен с
63
+ `IAPEER_BEARER_TOKEN`, окружение плагина тоже должно его нести.
64
+
65
+ Конфиг: `IAPEER_TRAY_HEARTBEAT_MS` настраивает каденцию хартбита (по умолчанию 15 с, зажато
66
+ 1 с…10 мин).
67
+
68
+ ## Обязанности клиента
69
+
70
+ Поскольку трей — клиент Fleet API, он соблюдает нормативные правила из
71
+ [15 — Fleet API §Client obligations](15-fleet-api.md): неизвестные виды `ev` и неизвестные
72
+ поля JSON игнорируются (аддитивная эволюция), события at-least-once (снапшот — состояние,
73
+ события — подсказки об изменении), а отсутствие маркера `fleet: 1` в `router.json` означает
74
+ «нет fleet API» — трей деградирует в состояние «демон недоступен», а не зондирует эндпоинты.
75
+
76
+ ## Границы
77
+
78
+ - **Снос GUI ≠ снос флота.** Удаление плагина (или выход из SwiftBar) оставляет демон, флот
79
+ и CLI/TUI полностью рабочими — дашборд опционален.
80
+ - **Attach — не команда API**, это терминальная передача, которую выполняет клиент.
81
+ - Нативное приложение, питомец-оверлей и кнопки подтверждений — более поздние фазы (нативное
82
+ Swift-приложение `iapeer-tray` рассматривается, только если версии на SwiftBar не хватит);
83
+ все они — тоже тонкие клиенты того же Fleet API.
package/docs/ru/README.md CHANGED
@@ -77,6 +77,7 @@ iapeer send assistant --message "привет, ты на связи?"
77
77
  - [13 — Архитектура и контракт](13-архитектура-и-контракт.md) — для разработчиков плагинов и рантайм-пакетов
78
78
  - [14 — Конфигурация и журналы](14-конфигурация-и-журналы.md) — переменные окружения, журналы, диагностика
79
79
  - [15 — Fleet API](15-fleet-api.md) — HTTP-поверхность демона для операторских клиентов (дашборды, tray-приложения, скрипты): снапшот, поток событий, команды
80
+ - [16 — Трей](16-tray.md) — дашборд флота в меню-баре macOS (плагин SwiftBar), первый внешний клиент Fleet API
80
81
 
81
82
  ## Статус
82
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.63",
3
+ "version": "0.4.65",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -253,12 +253,9 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
253
253
  killSession(sock, identity, env)
254
254
  const stderrText = (r.stderr ?? '').trim()
255
255
  const benign = r.status === 0 || r.status === 3 || /No such process/i.test(stderrText)
256
- out.push({
257
- personality,
258
- runtime: rt,
259
- action: 'bootout',
260
- reason: benign ? undefined : `launchctl bootout exited ${r.status}${stderrText ? `: ${stderrText}` : ''}`,
261
- })
256
+ const reason = benign ? undefined : `launchctl bootout exited ${r.status}${stderrText ? `: ${stderrText}` : ''}`
257
+ out.push({ personality, runtime: rt, action: 'bootout', reason })
258
+ appendLifecycleEvent(cfg.eventLogDir, { ev: 'stopped', identity, personality, runtime: rt, action: 'bootout', reason }, { env })
262
259
  } else {
263
260
  // A deliberate stop is a CLEAN PARK, not a death (stop→start must survive ≥
264
261
  // idle-reap): park-mark BEFORE the kill so the post-`start`
@@ -270,6 +267,12 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
270
267
  killSession(sock, identity, env)
271
268
  removeSessionState(cfg, identity)
272
269
  out.push({ personality, runtime: rt, action: 'stopped' })
270
+ // Durable trace (fleet-API follow-up): the verb was SILENT in lifecycle.log — a
271
+ // CLI stop changed the fleet state (● → ✕) with no event, so an SSE-fed client
272
+ // (tray class) showed a stale status indefinitely (no event ⇒ no snapshot
273
+ // re-read; only the initiator knew). One line per state-changing outcome; the
274
+ // refused-foreign-launchd branch changes nothing and stays silent.
275
+ appendLifecycleEvent(cfg.eventLogDir, { ev: 'stopped', identity, personality, runtime: rt, action: 'stopped' }, { env })
273
276
  }
274
277
  }
275
278
  return out
@@ -301,17 +304,17 @@ export function startPeer(personality: string, runtime: string | undefined, opts
301
304
  // sentinel fleet-guard + sandbox guard the raw spawn never had.)
302
305
  const r = launchctlBootstrap(personality, plist, env)
303
306
  const ok = r.state === 'loaded' || r.state === 'already-loaded' || r.state === 'skipped-sandbox'
304
- out.push({
305
- personality,
306
- runtime: rt,
307
- action: 'bootstrap',
308
- reason: ok
309
- ? undefined
310
- : `launchctl bootstrap FAILED${r.detail ? `: ${r.detail}` : ''} — peer not started; manual rescue: launchctl bootstrap gui/$(id -u) ${plist}`,
311
- })
307
+ const reason = ok
308
+ ? undefined
309
+ : `launchctl bootstrap FAILED${r.detail ? `: ${r.detail}` : ''} — peer not started; manual rescue: launchctl bootstrap gui/$(id -u) ${plist}`
310
+ out.push({ personality, runtime: rt, action: 'bootstrap', reason })
311
+ appendLifecycleEvent(cfg.eventLogDir, { ev: 'started', identity, personality, runtime: rt, action: 'bootstrap', reason }, { env })
312
312
  } else {
313
313
  clearStopped(cfg, identity)
314
314
  out.push({ personality, runtime: rt, action: 'started' })
315
+ // Durable trace — mirror of the `stopped` event in stopPeer (✕ → ○ is a state
316
+ // change an SSE-fed client must hear about).
317
+ appendLifecycleEvent(cfg.eventLogDir, { ev: 'started', identity, personality, runtime: rt, action: 'started' }, { env })
315
318
  }
316
319
  }
317
320
  return out
@@ -1162,7 +1165,9 @@ const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
1162
1165
  'no-telegram',
1163
1166
  'no-voice',
1164
1167
  'npx',
1168
+ 'plugin-only',
1165
1169
  'remove-codesign-identity',
1170
+ 'stream',
1166
1171
  'yes',
1167
1172
  ])
1168
1173
 
@@ -1272,6 +1277,10 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
1272
1277
  sig: 'supervisor up|start|attach|list|kill <sess> [runtime]',
1273
1278
  desc: 'DARK (cutover Block 2): detach-persistent pty-supervisor PoC port; serves nothing on the live fleet (throwaway validation only)',
1274
1279
  },
1280
+ {
1281
+ sig: 'tray install|uninstall|render [--stream]|cmd <c> <peer>|status',
1282
+ desc: 'the macOS menu-bar fleet dashboard (SwiftBar plugin) — first external Fleet API client; install activates it (installs SwiftBar when absent). See docs/16.',
1283
+ },
1275
1284
  ]
1276
1285
 
1277
1286
  /** Greedy word-wrap to `width` columns. A token longer than `width` keeps its own
@@ -2272,6 +2281,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2272
2281
  // docs on its OWN install, version = its binary). install runs from source, so
2273
2282
  // docs/ sits at <entry>/../../docs. Best-effort: never fails the install.
2274
2283
  const docs = scaffoldHostDocs('iapeer', join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'docs'), env)
2284
+ // Ship the tray face WITH the foundation (owner decision — same class as the
2285
+ // `iapeer list` TUI). Plugin-file ONLY: drop/refresh the SwiftBar plugin shim
2286
+ // (into a configured SwiftBar dir if present, else the dedicated ~/.iapeer dir).
2287
+ // Inert without SwiftBar; NEVER auto-installs the GUI here (that is the explicit
2288
+ // `iapeer tray install`). Best-effort — a hiccup never fails the foundation install.
2289
+ let trayLine = ''
2290
+ try {
2291
+ const { installTray } = await import('../tray/index.ts')
2292
+ const t = installTray({ env, installApp: false, launch: false })
2293
+ trayLine = ` tray plugin: ${t.wrote ? 'written' : 'unchanged'} → ${t.pluginFile} (${t.dir})\n`
2294
+ } catch (e) {
2295
+ trayLine = ` tray plugin: skipped (${e instanceof Error ? e.message : String(e)})\n`
2296
+ }
2275
2297
  const { path: plist, changed: plistChanged } = installDaemonPlist({ env })
2276
2298
  const signingLine =
2277
2299
  r.signing == null
@@ -2288,6 +2310,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2288
2310
  ` scaffold: ~/.iapeer/ ensured (peers/, state, logs, cache, runtimes)\n` +
2289
2311
  ` host doctrine: ${hostDoctrine.path}${hostDoctrine.created ? ' (stub created — fill it in)' : ' (kept)'}\n` +
2290
2312
  ` docs: ${docs.copied ? docs.dest : `skipped (${docs.reason})`}\n` +
2313
+ trayLine +
2291
2314
  ` daemon plist ${plistChanged ? 'written' : 'unchanged (byte-identical — no write, no BTM notification)'}: ${plist}\n` +
2292
2315
  ` (NOT loaded — a live daemon migration is a separate step: launchctl bootstrap gui/$(id -u) ${plist})\n`,
2293
2316
  )
@@ -2466,6 +2489,82 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2466
2489
  const { runSupervisorCli } = await import('../supervisor/index.ts')
2467
2490
  return await runSupervisorCli(rest)
2468
2491
  }
2492
+ case 'tray': {
2493
+ // Ф1 of iapeer-tray — the SwiftBar fleet dashboard, an EXTERNAL fleet-API
2494
+ // client (docs/15). Subcommands: render[--stream] · cmd · install · uninstall
2495
+ // · status. The plugin file is a shim that execs `tray render --stream`; all
2496
+ // rendering/streaming lives here (tested TS), the .sh carries only metadata.
2497
+ const tray = await import('../tray/index.ts')
2498
+ const sub = positionals[0]
2499
+ switch (sub) {
2500
+ case 'render': {
2501
+ if (flags.stream === true) {
2502
+ await tray.streamTray(env) // SwiftBar streamable loop — runs until the process is killed
2503
+ return 0
2504
+ }
2505
+ out(await tray.renderTrayOnce(env))
2506
+ return 0
2507
+ }
2508
+ case 'cmd': {
2509
+ const command = positionals[1]
2510
+ const peer = positionals[2]
2511
+ const runtime = positionals[3]
2512
+ if (!command || !peer) {
2513
+ return argErr(errOut, 'usage: iapeer tray cmd <wake|stop|start|new|refresh|interrupt|compact> <peer> [runtime]')
2514
+ }
2515
+ let r: Awaited<ReturnType<typeof tray.trayCmd>>
2516
+ try {
2517
+ r = await tray.trayCmd(env, command, peer, runtime)
2518
+ } catch (e) {
2519
+ errOut(`tray cmd: ${e instanceof Error ? e.message : String(e)}\n`)
2520
+ return 1
2521
+ }
2522
+ out(`${JSON.stringify(r.body)}\n`)
2523
+ return r.ok ? 0 : 1
2524
+ }
2525
+ case 'install': {
2526
+ // Explicit activation verb: installs SwiftBar.app when absent
2527
+ // (owner-sanctioned) + launches, unless --plugin-only (file only).
2528
+ const pluginOnly = flags['plugin-only'] === true
2529
+ const r = tray.installTray({ env, installApp: !pluginOnly, launch: !pluginOnly })
2530
+ out(
2531
+ `tray plugin ${r.wrote ? 'written' : 'unchanged'}: ${r.pluginFile}\n` +
2532
+ ` plugin dir: ${r.pluginDir} (${r.dir})\n` +
2533
+ ` SwiftBar: ${r.app}${r.appReason ? ` — ${r.appReason}` : ''}\n` +
2534
+ (r.launched ? ' launched + refreshed SwiftBar\n' : '') +
2535
+ (r.app === 'absent' || r.app === 'install-failed'
2536
+ ? ' (SwiftBar not active — run `iapeer tray install` to install it, then it appears in the menu bar)\n'
2537
+ : ''),
2538
+ )
2539
+ return 0
2540
+ }
2541
+ case 'uninstall': {
2542
+ const r = tray.uninstallTray({ env, launch: true })
2543
+ out(
2544
+ (r.removed.length ? `removed:\n${r.removed.map(f => ` ${f}`).join('\n')}\n` : 'no tray plugin file found\n') +
2545
+ (r.refreshed ? ' refreshed SwiftBar\n' : '') +
2546
+ ' fleet untouched (daemon / TUI / delivery unaffected)\n',
2547
+ )
2548
+ return 0
2549
+ }
2550
+ case 'status': {
2551
+ const s = tray.trayStatus({ env })
2552
+ out(
2553
+ `daemon fleet API: ${s.daemon.fleet ? `up (v${s.daemon.version ?? '?'})` : 'DOWN / no fleet:1'}\n` +
2554
+ ` sock: ${s.daemon.sock ?? '—'}\n` +
2555
+ ` tcp: ${s.daemon.tcp ?? '—'}\n` +
2556
+ `SwiftBar: ${s.swiftbar.installed ? `installed (plugin dir: ${s.swiftbar.pluginDir ?? 'unset'})` : 'not installed'}\n` +
2557
+ `plugin: ${s.plugin.installed ? s.plugin.path : 'not installed'}\n`,
2558
+ )
2559
+ return 0
2560
+ }
2561
+ default:
2562
+ return argErr(
2563
+ errOut,
2564
+ 'usage: iapeer tray <render [--stream] | cmd <command> <peer> [runtime] | install [--plugin-only] | uninstall | status>',
2565
+ )
2566
+ }
2567
+ }
2469
2568
  default: {
2470
2569
  // `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh. On a TTY this is
2471
2570
  // the human "start a fresh session and work in it" verb (parallel to `attach`, which resumes):
@@ -25,6 +25,7 @@ import {
25
25
  splitDanglingEscape,
26
26
  stripQueries,
27
27
  } from './protocol.ts'
28
+ import { formatEventLine } from '../storage/rotatelog.ts'
28
29
  import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry, writePidFile } from './paths.ts'
29
30
  import { newStuckGate, nextBootAction, nextNagAction, paneIsStuck, type BootPredicates, type NagPredicate } from './boot.ts'
30
31
  import { modelToPlainText } from './render.ts'
@@ -289,6 +290,30 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
289
290
  return ok
290
291
  }
291
292
 
293
+ // ── death-trace (fleet-API follow-up, 05.07) ──────────────────────────────────
294
+ // exits.log is the durable «why did this session end» record. Before this, ONLY a
295
+ // child that died on its own (exit callback below) wrote a line; a SIGTERM'd
296
+ // supervisor (reap / stop / new / self-fresh — EVERY daemon-initiated teardown)
297
+ // killed the child and process.exit'ed before the callback could run, and a
298
+ // supervisor crash left nothing at all — exits.log stayed silent for weeks while
299
+ // sessions died (live gap: claude-boris server-dead 04.07 23:59 with ZERO forensics;
300
+ // the supervise classification even points the investigator at exits.log). Now every
301
+ // in-process exit path writes exactly one line: `exitLogged` dedups the child-exit
302
+ // callback vs the shutdown path. logfmt via the shared writer (quoted values parse
303
+ // intact in the fleet SSE stream). Out-of-process deaths (SIGKILL / OOM / Bun panic)
304
+ // remain unrecordable from here by nature — for those the supervise-tick reaped-gone
305
+ // classification in lifecycle.log stays the only trace.
306
+ let exitLogged = false
307
+ const appendExitEvent = (fields: Record<string, string | number | undefined>): void => {
308
+ if (!opts.exitLogPath) return
309
+ try {
310
+ mkdirSync(dirname(opts.exitLogPath), { recursive: true, mode: 0o700 })
311
+ appendFileSync(opts.exitLogPath, `${formatEventLine(Date.now(), fields)}\n`)
312
+ } catch {
313
+ /* best-effort — the trace must never block serving/teardown */
314
+ }
315
+ }
316
+
292
317
  const child = Bun.spawn(CMD, {
293
318
  cwd: opts.cwd ?? runDir,
294
319
  env,
@@ -337,20 +362,18 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
337
362
  // supervise-tick death classification + crash-loop accounting have the cause. host=supervisor
338
363
  // distinguishes the source. The Bun-native pty exit callback passes (exitCode, signalCode) like
339
364
  // onExit; fall back to the subprocess props. Best-effort — never blocks shutdown.
340
- if (opts.exitLogPath) {
341
- try {
342
- mkdirSync(dirname(opts.exitLogPath), { recursive: true, mode: 0o700 }) // ensure the exits.log dir exists
343
- const c = child as { exitCode?: number | null; signalCode?: number | null }
344
- const status = exitCode ?? c.exitCode ?? ''
345
- const signal = signalCode ?? c.signalCode ?? ''
346
- appendFileSync(
347
- opts.exitLogPath,
348
- `ts=${new Date().toISOString()} ev=session-exit identity=${session} dead_status=${status} dead_signal=${signal} host=supervisor\n`,
349
- )
350
- } catch {
351
- /* best-effort */
352
- }
353
- }
365
+ const c = child as { exitCode?: number | null; signalCode?: number | null }
366
+ const status = exitCode ?? c.exitCode
367
+ const signal = signalCode ?? c.signalCode
368
+ exitLogged = true // this IS the death record — shutdown below must not write a second one
369
+ appendExitEvent({
370
+ ev: 'session-exit',
371
+ identity: session,
372
+ dead_status: status ?? '',
373
+ dead_signal: signal ?? '',
374
+ cause: 'child-exit',
375
+ host: 'supervisor',
376
+ })
354
377
  shutdown()
355
378
  },
356
379
  },
@@ -554,9 +577,17 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
554
577
  writeGeometry(runDir, session, serveCols, serveRows) // initial geometry sidecar (= serve); updated on every real child resize
555
578
 
556
579
  let down = false
557
- function shutdown(): void {
580
+ function shutdown(cause = 'shutdown'): void {
558
581
  if (down) return
559
582
  down = true
583
+ // Death-trace: a signalled shutdown kills the child below and process.exit()s
584
+ // immediately — the child exit callback never runs, so THIS is the only chance
585
+ // to record the session's end. `exitLogged` is already set when the child died
586
+ // on its own (the callback wrote the authoritative dead_status/dead_signal line).
587
+ if (!exitLogged) {
588
+ exitLogged = true
589
+ appendExitEvent({ ev: 'session-exit', identity: session, cause, host: 'supervisor' })
590
+ }
560
591
  if (paneLogFd >= 0)
561
592
  try {
562
593
  closeSync(paneLogFd)
@@ -582,18 +613,31 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
582
613
  }
583
614
  process.exit(0)
584
615
  }
585
- process.on('SIGTERM', shutdown)
586
- process.on('SIGINT', shutdown)
616
+ process.on('SIGTERM', () => shutdown('shutdown-sigterm'))
617
+ process.on('SIGINT', () => shutdown('shutdown-sigint'))
587
618
  process.on('SIGHUP', () => {
588
619
  /* daemon survives client window close */
589
620
  })
590
- process.on('uncaughtException', e => {
621
+ // A survivable fault must still leave a trace: the console.error goes to the
622
+ // detached daemon's session log, which sat at 0 bytes for weeks on the live fleet —
623
+ // effectively invisible. Record it in exits.log too (ev=supervisor-uncaught; the
624
+ // session keeps serving — killing it here would regress the detach-crash robustness),
625
+ // so a later wedge/death postmortem can see the fault that preceded it.
626
+ const traceFault = (kind: string, e: unknown): void => {
591
627
  try {
592
- console.error('[supervisor-daemon] uncaught', (e && (e as Error).stack) || e)
628
+ console.error(`[supervisor-daemon] ${kind}`, (e && (e as Error).stack) || e)
593
629
  } catch {
594
630
  /* */
595
631
  }
596
- })
632
+ appendExitEvent({
633
+ ev: 'supervisor-uncaught',
634
+ identity: session,
635
+ kind,
636
+ reason: String((e as Error)?.stack ?? e).replace(/\s+/g, ' ').slice(0, 300),
637
+ })
638
+ }
639
+ process.on('uncaughtException', e => traceFault('exception', e))
640
+ process.on('unhandledRejection', e => traceFault('rejection', e))
597
641
 
598
642
  // ── boot-dialog driver (serving slice a) ────────────────────────────────────────
599
643
  // For a real runtime, answer the startup dialogs (codex trust/update/hooks, claude trust/resume)