@agfpd/iapeer 0.4.66 → 0.4.67

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.67",
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
  }
@@ -259,6 +259,36 @@ export function uninstallTray(
259
259
  return { removed, refreshed }
260
260
  }
261
261
 
262
+ /**
263
+ * Open a system Terminal attached to a peer WITHOUT SwiftBar's `terminal=true`. SwiftBar
264
+ * v2's Terminal.app launch drives a `Cmd-T` keystroke via System Events, which silently
265
+ * no-ops unless SwiftBar holds Accessibility permission — so tray attach clicks appeared
266
+ * dead even after the Automation→Terminal grant. Instead: write a per-peer `.command`
267
+ * launcher and `open` it. `open <file>.command` hands the file to Terminal.app (its
268
+ * default handler) and runs it — a document-open, needing NO Automation/Accessibility
269
+ * TCC. This is the tray's terminal handoff (docs/15: attach is client-side). The peer
270
+ * name is validated (it is interpolated into a shell script). Returns the launcher path.
271
+ */
272
+ export function trayAttachTerm(opts: {
273
+ env?: NodeJS.ProcessEnv
274
+ personality: string
275
+ runtime?: string
276
+ run?: Runner
277
+ }): { cmdFile: string } {
278
+ const env = opts.env ?? process.env
279
+ const run = opts.run ?? defaultRun
280
+ const p = opts.personality
281
+ if (!/^[a-z][a-z0-9-]*$/.test(p)) throw new Error(`invalid peer name "${p}"`)
282
+ const rt = opts.runtime && /^[a-z][a-z0-9-]*$/.test(opts.runtime) ? opts.runtime : undefined
283
+ const binPath = iapeerBinPath(env)
284
+ const dir = join(resolveGlobalRoot(env), 'tray')
285
+ mkdirSync(dir, { recursive: true })
286
+ const cmdFile = join(dir, `attach-${p}.command`)
287
+ writeFileSync(cmdFile, `#!/bin/bash\nexec "${binPath}" attach ${p}${rt ? ` ${rt}` : ''}\n`, { mode: 0o755 })
288
+ run('open', [cmdFile])
289
+ return { cmdFile }
290
+ }
291
+
262
292
  export interface TrayStatus {
263
293
  daemon: { fleet: boolean; version?: string; sock?: string; tcp?: string }
264
294
  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,14 +149,19 @@ 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
166
  lines.push('---')
167
167
  lines.push(line(0, 'Refresh', { refresh: true, sfimage: 'arrow.clockwise' }))
@@ -184,7 +184,23 @@ function renderProviders(host: TraySnapshot['host']): string {
184
184
  return seg.join(' · ')
185
185
  }
186
186
 
187
- function renderPeerRow(p: TrayPeer, bin: string, now: number): string[] {
187
+ /** A peer is attachable when it is NOT launchd-managed: launchd-owned infra peers
188
+ * (telegram / notifier) have no attachable pty session, so their rows carry no attach
189
+ * action (a plain status line). Agent peers (claude / codex) are attachable. */
190
+ function attachable(p: TrayPeer): boolean {
191
+ return !p.launchd_managed
192
+ }
193
+
194
+ /**
195
+ * One peer row. For an ATTACHABLE peer the row IS a DIRECT attach action (no submenu,
196
+ * so a click attaches instead of merely expanding): clicking runs `iapeer tray
197
+ * attach-term <peer>` in the BACKGROUND (terminal=false), which opens a `.command` via
198
+ * `open` — a system-Terminal handoff that needs NO Accessibility/Automation TCC. This
199
+ * deliberately AVOIDS SwiftBar's own `terminal=true`, whose Terminal.app launch drives a
200
+ * `Cmd-T` System-Events keystroke that silently no-ops without Accessibility permission.
201
+ * Infra (launchd) rows are plain status lines. Lifecycle lives in the Manage submenu.
202
+ */
203
+ function renderPeerRow(p: TrayPeer, bin: string, now: number): string {
188
204
  const agg = aggregate(p)
189
205
  const detail = p.runtimes.map(r => `${r.runtime}${GLYPH[r.status]}`).join(' ')
190
206
  const badges =
@@ -194,36 +210,30 @@ function renderPeerRow(p: TrayPeer, bin: string, now: number): string[] {
194
210
  const ageStr = p.last_active_ms ? ` ${fmtAge(p.last_active_ms, now)}` : ''
195
211
  const label = `${p.personality} ${detail}${badges}${ageStr}`
196
212
  const rowColor = agg === 'live' ? COLOR.live : COLOR.meta
213
+ if (!attachable(p)) return line(0, label, { color: rowColor })
214
+ return line(0, label, { color: rowColor, bash: bin, param1: 'tray', param2: 'attach-term', param3: p.personality, terminal: false })
215
+ }
197
216
 
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
- )
217
+ /** The Manage submenu: one sub-submenu per peer with its lifecycle commands, each a
218
+ * `iapeer tray cmd <c> <peer>` → POST /fleet/v1/peers/<peer>/<c> (terminal=false,
219
+ * refresh to reflect the outcome). Kept OFF the peer rows so a row click attaches. */
220
+ function renderManage(peers: TrayPeer[], bin: string): string[] {
221
+ const out: string[] = [line(0, 'Manage', { sfimage: 'slider.horizontal.3' })]
222
+ for (const p of peers) {
223
+ out.push(line(1, p.personality))
224
+ for (const c of MANAGE_COMMANDS) {
225
+ out.push(
226
+ line(2, c.label, {
227
+ bash: bin,
228
+ param1: 'tray',
229
+ param2: 'cmd',
230
+ param3: c.cmd,
231
+ param4: p.personality,
232
+ terminal: false,
233
+ refresh: true,
234
+ }),
235
+ )
236
+ }
227
237
  }
228
238
  return out
229
239
  }