@agfpd/iapeer 0.4.84 → 0.4.86
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/10-messaging.md +19 -1
- package/docs/16-tray.md +61 -22
- package/docs/ru/16-tray.md +59 -21
- package/package.json +1 -1
- package/src/cli/index.ts +3 -1
- package/src/codec/index.ts +144 -6
- package/src/core/constants.ts +3 -2
- package/src/daemon/index.ts +1 -1
- package/src/daemon/main.ts +2 -2
- package/src/lifecycle/index.ts +10 -3
- package/src/transport/index.ts +30 -12
- package/src/tray/index.ts +73 -1
- package/src/tray/install.ts +54 -17
- package/src/tray/render.ts +23 -1
package/docs/10-messaging.md
CHANGED
|
@@ -34,7 +34,25 @@ The daemon finds the recipient in the registry and looks at its state:
|
|
|
34
34
|
|
|
35
35
|
The key guarantee: `delivered` means the message actually landed in a live session (the daemon waited for confirmation), not that it was just sent into the void. If the session didn't confirm receipt in the allotted time, the daemon treats it as dead and doesn't lie about success.
|
|
36
36
|
|
|
37
|
-
In every message's envelope travels the sender's nature (`intelligence`) — the recipient knows whether a human, an agent, or a service process is writing to it.
|
|
37
|
+
In every message's envelope travels the sender's nature (`intelligence`) — the recipient knows whether a human, an agent, or a service process is writing to it — and the dispatch instant (`ts`): the moment the router accepted the send. The same instant is returned to the sender as the `ts` field of its `send_to_peer` result, so both sides of a conversation can literally compare timestamps — which is what makes an async desync (a reply addressing an already-outdated state) visible to the recipient instead of silent.
|
|
38
|
+
|
|
39
|
+
## Envelope: wire form vs agent presentation
|
|
40
|
+
|
|
41
|
+
The envelope exists in two renderings of the same content:
|
|
42
|
+
|
|
43
|
+
- **Wire form** — the canonical durable shape: legacy attribute names (`from-personality` / `from-runtime` / `from-intelligence`), `ts` as a full local ISO instant with offset, the body always CDATA-wrapped, `<message>` tag. This is what travels through disk queues, what bridge runtimes (telegram / notifier / voicetalk) receive and parse, and what their pane logs show. It never slims: its bytes cost no tokens, and every deployed parser keeps working unchanged.
|
|
44
|
+
- **Agent presentation** — rendered at the last hop wherever text enters an LLM context (a live delivery to a claude/codex session, a wake boot first-message, an ephemeral-queue drain):
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
<iap from="boris" runtime="claude" intelligence="artificial" ts="01:23:45">
|
|
48
|
+
Reply via send_to_peer.
|
|
49
|
+
<msg>message body…</msg>
|
|
50
|
+
</iap>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Short attribute names, `ts` compacted to local host time — time-only when the message was sent the same local calendar day it is delivered, date-prefixed (`2026-07-13 01:23:45`) otherwise, so a queue-drained or long-delayed message can never masquerade as fresh. The body is raw (no CDATA) unless it quotes envelope machinery itself — then it keeps the CDATA wrapping (a cosmetic fallback, nothing parses the presentation). The render is fail-open: any problem delivers the wire form as-is rather than risking the message.
|
|
54
|
+
|
|
55
|
+
So host logs deliberately show two envelope shapes: bridge-bound pane logs carry the wire form, agent-bound pane logs and transcripts carry the compact presentation. The core decoder reads both name sets (and both `<message>`/`<msg>` tags), so any envelope ever emitted on the host — including historic transcripts — still decodes.
|
|
38
56
|
|
|
39
57
|
## Message limits
|
|
40
58
|
|
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**, and **is the menu-bar icon visible right now** (the persisted `NSStatusItem VisibleCC` state — the ground truth to read first when "the icon is gone" is reported; no screen needed). 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
|
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-хоста** и **видима ли иконка в менюбаре прямо сейчас** (персистентное состояние `NSStatusItem VisibleCC` — ground truth, который читают первым при жалобе «иконка пропала»; экран не нужен). Ничего не чинит. |
|
|
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/cli/index.ts
CHANGED
|
@@ -2607,8 +2607,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2607
2607
|
` sock: ${s.daemon.sock ?? '—'}\n` +
|
|
2608
2608
|
` tcp: ${s.daemon.tcp ?? '—'}\n` +
|
|
2609
2609
|
`SwiftBar: ${s.swiftbar.installed ? `installed (plugin dir: ${s.swiftbar.pluginDir ?? 'unset'})` : 'not installed'}\n` +
|
|
2610
|
+
// the persisted NSStatusItem visibility — ground truth for "the icon is gone"
|
|
2611
|
+
`menu-bar icon: ${s.swiftbar.iconVisible === undefined ? 'unknown (no persisted state)' : s.swiftbar.iconVisible ? 'visible' : 'HIDDEN (persisted NSStatusItem VisibleCC=0)'}\n` +
|
|
2610
2612
|
`plugin: ${s.plugin.installed ? s.plugin.path : 'not installed'}\n` +
|
|
2611
|
-
`
|
|
2613
|
+
`supervisor (autostart): ${s.autostart.registered ? s.autostart.path : 'not registered'}\n`,
|
|
2612
2614
|
)
|
|
2613
2615
|
return 0
|
|
2614
2616
|
}
|
package/src/codec/index.ts
CHANGED
|
@@ -30,11 +30,37 @@ export interface EnvelopeInput {
|
|
|
30
30
|
fromPersonality: string
|
|
31
31
|
fromRuntime: Runtime
|
|
32
32
|
fromIntelligence: Intelligence
|
|
33
|
+
/** ISO-8601 LOCAL instant with offset (formatSentAt) — the moment the router
|
|
34
|
+
* ACCEPTED the send; the same string is returned to the sender as the `ts`
|
|
35
|
+
* field of its send_to_peer result, so recipient-side `ts` and sender-side
|
|
36
|
+
* `ts` correspond literally. Rendered as the `ts` open-tag attribute.
|
|
37
|
+
* ADDITIVE and optional: every known envelope consumer reads attributes by
|
|
38
|
+
* NAME and ignores unknown ones; absent on legacy envelopes. On the WIRE this
|
|
39
|
+
* is always the FULL date-time (durable stores — disk queues, pane logs —
|
|
40
|
+
* must be unambiguous); the agent-facing render compacts it (see
|
|
41
|
+
* renderEnvelopeForAgent). */
|
|
42
|
+
sentAt?: string
|
|
33
43
|
topic?: string
|
|
34
44
|
attachments?: readonly string[]
|
|
35
45
|
message: string
|
|
36
46
|
}
|
|
37
47
|
|
|
48
|
+
/** Format a Date as ISO-8601 LOCAL time with numeric offset, second precision:
|
|
49
|
+
* `2026-07-14T01:23:45+03:00`. Local (not UTC-Z) on purpose: the fleet's agents
|
|
50
|
+
* compare envelope timestamps against their LOCAL statusline clock — a Z-form
|
|
51
|
+
* invites silent off-by-offset misreads by an LLM reader. */
|
|
52
|
+
export function formatSentAt(date: Date): string {
|
|
53
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
54
|
+
const offMin = -date.getTimezoneOffset() // minutes EAST of UTC
|
|
55
|
+
const sign = offMin >= 0 ? '+' : '-'
|
|
56
|
+
const abs = Math.abs(offMin)
|
|
57
|
+
return (
|
|
58
|
+
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
59
|
+
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
|
|
60
|
+
`${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
38
64
|
function escapeAttr(value: string): string {
|
|
39
65
|
return value
|
|
40
66
|
.replace(/&/g, '&')
|
|
@@ -49,12 +75,20 @@ function cdata(value: string): string {
|
|
|
49
75
|
return `<![CDATA[${value.replaceAll(']]>', ']]]]><![CDATA[>')}]]>`
|
|
50
76
|
}
|
|
51
77
|
|
|
78
|
+
/** WIRE encoder — the canonical durable form (disk queues, bridge deliveries,
|
|
79
|
+
* pane logs): legacy attribute names + always-CDATA body, so every deployed
|
|
80
|
+
* sibling parser (telegram-/notifier-/voicetalk-runtime) keeps working
|
|
81
|
+
* unchanged. `ts` is the only additive attribute (envelope-compaction F).
|
|
82
|
+
* Agent-bound deliveries are re-rendered compactly at the LAST hop — see
|
|
83
|
+
* renderEnvelopeForAgent; the wire form itself never slims (its bytes cost no
|
|
84
|
+
* tokens; only the LLM-context presentation does). */
|
|
52
85
|
export function buildEnvelope(input: EnvelopeInput): string {
|
|
53
86
|
const attrs = [
|
|
54
87
|
`from-personality="${escapeAttr(input.fromPersonality)}"`,
|
|
55
88
|
`from-runtime="${escapeAttr(input.fromRuntime)}"`,
|
|
56
89
|
`from-intelligence="${escapeAttr(input.fromIntelligence)}"`,
|
|
57
90
|
]
|
|
91
|
+
if (input.sentAt) attrs.push(`ts="${escapeAttr(input.sentAt)}"`)
|
|
58
92
|
const topic = input.topic?.trim()
|
|
59
93
|
if (topic) {
|
|
60
94
|
attrs.push(`topic="${escapeAttr(topic.slice(0, MAX_TOPIC_LEN))}"`)
|
|
@@ -146,13 +180,21 @@ export interface IapEnvelope {
|
|
|
146
180
|
fromPersonality: string
|
|
147
181
|
fromRuntime: Runtime
|
|
148
182
|
fromIntelligence?: Intelligence
|
|
183
|
+
/** The sender's dispatch instant (`ts` attribute) — full local ISO on the wire;
|
|
184
|
+
* absent on legacy envelopes. */
|
|
185
|
+
sentAt?: string
|
|
149
186
|
topic?: string
|
|
150
187
|
attachments: string[]
|
|
151
188
|
message: string
|
|
152
189
|
}
|
|
153
190
|
|
|
191
|
+
/** NAME-ANCHORED attribute lookup. The anchor `(^|\s)` is load-bearing: the old
|
|
192
|
+
* unanchored regex made `runtime="` match the TAIL of `from-runtime="…"` (and
|
|
193
|
+
* `intelligence="` the tail of `from-intelligence="…"`) — a latent mine that the
|
|
194
|
+
* read-both decoder below would have stepped on (the short-name lookup must NOT
|
|
195
|
+
* be satisfied by a legacy long-name attribute). */
|
|
154
196
|
function attrValue(attrs: string, name: string): string | undefined {
|
|
155
|
-
const re = new RegExp(
|
|
197
|
+
const re = new RegExp(`(?:^|\\s)${name}="([^"]*)"`)
|
|
156
198
|
const m = re.exec(attrs)
|
|
157
199
|
return m ? unescapeAttr(m[1]) : undefined
|
|
158
200
|
}
|
|
@@ -167,25 +209,34 @@ function unescapeAttr(value: string): string {
|
|
|
167
209
|
.replace(/&/g, '&')
|
|
168
210
|
}
|
|
169
211
|
|
|
212
|
+
/** READ-BOTH decoder: accepts the legacy wire names (`from-personality` /
|
|
213
|
+
* `from-runtime` / `from-intelligence`, `<message>`) AND the compact
|
|
214
|
+
* presentation names (`from` / `runtime` / `intelligence`, `<msg>`) — so any
|
|
215
|
+
* envelope form ever emitted on this host (wire, agent-facing render, historic
|
|
216
|
+
* transcripts) decodes. Short names win when both are present. */
|
|
170
217
|
export function decodeEnvelope(xml: string): IapEnvelope {
|
|
171
218
|
const open = /^<iap\s+([^>]*)>/.exec(xml.trim())
|
|
172
219
|
if (!open) throw new IapError('invalid IAP envelope: missing <iap ...>')
|
|
173
|
-
const fromPersonality = attrValue(open[1], 'from-personality')
|
|
174
|
-
const fromRuntime = attrValue(open[1], 'from-runtime')
|
|
220
|
+
const fromPersonality = attrValue(open[1], 'from') ?? attrValue(open[1], 'from-personality')
|
|
221
|
+
const fromRuntime = attrValue(open[1], 'runtime') ?? attrValue(open[1], 'from-runtime')
|
|
175
222
|
if (!fromPersonality || !fromRuntime) {
|
|
176
223
|
throw new IapError('invalid IAP envelope: missing from-personality/from-runtime')
|
|
177
224
|
}
|
|
178
225
|
// READ-COMPAT: a legacy peer (live telegram) may stamp from-intelligence="human";
|
|
179
226
|
// normalize human→natural / scripted→absent, drop a genuinely unknown value.
|
|
180
|
-
const fromIntelligence = normalizeIntelligenceValue(
|
|
181
|
-
|
|
227
|
+
const fromIntelligence = normalizeIntelligenceValue(
|
|
228
|
+
attrValue(open[1], 'intelligence') ?? attrValue(open[1], 'from-intelligence'),
|
|
229
|
+
)
|
|
230
|
+
const message = readTagContent(xml, 'message') ?? readTagContent(xml, 'msg')
|
|
182
231
|
if (message === undefined) throw new IapError('invalid IAP envelope: missing message')
|
|
183
232
|
const attachmentsRaw = readTagContent(xml, 'attachments')
|
|
184
233
|
const topic = attrValue(open[1], 'topic')
|
|
234
|
+
const sentAt = attrValue(open[1], 'ts')
|
|
185
235
|
return {
|
|
186
236
|
fromPersonality,
|
|
187
237
|
fromRuntime,
|
|
188
238
|
...(fromIntelligence ? { fromIntelligence } : {}),
|
|
239
|
+
...(sentAt ? { sentAt } : {}),
|
|
189
240
|
...(topic ? { topic } : {}),
|
|
190
241
|
attachments: attachmentsRaw
|
|
191
242
|
? attachmentsRaw.split('\n').map(item => item.trim()).filter(Boolean)
|
|
@@ -209,7 +260,12 @@ function openTagVerdict(candidate: string): 'valid' | 'invalid' | 'incomplete' {
|
|
|
209
260
|
return unclosed && candidate.length <= MAX_OPEN_TAG_LEN ? 'incomplete' : 'invalid'
|
|
210
261
|
}
|
|
211
262
|
if (m[0].length > MAX_OPEN_TAG_LEN) return 'invalid'
|
|
212
|
-
|
|
263
|
+
// Read-both (envelope-compaction F): a real open tag carries the required pair
|
|
264
|
+
// under EITHER naming — legacy wire (from-personality/from-runtime) or compact
|
|
265
|
+
// presentation (from/runtime, name-anchored so a legacy tail never satisfies it).
|
|
266
|
+
const hasPersonality = m[1].includes('from-personality="') || /(?:^|\s)from="/.test(m[1])
|
|
267
|
+
const hasRuntime = m[1].includes('from-runtime="') || /(?:^|\s)runtime="/.test(m[1])
|
|
268
|
+
return hasPersonality && hasRuntime ? 'valid' : 'invalid'
|
|
213
269
|
}
|
|
214
270
|
|
|
215
271
|
/**
|
|
@@ -257,3 +313,85 @@ export function extractEnvelopes(buffer: string): { envelopes: string[]; rest: s
|
|
|
257
313
|
rest = rest.slice(envelopeEnd)
|
|
258
314
|
}
|
|
259
315
|
}
|
|
316
|
+
|
|
317
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
318
|
+
// Agent-facing presentation render (envelope-compaction F)
|
|
319
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
/** Presentation-hybrid gate: a body that itself contains envelope machinery
|
|
322
|
+
* (a quoted envelope, tag markers, CDATA syntax) or a literal CR keeps its
|
|
323
|
+
* CDATA wrapping in the presentation too — the cost of the gate firing is
|
|
324
|
+
* purely COSMETIC (the agent sees today's CDATA form), whereas rendering such
|
|
325
|
+
* a body raw would show the agent a "broken" nested envelope. Plain `<` (code
|
|
326
|
+
* snippets etc.) renders raw: nothing parses the presentation. */
|
|
327
|
+
const PRESENTATION_CDATA_MARKERS = [
|
|
328
|
+
'<iap ',
|
|
329
|
+
'</iap>',
|
|
330
|
+
'<message>',
|
|
331
|
+
'</message>',
|
|
332
|
+
'<msg>',
|
|
333
|
+
'</msg>',
|
|
334
|
+
'<attachments>',
|
|
335
|
+
'</attachments>',
|
|
336
|
+
'<![CDATA[',
|
|
337
|
+
']]>',
|
|
338
|
+
'\r',
|
|
339
|
+
] as const
|
|
340
|
+
|
|
341
|
+
function presentBody(value: string): string {
|
|
342
|
+
return PRESENTATION_CDATA_MARKERS.some(marker => value.includes(marker)) ? cdata(value) : value
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Compact the wire `ts` (full local ISO with offset) for the agent's eyes:
|
|
346
|
+
* time-only `01:23:45` when the send happened on the SAME local calendar day
|
|
347
|
+
* as the render (the normal case), date-prefixed `2026-07-13 01:23:45` when it
|
|
348
|
+
* did not (queue drains, long sleeps, midnight crossings) — so a stale message
|
|
349
|
+
* can never masquerade as fresh. Renders happen at the LAST delivery hop, so
|
|
350
|
+
* "today" is delivery-time local. An unparseable ts passes through verbatim. */
|
|
351
|
+
function renderTsForAgent(sentAt: string, now: Date): string {
|
|
352
|
+
const d = new Date(sentAt)
|
|
353
|
+
if (Number.isNaN(d.getTime())) return sentAt
|
|
354
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
355
|
+
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
356
|
+
const sameLocalDay =
|
|
357
|
+
d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
|
|
358
|
+
return sameLocalDay ? time : `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Render a WIRE envelope into the compact agent-facing presentation:
|
|
363
|
+
*
|
|
364
|
+
* <iap from="boris" runtime="claude" intelligence="artificial" ts="01:23:45">
|
|
365
|
+
* Reply via send_to_peer.
|
|
366
|
+
* <msg>raw body…</msg>
|
|
367
|
+
* </iap>
|
|
368
|
+
*
|
|
369
|
+
* Short attribute names, time-only `ts` (cross-day rule above), raw body
|
|
370
|
+
* (hybrid CDATA when the body quotes envelope machinery). Called ONLY at the
|
|
371
|
+
* two hops where text enters an LLM context (composeFirstMessage — wake boot +
|
|
372
|
+
* ephemeral drain; deliverWarm — live paths, gated on agent runtimes); bridge
|
|
373
|
+
* targets always receive the wire form their parsers expect.
|
|
374
|
+
*
|
|
375
|
+
* FAIL-OPEN: any decode/render problem returns the wire text unchanged — a
|
|
376
|
+
* presentation hiccup must never lose or mangle a delivery.
|
|
377
|
+
*/
|
|
378
|
+
export function renderEnvelopeForAgent(wire: string, now: Date = new Date()): string {
|
|
379
|
+
try {
|
|
380
|
+
const env = decodeEnvelope(wire)
|
|
381
|
+
const attrs = [`from="${escapeAttr(env.fromPersonality)}"`, `runtime="${escapeAttr(env.fromRuntime)}"`]
|
|
382
|
+
if (env.fromIntelligence) attrs.push(`intelligence="${escapeAttr(env.fromIntelligence)}"`)
|
|
383
|
+
if (env.sentAt) attrs.push(`ts="${escapeAttr(renderTsForAgent(env.sentAt, now))}"`)
|
|
384
|
+
if (env.topic) attrs.push(`topic="${escapeAttr(env.topic)}"`)
|
|
385
|
+
return [
|
|
386
|
+
`<iap ${attrs.join(' ')}>`,
|
|
387
|
+
IAP_INSTRUCTION,
|
|
388
|
+
...(env.attachments.length
|
|
389
|
+
? [`<attachments>${presentBody(env.attachments.join('\n'))}</attachments>`]
|
|
390
|
+
: []),
|
|
391
|
+
`<msg>${presentBody(env.message)}</msg>`,
|
|
392
|
+
'</iap>',
|
|
393
|
+
].join('\n')
|
|
394
|
+
} catch {
|
|
395
|
+
return wire // fail-open: deliver the wire form rather than risk the message
|
|
396
|
+
}
|
|
397
|
+
}
|
package/src/core/constants.ts
CHANGED
|
@@ -197,8 +197,9 @@ export const PEERS_HOME_DIR = 'peers'
|
|
|
197
197
|
export const LAUNCHD_LABEL_PREFIX = 'com.iapeer.'
|
|
198
198
|
export const DAEMON_PLIST_LABEL = 'com.agfpd.iapeer'
|
|
199
199
|
|
|
200
|
-
|
|
201
|
-
|
|
200
|
+
// Shortened 0.4.86 (envelope-compaction F): the long preamble duplicated doctrine
|
|
201
|
+
// (origin-routing) for live sessions; the reply-routing cue is the load-bearing part.
|
|
202
|
+
export const IAP_INSTRUCTION = 'Reply via send_to_peer.'
|
|
202
203
|
|
|
203
204
|
export const ALWAYS_LOAD_META = {
|
|
204
205
|
'anthropic/alwaysLoad': true,
|
package/src/daemon/index.ts
CHANGED
|
@@ -121,7 +121,7 @@ export function buildSendDescription(): string {
|
|
|
121
121
|
'Peers can be agents, humans, or services. Runtime is the delivery surface, not the peer type.',
|
|
122
122
|
'Current delivery supports any runtime endpoint that follows the IAP tmux socket convention. Claude/Codex are built-in local runtimes; external runtimes such as telegram can be exposed by runtime-router packages.',
|
|
123
123
|
'',
|
|
124
|
-
"Use `personality`, not a runtime-prefixed identity. Set `runtime` to target a specific declared runtime for this send. If that runtime is warm-asleep, the daemon wakes it and delivers there; use this to intentionally bring up a second session of the same peer in parallel to the default runtime (for example codex alongside claude). Inbound peer messages arrive as <iap from
|
|
124
|
+
"Use `personality`, not a runtime-prefixed identity. Set `runtime` to target a specific declared runtime for this send. If that runtime is warm-asleep, the daemon wakes it and delivers there; use this to intentionally bring up a second session of the same peer in parallel to the default runtime (for example codex alongside claude). Inbound peer messages arrive as <iap from=\"...\" runtime=\"...\" intelligence=\"...\" ts=\"...\"> blocks; `ts` is the sender's dispatch time (local host time; date-prefixed when not sent today) — compare it with the current time to spot a stale/desynced message. The topic attribute is optional.",
|
|
125
125
|
].join('\n')
|
|
126
126
|
}
|
|
127
127
|
|
package/src/daemon/main.ts
CHANGED
|
@@ -181,7 +181,7 @@ export function makeEphemeralRouteDeps(
|
|
|
181
181
|
): EphemeralRouteDeps {
|
|
182
182
|
return {
|
|
183
183
|
isEphemeral: cwd => isEphemeralPeer(cwd),
|
|
184
|
-
deliver: async ({ peer, envelope, topic, runtime }) => {
|
|
184
|
+
deliver: async ({ peer, envelope, topic, runtime, sentAt }) => {
|
|
185
185
|
const rt = resolveWakeRuntime(runtime, peer)
|
|
186
186
|
if (!rt.ok) return rt
|
|
187
187
|
const identity = buildProcessAddress(rt.value, peer.personality)
|
|
@@ -201,7 +201,7 @@ export function makeEphemeralRouteDeps(
|
|
|
201
201
|
queued: true,
|
|
202
202
|
queuedBy: 'ephemeral',
|
|
203
203
|
queueDepth: depth,
|
|
204
|
-
ts: new Date().toISOString(),
|
|
204
|
+
ts: sentAt ?? new Date().toISOString(),
|
|
205
205
|
})
|
|
206
206
|
},
|
|
207
207
|
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type Runtime,
|
|
26
26
|
} from '../core/constants.ts'
|
|
27
27
|
import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
|
|
28
|
+
import { renderEnvelopeForAgent } from '../codec/index.ts'
|
|
28
29
|
import { err, ok, type Result } from '../core/errors.ts'
|
|
29
30
|
import { capPaneLogs, paneLogRotateConfig } from '../launch/cmdlog.ts'
|
|
30
31
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
@@ -917,17 +918,23 @@ function composePeerPrompt(
|
|
|
917
918
|
* yields no seed and never blocks the wake (the seed is optional).
|
|
918
919
|
*/
|
|
919
920
|
export function composeFirstMessage(cwd: string, task: string, fresh: boolean): string {
|
|
920
|
-
|
|
921
|
+
// Envelope-compaction F: the boot first-message enters an LLM context (wake
|
|
922
|
+
// targets are agent runtimes by construction — launchd-held bridges are never
|
|
923
|
+
// woken, H4), so the routed WIRE envelope is rendered into the compact
|
|
924
|
+
// agent-facing presentation here — the boot-side twin of deliverWarm's render.
|
|
925
|
+
// fail-open inside the renderer (non-envelope/empty task passes through as-is).
|
|
926
|
+
const presented = task ? renderEnvelopeForAgent(task) : task
|
|
927
|
+
if (!fresh) return presented
|
|
921
928
|
let seed: string | undefined
|
|
922
929
|
try {
|
|
923
930
|
seed = readPeerProfile(cwd)?.initial_prompt
|
|
924
931
|
} catch {
|
|
925
932
|
/* invalid/absent profile → no seed */
|
|
926
933
|
}
|
|
927
|
-
if (!seed) return
|
|
934
|
+
if (!seed) return presented
|
|
928
935
|
// seed + the routed message (both delivered, seed first). When there is NO incoming
|
|
929
936
|
// message (an eager /new re-launch, C4b — task is empty), the seed is self-sufficient.
|
|
930
|
-
return
|
|
937
|
+
return presented ? `${seed}\n\n${presented}` : seed
|
|
931
938
|
}
|
|
932
939
|
|
|
933
940
|
// ─────────────────────────────────────────────────────────────────────────────
|
package/src/transport/index.ts
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
buildSocketPath,
|
|
33
33
|
type ProcessAddress,
|
|
34
34
|
} from '../core/socket.ts'
|
|
35
|
-
import { buildEnvelope } from '../codec/index.ts'
|
|
35
|
+
import { buildEnvelope, formatSentAt, renderEnvelopeForAgent } from '../codec/index.ts'
|
|
36
36
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
37
37
|
import { findPeer, readPeersIndex, type PeerRecord } from '../registry/index.ts'
|
|
38
38
|
import type { ResolvedCaller } from '../identity/index.ts'
|
|
@@ -238,12 +238,18 @@ export async function deliverWarm(
|
|
|
238
238
|
seam: WarmDeliverSeam = {},
|
|
239
239
|
env: NodeJS.ProcessEnv = process.env,
|
|
240
240
|
): Promise<Result<void>> {
|
|
241
|
+
// Envelope-compaction F: an AGENT target (claude/codex — an LLM reads the text)
|
|
242
|
+
// receives the compact presentation; a bridge target (telegram/notifier/…)
|
|
243
|
+
// receives the WIRE form its parser expects. Rendered BEFORE deliverViaHost so
|
|
244
|
+
// the transcript-confirm matches the text actually delivered. fail-open inside
|
|
245
|
+
// the renderer: a render problem degrades to the wire form, never to a loss.
|
|
246
|
+
const payload = isSupportedLocalRuntime(target.runtime) ? renderEnvelopeForAgent(envelope) : envelope
|
|
241
247
|
const address = target.address
|
|
242
248
|
const prev = deliverTails.get(address) ?? Promise.resolve()
|
|
243
249
|
// run AFTER prev settles (success or failure) — a prior delivery's error must not stall the chain
|
|
244
250
|
const run = prev.then(
|
|
245
|
-
() => deliverViaHost(target,
|
|
246
|
-
() => deliverViaHost(target,
|
|
251
|
+
() => deliverViaHost(target, payload, cwd, seam, env),
|
|
252
|
+
() => deliverViaHost(target, payload, cwd, seam, env),
|
|
247
253
|
)
|
|
248
254
|
deliverTails.set(
|
|
249
255
|
address,
|
|
@@ -884,6 +890,9 @@ export interface EphemeralRouteDeps {
|
|
|
884
890
|
envelope: string
|
|
885
891
|
topic?: string
|
|
886
892
|
runtime?: string
|
|
893
|
+
/** Router-acceptance instant (formatSentAt) — the enqueue ACK's `ts` mirrors
|
|
894
|
+
* it so sender-side ts ≡ envelope ts holds on the queued path too. */
|
|
895
|
+
sentAt?: string
|
|
887
896
|
}) => Promise<Result<RouteResult>>
|
|
888
897
|
}
|
|
889
898
|
|
|
@@ -904,6 +913,8 @@ export interface ComposerQueueTryEnqueueArgs {
|
|
|
904
913
|
target: DeliveryTarget
|
|
905
914
|
envelope: string
|
|
906
915
|
topic?: string
|
|
916
|
+
/** Router-acceptance instant (formatSentAt) — the queued ACK's `ts` mirrors it. */
|
|
917
|
+
sentAt?: string
|
|
907
918
|
}
|
|
908
919
|
|
|
909
920
|
export interface ComposerQueueRouteDeps {
|
|
@@ -1192,7 +1203,7 @@ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions):
|
|
|
1192
1203
|
queued: true,
|
|
1193
1204
|
queuedBy: 'composer',
|
|
1194
1205
|
queueDepth: q.length,
|
|
1195
|
-
ts: new Date().toISOString(),
|
|
1206
|
+
ts: args.sentAt ?? new Date().toISOString(),
|
|
1196
1207
|
})
|
|
1197
1208
|
},
|
|
1198
1209
|
failAll: async reason => {
|
|
@@ -1317,12 +1328,19 @@ export async function routeSend(
|
|
|
1317
1328
|
const intendedGuard = telegramSenderGuard(caller, runtime ?? peer.runtime, personality)
|
|
1318
1329
|
if (!intendedGuard.ok) return intendedGuard
|
|
1319
1330
|
|
|
1331
|
+
// Stamped ONCE at router acceptance: the same instant travels as the envelope's
|
|
1332
|
+
// `ts` attribute (recipient side) AND is returned as the result's `ts` (sender
|
|
1333
|
+
// side) — the two correspond literally, which is what makes async-desync
|
|
1334
|
+
// visible (the recipient can tell how old the message it is reading is).
|
|
1335
|
+
const sentAt = formatSentAt(new Date())
|
|
1320
1336
|
// Built once — it is both the live-delivery payload and, on a miss, the wake
|
|
1321
|
-
// first-message (the woken session receives it as its boot task).
|
|
1337
|
+
// first-message (the woken session receives it as its boot task). This is the
|
|
1338
|
+
// WIRE form; agent-bound hops re-render it compactly at delivery.
|
|
1322
1339
|
const envelope = buildEnvelope({
|
|
1323
1340
|
fromPersonality: caller.personality,
|
|
1324
1341
|
fromRuntime: caller.runtime,
|
|
1325
1342
|
fromIntelligence: caller.intelligence,
|
|
1343
|
+
sentAt,
|
|
1326
1344
|
topic,
|
|
1327
1345
|
attachments: attachmentsResult.value,
|
|
1328
1346
|
message,
|
|
@@ -1336,7 +1354,7 @@ export async function routeSend(
|
|
|
1336
1354
|
// would deadlock its own die-after-reply reap on a forever-non-empty queue).
|
|
1337
1355
|
if (deps.ephemeral?.isEphemeral(peer.cwd)) {
|
|
1338
1356
|
if (caller.personality === peer.personality) return err('cannot send to self')
|
|
1339
|
-
return deps.ephemeral.deliver({ peer, envelope, topic, runtime })
|
|
1357
|
+
return deps.ephemeral.deliver({ peer, envelope, topic, runtime, sentAt })
|
|
1340
1358
|
}
|
|
1341
1359
|
|
|
1342
1360
|
const target = resolvePeerDeliveryTarget(personality, runtime, peer, env)
|
|
@@ -1346,7 +1364,7 @@ export async function routeSend(
|
|
|
1346
1364
|
// land on telegram without an override and without telegram being the default).
|
|
1347
1365
|
const liveGuard = telegramSenderGuard(caller, target.value.runtime, personality)
|
|
1348
1366
|
if (!liveGuard.ok) return liveGuard
|
|
1349
|
-
const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: target.value, envelope, topic })
|
|
1367
|
+
const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: target.value, envelope, topic, sentAt })
|
|
1350
1368
|
if (queued) return queued
|
|
1351
1369
|
// peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
|
|
1352
1370
|
// deliverWarm is HOST-AWARE (spawn-flip Ф0b-2): a supervisor-hosted target delivers over its
|
|
@@ -1359,7 +1377,7 @@ export async function routeSend(
|
|
|
1359
1377
|
ok: true,
|
|
1360
1378
|
delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
|
|
1361
1379
|
woke: false,
|
|
1362
|
-
ts:
|
|
1380
|
+
ts: sentAt,
|
|
1363
1381
|
})
|
|
1364
1382
|
}
|
|
1365
1383
|
|
|
@@ -1391,7 +1409,7 @@ export async function routeSend(
|
|
|
1391
1409
|
ok: true,
|
|
1392
1410
|
delivered_to: { personality: revived.value.personality, runtime: revived.value.runtime },
|
|
1393
1411
|
woke: false,
|
|
1394
|
-
ts:
|
|
1412
|
+
ts: sentAt,
|
|
1395
1413
|
})
|
|
1396
1414
|
}
|
|
1397
1415
|
// resolved but the deliver didn't land (session still settling) → keep polling
|
|
@@ -1427,7 +1445,7 @@ export async function routeSend(
|
|
|
1427
1445
|
// a false ok:true — the class the contract forbids. A delivery failure here is
|
|
1428
1446
|
// surfaced loudly (false-FAIL over false-OK).
|
|
1429
1447
|
if (woke.taskDelivered === false) {
|
|
1430
|
-
const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: live.value, envelope, topic })
|
|
1448
|
+
const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: live.value, envelope, topic, sentAt })
|
|
1431
1449
|
if (queued) return queued
|
|
1432
1450
|
const delivered = await deliverWarm(live.value, envelope, peer.cwd, {}, env)
|
|
1433
1451
|
if (!delivered.ok) return delivered
|
|
@@ -1436,7 +1454,7 @@ export async function routeSend(
|
|
|
1436
1454
|
ok: true,
|
|
1437
1455
|
delivered_to: { personality: live.value.personality, runtime: live.value.runtime },
|
|
1438
1456
|
woke: false, // honest: this sender's envelope went the LIVE path, not a boot
|
|
1439
|
-
ts:
|
|
1457
|
+
ts: sentAt,
|
|
1440
1458
|
})
|
|
1441
1459
|
}
|
|
1442
1460
|
// The envelope was delivered as the boot first-message during wake.
|
|
@@ -1444,6 +1462,6 @@ export async function routeSend(
|
|
|
1444
1462
|
ok: true,
|
|
1445
1463
|
delivered_to: { personality: live.value.personality, runtime: live.value.runtime },
|
|
1446
1464
|
woke: true,
|
|
1447
|
-
ts:
|
|
1465
|
+
ts: sentAt,
|
|
1448
1466
|
})
|
|
1449
1467
|
}
|
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 }
|
|
@@ -463,9 +486,18 @@ export function trayAttachTerm(opts: {
|
|
|
463
486
|
|
|
464
487
|
export interface TrayStatus {
|
|
465
488
|
daemon: { fleet: boolean; version?: string; sock?: string; tcp?: string }
|
|
466
|
-
swiftbar: {
|
|
489
|
+
swiftbar: {
|
|
490
|
+
installed: boolean
|
|
491
|
+
pluginDir?: string
|
|
492
|
+
/** GROUND TRUTH of the menu-bar icon: AppKit persists NSStatusItem visibility per
|
|
493
|
+
* autosaveName in SwiftBar's defaults (`NSStatusItem VisibleCC <plugin>`). false =
|
|
494
|
+
* the icon is hidden RIGHT NOW (whatever hid it — a mid-chunk decode nil on an old
|
|
495
|
+
* binary, a ⌘-drag removal); undefined = key absent / SwiftBar not installed. The
|
|
496
|
+
* first thing to read when "the icon is gone" is reported — no screen needed. */
|
|
497
|
+
iconVisible?: boolean
|
|
498
|
+
}
|
|
467
499
|
plugin: { installed: boolean; path?: string }
|
|
468
|
-
/**
|
|
500
|
+
/** Tray-host supervisor LaunchAgent: registered (our plist present) + its path. */
|
|
469
501
|
autostart: { registered: boolean; path?: string }
|
|
470
502
|
}
|
|
471
503
|
|
|
@@ -480,9 +512,14 @@ export function trayStatus(opts: { env?: NodeJS.ProcessEnv; run?: Runner; probeA
|
|
|
480
512
|
const found = searchDirs.map(d => join(d, PLUGIN_BASENAME)).find(f => existsSync(f))
|
|
481
513
|
const autostartPath = swiftBarAutostartPlistPath(env)
|
|
482
514
|
const autostartOurs = existsSync(autostartPath) && isFoundationOwnedPlist(autostartPath)
|
|
515
|
+
let iconVisible: boolean | undefined
|
|
516
|
+
if (installed) {
|
|
517
|
+
const r = run('defaults', ['read', SWIFTBAR_DOMAIN, `NSStatusItem VisibleCC ${PLUGIN_BASENAME}`])
|
|
518
|
+
if (r.status === 0) iconVisible = r.stdout.trim() === '1'
|
|
519
|
+
}
|
|
483
520
|
return {
|
|
484
521
|
daemon: { fleet: addr.fleet === 1, version: addr.version, sock: addr.sock, tcp: addr.tcp },
|
|
485
|
-
swiftbar: { installed, pluginDir: cfgDir },
|
|
522
|
+
swiftbar: { installed, pluginDir: cfgDir, iconVisible },
|
|
486
523
|
plugin: { installed: Boolean(found), path: found },
|
|
487
524
|
autostart: { registered: autostartOurs, path: autostartOurs ? autostartPath : undefined },
|
|
488
525
|
}
|
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
|
}
|