@agfpd/iapeer 0.4.83 → 0.4.85
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/14-configuration-and-logs.md +4 -0
- package/docs/16-tray.md +61 -22
- package/docs/ru/14-/320/272/320/276/320/275/321/204/320/270/320/263/321/203/321/200/320/260/321/206/320/270/321/217-/320/270-/320/266/321/203/321/200/320/275/320/260/320/273/321/213.md +4 -0
- package/docs/ru/16-tray.md +59 -21
- package/package.json +1 -1
- package/src/launch/cmdlog.ts +108 -40
- package/src/lifecycle/index.ts +10 -8
- package/src/tray/index.ts +73 -1
- package/src/tray/install.ts +37 -14
- package/src/tray/render.ts +23 -1
|
@@ -61,6 +61,8 @@ The daemon's supervisory tick is fixed — once a minute, not configurable.
|
|
|
61
61
|
| `IAPEER_LIFECYCLE_LOG_KEEP` | `5` | How many `lifecycle.log` rotations to keep. |
|
|
62
62
|
| `IAPEER_DELIVERY_LOG_MAX_BYTES` | `5 MiB` | The `delivery.log` size for rotation. |
|
|
63
63
|
| `IAPEER_DELIVERY_LOG_KEEP` | `5` | How many `delivery.log` rotations to keep. |
|
|
64
|
+
| `IAPEER_PANELOG_MAX_BYTES` | `16 MiB` | The per-peer pane-log (`logs/lifecycle/<runtime>-<peer>.log`, the raw terminal byte stream) size at which rotation happens. |
|
|
65
|
+
| `IAPEER_PANELOG_KEEP` | `2` | How many rotated pane-log copies to keep (`<log>.1` newest … `.N`). |
|
|
64
66
|
| `IAPEER_SUPERVISE_LOG_VERBOSE` | off | A verbose log of supervisory ticks (by default only decisions are written, not routine "doing nothing"). |
|
|
65
67
|
| `IAPEER_DAEMON_LOG` | off | An extended daemon log. |
|
|
66
68
|
|
|
@@ -80,6 +82,8 @@ The daemon keeps three durable logs in `~/.iapeer/logs/iapeer/`:
|
|
|
80
82
|
- **`delivery.log`** — the outcome of each delivery: who, to whom, into which runtime, the result, sizes, topic. Metadata only — **the message body is not written**.
|
|
81
83
|
- **`exits.log`** — session deaths with postmortem data: the supervisor records the cause (exit code / signal) at the moment a session dies.
|
|
82
84
|
|
|
85
|
+
Per-peer **pane-logs** live separately under `~/.iapeer/logs/lifecycle/<runtime>-<peer>.log` — the raw terminal (pty) byte stream the supervisor appends for a session's whole life (occupancy/typing detection, the boot ready-gate, and `tail`-based monitors read them). They are rotated on the supervise tick by **copytruncate** (`IAPEER_PANELOG_MAX_BYTES` × `IAPEER_PANELOG_KEEP`, default 16 MiB × 2): the base file keeps its path and inode (its last 8 MiB tail is retained in place, so the live writer and every reader are undisturbed) while the preceding window is promoted to `<log>.1` … `.N`. Total per peer is bounded by roughly `MAX_BYTES × (KEEP + 1)`.
|
|
86
|
+
|
|
83
87
|
### Postmortem diagnostics
|
|
84
88
|
|
|
85
89
|
The cause of a session's death (exit code / signal) the supervisor records at the moment of death to `exits.log` — what an ordinary "is the process alive" check misses. It's the material for answering "why did the agent die".
|
package/docs/16-tray.md
CHANGED
|
@@ -80,12 +80,12 @@ whole dashboard twice.
|
|
|
80
80
|
|
|
81
81
|
| Verb | What it does |
|
|
82
82
|
|---|---|
|
|
83
|
-
| `iapeer tray install [--plugin-only]` | Install SwiftBar.app when absent (owner-sanctioned), point it at the plugin dir, write the plugin, launch + refresh, **and register the
|
|
84
|
-
| `iapeer tray uninstall` | Remove the plugin file, refresh SwiftBar, **and tear down the
|
|
83
|
+
| `iapeer tray install [--plugin-only]` | Install SwiftBar.app when absent (owner-sanctioned), point it at the plugin dir, write the plugin, launch + refresh, **and register the tray-host supervisor LaunchAgent** (login autostart + relaunch-on-death). `--plugin-only` writes just the plugin file (no app, no launch, no autostart — what the foundation install does). Idempotent. |
|
|
84
|
+
| `iapeer tray uninstall` | Remove the plugin file, refresh SwiftBar, **and tear down the tray-host supervisor LaunchAgent**. **Never touches the fleet** — the daemon, TUI and delivery keep running; SwiftBar.app is left installed (it may host other plugins). |
|
|
85
85
|
| `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. |
|
|
86
86
|
| `iapeer tray cmd <command> <peer> [runtime]` | POST a fleet command (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — the menu's lifecycle actions call this. |
|
|
87
87
|
| `iapeer tray approve <id>` · `iapeer tray deny <id> [reason]` | Resolve a pending human-approval (docs/17) — `POST /fleet/v1/approvals/<id>/(approve\|deny)` over the same unix-first fleet client. The menu's Allow/Deny items call these; the single-queue invariant means the resolution is seen by every channel (CLI, telegram). |
|
|
88
|
-
| `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, **is the
|
|
88
|
+
| `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, **is the tray-host supervisor registered**. Repairs nothing. |
|
|
89
89
|
|
|
90
90
|
## How it is wired
|
|
91
91
|
|
|
@@ -102,25 +102,32 @@ plugin is installed into the user's own dir); otherwise a dedicated
|
|
|
102
102
|
reads on launch (it is not sandboxed and stores no security-scoped bookmark), so the
|
|
103
103
|
folder picker is skipped.
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
`~/Library/LaunchAgents/com.agfpd.iapeer.tray.plist` (RunAtLoad
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
105
|
+
Tray-host supervision: activating the tray registers a user LaunchAgent
|
|
106
|
+
`~/Library/LaunchAgents/com.agfpd.iapeer.tray.plist` (RunAtLoad **and KeepAlive**) that
|
|
107
|
+
runs a tiny `/bin/sh` watchdog loop: every 5 s, if no SwiftBar process exists, `open -g
|
|
108
|
+
-b com.ameba.SwiftBar` (a no-op activation when it is already running — race-safe, never
|
|
109
|
+
a duplicate instance; an absent SwiftBar.app backs off 5 min). So SwiftBar — and with it
|
|
110
|
+
the fleet dashboard + approval badge — comes back on its own after a reboot AND within
|
|
111
|
+
seconds after a crash or a stray kill. The tray is the owner's fleet-visibility surface;
|
|
112
|
+
a silently-dead SwiftBar hides the state of the whole system, so its lifetime is
|
|
113
|
+
supervised, not fire-and-forget. Deliberate consequence: quitting SwiftBar by hand also
|
|
114
|
+
relaunches it — the supported off-switch is `iapeer tray uninstall`. launchd must NOT
|
|
115
|
+
KeepAlive `open` itself (it exits the moment SwiftBar is up — KeepAlive on it is a
|
|
116
|
+
respawn storm); KeepAlive guards the long-lived loop process. A **LaunchAgent** is used
|
|
117
|
+
deliberately, not a System Events "Login Item" nor SMAppService: a Login Item needs the
|
|
118
|
+
invoking process to hold Automation TCC over System Events (attributed to whatever
|
|
119
|
+
parent runs the verb — fragile in a headless / `iapeer update` context), and
|
|
120
|
+
SMAppService can only register a helper the *target* app ships (we cannot register the
|
|
121
|
+
third-party SwiftBar with it from a CLI). The LaunchAgent needs no TCC and reuses the
|
|
122
|
+
foundation's idempotent plist lifecycle: the plist is byte-stable (write-if-changed),
|
|
123
|
+
carries the `com.iapeer.managed` ownership sentinel, and is bootstrapped/cycled
|
|
124
|
+
undead-safe — so re-activation never plants a duplicate, and a foundation install
|
|
125
|
+
(`iapeer update`) upgrades an already-registered agent's plist in place (bytes-changed →
|
|
126
|
+
re-cycle). `tray uninstall` boots it out and removes the plist (only our own
|
|
127
|
+
sentinel-marked file — a foreign plist at the label is left untouched). The label lives
|
|
128
|
+
in the foundation's own `com.agfpd.*` namespace, never `com.iapeer.<personality>` (the
|
|
129
|
+
persistent-peer fleet), so the H4 launchd-owned sweep-guard can never mistake it for a
|
|
130
|
+
peer.
|
|
124
131
|
|
|
125
132
|
Auth follows the Fleet API: on an open-local host no bearer is needed; when the daemon is
|
|
126
133
|
configured with `IAPEER_BEARER_TOKEN`, the plugin's environment must carry it too.
|
|
@@ -128,6 +135,38 @@ configured with `IAPEER_BEARER_TOKEN`, the plugin's environment must carry it to
|
|
|
128
135
|
Config: `IAPEER_TRAY_HEARTBEAT_MS` tunes the heartbeat cadence (default 15 s, clamped
|
|
129
136
|
1 s…10 min).
|
|
130
137
|
|
|
138
|
+
## Icon visibility (why the stream writes in 512-byte quanta)
|
|
139
|
+
|
|
140
|
+
SwiftBar v2.0.1 decodes **every pipe chunk independently** (`RunScript.swift`:
|
|
141
|
+
`String(data: availableData, encoding: .utf8)` per readability callback). A chunk
|
|
142
|
+
boundary that lands inside a multi-byte UTF-8 character makes that decode `nil`;
|
|
143
|
+
`StreamablePlugin` treats `nil` as "clear content", and `MenuBarItem._updateMenu` answers
|
|
144
|
+
empty content with `hide()` — the `NSStatusItem` vanishes from the menu bar. Worse, the
|
|
145
|
+
item has an `autosaveName`, so AppKit **persists** the hidden state
|
|
146
|
+
(`NSStatusItem VisibleCC <plugin>` = 0 in SwiftBar's defaults): the icon stays gone
|
|
147
|
+
across SwiftBar restarts until some later update calls `show()`.
|
|
148
|
+
|
|
149
|
+
The tray's menu block is tens of kilobytes of emoji/box-glyph-rich text; a single
|
|
150
|
+
`write(2)` of it reaches the reader in pipe-buffer-fill chunks (measured live:
|
|
151
|
+
16384+16384+2400 — byte-arbitrary boundaries), so with a static fleet the same bad
|
|
152
|
+
boundary recurred every heartbeat and the icon stayed invisible for hours (owner
|
|
153
|
+
incident 13.07.2026, reproduced deterministically with a probe plugin that split an
|
|
154
|
+
emoji across two writes).
|
|
155
|
+
|
|
156
|
+
Two layers make the class impossible:
|
|
157
|
+
|
|
158
|
+
1. **Atomic UTF-8 quanta** — `tray render --stream` writes its output in chunks of
|
|
159
|
+
≤512 bytes (`PIPE_BUF`: writes of at most that size into a pipe are atomic,
|
|
160
|
+
all-or-nothing), each ending on a UTF-8 character boundary. The pipe buffer then only
|
|
161
|
+
ever contains whole quanta and every chunk SwiftBar can see is valid UTF-8 — the nil
|
|
162
|
+
decode cannot happen by construction.
|
|
163
|
+
2. **Freshness stamp** — every rendered block (and every daemon-down block) carries a
|
|
164
|
+
`HH:MM:SS` footer clock, so consecutive emits always differ and SwiftBar runs its
|
|
165
|
+
update path (which starts with `show()`) on every heartbeat. Any hidden icon —
|
|
166
|
+
whatever hid it (an accidental ⌘-drag removal, a stale persisted `VisibleCC` flag, a
|
|
167
|
+
future SwiftBar quirk) — recovers within one heartbeat (≤15 s) instead of staying
|
|
168
|
+
invisible while the fleet is static.
|
|
169
|
+
|
|
131
170
|
## Client obligations
|
|
132
171
|
|
|
133
172
|
Because the tray is a Fleet API client, it obeys the normative rules in
|
|
@@ -61,6 +61,8 @@
|
|
|
61
61
|
| `IAPEER_LIFECYCLE_LOG_KEEP` | `5` | Сколько ротаций `lifecycle.log` хранить. |
|
|
62
62
|
| `IAPEER_DELIVERY_LOG_MAX_BYTES` | `5 МиБ` | Размер `delivery.log` для ротации. |
|
|
63
63
|
| `IAPEER_DELIVERY_LOG_KEEP` | `5` | Сколько ротаций `delivery.log` хранить. |
|
|
64
|
+
| `IAPEER_PANELOG_MAX_BYTES` | `16 МиБ` | Размер пер-пирового pane-лога (`logs/lifecycle/<runtime>-<peer>.log`, сырой байтовый поток терминала), при котором происходит ротация. |
|
|
65
|
+
| `IAPEER_PANELOG_KEEP` | `2` | Сколько ротированных копий pane-лога хранить (`<log>.1` — свежайшая … `.N`). |
|
|
64
66
|
| `IAPEER_SUPERVISE_LOG_VERBOSE` | выкл. | Подробный лог надзорных тиков (по умолчанию пишутся только решения, не рутинные «ничего не делаю»). |
|
|
65
67
|
| `IAPEER_DAEMON_LOG` | выкл. | Расширенный лог демона. |
|
|
66
68
|
|
|
@@ -80,6 +82,8 @@
|
|
|
80
82
|
- **`delivery.log`** — исход каждой доставки: кто, кому, в какой рантайм, результат, размеры, тема. Только метаданные — **тело сообщения не пишется**.
|
|
81
83
|
- **`exits.log`** — смерти сессий с посмертными данными: супервизор фиксирует причину (код выхода / сигнал) в момент смерти сессии.
|
|
82
84
|
|
|
85
|
+
Пер-пировые **pane-логи** лежат отдельно в `~/.iapeer/logs/lifecycle/<runtime>-<peer>.log` — сырой байтовый поток терминала (pty), который супервизор дописывает всю жизнь сессии (его читают детект занятости/typing, boot ready-gate и `tail`-мониторы). Они ротируются на надзорном тике методом **copytruncate** (`IAPEER_PANELOG_MAX_BYTES` × `IAPEER_PANELOG_KEEP`, по умолчанию 16 МиБ × 2): базовый файл сохраняет путь и inode (его последние 8 МиБ остаются на месте, поэтому живой писатель и все читатели не задеты), а предшествующее окно повышается в `<log>.1` … `.N`. Суммарный объём на пира ограничен примерно `MAX_BYTES × (KEEP + 1)`.
|
|
86
|
+
|
|
83
87
|
### Посмертная диагностика
|
|
84
88
|
|
|
85
89
|
Причину смерти сессии (код выхода / сигнал) супервизор фиксирует в момент смерти в `exits.log` — это то, что обычная проверка «жив ли процесс» не ловит. Это материал для ответа на вопрос «почему агент умер».
|
package/docs/ru/16-tray.md
CHANGED
|
@@ -76,12 +76,12 @@ SwiftBar.app, если его нет.
|
|
|
76
76
|
|
|
77
77
|
| Verb | Что делает |
|
|
78
78
|
|---|---|
|
|
79
|
-
| `iapeer tray install [--plugin-only]` | Доустановит SwiftBar.app, если его нет (санкция владельца), нацелит его на каталог плагинов, запишет плагин, запустит + обновит **и зарегистрирует LaunchAgent
|
|
80
|
-
| `iapeer tray uninstall` | Удалит файл плагина, обновит SwiftBar **и снесёт LaunchAgent
|
|
79
|
+
| `iapeer tray install [--plugin-only]` | Доустановит SwiftBar.app, если его нет (санкция владельца), нацелит его на каталог плагинов, запишет плагин, запустит + обновит **и зарегистрирует LaunchAgent-супервизор tray-хоста** (автозапуск при логине + перезапуск после смерти). `--plugin-only` пишет только файл плагина (без приложения, запуска и автозапуска — так делает foundation-install). Идемпотентно. |
|
|
80
|
+
| `iapeer tray uninstall` | Удалит файл плагина, обновит SwiftBar **и снесёт LaunchAgent-супервизор tray-хоста**. **Флот не трогает** — демон, TUI и доставка живут; SwiftBar.app остаётся (может держать другие плагины). |
|
|
81
81
|
| `iapeer tray render [--stream]` | Печатает вывод плагина SwiftBar. `--stream` — streamable-цикл, который крутит плагин; одноразовая форма — поллинг-фолбэк и тест-поверхность. |
|
|
82
82
|
| `iapeer tray cmd <command> <peer> [runtime]` | POST fleet-команды (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — lifecycle-пункты меню зовут это. |
|
|
83
83
|
| `iapeer tray approve <id>` · `iapeer tray deny <id> [reason]` | Резолв ожидающего human-approval (docs/17) — `POST /fleet/v1/approvals/<id>/(approve\|deny)` через тот же unix-first fleet-клиент. Пункты Allow/Deny меню зовут это; инвариант единой очереди — резолв виден каждому каналу (CLI, telegram). |
|
|
84
|
-
| `iapeer tray status` | Только чтение: жив ли fleet API (из `router.json`), установлен ли SwiftBar и где его каталог плагинов, на месте ли файл плагина, **зарегистрирован ли
|
|
84
|
+
| `iapeer tray status` | Только чтение: жив ли fleet API (из `router.json`), установлен ли SwiftBar и где его каталог плагинов, на месте ли файл плагина, **зарегистрирован ли супервизор tray-хоста**. Ничего не чинит. |
|
|
85
85
|
|
|
86
86
|
## Как устроено
|
|
87
87
|
|
|
@@ -97,24 +97,30 @@ SwiftBar.app, если его нет.
|
|
|
97
97
|
который SwiftBar читает при запуске (он не sandboxed и не хранит security-scoped bookmark),
|
|
98
98
|
поэтому окно выбора папки пропускается.
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
`~/Library/LaunchAgents/com.agfpd.iapeer.tray.plist` (RunAtLoad
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
100
|
+
Супервизия tray-хоста: активация трея регистрирует пользовательский LaunchAgent
|
|
101
|
+
`~/Library/LaunchAgents/com.agfpd.iapeer.tray.plist` (RunAtLoad **и KeepAlive**), который
|
|
102
|
+
крутит крошечный watchdog-цикл `/bin/sh`: раз в 5 с, если процесса SwiftBar нет — `open -g -b
|
|
103
|
+
com.ameba.SwiftBar` (на уже работающем приложении это no-op-активация — гонко-безопасно, дубль
|
|
104
|
+
инстанса невозможен; при отсутствии SwiftBar.app — бэкофф 5 мин). Так SwiftBar — а с ним
|
|
105
|
+
fleet-дашборд и бейдж аппрувов — сам поднимается и после перезагрузки, И за секунды после
|
|
106
|
+
краша или случайного kill. Трей — поверхность видимости флота для владельца; молчаливо
|
|
107
|
+
мёртвый SwiftBar прячет состояние всей системы, поэтому его жизнь супервизируется, а не
|
|
108
|
+
«запустили и забыли». Намеренное следствие: закрытый руками SwiftBar тоже перезапустится —
|
|
109
|
+
штатный выключатель это `iapeer tray uninstall`. launchd НЕ должен KeepAlive'ить сам `open`
|
|
110
|
+
(тот завершается сразу, как SwiftBar поднят — KeepAlive на нём это respawn-шторм); KeepAlive
|
|
111
|
+
сторожит долгоживущий процесс цикла. **LaunchAgent** выбран намеренно, не «Login Item» через
|
|
112
|
+
System Events и не SMAppService: Login Item требует, чтобы вызывающий процесс держал
|
|
113
|
+
Automation-TCC на System Events (грант атрибутируется тому родителю, что запустил verb —
|
|
114
|
+
хрупко в headless / `iapeer update` контексте), а SMAppService умеет регистрировать только
|
|
115
|
+
хелпер, который несёт *само* целевое приложение (сторонний SwiftBar так из CLI не
|
|
116
|
+
зарегистрировать). LaunchAgent не требует TCC и переиспользует идемпотентный plist-жизненный
|
|
117
|
+
цикл foundation: plist байт-стабилен (пишется только при изменении), несёт sentinel владения
|
|
118
|
+
`com.iapeer.managed` и bootstrap'ится/пересоздаётся undead-safe — повторная активация не
|
|
119
|
+
плодит дубль, а foundation-install (`iapeer update`) апгрейдит plist уже зарегистрированного
|
|
120
|
+
агента на месте (байты изменились → пере-cycle). `tray uninstall` делает bootout и удаляет
|
|
121
|
+
plist (только наш файл с sentinel — чужой plist на этом лейбле не трогается). Лейбл живёт в
|
|
122
|
+
собственном namespace foundation `com.agfpd.*`, никогда не `com.iapeer.<personality>` (флот
|
|
123
|
+
persistent-пиров), поэтому H4-страж launchd-owned пиров не может принять его за пира.
|
|
118
124
|
|
|
119
125
|
Авторизация — как у Fleet API: на open-local хосте bearer не нужен; если демон настроен с
|
|
120
126
|
`IAPEER_BEARER_TOKEN`, окружение плагина тоже должно его нести.
|
|
@@ -122,6 +128,38 @@ H4-страж launchd-owned пиров не может принять его з
|
|
|
122
128
|
Конфиг: `IAPEER_TRAY_HEARTBEAT_MS` настраивает каденцию хартбита (по умолчанию 15 с, зажато
|
|
123
129
|
1 с…10 мин).
|
|
124
130
|
|
|
131
|
+
## Видимость иконки (почему стрим пишет квантами по 512 байт)
|
|
132
|
+
|
|
133
|
+
SwiftBar v2.0.1 декодирует **каждый чанк пайпа независимо** (`RunScript.swift`:
|
|
134
|
+
`String(data: availableData, encoding: .utf8)` на каждый readability-коллбек). Граница
|
|
135
|
+
чанка внутри многобайтового UTF-8-символа даёт `nil`; `StreamablePlugin` трактует `nil`
|
|
136
|
+
как «очисти контент», а `MenuBarItem._updateMenu` отвечает на пустой контент `hide()` —
|
|
137
|
+
`NSStatusItem` исчезает из менюбара. Хуже: у айтема есть `autosaveName`, и AppKit
|
|
138
|
+
**персистит** скрытость (`NSStatusItem VisibleCC <plugin>` = 0 в defaults SwiftBar) —
|
|
139
|
+
иконка остаётся скрытой через рестарты SwiftBar, пока какой-нибудь следующий апдейт не
|
|
140
|
+
позовёт `show()`.
|
|
141
|
+
|
|
142
|
+
Меню-блок трея — десятки килобайт текста, насыщенного эмодзи и бокс-глифами; один
|
|
143
|
+
`write(2)` доезжает до читателя чанками по заполнению пайп-буфера (замерено живьём:
|
|
144
|
+
16384+16384+2400 — границы побайтово произвольны), поэтому при статичном флоте одна и та
|
|
145
|
+
же плохая граница повторялась каждый хартбит и иконка была невидима часами (инцидент
|
|
146
|
+
владельца 13.07.2026, детерминированно воспроизведён probe-плагином, разрезавшим эмодзи
|
|
147
|
+
между двумя write).
|
|
148
|
+
|
|
149
|
+
Класс закрыт двумя слоями:
|
|
150
|
+
|
|
151
|
+
1. **Атомарные UTF-8-кванты** — `tray render --stream` пишет вывод кусками ≤512 байт
|
|
152
|
+
(`PIPE_BUF`: записи не больше этого размера в пайп атомарны, всё-или-ничего), каждый
|
|
153
|
+
заканчивается на границе UTF-8-символа. В пайп-буфере тогда лежат только целые
|
|
154
|
+
кванты, и любой чанк, который SwiftBar может увидеть, — валидный UTF-8: nil-декод
|
|
155
|
+
невозможен по построению.
|
|
156
|
+
2. **Штамп свежести** — каждый отрендеренный блок (и каждый блок daemon-down) несёт в
|
|
157
|
+
футере часы `HH:MM:SS`, так что соседние emit'ы всегда различаются и SwiftBar гоняет
|
|
158
|
+
свой update-путь (начинающийся с `show()`) на каждом хартбите. Любая скрытая иконка —
|
|
159
|
+
чем бы её ни скрыло (случайный ⌘-drag, залипший персистентный флаг `VisibleCC`,
|
|
160
|
+
будущая причуда SwiftBar) — восстанавливается за один хартбит (≤15 с), а не висит
|
|
161
|
+
невидимой, пока флот статичен.
|
|
162
|
+
|
|
125
163
|
## Обязанности клиента
|
|
126
164
|
|
|
127
165
|
Поскольку трей — клиент Fleet API, он соблюдает нормативные правила из
|
package/package.json
CHANGED
package/src/launch/cmdlog.ts
CHANGED
|
@@ -1,69 +1,137 @@
|
|
|
1
|
-
// Pane-log volume
|
|
2
|
-
// pty byte stream the supervisor appends for a session's whole life. Unlike
|
|
3
|
-
// (their own size
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Pane-log volume ROTATION — bound the per-identity pane-logs (<logDir>/<identity>.log),
|
|
2
|
+
// the RAW child pty byte stream the supervisor appends for a session's whole life. Unlike
|
|
3
|
+
// the daemon lifecycle/delivery/approvals logs (their own size+copies rotation under
|
|
4
|
+
// logs/iapeer via storage/rotatelog.ts), the pane-log had NO bound and grew to tens of MB
|
|
5
|
+
// per warm peer AND per always-on router on this host; the supervise tick rotates it here.
|
|
6
|
+
//
|
|
7
|
+
// MECHANISM — COPYTRUNCATE, not rename. The pane-log is written by a LONG-LIVED supervisor
|
|
8
|
+
// holding a persistent O_APPEND fd (a different process from this daemon-side janitor), and
|
|
9
|
+
// it is READ live by load-bearing consumers: telegram-runtime's occupancy signal keys on
|
|
10
|
+
// the file's MTIME at its fixed path (canon: the pane-log occupancy contract), the boot
|
|
11
|
+
// ready-gate seeds a headless xterm from the tail, doc's monitor tail -c's it. A rename
|
|
12
|
+
// would move the writer's fd onto the rotated inode and briefly vanish the base path —
|
|
13
|
+
// breaking mtime-occupancy. So rotation keeps the base INODE and PATH: it copies the base's
|
|
14
|
+
// last maxBytes out to <path>.1 (shifting older copies up), then TRUNCATES the base in place
|
|
15
|
+
// to its last tailBytes. Same inode ⇒ the supervisor's O_APPEND fd resumes at the new EOF
|
|
16
|
+
// (proven by the live truncation test), mtime stays fresh, and the В19 heal never sees an
|
|
17
|
+
// inode change (no spurious reopen). Readers keep reading the same path; the rotated copies
|
|
18
|
+
// (<path>.1..N) are history nobody reads live.
|
|
19
|
+
//
|
|
20
|
+
// Best-effort BY CONSTRUCTION: rotation must never fail a supervise tick — every FS op
|
|
21
|
+
// swallows errors. The dropped head of an already-oversized legacy file is redraw noise; the
|
|
22
|
+
// last maxBytes is snapshotted, older bytes are discarded (never a giant copy of the bloat).
|
|
6
23
|
|
|
7
|
-
import { openSync, closeSync, readSync, readdirSync, statSync, writeFileSync, fstatSync } from 'fs'
|
|
24
|
+
import { openSync, closeSync, readSync, readdirSync, statSync, unlinkSync, writeFileSync, fstatSync, renameSync } from 'fs'
|
|
8
25
|
import { join } from 'path'
|
|
9
26
|
|
|
10
|
-
/**
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
27
|
+
/** Rotate threshold (env IAPEER_PANELOG_MAX_BYTES) — a base pane-log over this is rotated.
|
|
28
|
+
* Default 16 MiB (owner direction 12.07: order 10–20 MiB × 2–3 copies). */
|
|
29
|
+
export const PANELOG_MAX_BYTES = 16 * 1024 * 1024
|
|
30
|
+
/** Number of rotated copies kept (env IAPEER_PANELOG_KEEP) — <path>.1 (newest) … .N. Default 2. */
|
|
31
|
+
export const PANELOG_COPIES = 2
|
|
32
|
+
/** Base TAIL kept in place after a rotation. INVARIANT: ≥ the pane-log reader's seed window
|
|
33
|
+
* (readyGateModel SEED_BYTES = 4 MiB, the tail fed into the headless xterm for
|
|
34
|
+
* composer-occupancy / viewport). 8 MiB = 2× the seed. This is the reader-floor the base is
|
|
35
|
+
* truncated to — NOT the copy count (that is IAPEER_PANELOG_KEEP). Internal invariant, not
|
|
36
|
+
* env-tunable: capping the base below the seed would starve occupancy/ready-gate detection. */
|
|
37
|
+
export const PANELOG_TAIL_BYTES = 8 * 1024 * 1024
|
|
17
38
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
39
|
+
/** Parse a positive integer env value, else the default (mirror of the daemon-log config helpers). */
|
|
40
|
+
function envPosInt(raw: string | undefined, dflt: number): number {
|
|
41
|
+
const n = raw !== undefined ? Number(raw) : NaN
|
|
42
|
+
return Number.isInteger(n) && n > 0 ? n : dflt
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resolve the pane-log rotation knobs from env (defaults above). maxBytes is floored just
|
|
46
|
+
* above the reader-tail so the base can always drop below the threshold after a rotation
|
|
47
|
+
* (else rotation would fire every tick). */
|
|
48
|
+
export function paneLogRotateConfig(env: NodeJS.ProcessEnv = process.env): { maxBytes: number; copies: number } {
|
|
49
|
+
const maxBytes = Math.max(envPosInt(env.IAPEER_PANELOG_MAX_BYTES, PANELOG_MAX_BYTES), PANELOG_TAIL_BYTES + 1024 * 1024)
|
|
50
|
+
return { maxBytes, copies: envPosInt(env.IAPEER_PANELOG_KEEP, PANELOG_COPIES) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read the last wantBytes of a file into a Buffer (or null on error / absent). */
|
|
54
|
+
function readTail(path: string, wantBytes: number): Buffer | null {
|
|
24
55
|
try {
|
|
25
|
-
if (statSync(path).size <= capBytes) return false
|
|
26
56
|
const fd = openSync(path, 'r')
|
|
27
|
-
let tail: Buffer
|
|
28
57
|
try {
|
|
29
58
|
const size = fstatSync(fd).size
|
|
30
|
-
const start = Math.max(0, size -
|
|
31
|
-
|
|
32
|
-
readSync(fd,
|
|
59
|
+
const start = Math.max(0, size - wantBytes)
|
|
60
|
+
const buf = Buffer.alloc(Math.min(wantBytes, size))
|
|
61
|
+
if (buf.length > 0) readSync(fd, buf, 0, buf.length, start)
|
|
62
|
+
return buf
|
|
33
63
|
} finally {
|
|
34
64
|
closeSync(fd)
|
|
35
65
|
}
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Rotate ONE pane-log file if it exceeds maxBytes (copytruncate — see file header):
|
|
72
|
+
* 1. snapshot the base's last maxBytes (bounded — never a giant copy of legacy bloat)
|
|
73
|
+
* 2. shift .(k)→.(k+1), dropping the oldest beyond `copies`; write the snapshot to .1
|
|
74
|
+
* 3. truncate the base IN PLACE to its last tailBytes (same inode: writer fd + mtime +
|
|
75
|
+
* reader seed all survive; no В19 reopen)
|
|
76
|
+
* Returns true iff rotated. Best-effort: swallows FS errors. */
|
|
77
|
+
function rotateFile(path: string, maxBytes: number, copies: number, tailBytes: number): boolean {
|
|
78
|
+
try {
|
|
79
|
+
if (statSync(path).size <= maxBytes) return false
|
|
80
|
+
// 1 — snapshot the tail we promote to .1, taken BEFORE any truncate (copytruncate race:
|
|
81
|
+
// bytes appended between here and the truncate are lost — accepted mid-life noise,
|
|
82
|
+
// same as the pre-rotation tail-keep).
|
|
83
|
+
const rotBuf = readTail(path, maxBytes)
|
|
84
|
+
if (rotBuf === null || rotBuf.length === 0) return false
|
|
85
|
+
// 2 — shift existing copies up, dropping the oldest; also sweep one orphan beyond the
|
|
86
|
+
// current copy count (handles a just-lowered IAPEER_PANELOG_KEEP).
|
|
87
|
+
try {
|
|
88
|
+
unlinkSync(`${path}.${copies + 1}`)
|
|
89
|
+
} catch {
|
|
90
|
+
/* absent → fine */
|
|
91
|
+
}
|
|
92
|
+
for (let k = copies - 1; k >= 1; k--) {
|
|
93
|
+
try {
|
|
94
|
+
renameSync(`${path}.${k}`, `${path}.${k + 1}`)
|
|
95
|
+
} catch {
|
|
96
|
+
/* absent → skip */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (copies >= 1) writeFileSync(`${path}.1`, rotBuf, { mode: 0o600 })
|
|
100
|
+
// 3 — tail-keep the base in place (last tailBytes of the snapshot; same inode).
|
|
101
|
+
const tail = tailBytes >= rotBuf.length ? rotBuf : rotBuf.subarray(rotBuf.length - tailBytes)
|
|
36
102
|
writeFileSync(path, tail, { mode: 0o600 })
|
|
37
103
|
return true
|
|
38
104
|
} catch {
|
|
39
|
-
return false // per-file best-effort
|
|
105
|
+
return false // per-file best-effort — a rotation never fails a supervise tick
|
|
40
106
|
}
|
|
41
107
|
}
|
|
42
108
|
|
|
43
|
-
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Rotate the per-identity PANE-LOGS in logDir (logs/lifecycle). Scans top-level `*.log`
|
|
111
|
+
* only — the rotated copies are `<identity>.log.1..N` (do NOT end in `.log`), so they are
|
|
112
|
+
* never re-processed, and no other log lives here. Called each supervise tick (NOT H4-gated:
|
|
113
|
+
* log janitoring is orthogonal to lifecycle ownership, so launchd-managed routers' pane-logs
|
|
114
|
+
* are rotated too). maxBytes/copies come from paneLogRotateConfig(env). Best-effort by
|
|
115
|
+
* construction. Returns the rotated paths.
|
|
116
|
+
*/
|
|
51
117
|
export function capPaneLogs(
|
|
52
118
|
logDir: string,
|
|
53
|
-
|
|
54
|
-
|
|
119
|
+
maxBytes: number = PANELOG_MAX_BYTES,
|
|
120
|
+
copies: number = PANELOG_COPIES,
|
|
121
|
+
tailBytes: number = PANELOG_TAIL_BYTES,
|
|
55
122
|
): string[] {
|
|
56
|
-
const
|
|
123
|
+
const cap = Math.max(maxBytes, tailBytes + 1024 * 1024) // base(tail) must be able to drop below cap
|
|
124
|
+
const rotated: string[] = []
|
|
57
125
|
let names: string[]
|
|
58
126
|
try {
|
|
59
127
|
names = readdirSync(logDir)
|
|
60
128
|
} catch {
|
|
61
|
-
return
|
|
129
|
+
return rotated
|
|
62
130
|
}
|
|
63
131
|
for (const name of names) {
|
|
64
|
-
if (!name.endsWith('.log')) continue
|
|
132
|
+
if (!name.endsWith('.log')) continue // skips the rotated <identity>.log.1/.2
|
|
65
133
|
const path = join(logDir, name)
|
|
66
|
-
if (
|
|
134
|
+
if (rotateFile(path, cap, copies, tailBytes)) rotated.push(path)
|
|
67
135
|
}
|
|
68
|
-
return
|
|
136
|
+
return rotated
|
|
69
137
|
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from '../core/constants.ts'
|
|
27
27
|
import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
|
|
28
28
|
import { err, ok, type Result } from '../core/errors.ts'
|
|
29
|
-
import { capPaneLogs } from '../launch/cmdlog.ts'
|
|
29
|
+
import { capPaneLogs, paneLogRotateConfig } from '../launch/cmdlog.ts'
|
|
30
30
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
31
31
|
import { resolveMemoryWritableRoots } from '../status/index.ts'
|
|
32
32
|
import { approvalModeOf, readPeerProfile, resolveIdentity, type ApprovalMode } from '../identity/index.ts'
|
|
@@ -1491,14 +1491,16 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1491
1491
|
const isAlive = deps.sessionAlive ?? ((sock: string, identity: string) => sessionAlive(sock, identity, env))
|
|
1492
1492
|
const kill = deps.killSession ?? ((sock: string, identity: string) => killSession(sock, identity, env))
|
|
1493
1493
|
const verbose = superviseLogVerbose(env)
|
|
1494
|
-
// Pane-log volume
|
|
1494
|
+
// Pane-log volume ROTATION (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
|
|
1495
1495
|
// (<logDir>/<identity>.log) is the RAW TUI byte stream pipe-pane/the supervisor
|
|
1496
|
-
// append for a session's whole life — it had NO
|
|
1497
|
-
// per warm peer
|
|
1498
|
-
//
|
|
1499
|
-
//
|
|
1500
|
-
//
|
|
1501
|
-
|
|
1496
|
+
// append for a session's whole life — it had NO copies-rotation and grew to tens of MB
|
|
1497
|
+
// per warm peer AND per always-on router on this host. Copytruncate-rotate it each tick
|
|
1498
|
+
// (base kept in place — reader seed + telegram mtime-occupancy intact; last maxBytes
|
|
1499
|
+
// snapshotted to <path>.1..N). Threshold/copies from IAPEER_PANELOG_MAX_BYTES /
|
|
1500
|
+
// IAPEER_PANELOG_KEEP (defaults 16 MiB × 2). Not H4-gated: log janitoring is orthogonal
|
|
1501
|
+
// to lifecycle ownership, so launchd-managed routers' pane-logs are rotated too.
|
|
1502
|
+
const paneLogRc = paneLogRotateConfig(env)
|
|
1503
|
+
capPaneLogs(cfg.logDir, paneLogRc.maxBytes, paneLogRc.copies)
|
|
1502
1504
|
// Durable decision trace (eventlog.ts): every reap/death/eager-fresh gets a line
|
|
1503
1505
|
// so a postmortem can answer "when & how did peer X's prior session end" even
|
|
1504
1506
|
// after the .idle-reaped / .deaths markers are consumed. alive / skipped-launchd
|
package/src/tray/index.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// across daemon restarts / down windows. Degrades to the daemon-down block.
|
|
12
12
|
// • cmd — POST a fleet command (wake/stop/…): dogfoods the command endpoints.
|
|
13
13
|
|
|
14
|
+
import { writeSync } from 'fs'
|
|
14
15
|
import { resolveFleetAddress, fleetGetJson, fleetPostJson, streamFleetEvents } from './client.ts'
|
|
15
16
|
import { renderSwiftBar, renderDaemonDown, type TraySnapshot } from './render.ts'
|
|
16
17
|
import { iapeerBinPath } from '../install/index.ts'
|
|
@@ -52,6 +53,74 @@ export async function renderTrayOnce(env: NodeJS.ProcessEnv): Promise<string> {
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// ── UTF-8-atomic stdout sink ─────────────────────────────────────────────────────
|
|
57
|
+
// WHY (root cause of the vanishing menu-bar icon, 13.07.2026): SwiftBar v2.0.1 decodes
|
|
58
|
+
// EVERY pipe chunk independently — RunScript.swift: `String(data: availableData, .utf8)`
|
|
59
|
+
// per readabilityHandler fire. A chunk boundary that lands INSIDE a multi-byte UTF-8
|
|
60
|
+
// character makes that decode nil; StreamablePlugin treats nil as "clear content" and
|
|
61
|
+
// MenuBarItem._updateMenu answers empty content with hide() → the NSStatusItem vanishes
|
|
62
|
+
// (and AppKit PERSISTS the hidden state as `NSStatusItem VisibleCC <plugin>` = 0 via the
|
|
63
|
+
// item's autosaveName, so it survives SwiftBar restarts). Our menu block is ~35 KB of
|
|
64
|
+
// emoji/box-glyph-rich text written in ONE write(2): the kernel delivers it to the reader
|
|
65
|
+
// in pipe-buffer-fill chunks (measured live: 16384+16384+2400 — byte-arbitrary
|
|
66
|
+
// boundaries), so a mid-character split — and a hidden icon — was a stochastic certainty.
|
|
67
|
+
//
|
|
68
|
+
// FIX (kills the class by construction): emit in quanta of ≤512 bytes (PIPE_BUF on
|
|
69
|
+
// macOS — writes ≤PIPE_BUF into a pipe are ATOMIC, all-or-nothing) with every quantum
|
|
70
|
+
// ending on a UTF-8 character boundary. The pipe buffer then only ever contains whole
|
|
71
|
+
// quanta, and a full-drain read (what FileHandle.availableData does — verified with a
|
|
72
|
+
// live harness) always returns a concatenation of whole quanta ⇒ every chunk SwiftBar
|
|
73
|
+
// can ever see is valid UTF-8. See docs/16-tray.md §icon-visibility.
|
|
74
|
+
|
|
75
|
+
const PIPE_ATOMIC_MAX = 512
|
|
76
|
+
|
|
77
|
+
/** Split `s` into Buffers of ≤`max` bytes, each ending on a UTF-8 character boundary.
|
|
78
|
+
* Exported for tests. */
|
|
79
|
+
export function utf8AtomicQuanta(s: string, max = PIPE_ATOMIC_MAX): Buffer[] {
|
|
80
|
+
const buf = Buffer.from(s, 'utf8')
|
|
81
|
+
const quanta: Buffer[] = []
|
|
82
|
+
let i = 0
|
|
83
|
+
while (i < buf.length) {
|
|
84
|
+
let end = Math.min(i + max, buf.length)
|
|
85
|
+
if (end < buf.length) {
|
|
86
|
+
// buf[end] is the first byte of the NEXT quantum — it must start a character.
|
|
87
|
+
// 0b10xxxxxx = UTF-8 continuation byte ⇒ step back to the character's first byte.
|
|
88
|
+
while (end > i && (buf[end]! & 0xc0) === 0x80) end--
|
|
89
|
+
// Defensive only (a valid UTF-8 char is ≤4 B < max): never stall on garbage input.
|
|
90
|
+
if (end === i) end = Math.min(i + max, buf.length)
|
|
91
|
+
}
|
|
92
|
+
quanta.push(buf.subarray(i, end))
|
|
93
|
+
i = end
|
|
94
|
+
}
|
|
95
|
+
return quanta
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sleepSyncMs(ms: number): void {
|
|
99
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The default stream sink: synchronous atomic-quantum writes to stdout (fd 1).
|
|
103
|
+
* EPIPE ⇒ the reader (SwiftBar) is gone — exit cleanly so a dead tray-host never
|
|
104
|
+
* accumulates orphaned `tray render --stream` processes. */
|
|
105
|
+
function writeStdoutAtomic(s: string): void {
|
|
106
|
+
for (const q of utf8AtomicQuanta(s)) {
|
|
107
|
+
let off = 0
|
|
108
|
+
while (off < q.length) {
|
|
109
|
+
try {
|
|
110
|
+
off += writeSync(1, q, off, q.length - off)
|
|
111
|
+
} catch (e) {
|
|
112
|
+
const code = (e as NodeJS.ErrnoException).code
|
|
113
|
+
if (code === 'EAGAIN') {
|
|
114
|
+
sleepSyncMs(2) // pipe full — the reader is momentarily busy
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
if (code === 'EPIPE') process.exit(0)
|
|
118
|
+
throw e
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
55
124
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
56
125
|
return new Promise(resolve => {
|
|
57
126
|
if (signal?.aborted) return resolve()
|
|
@@ -69,7 +138,10 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
69
138
|
*/
|
|
70
139
|
export async function streamTray(
|
|
71
140
|
env: NodeJS.ProcessEnv,
|
|
72
|
-
|
|
141
|
+
// Default sink is the UTF-8-atomic quantum writer — NOT a plain stdout.write: a single
|
|
142
|
+
// big write reaches SwiftBar in byte-arbitrary chunks whose per-chunk UTF-8 decode can
|
|
143
|
+
// fail and hide the menu-bar icon (see writeStdoutAtomic above).
|
|
144
|
+
out: (s: string) => void = writeStdoutAtomic,
|
|
73
145
|
signal?: AbortSignal,
|
|
74
146
|
): Promise<void> {
|
|
75
147
|
// Serialize all emits through a promise chain so a `~~~`+block is never interleaved.
|
package/src/tray/install.ts
CHANGED
|
@@ -133,8 +133,12 @@ export function buildPluginShim(binPath: string): string {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
136
|
-
//
|
|
137
|
-
//
|
|
136
|
+
// Tray-host supervision — a KeepAlive LaunchAgent running a tiny /bin/sh watchdog loop
|
|
137
|
+
// that brings SwiftBar up at login AND brings it back within seconds whenever it dies.
|
|
138
|
+
// The tray is the owner's fleet-visibility surface: a silently-dead SwiftBar hides the
|
|
139
|
+
// state of the whole system (owner incident 13.07.2026), so its lifetime is supervised,
|
|
140
|
+
// not fire-and-forget. The deliberate consequence: quitting SwiftBar by hand also
|
|
141
|
+
// relaunches it — the supported off-switch is `iapeer tray uninstall`.
|
|
138
142
|
//
|
|
139
143
|
// MECHANISM CHOICE (owner-relevant): a user LaunchAgent, not a System Events "Login
|
|
140
144
|
// Item" and not SMAppService.
|
|
@@ -149,11 +153,12 @@ export function buildPluginShim(binPath: string): string {
|
|
|
149
153
|
// (write-if-changed + undead-safe bootstrap/cycle, sandbox-guarded) and carries the
|
|
150
154
|
// ownership sentinel so uninstall can recognize + remove exactly our artifact.
|
|
151
155
|
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
156
|
+
// WATCHDOG SHAPE: launchd must NOT KeepAlive `open` itself — `open` exits the moment
|
|
157
|
+
// SwiftBar is up, so KeepAlive on it is a respawn storm (the 0.4.82 lesson). Instead the
|
|
158
|
+
// job runs a long-lived poll loop: every 5 s, if no SwiftBar process exists, `open -g -b`
|
|
159
|
+
// it (a no-op activation when it IS running — race-safe, never a duplicate instance;
|
|
160
|
+
// absolute /usr/bin//bin paths — launchd's PATH is minimal). An absent SwiftBar.app
|
|
161
|
+
// backs off 5 min instead of churning. KeepAlive guards the LOOP process itself.
|
|
157
162
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
158
163
|
|
|
159
164
|
/** Absolute path of the tray autostart LaunchAgent plist (IAPEER_LAUNCHAGENTS_DIR
|
|
@@ -162,11 +167,17 @@ export function swiftBarAutostartPlistPath(env: NodeJS.ProcessEnv = process.env)
|
|
|
162
167
|
return join(launchAgentsDir(env), `${SWIFTBAR_AUTOSTART_LABEL}.plist`)
|
|
163
168
|
}
|
|
164
169
|
|
|
165
|
-
/** Render the
|
|
170
|
+
/** Render the SwiftBar tray-host supervisor plist. PURE/deterministic (golden-testable).
|
|
166
171
|
* Launch is `open -g -b <bundleid>`: LaunchServices activates the app correctly as a
|
|
167
172
|
* menu-bar agent (`-g` = don't steal focus), and bundle-id targeting survives the app
|
|
168
173
|
* moving. Carries the foundation ownership sentinel (inert `<key>` launchd ignores). */
|
|
169
174
|
export function renderSwiftBarAutostartPlist(): string {
|
|
175
|
+
// Single-line sh loop (see WATCHDOG SHAPE above): 5 s liveness poll, no-op when
|
|
176
|
+
// SwiftBar runs, 5 min backoff when the app is not installed at all.
|
|
177
|
+
const loop =
|
|
178
|
+
'while :; do ' +
|
|
179
|
+
`if ! /usr/bin/pgrep -xq SwiftBar; then /usr/bin/open -g -b ${SWIFTBAR_BUNDLE_ID} || /bin/sleep 300; fi; ` +
|
|
180
|
+
'/bin/sleep 5; done'
|
|
170
181
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
171
182
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
172
183
|
<plist version="1.0">
|
|
@@ -177,13 +188,16 @@ export function renderSwiftBarAutostartPlist(): string {
|
|
|
177
188
|
<true/>
|
|
178
189
|
<key>ProgramArguments</key>
|
|
179
190
|
<array>
|
|
180
|
-
<string>/
|
|
181
|
-
<string>-
|
|
182
|
-
<string
|
|
183
|
-
<string>${SWIFTBAR_BUNDLE_ID}</string>
|
|
191
|
+
<string>/bin/sh</string>
|
|
192
|
+
<string>-c</string>
|
|
193
|
+
<string>${loop}</string>
|
|
184
194
|
</array>
|
|
185
195
|
<key>RunAtLoad</key>
|
|
186
196
|
<true/>
|
|
197
|
+
<key>KeepAlive</key>
|
|
198
|
+
<true/>
|
|
199
|
+
<key>ProcessType</key>
|
|
200
|
+
<string>Background</string>
|
|
187
201
|
</dict>
|
|
188
202
|
</plist>
|
|
189
203
|
`
|
|
@@ -302,7 +316,8 @@ export interface InstallTrayResult {
|
|
|
302
316
|
app: 'present' | 'installed' | 'install-failed' | 'absent'
|
|
303
317
|
appReason?: string
|
|
304
318
|
launched: boolean
|
|
305
|
-
/**
|
|
319
|
+
/** Tray-host supervisor (autostart) registration outcome: set when activated
|
|
320
|
+
* (launch && SwiftBar present) OR upgraded in place (our plist already on disk). */
|
|
306
321
|
autostart?: EnsureAutostartResult
|
|
307
322
|
}
|
|
308
323
|
|
|
@@ -369,7 +384,8 @@ export function installTray(opts: InstallTrayOptions = {}): InstallTrayResult {
|
|
|
369
384
|
}
|
|
370
385
|
}
|
|
371
386
|
|
|
372
|
-
// 4 — launch + refresh + register
|
|
387
|
+
// 4 — launch + refresh + register the tray-host supervisor (login autostart +
|
|
388
|
+
// relaunch-on-death), so a reboot or a SwiftBar crash brings the tray back by itself.
|
|
373
389
|
let launched = false
|
|
374
390
|
let autostart: EnsureAutostartResult | undefined
|
|
375
391
|
if (opts.launch && present) {
|
|
@@ -380,6 +396,13 @@ export function installTray(opts: InstallTrayOptions = {}): InstallTrayResult {
|
|
|
380
396
|
// Idempotent: the plist is byte-stable and the bootstrap is a no-op when loaded, so
|
|
381
397
|
// re-activation never plants a duplicate autostart.
|
|
382
398
|
autostart = ensureSwiftBarAutostart(env)
|
|
399
|
+
} else if (existsSync(swiftBarAutostartPlistPath(env)) && isFoundationOwnedPlist(swiftBarAutostartPlistPath(env))) {
|
|
400
|
+
// UPGRADE-IN-PLACE: this host opted into the autostart earlier (our plist is on
|
|
401
|
+
// disk). The foundation `install` verb runs installTray with launch:false — without
|
|
402
|
+
// this branch a deploy (`iapeer update` → install) would leave an OUTDATED agent
|
|
403
|
+
// (e.g. the 0.4.82 one-shot RunAtLoad plist) registered forever. ensure() rewrites
|
|
404
|
+
// the plist only when its bytes changed and re-cycles the job onto the new content.
|
|
405
|
+
autostart = ensureSwiftBarAutostart(env)
|
|
383
406
|
}
|
|
384
407
|
|
|
385
408
|
return { pluginFile, pluginDir, wrote, dir, app, appReason, launched, autostart }
|
package/src/tray/render.ts
CHANGED
|
@@ -125,6 +125,13 @@ export function fmtAge(ms: number, now: number): string {
|
|
|
125
125
|
return `${Math.floor(h / 24)}d ago`
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/** Local wall-clock `HH:MM:SS` for the footer freshness stamp. */
|
|
129
|
+
export function fmtClock(now: number): string {
|
|
130
|
+
const d = new Date(now)
|
|
131
|
+
const p = (n: number): string => String(n).padStart(2, '0')
|
|
132
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
|
133
|
+
}
|
|
134
|
+
|
|
128
135
|
// ── peer status aggregation ─────────────────────────────────────────────────────
|
|
129
136
|
|
|
130
137
|
function aggregate(p: TrayPeer): 'live' | 'asleep' | 'stopped' {
|
|
@@ -214,8 +221,17 @@ export function renderSwiftBar(snapshot: TraySnapshot, opts: RenderOptions): str
|
|
|
214
221
|
// always current on open — a manual refresh gives the user nothing and (being a
|
|
215
222
|
// SwiftBar refresh) just closes the dropdown. The emergency "restart the plugin" lever
|
|
216
223
|
// lives in SwiftBar's own service menu (right-click → Refresh).
|
|
224
|
+
//
|
|
225
|
+
// The trailing clock is LOAD-BEARING, not cosmetic: it makes every rendered block
|
|
226
|
+
// unique, so each streamable heartbeat emit DIFFERS from the previous one and
|
|
227
|
+
// SwiftBar's content-change publisher fires every time. SwiftBar's _updateMenu()
|
|
228
|
+
// starts with show() (NSStatusItem.isVisible = true) — that is the tray's self-heal:
|
|
229
|
+
// a menu-bar icon hidden for ANY reason (SwiftBar's per-chunk UTF-8 decode nil
|
|
230
|
+
// hiding the item, an accidental ⌘-drag removal, a stale persisted
|
|
231
|
+
// `NSStatusItem VisibleCC` flag) comes back within one heartbeat (≤15 s) instead of
|
|
232
|
+
// staying invisible until the fleet next changes. See docs/16-tray.md §icon-visibility.
|
|
217
233
|
lines.push('---')
|
|
218
|
-
lines.push(line(0, `iapeer · fleet dashboard`, { color: COLOR.meta, size: 11 }))
|
|
234
|
+
lines.push(line(0, `iapeer · fleet dashboard · ${fmtClock(now)}`, { color: COLOR.meta, size: 11 }))
|
|
219
235
|
return lines.join('\n') + '\n'
|
|
220
236
|
}
|
|
221
237
|
|
|
@@ -396,6 +412,7 @@ function renderApprovals(approvals: TrayApproval[], bin: string): string[] {
|
|
|
396
412
|
* dashboard that cannot reach any advertised address should render the daemon as
|
|
397
413
|
* down"). Distinct menu-bar icon + a red header; a Refresh item to retry. */
|
|
398
414
|
export function renderDaemonDown(reason: string, opts: RenderOptions): string {
|
|
415
|
+
const now = opts.now ?? Date.now()
|
|
399
416
|
const lines: string[] = []
|
|
400
417
|
lines.push(line(0, '', { sfimage: 'exclamationmark.triangle.fill', color: COLOR.down }))
|
|
401
418
|
lines.push('---')
|
|
@@ -403,5 +420,10 @@ export function renderDaemonDown(reason: string, opts: RenderOptions): string {
|
|
|
403
420
|
lines.push(line(0, reason, { color: COLOR.meta }))
|
|
404
421
|
lines.push('---')
|
|
405
422
|
lines.push(line(0, 'Refresh', { refresh: true, sfimage: 'arrow.clockwise' }))
|
|
423
|
+
// Same load-bearing uniqueness stamp as renderSwiftBar's footer: the down-block is
|
|
424
|
+
// re-emitted every few seconds while the daemon is away — the changing clock keeps
|
|
425
|
+
// the ⚠ icon visible (show() fires per update) instead of a stale hidden item.
|
|
426
|
+
lines.push('---')
|
|
427
|
+
lines.push(line(0, `iapeer · ${fmtClock(now)}`, { color: COLOR.meta, size: 11 }))
|
|
406
428
|
return lines.join('\n') + '\n'
|
|
407
429
|
}
|