@agfpd/iapeer 0.4.75 → 0.4.77

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.
@@ -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
@@ -42,8 +42,16 @@ activation that also installs SwiftBar.app when it is absent.
42
42
  own `terminal=true`, whose Terminal launch drives a `Cmd-T` System-Events keystroke that
43
43
  silently no-ops unless SwiftBar holds Accessibility permission. **Launchd-managed infra
44
44
  peers (telegram / notifier, `🔒`) are not pty-attachable** — their rows are plain status
45
- lines. Lifecycle actions (Wake / Stop / Start / New / Interrupt / Refresh / Compact) live
45
+ lines. Lifecycle actions (Stop / Start / New / Interrupt / Refresh / Compact) live
46
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.
47
55
  - **Pending approvals (docs/17):** when the queue is non-empty, the **top of the dropdown** shows
48
56
  each pending request **expanded, with no extra clicks** — a header (`<peer> · <tool>`), the
49
57
  **verbatim action content** (the command / diff / plan, monospace, capped for the menu; the FULL
@@ -39,8 +39,15 @@ SwiftBar.app, если его нет.
39
39
  родной `terminal=true` SwiftBar, чей запуск Terminal бьёт `Cmd-T` кейстроком через System
40
40
  Events и молча не срабатывает без права Accessibility у SwiftBar. **Launchd-инфра-пиры
41
41
  (telegram / notifier, `🔒`) не pty-аттачатся** — их строки просто статус. Lifecycle-действия
42
- (Wake / Stop / Start / New / Interrupt / Refresh / Compact) — в подменю **Manage**, каждое —
43
- `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
+ случайным кликом.
44
51
  - **Ожидающие подтверждения (docs/17):** когда очередь непуста, **вверху выпадашки** каждый
45
52
  ожидающий запрос показан **раскрытым, без доп. кликов** — заголовок (`<peer> · <tool>`),
46
53
  **дословное содержимое действия** (команда / диф / план, моноширинно, усечено для меню; ПОЛНОЕ
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.75",
3
+ "version": "0.4.77",
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",
@@ -112,10 +112,20 @@ function throwEvent(v: unknown): never {
112
112
  throw new Error(`hook stdin has an unexpected hook_event_name "${String(v)}"`)
113
113
  }
114
114
 
115
- /** The decision JSON in the shape the given event accepts. A deny carries the reason to the
116
- * model. PermissionRequest uses `decision.behavior` (+ `message`); PreToolUse uses
117
- * `permissionDecision` (+ `permissionDecisionReason`) — both verified live 2.1.201 / codex 0.142.5. */
118
- export function formatHookDecision(d: ApprovalDecision, event: HookEvent): string {
115
+ /**
116
+ * The decision JSON in the shape the given event (and runtime) accepts. A deny carries the
117
+ * reason to the model. PermissionRequest uses `decision.behavior` (+ `message`); PreToolUse
118
+ * uses `permissionDecision` (+ `permissionDecisionReason`).
119
+ *
120
+ * codex vs claude on the PreToolUse **allow** path (VERIFIED LIVE, codex-cli 0.142.5): codex
121
+ * honors ONLY `permissionDecision:"deny"` — emitting `permissionDecision:"allow"` marks the
122
+ * hook **Failed** (fail-open, the "PreToolUse Failed"/"unsupported permissionDecision:allow"
123
+ * the operator saw). The clean allow on codex is to **abstain**: emit NOTHING (exit 0) → codex
124
+ * marks the hook **Completed** and the tool proceeds under the gated bypass base (approval is a
125
+ * DENY-or-abstain gate on codex, not a two-sided allow/deny one). claude's PreToolUse DOES honor
126
+ * `permissionDecision:"allow"`, so it keeps the affirmative form. Deny is identical on both.
127
+ */
128
+ export function formatHookDecision(d: ApprovalDecision, event: HookEvent, runtime = 'claude'): string {
119
129
  if (event === 'PermissionRequest') {
120
130
  return JSON.stringify({
121
131
  hookSpecificOutput: {
@@ -124,11 +134,19 @@ export function formatHookDecision(d: ApprovalDecision, event: HookEvent): strin
124
134
  },
125
135
  })
126
136
  }
137
+ // PreToolUse
138
+ if (d.decision === 'allow') {
139
+ // codex: abstain (empty output) — an affirmative "allow" fails the hook (verified live 0.142.5).
140
+ if (runtime === 'codex') return ''
141
+ // claude: affirmative allow is honored.
142
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' } })
143
+ }
144
+ // deny (+ reason) — honored identically on claude and codex.
127
145
  return JSON.stringify({
128
146
  hookSpecificOutput: {
129
147
  hookEventName: 'PreToolUse',
130
- permissionDecision: d.decision,
131
- ...(d.decision === 'deny' && d.reason ? { permissionDecisionReason: d.reason } : {}),
148
+ permissionDecision: 'deny',
149
+ ...(d.reason ? { permissionDecisionReason: d.reason } : {}),
132
150
  },
133
151
  })
134
152
  }
@@ -162,6 +180,9 @@ export async function runApprovalHook(stdin: string, deps: RunHookDeps = {}): Pr
162
180
  const env = deps.env ?? process.env
163
181
  const doFetch = deps.fetch ?? fetch
164
182
  const timeoutMs = deps.timeoutMs
183
+ // Runtime resolved up front so BOTH the success and the fail-safe-deny paths format for the
184
+ // right runtime (codex's allow-shape differs from claude's — see formatHookDecision).
185
+ const runtime = env.PEER_RUNTIME?.trim() || 'claude'
165
186
  let event: HookEvent
166
187
  let toolName: string
167
188
  let toolInput: Record<string, unknown> | undefined
@@ -174,18 +195,17 @@ export async function runApprovalHook(stdin: string, deps: RunHookDeps = {}): Pr
174
195
  try {
175
196
  const personality = env.PEER_PERSONALITY?.trim()
176
197
  if (!personality) throw new Error('no PEER_PERSONALITY in the hook environment')
177
- const runtime = env.PEER_RUNTIME?.trim() || 'claude'
178
198
  const { kind, content, summary } = actionContent(toolName, toolInput)
179
199
  const decision = await requestApproval(
180
200
  resolveFleetBase(env),
181
201
  { personality, runtime, kind, tool: toolName, content, summary },
182
202
  { env, fetch: doFetch, timeoutMs },
183
203
  )
184
- return { stdout: formatHookDecision(decision, event), stderr: '', exitCode: 0 }
204
+ return { stdout: formatHookDecision(decision, event, runtime), stderr: '', exitCode: 0 }
185
205
  } catch (e) {
186
206
  // Event known → event-appropriate DENY JSON (never allow-by-accident, never hang).
187
207
  return {
188
- stdout: formatHookDecision({ decision: 'deny', reason: `iapeer approval unavailable (${e instanceof Error ? e.message : String(e)}) — denied fail-safe` }, event),
208
+ stdout: formatHookDecision({ decision: 'deny', reason: `iapeer approval unavailable (${e instanceof Error ? e.message : String(e)}) — denied fail-safe` }, event, runtime),
189
209
  stderr: '',
190
210
  exitCode: 0,
191
211
  }
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
@@ -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
+ }
@@ -144,7 +144,9 @@ const lockRank = (p: TrayPeer): number => (p.launchd_managed ? 0 : 1)
144
144
  // ── renderers ─────────────────────────────────────────────────────────────────
145
145
 
146
146
  const MANAGE_COMMANDS: Array<{ cmd: string; label: string }> = [
147
- { 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.
148
150
  { cmd: 'stop', label: 'Stop' },
149
151
  { cmd: 'start', label: 'Start' },
150
152
  { cmd: 'new', label: 'New session (fresh)' },
@@ -295,10 +297,54 @@ function renderManage(peers: TrayPeer[], bin: string): string[] {
295
297
  }),
296
298
  )
297
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))
298
303
  }
299
304
  return out
300
305
  }
301
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
+
302
348
  // ── approval renderers (docs/17 — the always-on approval channel in the bar) ─────
303
349
 
304
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)