@agfpd/iapeer 0.4.74 → 0.4.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -107,6 +107,8 @@ What this means for you:
107
107
  - You are trusting the model's judgment, and the doctrine you give each peer, the same way you would trust a developer with a shell on your account.
108
108
  - iapeer does **not** silently weaken your own interactive `claude` / `codex` setup. The bypass acceptance is recorded per peer, in that agent's own config; the project-MCP pre-approval is written **project-locally**, under the peer's folder — never to your global user settings.
109
109
 
110
+ **Want a human in the loop for a given peer?** Set its approval mode to `gated` — `iapeer approval-mode <peer> gated`. That peer launches **without** the bypass flag, and every blocking approval its runtime would raise is routed to you — a Telegram button, a click in the tray, or `iapeer approve` on the CLI — before the tool runs; your `allow`/`deny` (with a reason) flows back and the tool proceeds or is refused. The default stays `yolo` (bypass) for every peer; `gated` is opt-in, per peer. See [docs/17 — Human approval](docs/17-approval.md).
111
+
110
112
  Run iapeer on a host you control, give each peer only the access it needs, and treat a peer's instructions as security-relevant. `iapeer onboard` states this at setup; it's repeated here so the trade-off is explicit before you create your first agent. Details on every first-run dialog: [docs/07 — Install & update](docs/07-install-and-update.md#first-run-dialogs-on-a-clean-machine).
111
113
 
112
114
  ## Documentation
@@ -121,8 +123,6 @@ Planned, not yet built:
121
123
  - **Linux support** — iapeer runs on macOS today; Linux is planned.
122
124
  - **More agent runtimes** — `opencode` and `antigravity`, two further CLI runtimes alongside Claude Code and Codex.
123
125
  - **Discord runtime** — a human-presence runtime for Discord, alongside Telegram.
124
- - **Permissions gate** — finer control over what an agent is allowed to do.
125
- - **Approve permissions over Telegram** — approve an agent's permission requests from the chat; today permissions are auto-allowed by flags.
126
126
 
127
127
  ## License
128
128
 
@@ -35,7 +35,7 @@ The same operation is available as `iapeer install` if the binary is already in
35
35
  iapeer onboard
36
36
  ```
37
37
 
38
- `onboard` prepares the host for agents. First of all it **brings up the daemon** — loads the plist into launchd, after which launchd holds the daemon itself, bringing it back up on a crash and a reboot. No separate `launchctl` command is needed for this. Then onboard registers the extension marketplace in Claude and Codex, provisions the scheduler (`notifier`), creates a human peer on Telegram, and installs the memory provider. The daemon start always runs; the rest are on by default and turned off with flags (`--no-notifier`, `--no-telegram`, `--no-memory`).
38
+ `onboard` prepares the host for agents. First of all it **brings up the daemon** — loads the plist into launchd, after which launchd holds the daemon itself, bringing it back up on a crash and a reboot. No separate `launchctl` command is needed for this. Then onboard registers the extension marketplace in Claude and Codex, provisions the scheduler (`notifier`), creates a human peer on Telegram, installs the memory provider and the voice backend, and offers the menu-bar tray face (SwiftBar). The daemon start always runs; the memory/voice/notifier/telegram steps are on by default and turned off with flags (`--no-notifier`, `--no-telegram`, `--no-memory`, `--no-voice`). The **tray** is the exception — default **off** (it pulls in a GUI app via Homebrew), opted into with `--tray` on the non-interactive path or a `[y/N]` prompt in the interactive wizard; without Homebrew it degrades to a soft skip (the plugin is left in place, install SwiftBar manually with `iapeer tray install --app`).
39
39
 
40
40
  Check the daemon is alive and the host is ready:
41
41
 
package/docs/16-tray.md CHANGED
@@ -24,11 +24,16 @@ activation that also installs SwiftBar.app when it is absent.
24
24
  daemon any other way; the built-in TUI keeps its direct read path for that case, see
25
25
  [15](15-fleet-api.md)).
26
26
  - **Dropdown:** a host header (`iapeer <version> · up <uptime>`) and the memory/voice
27
- provider heartbeats, then one row per peer:
27
+ provider heartbeats, then one row per peer, in **two groups**: the `🔒` **launchd block**
28
+ (the human channel + service infra — non-clickable status rows) is pulled to the **top as
29
+ one contiguous block, before the first working agent**, regardless of live/asleep — so the
30
+ bar separates "service, don't touch" from the working agents at a glance. Working agents
31
+ follow. Within **each** group the order is live → asleep → stopped, then alphabetical.
28
32
  - `<peer> <runtime><glyph> … <age>` with the same glyph semantics as the CLI —
29
33
  `●` live · `○` asleep · `✕` stopped — green when any runtime is live. Badges: `👤`
30
- a human is attached, `🔒` launchd-managed (H4 read-only), `⏳N` an ephemeral peer's
31
- queue depth.
34
+ a human is attached, `🔒` launchd-managed (H4 read-only), `🛡` the peer runs in **gated**
35
+ human-approval mode (docs/17; `yolo` is the fleet default and carries no badge — a bare
36
+ row reads as yolo), `⏳N` an ephemeral peer's queue depth.
32
37
  - **Click an agent peer → Terminal.app running `iapeer attach <peer>`.** The row itself is
33
38
  the attach action (no submenu, so a click attaches rather than merely expanding). The
34
39
  handoff goes through `iapeer tray attach-term`, which writes a per-peer `.command`
@@ -37,8 +42,16 @@ activation that also installs SwiftBar.app when it is absent.
37
42
  own `terminal=true`, whose Terminal launch drives a `Cmd-T` System-Events keystroke that
38
43
  silently no-ops unless SwiftBar holds Accessibility permission. **Launchd-managed infra
39
44
  peers (telegram / notifier, `🔒`) are not pty-attachable** — their rows are plain status
40
- lines. Lifecycle actions (Wake / Stop / Start / New / Interrupt / Refresh / Compact) live
45
+ lines. Lifecycle actions (Stop / Start / New / Interrupt / Refresh / Compact) live
41
46
  in the **Manage** submenu, each a `POST /fleet/v1/peers/<peer>/<cmd>` via `iapeer tray cmd`.
47
+ (No Wake item — a peer-row click already wakes a sleeping peer before it attaches, so
48
+ it was redundant.) For an agentic peer the Manage submenu also carries an
49
+ **approval-mode toggle** (docs/17): it reads the current mode and flips it via the local
50
+ `iapeer approval-mode <peer> <mode>` verb, applied on the peer's **next fresh session**
51
+ (a click never respawns a live session). The friction is asymmetric to the security
52
+ weight: `yolo → gated` (adds human approval) is one safe tap; `gated → yolo` (REMOVES the
53
+ approval perimeter) sits behind the submenu as an explicit red ⚠ confirm, so it is never
54
+ dropped by a stray click.
42
55
  - **Pending approvals (docs/17):** when the queue is non-empty, the **top of the dropdown** shows
43
56
  each pending request **expanded, with no extra clicks** — a header (`<peer> · <tool>`), the
44
57
  **verbatim action content** (the command / diff / plan, monospace, capped for the menu; the FULL
@@ -22,11 +22,16 @@ SwiftBar.app, если его нет.
22
22
  покажет сломанный демон — встроенный TUI держит прямой путь чтения для этого случая, см.
23
23
  [15](15-fleet-api.md)).
24
24
  - **Выпадашка:** шапка хоста (`iapeer <версия> · up <аптайм>`) и хартбиты провайдеров памяти
25
- и голоса, затем строка на пира:
25
+ и голоса, затем строка на пира, **двумя группами**: блок `🔒` **под launchd** (человеческий
26
+ канал + служебная инфра — некликабельные статус-строки) вынесен **наверх единым непрерывным
27
+ блоком, перед первым рабочим агентом**, независимо от live/asleep — так бар с одного взгляда
28
+ отделяет «служебное, не трогать» от рабочих агентов. Рабочие агенты идут следом. Внутри
29
+ **каждой** группы порядок: живой → спит → остановлен, затем по алфавиту.
26
30
  - `<peer> <рантайм><глиф> … <возраст>` с той же семантикой глифов, что в CLI —
27
31
  `●` живой · `○` спит · `✕` остановлен — зелёный, если хоть один рантайм живой. Бейджи:
28
- `👤` подключён человек, `🔒` под launchd (H4 read-only), `⏳N` глубина очереди эфемерного
29
- пира.
32
+ `👤` подключён человек, `🔒` под launchd (H4 read-only), `🛡` пир в режиме **gated**
33
+ human-approval (docs/17; `yolo` — дефолт флота, без бейджа: голая строка читается как yolo),
34
+ `⏳N` глубина очереди эфемерного пира.
30
35
  - **Клик по агент-пиру → Terminal.app с `iapeer attach <peer>`.** Строка сама и есть действие
31
36
  attach (без подменю — клик аттачит, а не раскрывает). Передача идёт через `iapeer tray
32
37
  attach-term`: пишет per-peer `.command`-лаунчер и `open`'ит его — `open <файл>.command`
@@ -34,8 +39,15 @@ SwiftBar.app, если его нет.
34
39
  родной `terminal=true` SwiftBar, чей запуск Terminal бьёт `Cmd-T` кейстроком через System
35
40
  Events и молча не срабатывает без права Accessibility у SwiftBar. **Launchd-инфра-пиры
36
41
  (telegram / notifier, `🔒`) не pty-аттачатся** — их строки просто статус. Lifecycle-действия
37
- (Wake / Stop / Start / New / Interrupt / Refresh / Compact) — в подменю **Manage**, каждое —
38
- `POST /fleet/v1/peers/<peer>/<cmd>` через `iapeer tray cmd`.
42
+ (Stop / Start / New / Interrupt / Refresh / Compact) — в подменю **Manage**, каждое —
43
+ `POST /fleet/v1/peers/<peer>/<cmd>` через `iapeer tray cmd`. (Пункта Wake нет — клик по строке
44
+ пира и так будит спящего перед аттачем, отдельный Wake был избыточен.) Для агентного пира
45
+ подменю Manage несёт ещё **переключатель approval-режима** (docs/17): читает текущий режим и
46
+ флипает его локальным verb'ом `iapeer approval-mode <peer> <mode>`, применяется на **следующей
47
+ свежей сессии** пира (клик никогда не респавнит живую сессию). Трение асимметрично весу
48
+ безопасности: `yolo → gated` (добавляет human-approval) — один безопасный тап; `gated → yolo`
49
+ (СНИМАЕТ периметр approval) спрятан в подменю как явный красный ⚠ confirm, чтобы его не сбросить
50
+ случайным кликом.
39
51
  - **Ожидающие подтверждения (docs/17):** когда очередь непуста, **вверху выпадашки** каждый
40
52
  ожидающий запрос показан **раскрытым, без доп. кликов** — заголовок (`<peer> · <tool>`),
41
53
  **дословное содержимое действия** (команда / диф / план, моноширинно, усечено для меню; ПОЛНОЕ
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.74",
3
+ "version": "0.4.76",
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",
package/src/cli/index.ts CHANGED
@@ -1168,6 +1168,7 @@ const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
1168
1168
  'plugin-only',
1169
1169
  'remove-codesign-identity',
1170
1170
  'stream',
1171
+ 'tray',
1171
1172
  'yes',
1172
1173
  ])
1173
1174
 
@@ -1219,8 +1220,8 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
1219
1220
  { sig: 'help | --help | -h', desc: 'print this usage (works appended to any verb; executes nothing)' },
1220
1221
  { sig: 'daemon [--install-plist]', desc: 'run the host-wide HTTP-MCP router (launchd-held)' },
1221
1222
  {
1222
- sig: 'onboard [--accept-risk] [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--no-voice] [--voice <pkg>] [--infra <csv>]',
1223
- desc: 'backbone host-phase: marketplace → notifier → telegram (human peer) → memory → voice (all default YES). --accept-risk (or IAPEER_ACCEPT_RISK=1) accepts the security warning non-interactively',
1223
+ sig: 'onboard [--accept-risk] [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--no-voice] [--voice <pkg>] [--tray] [--infra <csv>]',
1224
+ desc: 'backbone host-phase: marketplace → notifier → telegram (human peer) → memory → voice (default YES) → tray (default NO, --tray opts in). --accept-risk (or IAPEER_ACCEPT_RISK=1) accepts the security warning non-interactively',
1224
1225
  },
1225
1226
  { sig: 'status', desc: 'host snapshot: version, daemon health, memory + voice slots (<provider> | none)' },
1226
1227
  { sig: 'live-runtime <peer>', desc: 'print the peer\'s CURRENT live runtime (freshest pane-log among pid-alive sessions; NOT default_runtime). Exit 1 + no output if none alive' },
@@ -1526,6 +1527,17 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1526
1527
  })
1527
1528
  const voiceLabel = voice.provider ? `${voice.provider.provider} ${voice.provider.version}` : 'none'
1528
1529
  out(`voice: ${voice.state}${voice.detail ? ` — ${voice.detail}` : ''} (slot: ${voiceLabel})\n`)
1530
+ // Tray face (SwiftBar menu-bar fleet dashboard): OPTIONAL and DEFAULT-NO on the
1531
+ // linear path (a GUI dependency — conservatively opt-in). `--tray` opts in;
1532
+ // without it the step is skipped (non-interactive never hangs — the step asks
1533
+ // nothing). The plugin FILE is already dropped by install; this is GUI activation.
1534
+ const { onboardTrayStep } = await import('../onboard/tray.ts')
1535
+ const tray = await onboardTrayStep({
1536
+ skip: flags.tray !== true,
1537
+ dryRun: flags['dry-run'] === true,
1538
+ env,
1539
+ })
1540
+ out(`tray: ${tray.state}${tray.detail ? ` — ${tray.detail}` : ''}\n`)
1529
1541
  // Runtime auth readiness (clean-host prerequisite): a peer runs the runtime's
1530
1542
  // interactive TUI; the launcher auto-clears first-run modals (theme/trust) but
1531
1543
  // CANNOT complete a login OAuth flow. Warn for every INSTALLED runtime (marketplace
@@ -122,6 +122,24 @@ export function exitLogPath(exitLogDir: string): string {
122
122
  // launch — bring up ONE session (runtime-agnostic via the adapter)
123
123
  // ─────────────────────────────────────────────────────────────────────────────
124
124
 
125
+ /**
126
+ * The intelligence gate — launch step (0), extracted as a PURE predicate so it is
127
+ * unit-testable in isolation (no bring-up, never touches the pty host). A channel
128
+ * adapter that declares `allowedIntelligences` (telegram/voicetalk → ['natural',
129
+ * 'absent']) refuses a peer whose nature is NOT in the set — and refuses too when
130
+ * the nature is unknown (cannot confirm). Returns the fail-loud reason string, or
131
+ * `null` when the gate PASSES (the peer may proceed to bring-up).
132
+ */
133
+ export function intelligenceGateReason(adapter: RuntimeAdapter, spec: LaunchSpec): string | null {
134
+ if (adapter.allowedIntelligences && (spec.intelligence == null || !adapter.allowedIntelligences.includes(spec.intelligence))) {
135
+ return (
136
+ `runtime "${spec.runtime}" requires intelligence ∈ {${adapter.allowedIntelligences.join(', ')}}, ` +
137
+ `peer "${spec.personality}" is ${spec.intelligence ?? 'unknown'} — refusing to launch`
138
+ )
139
+ }
140
+ return null
141
+ }
142
+
125
143
  export const launch: LaunchFn = async (
126
144
  spec: LaunchSpec,
127
145
  adapter: RuntimeAdapter,
@@ -137,13 +155,8 @@ export const launch: LaunchFn = async (
137
155
  // A channel runtime carries a human (natural) OR a faceless service bot (absent), never an
138
156
  // LLM agent (artificial). Ports the persistent-peer FATAL human-channel guard, relaxed to
139
157
  // first-class faceless service bots (docs/Рантайм-адаптеры).
140
- if (adapter.allowedIntelligences && (spec.intelligence == null || !adapter.allowedIntelligences.includes(spec.intelligence))) {
141
- return fail(
142
- identity,
143
- `runtime "${spec.runtime}" requires intelligence ∈ {${adapter.allowedIntelligences.join(', ')}}, ` +
144
- `peer "${spec.personality}" is ${spec.intelligence ?? 'unknown'} — refusing to launch`,
145
- )
146
- }
158
+ const gateReason = intelligenceGateReason(adapter, spec)
159
+ if (gateReason) return fail(identity, gateReason)
147
160
 
148
161
  // (0.5) Ensure the socket's PARENT dir exists before `tmux new-session -S <sock>` —
149
162
  // tmux does NOT create it and fails SILENTLY (the session "dies immediately")
@@ -0,0 +1,101 @@
1
+ // Tray-face onboard step (the SwiftBar menu-bar fleet dashboard).
2
+ //
3
+ // Host-phase, OPTIONAL, DEFAULT-NO (a GUI dependency — conservative: do not pull it
4
+ // onto the host without an explicit yes). This is a thin WRAPPER over the existing
5
+ // activation `installTray` (src/tray/install.ts) — the SAME mechanics as `iapeer tray
6
+ // install --app`: brew cask + dequarantine + launch + refresh — dressed in an
7
+ // onboard-shaped result vocabulary, with a SOFT no-Homebrew path (report + continue,
8
+ // NEVER fail the onboard) per the phase principle "unavailability is a soft skip".
9
+ //
10
+ // IDEMPOTENT: SwiftBar already installed → NOT reinstalled, only the plugin is
11
+ // (re)activated + launched. The plugin FILE itself is dropped by the foundation
12
+ // install step regardless (inert without SwiftBar); this step is the GUI activation.
13
+ //
14
+ // Outcome semantics: this step NEVER fails the onboard exit code — no tray is a fully
15
+ // valid state regardless of why (skipped / no brew / cask failure). Mirrors
16
+ // onboard/voice.ts deliberately (same shape), minus the provider-slot concepts.
17
+
18
+ import { isExecutable } from './index.ts'
19
+ import { installTray, swiftBarInstalled, type Runner } from '../tray/install.ts'
20
+
21
+ export interface TrayOnboardOptions {
22
+ /** --no-tray (wizard) / absence of --tray (linear): skip the step entirely. */
23
+ skip?: boolean
24
+ dryRun?: boolean
25
+ env?: NodeJS.ProcessEnv
26
+ /** Injectable side-effect runner (tests). Default: installTray's own spawnSync. */
27
+ run?: Runner
28
+ /** Injectable SwiftBar.app presence probe (tests). Default swiftBarInstalled(). */
29
+ probeApp?: () => boolean
30
+ /** Injectable Homebrew-availability probe (tests). Default: `brew` on PATH. */
31
+ brewAvailable?: () => boolean
32
+ }
33
+
34
+ export interface TrayOnboardResult {
35
+ state:
36
+ | 'installed' // SwiftBar.app installed (brew cask) + plugin activated + launched
37
+ | 'activated' // SwiftBar already present → plugin activated + launched (idempotent, no reinstall)
38
+ | 'skipped-flag' // --no-tray / not opted-in
39
+ | 'skipped-no-brew' // SwiftBar absent AND no Homebrew — soft skip (plugin dropped inert; guidance given)
40
+ | 'install-failed' // brew ran and failed (network/cask) — soft (reported; plugin still lands)
41
+ | 'dry-run'
42
+ detail?: string
43
+ /** The resolved plugin file, when known. */
44
+ pluginFile?: string
45
+ }
46
+
47
+ const DOCS = 'docs/16-tray.md'
48
+
49
+ export async function onboardTrayStep(opts: TrayOnboardOptions = {}): Promise<TrayOnboardResult> {
50
+ const env = opts.env ?? process.env
51
+ if (opts.skip) return { state: 'skipped-flag' }
52
+ const probeApp = opts.probeApp ?? swiftBarInstalled
53
+ const brewAvailable = opts.brewAvailable ?? ((): boolean => isExecutable('brew', env))
54
+
55
+ if (opts.dryRun) {
56
+ const present = probeApp()
57
+ return {
58
+ state: 'dry-run',
59
+ detail: present
60
+ ? 'SwiftBar present — would activate the fleet plugin + launch'
61
+ : brewAvailable()
62
+ ? 'would install SwiftBar.app (brew cask) + activate the fleet plugin + launch'
63
+ : 'no Homebrew — would drop the plugin inert + advise manual SwiftBar install',
64
+ }
65
+ }
66
+
67
+ const present = probeApp()
68
+
69
+ // SwiftBar absent AND no Homebrew → SOFT skip. The auto-install path is brew-only, so
70
+ // without it there is nothing to run; drop the plugin inert (it activates the moment
71
+ // SwiftBar appears), guide the user, and NEVER fail the onboard.
72
+ if (!present && !brewAvailable()) {
73
+ const r = installTray({ env, installApp: false, launch: false, run: opts.run, probeApp: opts.probeApp })
74
+ return {
75
+ state: 'skipped-no-brew',
76
+ pluginFile: r.pluginFile,
77
+ detail:
78
+ 'Homebrew not found — the menu-bar app (SwiftBar) needs it. The fleet plugin is in place; ' +
79
+ 'install SwiftBar manually (`brew install --cask swiftbar`, or https://swiftbar.app), then run ' +
80
+ `\`iapeer tray install --app\`. Details: ${DOCS}`,
81
+ }
82
+ }
83
+
84
+ // Present → activate ONLY (installApp:false — never reinstall). Absent + brew → install the cask.
85
+ const r = installTray({ env, installApp: !present, launch: true, run: opts.run, probeApp: opts.probeApp })
86
+ if (r.app === 'install-failed') {
87
+ return {
88
+ state: 'install-failed',
89
+ pluginFile: r.pluginFile,
90
+ detail:
91
+ `SwiftBar install via brew failed (${r.appReason ?? 'unknown'}) — the fleet plugin is in place; ` +
92
+ `retry with \`iapeer tray install --app\` or install SwiftBar manually (${DOCS}).`,
93
+ }
94
+ }
95
+ const activatedMsg = r.launched ? 'plugin activated + SwiftBar launched' : 'plugin written (SwiftBar not launched)'
96
+ return {
97
+ state: present ? 'activated' : 'installed',
98
+ pluginFile: r.pluginFile,
99
+ detail: `${activatedMsg}${r.dir === 'existing-swiftbar' ? ' (your SwiftBar plugin dir)' : ''}`,
100
+ }
101
+ }
@@ -43,6 +43,8 @@ export interface TrayPeer {
43
43
  last_active_ms?: number
44
44
  attached?: boolean
45
45
  launchd_managed?: boolean
46
+ /** Human-approval mode (docs/17). ABSENT ⇒ `yolo` (client obligation — a pre-approval daemon omits it). */
47
+ approval_mode?: 'yolo' | 'gated'
46
48
  wake_policy?: 'warm' | 'ephemeral'
47
49
  queue_depth?: number
48
50
  }
@@ -133,10 +135,18 @@ function aggregate(p: TrayPeer): 'live' | 'asleep' | 'stopped' {
133
135
 
134
136
  const RANK: Record<'live' | 'asleep' | 'stopped', number> = { live: 0, asleep: 1, stopped: 2 }
135
137
 
138
+ // 🔒 launchd-managed peers (the human channel + service infra — arthur/timer/watcher) are
139
+ // NON-clickable status rows. They form ONE contiguous block at the very TOP, before the first
140
+ // working agent, regardless of live/asleep — so the bar separates "service, don't touch" from the
141
+ // working agents at a glance (owner UX). `attachable` (= !launchd_managed) is the same predicate.
142
+ const lockRank = (p: TrayPeer): number => (p.launchd_managed ? 0 : 1)
143
+
136
144
  // ── renderers ─────────────────────────────────────────────────────────────────
137
145
 
138
146
  const MANAGE_COMMANDS: Array<{ cmd: string; label: string }> = [
139
- { cmd: 'wake', label: 'Wake' },
147
+ // NB: no 'Wake' — a peer-row click (attach) already wakes a sleeping peer before it
148
+ // connects, so a separate Wake item was redundant (owner decision). `tray cmd wake`
149
+ // stays available on the CLI / fleet API; only the menu item is dropped.
140
150
  { cmd: 'stop', label: 'Stop' },
141
151
  { cmd: 'start', label: 'Start' },
142
152
  { cmd: 'new', label: 'New session (fresh)' },
@@ -150,6 +160,10 @@ export function renderSwiftBar(snapshot: TraySnapshot, opts: RenderOptions): str
150
160
  const now = opts.now ?? Date.now()
151
161
  const bin = opts.binPath
152
162
  const peers = [...snapshot.peers].sort((a, b) => {
163
+ // primary: the 🔒 lock block first (service/human infra), working agents below
164
+ const l = lockRank(a) - lockRank(b)
165
+ if (l !== 0) return l
166
+ // secondary (within EACH block): the existing live→asleep→stopped, then alpha
153
167
  const r = RANK[aggregate(a)] - RANK[aggregate(b)]
154
168
  return r !== 0 ? r : a.personality.localeCompare(b.personality)
155
169
  })
@@ -241,9 +255,14 @@ function attachable(p: TrayPeer): boolean {
241
255
  function renderPeerRow(p: TrayPeer, bin: string, now: number, pendingCount = 0): string {
242
256
  const agg = aggregate(p)
243
257
  const detail = p.runtimes.map(r => `${r.runtime}${GLYPH[r.status]}`).join(' ')
258
+ // approval mode (docs/17): the whole fleet defaults to `yolo`, so showing it on every row is
259
+ // noise — mark ONLY the exception, `gated` (this peer's blocking approvals go to a human), with a
260
+ // 🛡 shield. No badge ⇒ yolo (the default; also how a pre-approval snapshot with the field absent
261
+ // renders). So the row reads the mode: 🛡 = gated, bare = yolo.
244
262
  const badges =
245
263
  (p.attached ? ' 👤' : '') +
246
264
  (p.launchd_managed ? ' 🔒' : '') +
265
+ (p.approval_mode === 'gated' ? ' 🛡' : '') +
247
266
  (p.wake_policy === 'ephemeral' && (p.queue_depth ?? 0) > 0 ? ` ⏳${p.queue_depth}` : '')
248
267
  const ageStr = p.last_active_ms ? ` ${fmtAge(p.last_active_ms, now)}` : ''
249
268
  // HIGHLIGHT a peer with a pending approval: a ⚠ prefix + a red count badge + the row painted red
@@ -278,10 +297,54 @@ function renderManage(peers: TrayPeer[], bin: string): string[] {
278
297
  }),
279
298
  )
280
299
  }
300
+ // approval-mode toggle — only for agentic (attachable) peers; meaningless for the
301
+ // human channel / notifier infra (arthur/timer/watcher), which are always yolo.
302
+ if (attachable(p)) out.push(...renderApprovalToggle(p, bin))
281
303
  }
282
304
  return out
283
305
  }
