@agfpd/iapeer 0.4.76 → 0.4.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/16-tray.md CHANGED
@@ -34,24 +34,28 @@ activation that also installs SwiftBar.app when it is absent.
34
34
  a human is attached, `🔒` launchd-managed (H4 read-only), `🛡` the peer runs in **gated**
35
35
  human-approval mode (docs/17; `yolo` is the fleet default and carries no badge — a bare
36
36
  row reads as yolo), `⏳N` an ephemeral peer's queue depth.
37
- - **Click an agent peer → Terminal.app running `iapeer attach <peer>`.** The row itself is
38
- the attach action (no submenu, so a click attaches rather than merely expanding). The
39
- handoff goes through `iapeer tray attach-term`, which writes a per-peer `.command`
40
- launcher and `open`s it — `open <file>.command` hands the file to Terminal.app with
41
- **no Automation/Accessibility permission prompt**. This deliberately avoids SwiftBar's
42
- own `terminal=true`, whose Terminal launch drives a `Cmd-T` System-Events keystroke that
43
- silently no-ops unless SwiftBar holds Accessibility permission. **Launchd-managed infra
44
- peers (telegram / notifier, `🔒`) are not pty-attachable** — their rows are plain status
45
- lines. Lifecycle actions (Stop / Start / New / Interrupt / Refresh / Compact) live
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.
37
+ - **Each peer is a SUBMENU** — one unified list (no separate Manage section; the peer rows
38
+ and a parallel Manage list of every peer used to be a visible dupe). Clicking a peer
39
+ EXPANDS it; its actions are the children, in order:
40
+ - **Attach** (agent peers only) — the FIRST child. Runs `iapeer tray attach-term <peer>`,
41
+ which writes a per-peer `.command` launcher and `open`s it — `open <file>.command` hands
42
+ the file to Terminal.app with **no Automation/Accessibility permission prompt** (this
43
+ deliberately avoids SwiftBar's own `terminal=true`, whose Terminal launch drives a `Cmd-T`
44
+ System-Events keystroke that silently no-ops unless SwiftBar holds Accessibility). Attach
45
+ is now expand-then-Attach (two clicks) — the trade for a single dupe-free list.
46
+ - **Lifecycle** — Stop / Start / New / Interrupt / Refresh / Compact, each a
47
+ `POST /fleet/v1/peers/<peer>/<cmd>` via `iapeer tray cmd`. (No Wake item — a peer-row
48
+ click path already wakes a sleeping peer before it attaches, so it was redundant.)
49
+ - **Approval-mode toggle** (agent peers only, docs/17): reads the current mode and flips it
50
+ via the local `iapeer approval-mode <peer> <mode>` verb, applied on the peer's **next fresh
51
+ session** (a click never respawns a live session). The friction is asymmetric to the
52
+ security weight: `yolo → gated` (adds human approval) is one safe tap; `gated → yolo`
53
+ (REMOVES the approval perimeter) sits one level deeper behind an explicit red ⚠ confirm, so
54
+ it is never dropped by a stray click.
55
+
56
+ **Launchd-managed infra peers (telegram / notifier, `🔒`)** are not pty-attachable, so their
57
+ submenu carries **no Attach** and **no approval toggle** (always yolo) — just the lifecycle
58
+ commands (service Stop / Start etc.).
55
59
  - **Pending approvals (docs/17):** when the queue is non-empty, the **top of the dropdown** shows
56
60
  each pending request **expanded, with no extra clicks** — a header (`<peer> · <tool>`), the
