@agfpd/iapeer 0.4.65 → 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.65",
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]
@@ -351,6 +351,26 @@ export function daemonDiscoveryPath(options: StorageOptions = {}): string {
351
351
  return join(pluginStateDir('iapeer', options), 'router.json')
352
352
  }
353
353
 
354
+ /**
355
+ * Normalize an HTTP request-target to origin-form (path[+query]) for path routing.
356
+ * A request that traversed an HTTP proxy arrives in ABSOLUTE-FORM (RFC 7230 §5.3.2:
357
+ * "GET http://host/path"), and Node exposes the whole target as `req.url` — so a
358
+ * path check like `req.url.startsWith('/fleet/')` misses and the request wrongly
359
+ * falls to the MCP catch-all (→ 406). Any client with HTTP(S)_PROXY set is affected
360
+ * (Bun's fetch proxies loopback even when NO_PROXY lists 127.0.0.1). Origin-form and
361
+ * unparseable targets pass through unchanged. Exported for tests. */
362
+ export function normalizeRequestTarget(url: string | undefined): string | undefined {
363
+ if (url && /^https?:\/\//i.test(url)) {
364
+ try {
365
+ const u = new URL(url)
366
+ return u.pathname + u.search
367
+ } catch {
368
+ /* unparseable — leave as received */
369
+ }
370
+ }
371
+ return url
372
+ }
373
+
354
374
  export interface DaemonHandle {
355
375
  socketPath?: string
356
376
  host?: string
@@ -498,6 +518,18 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
498
518
  // gate first, then the fleet-API branch (when wired), then stateless MCP —
499
519
  // a fresh Server + transport per request, fully isolated.
500
520
  const handle = (req: IncomingMessage, res: ServerResponse): void => {
521
+ // RFC 7230 §5.3.2 request-target hygiene (FIRST — before ANY path routing). A
522
+ // request that traversed an HTTP proxy carries the ABSOLUTE-FORM target
523
+ // ("GET http://127.0.0.1:8765/fleet/v1/snapshot"), and Node puts that whole string
524
+ // in req.url — so path routing (/fleet vs /mcp) would miss and fall through to MCP
525
+ // (→ a 406 "must accept text/event-stream" for a plain GET). This bites any client
526
+ // whose environment has HTTP(S)_PROXY set: Bun's fetch (and others) route loopback
527
+ // through the proxy even when NO_PROXY lists 127.0.0.1 (Bun ignores NO_PROXY). We
528
+ // can't fix every client, so the daemon normalizes to origin-form here — a proxied
529
+ // request then routes identically to a direct one, for BOTH the fleet and MCP
530
+ // surfaces. (live-proven: HTTP_PROXY pointed at the daemon itself reproduced the
531
+ // 406; normalization restores /fleet routing.)
532
+ req.url = normalizeRequestTarget(req.url)
501
533
  // H8 bearer layer (FIRST, before any MCP/fleet work): when a token is configured,
502
534
  // reject anything not carrying `Authorization: Bearer <token>`. Off when unset.
503
535
  if (bearer && req.headers.authorization !== `Bearer ${bearer}`) {
@@ -80,6 +80,7 @@ export class FleetUnreachableError extends Error {
80
80
  }
81
81
 
82
82
  interface FetchAttempt {
83
+ transport: 'unix' | 'tcp'
83
84
  url: string
84
85
  init: RequestInit & { unix?: string }
85
86
  }
@@ -89,36 +90,66 @@ interface FetchAttempt {
89
90
  function attempts(addr: FleetAddress, path: string, base: RequestInit, env: NodeJS.ProcessEnv): FetchAttempt[] {
90
91
  const headers = { ...(base.headers as Record<string, string> | undefined), ...bearerHeader(env) }
91
92
  const list: FetchAttempt[] = []
92
- if (addr.sock) list.push({ url: `http://iapeer${path}`, init: { ...base, headers, unix: addr.sock } })
93
- if (addr.tcp) list.push({ url: `${addr.tcp}${path}`, init: { ...base, headers } })
93
+ if (addr.sock) list.push({ transport: 'unix', url: `http://iapeer${path}`, init: { ...base, headers, unix: addr.sock } })
94
+ if (addr.tcp) list.push({ transport: 'tcp', url: `${addr.tcp}${path}`, init: { ...base, headers } })
94
95
  return list
95
96
  }
96
97
 
98
+ function debug(env: NodeJS.ProcessEnv, msg: string): void {
99
+ if (env.IAPEER_TRAY_DEBUG === '1') process.stderr.write(`[tray] ${msg}\n`)
100
+ }
101
+
102
+ /** The response of a fleet request, carrying the ACTUAL wire transport + URL used —
103
+ * so a failure surfaces where the request really went (a proxied request lands on a
104
+ * different route than the client's intended path). */
105
+ export interface FleetResponse {
106
+ res: Response
107
+ transport: string
108
+ url: string
109
+ }
110
+
111
+ /**
112
+ * Try each transport (unix first, TCP second) until one answers. On a THROW, move to
113
+ * the next transport. On a NON-OK status the behavior depends on `retryBadStatus`:
114
+ * GETs (idempotent) retry on the OTHER transport (a single misrouting transport — e.g.
115
+ * one behind a proxy — cannot then sink the client); POSTs do NOT (never re-execute a
116
+ * command). Returns the actual transport+URL for honest diagnostics.
117
+ */
97
118
  async function fetchFleet(
98
119
  addr: FleetAddress,
99
120
  path: string,
100
121
  init: RequestInit,
101
122
  env: NodeJS.ProcessEnv,
102
- ): Promise<Response> {
123
+ opts: { retryBadStatus?: boolean } = {},
124
+ ): Promise<FleetResponse> {
103
125
  const tries = attempts(addr, path, init, env)
104
126
  if (tries.length === 0) throw new FleetUnreachableError('no advertised address (router.json missing sock/tcp)')
105
127
  let lastErr = ''
128
+ let firstBad: FleetResponse | undefined
106
129
  for (const a of tries) {
130
+ let res: Response
107
131
  try {
108
- const res = await fetch(a.url, a.init as RequestInit)
109
- return res
132
+ res = await fetch(a.url, a.init as RequestInit)
110
133
  } catch (e) {
111
134
  lastErr = e instanceof Error ? e.message : String(e)
112
- // try the next transport
135
+ debug(env, `${a.transport} ${a.url} → THREW ${lastErr}`)
136
+ continue
113
137
  }
138
+ debug(env, `${a.transport} ${a.url} → ${res.status}`)
139
+ const wrapped: FleetResponse = { res, transport: a.transport, url: a.url }
140
+ if (res.ok) return wrapped
141
+ if (!firstBad) firstBad = wrapped
142
+ if (!opts.retryBadStatus) return wrapped // POST: return the error as-is (no re-execute)
143
+ // GET: fall through to the next transport; firstBad is returned if none succeed
114
144
  }
115
- throw new FleetUnreachableError(lastErr || 'all transports failed')
145
+ if (firstBad) return firstBad
146
+ throw new FleetUnreachableError(`${lastErr || 'all transports failed'} (tried ${tries.map(t => `${t.transport}=${t.url}`).join(', ')})`)
116
147
  }
117
148
 
118
149
  export async function fleetGetJson<T = unknown>(addr: FleetAddress, path: string, env: NodeJS.ProcessEnv): Promise<T> {
119
- const res = await fetchFleet(addr, path, { method: 'GET' }, env)
150
+ const { res, transport, url } = await fetchFleet(addr, path, { method: 'GET' }, env, { retryBadStatus: true })
120
151
  const text = await res.text()
121
- if (!res.ok) throw new Error(`GET ${path} → ${res.status}: ${text.slice(0, 300)}`)
152
+ if (!res.ok) throw new Error(`GET ${url} (${transport}) → ${res.status}: ${text.slice(0, 300)}`)
122
153
  return JSON.parse(text) as T
123
154
  }
124
155
 
@@ -128,11 +159,12 @@ export async function fleetPostJson<T = unknown>(
128
159
  body: Record<string, unknown>,
129
160
  env: NodeJS.ProcessEnv,
130
161
  ): Promise<{ status: number; body: T }> {
131
- const res = await fetchFleet(
162
+ const { res } = await fetchFleet(
132
163
  addr,
133
164
  path,
134
165
  { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) },
135
166
  env,
167
+ { retryBadStatus: false }, // a command must never re-execute on the other transport
136
168
  )
137
169
  const text = await res.text()
138
170
  let parsed: T
@@ -170,13 +202,14 @@ export async function streamFleetEvents(
170
202
  opts: { replay?: number; signal?: AbortSignal; env: NodeJS.ProcessEnv },
171
203
  ): Promise<void> {
172
204
  const replay = Math.max(0, Math.min(500, opts.replay ?? 0))
173
- const res = await fetchFleet(
205
+ const { res, transport, url } = await fetchFleet(
174
206
  addr,
175
207
  `/fleet/v1/events?replay=${replay}`,
176
208
  { method: 'GET', headers: { accept: 'text/event-stream' }, signal: opts.signal },
177
209
  opts.env,
210
+ { retryBadStatus: true },
178
211
  )
179
- if (!res.ok || !res.body) throw new Error(`events stream → ${res.status}`)
212
+ if (!res.ok || !res.body) throw new Error(`events stream ${url} (${transport}) → ${res.status}`)
180
213
  const reader = res.body.getReader()
181
214
  const decoder = new TextDecoder()
182
215
  let buf = ''
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
  }
@@ -69,7 +69,17 @@ function setSwiftBarPluginDir(dir: string, run: Runner): RunResult {
69
69
  }
70
70
 
71
71
  function installSwiftBarApp(run: Runner): RunResult {
72
- return run('brew', ['install', '--cask', 'swiftbar'])
72
+ // --no-quarantine: an automated install has no one to click through the Gatekeeper
73
+ // "app downloaded from the Internet" dialog — quarantine would stall first launch.
74
+ return run('brew', ['install', '--cask', '--no-quarantine', 'swiftbar'])
75
+ }
76
+
77
+ /** Strip any residual quarantine xattr from the app (belt-and-suspenders: an already
78
+ * brew-installed SwiftBar, or a brew that quarantined despite the flag, would else
79
+ * block first launch on a headless / no-click box). Best-effort. */
80
+ function dequarantineApp(run: Runner): void {
81
+ const app = SWIFTBAR_APP_PATHS.find(p => existsSync(p))
82
+ if (app) run('xattr', ['-dr', 'com.apple.quarantine', app])
73
83
  }
74
84
 
75
85
  function launchSwiftBar(run: Runner): void {
@@ -93,6 +103,11 @@ export function buildPluginShim(binPath: string): string {
93
103
  '# <swiftbar.hideRunInTerminal>true</swiftbar.hideRunInTerminal>',
94
104
  '# <swiftbar.hideLastUpdated>true</swiftbar.hideLastUpdated>',
95
105
  '# <swiftbar.hideDisablePlugin>true</swiftbar.hideDisablePlugin>',
106
+ // The dashboard ONLY ever talks to the local daemon (unix socket / loopback), so it
107
+ // must never route through an HTTP proxy. Bun's fetch caches proxy config at process
108
+ // start and ignores NO_PROXY for loopback, so clearing the vars must happen in the
109
+ // ENV before the binary launches (a runtime delete is too late). unset here.
110
+ 'unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy',
96
111
  `exec "${binPath}" tray render --stream`,
97
112
  '',
98
113
  ].join('\n')
@@ -195,6 +210,7 @@ export function installTray(opts: InstallTrayOptions = {}): InstallTrayResult {
195
210
  // 4 — launch + refresh
196
211
  let launched = false
197
212
  if (opts.launch && present) {
213
+ dequarantineApp(run)
198
214
  launchSwiftBar(run)
199
215
  refreshSwiftBar(run)
200
216
  launched = true
@@ -243,6 +259,36 @@ export function uninstallTray(
243
259
  return { removed, refreshed }
244
260
  }
245
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
+
246
292
  export interface TrayStatus {
247
293
  daemon: { fleet: boolean; version?: string; sock?: string; tcp?: string }
248
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
  }