@agfpd/iapeer 0.4.69 → 0.4.71

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.
@@ -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/&lt;personality&gt;
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), **approve** (no approval broker exists yet; it will arrive as new endpoints + `ev` kinds under this same contract).
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 no
20
- advertised daemon address answers, a red warning triangle instead (the daemon is down —
21
- a purely API-fed dashboard cannot show a broken daemon any other way; the built-in TUI
22
- keeps its direct read path for that case, see [15](15-fleet-api.md)).
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
@@ -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 — structural; live-verify pending):
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.
@@ -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/&lt;personality&gt;
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` — добавятся, когда появится клиент с реальной потребностью), **approve** (брокера подтверждений ещё не существует; он придёт новыми эндпоинтами + видами `ev` в рамках этого же контракта).
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
 
@@ -15,8 +15,10 @@ SwiftBar.app, если его нет.
15
15
 
16
16
  ## Что видно
17
17
 
18
- - **Меню-бар:** глиф-антенна + число пиров с живой сессией. Если ни один объявленный адрес
19
- демона не отвечает — красный треугольник (демон недоступен; чисто API-дашборд иначе не
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
  ## Как устроено
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.69",
3
+ "version": "0.4.71",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -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
- /** codex tool-name regex (codex PreToolUse path — structural, live-verify pending). */
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).
@@ -65,6 +65,27 @@ export function defaultIntelligenceForRuntime(runtime: string): Intelligence {
65
65
  return 'artificial'
66
66
  }
67
67
 
68
+ /**
69
+ * The intelligences a runtime may LEGITIMATELY carry (the single Ф0 source; provision
70
+ * validates against it, the launch gate enforces it). Identity (natural/absent/artificial)
71
+ * is ORTHOGONAL to the channel: a human-channel runtime can carry a HUMAN (natural) OR a
72
+ * FACELESS SERVICE bot (absent) — e.g. a Telegram approval-card bot — but never an LLM agent
73
+ * (artificial) on that channel. A service-only runtime carries services (absent). An agentic
74
+ * runtime carries an LLM (artificial). The DEFAULT (defaultIntelligenceForRuntime) stays the
75
+ * first of each set, so an existing peer with no explicit nature is unchanged — `absent` on a
76
+ * channel runtime is strictly OPT-IN (an explicit `--intelligence absent` / manifest decl).
77
+ */
78
+ export function allowedIntelligencesForRuntime(runtime: string): readonly Intelligence[] {
79
+ if (NATURAL_RUNTIMES.has(runtime)) return ['natural', 'absent']
80
+ if (ABSENT_RUNTIMES.has(runtime)) return ['absent']
81
+ return ['artificial']
82
+ }
83
+
84
+ /** Is `intelligence` a legitimate nature for `runtime`? (provision fail-loud + launch gate.) */
85
+ export function isIntelligenceAllowedForRuntime(runtime: string, intelligence: Intelligence): boolean {
86
+ return allowedIntelligencesForRuntime(runtime).includes(intelligence)
87
+ }
88
+
68
89
  // Infra runtimes are ALWAYS-ON (held live by launchd KeepAlive), as opposed to the
69
90
  // warm-on-demand agentic runtimes (claude/codex, woken by the daemon). Liveness is
70
91
  // a property of the RUNTIME, not the personality (zone Идентичность). Infra runtimes
@@ -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
- return sendJson(res, 200, buildFleetSnapshot(env, cfg, await getOps(), startedAtMs))
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' })
@@ -11,8 +11,10 @@ import {
11
11
  IAPEER_DIR,
12
12
  PEER_PROFILE_FILE,
13
13
  NAME_RE,
14
+ allowedIntelligencesForRuntime,
14
15
  defaultIntelligenceForRuntime,
15
16
  isInfraRuntime,
17
+ isIntelligenceAllowedForRuntime,
16
18
  isRuntime,
17
19
  isValidName,
18
20
  normalizeIntelligenceValue,
@@ -131,6 +133,12 @@ export interface EnsurePeerProfileOptions {
131
133
  * always-on plist so launchd's minimal PATH resolves it. Forwarded to
132
134
  * installAlwaysOnPlist; ignored for warm-on-demand runtimes. */
133
135
  runtimeBin?: string
136
+ /** Explicit peer nature (natural/absent/artificial). OPT-IN override of the runtime
137
+ * default (defaultIntelligenceForRuntime): a channel runtime defaults `natural` but may be
138
+ * provisioned `absent` for a FACELESS SERVICE bot (e.g. a Telegram approval-card bot).
139
+ * Validated against allowedIntelligencesForRuntime — an out-of-set value fails LOUD. Absent
140
+ * (undefined) ⇒ the runtime default (unchanged for every existing peer). */
141
+ intelligence?: Intelligence
134
142
  /** В36 — peer description, persisted into the LOCAL profile (the source of truth the
135
143
  * registry is reindexed FROM). Writing it only to the registry made it a stale copy:
136
144
  * the first reindexFromLocals (addRuntime on a dual-runtime create, connect telegram,
@@ -503,6 +511,21 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
503
511
  const peers = options.peers ?? []
504
512
  ensureLocalIapScaffold(cwd)
505
513
  const discoveredRuntimes = discoverPeerRuntimes(cwd, options.runtime)
514
+ // Nature = the EXPLICIT intelligence when supplied (validated against the runtime's allowed
515
+ // set — an out-of-set value, e.g. artificial on telegram, fails LOUD), else the runtime
516
+ // default. This is where a FACELESS SERVICE bot (absent) on a channel runtime becomes
517
+ // first-class (docs/Идентичность); an omitted intelligence keeps every existing peer's
518
+ // default nature unchanged.
519
+ const resolvedIntelligence = (rt: Runtime): Intelligence => {
520
+ const explicit = options.intelligence
521
+ if (explicit === undefined) return defaultIntelligenceForRuntime(rt)
522
+ if (!isIntelligenceAllowedForRuntime(rt, explicit)) {
523
+ throw new IapError(
524
+ `intelligence "${explicit}" is not valid for runtime "${rt}" — allowed: ${allowedIntelligencesForRuntime(rt).join(', ')}`,
525
+ )
526
+ }
527
+ return explicit
528
+ }
506
529
  const existing = readPeerProfile(cwd)
507
530
  if (!existing) {
508
531
  let personality: string
@@ -550,7 +573,7 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
550
573
  // В36 — the description lands in the LOCAL profile at birth (source of truth);
551
574
  // the registry row is a projection of this file, not the other way around.
552
575
  description: options.description?.trim() ?? '',
553
- intelligence: defaultIntelligenceForRuntime(options.runtime),
576
+ intelligence: resolvedIntelligence(options.runtime),
554
577
  }
555
578
  ensureLocalRuntimeScopes(cwd, profile.runtimes)
556
579
  // INFRA runtime → provision the always-on launchd plist that holds it live.
@@ -607,7 +630,10 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
607
630
  runtimeBin: options.runtimeBin,
608
631
  env: options.env,
609
632
  })
610
- intelligence = defaultIntelligenceForRuntime(options.runtime)
633
+ // Re-provision honors an EXPLICIT nature (a manifest-declared absent service bot survives
634
+ // update-runtime re-provision), else the runtime default (the legacy vocab-flip this fix
635
+ // preserved). resolvedIntelligence validates the explicit against the runtime's allowed set.
636
+ intelligence = resolvedIntelligence(options.runtime)
611
637
  }
612
638
  // В36 — an explicit description on RE-provision updates the local profile too (H1 in
613
639
  // writePeerProfileAtomic still guards: absent/empty never wipes an existing value).
@@ -23,6 +23,7 @@
23
23
  // that is install-time (blueprint §0.6 fast-wake), not session bring-up.
24
24
 
25
25
  import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
26
+ import { allowedIntelligencesForRuntime } from '../../core/constants.ts'
26
27
 
27
28
  // ─────────────────────────────────────────────────────────────────────────────
28
29
  // telegramAdapter
@@ -38,13 +39,14 @@ export const telegramAdapter: RuntimeAdapter = {
38
39
  deliveryMarkers: { promptGlyphs: [] },
39
40
 
40
41
  /**
41
- * telegram is a HUMAN channel — launch REFUSES a non-natural peer (fail-loud).
42
- * Porting the persistent-peer FATAL guard (it stood in two places): a peer with
43
- * intelligence artificial/absent must never be brought up on telegram (it would
44
- * route a human's bot to an agent/script). The launch primitive enforces this
45
- * against LaunchSpec.intelligence; source of intelligence → docs/Идентичность.
42
+ * telegram carries a HUMAN (natural) OR a FACELESS SERVICE bot (absent) — but never an
43
+ * LLM agent (artificial), which would route a human's channel to an agent/script. Launch
44
+ * REFUSES anything outside this set (fail-loud), the persistent-peer FATAL guard relaxed to
45
+ * first-class faceless service bots (e.g. a Telegram approval-card bot). Sourced from the
46
+ * single Ф0 truth (allowedIntelligencesForRuntime); enforced by the launch primitive against
47
+ * LaunchSpec.intelligence. Source of intelligence → docs/Идентичность.
46
48
  */
47
- requiresIntelligence: 'natural',
49
+ allowedIntelligences: allowedIntelligencesForRuntime('telegram'),
48
50
 
49
51
  /**
50
52
  * argv = telegramBin + 'run' + extraArgs (telegram-start.sh:119,
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // voicetalk is a presence-runtime for a HUMAN (the owner's voice channel), modelled on
11
11
  // telegram per the foundation presence-runtime contract: kind:'router',
12
- // usesDoctrine:false, requiresIntelligence:'natural', argv = `<bin> run <extra>`. The
12
+ // usesDoctrine:false, allowedIntelligences:['natural','absent'], argv = `<bin> run <extra>`. The
13
13
  // STT/TTS engines and providers live in a shared voice service (voice-connect), NOT in
14
14
  // this adapter and NOT in the peer passport — interfaces.voicetalk carries only the
15
15
  // binding ({wake_word, device}), opaque to the foundation.
@@ -19,6 +19,7 @@
19
19
  // bring-up.
20
20
 
21
21
  import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
22
+ import { allowedIntelligencesForRuntime } from '../../core/constants.ts'
22
23
 
23
24
  // ─────────────────────────────────────────────────────────────────────────────
24
25
  // voicetalkAdapter
@@ -34,13 +35,12 @@ export const voicetalkAdapter: RuntimeAdapter = {
34
35
  deliveryMarkers: { promptGlyphs: [] },
35
36
 
36
37
  /**
37
- * voicetalk is a HUMAN channel — launch REFUSES a non-natural peer (fail-loud).
38
- * Same nature gate as telegram: a peer with intelligence artificial/absent must never
39
- * be brought up on voicetalk (it would route the owner's voice channel to an
40
- * agent/script). The launch primitive enforces this against LaunchSpec.intelligence;
41
- * source of intelligence → docs/Идентичность.
38
+ * voicetalk carries a HUMAN (natural) OR a FACELESS SERVICE bot (absent) — never an LLM
39
+ * agent (artificial), which would route the owner's voice channel to an agent/script. Same
40
+ * nature gate as telegram, sourced from the single Ф0 truth (allowedIntelligencesForRuntime);
41
+ * enforced by the launch primitive against LaunchSpec.intelligence. Source → docs/Идентичность.
42
42
  */
43
- requiresIntelligence: 'natural',
43
+ allowedIntelligences: allowedIntelligencesForRuntime('voicetalk'),
44
44
 
45
45
  /**
46
46
  * argv = voicetalkBin + 'run' + extraArgs.
@@ -131,14 +131,16 @@ export const launch: LaunchFn = async (
131
131
  const { identity, socketPath: sock, cwd } = spec
132
132
  const env = cfg.env ?? process.env
133
133
 
134
- // (0) Intelligence gate (fail-loud BEFORE any tmux work): an adapter that declares
135
- // requiresIntelligence (telegram → 'natural') refuses a peer whose nature does
136
- // not match — and refuses too when the nature is unknown (cannot confirm). Ports
137
- // the persistent-peer FATAL human-channel guard (docs/Рантайм-адаптеры).
138
- if (adapter.requiresIntelligence && spec.intelligence !== adapter.requiresIntelligence) {
134
+ // (0) Intelligence gate (fail-loud BEFORE any bring-up work): an adapter that declares
135
+ // allowedIntelligences (telegram/voicetalk → ['natural','absent']) refuses a peer whose
136
+ // nature is not IN the set — and refuses too when the nature is unknown (cannot confirm).
137
+ // A channel runtime carries a human (natural) OR a faceless service bot (absent), never an
138
+ // LLM agent (artificial). Ports the persistent-peer FATAL human-channel guard, relaxed to
139
+ // first-class faceless service bots (docs/Рантайм-адаптеры).
140
+ if (adapter.allowedIntelligences && (spec.intelligence == null || !adapter.allowedIntelligences.includes(spec.intelligence))) {
139
141
  return fail(
140
142
  identity,
141
- `runtime "${spec.runtime}" requires intelligence=${adapter.requiresIntelligence}, ` +
143
+ `runtime "${spec.runtime}" requires intelligence ∈ {${adapter.allowedIntelligences.join(', ')}}, ` +
142
144
  `peer "${spec.personality}" is ${spec.intelligence ?? 'unknown'} — refusing to launch`,
143
145
  )
144
146
  }
@@ -160,9 +160,9 @@ export interface LaunchSpec {
160
160
  /** Free-form extra CLI args (PEER_START_ARGS). */
161
161
  extraArgs?: string[]
162
162
  /** Peer intelligence (artificial/natural/absent). Used to enforce an adapter's
163
- * intelligence gate at launch (telegram requires natural). Optional: a doctrine-
164
- * less throwaway may omit it, but an adapter that declares requiresIntelligence
165
- * then refuses (cannot confirm the required nature). */
163
+ * intelligence gate at launch (telegram allows natural|absent). Optional: a doctrine-
164
+ * less throwaway may omit it, but an adapter that declares allowedIntelligences
165
+ * then refuses (cannot confirm the nature is in the set). */
166
166
  intelligence?: Intelligence
167
167
  /** Human-approval mode (docs/17): `yolo` (default/omitted) launches with the
168
168
  * runtime bypass flag (current fleet behavior); `gated` launches WITHOUT bypass
@@ -273,13 +273,14 @@ export interface RuntimeAdapter {
273
273
  deliveryConfirm?: 'transcript' | 'socket-ack'
274
274
 
275
275
  /**
276
- * If set, launch REFUSES unless the peer's intelligence equals this value
277
- * (fail-loud). telegram → 'natural' — it is a human channel; launching an
278
- * artificial/absent peer on it is a category error (the persistent-peer path held
279
- * a FATAL guard in two places). Most adapters omit it (no nature gate). Source of intelligence →
276
+ * If set, launch REFUSES unless the peer's intelligence is IN this set (fail-loud —
277
+ * also when the nature is unknown). A channel runtime carries a HUMAN (natural) OR a
278
+ * FACELESS SERVICE bot (absent) but never an LLM agent (artificial): telegram/voicetalk →
279
+ * ['natural','absent'] (sourced from allowedIntelligencesForRuntime — the single Ф0 truth).
280
+ * Most adapters omit it (no nature gate — claude/codex/notifier). Source of intelligence →
280
281
  * docs/Идентичность; enforced by the launch primitive against LaunchSpec.intelligence.
281
282
  */
282
- requiresIntelligence?: Intelligence
283
+ allowedIntelligences?: readonly Intelligence[]
283
284
 
284
285
  /**
285
286
  * Build the runtime argv: binary + flags, wiring systemPromptFile per-runtime
@@ -89,6 +89,11 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
89
89
  peers,
90
90
  personality: opts.personality,
91
91
  runtimeBin,
92
+ // The EXPLICIT nature now reaches the LOCAL profile too (A1) — previously it landed only
93
+ // in the registry upsert below, so a `--intelligence absent` telegram bot got a natural
94
+ // local profile (split-brain) and its plist crash-looped the natural gate. Validated
95
+ // against the runtime's allowed set inside ensurePeerProfile.
96
+ intelligence: opts.intelligence,
92
97
  // В36 — description goes into the LOCAL profile (source of truth); the registry
93
98
  // upsert below then persists a value reindexFromLocals can no longer wipe.
94
99
  description: opts.description,
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
+ }
@@ -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: antenna icon + live-peer count ──
141
- lines.push(line(0, String(liveCount), { sfimage: 'antenna.radiowaves.left.and.right' }))
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
- const label = `${p.personality} ${detail}${badges}${ageStr}`
215
- const rowColor = agg === 'live' ? COLOR.live : COLOR.meta
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. */