@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.
- package/docs/12-cli-reference.md +12 -0
- package/docs/15-fleet-api.md +2 -2
- package/docs/16-tray.md +88 -0
- package/docs/README.md +1 -0
- package/docs/ru/15-fleet-api.md +2 -2
- package/docs/ru/16-tray.md +83 -0
- package/docs/ru/README.md +1 -0
- package/package.json +1 -1
- package/src/cli/index.ts +113 -14
- package/src/supervisor/daemon.ts +64 -20
- package/src/tray/client.ts +233 -0
- package/src/tray/index.ts +146 -0
- package/src/tray/install.ts +266 -0
- package/src/tray/render.ts +243 -0
package/docs/12-cli-reference.md
CHANGED
|
@@ -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).
|
package/docs/15-fleet-api.md
CHANGED
|
@@ -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
|
|
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
|
|
package/docs/16-tray.md
ADDED
|
@@ -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
|
|
package/docs/ru/15-fleet-api.md
CHANGED
|
@@ -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
|
|
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
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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):
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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(
|
|
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)
|