@agfpd/iapeer 0.4.66 → 0.4.68

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
@@ -26,17 +26,25 @@ activation that also installs SwiftBar.app when it is absent.
26
26
  `●` live · `○` asleep · `✕` stopped — green when any runtime is live. Badges: `👤`
27
27
  a human is attached, `🔒` launchd-managed (H4 read-only), `⏳N` an ephemeral peer's
28
28
  queue depth.
29
- - **Click a peer → Terminal.app running `iapeer attach <peer>`.** Attach is a terminal
30
- handoff the API deliberately keeps client-side (a PTY inside a GUI is out of scope);
31
- the same row's submenu carries the lifecycle actions (Wake / Stop / Start / New /
32
- Interrupt / Refresh / Compact), each a `POST /fleet/v1/peers/<peer>/<cmd>` via
33
- `iapeer tray cmd`.
29
+ - **Click an agent peer → Terminal.app running `iapeer attach <peer>`.** The row itself is
30
+ the attach action (no submenu, so a click attaches rather than merely expanding). The
31
+ handoff goes through `iapeer tray attach-term`, which writes a per-peer `.command`
32
+ launcher and `open`s it — `open <file>.command` hands the file to Terminal.app with
33
+ **no Automation/Accessibility permission prompt**. This deliberately avoids SwiftBar's
34
+ own `terminal=true`, whose Terminal launch drives a `Cmd-T` System-Events keystroke that
35
+ silently no-ops unless SwiftBar holds Accessibility permission. **Launchd-managed infra
36
+ peers (telegram / notifier, `🔒`) are not pty-attachable** — their rows are plain status
37
+ lines. Lifecycle actions (Wake / Stop / Start / New / Interrupt / Refresh / Compact) live
38
+ in the **Manage** submenu, each a `POST /fleet/v1/peers/<peer>/<cmd>` via `iapeer tray cmd`.
34
39
 
35
40
  The list updates itself: the plugin is **streamable** — it subscribes to the event stream
36
41
  and re-fetches the snapshot on every change (a peer waking, an operator stop/start, a
37
- death), coalescing bursts. A ~15 s heartbeat keeps ages fresh and self-heals a missed
38
- event; the stream reconnects across daemon restarts. No manual refresh needed (there is a
39
- Refresh item anyway).
42
+ death), coalescing bursts. A heartbeat keeps ages fresh and self-heals a missed event; the
43
+ stream reconnects across daemon restarts. Every emitted menu block is prefixed with the
44
+ `~~~` stream separator (SwiftBar's default leading-separator mode = replace), including the
45
+ first — otherwise a manual Refresh, which `terminate()`s and re-`invoke()`s the plugin
46
+ without clearing SwiftBar's held content, would append the restarted block and render the
47
+ whole dashboard twice.
40
48
 
41
49
  ## Verbs
42
50
 
@@ -25,15 +25,23 @@ SwiftBar.app, если его нет.
25
25
  `●` живой · `○` спит · `✕` остановлен — зелёный, если хоть один рантайм живой. Бейджи:
26
26
  `👤` подключён человек, `🔒` под launchd (H4 read-only), `⏳N` глубина очереди эфемерного
27
27
  пира.
28
- - **Клик по пиру → Terminal.app с `iapeer attach <peer>`.** Attach — терминальная передача,
29
- которую API намеренно оставляет клиенту (PTY внутри GUI вне рамок); подменю той же строки
30
- несёт lifecycle-действия (Wake / Stop / Start / New / Interrupt / Refresh / Compact),
31
- каждое — `POST /fleet/v1/peers/<peer>/<cmd>` через `iapeer tray cmd`.
28
+ - **Клик по агент-пиру → Terminal.app с `iapeer attach <peer>`.** Строка сама и есть действие
29
+ attach (без подменю — клик аттачит, а не раскрывает). Передача идёт через `iapeer tray
30
+ attach-term`: пишет per-peer `.command`-лаунчер и `open`'ит его — `open <файл>.command`
31
+ отдаёт файл Terminal.app **без запроса Automation/Accessibility**. Это намеренно обходит
32
+ родной `terminal=true` SwiftBar, чей запуск Terminal бьёт `Cmd-T` кейстроком через System
33
+ Events и молча не срабатывает без права Accessibility у SwiftBar. **Launchd-инфра-пиры
34
+ (telegram / notifier, `🔒`) не pty-аттачатся** — их строки просто статус. Lifecycle-действия
35
+ (Wake / Stop / Start / New / Interrupt / Refresh / Compact) — в подменю **Manage**, каждое —
36
+ `POST /fleet/v1/peers/<peer>/<cmd>` через `iapeer tray cmd`.
32
37
 
33
38
  Список обновляется сам: плагин **streamable** — подписан на поток событий и перечитывает
34
39
  снапшот на каждое изменение (пробуждение пира, стоп/старт оператором, смерть), склеивая
35
- всплески. Хартбит ~15 с держит возрасты свежими и самозалечивает пропущенное событие; поток
36
- переподключается через рестарты демона. Ручной refresh не нужен (пункт Refresh всё же есть).
40
+ всплески. Хартбит держит возрасты свежими и самозалечивает пропущенное событие; поток
41
+ переподключается через рестарты демона. Каждый выдаваемый блок меню предваряется сепаратором
42
+ `~~~` (дефолтный leading-режим SwiftBar = замена), включая первый — иначе ручной Refresh,
43
+ который `terminate()`'ит и заново `invoke()`'ит плагин НЕ очищая удерживаемый SwiftBar контент,
44
+ пришил бы перезапущенный блок и отрисовал бы весь дашборд дважды.
37
45
 
38
46
  ## Verb'ы
39
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.66",
3
+ "version": "0.4.68",
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
@@ -2505,6 +2505,21 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2505
2505
  out(await tray.renderTrayOnce(env))
2506
2506
  return 0
2507
2507
  }
