@agfpd/iapeer 0.4.68 → 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.
@@ -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. */