@agfpd/iapeer 0.4.64 → 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/16-tray.md +88 -0
- package/docs/README.md +1 -0
- 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 +96 -0
- 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/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
|
|
|
@@ -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
|
@@ -1165,7 +1165,9 @@ const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
|
|
|
1165
1165
|
'no-telegram',
|
|
1166
1166
|
'no-voice',
|
|
1167
1167
|
'npx',
|
|
1168
|
+
'plugin-only',
|
|
1168
1169
|
'remove-codesign-identity',
|
|
1170
|
+
'stream',
|
|
1169
1171
|
'yes',
|
|
1170
1172
|
])
|
|
1171
1173
|
|
|
@@ -1275,6 +1277,10 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
|
|
|
1275
1277
|
sig: 'supervisor up|start|attach|list|kill <sess> [runtime]',
|
|
1276
1278
|
desc: 'DARK (cutover Block 2): detach-persistent pty-supervisor PoC port; serves nothing on the live fleet (throwaway validation only)',
|
|
1277
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
|
+
},
|
|
1278
1284
|
]
|
|
1279
1285
|
|
|
1280
1286
|
/** Greedy word-wrap to `width` columns. A token longer than `width` keeps its own
|
|
@@ -2275,6 +2281,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2275
2281
|
// docs on its OWN install, version = its binary). install runs from source, so
|
|
2276
2282
|
// docs/ sits at <entry>/../../docs. Best-effort: never fails the install.
|
|
2277
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
|
+
}
|
|
2278
2297
|
const { path: plist, changed: plistChanged } = installDaemonPlist({ env })
|
|
2279
2298
|
const signingLine =
|
|
2280
2299
|
r.signing == null
|
|
@@ -2291,6 +2310,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2291
2310
|
` scaffold: ~/.iapeer/ ensured (peers/, state, logs, cache, runtimes)\n` +
|
|
2292
2311
|
` host doctrine: ${hostDoctrine.path}${hostDoctrine.created ? ' (stub created — fill it in)' : ' (kept)'}\n` +
|
|
2293
2312
|
` docs: ${docs.copied ? docs.dest : `skipped (${docs.reason})`}\n` +
|
|
2313
|
+
trayLine +
|
|
2294
2314
|
` daemon plist ${plistChanged ? 'written' : 'unchanged (byte-identical — no write, no BTM notification)'}: ${plist}\n` +
|
|
2295
2315
|
` (NOT loaded — a live daemon migration is a separate step: launchctl bootstrap gui/$(id -u) ${plist})\n`,
|
|
2296
2316
|
)
|
|
@@ -2469,6 +2489,82 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2469
2489
|
const { runSupervisorCli } = await import('../supervisor/index.ts')
|
|
2470
2490
|
return await runSupervisorCli(rest)
|
|
2471
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
|
+
}
|
|
2472
2568
|
default: {
|
|
2473
2569
|
// `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh. On a TTY this is
|
|
2474
2570
|
// the human "start a fresh session and work in it" verb (parallel to `attach`, which resumes):
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// tray/client — the fleet-API HTTP client. Ф1 of iapeer-tray is the FIRST EXTERNAL
|
|
2
|
+
// client of the daemon's fleet API, and it is written ONLY against the normative
|
|
3
|
+
// contract docs/15-fleet-api.md — NOT against daemon source. This module therefore
|
|
4
|
+
// imports NOTHING from src/daemon: it discovers the daemon via router.json, speaks
|
|
5
|
+
// plain HTTP+JSON (+SSE) over the two advertised listeners, and models the snapshot
|
|
6
|
+
// with its OWN local interfaces (client obligation 2: unknown fields ignored). If the
|
|
7
|
+
// doc and the daemon ever diverge, this client feels it — that is the whole point of
|
|
8
|
+
// dogfooding the contract through a real second client.
|
|
9
|
+
//
|
|
10
|
+
// Transport: prefer the 0600 unix socket (same-uid, same trust class as the CLI),
|
|
11
|
+
// fall back to the TCP loopback. Bun's fetch carries the `unix` option; the compiled
|
|
12
|
+
// `iapeer` binary bundles the Bun runtime, so this works when SwiftBar execs it.
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'fs'
|
|
15
|
+
import { join } from 'path'
|
|
16
|
+
import { pluginStateDir, type StorageOptions } from '../storage/index.ts'
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Discovery — router.json (docs/15 §Surface and transport)
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface FleetAddress {
|
|
23
|
+
/** Unix-socket path (preferred). */
|
|
24
|
+
sock?: string
|
|
25
|
+
/** TCP origin, e.g. `http://127.0.0.1:8765` (derived from router.json `tcp`). */
|
|
26
|
+
tcp?: string
|
|
27
|
+
/** The daemon's advertised version, when present. */
|
|
28
|
+
version?: string
|
|
29
|
+
/** The fleet capability marker: `1` when the daemon serves /fleet/v1. Absent ⇒
|
|
30
|
+
* a pre-fleet daemon ⇒ the client must degrade (docs/15 §Discovery). */
|
|
31
|
+
fleet?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Read `~/.iapeer/state/iapeer/router.json`. Absent/unreadable ⇒ empty address
|
|
35
|
+
* (daemon down) — the caller renders the down state. Never throws. */
|
|
36
|
+
export function resolveFleetAddress(opts: StorageOptions = {}): FleetAddress {
|
|
37
|
+
const routerJson = join(pluginStateDir('iapeer', opts), 'router.json')
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(readFileSync(routerJson, 'utf8')) as {
|
|
40
|
+
sock?: unknown
|
|
41
|
+
tcp?: unknown
|
|
42
|
+
version?: unknown
|
|
43
|
+
fleet?: unknown
|
|
44
|
+
}
|
|
45
|
+
const addr: FleetAddress = {}
|
|
46
|
+
if (typeof parsed.sock === 'string' && parsed.sock) addr.sock = parsed.sock
|
|
47
|
+
// router.json advertises the MCP url (…/mcp); the fleet surface lives on the SAME
|
|
48
|
+
// origin under /fleet/v1 — keep only the origin.
|
|
49
|
+
if (typeof parsed.tcp === 'string' && parsed.tcp) {
|
|
50
|
+
try {
|
|
51
|
+
addr.tcp = new URL(parsed.tcp).origin
|
|
52
|
+
} catch {
|
|
53
|
+
/* malformed tcp url → no tcp fallback */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (typeof parsed.version === 'string') addr.version = parsed.version
|
|
57
|
+
if (typeof parsed.fleet === 'number') addr.fleet = parsed.fleet
|
|
58
|
+
return addr
|
|
59
|
+
} catch {
|
|
60
|
+
return {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
// Requests — unix-first, TCP-fallback, optional bearer
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** The bearer, when the host configured H8 auth (docs/15 §Auth). Read from the
|
|
69
|
+
* env the client runs under; open-local hosts have none. */
|
|
70
|
+
function bearerHeader(env: NodeJS.ProcessEnv): Record<string, string> {
|
|
71
|
+
const t = env.IAPEER_BEARER_TOKEN?.trim()
|
|
72
|
+
return t ? { authorization: `Bearer ${t}` } : {}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class FleetUnreachableError extends Error {
|
|
76
|
+
constructor(readonly detail: string) {
|
|
77
|
+
super(`fleet daemon unreachable: ${detail}`)
|
|
78
|
+
this.name = 'FleetUnreachableError'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface FetchAttempt {
|
|
83
|
+
url: string
|
|
84
|
+
init: RequestInit & { unix?: string }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build the ordered transport attempts for a fleet path: unix socket first, TCP
|
|
88
|
+
* second. `unix` is Bun's fetch option (routes the HTTP request over the socket). */
|
|
89
|
+
function attempts(addr: FleetAddress, path: string, base: RequestInit, env: NodeJS.ProcessEnv): FetchAttempt[] {
|
|
90
|
+
const headers = { ...(base.headers as Record<string, string> | undefined), ...bearerHeader(env) }
|
|
91
|
+
const list: FetchAttempt[] = []
|
|
92
|
+
if (addr.sock) list.push({ url: `http://iapeer${path}`, init: { ...base, headers, unix: addr.sock } })
|
|
93
|
+
if (addr.tcp) list.push({ url: `${addr.tcp}${path}`, init: { ...base, headers } })
|
|
94
|
+
return list
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function fetchFleet(
|
|
98
|
+
addr: FleetAddress,
|
|
99
|
+
path: string,
|
|
100
|
+
init: RequestInit,
|
|
101
|
+
env: NodeJS.ProcessEnv,
|
|
102
|
+
): Promise<Response> {
|
|
103
|
+
const tries = attempts(addr, path, init, env)
|
|
104
|
+
if (tries.length === 0) throw new FleetUnreachableError('no advertised address (router.json missing sock/tcp)')
|
|
105
|
+
let lastErr = ''
|
|
106
|
+
for (const a of tries) {
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch(a.url, a.init as RequestInit)
|
|
109
|
+
return res
|
|
110
|
+
} catch (e) {
|
|
111
|
+
lastErr = e instanceof Error ? e.message : String(e)
|
|
112
|
+
// try the next transport
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
throw new FleetUnreachableError(lastErr || 'all transports failed')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function fleetGetJson<T = unknown>(addr: FleetAddress, path: string, env: NodeJS.ProcessEnv): Promise<T> {
|
|
119
|
+
const res = await fetchFleet(addr, path, { method: 'GET' }, env)
|
|
120
|
+
const text = await res.text()
|
|
121
|
+
if (!res.ok) throw new Error(`GET ${path} → ${res.status}: ${text.slice(0, 300)}`)
|
|
122
|
+
return JSON.parse(text) as T
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function fleetPostJson<T = unknown>(
|
|
126
|
+
addr: FleetAddress,
|
|
127
|
+
path: string,
|
|
128
|
+
body: Record<string, unknown>,
|
|
129
|
+
env: NodeJS.ProcessEnv,
|
|
130
|
+
): Promise<{ status: number; body: T }> {
|
|
131
|
+
const res = await fetchFleet(
|
|
132
|
+
addr,
|
|
133
|
+
path,
|
|
134
|
+
{ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) },
|
|
135
|
+
env,
|
|
136
|
+
)
|
|
137
|
+
const text = await res.text()
|
|
138
|
+
let parsed: T
|
|
139
|
+
try {
|
|
140
|
+
parsed = JSON.parse(text) as T
|
|
141
|
+
} catch {
|
|
142
|
+
parsed = text as unknown as T
|
|
143
|
+
}
|
|
144
|
+
return { status: res.status, body: parsed }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
// SSE — GET /fleet/v1/events (docs/15 §GET /fleet/v1/events)
|
|
149
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export interface SseEvent {
|
|
152
|
+
/** The event name (`event:` line) — the `ev` vocabulary. Unknown kinds MUST be
|
|
153
|
+
* tolerated by the caller (client obligation 1). */
|
|
154
|
+
event: string
|
|
155
|
+
/** Epoch-ms of the event (`id:` line) — for at-least-once dedup. */
|
|
156
|
+
id?: number
|
|
157
|
+
/** The parsed `data:` JSON payload. */
|
|
158
|
+
data: Record<string, unknown>
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Open the SSE stream and invoke `onEvent` per frame until the signal aborts or the
|
|
163
|
+
* connection drops (then this resolves — the caller decides whether to reconnect).
|
|
164
|
+
* Parses the `event:` / `id:` / `data:` triplet; comments (`: connected`, `: hb`) are
|
|
165
|
+
* skipped. Deliberately minimal — no reconnect here (the render loop owns that).
|
|
166
|
+
*/
|
|
167
|
+
export async function streamFleetEvents(
|
|
168
|
+
addr: FleetAddress,
|
|
169
|
+
onEvent: (e: SseEvent) => void,
|
|
170
|
+
opts: { replay?: number; signal?: AbortSignal; env: NodeJS.ProcessEnv },
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
const replay = Math.max(0, Math.min(500, opts.replay ?? 0))
|
|
173
|
+
const res = await fetchFleet(
|
|
174
|
+
addr,
|
|
175
|
+
`/fleet/v1/events?replay=${replay}`,
|
|
176
|
+
{ method: 'GET', headers: { accept: 'text/event-stream' }, signal: opts.signal },
|
|
177
|
+
opts.env,
|
|
178
|
+
)
|
|
179
|
+
if (!res.ok || !res.body) throw new Error(`events stream → ${res.status}`)
|
|
180
|
+
const reader = res.body.getReader()
|
|
181
|
+
const decoder = new TextDecoder()
|
|
182
|
+
let buf = ''
|
|
183
|
+
try {
|
|
184
|
+
for (;;) {
|
|
185
|
+
const { value, done } = await reader.read()
|
|
186
|
+
if (done) break
|
|
187
|
+
buf += decoder.decode(value, { stream: true })
|
|
188
|
+
// Frames are separated by a blank line.
|
|
189
|
+
let sep: number
|
|
190
|
+
while ((sep = buf.indexOf('\n\n')) !== -1) {
|
|
191
|
+
const frame = buf.slice(0, sep)
|
|
192
|
+
buf = buf.slice(sep + 2)
|
|
193
|
+
const parsed = parseSseFrame(frame)
|
|
194
|
+
if (parsed) onEvent(parsed)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
try {
|
|
199
|
+
reader.releaseLock()
|
|
200
|
+
} catch {
|
|
201
|
+
/* already released */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Parse one SSE frame (the text between blank-line separators). Returns null for
|
|
207
|
+
* comment-only frames (`: connected`, `: hb`) or frames without a data payload. */
|
|
208
|
+
export function parseSseFrame(frame: string): SseEvent | null {
|
|
209
|
+
let event = 'message'
|
|
210
|
+
let id: number | undefined
|
|
211
|
+
const dataLines: string[] = []
|
|
212
|
+
for (const raw of frame.split('\n')) {
|
|
213
|
+
const line = raw.replace(/\r$/, '')
|
|
214
|
+
if (!line || line.startsWith(':')) continue // comment or blank
|
|
215
|
+
const colon = line.indexOf(':')
|
|
216
|
+
const field = colon === -1 ? line : line.slice(0, colon)
|
|
217
|
+
const val = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '')
|
|
218
|
+
if (field === 'event') event = val
|
|
219
|
+
else if (field === 'id') {
|
|
220
|
+
const n = parseInt(val, 10)
|
|
221
|
+
if (Number.isFinite(n)) id = n
|
|
222
|
+
} else if (field === 'data') dataLines.push(val)
|
|
223
|
+
}
|
|
224
|
+
if (dataLines.length === 0) return null
|
|
225
|
+
let data: Record<string, unknown> = {}
|
|
226
|
+
try {
|
|
227
|
+
const parsed = JSON.parse(dataLines.join('\n'))
|
|
228
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data = parsed as Record<string, unknown>
|
|
229
|
+
} catch {
|
|
230
|
+
return null
|
|
231
|
+
}
|
|
232
|
+
return { event, ...(id !== undefined ? { id } : {}), data }
|
|
233
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// tray/index — the fleet dashboard's runtime entry points, called by the `tray` CLI
|
|
2
|
+
// verb. Ф1 of iapeer-tray: the FIRST external client of the daemon fleet API, written
|
|
3
|
+
// strictly against docs/15-fleet-api.md.
|
|
4
|
+
//
|
|
5
|
+
// • render (one-shot) — fetch /snapshot once, print one SwiftBar block. Used by the
|
|
6
|
+
// poll-fallback plugin and by tests.
|
|
7
|
+
// • render --stream — the SwiftBar streamable loop: print a block, subscribe to
|
|
8
|
+
// /events, and re-fetch the snapshot on each change (the canonical docs/15 loop
|
|
9
|
+
// "snapshot is state, events are hints"). Coalesces event bursts, heartbeats a
|
|
10
|
+
// refresh every 15 s (ages stay fresh + a missed event self-heals), and reconnects
|
|
11
|
+
// across daemon restarts / down windows. Degrades to the daemon-down block.
|
|
12
|
+
// • cmd — POST a fleet command (wake/stop/…): dogfoods the command endpoints.
|
|
13
|
+
|
|
14
|
+
import { resolveFleetAddress, fleetGetJson, fleetPostJson, streamFleetEvents } from './client.ts'
|
|
15
|
+
import { renderSwiftBar, renderDaemonDown, type TraySnapshot } from './render.ts'
|
|
16
|
+
import { iapeerBinPath } from '../install/index.ts'
|
|
17
|
+
|
|
18
|
+
export * from './install.ts'
|
|
19
|
+
export { resolveFleetAddress } from './client.ts'
|
|
20
|
+
|
|
21
|
+
const SNAPSHOT_PATH = '/fleet/v1/snapshot'
|
|
22
|
+
const DEFAULT_HEARTBEAT_MS = 15_000
|
|
23
|
+
const COALESCE_MS = 200
|
|
24
|
+
const RECONNECT_BACKOFF_MS = 1_000
|
|
25
|
+
const DOWN_RETRY_MS = 3_000
|
|
26
|
+
|
|
27
|
+
/** Heartbeat cadence: re-render even without an event so ages stay fresh and a missed
|
|
28
|
+
* event self-heals. Tunable via IAPEER_TRAY_HEARTBEAT_MS (clamped 1s…10m). */
|
|
29
|
+
function heartbeatMs(env: NodeJS.ProcessEnv): number {
|
|
30
|
+
const raw = parseInt(env.IAPEER_TRAY_HEARTBEAT_MS ?? '', 10)
|
|
31
|
+
if (!Number.isFinite(raw)) return DEFAULT_HEARTBEAT_MS
|
|
32
|
+
return Math.max(1_000, Math.min(600_000, raw))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const TRAY_COMMANDS = ['wake', 'stop', 'start', 'new', 'refresh', 'interrupt', 'compact'] as const
|
|
36
|
+
export type TrayCommand = (typeof TRAY_COMMANDS)[number]
|
|
37
|
+
|
|
38
|
+
/** Fetch the snapshot and render one SwiftBar block, or the degraded down-block when
|
|
39
|
+
* no advertised address answers / the daemon predates the fleet API. Never throws. */
|
|
40
|
+
export async function renderTrayOnce(env: NodeJS.ProcessEnv): Promise<string> {
|
|
41
|
+
const binPath = iapeerBinPath(env)
|
|
42
|
+
const addr = resolveFleetAddress({ env })
|
|
43
|
+
if (addr.fleet !== 1 || (!addr.sock && !addr.tcp)) {
|
|
44
|
+
const reason = addr.sock || addr.tcp ? 'daemon predates the fleet API (no fleet:1 in router.json)' : 'no daemon address (router.json missing)'
|
|
45
|
+
return renderDaemonDown(reason, { binPath })
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const snap = await fleetGetJson<TraySnapshot>(addr, SNAPSHOT_PATH, env)
|
|
49
|
+
return renderSwiftBar(snap, { binPath })
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return renderDaemonDown(e instanceof Error ? e.message : String(e), { binPath })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
56
|
+
return new Promise(resolve => {
|
|
57
|
+
if (signal?.aborted) return resolve()
|
|
58
|
+
const t = setTimeout(resolve, ms)
|
|
59
|
+
;(t as { unref?: () => void }).unref?.()
|
|
60
|
+
signal?.addEventListener('abort', () => { clearTimeout(t); resolve() }, { once: true })
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The SwiftBar streamable loop (docs/16 §streaming). Emits a full menu block, then a
|
|
66
|
+
* `~~~` separator before every subsequent block. Drives updates off the SSE stream
|
|
67
|
+
* with burst-coalescing + a heartbeat; reconnects forever. `out` defaults to stdout;
|
|
68
|
+
* `signal` (tests) stops the loop.
|
|
69
|
+
*/
|
|
70
|
+
export async function streamTray(
|
|
71
|
+
env: NodeJS.ProcessEnv,
|
|
72
|
+
out: (s: string) => void = s => process.stdout.write(s),
|
|
73
|
+
signal?: AbortSignal,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
let first = true
|
|
76
|
+
// Serialize all emits through a promise chain so a `~~~`+block is never interleaved.
|
|
77
|
+
let chain: Promise<void> = Promise.resolve()
|
|
78
|
+
const emit = (): Promise<void> => {
|
|
79
|
+
chain = chain.then(async () => {
|
|
80
|
+
const block = await renderTrayOnce(env)
|
|
81
|
+
out(first ? block : `~~~\n${block}`)
|
|
82
|
+
first = false
|
|
83
|
+
})
|
|
84
|
+
return chain
|
|
85
|
+
}
|
|
86
|
+
// Coalesce event bursts into a single re-render.
|
|
87
|
+
let coalesceTimer: ReturnType<typeof setTimeout> | undefined
|
|
88
|
+
const requestEmit = (): void => {
|
|
89
|
+
if (coalesceTimer) return
|
|
90
|
+
coalesceTimer = setTimeout(() => {
|
|
91
|
+
coalesceTimer = undefined
|
|
92
|
+
void emit()
|
|
93
|
+
}, COALESCE_MS)
|
|
94
|
+
;(coalesceTimer as { unref?: () => void }).unref?.()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await emit() // initial block
|
|
98
|
+
|
|
99
|
+
while (!signal?.aborted) {
|
|
100
|
+
const addr = resolveFleetAddress({ env })
|
|
101
|
+
if (addr.fleet !== 1 || (!addr.sock && !addr.tcp)) {
|
|
102
|
+
await sleep(DOWN_RETRY_MS, signal)
|
|
103
|
+
await emit() // keep the down-block current until the daemon returns
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
const heartbeat = setInterval(requestEmit, heartbeatMs(env))
|
|
107
|
+
;(heartbeat as { unref?: () => void }).unref?.()
|
|
108
|
+
try {
|
|
109
|
+
// Blocks until the stream ends (daemon restart / network drop). Each event just
|
|
110
|
+
// marks the menu dirty — the snapshot re-fetch is the source of truth.
|
|
111
|
+
await streamFleetEvents(addr, () => requestEmit(), { replay: 0, signal, env })
|
|
112
|
+
} catch {
|
|
113
|
+
/* connection failed → fall through to a refresh + backoff + reconnect */
|
|
114
|
+
} finally {
|
|
115
|
+
clearInterval(heartbeat)
|
|
116
|
+
}
|
|
117
|
+
if (signal?.aborted) break
|
|
118
|
+
await emit() // reflect whatever changed across the drop
|
|
119
|
+
await sleep(RECONNECT_BACKOFF_MS, signal)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface TrayCmdResult {
|
|
124
|
+
ok: boolean
|
|
125
|
+
status: number
|
|
126
|
+
body: unknown
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** POST a fleet command for a peer (dogfoods POST /fleet/v1/peers/<peer>/<cmd>). */
|
|
130
|
+
export async function trayCmd(
|
|
131
|
+
env: NodeJS.ProcessEnv,
|
|
132
|
+
command: string,
|
|
133
|
+
personality: string,
|
|
134
|
+
runtime?: string,
|
|
135
|
+
): Promise<TrayCmdResult> {
|
|
136
|
+
if (!(TRAY_COMMANDS as readonly string[]).includes(command)) {
|
|
137
|
+
throw new Error(`unknown tray command "${command}" — one of: ${TRAY_COMMANDS.join(', ')}`)
|
|
138
|
+
}
|
|
139
|
+
const addr = resolveFleetAddress({ env })
|
|
140
|
+
if (addr.fleet !== 1 || (!addr.sock && !addr.tcp)) {
|
|
141
|
+
return { ok: false, status: 0, body: { error: 'fleet daemon unreachable (no fleet:1 in router.json)' } }
|
|
142
|
+
}
|
|
143
|
+
const path = `/fleet/v1/peers/${encodeURIComponent(personality)}/${command}`
|
|
144
|
+
const { status, body } = await fleetPostJson(addr, path, runtime ? { runtime } : {}, env)
|
|
145
|
+
return { ok: status < 400, status, body }
|
|
146
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// tray/install — idempotent lifecycle of the SwiftBar plugin that hosts the fleet
|
|
2
|
+
// dashboard. The plugin FILE is a thin shim: it execs the installed `iapeer` binary
|
|
3
|
+
// (`iapeer tray render --stream`), so ALL rendering/streaming logic lives in the
|
|
4
|
+
// binary (TypeScript, tested) rather than in brittle shell — the .sh is stable and
|
|
5
|
+
// carries only SwiftBar metadata.
|
|
6
|
+
//
|
|
7
|
+
// SwiftBar facts (verified against SwiftBar/PreferencesStore.swift):
|
|
8
|
+
// • PluginDirectory is a PLAIN String default (read via expandingTildeInPath), NOT
|
|
9
|
+
// a security-scoped bookmark — `defaults write com.ameba.SwiftBar PluginDirectory
|
|
10
|
+
// -string <dir>` set BEFORE first launch is honored and skips the folder picker.
|
|
11
|
+
// • SwiftBar is not sandboxed (it runs arbitrary scripts), so a hidden ~/.iapeer
|
|
12
|
+
// path is readable without a TCC prompt.
|
|
13
|
+
//
|
|
14
|
+
// Packaging (owner decision 05.07.2026): the tray ships WITH the foundation (same
|
|
15
|
+
// class as the `iapeer list` TUI — another face of the daemon). `iapeer install`
|
|
16
|
+
// drops the plugin file (inert without SwiftBar); `iapeer tray install [--app]` is
|
|
17
|
+
// the activation verb that also installs SwiftBar.app when absent (owner-sanctioned)
|
|
18
|
+
// and launches it. Tearing down the GUI layer NEVER touches the fleet.
|
|
19
|
+
//
|
|
20
|
+
// Paths route through cfg/env (the dedicated plugin dir is under resolveGlobalRoot,
|
|
21
|
+
// so IAPEER_ROOT isolates it in tests); every external side-effect (defaults / brew /
|
|
22
|
+
// open) goes through an injectable Runner so tests stay hermetic.
|
|
23
|
+
|
|
24
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
25
|
+
import { spawnSync } from 'child_process'
|
|
26
|
+
import { homedir } from 'os'
|
|
27
|
+
import { join } from 'path'
|
|
28
|
+
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
29
|
+
import { iapeerBinPath } from '../install/index.ts'
|
|
30
|
+
import { resolveFleetAddress } from './client.ts'
|
|
31
|
+
|
|
32
|
+
/** Streamable plugin: the `.10s.` token is the RESPAWN cadence SwiftBar uses if the
|
|
33
|
+
* streaming process exits (daemon-down backoff); while the stream is alive it is
|
|
34
|
+
* ignored. The basename is stable — install rewrites the same file (idempotent). */
|
|
35
|
+
export const PLUGIN_BASENAME = 'iapeer.10s.sh'
|
|
36
|
+
const SWIFTBAR_DOMAIN = 'com.ameba.SwiftBar'
|
|
37
|
+
const SWIFTBAR_APP_PATHS = ['/Applications/SwiftBar.app', join(homedir(), 'Applications', 'SwiftBar.app')]
|
|
38
|
+
|
|
39
|
+
export type RunResult = { status: number | null; stdout: string; stderr: string }
|
|
40
|
+
export type Runner = (cmd: string, args: string[]) => RunResult
|
|
41
|
+
|
|
42
|
+
const defaultRun: Runner = (cmd, args) => {
|
|
43
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8' })
|
|
44
|
+
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The dedicated iapeer-owned plugin dir under ~/.iapeer (IAPEER_ROOT-isolated). */
|
|
48
|
+
export function dedicatedPluginDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
49
|
+
return join(resolveGlobalRoot(env), 'tray', 'plugins')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Is SwiftBar.app installed? (either /Applications or ~/Applications). */
|
|
53
|
+
export function swiftBarInstalled(): boolean {
|
|
54
|
+
return SWIFTBAR_APP_PATHS.some(p => existsSync(p))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The plugin-dir SwiftBar is currently configured with (plain-string default,
|
|
58
|
+
* ~-expanded), or undefined when unset/unreadable. */
|
|
59
|
+
export function readSwiftBarPluginDir(run: Runner = defaultRun): string | undefined {
|
|
60
|
+
const r = run('defaults', ['read', SWIFTBAR_DOMAIN, 'PluginDirectory'])
|
|
61
|
+
if (r.status !== 0) return undefined
|
|
62
|
+
const raw = r.stdout.trim()
|
|
63
|
+
if (!raw) return undefined
|
|
64
|
+
return raw.startsWith('~') ? join(homedir(), raw.slice(1)) : raw
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function setSwiftBarPluginDir(dir: string, run: Runner): RunResult {
|
|
68
|
+
return run('defaults', ['write', SWIFTBAR_DOMAIN, 'PluginDirectory', '-string', dir])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function installSwiftBarApp(run: Runner): RunResult {
|
|
72
|
+
return run('brew', ['install', '--cask', 'swiftbar'])
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function launchSwiftBar(run: Runner): void {
|
|
76
|
+
run('open', ['-g', '-a', 'SwiftBar'])
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function refreshSwiftBar(run: Runner): void {
|
|
80
|
+
run('open', ['-g', 'swiftbar://refreshallplugins'])
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The plugin shim: SwiftBar metadata + exec of the streaming renderer. binPath is
|
|
84
|
+
* embedded absolute (SwiftBar's PATH is minimal, like launchd). */
|
|
85
|
+
export function buildPluginShim(binPath: string): string {
|
|
86
|
+
return [
|
|
87
|
+
'#!/bin/bash',
|
|
88
|
+
'# <xbar.title>iapeer fleet</xbar.title>',
|
|
89
|
+
'# <xbar.version>v1</xbar.version>',
|
|
90
|
+
'# <xbar.author>iapeer</xbar.author>',
|
|
91
|
+
'# <xbar.desc>iapeer multi-agent fleet — live peer dashboard over the daemon fleet API.</xbar.desc>',
|
|
92
|
+
'# <swiftbar.type>streamable</swiftbar.type>',
|
|
93
|
+
'# <swiftbar.hideRunInTerminal>true</swiftbar.hideRunInTerminal>',
|
|
94
|
+
'# <swiftbar.hideLastUpdated>true</swiftbar.hideLastUpdated>',
|
|
95
|
+
'# <swiftbar.hideDisablePlugin>true</swiftbar.hideDisablePlugin>',
|
|
96
|
+
`exec "${binPath}" tray render --stream`,
|
|
97
|
+
'',
|
|
98
|
+
].join('\n')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Fail-closed sandbox guard (in spirit with install/scaffoldHostDocs): never write
|
|
102
|
+
* into the REAL ~/.iapeer under a sandboxed test that forgot to set IAPEER_ROOT. */
|
|
103
|
+
function assertTraySandboxIsolated(env: NodeJS.ProcessEnv): void {
|
|
104
|
+
if (env.IAPEER_TEST_SANDBOX === '1' && resolveGlobalRoot(env) === join(homedir(), '.iapeer')) {
|
|
105
|
+
throw new Error('refusing to install the tray plugin into the REAL ~/.iapeer under IAPEER_TEST_SANDBOX=1 — set IAPEER_ROOT')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface InstallTrayOptions {
|
|
110
|
+
env?: NodeJS.ProcessEnv
|
|
111
|
+
/** Install SwiftBar.app via brew when absent (owner-sanctioned). Default false. */
|
|
112
|
+
installApp?: boolean
|
|
113
|
+
/** Launch + refresh SwiftBar after writing the plugin. Default false. */
|
|
114
|
+
launch?: boolean
|
|
115
|
+
run?: Runner
|
|
116
|
+
/** Injectable app-presence probe (tests). Default: swiftBarInstalled(). */
|
|
117
|
+
probeApp?: () => boolean
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface InstallTrayResult {
|
|
121
|
+
pluginFile: string
|
|
122
|
+
pluginDir: string
|
|
123
|
+
/** Whether the plugin file bytes changed (false = already current). */
|
|
124
|
+
wrote: boolean
|
|
125
|
+
/** How the plugin dir was chosen. */
|
|
126
|
+
dir: 'existing-swiftbar' | 'dedicated-configured' | 'dedicated-only'
|
|
127
|
+
app: 'present' | 'installed' | 'install-failed' | 'absent'
|
|
128
|
+
appReason?: string
|
|
129
|
+
launched: boolean
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Idempotently install the tray plugin. Steps (each safe to repeat):
|
|
134
|
+
* 1. install SwiftBar.app if absent && installApp (best-effort — a brew failure is
|
|
135
|
+
* reported, not thrown; the plugin file still lands so it activates later).
|
|
136
|
+
* 2. resolve the target dir: an already-configured SwiftBar PluginDirectory is
|
|
137
|
+
* RESPECTED (install into the user's dir); otherwise the dedicated ~/.iapeer dir,
|
|
138
|
+
* and — only when SwiftBar is present — point SwiftBar at it via defaults.
|
|
139
|
+
* 3. write the plugin shim (skip if byte-identical; always ensure +x).
|
|
140
|
+
* 4. launch + refresh SwiftBar when launch && SwiftBar present.
|
|
141
|
+
*/
|
|
142
|
+
export function installTray(opts: InstallTrayOptions = {}): InstallTrayResult {
|
|
143
|
+
const env = opts.env ?? process.env
|
|
144
|
+
const run = opts.run ?? defaultRun
|
|
145
|
+
const probeApp = opts.probeApp ?? swiftBarInstalled
|
|
146
|
+
assertTraySandboxIsolated(env)
|
|
147
|
+
const binPath = iapeerBinPath(env)
|
|
148
|
+
|
|
149
|
+
// 1 — app
|
|
150
|
+
let app: InstallTrayResult['app'] = probeApp() ? 'present' : 'absent'
|
|
151
|
+
let appReason: string | undefined
|
|
152
|
+
if (app === 'absent' && opts.installApp) {
|
|
153
|
+
const r = installSwiftBarApp(run)
|
|
154
|
+
if (r.status === 0 || probeApp()) app = 'installed'
|
|
155
|
+
else {
|
|
156
|
+
app = 'install-failed'
|
|
157
|
+
appReason = (r.stderr || r.stdout || `brew exit ${r.status}`).trim().slice(0, 300)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const present = app === 'present' || app === 'installed'
|
|
161
|
+
|
|
162
|
+
// 2 — target dir
|
|
163
|
+
const existing = present ? readSwiftBarPluginDir(run) : undefined
|
|
164
|
+
let pluginDir: string
|
|
165
|
+
let dir: InstallTrayResult['dir']
|
|
166
|
+
if (existing && existsSync(existing)) {
|
|
167
|
+
pluginDir = existing
|
|
168
|
+
dir = 'existing-swiftbar'
|
|
169
|
+
} else {
|
|
170
|
+
pluginDir = dedicatedPluginDir(env)
|
|
171
|
+
if (present) {
|
|
172
|
+
setSwiftBarPluginDir(pluginDir, run)
|
|
173
|
+
dir = 'dedicated-configured'
|
|
174
|
+
} else {
|
|
175
|
+
dir = 'dedicated-only'
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
mkdirSync(pluginDir, { recursive: true })
|
|
179
|
+
|
|
180
|
+
// 3 — plugin file (idempotent write; always +x)
|
|
181
|
+
const pluginFile = join(pluginDir, PLUGIN_BASENAME)
|
|
182
|
+
const content = buildPluginShim(binPath)
|
|
183
|
+
let wrote = false
|
|
184
|
+
if (!existsSync(pluginFile) || readFileSync(pluginFile, 'utf8') !== content) {
|
|
185
|
+
writeFileSync(pluginFile, content, { mode: 0o755 })
|
|
186
|
+
wrote = true
|
|
187
|
+
} else {
|
|
188
|
+
try {
|
|
189
|
+
chmodSync(pluginFile, 0o755)
|
|
190
|
+
} catch {
|
|
191
|
+
/* best-effort */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 4 — launch + refresh
|
|
196
|
+
let launched = false
|
|
197
|
+
if (opts.launch && present) {
|
|
198
|
+
launchSwiftBar(run)
|
|
199
|
+
refreshSwiftBar(run)
|
|
200
|
+
launched = true
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { pluginFile, pluginDir, wrote, dir, app, appReason, launched }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface UninstallTrayResult {
|
|
207
|
+
removed: string[]
|
|
208
|
+
/** SwiftBar was refreshed to drop the now-missing plugin. */
|
|
209
|
+
refreshed: boolean
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Remove the tray plugin file from EVERY dir it could live in (the dedicated dir and
|
|
214
|
+
* a configured SwiftBar dir) and refresh SwiftBar. DELIBERATELY leaves SwiftBar.app
|
|
215
|
+
* installed (the user may run other plugins) and NEVER touches the fleet — the daemon,
|
|
216
|
+
* TUI and delivery keep running (acceptance criterion: GUI teardown ≠ fleet teardown).
|
|
217
|
+
*/
|
|
218
|
+
export function uninstallTray(
|
|
219
|
+
opts: { env?: NodeJS.ProcessEnv; launch?: boolean; run?: Runner; probeApp?: () => boolean } = {},
|
|
220
|
+
): UninstallTrayResult {
|
|
221
|
+
const env = opts.env ?? process.env
|
|
222
|
+
const run = opts.run ?? defaultRun
|
|
223
|
+
assertTraySandboxIsolated(env)
|
|
224
|
+
const present = (opts.probeApp ?? swiftBarInstalled)()
|
|
225
|
+
const dirs = new Set<string>([dedicatedPluginDir(env)])
|
|
226
|
+
if (present) {
|
|
227
|
+
const existing = readSwiftBarPluginDir(run)
|
|
228
|
+
if (existing) dirs.add(existing)
|
|
229
|
+
}
|
|
230
|
+
const removed: string[] = []
|
|
231
|
+
for (const d of dirs) {
|
|
232
|
+
const f = join(d, PLUGIN_BASENAME)
|
|
233
|
+
if (existsSync(f)) {
|
|
234
|
+
rmSync(f, { force: true })
|
|
235
|
+
removed.push(f)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
let refreshed = false
|
|
239
|
+
if (opts.launch && present) {
|
|
240
|
+
refreshSwiftBar(run)
|
|
241
|
+
refreshed = true
|
|
242
|
+
}
|
|
243
|
+
return { removed, refreshed }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface TrayStatus {
|
|
247
|
+
daemon: { fleet: boolean; version?: string; sock?: string; tcp?: string }
|
|
248
|
+
swiftbar: { installed: boolean; pluginDir?: string }
|
|
249
|
+
plugin: { installed: boolean; path?: string }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Read-only diagnostics — never repairs anything. */
|
|
253
|
+
export function trayStatus(opts: { env?: NodeJS.ProcessEnv; run?: Runner; probeApp?: () => boolean } = {}): TrayStatus {
|
|
254
|
+
const env = opts.env ?? process.env
|
|
255
|
+
const run = opts.run ?? defaultRun
|
|
256
|
+
const addr = resolveFleetAddress({ env })
|
|
257
|
+
const installed = (opts.probeApp ?? swiftBarInstalled)()
|
|
258
|
+
const cfgDir = installed ? readSwiftBarPluginDir(run) : undefined
|
|
259
|
+
const searchDirs = [cfgDir, dedicatedPluginDir(env)].filter(Boolean) as string[]
|
|
260
|
+
const found = searchDirs.map(d => join(d, PLUGIN_BASENAME)).find(f => existsSync(f))
|
|
261
|
+
return {
|
|
262
|
+
daemon: { fleet: addr.fleet === 1, version: addr.version, sock: addr.sock, tcp: addr.tcp },
|
|
263
|
+
swiftbar: { installed, pluginDir: cfgDir },
|
|
264
|
+
plugin: { installed: Boolean(found), path: found },
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// tray/render — PURE SwiftBar-format renderer for the fleet snapshot. No IO, fully
|
|
2
|
+
// deterministic (a fixed snapshot + `now` → a fixed string), so it is unit-tested
|
|
3
|
+
// directly. It consumes a snapshot modelled by its OWN local interfaces (the docs/15
|
|
4
|
+
// shape) and ignores every field it does not name — client obligation 2 (additive
|
|
5
|
+
// evolution: a new snapshot field must never break an old client). This module is the
|
|
6
|
+
// second consumer of docs/15 after fleet.ts wrote it — the contract dogfood.
|
|
7
|
+
//
|
|
8
|
+
// SwiftBar output grammar (see docs/16-tray.md): lines before the first `---` are the
|
|
9
|
+
// menu-bar title; lines after are the dropdown. Item params follow ` | key=value`.
|
|
10
|
+
// Submenu depth is a `--` prefix per level. Click actions: `bash=<bin> paramN=…
|
|
11
|
+
// terminal=<bool>` runs the binary (terminal=true opens Terminal.app — that is how a
|
|
12
|
+
// peer row hands off to `iapeer attach`, the terminal-handoff the API deliberately
|
|
13
|
+
// keeps client-side).
|
|
14
|
+
|
|
15
|
+
const GLYPH: Record<'live' | 'asleep' | 'stopped', string> = { live: '●', asleep: '○', stopped: '✕' }
|
|
16
|
+
|
|
17
|
+
const COLOR = {
|
|
18
|
+
meta: '#8b949e',
|
|
19
|
+
live: '#3fb950',
|
|
20
|
+
down: '#f85149',
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
// ── docs/15 snapshot shape (only the fields the tray reads) ───────────────────
|
|
24
|
+
|
|
25
|
+
export interface TrayRuntime {
|
|
26
|
+
runtime: string
|
|
27
|
+
status: 'live' | 'asleep' | 'stopped'
|
|
28
|
+
attached?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TrayPeer {
|
|
32
|
+
personality: string
|
|
33
|
+
intelligence?: string
|
|
34
|
+
runtimes: TrayRuntime[]
|
|
35
|
+
last_active_runtime?: string
|
|
36
|
+
last_active_ms?: number
|
|
37
|
+
attached?: boolean
|
|
38
|
+
launchd_managed?: boolean
|
|
39
|
+
wake_policy?: 'warm' | 'ephemeral'
|
|
40
|
+
queue_depth?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TraySnapshot {
|
|
44
|
+
version?: string
|
|
45
|
+
host?: {
|
|
46
|
+
version?: string
|
|
47
|
+
uptimeSecs?: number
|
|
48
|
+
memory?: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
|
|
49
|
+
voice?: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
|
|
50
|
+
fda?: boolean | null
|
|
51
|
+
}
|
|
52
|
+
peers: TrayPeer[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RenderOptions {
|
|
56
|
+
/** Absolute path to the installed `iapeer` binary (embedded into click actions). */
|
|
57
|
+
binPath: string
|
|
58
|
+
/** Clock for age rendering (injectable for tests). */
|
|
59
|
+
now?: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── param serialization ───────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/** Serialize SwiftBar item params. Simple tokens stay bare; anything else is
|
|
65
|
+
* double-quoted with embedded quotes escaped (SwiftBar accepts quoted values). */
|
|
66
|
+
function params(pairs: Record<string, string | number | boolean | undefined>): string {
|
|
67
|
+
const parts: string[] = []
|
|
68
|
+
for (const [k, v] of Object.entries(pairs)) {
|
|
69
|
+
if (v === undefined) continue
|
|
70
|
+
const s = String(v)
|
|
71
|
+
const val = /^[\w.#/@-]+$/.test(s) ? s : `"${s.replace(/"/g, '\\"')}"`
|
|
72
|
+
parts.push(`${k}=${val}`)
|
|
73
|
+
}
|
|
74
|
+
return parts.length ? ` | ${parts.join(' ')}` : ''
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One dropdown line at submenu `depth` (0 = top level). */
|
|
78
|
+
function line(depth: number, text: string, p: Record<string, string | number | boolean | undefined> = {}): string {
|
|
79
|
+
return `${'--'.repeat(depth)}${text}${params(p)}`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A menu separator at submenu `depth`: the `--`×depth prefix + `---` (SwiftBar: a
|
|
83
|
+
* top-level separator is `---`, a depth-1 one is `-----`, etc.). */
|
|
84
|
+
function separator(depth: number): string {
|
|
85
|
+
return `${'--'.repeat(depth)}---`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── time helpers ────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export function fmtUptime(secs: number): string {
|
|
91
|
+
if (!Number.isFinite(secs) || secs < 0) return '?'
|
|
92
|
+
const d = Math.floor(secs / 86400)
|
|
93
|
+
const h = Math.floor((secs % 86400) / 3600)
|
|
94
|
+
const m = Math.floor((secs % 3600) / 60)
|
|
95
|
+
if (d > 0) return `${d}d ${h}h`
|
|
96
|
+
if (h > 0) return `${h}h ${m}m`
|
|
97
|
+
if (m > 0) return `${m}m`
|
|
98
|
+
return `${Math.floor(secs)}s`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function fmtAge(ms: number, now: number): string {
|
|
102
|
+
const secs = Math.floor((now - ms) / 1000)
|
|
103
|
+
if (!Number.isFinite(secs)) return ''
|
|
104
|
+
if (secs < 45) return 'now'
|
|
105
|
+
const m = Math.floor(secs / 60)
|
|
106
|
+
if (m < 60) return `${m}m ago`
|
|
107
|
+
const h = Math.floor(m / 60)
|
|
108
|
+
if (h < 24) return `${h}h ago`
|
|
109
|
+
return `${Math.floor(h / 24)}d ago`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── peer status aggregation ─────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
function aggregate(p: TrayPeer): 'live' | 'asleep' | 'stopped' {
|
|
115
|
+
if (p.runtimes.some(r => r.status === 'live')) return 'live'
|
|
116
|
+
if (p.runtimes.some(r => r.status === 'asleep')) return 'asleep'
|
|
117
|
+
return 'stopped'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const RANK: Record<'live' | 'asleep' | 'stopped', number> = { live: 0, asleep: 1, stopped: 2 }
|
|
121
|
+
|
|
122
|
+
// ── renderers ─────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
const MANAGE_COMMANDS: Array<{ cmd: string; label: string }> = [
|
|
125
|
+
{ cmd: 'wake', label: 'Wake' },
|
|
126
|
+
{ cmd: 'stop', label: 'Stop' },
|
|
127
|
+
{ cmd: 'start', label: 'Start' },
|
|
128
|
+
{ cmd: 'new', label: 'New session (fresh)' },
|
|
129
|
+
{ cmd: 'interrupt', label: 'Interrupt' },
|
|
130
|
+
{ cmd: 'refresh', label: 'Refresh (soft-reload)' },
|
|
131
|
+
{ cmd: 'compact', label: 'Compact dialogue' },
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
/** The full SwiftBar plugin output for a reachable daemon. */
|
|
135
|
+
export function renderSwiftBar(snapshot: TraySnapshot, opts: RenderOptions): string {
|
|
136
|
+
const now = opts.now ?? Date.now()
|
|
137
|
+
const bin = opts.binPath
|
|
138
|
+
const peers = [...snapshot.peers].sort((a, b) => {
|
|
139
|
+
const r = RANK[aggregate(a)] - RANK[aggregate(b)]
|
|
140
|
+
return r !== 0 ? r : a.personality.localeCompare(b.personality)
|
|
141
|
+
})
|
|
142
|
+
const liveCount = snapshot.peers.filter(p => aggregate(p) === 'live').length
|
|
143
|
+
|
|
144
|
+
const lines: string[] = []
|
|
145
|
+
// ── menu-bar title: antenna icon + live-peer count ──
|
|
146
|
+
lines.push(line(0, String(liveCount), { sfimage: 'antenna.radiowaves.left.and.right' }))
|
|
147
|
+
lines.push('---')
|
|
148
|
+
|
|
149
|
+
// ── host header ──
|
|
150
|
+
const ver = snapshot.host?.version ?? snapshot.version ?? '?'
|
|
151
|
+
const up = snapshot.host?.uptimeSecs !== undefined ? ` · up ${fmtUptime(snapshot.host.uptimeSecs)}` : ''
|
|
152
|
+
lines.push(line(0, `iapeer ${ver}${up}`, { color: COLOR.meta }))
|
|
153
|
+
const providers = renderProviders(snapshot.host)
|
|
154
|
+
if (providers) lines.push(line(0, providers, { color: COLOR.meta }))
|
|
155
|
+
lines.push('---')
|
|
156
|
+
|
|
157
|
+
// ── peers ──
|
|
158
|
+
if (peers.length === 0) {
|
|
159
|
+
lines.push(line(0, 'no peers registered', { color: COLOR.meta }))
|
|
160
|
+
}
|
|
161
|
+
for (const p of peers) {
|
|
162
|
+
lines.push(...renderPeerRow(p, bin, now))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── footer ──
|
|
166
|
+
lines.push('---')
|
|
167
|
+
lines.push(line(0, 'Refresh', { refresh: true, sfimage: 'arrow.clockwise' }))
|
|
168
|
+
lines.push(line(0, `iapeer · fleet dashboard`, { color: COLOR.meta, size: 11 }))
|
|
169
|
+
return lines.join('\n') + '\n'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderProviders(host: TraySnapshot['host']): string {
|
|
173
|
+
if (!host) return ''
|
|
174
|
+
const seg: string[] = []
|
|
175
|
+
const one = (label: string, slot: { version: string; heartbeatAgeSecs: number | null } | null | undefined): void => {
|
|
176
|
+
if (!slot) return
|
|
177
|
+
// fresh heartbeat (< 90s) → ●, declared-but-silent → ○
|
|
178
|
+
const glyph = slot.heartbeatAgeSecs !== null && slot.heartbeatAgeSecs < 90 ? '●' : '○'
|
|
179
|
+
seg.push(`${label} ${glyph} ${slot.version}`)
|
|
180
|
+
}
|
|
181
|
+
one('mem', host.memory)
|
|
182
|
+
one('voice', host.voice)
|
|
183
|
+
if (host.fda === false) seg.push('⚠ no full-disk-access')
|
|
184
|
+
return seg.join(' · ')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function renderPeerRow(p: TrayPeer, bin: string, now: number): string[] {
|
|
188
|
+
const agg = aggregate(p)
|
|
189
|
+
const detail = p.runtimes.map(r => `${r.runtime}${GLYPH[r.status]}`).join(' ')
|
|
190
|
+
const badges =
|
|
191
|
+
(p.attached ? ' 👤' : '') +
|
|
192
|
+
(p.launchd_managed ? ' 🔒' : '') +
|
|
193
|
+
(p.wake_policy === 'ephemeral' && (p.queue_depth ?? 0) > 0 ? ` ⏳${p.queue_depth}` : '')
|
|
194
|
+
const ageStr = p.last_active_ms ? ` ${fmtAge(p.last_active_ms, now)}` : ''
|
|
195
|
+
const label = `${p.personality} ${detail}${badges}${ageStr}`
|
|
196
|
+
const rowColor = agg === 'live' ? COLOR.live : COLOR.meta
|
|
197
|
+
|
|
198
|
+
const out: string[] = []
|
|
199
|
+
// The peer row IS the attach action: click → Terminal.app running `iapeer attach
|
|
200
|
+
// <peer>` (attach resolves its own runtime — default-anchored last-active, same as
|
|
201
|
+
// the CLI). terminal=true is the terminal-handoff docs/15 keeps client-side.
|
|
202
|
+
out.push(
|
|
203
|
+
line(0, label, {
|
|
204
|
+
color: rowColor,
|
|
205
|
+
bash: bin,
|
|
206
|
+
param1: 'attach',
|
|
207
|
+
param2: p.personality,
|
|
208
|
+
terminal: true,
|
|
209
|
+
}),
|
|
210
|
+
)
|
|
211
|
+
// Lifecycle controls live in a submenu (they route through the fleet command
|
|
212
|
+
// endpoints via `iapeer tray cmd …` — dogfooding POST /fleet/v1/peers/<p>/<cmd>).
|
|
213
|
+
out.push(line(1, 'Attach…', { bash: bin, param1: 'attach', param2: p.personality, terminal: true, sfimage: 'terminal' }))
|
|
214
|
+
out.push(separator(1))
|
|
215
|
+
for (const c of MANAGE_COMMANDS) {
|
|
216
|
+
out.push(
|
|
217
|
+
line(1, c.label, {
|
|
218
|
+
bash: bin,
|
|
219
|
+
param1: 'tray',
|
|
220
|
+
param2: 'cmd',
|
|
221
|
+
param3: c.cmd,
|
|
222
|
+
param4: p.personality,
|
|
223
|
+
terminal: false,
|
|
224
|
+
refresh: true,
|
|
225
|
+
}),
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The degraded output when no advertised daemon address answers (docs/15: "A
|
|
232
|
+
* dashboard that cannot reach any advertised address should render the daemon as
|
|
233
|
+
* down"). Distinct menu-bar icon + a red header; a Refresh item to retry. */
|
|
234
|
+
export function renderDaemonDown(reason: string, opts: RenderOptions): string {
|
|
235
|
+
const lines: string[] = []
|
|
236
|
+
lines.push(line(0, '', { sfimage: 'exclamationmark.triangle.fill', color: COLOR.down }))
|
|
237
|
+
lines.push('---')
|
|
238
|
+
lines.push(line(0, 'iapeer daemon unreachable', { color: COLOR.down }))
|
|
239
|
+
lines.push(line(0, reason, { color: COLOR.meta }))
|
|
240
|
+
lines.push('---')
|
|
241
|
+
lines.push(line(0, 'Refresh', { refresh: true, sfimage: 'arrow.clockwise' }))
|
|
242
|
+
return lines.join('\n') + '\n'
|
|
243
|
+
}
|