@agfpd/iapeer 0.4.64 → 0.4.66

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,266 @@
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
+ transport: 'unix' | 'tcp'
84
+ url: string
85
+ init: RequestInit & { unix?: string }
86
+ }
87
+
88
+ /** Build the ordered transport attempts for a fleet path: unix socket first, TCP
89
+ * second. `unix` is Bun's fetch option (routes the HTTP request over the socket). */
90
+ function attempts(addr: FleetAddress, path: string, base: RequestInit, env: NodeJS.ProcessEnv): FetchAttempt[] {
91
+ const headers = { ...(base.headers as Record<string, string> | undefined), ...bearerHeader(env) }
92
+ const list: FetchAttempt[] = []
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 } })
95
+ return list
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
+ */
118
+ async function fetchFleet(
119
+ addr: FleetAddress,
120
+ path: string,
121
+ init: RequestInit,
122
+ env: NodeJS.ProcessEnv,
123
+ opts: { retryBadStatus?: boolean } = {},
124
+ ): Promise<FleetResponse> {
125
+ const tries = attempts(addr, path, init, env)
126
+ if (tries.length === 0) throw new FleetUnreachableError('no advertised address (router.json missing sock/tcp)')
127
+ let lastErr = ''
128
+ let firstBad: FleetResponse | undefined
129
+ for (const a of tries) {
130
+ let res: Response
131
+ try {
132
+ res = await fetch(a.url, a.init as RequestInit)
133
+ } catch (e) {
134
+ lastErr = e instanceof Error ? e.message : String(e)
135
+ debug(env, `${a.transport} ${a.url} → THREW ${lastErr}`)
136
+ continue
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
144
+ }
145
+ if (firstBad) return firstBad
146
+ throw new FleetUnreachableError(`${lastErr || 'all transports failed'} (tried ${tries.map(t => `${t.transport}=${t.url}`).join(', ')})`)
147
+ }
148
+
149
+ export async function fleetGetJson<T = unknown>(addr: FleetAddress, path: string, env: NodeJS.ProcessEnv): Promise<T> {
150
+ const { res, transport, url } = await fetchFleet(addr, path, { method: 'GET' }, env, { retryBadStatus: true })
151
+ const text = await res.text()
152
+ if (!res.ok) throw new Error(`GET ${url} (${transport}) → ${res.status}: ${text.slice(0, 300)}`)
153
+ return JSON.parse(text) as T
154
+ }
155
+
156
+ export async function fleetPostJson<T = unknown>(
157
+ addr: FleetAddress,
158
+ path: string,
159
+ body: Record<string, unknown>,
160
+ env: NodeJS.ProcessEnv,
161
+ ): Promise<{ status: number; body: T }> {
162
+ const { res } = await fetchFleet(
163
+ addr,
164
+ path,
165
+ { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) },
166
+ env,
167
+ { retryBadStatus: false }, // a command must never re-execute on the other transport
168
+ )
169
+ const text = await res.text()
170
+ let parsed: T
171
+ try {
172
+ parsed = JSON.parse(text) as T
173
+ } catch {
174
+ parsed = text as unknown as T
175
+ }
176
+ return { status: res.status, body: parsed }
177
+ }
178
+
179
+ // ─────────────────────────────────────────────────────────────────────────────
180
+ // SSE — GET /fleet/v1/events (docs/15 §GET /fleet/v1/events)
181
+ // ─────────────────────────────────────────────────────────────────────────────
182
+
183
+ export interface SseEvent {
184
+ /** The event name (`event:` line) — the `ev` vocabulary. Unknown kinds MUST be
185
+ * tolerated by the caller (client obligation 1). */
186
+ event: string
187
+ /** Epoch-ms of the event (`id:` line) — for at-least-once dedup. */
188
+ id?: number
189
+ /** The parsed `data:` JSON payload. */
190
+ data: Record<string, unknown>
191
+ }
192
+
193
+ /**
194
+ * Open the SSE stream and invoke `onEvent` per frame until the signal aborts or the
195
+ * connection drops (then this resolves — the caller decides whether to reconnect).
196
+ * Parses the `event:` / `id:` / `data:` triplet; comments (`: connected`, `: hb`) are
197
+ * skipped. Deliberately minimal — no reconnect here (the render loop owns that).
198
+ */
199
+ export async function streamFleetEvents(
200
+ addr: FleetAddress,
201
+ onEvent: (e: SseEvent) => void,
202
+ opts: { replay?: number; signal?: AbortSignal; env: NodeJS.ProcessEnv },
203
+ ): Promise<void> {
204
+ const replay = Math.max(0, Math.min(500, opts.replay ?? 0))
205
+ const { res, transport, url } = await fetchFleet(
206
+ addr,
207
+ `/fleet/v1/events?replay=${replay}`,
208
+ { method: 'GET', headers: { accept: 'text/event-stream' }, signal: opts.signal },
209
+ opts.env,
210
+ { retryBadStatus: true },
211
+ )
212
+ if (!res.ok || !res.body) throw new Error(`events stream ${url} (${transport}) → ${res.status}`)
213
+ const reader = res.body.getReader()
214
+ const decoder = new TextDecoder()
215
+ let buf = ''
216
+ try {
217
+ for (;;) {
218
+ const { value, done } = await reader.read()
219
+ if (done) break
220
+ buf += decoder.decode(value, { stream: true })
221
+ // Frames are separated by a blank line.
222
+ let sep: number
223
+ while ((sep = buf.indexOf('\n\n')) !== -1) {
224
+ const frame = buf.slice(0, sep)
225
+ buf = buf.slice(sep + 2)
226
+ const parsed = parseSseFrame(frame)
227
+ if (parsed) onEvent(parsed)
228
+ }
229
+ }
230
+ } finally {
231
+ try {
232
+ reader.releaseLock()
233
+ } catch {
234
+ /* already released */
235
+ }
236
+ }
237
+ }
238
+
239
+ /** Parse one SSE frame (the text between blank-line separators). Returns null for
240
+ * comment-only frames (`: connected`, `: hb`) or frames without a data payload. */
241
+ export function parseSseFrame(frame: string): SseEvent | null {
242
+ let event = 'message'
243
+ let id: number | undefined
244
+ const dataLines: string[] = []
245
+ for (const raw of frame.split('\n')) {
246
+ const line = raw.replace(/\r$/, '')
247
+ if (!line || line.startsWith(':')) continue // comment or blank
248
+ const colon = line.indexOf(':')
249
+ const field = colon === -1 ? line : line.slice(0, colon)
250
+ const val = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '')
251
+ if (field === 'event') event = val
252
+ else if (field === 'id') {
253
+ const n = parseInt(val, 10)
254
+ if (Number.isFinite(n)) id = n
255
+ } else if (field === 'data') dataLines.push(val)
256
+ }
257
+ if (dataLines.length === 0) return null
258
+ let data: Record<string, unknown> = {}
259
+ try {
260
+ const parsed = JSON.parse(dataLines.join('\n'))
261
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data = parsed as Record<string, unknown>
262
+ } catch {
263
+ return null
264
+ }
265
+ return { event, ...(id !== undefined ? { id } : {}), data }
266
+ }
@@ -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
+ }