@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 +16 -8
- package/docs/ru/16-tray.md +14 -6
- package/package.json +1 -1
- package/src/cli/index.ts +15 -0
- package/src/daemon/index.ts +32 -0
- package/src/tray/client.ts +45 -12
- package/src/tray/index.ts +9 -3
- package/src/tray/install.ts +47 -1
- package/src/tray/render.ts +47 -37
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
package/docs/ru/16-tray.md
CHANGED
|
@@ -25,15 +25,23 @@ SwiftBar.app, если его нет.
|
|
|
25
25
|
`●` живой · `○` спит · `✕` остановлен — зелёный, если хоть один рантайм живой. Бейджи:
|
|
26
26
|
`👤` подключён человек, `🔒` под launchd (H4 read-only), `⏳N` глубина очереди эфемерного
|
|
27
27
|
пира.
|
|
28
|
-
- **Клик по
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
всплески. Хартбит
|
|
36
|
-
переподключается через рестарты демона.
|
|
40
|
+
всплески. Хартбит держит возрасты свежими и самозалечивает пропущенное событие; поток
|
|
41
|
+
переподключается через рестарты демона. Каждый выдаваемый блок меню предваряется сепаратором
|
|
42
|
+
`~~~` (дефолтный leading-режим SwiftBar = замена), включая первый — иначе ручной Refresh,
|
|
43
|
+
который `terminate()`'ит и заново `invoke()`'ит плагин НЕ очищая удерживаемый SwiftBar контент,
|
|
44
|
+
пришил бы перезапущенный блок и отрисовал бы весь дашборд дважды.
|
|
37
45
|
|
|
38
46
|
## Verb'ы
|
|
39
47
|
|
package/package.json
CHANGED
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/daemon/index.ts
CHANGED
|
@@ -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}`) {
|
package/src/tray/client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
82
|
-
|
|
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
|
}
|
package/src/tray/install.ts
CHANGED
|
@@ -69,7 +69,17 @@ function setSwiftBarPluginDir(dir: string, run: Runner): RunResult {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function installSwiftBarApp(run: Runner): RunResult {
|
|
72
|
-
|
|
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 }
|
package/src/tray/render.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
out.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
}
|