@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.
@@ -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.70",
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).
@@ -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' })
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. */