284
306
 
307
+ /**
308
+ * The per-peer approval-mode control in the Manage submenu (docs/17): READS the current
309
+ * mode and FLIPS it via the local `approval-mode` verb.
310
+ *
311
+ * APPLY = next session, NOT now. The flip persists the mode + brings the runtime
312
+ * surfaces to it; it does NOT pass --now, so it takes effect on the peer's NEXT fresh
313
+ * session. A menu tap must never kill a live session mid-work (a respawn is a heavy,
314
+ * surprising side effect of a click). The item says "(next session)" so the wait is
315
+ * explicit.
316
+ *
317
+ * refresh=true is the ONE deliberate exception to the "no refresh in the streamable
318
+ * menu" rule: the flip runs the local verb (no daemon round-trip → NO SSE event), so
319
+ * the plugin must re-render to reflect the new mode.
320
+ *
321
+ * SECURITY-ASYMMETRIC friction (a mode-flip changes the security PERIMETER — unlike a
322
+ * one-off Deny): yolo→gated STRENGTHENS (adds human approval) → a single safe tap;
323
+ * gated→yolo WEAKENS (REMOVES human approval) → the current mode sits on a submenu and
324
+ * the flip is an explicit red ⚠ confirm CHILD, so the perimeter is never dropped by one
325
+ * stray click. SwiftBar has no native confirm dialog — the nested explicit item IS the
326
+ * confirm.
327
+ */
328
+ function renderApprovalToggle(p: TrayPeer, bin: string): string[] {
329
+ const flip = (target: 'gated' | 'yolo'): Record<string, string | boolean> => ({
330
+ bash: bin,
331
+ param1: 'approval-mode',
332
+ param2: p.personality,
333
+ param3: target,
334
+ terminal: false,
335
+ refresh: true, // local verb → no SSE event → re-render to show the new mode
336
+ })
337
+ if (p.approval_mode === 'gated') {
338
+ // WEAKEN — current on the parent (no action); the flip is an explicit red confirm child.
339
+ return [
340
+ line(2, 'Approval: gated 🛡', { color: COLOR.warn }),
341
+ line(3, '⚠ Switch to yolo — removes human approval (next session)', { color: COLOR.down, ...flip('yolo') }),
342
+ ]
343
+ }
344
+ // STRENGTHEN (yolo → gated) — safe, one tap.
345
+ return [line(2, 'Approval: yolo → gated 🛡 (next session)', flip('gated'))]
346
+ }
347
+
285
348
  // ── approval renderers (docs/17 — the always-on approval channel in the bar) ─────
