@agfpd/iapeer 0.4.69 → 0.4.70
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/15-fleet-api.md +7 -1
- package/docs/16-tray.md +18 -4
- package/docs/17-approval.md +2 -2
- package/docs/ru/15-fleet-api.md +7 -1
- package/docs/ru/16-tray.md +14 -2
- package/docs/ru/17-approval.md +2 -2
- package/package.json +1 -1
- package/src/approval/install.ts +7 -1
- package/src/cli/index.ts +19 -2
- package/src/daemon/fleet.ts +28 -1
- package/src/tray/index.ts +25 -0
- package/src/tray/render.ts +107 -6
package/docs/15-fleet-api.md
CHANGED
|
@@ -58,6 +58,11 @@ The full fleet state. The peer rows are produced by the **same in-process functi
|
|
|
58
58
|
"wake_policy": "warm",
|
|
59
59
|
"approval_mode": "yolo"
|
|
60
60
|
}
|
|
61
|
+
],
|
|
62
|
+
"approvals": [
|
|
63
|
+
{ "id": "a1", "personality": "boris", "runtime": "claude", "kind": "circuit-breaker",
|
|
64
|
+
"tool": "dangerous-rm", "summary": "rm -rf /tmp/x",
|
|
65
|
+
"content": "cmd=\"rm -rf /tmp/x\" target=\"/tmp/x\"", "createdMs": 1751731100000, "expiresMs": 1751731400000 }
|
|
61
66
|
]
|
|
62
67
|
}
|
|
63
68
|
```
|
|
@@ -75,6 +80,7 @@ Field notes:
|
|
|
75
80
|
- `host.fda` — Full Disk Access of the binary: `true` / `false` / `null` (undeterminable).
|
|
76
81
|
- The daemon answering **is** the health signal: there is no `healthy` boolean. A dashboard that cannot reach any advertised address should render the daemon as down (this is also why a purely API-fed dashboard is insufficient when the daemon itself is broken — the built-in TUI keeps its direct in-process read path for exactly that case).
|
|
77
82
|
- The peer's **LLM model is deliberately absent**: the effective model of a live session is not an observable registry fact (static launch pins exist, but reporting a pin as «the model» would lie under runtime auto-switching). If it ever becomes observable, it will appear as an additive field.
|
|
83
|
+
- `approvals` (top-level, docs/17-approval) — the pending human-approval queue, the SAME items `GET /fleet/v1/approvals` returns, carried in the snapshot so a client that already re-fetches `/snapshot` on every event renders the queue with no extra call. **ADDITIVE + OMITTED when the queue is empty** — a client MUST treat its absence as an empty queue (the same rule as `approval_mode`). Each item: `id` (broker id, the target of `POST /fleet/v1/approvals/<id>/(approve|deny)`), `personality` + `runtime` (whose action), `kind` (`tool`|`plan`|`question`|`circuit-breaker`), `tool` (the tool / breaker name), `summary` (a one-line badge string), `content` (the FULL verbatim action — command / diff / plan; criterion #7), `createdMs` / `expiresMs`. The broker's item is a superset — unknown extra fields (`title`, `approvers`) MUST be ignored (obligation 2).
|
|
78
84
|
|
|
79
85
|
## GET /fleet/v1/peers/<personality>
|
|
80
86
|
|
|
@@ -134,7 +140,7 @@ What the headline states look like on the stream: a peer **waking** is `ev=wake`
|
|
|
134
140
|
|
|
135
141
|
`from` is optional: a full `<runtime>-<personality>`, or a bare personality (its default runtime). Default: the **single natural-intelligence peer** of the registry — a GUI-originated message is the human's. With zero or several natural peers the request is refused (`400`) with an instruction to pass `from`.
|
|
136
142
|
|
|
137
|
-
Not part of the API, deliberately: **attach** (a terminal handoff — a client opens the system terminal with `iapeer attach <peer>`), registry/host **admin verbs** (`create`/`remove`/`update`/`uninstall` — will be added when a client actually needs them)
|
|
143
|
+
Not part of the *command* API, deliberately: **attach** (a terminal handoff — a client opens the system terminal with `iapeer attach <peer>`), registry/host **admin verbs** (`create`/`remove`/`update`/`uninstall` — will be added when a client actually needs them). The **human-approval broker** now lives under `/fleet/v1/approvals*` (`GET` the queue / one item, `POST /<id>/(approve|deny)`) with the `approval-request` / `approval-resolved` SSE `ev` kinds and the `approvals` snapshot field above — the full contract is **docs/17-approval**.
|
|
138
144
|
|
|
139
145
|
Errors are always `{"error": "…"}` with an honest status: `400` malformed request, `401` bearer required, `404` unknown peer/endpoint, `405` wrong method, `502` the verb itself failed, `500` internal.
|
|
140
146
|
|
package/docs/16-tray.md
CHANGED
|
@@ -16,10 +16,13 @@ activation that also installs SwiftBar.app when it is absent.
|
|
|
16
16
|
|
|
17
17
|
## What you see
|
|
18
18
|
|
|
19
|
-
- **Menu bar:** an antenna glyph + the count of peers with a live session. When
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
- **Menu bar:** an antenna glyph + the count of peers with a live session. When one or more
|
|
20
|
+
**human-approval** requests are pending, the menu bar instead shows a **red `N.circle.fill`
|
|
21
|
+
badge** (the iOS-notification look — a red circle with the count) so the owner sees an
|
|
22
|
+
awaiting request at a glance. When no advertised daemon address answers, a red warning
|
|
23
|
+
triangle instead (the daemon is down — a purely API-fed dashboard cannot show a broken
|
|
24
|
+
daemon any other way; the built-in TUI keeps its direct read path for that case, see
|
|
25
|
+
[15](15-fleet-api.md)).
|
|
23
26
|
- **Dropdown:** a host header (`iapeer <version> · up <uptime>`) and the memory/voice
|
|
24
27
|
provider heartbeats, then one row per peer:
|
|
25
28
|
- `<peer> <runtime><glyph> … <age>` with the same glyph semantics as the CLI —
|
|
@@ -36,6 +39,16 @@ activation that also installs SwiftBar.app when it is absent.
|
|
|
36
39
|
peers (telegram / notifier, `🔒`) are not pty-attachable** — their rows are plain status
|
|
37
40
|
lines. Lifecycle actions (Wake / Stop / Start / New / Interrupt / Refresh / Compact) live
|
|
38
41
|
in the **Manage** submenu, each a `POST /fleet/v1/peers/<peer>/<cmd>` via `iapeer tray cmd`.
|
|
42
|
+
- **Pending approvals (docs/17):** when the queue is non-empty, the **top of the dropdown** shows
|
|
43
|
+
each pending request **expanded, with no extra clicks** — a header (`<peer> · <tool>`), the
|
|
44
|
+
**verbatim action content** (the command / diff / plan, monospace, capped for the menu; the FULL
|
|
45
|
+
content stays in `iapeer approvals`), then **Allow** / **Deny** items directly below. Clicking runs
|
|
46
|
+
`iapeer tray approve|deny <id>` in the background → the broker resolves → the stream re-renders (the
|
|
47
|
+
badge clears). The peer with a pending request is **highlighted in the fleet list** (a `⚠` prefix, a
|
|
48
|
+
red `🔴N` count, the row painted red) so the owner sees *which* peer is waiting. This channel is
|
|
49
|
+
**always on** — the approval queue is reflected whenever it is non-empty, independent of everything
|
|
50
|
+
else; an empty queue shows no section and no badge. (A true *pulse* animation is not possible in a
|
|
51
|
+
static SwiftBar menu — the highlight + badge is its equivalent.)
|
|
39
52
|
|
|
40
53
|
The list updates itself: the plugin is **streamable** — it subscribes to the event stream
|
|
41
54
|
and re-fetches the snapshot on every change (a peer waking, an operator stop/start, a
|
|
@@ -54,6 +67,7 @@ whole dashboard twice.
|
|
|
54
67
|
| `iapeer tray uninstall` | Remove the plugin file and refresh SwiftBar. **Never touches the fleet** — the daemon, TUI and delivery keep running; SwiftBar.app is left installed (it may host other plugins). |
|
|
55
68
|
| `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. |
|
|
56
69
|
| `iapeer tray cmd <command> <peer> [runtime]` | POST a fleet command (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — the menu's lifecycle actions call this. |
|
|
70
|
+
| `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). |
|
|
57
71
|
| `iapeer tray status` | Read-only: is the fleet API up (from `router.json`), is SwiftBar installed and where is its plugin dir, is the plugin file present. Repairs nothing. |
|
|
58
72
|
|
|
59
73
|
## How it is wired
|
package/docs/17-approval.md
CHANGED
|
@@ -37,7 +37,7 @@ The bypass is a **launch argument**, different per runtime, and the mode is a co
|
|
|
37
37
|
|
|
38
38
|
**Why matcher-free `PermissionRequest`, not `PreToolUse`.** `PermissionRequest` fires **only when the runtime's permission config decided to prompt** — a call not covered by an allow/deny rule. So the policy of *what to ask* stays 100% at the runtime: a user tunes it with ordinary permission rules (a tool in `permissions.allow` is never asked → the hook never fires → auto-allowed). No class matcher to drift, no new tool slipping through, no hang. iapeer only seeds the hook + the one allow-rule for the peer's own MCP tool.
|
|
39
39
|
|
|
40
|
-
**codex** (`PreToolUse` hook —
|
|
40
|
+
**codex** (`PreToolUse` hook — verified live, codex-cli 0.142.5):
|
|
41
41
|
|
|
42
42
|
| # | Surface | yolo | gated |
|
|
43
43
|
|---|---|---|---|
|
|
@@ -62,7 +62,7 @@ All surfaces are read/applied **at session start**. A **live session keeps its l
|
|
|
62
62
|
Both runtimes carry a Claude-compatible **hook** system that intercepts an approval programmatically and returns `allow`/`deny` + a reason + the structured action content. That is the primary mechanism. **pty screen-scrape is a backstop for one class only** — claude's `dangerous-rm` circuit-breaker, which sits *above* the permission layer and no hook can see.
|
|
63
63
|
|
|
64
64
|
- **claude `PermissionRequest`** — stdin carries `{tool_name, tool_input, …}`; the hook prints `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow|deny","message":"…"}}}`. A `deny` carries `message` to the model. Fires only on prompt-worthy calls.
|
|
65
|
-
- **codex `PreToolUse`** — same stdin shape; prints `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow|deny","permissionDecisionReason":"…"}}`. `exit 2` (+ stderr) also denies.
|
|
65
|
+
- **codex `PreToolUse`** — same stdin shape; prints `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow|deny","permissionDecisionReason":"…"}}`. `exit 2` (+ stderr) also denies. Verified live (0.142.5): codex fires `PreToolUse` (not `PermissionRequest`) for tool calls with `tool_name:"Bash"` for shell; a `deny` hard-blocks the tool and the reason reaches the model even under `sandbox_mode=danger-full-access` — so under the gated config (danger-full-access + on-request → `permission_mode=bypassPermissions`) the hook is the SOLE gate.
|
|
66
66
|
- **supervisor circuit-breaker** — claude's `dangerous-rm`/`rmdir` guard (and the standard command-approval prompt a no-bypass session shows) is a TUI select the hook never sees. The pty supervisor reads it off its authoritative model and, under gated, POSTs it to the broker (below); under yolo it auto-presses YES with an audit line.
|
|
67
67
|
|
|
68
68
|
The hook binary is the `iapeer approval-hook` subcommand: it reads the runtime's hook JSON on stdin, resolves `PEER_PERSONALITY`/`PEER_RUNTIME` from its session env and the daemon URL from `router.json`, POSTs a blocking request to the broker, and prints the runtime-appropriate decision JSON.
|
package/docs/ru/15-fleet-api.md
CHANGED
|
@@ -57,6 +57,11 @@ HTTP-поверхность демона для **операторских кл
|
|
|
57
57
|
"launchd_managed": false,
|
|
58
58
|
"wake_policy": "warm"
|
|
59
59
|
}
|
|
60
|
+
],
|
|
61
|
+
"approvals": [
|
|
62
|
+
{ "id": "a1", "personality": "boris", "runtime": "claude", "kind": "circuit-breaker",
|
|
63
|
+
"tool": "dangerous-rm", "summary": "rm -rf /tmp/x",
|
|
64
|
+
"content": "cmd=\"rm -rf /tmp/x\" target=\"/tmp/x\"", "createdMs": 1751731100000, "expiresMs": 1751731400000 }
|
|
60
65
|
]
|
|
61
66
|
}
|
|
62
67
|
```
|
|
@@ -73,6 +78,7 @@ HTTP-поверхность демона для **операторских кл
|
|
|
73
78
|
- `host.fda` — Full Disk Access бинаря: `true` / `false` / `null` (неопределимо).
|
|
74
79
|
- Сам факт ответа демона **и есть** сигнал здоровья: булева `healthy` нет. Дашборд, не достучавшийся ни до одного объявленного адреса, обязан рисовать демона лежачим (поэтому же дашборд, питающийся только API, недостаточен, когда сломан сам демон — встроенный TUI сохраняет прямой in-process путь чтения ровно на этот случай).
|
|
75
80
|
- **LLM-модель пира отсутствует намеренно**: эффективная модель живой сессии — не наблюдаемый факт реестра (статические пины запуска существуют, но выдавать пин за «модель» — врать при рантаймном автопереключении). Станет наблюдаемой — появится аддитивным полем.
|
|
81
|
+
- `approvals` (top-level, docs/17-approval) — очередь ожидающих human-approval, ТЕ ЖЕ item'ы, что отдаёт `GET /fleet/v1/approvals`, в снапшоте — чтобы клиент, уже перефетчащий `/snapshot` на каждое событие, рендерил очередь без доп. вызова. **АДДИТИВНО + ОПУСКАЕТСЯ при пустой очереди** — клиент ОБЯЗАН трактовать отсутствие как пустую очередь (то же правило, что `approval_mode`). Поля item: `id` (broker-id, цель `POST /fleet/v1/approvals/<id>/(approve|deny)`), `personality` + `runtime` (чьё действие), `kind` (`tool`|`plan`|`question`|`circuit-breaker`), `tool` (имя инструмента / breaker'а), `summary` (однострочник для бейджа), `content` (ПОЛНОЕ дословное действие — команда / диф / план; крит №7), `createdMs` / `expiresMs`. Item брокера — суперсет: неизвестные лишние поля (`title`, `approvers`) ОБЯЗАНЫ игнорироваться (обязательство 2).
|
|
76
82
|
|
|
77
83
|
## GET /fleet/v1/peers/<personality>
|
|
78
84
|
|
|
@@ -132,7 +138,7 @@ GET /fleet/v1/events?replay=50
|
|
|
132
138
|
|
|
133
139
|
`from` опционален: полный `<runtime>-<personality>` или голая personality (её default-рантайм). По умолчанию — **единственный natural-intelligence пир** реестра: сообщение из GUI принадлежит человеку. При нуле или нескольких natural-пирах запрос отклоняется (`400`) с указанием передать `from`.
|
|
134
140
|
|
|
135
|
-
Не входит в API, намеренно: **attach** (передача терминала — клиент открывает системный терминал с `iapeer attach <peer>`), реестровые/хостовые **admin-verb'ы** (`create`/`remove`/`update`/`uninstall` — добавятся, когда появится клиент с реальной потребностью)
|
|
141
|
+
Не входит в *командный* API, намеренно: **attach** (передача терминала — клиент открывает системный терминал с `iapeer attach <peer>`), реестровые/хостовые **admin-verb'ы** (`create`/`remove`/`update`/`uninstall` — добавятся, когда появится клиент с реальной потребностью). **Брокер human-approval** теперь живёт под `/fleet/v1/approvals*` (`GET` очередь / один item, `POST /<id>/(approve|deny)`) с видами SSE `approval-request` / `approval-resolved` и полем `approvals` в снапшоте выше — полный контракт в **docs/17-approval**.
|
|
136
142
|
|
|
137
143
|
Ошибки — всегда `{"error": "…"}` с честным статусом: `400` некорректный запрос, `401` требуется bearer, `404` неизвестный пир/эндпоинт, `405` не тот метод, `502` сам verb упал, `500` внутренняя.
|
|
138
144
|
|
package/docs/ru/16-tray.md
CHANGED
|
@@ -15,8 +15,10 @@ SwiftBar.app, если его нет.
|
|
|
15
15
|
|
|
16
16
|
## Что видно
|
|
17
17
|
|
|
18
|
-
- **Меню-бар:** глиф-антенна + число пиров с живой сессией.
|
|
19
|
-
|
|
18
|
+
- **Меню-бар:** глиф-антенна + число пиров с живой сессией. Когда есть ожидающие запросы
|
|
19
|
+
**human-approval**, меню-бар вместо этого показывает **красный бейдж `N.circle.fill`** (iOS-look —
|
|
20
|
+
красный круг с числом), чтобы владелец сразу видел ожидающий запрос. Если ни один объявленный
|
|
21
|
+
адрес демона не отвечает — красный треугольник (демон недоступен; чисто API-дашборд иначе не
|
|
20
22
|
покажет сломанный демон — встроенный TUI держит прямой путь чтения для этого случая, см.
|
|
21
23
|
[15](15-fleet-api.md)).
|
|
22
24
|
- **Выпадашка:** шапка хоста (`iapeer <версия> · up <аптайм>`) и хартбиты провайдеров памяти
|
|
@@ -34,6 +36,15 @@ SwiftBar.app, если его нет.
|
|
|
34
36
|
(telegram / notifier, `🔒`) не pty-аттачатся** — их строки просто статус. Lifecycle-действия
|
|
35
37
|
(Wake / Stop / Start / New / Interrupt / Refresh / Compact) — в подменю **Manage**, каждое —
|
|
36
38
|
`POST /fleet/v1/peers/<peer>/<cmd>` через `iapeer tray cmd`.
|
|
39
|
+
- **Ожидающие подтверждения (docs/17):** когда очередь непуста, **вверху выпадашки** каждый
|
|
40
|
+
ожидающий запрос показан **раскрытым, без доп. кликов** — заголовок (`<peer> · <tool>`),
|
|
41
|
+
**дословное содержимое действия** (команда / диф / план, моноширинно, усечено для меню; ПОЛНОЕ
|
|
42
|
+
содержимое — в `iapeer approvals`), затем пункты **Allow** / **Deny** прямо под ним. Клик запускает
|
|
43
|
+
`iapeer tray approve|deny <id>` в фоне → брокер резолвит → стрим ре-рендерит (бейдж гаснет). Пир с
|
|
44
|
+
ожидающим запросом **выделен в списке флота** (префикс `⚠`, красный счётчик `🔴N`, строка красная),
|
|
45
|
+
чтобы владелец видел, *какой* пир ждёт. Канал **always-on** — очередь отражается всегда, когда
|
|
46
|
+
непуста, независимо от прочего; пустая очередь — ни секции, ни бейджа. (Настоящая *пульс*-анимация в
|
|
47
|
+
статичном SwiftBar-меню невозможна — highlight + бейдж это её эквивалент.)
|
|
37
48
|
|
|
38
49
|
Список обновляется сам: плагин **streamable** — подписан на поток событий и перечитывает
|
|
39
50
|
снапшот на каждое изменение (пробуждение пира, стоп/старт оператором, смерть), склеивая
|
|
@@ -51,6 +62,7 @@ SwiftBar.app, если его нет.
|
|
|
51
62
|
| `iapeer tray uninstall` | Удалит файл плагина и обновит SwiftBar. **Флот не трогает** — демон, TUI и доставка живут; SwiftBar.app остаётся (может держать другие плагины). |
|
|
52
63
|
| `iapeer tray render [--stream]` | Печатает вывод плагина SwiftBar. `--stream` — streamable-цикл, который крутит плагин; одноразовая форма — поллинг-фолбэк и тест-поверхность. |
|
|
53
64
|
| `iapeer tray cmd <command> <peer> [runtime]` | POST fleet-команды (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — lifecycle-пункты меню зовут это. |
|
|
65
|
+
| `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). |
|
|
54
66
|
| `iapeer tray status` | Только чтение: жив ли fleet API (из `router.json`), установлен ли SwiftBar и где его каталог плагинов, на месте ли файл плагина. Ничего не чинит. |
|
|
55
67
|
|
|
56
68
|
## Как устроено
|
package/docs/ru/17-approval.md
CHANGED
|
@@ -37,7 +37,7 @@ Bypass — это **аргумент запуска**, у каждого ран
|
|
|
37
37
|
|
|
38
38
|
**Почему matcher-free `PermissionRequest`, а не `PreToolUse`.** `PermissionRequest` фаярит **только когда permission-конфиг рантайма решил спросить** — вызов, не покрытый allow/deny-правилом. Значит политика *что спрашивать* остаётся на 100% у рантайма: юзер настраивает её обычными permission-правилами (инструмент в `permissions.allow` не спрашивается никогда → хук не фаярит → авто-allow). Нет матчера классов, который дрейфует, нет проскакивающего нового инструмента, нет зависания. iapeer лишь сеет хук + одно allow-правило для собственного MCP-инструмента пира.
|
|
39
39
|
|
|
40
|
-
**codex** (хук `PreToolUse` —
|
|
40
|
+
**codex** (хук `PreToolUse` — проверено живьём, codex-cli 0.142.5):
|
|
41
41
|
|
|
42
42
|
| # | Поверхность | yolo | gated |
|
|
43
43
|
|---|---|---|---|
|
|
@@ -62,7 +62,7 @@ codex-хук матчится по регекспу имён инструмен
|
|
|
62
62
|
У обоих рантаймов есть Claude-совместимая **hook-система**, которая перехватывает подтверждение программно и возвращает `allow`/`deny` + причину + структурное содержимое действия. Это первичный механизм. **Экранный pty-скрейп — бэкстоп только для одного класса** — claude-circuit-breaker `dangerous-rm`, который живёт *выше* permission-слоя и не виден ни одному хуку.
|
|
63
63
|
|
|
64
64
|
- **claude `PermissionRequest`** — на stdin `{tool_name, tool_input, …}`; хук печатает `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow|deny","message":"…"}}}`. `deny` доносит `message` до модели. Фаярит только на вопрос-достойных вызовах.
|
|
65
|
-
- **codex `PreToolUse`** — тот же shape stdin; печатает `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow|deny","permissionDecisionReason":"…"}}`. `exit 2` (+ stderr) тоже отклоняет.
|
|
65
|
+
- **codex `PreToolUse`** — тот же shape stdin; печатает `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow|deny","permissionDecisionReason":"…"}}`. `exit 2` (+ stderr) тоже отклоняет. Проверено живьём (0.142.5): codex фаярит `PreToolUse` (не `PermissionRequest`) на tool-вызовах с `tool_name:"Bash"` для shell; `deny` жёстко блокирует тул и причина доходит до модели даже под `sandbox_mode=danger-full-access` — так что под gated-конфигом (danger-full-access + on-request → `permission_mode=bypassPermissions`) хук — ЕДИНСТВЕННЫЙ гейт.
|
|
66
66
|
- **супервизор circuit-breaker** — гард `dangerous-rm`/`rmdir` claude (и стандартный command-approval-промпт, который показывает no-bypass-сессия) — это TUI-селект, невидимый хуку. pty-супервизор читает его со своей авторитетной модели и под gated POSTит брокеру (ниже); под yolo авто-жмёт YES с аудит-строкой.
|
|
67
67
|
|
|
68
68
|
Хук-бинарь — сабкоманда `iapeer approval-hook`: читает hook-JSON рантайма со stdin, резолвит `PEER_PERSONALITY`/`PEER_RUNTIME` из session-env и URL демона из `router.json`, POSTит блокирующий запрос брокеру и печатает рантайм-специфичный JSON решения.
|
package/package.json
CHANGED
package/src/approval/install.ts
CHANGED
|
@@ -25,7 +25,13 @@ import { preSeedCodexHooksTrust, removeCodexHooksTrustUnder, type HooksTrustOutc
|
|
|
25
25
|
// never asked → the hook never fires → auto-allowed). No class matcher, no drift, no hang: a new
|
|
26
26
|
// tool the runtime prompts for is intercepted the same way. iapeer only seeds the hook + the
|
|
27
27
|
// allow-rule for its own MCP tool (else the runtime would prompt on the peer's own IAP channel).
|
|
28
|
-
|
|
28
|
+
// codex tool-name regex (codex PreToolUse path). VERIFIED LIVE (codex-cli 0.142.5, Unit 3): codex
|
|
29
|
+
// fires `PreToolUse` (NOT PermissionRequest) for tool calls, with `tool_name:"Bash"` for shell exec +
|
|
30
|
+
// `tool_input.command` = the full command; a PreToolUse `deny` HARD-BLOCKS the tool AND the
|
|
31
|
+
// `permissionDecisionReason` reaches the model — even under `sandbox_mode=danger-full-access`
|
|
32
|
+
// (permission_mode=bypassPermissions), so under the gated config the hook is the SOLE gate. `Bash`
|
|
33
|
+
// confirmed live; `apply_patch` (the patch tool) per the codex hooks docs. The extra alternatives are a
|
|
34
|
+
// forward-compatible superset (a future codex rename still matches) — harmless, never over-matches.
|
|
29
35
|
export const CODEX_APPROVAL_MATCHER = '^(Bash|Shell|shell|local_shell|exec|apply_patch|ApplyPatch)$'
|
|
30
36
|
/** The peer's own IAP tool — allow-listed so a gated peer's send_to_peer is not itself gated. */
|
|
31
37
|
export const IAPEER_MCP_ALLOW = 'mcp__iapeer__send_to_peer'
|
package/src/cli/index.ts
CHANGED
|
@@ -1278,8 +1278,8 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
|
|
|
1278
1278
|
desc: 'DARK (cutover Block 2): detach-persistent pty-supervisor PoC port; serves nothing on the live fleet (throwaway validation only)',
|
|
1279
1279
|
},
|
|
1280
1280
|
{
|
|
1281
|
-
sig: 'tray install|uninstall|render [--stream]|cmd <c> <peer>|status',
|
|
1282
|
-
desc: 'the macOS menu-bar fleet dashboard (SwiftBar plugin) — first external Fleet API client; install activates it (installs SwiftBar when absent). See docs/16.',
|
|
1281
|
+
sig: 'tray install|uninstall|render [--stream]|cmd <c> <peer>|approve <id>|deny <id> [reason]|status',
|
|
1282
|
+
desc: 'the macOS menu-bar fleet dashboard (SwiftBar plugin) — first external Fleet API client; renders the approval queue (badge + Allow/Deny), install activates it (installs SwiftBar when absent). See docs/16.',
|
|
1283
1283
|
},
|
|
1284
1284
|
{ sig: 'approval-mode <peer> [gated|yolo]', desc: 'read/flip a peer\'s human-approval mode (docs/17): yolo=current bypass; gated=blocking runtime approvals routed to a human. Persists + brings runtime surfaces to the mode; applies on next fresh session' },
|
|
1285
1285
|
{ sig: 'approvals [--json]', desc: 'list pending human-approval requests (verbatim action content); the host answer channel to the daemon broker' },
|
|
@@ -2542,6 +2542,23 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2542
2542
|
out(`${JSON.stringify(r.body)}\n`)
|
|
2543
2543
|
return r.ok ? 0 : 1
|
|
2544
2544
|
}
|
|
2545
|
+
case 'approve':
|
|
2546
|
+
case 'deny': {
|
|
2547
|
+
// Tray click → resolve a pending approval over the unix-first fleet client (docs/17). The
|
|
2548
|
+
// menu Allow/Deny items run these; the broker resolution is seen by every channel.
|
|
2549
|
+
const id = positionals[1]
|
|
2550
|
+
if (!id) return argErr(errOut, `usage: iapeer tray ${sub} <approval-id> [reason]`)
|
|
2551
|
+
const reason = sub === 'deny' ? positionals.slice(2).join(' ') || undefined : undefined
|
|
2552
|
+
let r: Awaited<ReturnType<typeof tray.trayResolveApproval>>
|
|
2553
|
+
try {
|
|
2554
|
+
r = await tray.trayResolveApproval(env, sub, id, reason)
|
|
2555
|
+
} catch (e) {
|
|
2556
|
+
errOut(`tray ${sub}: ${e instanceof Error ? e.message : String(e)}\n`)
|
|
2557
|
+
return 1
|
|
2558
|
+
}
|
|
2559
|
+
out(`${JSON.stringify(r.body)}\n`)
|
|
2560
|
+
return r.ok ? 0 : 1
|
|
2561
|
+
}
|
|
2545
2562
|
case 'install': {
|
|
2546
2563
|
// Explicit activation verb: installs SwiftBar.app when absent
|
|
2547
2564
|
// (owner-sanctioned) + launches, unless --plugin-only (file only).
|
package/src/daemon/fleet.ts
CHANGED
|
@@ -111,12 +111,32 @@ export interface FleetHost {
|
|
|
111
111
|
fda: boolean | null
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/** A pending human-approval request as the snapshot exposes it (docs/17). The SAME items
|
|
115
|
+
* `GET /fleet/v1/approvals` returns (broker.list()) — carried in the snapshot so a client
|
|
116
|
+
* that already fetches `/snapshot` on every event renders the queue with no extra call. The
|
|
117
|
+
* compact `summary` fuels a badge/one-liner; the verbatim `content` (criterion #7 — the full
|
|
118
|
+
* command / diff / plan) rides along for inline display, capped by the client if it wishes. */
|
|
119
|
+
export interface FleetApproval {
|
|
120
|
+
id: string
|
|
121
|
+
personality: string
|
|
122
|
+
runtime: string
|
|
123
|
+
kind: string
|
|
124
|
+
tool: string
|
|
125
|
+
summary: string
|
|
126
|
+
content: string
|
|
127
|
+
createdMs: number
|
|
128
|
+
expiresMs: number
|
|
129
|
+
}
|
|
130
|
+
|
|
114
131
|
export interface FleetSnapshot {
|
|
115
132
|
api: typeof FLEET_API_VERSION
|
|
116
133
|
version: string
|
|
117
134
|
ts: string
|
|
118
135
|
host: FleetHost
|
|
119
136
|
peers: FleetPeer[]
|
|
137
|
+
/** Pending human-approval requests (docs/17). ADDITIVE + omitted when the queue is empty —
|
|
138
|
+
* a client MUST treat its absence as an empty queue (same rule as `approval_mode`). */
|
|
139
|
+
approvals?: FleetApproval[]
|
|
120
140
|
}
|
|
121
141
|
|
|
122
142
|
// The CLI op functions, injectable for tests. Defaults LAZY-load cli/index.ts at
|
|
@@ -152,6 +172,7 @@ export function buildFleetSnapshot(
|
|
|
152
172
|
ops: Pick<FleetOps, 'listPeers'>,
|
|
153
173
|
startedAtMs: number,
|
|
154
174
|
nowMs: number = Date.now(),
|
|
175
|
+
approvals: FleetApproval[] = [],
|
|
155
176
|
): FleetSnapshot {
|
|
156
177
|
const peers: FleetPeer[] = ops.listPeers({ env }).map(r => {
|
|
157
178
|
const runtimes: FleetRuntimeStatus[] = r.runtimes.map(s => {
|
|
@@ -208,6 +229,10 @@ export function buildFleetSnapshot(
|
|
|
208
229
|
fda: probeFullDiskAccess(env),
|
|
209
230
|
},
|
|
210
231
|
peers,
|
|
232
|
+
// ADDITIVE + omitted when empty (absence ⇒ empty queue, docs/15). broker.list() is a
|
|
233
|
+
// superset of FleetApproval — the named fields are the contract; extras ride along and
|
|
234
|
+
// clients ignore them (obligation 2).
|
|
235
|
+
...(approvals.length ? { approvals } : {}),
|
|
211
236
|
}
|
|
212
237
|
}
|
|
213
238
|
|
|
@@ -484,7 +509,9 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
|
|
|
484
509
|
// ── reads ────────────────────────────────────────────────────────────
|
|
485
510
|
if (path === '/fleet/v1/snapshot') {
|
|
486
511
|
if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/snapshot' })
|
|
487
|
-
|
|
512
|
+
// broker.list() is PendingApproval[] — structurally a superset of FleetApproval, so it
|
|
513
|
+
// satisfies the snapshot contract (extra fields serialize + are ignored by clients).
|
|
514
|
+
return sendJson(res, 200, buildFleetSnapshot(env, cfg, await getOps(), startedAtMs, undefined, broker.list()))
|
|
488
515
|
}
|
|
489
516
|
if (path === '/fleet/v1/events') {
|
|
490
517
|
if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/events' })
|
package/src/tray/index.ts
CHANGED
|
@@ -150,3 +150,28 @@ export async function trayCmd(
|
|
|
150
150
|
const { status, body } = await fleetPostJson(addr, path, runtime ? { runtime } : {}, env)
|
|
151
151
|
return { ok: status < 400, status, body }
|
|
152
152
|
}
|
|
153
|
+
|
|
154
|
+
export type ApprovalVerb = 'approve' | 'deny'
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Resolve a pending human-approval from the tray: POST /fleet/v1/approvals/<id>/(approve|deny) over
|
|
158
|
+
* the SAME unix-first fleet client trayCmd uses — the proxy-safe transport (the absolute-form-misroute
|
|
159
|
+
* grabli: a peer's Happ proxy would otherwise mangle a loopback POST). Tags `via:'tray'` for the audit
|
|
160
|
+
* trail; the single-queue invariant means this resolution is seen by every channel (CLI/telegram). A
|
|
161
|
+
* 404 (already resolved / expired) surfaces as ok=false with the daemon's message — the caller reports
|
|
162
|
+
* it (the request the user saw was answered elsewhere first).
|
|
163
|
+
*/
|
|
164
|
+
export async function trayResolveApproval(
|
|
165
|
+
env: NodeJS.ProcessEnv,
|
|
166
|
+
decision: ApprovalVerb,
|
|
167
|
+
id: string,
|
|
168
|
+
reason?: string,
|
|
169
|
+
): Promise<TrayCmdResult> {
|
|
170
|
+
const addr = resolveFleetAddress({ env })
|
|
171
|
+
if (addr.fleet !== 1 || (!addr.sock && !addr.tcp)) {
|
|
172
|
+
return { ok: false, status: 0, body: { error: 'fleet daemon unreachable (no fleet:1 in router.json)' } }
|
|
173
|
+
}
|
|
174
|
+
const path = `/fleet/v1/approvals/${encodeURIComponent(id)}/${decision}`
|
|
175
|
+
const { status, body } = await fleetPostJson(addr, path, { via: 'tray', ...(reason ? { reason } : {}) }, env)
|
|
176
|
+
return { ok: status < 400, status, body }
|
|
177
|
+
}
|
package/src/tray/render.ts
CHANGED
|
@@ -18,8 +18,15 @@ const COLOR = {
|
|
|
18
18
|
meta: '#8b949e',
|
|
19
19
|
live: '#3fb950',
|
|
20
20
|
down: '#f85149',
|
|
21
|
+
warn: '#d29922',
|
|
21
22
|
} as const
|
|
22
23
|
|
|
24
|
+
// Approval content shown INLINE in the menu is capped (a big diff must not blow up the
|
|
25
|
+
// dropdown); the FULL verbatim content stays available via `iapeer approvals` + GET
|
|
26
|
+
// /fleet/v1/approvals/<id> (criterion #7 holds on the payload, the menu is its truncated view).
|
|
27
|
+
const APPROVAL_CONTENT_MAX_LINES = 20
|
|
28
|
+
const APPROVAL_CONTENT_MAX_CHARS = 8000
|
|
29
|
+
|
|
23
30
|
// ── docs/15 snapshot shape (only the fields the tray reads) ───────────────────
|
|
24
31
|
|
|
25
32
|
export interface TrayRuntime {
|
|
@@ -40,6 +47,16 @@ export interface TrayPeer {
|
|
|
40
47
|
queue_depth?: number
|
|
41
48
|
}
|
|
42
49
|
|
|
50
|
+
export interface TrayApproval {
|
|
51
|
+
id: string
|
|
52
|
+
personality: string
|
|
53
|
+
runtime?: string
|
|
54
|
+
kind?: string
|
|
55
|
+
tool: string
|
|
56
|
+
summary?: string
|
|
57
|
+
content?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
export interface TraySnapshot {
|
|
44
61
|
version?: string
|
|
45
62
|
host?: {
|
|
@@ -50,6 +67,8 @@ export interface TraySnapshot {
|
|
|
50
67
|
fda?: boolean | null
|
|
51
68
|
}
|
|
52
69
|
peers: TrayPeer[]
|
|
70
|
+
/** Pending human-approval requests (docs/15/17). ABSENT ⇒ empty queue (client obligation). */
|
|
71
|
+
approvals?: TrayApproval[]
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
export interface RenderOptions {
|
|
@@ -135,12 +154,28 @@ export function renderSwiftBar(snapshot: TraySnapshot, opts: RenderOptions): str
|
|
|
135
154
|
return r !== 0 ? r : a.personality.localeCompare(b.personality)
|
|
136
155
|
})
|
|
137
156
|
const liveCount = snapshot.peers.filter(p => aggregate(p) === 'live').length
|
|
157
|
+
const pending = snapshot.approvals ?? []
|
|
158
|
+
const pendingByPeer = new Map<string, number>()
|
|
159
|
+
for (const a of pending) pendingByPeer.set(a.personality, (pendingByPeer.get(a.personality) ?? 0) + 1)
|
|
138
160
|
|
|
139
161
|
const lines: string[] = []
|
|
140
|
-
// ── menu-bar title
|
|
141
|
-
|
|
162
|
+
// ── menu-bar title ──
|
|
163
|
+
// Pending approvals TAKE OVER the menu-bar as a red iOS-style badge so the owner sees an awaiting
|
|
164
|
+
// request at a glance; an empty queue is the normal antenna + live-peer count.
|
|
165
|
+
if (pending.length > 0) {
|
|
166
|
+
lines.push(renderBadge(pending.length))
|
|
167
|
+
} else {
|
|
168
|
+
lines.push(line(0, String(liveCount), { sfimage: 'antenna.radiowaves.left.and.right' }))
|
|
169
|
+
}
|
|
142
170
|
lines.push('---')
|
|
143
171
|
|
|
172
|
+
// ── pending approvals (ALWAYS-ON: whenever the queue is non-empty, at the TOP of the dropdown;
|
|
173
|
+
// each request expanded with verbatim content + Allow/Deny at depth 0 — no extra clicks) ──
|
|
174
|
+
if (pending.length > 0) {
|
|
175
|
+
lines.push(...renderApprovals(pending, bin))
|
|
176
|
+
lines.push('---')
|
|
177
|
+
}
|
|
178
|
+
|
|
144
179
|
// ── host header ──
|
|
145
180
|
const ver = snapshot.host?.version ?? snapshot.version ?? '?'
|
|
146
181
|
const up = snapshot.host?.uptimeSecs !== undefined ? ` · up ${fmtUptime(snapshot.host.uptimeSecs)}` : ''
|
|
@@ -154,7 +189,7 @@ export function renderSwiftBar(snapshot: TraySnapshot, opts: RenderOptions): str
|
|
|
154
189
|
lines.push(line(0, 'no peers registered', { color: COLOR.meta }))
|
|
155
190
|
}
|
|
156
191
|
for (const p of peers) {
|
|
157
|
-
lines.push(renderPeerRow(p, bin, now))
|
|
192
|
+
lines.push(renderPeerRow(p, bin, now, pendingByPeer.get(p.personality) ?? 0))
|
|
158
193
|
}
|
|
159
194
|
|
|
160
195
|
// ── Manage submenu (lifecycle commands per peer; kept off the rows so a row click
|
|
@@ -203,7 +238,7 @@ function attachable(p: TrayPeer): boolean {
|
|
|
203
238
|
* `Cmd-T` System-Events keystroke that silently no-ops without Accessibility permission.
|
|
204
239
|
* Infra (launchd) rows are plain status lines. Lifecycle lives in the Manage submenu.
|
|
205
240
|
*/
|
|
206
|
-
function renderPeerRow(p: TrayPeer, bin: string, now: number): string {
|
|
241
|
+
function renderPeerRow(p: TrayPeer, bin: string, now: number, pendingCount = 0): string {
|
|
207
242
|
const agg = aggregate(p)
|
|
208
243
|
const detail = p.runtimes.map(r => `${r.runtime}${GLYPH[r.status]}`).join(' ')
|
|
209
244
|
const badges =
|
|
@@ -211,8 +246,13 @@ function renderPeerRow(p: TrayPeer, bin: string, now: number): string {
|
|
|
211
246
|
(p.launchd_managed ? ' 🔒' : '') +
|
|
212
247
|
(p.wake_policy === 'ephemeral' && (p.queue_depth ?? 0) > 0 ? ` ⏳${p.queue_depth}` : '')
|
|
213
248
|
const ageStr = p.last_active_ms ? ` ${fmtAge(p.last_active_ms, now)}` : ''
|
|
214
|
-
|
|
215
|
-
|
|
249
|
+
// HIGHLIGHT a peer with a pending approval: a ⚠ prefix + a red count badge + the row painted red
|
|
250
|
+
// so the owner sees WHICH peer is waiting (owner criterion — the static-menu equivalent of a pulse;
|
|
251
|
+
// SwiftBar cannot animate). The highlight wins over the live/meta color.
|
|
252
|
+
const prefix = pendingCount > 0 ? '⚠ ' : ''
|
|
253
|
+
const pendingBadge = pendingCount > 0 ? ` 🔴${pendingCount}` : ''
|
|
254
|
+
const label = `${prefix}${p.personality} ${detail}${badges}${pendingBadge}${ageStr}`
|
|
255
|
+
const rowColor = pendingCount > 0 ? COLOR.down : agg === 'live' ? COLOR.live : COLOR.meta
|
|
216
256
|
if (!attachable(p)) return line(0, label, { color: rowColor })
|
|
217
257
|
return line(0, label, { color: rowColor, bash: bin, param1: 'tray', param2: 'attach-term', param3: p.personality, terminal: false })
|
|
218
258
|
}
|
|
@@ -242,6 +282,67 @@ function renderManage(peers: TrayPeer[], bin: string): string[] {
|
|
|
242
282
|
return out
|
|
243
283
|
}
|
|
244
284
|
|
|
285
|
+
// ── approval renderers (docs/17 — the always-on approval channel in the bar) ─────
|
|
286
|
+
|
|
287
|
+
/** Menu-bar pending badge: a red `N.circle.fill` SF Symbol (the iOS-notification look — a red circle
|
|
288
|
+
* with the number inside). SF Symbols carries numbered circles 0…50; past that, a red
|
|
289
|
+
* exclamation-circle + the count as text. `sfcolor` tints the symbol red; `color` is set too as a
|
|
290
|
+
* fallback for SwiftBar builds that read `color` for the tint. The numbered symbol carries the count,
|
|
291
|
+
* so no text is needed for ≤50. */
|
|
292
|
+
function renderBadge(count: number): string {
|
|
293
|
+
if (count <= 50) return line(0, '', { sfimage: `${count}.circle.fill`, sfcolor: COLOR.down, color: COLOR.down })
|
|
294
|
+
return line(0, String(count), { sfimage: 'exclamationmark.circle.fill', sfcolor: COLOR.down, color: COLOR.down })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Defuse SwiftBar STRUCTURAL injection from verbatim content: ` | ` is the param separator (replace
|
|
298
|
+
* with a broken bar) and a leading `--` nests a submenu (defused by the `│ ` prefix in capContent).
|
|
299
|
+
* Display-only — the FULL verbatim content stays in `iapeer approvals` (criterion #7 on the payload;
|
|
300
|
+
* the menu is its truncated, structurally-safe view). */
|
|
301
|
+
function sanitizeMenuText(s: string): string {
|
|
302
|
+
return s.replace(/ \| /g, ' ¦ ')
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Cap the inline content (a big diff must not blow up the dropdown) and prefix each row with `│ ` so
|
|
306
|
+
* it reads as quoted content AND never begins with `--`. Full verbatim content stays in `iapeer
|
|
307
|
+
* approvals`. */
|
|
308
|
+
function capContent(content: string): string[] {
|
|
309
|
+
const clipped = content.length > APPROVAL_CONTENT_MAX_CHARS ? content.slice(0, APPROVAL_CONTENT_MAX_CHARS) : content
|
|
310
|
+
let rows = clipped.split('\n')
|
|
311
|
+
let truncated = content.length > APPROVAL_CONTENT_MAX_CHARS
|
|
312
|
+
if (rows.length > APPROVAL_CONTENT_MAX_LINES) {
|
|
313
|
+
rows = rows.slice(0, APPROVAL_CONTENT_MAX_LINES)
|
|
314
|
+
truncated = true
|
|
315
|
+
}
|
|
316
|
+
const out = rows.map(r => `│ ${sanitizeMenuText(r)}`)
|
|
317
|
+
if (truncated) out.push('│ …[truncated — full content: iapeer approvals]')
|
|
318
|
+
return out
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The pending-approval section (top of the dropdown). Each request is FLAT (depth 0, no submenu): a
|
|
322
|
+
* header (peer · tool), the verbatim content (monospace, capped), then Allow / Deny click actions —
|
|
323
|
+
* both visible + clickable with NO extra clicks. A click runs `iapeer tray approve|deny <id>` in the
|
|
324
|
+
* background (unix-first fleet POST) → the broker resolves → the streaming plugin re-renders off the
|
|
325
|
+
* approval-resolved SSE event (no refresh=true — the stream is always fresh). */
|
|
326
|
+
function renderApprovals(approvals: TrayApproval[], bin: string): string[] {
|
|
327
|
+
const out: string[] = [
|
|
328
|
+
line(0, `${approvals.length} pending approval${approvals.length === 1 ? '' : 's'}`, {
|
|
329
|
+
sfimage: 'bell.badge.fill',
|
|
330
|
+
sfcolor: COLOR.down,
|
|
331
|
+
color: COLOR.down,
|
|
332
|
+
}),
|
|
333
|
+
]
|
|
334
|
+
for (let i = 0; i < approvals.length; i++) {
|
|
335
|
+
if (i > 0) out.push('---') // separate multiple requests
|
|
336
|
+
const a = approvals[i]!
|
|
337
|
+
out.push(line(0, `${a.personality} · ${a.tool}`, { sfimage: 'hand.raised.fill', sfcolor: COLOR.warn, color: COLOR.warn }))
|
|
338
|
+
const content = a.content ?? a.summary ?? ''
|
|
339
|
+
if (content) for (const cl of capContent(content)) out.push(line(0, cl, { font: 'Menlo', size: 12, color: COLOR.meta }))
|
|
340
|
+
out.push(line(0, 'Allow', { sfimage: 'checkmark.circle.fill', sfcolor: COLOR.live, color: COLOR.live, bash: bin, param1: 'tray', param2: 'approve', param3: a.id, terminal: false }))
|
|
341
|
+
out.push(line(0, 'Deny', { sfimage: 'xmark.circle.fill', sfcolor: COLOR.down, color: COLOR.down, bash: bin, param1: 'tray', param2: 'deny', param3: a.id, terminal: false }))
|
|
342
|
+
}
|
|
343
|
+
return out
|
|
344
|
+
}
|
|
345
|
+
|
|
245
346
|
/** The degraded output when no advertised daemon address answers (docs/15: "A
|
|
246
347
|
* dashboard that cannot reach any advertised address should render the daemon as
|
|
247
348
|
* down"). Distinct menu-bar icon + a red header; a Refresh item to retry. */
|