@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 +22 -18
- package/docs/ru/16-tray.md +22 -16
- package/package.json +1 -1
- package/src/approval/hook.ts +29 -9
- package/src/tray/render.ts +43 -57
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
|
-
- **
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
package/docs/ru/16-tray.md
CHANGED
|
@@ -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
|
-
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
package/src/approval/hook.ts
CHANGED
|
@@ -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
|
-
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
|
|
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:
|
|
131
|
-
...(d.
|
|
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/tray/render.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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(
|
|
341
|
-
line(
|
|
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(
|
|
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) ─────
|