57
61
  **verbatim action content** (the command / diff / plan, monospace, capped for the menu; the FULL
@@ -32,22 +32,28 @@ SwiftBar.app, если его нет.
32
32
  `👤` подключён человек, `🔒` под launchd (H4 read-only), `🛡` пир в режиме **gated**
33
33
  human-approval (docs/17; `yolo` — дефолт флота, без бейджа: голая строка читается как yolo),
34
34
  `⏳N` глубина очереди эфемерного пира.
35
- - **Клик по агент-пиру → Terminal.app с `iapeer attach <peer>`.** Строка сама и есть действие
36
- attach (без подменю — клик аттачит, а не раскрывает). Передача идёт через `iapeer tray
37
- attach-term`: пишет per-peer `.command`-лаунчер и `open`'ит его — `open <файл>.command`
38
- отдаёт файл Terminal.app **без запроса Automation/Accessibility**. Это намеренно обходит
39
- родной `terminal=true` SwiftBar, чей запуск Terminal бьёт `Cmd-T` кейстроком через System
40
- Events и молча не срабатывает без права Accessibility у SwiftBar. **Launchd-инфра-пиры
41
- (telegram / notifier, `🔒`) не pty-аттачатся** — их строки просто статус. Lifecycle-действия
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
- случайным кликом.
35
+ - **Каждый пир — ПОДМЕНЮ** — один единый список (без отдельной секции Manage; строки пиров и
36
+ параллельный Manage-список всех пиров раньше были видимым дублем). Клик по пиру РАСКРЫВАЕТ
37
+ его; действия — дочерние пункты, по порядку:
38
+ - **Attach** (только агент-пиры) — ПЕРВЫЙ пункт. Запускает `iapeer tray attach-term <peer>`:
39
+ пишет per-peer `.command`-лаунчер и `open`'ит его — `open <файл>.command` отдаёт файл
40
+ Terminal.app **без запроса Automation/Accessibility** (намеренно обходит родной
41
+ `terminal=true` SwiftBar, чей запуск Terminal бьёт `Cmd-T` кейстроком через System Events и
42
+ молча не срабатывает без права Accessibility). Attach теперь = раскрыть-и-Attach (два клика) —
43
+ плата за единый список без дубля.
44
+ - **Lifecycle** — Stop / Start / New / Interrupt / Refresh / Compact, каждое —
45
+ `POST /fleet/v1/peers/<peer>/<cmd>` через `iapeer tray cmd`. (Пункта Wake нет — путь клика по
46
+ пиру и так будит спящего перед аттачем, отдельный Wake избыточен.)
47
+ - **Переключатель approval-режима** (только агент-пиры, docs/17): читает текущий режим и флипает
48
+ локальным verb'ом `iapeer approval-mode <peer> <mode>`, применяется на **следующей свежей
49
+ сессии** пира (клик никогда не респавнит живую сессию). Трение асимметрично весу безопасности:
50
+ `yolo → gated` (добавляет human-approval) — один безопасный тап; `gated → yolo` (СНИМАЕТ
51
+ периметр approval) на уровень глубже за явным красным ⚠ confirm, чтобы не сбросить случайным
52
+ кликом.
53
+
54
+ **Launchd-инфра-пиры (telegram / notifier, `🔒`)** не pty-аттачатся — их подменю несёт **без
55
+ Attach** и **без approval-переключателя** (всегда yolo), только lifecycle-команды (Stop / Start
56
+ сервиса и т.д.).
51
57
  - **Ожидающие подтверждения (docs/17):** когда очередь непуста, **вверху выпадашки** каждый
52
58
  ожидающий запрос показан **раскрытым, без доп. кликов** — заголовок (`<peer> · <tool>`),
53
59
  **дословное содержимое действия** (команда / диф / план, моноширинно, усечено для меню; ПОЛНОЕ
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.76",
3
+ "version": "0.4.78",
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
  }
@@ -198,19 +198,17 @@ export function renderSwiftBar(snapshot: TraySnapshot, opts: RenderOptions): str
198
198
  if (providers) lines.push(line(0, providers, { color: COLOR.meta }))
199
199
  lines.push('---')
200
200
 
201
- // ── peers (each row is a DIRECT attach action — click → Terminal) ──
201
+ // ── peers — each peer is a SUBMENU: expand → Attach (first) + its actions. ONE list
202
+ // (no separate Manage section): the peer rows and their lifecycle used to be two
203
+ // parallel lists of every peer (a click-attach list + a Manage list) — a visible
204
+ // dupe. Folded here (owner decision — 2 clicks to attach: expand peer → Attach). ──
202
205
  if (peers.length === 0) {
203
206
  lines.push(line(0, 'no peers registered', { color: COLOR.meta }))
204
207
  }
205
208
  for (const p of peers) {
206
- lines.push(renderPeerRow(p, bin, now, pendingByPeer.get(p.personality) ?? 0))
209
+ lines.push(...renderPeerSubmenu(p, bin, now, pendingByPeer.get(p.personality) ?? 0))
207
210
  }
208
211
 
209
- // ── Manage submenu (lifecycle commands per peer; kept off the rows so a row click
210
- // attaches instead of just expanding a submenu) ──
211
- lines.push('---')
212
- lines.push(...renderManage(peers, bin))
213
-
214
212
  // ── footer ──
215
213
  // No explicit "Refresh" item: the plugin is streamable + SSE-driven, so the menu is
216
214
  // always current on open — a manual refresh gives the user nothing and (being a
@@ -243,64 +241,52 @@ function attachable(p: TrayPeer): boolean {
243
241
  return !p.launchd_managed
244
242
  }
245
243
 
246
- /**
247
- * One peer row. For an ATTACHABLE peer the row IS a DIRECT attach action (no submenu,
248
- * so a click attaches instead of merely expanding): clicking runs `iapeer tray
249
- * attach-term <peer>` in the BACKGROUND (terminal=false), which opens a `.command` via
250
- * `open` — a system-Terminal handoff that needs NO Accessibility/Automation TCC. This
251
- * deliberately AVOIDS SwiftBar's own `terminal=true`, whose Terminal.app launch drives a
252
- * `Cmd-T` System-Events keystroke that silently no-ops without Accessibility permission.
253
- * Infra (launchd) rows are plain status lines. Lifecycle lives in the Manage submenu.
254
- */
255
- function renderPeerRow(p: TrayPeer, bin: string, now: number, pendingCount = 0): string {
256
- const agg = aggregate(p)
244
+ /** The status label for a peer's submenu-parent row: `<peer> <rt><glyph> … <badges> <age>`.
245
+ * Badges: 👤 attached · 🔒 launchd · 🛡 gated approval (docs/17; yolo=fleet default=no badge) ·
246
+ * ⏳N ephemeral queue. A pending approval prepends ⚠ + a 🔴N count so the owner sees WHICH peer
247
+ * waits (the Allow/Deny itself lives in the top approvals section, not here). */
248
+ function peerLabel(p: TrayPeer, now: number, pendingCount: number): string {
257
249
  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.
262
250
  const badges =
263
251
  (p.attached ? ' 👤' : '') +
264
252
  (p.launchd_managed ? ' 🔒' : '') +
265
253
  (p.approval_mode === 'gated' ? ' 🛡' : '') +
266
254
  (p.wake_policy === 'ephemeral' && (p.queue_depth ?? 0) > 0 ? ` ⏳${p.queue_depth}` : '')
267
255
  const ageStr = p.last_active_ms ? ` ${fmtAge(p.last_active_ms, now)}` : ''
268
- // HIGHLIGHT a peer with a pending approval: a ⚠ prefix + a red count badge + the row painted red
269
- // so the owner sees WHICH peer is waiting (owner criterion — the static-menu equivalent of a pulse;
270
- // SwiftBar cannot animate). The highlight wins over the live/meta color.
271
256
  const prefix = pendingCount > 0 ? '⚠ ' : ''
272
257
  const pendingBadge = pendingCount > 0 ? ` 🔴${pendingCount}` : ''
273
- const label = `${prefix}${p.personality} ${detail}${badges}${pendingBadge}${ageStr}`
274
- const rowColor = pendingCount > 0 ? COLOR.down : agg === 'live' ? COLOR.live : COLOR.meta
275
- if (!attachable(p)) return line(0, label, { color: rowColor })
276
- return line(0, label, { color: rowColor, bash: bin, param1: 'tray', param2: 'attach-term', param3: p.personality, terminal: false })
258
+ return `${prefix}${p.personality} ${detail}${badges}${pendingBadge}${ageStr}`
277
259
  }
278
260
 
279
- /** The Manage submenu: one sub-submenu per peer with its lifecycle commands, each a
280
- * `iapeer tray cmd <c> <peer>` → POST /fleet/v1/peers/<peer>/<c> (terminal=false,
281
- * refresh to reflect the outcome). Kept OFF the peer rows so a row click attaches. */
282
- function renderManage(peers: TrayPeer[], bin: string): string[] {
283
- const out: string[] = [line(0, 'Manage', { sfimage: 'slider.horizontal.3' })]
284
- for (const p of peers) {
285
- out.push(line(1, p.personality))
286
- for (const c of MANAGE_COMMANDS) {
287
- // No refresh=true: the SSE stream reflects the command's lifecycle event on its
288
- // own (streamable is always fresh); a refresh would only restart the stream.
289
- out.push(
290
- line(2, c.label, {
291
- bash: bin,
292
- param1: 'tray',
293
- param2: 'cmd',
294
- param3: c.cmd,
295
- param4: p.personality,
296
- terminal: false,
297
- }),
298
- )
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))
261
+ /**
262
+ * One peer as a SUBMENU (single unified list — no separate Manage). The parent row is the
263
+ * status label (no direct action — clicking EXPANDS); the children are, in order:
264
+ * 1. **Attach** (agentic peers only) — the terminal handoff, unchanged (0.4.67): `iapeer tray
265
+ * attach-term <peer>` in the BACKGROUND (terminal=false) opens a `.command` via `open`, needing
266
+ * NO Accessibility/Automation TCC (avoids SwiftBar's `terminal=true` Cmd-T keystroke). This is
267
+ * the ONE trade Arthur accepted: attach is now expand-then-Attach (2 clicks) for a dupe-free list.
268
+ * 2. lifecycle commands (Stop/Start/New/Interrupt/Refresh/Compact) — `iapeer tray cmd <c> <peer>`.
269
+ * 3. approval-mode toggle (agentic peers only — docs/17).
270
+ *
271
+ * Launchd-managed peers (arthur/timer/watcher, 🔒): NO Attach (no pty session) and NO approval toggle
272
+ * (always yolo); they keep the lifecycle submenu (service Stop/Start etc — the SAME set the old Manage
273
+ * already offered them, just relocated under the peer). Per-infra-type trimming (e.g. dropping
274
+ * Interrupt/Compact for the human peer) is a separate call, out of scope for a de-dupe render change.
275
+ */
276
+ function renderPeerSubmenu(p: TrayPeer, bin: string, now: number, pendingCount = 0): string[] {
277
+ const rowColor = pendingCount > 0 ? COLOR.down : aggregate(p) === 'live' ? COLOR.live : COLOR.meta
278
+ const out: string[] = [line(0, peerLabel(p, now, pendingCount), { color: rowColor })]
279
+ // 1) Attach FIRST (agentic only — launchd infra/human peers have no pty session to attach)
280
+ if (attachable(p)) {
281
+ out.push(line(1, 'Attach', { bash: bin, param1: 'tray', param2: 'attach-term', param3: p.personality, terminal: false }))
282
+ }
283
+ // 2) lifecycle commands — each `iapeer tray cmd <c> <peer>` → POST /fleet/v1/peers/<peer>/<c>.
284
+ // No refresh=true: the SSE stream reflects the outcome (streamable is always fresh).
285
+ for (const c of MANAGE_COMMANDS) {
286
+ out.push(line(1, c.label, { bash: bin, param1: 'tray', param2: 'cmd', param3: c.cmd, param4: p.personality, terminal: false }))
303
287
  }
288
+ // 3) approval-mode toggle (agentic only)
289
+ if (attachable(p)) out.push(...renderApprovalToggle(p, bin, 1))
304
290
  return out
305
291
  }
306
292
 
@@ -325,7 +311,7 @@ function renderManage(peers: TrayPeer[], bin: string): string[] {
325
311
  * stray click. SwiftBar has no native confirm dialog — the nested explicit item IS the
326
312
  * confirm.
327
313
  */
328
- function renderApprovalToggle(p: TrayPeer, bin: string): string[] {
314
+ function renderApprovalToggle(p: TrayPeer, bin: string, depth: number): string[] {
329
315
  const flip = (target: 'gated' | 'yolo'): Record<string, string | boolean> => ({
330
316
  bash: bin,
331
317
  param1: 'approval-mode',
@@ -337,12 +323,12 @@ function renderApprovalToggle(p: TrayPeer, bin: string): string[] {
337
323
  if (p.approval_mode === 'gated') {
338
324
  // WEAKEN — current on the parent (no action); the flip is an explicit red confirm child.
339
325
  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') }),
326
+ line(depth, 'Approval: gated 🛡', { color: COLOR.warn }),
327
+ line(depth + 1, '⚠ Switch to yolo — removes human approval (next session)', { color: COLOR.down, ...flip('yolo') }),
342
328
  ]
343
329
  }
344
330
  // STRENGTHEN (yolo → gated) — safe, one tap.
345
- return [line(2, 'Approval: yolo → gated 🛡 (next session)', flip('gated'))]
331
+ return [line(depth, 'Approval: yolo → gated 🛡 (next session)', flip('gated'))]
346
332
  }
347
333
 
348
334
  // ── approval renderers (docs/17 — the always-on approval channel in the bar) ─────