@agfpd/iapeer 0.4.63 → 0.4.65

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.
@@ -0,0 +1,233 @@
1
+ // tray/client — the fleet-API HTTP client. Ф1 of iapeer-tray is the FIRST EXTERNAL
2
+ // client of the daemon's fleet API, and it is written ONLY against the normative
3
+ // contract docs/15-fleet-api.md — NOT against daemon source. This module therefore
4
+ // imports NOTHING from src/daemon: it discovers the daemon via router.json, speaks
5
+ // plain HTTP+JSON (+SSE) over the two advertised listeners, and models the snapshot
6
+ // with its OWN local interfaces (client obligation 2: unknown fields ignored). If the
7
+ // doc and the daemon ever diverge, this client feels it — that is the whole point of
8
+ // dogfooding the contract through a real second client.
9
+ //
10
+ // Transport: prefer the 0600 unix socket (same-uid, same trust class as the CLI),
11
+ // fall back to the TCP loopback. Bun's fetch carries the `unix` option; the compiled
12
+ // `iapeer` binary bundles the Bun runtime, so this works when SwiftBar execs it.
13
+
14
+ import { readFileSync } from 'fs'
15
+ import { join } from 'path'
16
+ import { pluginStateDir, type StorageOptions } from '../storage/index.ts'
17
+
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+ // Discovery — router.json (docs/15 §Surface and transport)
20
+ // ─────────────────────────────────────────────────────────────────────────────
21
+
22
+ export interface FleetAddress {
23
+ /** Unix-socket path (preferred). */
24
+ sock?: string
25
+ /** TCP origin, e.g. `http://127.0.0.1:8765` (derived from router.json `tcp`). */
26
+ tcp?: string
27
+ /** The daemon's advertised version, when present. */
28
+ version?: string
29
+ /** The fleet capability marker: `1` when the daemon serves /fleet/v1. Absent ⇒
30
+ * a pre-fleet daemon ⇒ the client must degrade (docs/15 §Discovery). */
31
+ fleet?: number
32
+ }
33
+
34
+ /** Read `~/.iapeer/state/iapeer/router.json`. Absent/unreadable ⇒ empty address
35
+ * (daemon down) — the caller renders the down state. Never throws. */
36
+ export function resolveFleetAddress(opts: StorageOptions = {}): FleetAddress {
37
+ const routerJson = join(pluginStateDir('iapeer', opts), 'router.json')
38
+ try {
39
+ const parsed = JSON.parse(readFileSync(routerJson, 'utf8')) as {
40
+ sock?: unknown
41
+ tcp?: unknown
42
+ version?: unknown
43
+ fleet?: unknown
44
+ }
45
+ const addr: FleetAddress = {}
46
+ if (typeof parsed.sock === 'string' && parsed.sock) addr.sock = parsed.sock
47
+ // router.json advertises the MCP url (…/mcp); the fleet surface lives on the SAME
48
+ // origin under /fleet/v1 — keep only the origin.
49
+ if (typeof parsed.tcp === 'string' && parsed.tcp) {
50
+ try {
51
+ addr.tcp = new URL(parsed.tcp).origin
52
+ } catch {
53
+ /* malformed tcp url → no tcp fallback */
54
+ }
55
+ }
56
+ if (typeof parsed.version === 'string') addr.version = parsed.version
57
+ if (typeof parsed.fleet === 'number') addr.fleet = parsed.fleet
58
+ return addr
59
+ } catch {
60
+ return {}
61
+ }
62
+ }
63
+
64
+ // ─────────────────────────────────────────────────────────────────────────────
65
+ // Requests — unix-first, TCP-fallback, optional bearer
66
+ // ─────────────────────────────────────────────────────────────────────────────
67
+
68
+ /** The bearer, when the host configured H8 auth (docs/15 §Auth). Read from the
69
+ * env the client runs under; open-local hosts have none. */
70
+ function bearerHeader(env: NodeJS.ProcessEnv): Record<string, string> {
71
+ const t = env.IAPEER_BEARER_TOKEN?.trim()
72
+ return t ? { authorization: `Bearer ${t}` } : {}
73
+ }
74
+
75
+ export class FleetUnreachableError extends Error {
76
+ constructor(readonly detail: string) {
77
+ super(`fleet daemon unreachable: ${detail}`)
78
+ this.name = 'FleetUnreachableError'
79
+ }
80
+ }
81
+
82
+ interface FetchAttempt {
83
+ url: string
84
+ init: RequestInit & { unix?: string }
85
+ }
86
+
87
+ /** Build the ordered transport attempts for a fleet path: unix socket first, TCP
88
+ * second. `unix` is Bun's fetch option (routes the HTTP request over the socket). */
89
+ function attempts(addr: FleetAddress, path: string, base: RequestInit, env: NodeJS.ProcessEnv): FetchAttempt[] {
90
+ const headers = { ...(base.headers as Record<string, string> | undefined), ...bearerHeader(env) }
91
+ 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 } })
94
+ return list
95
+ }
96
+
97
+ async function fetchFleet(
98
+ addr: FleetAddress,
99
+ path: string,
100
+ init: RequestInit,
101
+ env: NodeJS.ProcessEnv,
102
+ ): Promise<Response> {
103
+ const tries = attempts(addr, path, init, env)
104
+ if (tries.length === 0) throw new FleetUnreachableError('no advertised address (router.json missing sock/tcp)')
105
+ let lastErr = ''
106
+ for (const a of tries) {
107
+ try {
108
+ const res = await fetch(a.url, a.init as RequestInit)
109
+ return res
110
+ } catch (e) {
111
+ lastErr = e instanceof Error ? e.message : String(e)
112
+ // try the next transport
113
+ }
114
+ }
115
+ throw new FleetUnreachableError(lastErr || 'all transports failed')
116
+ }
117
+
118
+ 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)
120
+ const text = await res.text()
121
+ if (!res.ok) throw new Error(`GET ${path} → ${res.status}: ${text.slice(0, 300)}`)
122
+ return JSON.parse(text) as T
123
+ }
124
+
125
+ export async function fleetPostJson<T = unknown>(
126
+ addr: FleetAddress,
127
+ path: string,
128
+ body: Record<string, unknown>,
129
+ env: NodeJS.ProcessEnv,
130
+ ): Promise<{ status: number; body: T }> {
131
+ const res = await fetchFleet(
132
+ addr,
133
+ path,
134
+ { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) },
135
+ env,
136
+ )
137
+ const text = await res.text()
138
+ let parsed: T
139
+ try {
140
+ parsed = JSON.parse(text) as T
141
+ } catch {
142
+ parsed = text as unknown as T
143
+ }
144
+ return { status: res.status, body: parsed }
145
+ }
146
+
147
+ // ─────────────────────────────────────────────────────────────────────────────
148
+ // SSE — GET /fleet/v1/events (docs/15 §GET /fleet/v1/events)
149
+ // ─────────────────────────────────────────────────────────────────────────────
150
+
151
+ export interface SseEvent {
152
+ /** The event name (`event:` line) — the `ev` vocabulary. Unknown kinds MUST be
153
+ * tolerated by the caller (client obligation 1). */
154
+ event: string
155
+ /** Epoch-ms of the event (`id:` line) — for at-least-once dedup. */
156
+ id?: number
157
+ /** The parsed `data:` JSON payload. */
158
+ data: Record<string, unknown>
159
+ }
160
+
161
+ /**
162
+ * Open the SSE stream and invoke `onEvent` per frame until the signal aborts or the
163
+ * connection drops (then this resolves — the caller decides whether to reconnect).
164
+ * Parses the `event:` / `id:` / `data:` triplet; comments (`: connected`, `: hb`) are
165
+ * skipped. Deliberately minimal — no reconnect here (the render loop owns that).
166
+ */
167
+ export async function streamFleetEvents(
168
+ addr: FleetAddress,
169
+ onEvent: (e: SseEvent) => void,
170
+ opts: { replay?: number; signal?: AbortSignal; env: NodeJS.ProcessEnv },
171
+ ): Promise<void> {
172
+ const replay = Math.max(0, Math.min(500, opts.replay ?? 0))
173
+ const res = await fetchFleet(
174
+ addr,
175
+ `/fleet/v1/events?replay=${replay}`,
176
+ { method: 'GET', headers: { accept: 'text/event-stream' }, signal: opts.signal },
177
+ opts.env,
178
+ )
179
+ if (!res.ok || !res.body) throw new Error(`events stream → ${res.status}`)
180
+ const reader = res.body.getReader()
181
+ const decoder = new TextDecoder()
182
+ let buf = ''
183
+ try {
184
+ for (;;) {
185
+ const { value, done } = await reader.read()
186
+ if (done) break
187
+ buf += decoder.decode(value, { stream: true })
188
+ // Frames are separated by a blank line.
189
+ let sep: number
190
+ while ((sep = buf.indexOf('\n\n')) !== -1) {
191
+ const frame = buf.slice(0, sep)
192
+ buf = buf.slice(sep + 2)
193
+ const parsed = parseSseFrame(frame)
194
+ if (parsed) onEvent(parsed)
195
+ }
196
+ }
197
+ } finally {
198
+ try {
199
+ reader.releaseLock()
200
+ } catch {
201
+ /* already released */
202
+ }
203
+ }
204
+ }
205
+
206
+ /** Parse one SSE frame (the text between blank-line separators). Returns null for
207
+ * comment-only frames (`: connected`, `: hb`) or frames without a data payload. */
208
+ export function parseSseFrame(frame: string): SseEvent | null {
209
+ let event = 'message'
210
+ let id: number | undefined
211
+ const dataLines: string[] = []
212
+ for (const raw of frame.split('\n')) {
213
+ const line = raw.replace(/\r$/, '')
214
+ if (!line || line.startsWith(':')) continue // comment or blank
215
+ const colon = line.indexOf(':')
216
+ const field = colon === -1 ? line : line.slice(0, colon)
217
+ const val = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '')
218
+ if (field === 'event') event = val
219
+ else if (field === 'id') {
220
+ const n = parseInt(val, 10)
221
+ if (Number.isFinite(n)) id = n
222
+ } else if (field === 'data') dataLines.push(val)
223
+ }
224
+ if (dataLines.length === 0) return null
225
+ let data: Record<string, unknown> = {}
226
+ try {
227
+ const parsed = JSON.parse(dataLines.join('\n'))
228
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data = parsed as Record<string, unknown>
229
+ } catch {
230
+ return null
231
+ }
232
+ return { event, ...(id !== undefined ? { id } : {}), data }
233
+ }
@@ -0,0 +1,146 @@
1
+ // tray/index — the fleet dashboard's runtime entry points, called by the `tray` CLI
2
+ // verb. Ф1 of iapeer-tray: the FIRST external client of the daemon fleet API, written
3
+ // strictly against docs/15-fleet-api.md.
4
+ //
5
+ // • render (one-shot) — fetch /snapshot once, print one SwiftBar block. Used by the
6
+ // poll-fallback plugin and by tests.
7
+ // • render --stream — the SwiftBar streamable loop: print a block, subscribe to
8
+ // /events, and re-fetch the snapshot on each change (the canonical docs/15 loop
9
+ // "snapshot is state, events are hints"). Coalesces event bursts, heartbeats a
10
+ // refresh every 15 s (ages stay fresh + a missed event self-heals), and reconnects
11
+ // across daemon restarts / down windows. Degrades to the daemon-down block.
12
+ // • cmd — POST a fleet command (wake/stop/…): dogfoods the command endpoints.
13
+
14
+ import { resolveFleetAddress, fleetGetJson, fleetPostJson, streamFleetEvents } from './client.ts'
15
+ import { renderSwiftBar, renderDaemonDown, type TraySnapshot } from './render.ts'
16
+ import { iapeerBinPath } from '../install/index.ts'
17
+
18
+ export * from './install.ts'
19
+ export { resolveFleetAddress } from './client.ts'
20
+
21
+ const SNAPSHOT_PATH = '/fleet/v1/snapshot'
22
+ const DEFAULT_HEARTBEAT_MS = 15_000
23
+ const COALESCE_MS = 200
24
+ const RECONNECT_BACKOFF_MS = 1_000
25
+ const DOWN_RETRY_MS = 3_000
26
+
27
+ /** Heartbeat cadence: re-render even without an event so ages stay fresh and a missed
28
+ * event self-heals. Tunable via IAPEER_TRAY_HEARTBEAT_MS (clamped 1s…10m). */
29
+ function heartbeatMs(env: NodeJS.ProcessEnv): number {
30
+ const raw = parseInt(env.IAPEER_TRAY_HEARTBEAT_MS ?? '', 10)
31
+ if (!Number.isFinite(raw)) return DEFAULT_HEARTBEAT_MS
32
+ return Math.max(1_000, Math.min(600_000, raw))
33
+ }
34
+
35
+ export const TRAY_COMMANDS = ['wake', 'stop', 'start', 'new', 'refresh', 'interrupt', 'compact'] as const
36
+ export type TrayCommand = (typeof TRAY_COMMANDS)[number]
37
+
38
+ /** Fetch the snapshot and render one SwiftBar block, or the degraded down-block when
39
+ * no advertised address answers / the daemon predates the fleet API. Never throws. */
40
+ export async function renderTrayOnce(env: NodeJS.ProcessEnv): Promise<string> {
41
+ const binPath = iapeerBinPath(env)
42
+ const addr = resolveFleetAddress({ env })
43
+ if (addr.fleet !== 1 || (!addr.sock && !addr.tcp)) {
44
+ const reason = addr.sock || addr.tcp ? 'daemon predates the fleet API (no fleet:1 in router.json)' : 'no daemon address (router.json missing)'
45
+ return renderDaemonDown(reason, { binPath })
46
+ }
47
+ try {
48
+ const snap = await fleetGetJson<TraySnapshot>(addr, SNAPSHOT_PATH, env)
49
+ return renderSwiftBar(snap, { binPath })
50
+ } catch (e) {
51
+ return renderDaemonDown(e instanceof Error ? e.message : String(e), { binPath })
52
+ }
53
+ }
54
+
55
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
56
+ return new Promise(resolve => {
57
+ if (signal?.aborted) return resolve()
58
+ const t = setTimeout(resolve, ms)
59
+ ;(t as { unref?: () => void }).unref?.()
60
+ signal?.addEventListener('abort', () => { clearTimeout(t); resolve() }, { once: true })
61
+ })
62
+ }
63
+
64
+ /**
65
+ * The SwiftBar streamable loop (docs/16 §streaming). Emits a full menu block, then a
66
+ * `~~~` separator before every subsequent block. Drives updates off the SSE stream
67
+ * with burst-coalescing + a heartbeat; reconnects forever. `out` defaults to stdout;
68
+ * `signal` (tests) stops the loop.
69
+ */
70
+ export async function streamTray(
71
+ env: NodeJS.ProcessEnv,
72
+ out: (s: string) => void = s => process.stdout.write(s),
73
+ signal?: AbortSignal,
74
+ ): Promise<void> {
75
+ let first = true
76
+ // Serialize all emits through a promise chain so a `~~~`+block is never interleaved.
77
+ let chain: Promise<void> = Promise.resolve()
78
+ const emit = (): Promise<void> => {
79
+ chain = chain.then(async () => {
80
+ const block = await renderTrayOnce(env)
81
+ out(first ? block : `~~~\n${block}`)
82
+ first = false
83
+ })
84
+ return chain
85
+ }
86
+ // Coalesce event bursts into a single re-render.
87
+ let coalesceTimer: ReturnType<typeof setTimeout> | undefined
88
+ const requestEmit = (): void => {
89
+ if (coalesceTimer) return
90
+ coalesceTimer = setTimeout(() => {
91
+ coalesceTimer = undefined
92
+ void emit()
93
+ }, COALESCE_MS)
94
+ ;(coalesceTimer as { unref?: () => void }).unref?.()
95
+ }
96
+
97
+ await emit() // initial block
98
+
99
+ while (!signal?.aborted) {
100
+ const addr = resolveFleetAddress({ env })
101
+ if (addr.fleet !== 1 || (!addr.sock && !addr.tcp)) {
102
+ await sleep(DOWN_RETRY_MS, signal)
103
+ await emit() // keep the down-block current until the daemon returns
104
+ continue
105
+ }
106
+ const heartbeat = setInterval(requestEmit, heartbeatMs(env))
107
+ ;(heartbeat as { unref?: () => void }).unref?.()
108
+ try {
109
+ // Blocks until the stream ends (daemon restart / network drop). Each event just
110
+ // marks the menu dirty — the snapshot re-fetch is the source of truth.
111
+ await streamFleetEvents(addr, () => requestEmit(), { replay: 0, signal, env })
112
+ } catch {
113
+ /* connection failed → fall through to a refresh + backoff + reconnect */
114
+ } finally {
115
+ clearInterval(heartbeat)
116
+ }
117
+ if (signal?.aborted) break
118
+ await emit() // reflect whatever changed across the drop
119
+ await sleep(RECONNECT_BACKOFF_MS, signal)
120
+ }
121
+ }
122
+
123
+ export interface TrayCmdResult {
124
+ ok: boolean
125
+ status: number
126
+ body: unknown
127
+ }
128
+
129
+ /** POST a fleet command for a peer (dogfoods POST /fleet/v1/peers/<peer>/<cmd>). */
130
+ export async function trayCmd(
131
+ env: NodeJS.ProcessEnv,
132
+ command: string,
133
+ personality: string,
134
+ runtime?: string,
135
+ ): Promise<TrayCmdResult> {
136
+ if (!(TRAY_COMMANDS as readonly string[]).includes(command)) {
137
+ throw new Error(`unknown tray command "${command}" — one of: ${TRAY_COMMANDS.join(', ')}`)
138
+ }
139
+ const addr = resolveFleetAddress({ env })
140
+ if (addr.fleet !== 1 || (!addr.sock && !addr.tcp)) {
141
+ return { ok: false, status: 0, body: { error: 'fleet daemon unreachable (no fleet:1 in router.json)' } }
142
+ }
143
+ const path = `/fleet/v1/peers/${encodeURIComponent(personality)}/${command}`
144
+ const { status, body } = await fleetPostJson(addr, path, runtime ? { runtime } : {}, env)
145
+ return { ok: status < 400, status, body }
146
+ }
@@ -0,0 +1,266 @@
1
+ // tray/install — idempotent lifecycle of the SwiftBar plugin that hosts the fleet
2
+ // dashboard. The plugin FILE is a thin shim: it execs the installed `iapeer` binary
3
+ // (`iapeer tray render --stream`), so ALL rendering/streaming logic lives in the
4
+ // binary (TypeScript, tested) rather than in brittle shell — the .sh is stable and
5
+ // carries only SwiftBar metadata.
6
+ //
7
+ // SwiftBar facts (verified against SwiftBar/PreferencesStore.swift):
8
+ // • PluginDirectory is a PLAIN String default (read via expandingTildeInPath), NOT
9
+ // a security-scoped bookmark — `defaults write com.ameba.SwiftBar PluginDirectory
10
+ // -string <dir>` set BEFORE first launch is honored and skips the folder picker.
11
+ // • SwiftBar is not sandboxed (it runs arbitrary scripts), so a hidden ~/.iapeer
12
+ // path is readable without a TCC prompt.
13
+ //
14
+ // Packaging (owner decision 05.07.2026): the tray ships WITH the foundation (same
15
+ // class as the `iapeer list` TUI — another face of the daemon). `iapeer install`
16
+ // drops the plugin file (inert without SwiftBar); `iapeer tray install [--app]` is
17
+ // the activation verb that also installs SwiftBar.app when absent (owner-sanctioned)
18
+ // and launches it. Tearing down the GUI layer NEVER touches the fleet.
19
+ //
20
+ // Paths route through cfg/env (the dedicated plugin dir is under resolveGlobalRoot,
21
+ // so IAPEER_ROOT isolates it in tests); every external side-effect (defaults / brew /
22
+ // open) goes through an injectable Runner so tests stay hermetic.
23
+
24
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
25
+ import { spawnSync } from 'child_process'
26
+ import { homedir } from 'os'
27
+ import { join } from 'path'
28
+ import { resolveGlobalRoot } from '../storage/index.ts'
29
+ import { iapeerBinPath } from '../install/index.ts'
30
+ import { resolveFleetAddress } from './client.ts'
31
+
32
+ /** Streamable plugin: the `.10s.` token is the RESPAWN cadence SwiftBar uses if the
33
+ * streaming process exits (daemon-down backoff); while the stream is alive it is
34
+ * ignored. The basename is stable — install rewrites the same file (idempotent). */
35
+ export const PLUGIN_BASENAME = 'iapeer.10s.sh'
36
+ const SWIFTBAR_DOMAIN = 'com.ameba.SwiftBar'
37
+ const SWIFTBAR_APP_PATHS = ['/Applications/SwiftBar.app', join(homedir(), 'Applications', 'SwiftBar.app')]
38
+
39
+ export type RunResult = { status: number | null; stdout: string; stderr: string }
40
+ export type Runner = (cmd: string, args: string[]) => RunResult
41
+
42
+ const defaultRun: Runner = (cmd, args) => {
43
+ const r = spawnSync(cmd, args, { encoding: 'utf8' })
44
+ return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
45
+ }
46
+
47
+ /** The dedicated iapeer-owned plugin dir under ~/.iapeer (IAPEER_ROOT-isolated). */
48
+ export function dedicatedPluginDir(env: NodeJS.ProcessEnv = process.env): string {
49
+ return join(resolveGlobalRoot(env), 'tray', 'plugins')
50
+ }
51
+
52
+ /** Is SwiftBar.app installed? (either /Applications or ~/Applications). */
53
+ export function swiftBarInstalled(): boolean {
54
+ return SWIFTBAR_APP_PATHS.some(p => existsSync(p))
55
+ }
56
+
57
+ /** The plugin-dir SwiftBar is currently configured with (plain-string default,
58
+ * ~-expanded), or undefined when unset/unreadable. */
59
+ export function readSwiftBarPluginDir(run: Runner = defaultRun): string | undefined {
60
+ const r = run('defaults', ['read', SWIFTBAR_DOMAIN, 'PluginDirectory'])
61
+ if (r.status !== 0) return undefined
62
+ const raw = r.stdout.trim()
63
+ if (!raw) return undefined
64
+ return raw.startsWith('~') ? join(homedir(), raw.slice(1)) : raw
65
+ }
66
+
67
+ function setSwiftBarPluginDir(dir: string, run: Runner): RunResult {
68
+ return run('defaults', ['write', SWIFTBAR_DOMAIN, 'PluginDirectory', '-string', dir])
69
+ }
70
+
71
+ function installSwiftBarApp(run: Runner): RunResult {
72
+ return run('brew', ['install', '--cask', 'swiftbar'])
73
+ }
74
+
75
+ function launchSwiftBar(run: Runner): void {
76
+ run('open', ['-g', '-a', 'SwiftBar'])
77
+ }
78
+
79
+ function refreshSwiftBar(run: Runner): void {
80
+ run('open', ['-g', 'swiftbar://refreshallplugins'])
81
+ }
82
+
83
+ /** The plugin shim: SwiftBar metadata + exec of the streaming renderer. binPath is
84
+ * embedded absolute (SwiftBar's PATH is minimal, like launchd). */
85
+ export function buildPluginShim(binPath: string): string {
86
+ return [
87
+ '#!/bin/bash',
88
+ '# <xbar.title>iapeer fleet</xbar.title>',
89
+ '# <xbar.version>v1</xbar.version>',
90
+ '# <xbar.author>iapeer</xbar.author>',
91
+ '# <xbar.desc>iapeer multi-agent fleet — live peer dashboard over the daemon fleet API.</xbar.desc>',
92
+ '# <swiftbar.type>streamable</swiftbar.type>',
93
+ '# <swiftbar.hideRunInTerminal>true</swiftbar.hideRunInTerminal>',
94
+ '# <swiftbar.hideLastUpdated>true</swiftbar.hideLastUpdated>',
95
+ '# <swiftbar.hideDisablePlugin>true</swiftbar.hideDisablePlugin>',
96
+ `exec "${binPath}" tray render --stream`,
97
+ '',
98
+ ].join('\n')
99
+ }
100
+
101
+ /** Fail-closed sandbox guard (in spirit with install/scaffoldHostDocs): never write
102
+ * into the REAL ~/.iapeer under a sandboxed test that forgot to set IAPEER_ROOT. */
103
+ function assertTraySandboxIsolated(env: NodeJS.ProcessEnv): void {
104
+ if (env.IAPEER_TEST_SANDBOX === '1' && resolveGlobalRoot(env) === join(homedir(), '.iapeer')) {
105
+ throw new Error('refusing to install the tray plugin into the REAL ~/.iapeer under IAPEER_TEST_SANDBOX=1 — set IAPEER_ROOT')
106
+ }
107
+ }
108
+
109
+ export interface InstallTrayOptions {
110
+ env?: NodeJS.ProcessEnv
111
+ /** Install SwiftBar.app via brew when absent (owner-sanctioned). Default false. */
112
+ installApp?: boolean
113
+ /** Launch + refresh SwiftBar after writing the plugin. Default false. */
114
+ launch?: boolean
115
+ run?: Runner
116
+ /** Injectable app-presence probe (tests). Default: swiftBarInstalled(). */
117
+ probeApp?: () => boolean
118
+ }
119
+
120
+ export interface InstallTrayResult {
121
+ pluginFile: string
122
+ pluginDir: string
123
+ /** Whether the plugin file bytes changed (false = already current). */
124
+ wrote: boolean
125
+ /** How the plugin dir was chosen. */
126
+ dir: 'existing-swiftbar' | 'dedicated-configured' | 'dedicated-only'
127
+ app: 'present' | 'installed' | 'install-failed' | 'absent'
128
+ appReason?: string
129
+ launched: boolean
130
+ }
131
+
132
+ /**
133
+ * Idempotently install the tray plugin. Steps (each safe to repeat):
134
+ * 1. install SwiftBar.app if absent && installApp (best-effort — a brew failure is
135
+ * reported, not thrown; the plugin file still lands so it activates later).
136
+ * 2. resolve the target dir: an already-configured SwiftBar PluginDirectory is
137
+ * RESPECTED (install into the user's dir); otherwise the dedicated ~/.iapeer dir,
138
+ * and — only when SwiftBar is present — point SwiftBar at it via defaults.
139
+ * 3. write the plugin shim (skip if byte-identical; always ensure +x).
140
+ * 4. launch + refresh SwiftBar when launch && SwiftBar present.
141
+ */
142
+ export function installTray(opts: InstallTrayOptions = {}): InstallTrayResult {
143
+ const env = opts.env ?? process.env
144
+ const run = opts.run ?? defaultRun
145
+ const probeApp = opts.probeApp ?? swiftBarInstalled
146
+ assertTraySandboxIsolated(env)
147
+ const binPath = iapeerBinPath(env)
148
+
149
+ // 1 — app
150
+ let app: InstallTrayResult['app'] = probeApp() ? 'present' : 'absent'
151
+ let appReason: string | undefined
152
+ if (app === 'absent' && opts.installApp) {
153
+ const r = installSwiftBarApp(run)
154
+ if (r.status === 0 || probeApp()) app = 'installed'
155
+ else {
156
+ app = 'install-failed'
157
+ appReason = (r.stderr || r.stdout || `brew exit ${r.status}`).trim().slice(0, 300)
158
+ }
159
+ }
160
+ const present = app === 'present' || app === 'installed'
161
+
162
+ // 2 — target dir
163
+ const existing = present ? readSwiftBarPluginDir(run) : undefined
164
+ let pluginDir: string
165
+ let dir: InstallTrayResult['dir']
166
+ if (existing && existsSync(existing)) {
167
+ pluginDir = existing
168
+ dir = 'existing-swiftbar'
169
+ } else {
170
+ pluginDir = dedicatedPluginDir(env)
171
+ if (present) {
172
+ setSwiftBarPluginDir(pluginDir, run)
173
+ dir = 'dedicated-configured'
174
+ } else {
175
+ dir = 'dedicated-only'
176
+ }
177
+ }
178
+ mkdirSync(pluginDir, { recursive: true })
179
+
180
+ // 3 — plugin file (idempotent write; always +x)
181
+ const pluginFile = join(pluginDir, PLUGIN_BASENAME)
182
+ const content = buildPluginShim(binPath)
183
+ let wrote = false
184
+ if (!existsSync(pluginFile) || readFileSync(pluginFile, 'utf8') !== content) {
185
+ writeFileSync(pluginFile, content, { mode: 0o755 })
186
+ wrote = true
187
+ } else {
188
+ try {
189
+ chmodSync(pluginFile, 0o755)
190
+ } catch {
191
+ /* best-effort */
192
+ }
193
+ }
194
+
195
+ // 4 — launch + refresh
196
+ let launched = false
197
+ if (opts.launch && present) {
198
+ launchSwiftBar(run)
199
+ refreshSwiftBar(run)
200
+ launched = true
201
+ }
202
+
203
+ return { pluginFile, pluginDir, wrote, dir, app, appReason, launched }
204
+ }
205
+
206
+ export interface UninstallTrayResult {
207
+ removed: string[]
208
+ /** SwiftBar was refreshed to drop the now-missing plugin. */
209
+ refreshed: boolean
210
+ }
211
+
212
+ /**
213
+ * Remove the tray plugin file from EVERY dir it could live in (the dedicated dir and
214
+ * a configured SwiftBar dir) and refresh SwiftBar. DELIBERATELY leaves SwiftBar.app
215
+ * installed (the user may run other plugins) and NEVER touches the fleet — the daemon,
216
+ * TUI and delivery keep running (acceptance criterion: GUI teardown ≠ fleet teardown).
217
+ */
218
+ export function uninstallTray(
219
+ opts: { env?: NodeJS.ProcessEnv; launch?: boolean; run?: Runner; probeApp?: () => boolean } = {},
220
+ ): UninstallTrayResult {
221
+ const env = opts.env ?? process.env
222
+ const run = opts.run ?? defaultRun
223
+ assertTraySandboxIsolated(env)
224
+ const present = (opts.probeApp ?? swiftBarInstalled)()
225
+ const dirs = new Set<string>([dedicatedPluginDir(env)])
226
+ if (present) {
227
+ const existing = readSwiftBarPluginDir(run)
228
+ if (existing) dirs.add(existing)
229
+ }
230
+ const removed: string[] = []
231
+ for (const d of dirs) {
232
+ const f = join(d, PLUGIN_BASENAME)
233
+ if (existsSync(f)) {
234
+ rmSync(f, { force: true })
235
+ removed.push(f)
236
+ }
237
+ }
238
+ let refreshed = false
239
+ if (opts.launch && present) {
240
+ refreshSwiftBar(run)
241
+ refreshed = true
242
+ }
243
+ return { removed, refreshed }
244
+ }
245
+
246
+ export interface TrayStatus {
247
+ daemon: { fleet: boolean; version?: string; sock?: string; tcp?: string }
248
+ swiftbar: { installed: boolean; pluginDir?: string }
249
+ plugin: { installed: boolean; path?: string }
250
+ }
251
+
252
+ /** Read-only diagnostics — never repairs anything. */
253
+ export function trayStatus(opts: { env?: NodeJS.ProcessEnv; run?: Runner; probeApp?: () => boolean } = {}): TrayStatus {
254
+ const env = opts.env ?? process.env
255
+ const run = opts.run ?? defaultRun
256
+ const addr = resolveFleetAddress({ env })
257
+ const installed = (opts.probeApp ?? swiftBarInstalled)()
258
+ const cfgDir = installed ? readSwiftBarPluginDir(run) : undefined
259
+ const searchDirs = [cfgDir, dedicatedPluginDir(env)].filter(Boolean) as string[]
260
+ const found = searchDirs.map(d => join(d, PLUGIN_BASENAME)).find(f => existsSync(f))
261
+ return {
262
+ daemon: { fleet: addr.fleet === 1, version: addr.version, sock: addr.sock, tcp: addr.tcp },
263
+ swiftbar: { installed, pluginDir: cfgDir },
264
+ plugin: { installed: Boolean(found), path: found },
265
+ }
266
+ }