286
349
 
287
350
  /** Menu-bar pending badge: a red `N.circle.fill` SF Symbol (the iOS-notification look — a red circle
@@ -19,6 +19,8 @@ export interface WizardResult {
19
19
  code: number
20
20
  memoryConsent: boolean
21
21
  voiceConsent: boolean
22
+ /** Install the SwiftBar menu-bar tray face (default-NO — a GUI dependency). */
23
+ trayConsent: boolean
22
24
  advisories: string[]
23
25
  summary: string[]
24
26
  telegramConsent: boolean
@@ -28,7 +30,7 @@ export interface WizardResult {
28
30
  hostRuntime?: OnboardRuntime
29
31
  }
30
32
 
31
- type Phase = 'gate' | 'running' | 'telegram' | 'memory' | 'voice' | 'declined' | 'finishing'
33
+ type Phase = 'gate' | 'running' | 'telegram' | 'memory' | 'voice' | 'tray' | 'declined' | 'finishing'
32
34
  type StepStatus = 'pending' | 'running' | 'ok' | 'warn' | 'fail'
33
35
  interface Step {
34
36
  key: string
@@ -98,16 +100,20 @@ export function OnboardApp({
98
100
  const [hostRuntime, setHostRuntime] = useState<OnboardRuntime | undefined>(undefined)
99
101
  const [telegramConsent, setTelegramConsent] = useState(false)
100
102
  const [memoryConsent, setMemoryConsent] = useState(false)
103
+ const [voiceConsent, setVoiceConsent] = useState(false)
101
104
  const spin = useSpinnerFrame(phase === 'running')
102
105
 
103
- const finish = (voiceConsent: boolean): void => {
106
+ const finish = (trayConsent: boolean): void => {
104
107
  const failed = steps.some(s => s.status === 'fail')
105
108
  const summary = steps.map(s => {
106
109
  const mark = s.status === 'ok' ? '✓' : s.status === 'warn' ? '!' : s.status === 'fail' ? '✗' : '·'
107
110
  return ` ${mark} ${s.label}${s.detail ? ` — ${s.detail}` : ''}`
108
111
  })
109
112
  summary.unshift(failed ? 'Onboard finished with errors:' : 'Onboard complete:')
110
- onResult({ code: failed ? 1 : 0, memoryConsent, voiceConsent, telegramConsent, advisories, summary, hostRuntime })
113
+ // voiceConsent is committed state by now (its phase completed a render cycle before
114
+ // tray); trayConsent is the last decision, passed directly (its setState wouldn't
115
+ // have flushed within this same input handler).
116
+ onResult({ code: failed ? 1 : 0, memoryConsent, voiceConsent, trayConsent, telegramConsent, advisories, summary, hostRuntime })
111
117
  setPhase('finishing')
112
118
  setTimeout(() => exit(), 30)
113
119
  }
@@ -152,19 +158,34 @@ export function OnboardApp({
152
158
  { isActive: phase === 'memory' },
153
159
  )
154
160
 
155
- // Voice consent — DEFAULT-YES ([Y/n]: Enter or y → install). The LAST consent → finish.
161
+ // Voice consent — DEFAULT-YES ([Y/n]: Enter or y → install). Then → tray.
156
162
  useInput(
157
163
  (input, key) => {
158
- if (input === 'n' || input === 'N') finish(false)
159
- else if (input === 'y' || input === 'Y' || key.return) finish(true)
164
+ if (input === 'n' || input === 'N') {
165
+ setVoiceConsent(false)
166
+ setPhase('tray')
167
+ } else if (input === 'y' || input === 'Y' || key.return) {
168
+ setVoiceConsent(true)
169
+ setPhase('tray')
170
+ }
160
171
  },
161
172
  { isActive: phase === 'voice' },
162
173
  )
163
174
 
175
+ // Tray consent — DEFAULT-NO ([y/N]: Enter or n → skip; y → install). A GUI
176
+ // dependency (SwiftBar), so conservatively opt-IN. The LAST consent → finish.
177
+ useInput(
178
+ (input, key) => {
179
+ if (input === 'y' || input === 'Y') finish(true)
180
+ else if (input === 'n' || input === 'N' || key.return || key.escape) finish(false)
181
+ },
182
+ { isActive: phase === 'tray' },
183
+ )
184
+
164
185
  // Declined → exit 1 (no host mutation).
165
186
  useEffect(() => {
166
187
  if (phase !== 'declined') return
167
- onResult({ code: 1, memoryConsent: false, voiceConsent: false, telegramConsent: false, advisories: [], summary: ['onboard aborted — risk not accepted.'] })
188
+ onResult({ code: 1, memoryConsent: false, voiceConsent: false, trayConsent: false, telegramConsent: false, advisories: [], summary: ['onboard aborted — risk not accepted.'] })
168
189
  const t = setTimeout(() => exit(), 30)
169
190
  return () => clearTimeout(t)
170
191
  }, [phase, onResult, exit])
@@ -377,6 +398,14 @@ export function OnboardApp({
377
398
  <Text dimColor> A local text-to-speech / speech-to-text HTTP service. Per-agent voice tools are a separate `iapeer enable`. [Y/n]</Text>
378
399
  </Box>
379
400
  ) : null}
401
+ {phase === 'tray' ? (
402
+ <Box marginTop={1} flexDirection="column">
403
+ <Text bold color="cyan">
404
+ ? Install the menu-bar tray face (SwiftBar)?
405
+ </Text>
406
+ <Text dimColor> A live fleet dashboard in your macOS menu bar (installs SwiftBar via Homebrew). [y/N]</Text>
407
+ </Box>
408
+ ) : null}
380
409
  </Box>
381
410
  )
382
411
  }
@@ -71,7 +71,7 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
71
71
  // wizard only runs on one), off under NO_COLOR.
72
72
  const a = makeAnsi(colorEnabled(env, process.stdout))
73
73
 
74
- let result: WizardResult = { code: 0, memoryConsent: false, voiceConsent: false, telegramConsent: false, advisories: [], summary: [] }
74
+ let result: WizardResult = { code: 0, memoryConsent: false, voiceConsent: false, trayConsent: false, telegramConsent: false, advisories: [], summary: [] }
75
75
  try {
76
76
  const app = render(<OnboardApp env={env} onResult={r => (result = r)} />)
77
77
  await app.waitUntilExit()
@@ -164,6 +164,31 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
164
164
  '\n' + paintResult('voice', 'skipped', ' — set it up later with `npx @agfpd/voice-connect init`', a) + '\n',
165
165
  )
166
166
  }
167
+
168
+ if (result.trayConsent) {
169
+ const { onboardTrayStep } = await import('../../onboard/tray.ts')
170
+ process.stdout.write(
171
+ '\n' +
172
+ a.bold('Installing the menu-bar tray face (SwiftBar)…') +
173
+ '\n' +
174
+ a.dim(
175
+ 'A live fleet dashboard in the macOS menu bar. Needs Homebrew to auto-install SwiftBar;\n' +
176
+ 'without brew the plugin is dropped inert and you install SwiftBar manually (`iapeer tray install --app`).',
177
+ ) +
178
+ '\n\n',
179
+ )
180
+ try {
181
+ const t = await onboardTrayStep({ env })
182
+ process.stdout.write('\n' + paintResult('tray', t.state, t.detail ? ` — ${t.detail}` : '', a) + '\n')
183
+ } catch (e) {
184
+ const rest = ` — ${e instanceof Error ? e.message : String(e)} ${a.dim('(activate later: `iapeer tray install --app`)')}`
185
+ process.stdout.write('\n' + paintResult('tray', 'step skipped', rest, a) + '\n')
186
+ }
187
+ } else {
188
+ process.stdout.write(
189
+ '\n' + paintResult('tray', 'skipped', ' — add it later with `iapeer tray install --app`', a) + '\n',
190
+ )
191
+ }
167
192
  } finally {
168
193
  process.removeListener('SIGINT', ignore)
169
194
  for (const h of prevInt) process.on('SIGINT', h as NodeJS.SignalsListener)