2508
+ case 'attach-term': {
2509
+ // Terminal handoff for a tray click: open a system Terminal running
2510
+ // `iapeer attach <peer>` via an `open`ed `.command` (no Accessibility/
2511
+ // Automation TCC — unlike SwiftBar's own terminal=true).
2512
+ const peer = positionals[1]
2513
+ if (!peer) return argErr(errOut, 'usage: iapeer tray attach-term <peer> [runtime]')
2514
+ try {
2515
+ const r = tray.trayAttachTerm({ env, personality: peer, runtime: positionals[2] })
2516
+ out(`opening Terminal → iapeer attach ${peer} (${r.cmdFile})\n`)
2517
+ } catch (e) {
2518
+ errOut(`tray attach-term: ${e instanceof Error ? e.message : String(e)}\n`)
2519
+ return 1
2520
+ }
2521
+ return 0
2522
+ }
2508
2523
  case 'cmd': {
2509
2524
  const command = positionals[1]
2510
2525
  const peer = positionals[2]
package/src/tray/index.ts CHANGED
@@ -72,14 +72,20 @@ export async function streamTray(
72
72
  out: (s: string) => void = s => process.stdout.write(s),
73
73
  signal?: AbortSignal,
74
74
  ): Promise<void> {
75
- let first = true
76
75
  // Serialize all emits through a promise chain so a `~~~`+block is never interleaved.
77
76
  let chain: Promise<void> = Promise.resolve()
78
77
  const emit = (): Promise<void> => {
79
78
  chain = chain.then(async () => {
80
79
  const block = await renderTrayOnce(env)
81
- out(first ? block : `~~~\n${block}`)
82
- first = false
80
+ // EVERY block — including the first — is prefixed with the `~~~` stream separator.
81
+ // SwiftBar's default (leading-separator) handler sets content = <text after the
82
+ // last ~~~>, i.e. REPLACE; a block WITHOUT a leading ~~~ is APPENDED to the
83
+ // current content. On a refresh (SwiftBar terminate()s the process WITHOUT
84
+ // clearing content, then re-invoke()s), the restarted process's first block would
85
+ // otherwise append to the stale pre-refresh menu → the whole dashboard renders
86
+ // twice. Always leading with ~~~ makes every emit a clean replace. (root: SwiftBar
87
+ // v2.0.1 StreamablePlugin.onOutputUpdate.)
88
+ out(`~~~\n${block}`)
83
89
  })
84
90
  return chain
85
91
  }
@@ -100,9 +100,10 @@ export function buildPluginShim(binPath: string): string {
100
100
  '# <xbar.author>iapeer</xbar.author>',
101
101
  '# <xbar.desc>iapeer multi-agent fleet — live peer dashboard over the daemon fleet API.</xbar.desc>',
102
102
  '# <swiftbar.type>streamable</swiftbar.type>',
103
- '# <swiftbar.hideRunInTerminal>true</swiftbar.hideRunInTerminal>',
104
- '# <swiftbar.hideLastUpdated>true</swiftbar.hideLastUpdated>',
105
- '# <swiftbar.hideDisablePlugin>true</swiftbar.hideDisablePlugin>',
103
+ // NB: deliberately NOT hiding SwiftBar's own service items (Run-in-Terminal /
104
+ // Disable / Last-Updated / Preferences / Refresh). They are the right-click "do
105
+ // stuff" menu the owner expects; hiding them made the icon feel dead. Minimalism is
106
+ // for OUR content (status + peer rows), not SwiftBar's utility menu.
106
107
  // The dashboard ONLY ever talks to the local daemon (unix socket / loopback), so it
107
108
  // must never route through an HTTP proxy. Bun's fetch caches proxy config at process
108
109
  // start and ignores NO_PROXY for loopback, so clearing the vars must happen in the
@@ -259,6 +260,36 @@ export function uninstallTray(
259
260
  return { removed, refreshed }
260
261
  }
261
262
 
263
+ /**
264
+ * Open a system Terminal attached to a peer WITHOUT SwiftBar's `terminal=true`. SwiftBar
265
+ * v2's Terminal.app launch drives a `Cmd-T` keystroke via System Events, which silently
266
+ * no-ops unless SwiftBar holds Accessibility permission — so tray attach clicks appeared
267
+ * dead even after the Automation→Terminal grant. Instead: write a per-peer `.command`
268
+ * launcher and `open` it. `open <file>.command` hands the file to Terminal.app (its
269
+ * default handler) and runs it — a document-open, needing NO Automation/Accessibility
270
+ * TCC. This is the tray's terminal handoff (docs/15: attach is client-side). The peer
271
+ * name is validated (it is interpolated into a shell script). Returns the launcher path.
272
+ */
273
+ export function trayAttachTerm(opts: {
274
+ env?: NodeJS.ProcessEnv
275
+ personality: string
276
+ runtime?: string
277
+ run?: Runner
278
+ }): { cmdFile: string } {
279
+ const env = opts.env ?? process.env
280
+ const run = opts.run ?? defaultRun
281
+ const p = opts.personality
282
+ if (!/^[a-z][a-z0-9-]*$/.test(p)) throw new Error(`invalid peer name "${p}"`)
283
+ const rt = opts.runtime && /^[a-z][a-z0-9-]*$/.test(opts.runtime) ? opts.runtime : undefined
284
+ const binPath = iapeerBinPath(env)
285
+ const dir = join(resolveGlobalRoot(env), 'tray')
286
+ mkdirSync(dir, { recursive: true })
287
+ const cmdFile = join(dir, `attach-${p}.command`)
288
+ writeFileSync(cmdFile, `#!/bin/bash\nexec "${binPath}" attach ${p}${rt ? ` ${rt}` : ''}\n`, { mode: 0o755 })
289
+ run('open', [cmdFile])
290
+ return { cmdFile }
291
+ }
292
+
262
293
  export interface TrayStatus {
263
294
  daemon: { fleet: boolean; version?: string; sock?: string; tcp?: string }
264
295
  swiftbar: { installed: boolean; pluginDir?: string }
@@ -79,11 +79,6 @@ function line(depth: number, text: string, p: Record<string, string | number | b
79
79
  return `${'--'.repeat(depth)}${text}${params(p)}`
80
80
  }
81
81
 
82
- /** A menu separator at submenu `depth`: the `--`×depth prefix + `---` (SwiftBar: a
83
- * top-level separator is `---`, a depth-1 one is `-----`, etc.). */
84
- function separator(depth: number): string {
85
- return `${'--'.repeat(depth)}---`
86
- }
87
82
 
88
83
  // ── time helpers ────────────────────────────────────────────────────────────────
89
84
 
@@ -154,17 +149,25 @@ export function renderSwiftBar(snapshot: TraySnapshot, opts: RenderOptions): str
154
149
  if (providers) lines.push(line(0, providers, { color: COLOR.meta }))
155
150
  lines.push('---')
156
151
 
157
- // ── peers ──
152
+ // ── peers (each row is a DIRECT attach action — click → Terminal) ──
158
153
  if (peers.length === 0) {
159
154
  lines.push(line(0, 'no peers registered', { color: COLOR.meta }))
160
155
  }
161
156
  for (const p of peers) {
162
- lines.push(...renderPeerRow(p, bin, now))
157
+ lines.push(renderPeerRow(p, bin, now))
163
158
  }
164
159
 
160
+ // ── Manage submenu (lifecycle commands per peer; kept off the rows so a row click
161
+ // attaches instead of just expanding a submenu) ──
162
+ lines.push('---')
163
+ lines.push(...renderManage(peers, bin))
164
+
165
165
  // ── footer ──
166
+ // No explicit "Refresh" item: the plugin is streamable + SSE-driven, so the menu is
167
+ // always current on open — a manual refresh gives the user nothing and (being a
168
+ // SwiftBar refresh) just closes the dropdown. The emergency "restart the plugin" lever
169
+ // lives in SwiftBar's own service menu (right-click → Refresh).
166
170
  lines.push('---')
167
- lines.push(line(0, 'Refresh', { refresh: true, sfimage: 'arrow.clockwise' }))
168
171
  lines.push(line(0, `iapeer · fleet dashboard`, { color: COLOR.meta, size: 11 }))
169
172
  return lines.join('\n') + '\n'
170
173
  }
@@ -184,7 +187,23 @@ function renderProviders(host: TraySnapshot['host']): string {
184
187
  return seg.join(' · ')
185
188
  }
186
189
 
187
- function renderPeerRow(p: TrayPeer, bin: string, now: number): string[] {
190
+ /** A peer is attachable when it is NOT launchd-managed: launchd-owned infra peers
191
+ * (telegram / notifier) have no attachable pty session, so their rows carry no attach
192
+ * action (a plain status line). Agent peers (claude / codex) are attachable. */
193
+ function attachable(p: TrayPeer): boolean {
194
+ return !p.launchd_managed
195
+ }
196
+
197
+ /**
198
+ * One peer row. For an ATTACHABLE peer the row IS a DIRECT attach action (no submenu,
199
+ * so a click attaches instead of merely expanding): clicking runs `iapeer tray
200
+ * attach-term <peer>` in the BACKGROUND (terminal=false), which opens a `.command` via
201
+ * `open` — a system-Terminal handoff that needs NO Accessibility/Automation TCC. This
202
+ * deliberately AVOIDS SwiftBar's own `terminal=true`, whose Terminal.app launch drives a
203
+ * `Cmd-T` System-Events keystroke that silently no-ops without Accessibility permission.
204
+ * Infra (launchd) rows are plain status lines. Lifecycle lives in the Manage submenu.
205
+ */
206
+ function renderPeerRow(p: TrayPeer, bin: string, now: number): string {
188
207
  const agg = aggregate(p)
189
208
  const detail = p.runtimes.map(r => `${r.runtime}${GLYPH[r.status]}`).join(' ')
190
209
  const badges =
@@ -194,36 +213,31 @@ function renderPeerRow(p: TrayPeer, bin: string, now: number): string[] {
194
213
  const ageStr = p.last_active_ms ? ` ${fmtAge(p.last_active_ms, now)}` : ''
195
214
  const label = `${p.personality} ${detail}${badges}${ageStr}`
196
215
  const rowColor = agg === 'live' ? COLOR.live : COLOR.meta
216
+ if (!attachable(p)) return line(0, label, { color: rowColor })
217
+ return line(0, label, { color: rowColor, bash: bin, param1: 'tray', param2: 'attach-term', param3: p.personality, terminal: false })
218
+ }
197
219
 
198
- const out: string[] = []
199
- // The peer row IS the attach action: click → Terminal.app running `iapeer attach
200
- // <peer>` (attach resolves its own runtime — default-anchored last-active, same as
201
- // the CLI). terminal=true is the terminal-handoff docs/15 keeps client-side.
202
- out.push(
203
- line(0, label, {
204
- color: rowColor,
205
- bash: bin,
206
- param1: 'attach',
207
- param2: p.personality,
208
- terminal: true,
209
- }),
210
- )
211
- // Lifecycle controls live in a submenu (they route through the fleet command
212
- // endpoints via `iapeer tray cmd …` — dogfooding POST /fleet/v1/peers/<p>/<cmd>).
213
- out.push(line(1, 'Attach…', { bash: bin, param1: 'attach', param2: p.personality, terminal: true, sfimage: 'terminal' }))
214
- out.push(separator(1))
215
- for (const c of MANAGE_COMMANDS) {
216
- out.push(
217
- line(1, c.label, {
218
- bash: bin,
219
- param1: 'tray',
220
- param2: 'cmd',
221
- param3: c.cmd,
222
- param4: p.personality,
223
- terminal: false,
224
- refresh: true,
225
- }),
226
- )
220
+ /** The Manage submenu: one sub-submenu per peer with its lifecycle commands, each a
221
+ * `iapeer tray cmd <c> <peer>` → POST /fleet/v1/peers/<peer>/<c> (terminal=false,
222
+ * refresh to reflect the outcome). Kept OFF the peer rows so a row click attaches. */
223
+ function renderManage(peers: TrayPeer[], bin: string): string[] {
224
+ const out: string[] = [line(0, 'Manage', { sfimage: 'slider.horizontal.3' })]
225
+ for (const p of peers) {
226
+ out.push(line(1, p.personality))
227
+ for (const c of MANAGE_COMMANDS) {
228
+ // No refresh=true: the SSE stream reflects the command's lifecycle event on its
229
+ // own (streamable is always fresh); a refresh would only restart the stream.
230
+ out.push(
231
+ line(2, c.label, {
232
+ bash: bin,
233
+ param1: 'tray',
234
+ param2: 'cmd',
235
+ param3: c.cmd,
236
+ param4: p.personality,
237
+ terminal: false,
238
+ }),
239
+ )
240
+ }
227
241
  }
228
242
  return out
229
243
  }