@agfpd/iapeer 0.4.61 → 0.4.63

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,654 @@
1
+ // Fleet-API — the daemon's OPERATOR-CLIENT HTTP surface (Ф0 of iapeer-tray: one
2
+ // fleet-API in the daemon, many thin faces on top — SwiftBar / native tray / web
3
+ // gateway / telegram approval buttons). Plain HTTP+JSON + SSE, DELIBERATELY NOT an
4
+ // MCP tool: MCP is the AGENT surface and the contract keeps it minimal
5
+ // (send_to_peer only — an extra tool occupies every session's context window);
6
+ // fleet clients are GUIs/scripts that consume HTTP natively.
7
+ //
8
+ // Design pillars (spec accepted by the PM 05.07.2026; normative client contract in
9
+ // docs/15-fleet-api.md):
10
+ // • SNAPSHOT truth = the SAME in-process primitives `iapeer list` uses
11
+ // (cli.listPeers) — agreement with the CLI is by construction, not by parsing.
12
+ // • EVENTS = tail-follow of the THREE durable logs (lifecycle.log / delivery.log /
13
+ // exits.log under cfg.eventLogDir). Those logs are written by EVERY process
14
+ // (daemon + CLI attach/stop/wake + supervisor deaths) — an in-memory daemon bus
15
+ // would silently miss the CLI-originated events. At-least-once semantics;
16
+ // clients MUST ignore unknown `ev` kinds (the approval-request growth seam).
17
+ // • COMMANDS = the EXISTING verb functions called in-process (stopPeer/startPeer/
18
+ // newPeer/refreshPeer/compactPeer/wakeOrSpawn/routeControl) — H4, the fleet
19
+ // guard and the crash-loop guard all live INSIDE those functions, so the API
20
+ // cannot bypass them. send routes through daemon.callTool — the SAME path as
21
+ // the MCP tool (delivery.log line + all post-delivery hooks included).
22
+ //
23
+ // Security class: unchanged. The endpoints listen on the SAME two listeners
24
+ // (0600 unix socket + TCP loopback) behind the SAME optional H8 bearer gate
25
+ // (checked in daemon/index.ts BEFORE dispatch); command power ≤ what the same-uid
26
+ // CLI already has. NO CORS headers — a browser page must NOT be able to drive
27
+ // loopback fleet commands cross-origin; the future web gateway is its own decision
28
+ // with bearer mandatory (see the frozen TCP open-auth decision + its triggers).
29
+ //
30
+ // Layering: this module is a COMPOSITION-SIDE sibling of daemon/main.ts (wired via
31
+ // StartDaemonOptions.fleet — the same injected-seam pattern as wake/supervise), so
32
+ // the daemon library stays lifecycle-free. The CLI verb functions are loaded via a
33
+ // LAZY dynamic import: cli/index.ts statically imports daemon/main.ts, so a static
34
+ // import back would create the repo's first import cycle.
35
+
36
+ import type { IncomingMessage, ServerResponse } from 'http'
37
+ import { closeSync, openSync, readSync, statSync } from 'fs'
38
+ import { buildProcessAddress } from '../core/socket.ts'
39
+ import { IAPEER_VERSION } from '../core/version.ts'
40
+ import {
41
+ ephemeralQueueDepth,
42
+ isEphemeralPeer,
43
+ isLaunchdManaged,
44
+ loadLifecycleConfig,
45
+ wakeOrSpawn,
46
+ type LifecycleConfig,
47
+ } from '../lifecycle/index.ts'
48
+ import { lifecycleLogPath } from '../lifecycle/eventlog.ts'
49
+ import { exitLogPath } from '../launch/index.ts'
50
+ import { hasAttachedSupervisorClient } from '../launch/ptyHost.ts'
51
+ import { readPeersIndex } from '../registry/index.ts'
52
+ import { resolveCallerIdentity, type ResolvedCaller } from '../identity/index.ts'
53
+ import { routeControl } from '../transport/index.ts'
54
+ import {
55
+ eventConcernsPeer,
56
+ parseEventLine,
57
+ readLogTail,
58
+ type ParsedEventLine,
59
+ } from '../storage/rotatelog.ts'
60
+ import { heartbeatAgeSecs, probeFullDiskAccess, readMemoryProvider, readVoiceProvider } from '../status/index.ts'
61
+ import { deliveryLogPath } from './deliverylog.ts'
62
+ import { callTool, type CallToolDeps } from './index.ts'
63
+
64
+ /** The API generation baked into every snapshot (additive evolution within v1 —
65
+ * see docs/15-fleet-api.md «Compatibility»). */
66
+ export const FLEET_API_VERSION = 1
67
+
68
+ // ─────────────────────────────────────────────────────────────────────────────
69
+ // Snapshot
70
+ // ─────────────────────────────────────────────────────────────────────────────
71
+
72
+ export interface FleetRuntimeStatus {
73
+ runtime: string
74
+ status: 'live' | 'asleep' | 'stopped'
75
+ /** Present (true) iff a HUMAN operator is attached to this live hosted session. */
76
+ attached?: boolean
77
+ }
78
+
79
+ export interface FleetPeer {
80
+ personality: string
81
+ description: string
82
+ intelligence: string
83
+ default_runtime: string
84
+ cwd: string
85
+ runtimes: FleetRuntimeStatus[]
86
+ last_active_runtime?: string
87
+ last_active_ms?: number
88
+ /** Any runtime session with a human attached. */
89
+ attached: boolean
90
+ /** H4 — launchd owns this peer's lifecycle; the daemon (and the wake/stop/start
91
+ * commands) treat it read-only / guarded. */
92
+ launchd_managed: boolean
93
+ wake_policy: 'warm' | 'ephemeral'
94
+ /** Ephemeral peers only: pending serial-queue tasks across declared runtimes. */
95
+ queue_depth?: number
96
+ }
97
+
98
+ export interface FleetHost {
99
+ version: string
100
+ pid: number
101
+ startedAt: string
102
+ uptimeSecs: number
103
+ memory: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
104
+ voice: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
105
+ fda: boolean | null
106
+ }
107
+
108
+ export interface FleetSnapshot {
109
+ api: typeof FLEET_API_VERSION
110
+ version: string
111
+ ts: string
112
+ host: FleetHost
113
+ peers: FleetPeer[]
114
+ }
115
+
116
+ // The CLI op functions, injectable for tests. Defaults LAZY-load cli/index.ts at
117
+ // call time (see the layering note in the module header).
118
+ export interface FleetOps {
119
+ listPeers: (opts: { env?: NodeJS.ProcessEnv }) => import('../cli/index.ts').PeerListing[]
120
+ stopPeer: typeof import('../cli/index.ts').stopPeer
121
+ startPeer: typeof import('../cli/index.ts').startPeer
122
+ refreshPeer: typeof import('../cli/index.ts').refreshPeer
123
+ newPeer: typeof import('../cli/index.ts').newPeer
124
+ compactPeer: typeof import('../cli/index.ts').compactPeer
125
+ }
126
+
127
+ async function defaultOps(): Promise<FleetOps> {
128
+ const cli = await import('../cli/index.ts')
129
+ return {
130
+ listPeers: cli.listPeers,
131
+ stopPeer: cli.stopPeer,
132
+ startPeer: cli.startPeer,
133
+ refreshPeer: cli.refreshPeer,
134
+ newPeer: cli.newPeer,
135
+ compactPeer: cli.compactPeer,
136
+ }
137
+ }
138
+
139
+ /** Assemble the full fleet snapshot. Peer rows come from the SAME listPeers the CLI
140
+ * `list` verb renders — agreement with `iapeer list` is by construction. On top:
141
+ * the occupancy/ownership facts the registry row alone does not carry (attached
142
+ * human, H4 launchd ownership, wake_policy, ephemeral queue depth). */
143
+ export function buildFleetSnapshot(
144
+ env: NodeJS.ProcessEnv,
145
+ cfg: LifecycleConfig,
146
+ ops: Pick<FleetOps, 'listPeers'>,
147
+ startedAtMs: number,
148
+ nowMs: number = Date.now(),
149
+ ): FleetSnapshot {
150
+ const peers: FleetPeer[] = ops.listPeers({ env }).map(r => {
151
+ const runtimes: FleetRuntimeStatus[] = r.runtimes.map(s => {
152
+ const attached =
153
+ s.status === 'live' && hasAttachedSupervisorClient(buildProcessAddress(s.runtime, r.personality), env)
154
+ return { runtime: s.runtime, status: s.status, ...(attached ? { attached: true } : {}) }
155
+ })
156
+ let ephemeral = false
157
+ try {
158
+ ephemeral = isEphemeralPeer(r.cwd)
159
+ } catch {
160
+ /* unreadable profile → treated as warm */
161
+ }
162
+ let queueDepth = 0
163
+ if (ephemeral) {
164
+ for (const s of r.runtimes) queueDepth += ephemeralQueueDepth(cfg, buildProcessAddress(s.runtime, r.personality))
165
+ }
166
+ return {
167
+ personality: r.personality,
168
+ description: r.description,
169
+ intelligence: r.intelligence,
170
+ default_runtime: r.default_runtime,
171
+ cwd: r.cwd,
172
+ runtimes,
173
+ ...(r.last_active_runtime ? { last_active_runtime: r.last_active_runtime } : {}),
174
+ ...(r.last_active_ms !== undefined ? { last_active_ms: r.last_active_ms } : {}),
175
+ attached: runtimes.some(s => s.attached === true),
176
+ launchd_managed: isLaunchdManaged(r.personality, env),
177
+ wake_policy: ephemeral ? ('ephemeral' as const) : ('warm' as const),
178
+ ...(ephemeral ? { queue_depth: queueDepth } : {}),
179
+ }
180
+ })
181
+ const mem = readMemoryProvider(env)
182
+ const voice = readVoiceProvider(env)
183
+ return {
184
+ api: FLEET_API_VERSION,
185
+ version: IAPEER_VERSION,
186
+ ts: new Date(nowMs).toISOString(),
187
+ host: {
188
+ version: IAPEER_VERSION,
189
+ pid: process.pid,
190
+ startedAt: new Date(startedAtMs).toISOString(),
191
+ uptimeSecs: Math.max(0, Math.floor((nowMs - startedAtMs) / 1000)),
192
+ memory: mem
193
+ ? { provider: mem.provider, version: mem.version, heartbeatAgeSecs: heartbeatAgeSecs(mem, nowMs) }
194
+ : null,
195
+ voice: voice
196
+ ? { provider: voice.provider, version: voice.version, heartbeatAgeSecs: heartbeatAgeSecs(voice, nowMs) }
197
+ : null,
198
+ fda: probeFullDiskAccess(env),
199
+ },
200
+ peers,
201
+ }
202
+ }
203
+
204
+ // ─────────────────────────────────────────────────────────────────────────────
205
+ // Events — tail-follow of the durable logs → SSE
206
+ // ─────────────────────────────────────────────────────────────────────────────
207
+
208
+ export interface FleetEvent extends ParsedEventLine {
209
+ /** Which log the event came from: lifecycle | delivery | exits. */
210
+ src: string
211
+ }
212
+
213
+ /** The three durable event logs under cfg.eventLogDir (exits.log shares the dir —
214
+ * lifecycle/index.ts passes exitLogDir: cfg.eventLogDir). */
215
+ export function fleetEventFiles(cfg: LifecycleConfig): Array<{ path: string; src: string }> {
216
+ return [
217
+ { path: lifecycleLogPath(cfg.eventLogDir), src: 'lifecycle' },
218
+ { path: deliveryLogPath(cfg.eventLogDir), src: 'delivery' },
219
+ { path: exitLogPath(cfg.eventLogDir), src: 'exits' },
220
+ ]
221
+ }
222
+
223
+ /** The newest `limit` events across the given logs (merged by timestamp) — the SSE
224
+ * `replay=N` primer and the peer-detail recent-events reader. Reads only the last
225
+ * 64 KiB of each file. */
226
+ export function readRecentEvents(
227
+ files: Array<{ path: string; src: string }>,
228
+ limit: number,
229
+ concerning?: string,
230
+ ): FleetEvent[] {
231
+ const events: FleetEvent[] = []
232
+ for (const f of files) {
233
+ for (const line of readLogTail(f.path).split('\n')) {
234
+ const e = parseEventLine(line)
235
+ if (!e) continue
236
+ if (concerning && !eventConcernsPeer(e, concerning)) continue
237
+ events.push({ ...e, src: f.src })
238
+ }
239
+ }
240
+ events.sort((a, b) => a.tsMs - b.tsMs)
241
+ return events.slice(-limit)
242
+ }
243
+
244
+ interface TailState {
245
+ path: string
246
+ src: string
247
+ offset: number
248
+ partial: string
249
+ }
250
+
251
+ /**
252
+ * Shared poll-based tail-follower over the durable logs: ONE timer regardless of
253
+ * how many SSE subscribers are connected (starts with the first, stops with the
254
+ * last). Rotation-aware: a size SMALLER than the held offset means the base file
255
+ * was rotated away (base → .1) — reading restarts at 0 on the fresh file. Poll is
256
+ * deliberately chosen over fs.watch: rotation-robust, no platform edge cases, and
257
+ * a few-hundred-ms latency is invisible on a dashboard.
258
+ */
259
+ export class FleetEventTailer {
260
+ private readonly files: TailState[]
261
+ private readonly subs = new Set<(e: FleetEvent) => void>()
262
+ private timer: ReturnType<typeof setInterval> | undefined
263
+ constructor(
264
+ files: Array<{ path: string; src: string }>,
265
+ private readonly pollMs = 400,
266
+ private readonly statSize: (path: string) => number | null = defaultStatSize,
267
+ private readonly readFrom: (path: string, offset: number, length: number) => string | null = defaultReadFrom,
268
+ ) {
269
+ this.files = files.map(f => ({ ...f, offset: -1, partial: '' }))
270
+ }
271
+
272
+ /** Subscribe to live events; returns the unsubscribe function. The FIRST
273
+ * subscriber primes every offset to the current EOF (history is the replay
274
+ * reader's job) and starts the poll timer; the last unsubscribe stops it. */
275
+ subscribe(fn: (e: FleetEvent) => void): () => void {
276
+ if (this.subs.size === 0) {
277
+ for (const f of this.files) {
278
+ f.offset = this.statSize(f.path) ?? 0
279
+ f.partial = ''
280
+ }
281
+ this.timer = setInterval(() => this.tick(), this.pollMs)
282
+ this.timer.unref?.()
283
+ }
284
+ this.subs.add(fn)
285
+ return () => {
286
+ this.subs.delete(fn)
287
+ if (this.subs.size === 0 && this.timer) {
288
+ clearInterval(this.timer)
289
+ this.timer = undefined
290
+ }
291
+ }
292
+ }
293
+
294
+ /** One poll pass over every file — exposed for tests (deterministic, no timer). */
295
+ tick(): void {
296
+ for (const f of this.files) {
297
+ const size = this.statSize(f.path)
298
+ if (size === null) {
299
+ // file gone (rotation window / never created yet) — restart at 0 when it appears
300
+ f.offset = 0
301
+ f.partial = ''
302
+ continue
303
+ }
304
+ if (size < f.offset) {
305
+ // rotated: base moved to .1, a fresh base is being written — restart
306
+ f.offset = 0
307
+ f.partial = ''
308
+ }
309
+ if (size === f.offset) continue
310
+ const chunk = this.readFrom(f.path, f.offset, size - f.offset)
311
+ if (chunk === null) continue // transient read failure — retry next tick
312
+ f.offset = size
313
+ const text = f.partial + chunk
314
+ const lines = text.split('\n')
315
+ f.partial = lines.pop() ?? '' // the trailing piece before the next newline
316
+ for (const line of lines) {
317
+ const e = parseEventLine(line)
318
+ if (!e) continue
319
+ for (const s of [...this.subs]) {
320
+ try {
321
+ s({ ...e, src: f.src })
322
+ } catch {
323
+ /* a subscriber must never break the tailer */
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ function defaultStatSize(path: string): number | null {
332
+ try {
333
+ return statSync(path).size
334
+ } catch {
335
+ return null
336
+ }
337
+ }
338
+
339
+ function defaultReadFrom(path: string, offset: number, length: number): string | null {
340
+ try {
341
+ const fd = openSync(path, 'r')
342
+ try {
343
+ const buf = Buffer.alloc(length)
344
+ const n = readSync(fd, buf, 0, length, offset)
345
+ return buf.subarray(0, n).toString('utf8')
346
+ } finally {
347
+ closeSync(fd)
348
+ }
349
+ } catch {
350
+ return null
351
+ }
352
+ }
353
+
354
+ // ─────────────────────────────────────────────────────────────────────────────
355
+ // HTTP handler
356
+ // ─────────────────────────────────────────────────────────────────────────────
357
+
358
+ const JSON_HEADERS = { 'content-type': 'application/json' }
359
+ const MAX_BODY_BYTES = 64 * 1024
360
+ const SSE_HEARTBEAT_MS = 15_000
361
+ const MAX_REPLAY = 500
362
+
363
+ function sendJson(res: ServerResponse, status: number, value: unknown): void {
364
+ res.writeHead(status, JSON_HEADERS)
365
+ res.end(`${JSON.stringify(value)}\n`)
366
+ }
367
+
368
+ function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
369
+ return new Promise((resolve, reject) => {
370
+ const chunks: Buffer[] = []
371
+ let size = 0
372
+ req.on('data', (c: Buffer) => {
373
+ size += c.length
374
+ if (size > MAX_BODY_BYTES) {
375
+ reject(new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`))
376
+ req.destroy()
377
+ return
378
+ }
379
+ chunks.push(c)
380
+ })
381
+ req.on('end', () => {
382
+ const raw = Buffer.concat(chunks).toString('utf8').trim()
383
+ if (!raw) {
384
+ resolve({})
385
+ return
386
+ }
387
+ try {
388
+ const parsed = JSON.parse(raw) as unknown
389
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
390
+ reject(new Error('request body must be a JSON object'))
391
+ return
392
+ }
393
+ resolve(parsed as Record<string, unknown>)
394
+ } catch (e) {
395
+ reject(new Error(`invalid JSON body: ${e instanceof Error ? e.message : String(e)}`))
396
+ }
397
+ })
398
+ req.on('error', reject)
399
+ })
400
+ }
401
+
402
+ /** Resolve the SENDER identity for POST /fleet/v1/send. Explicit `from` (either a
403
+ * full `<runtime>-<personality>` or a bare personality → its default runtime) wins;
404
+ * otherwise the SINGLE natural-intelligence peer of the registry (the host owner —
405
+ * a GUI-originated message is the human's). Ambiguity → an instructive error. */
406
+ export function resolveSendCaller(from: string | undefined, env: NodeJS.ProcessEnv): ResolvedCaller {
407
+ const index = readPeersIndex({ env })
408
+ if (from?.trim()) {
409
+ const raw = from.trim()
410
+ const byName = index.peers.find(p => p.personality === raw)
411
+ if (byName) return resolveCallerIdentity({ personality: byName.personality, runtime: byName.runtime }, index)
412
+ const dash = raw.indexOf('-')
413
+ if (dash > 0) {
414
+ return resolveCallerIdentity({ runtime: raw.slice(0, dash), personality: raw.slice(dash + 1) }, index)
415
+ }
416
+ throw new Error(`unknown from peer "${raw}"`)
417
+ }
418
+ const natural = index.peers.filter(p => p.intelligence === 'natural')
419
+ if (natural.length === 1) {
420
+ return resolveCallerIdentity({ personality: natural[0]!.personality, runtime: natural[0]!.runtime }, index)
421
+ }
422
+ throw new Error(
423
+ natural.length === 0
424
+ ? 'no natural-intelligence peer registered — pass "from" explicitly'
425
+ : `ambiguous default sender (${natural.length} natural peers) — pass "from" explicitly`,
426
+ )
427
+ }
428
+
429
+ export interface FleetHandlerOptions {
430
+ env?: NodeJS.ProcessEnv
431
+ /** Injectable CLI ops (tests). Default: lazy dynamic import of cli/index.ts. */
432
+ ops?: FleetOps
433
+ /** Injectable tailer poll interval (tests). */
434
+ pollMs?: number
435
+ }
436
+
437
+ export type FleetHandler = (
438
+ req: IncomingMessage,
439
+ res: ServerResponse,
440
+ ctx: { deps: CallToolDeps },
441
+ ) => Promise<void>
442
+
443
+ /**
444
+ * Build the /fleet/v1 request handler the composition point injects into
445
+ * startDaemon (StartDaemonOptions.fleet). One handler instance per daemon —
446
+ * it owns the shared event tailer and the daemon start timestamp.
447
+ */
448
+ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler {
449
+ const env = opts.env ?? process.env
450
+ const cfg = loadLifecycleConfig(env)
451
+ const startedAtMs = Date.now()
452
+ const files = fleetEventFiles(cfg)
453
+ const tailer = new FleetEventTailer(files, opts.pollMs)
454
+ let opsPromise: Promise<FleetOps> | undefined
455
+ const getOps = (): Promise<FleetOps> => {
456
+ if (opts.ops) return Promise.resolve(opts.ops)
457
+ opsPromise ??= defaultOps()
458
+ return opsPromise
459
+ }
460
+
461
+ return async (req, res, ctx) => {
462
+ const url = new URL(req.url ?? '/', 'http://fleet.local')
463
+ const path = url.pathname
464
+ const method = (req.method ?? 'GET').toUpperCase()
465
+ try {
466
+ // ── reads ────────────────────────────────────────────────────────────
467
+ if (path === '/fleet/v1/snapshot') {
468
+ if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/snapshot' })
469
+ return sendJson(res, 200, buildFleetSnapshot(env, cfg, await getOps(), startedAtMs))
470
+ }
471
+ if (path === '/fleet/v1/events') {
472
+ if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/events' })
473
+ serveEvents(req, res, url, tailer, files)
474
+ return
475
+ }
476
+ const peerMatch = /^\/fleet\/v1\/peers\/([^/]+)$/.exec(path)
477
+ if (peerMatch) {
478
+ if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/peers/<peer>' })
479
+ const personality = decodeURIComponent(peerMatch[1]!)
480
+ const snapshot = buildFleetSnapshot(env, cfg, await getOps(), startedAtMs)
481
+ const peer = snapshot.peers.find(p => p.personality === personality)
482
+ if (!peer) return sendJson(res, 404, { error: `unknown peer "${personality}"` })
483
+ const events = readRecentEvents(files, 50, personality)
484
+ return sendJson(res, 200, { api: FLEET_API_VERSION, version: IAPEER_VERSION, ts: snapshot.ts, peer, events })
485
+ }
486
+ // ── commands ─────────────────────────────────────────────────────────
487
+ const cmdMatch = /^\/fleet\/v1\/peers\/([^/]+)\/(wake|stop|start|new|refresh|interrupt|compact)$/.exec(path)
488
+ if (cmdMatch) {
489
+ if (method !== 'POST') return sendJson(res, 405, { error: 'method not allowed — POST' })
490
+ const personality = decodeURIComponent(cmdMatch[1]!)
491
+ const command = cmdMatch[2]!
492
+ let body: Record<string, unknown>
493
+ try {
494
+ body = await readBody(req)
495
+ } catch (e) {
496
+ return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
497
+ }
498
+ const runtime = typeof body.runtime === 'string' ? body.runtime : undefined
499
+ const topic = typeof body.topic === 'string' ? body.topic : undefined
500
+ return await runCommand(res, command, { personality, runtime, topic }, cfg, env, await getOps())
501
+ }
502
+ if (path === '/fleet/v1/send') {
503
+ if (method !== 'POST') return sendJson(res, 405, { error: 'method not allowed — POST /fleet/v1/send' })
504
+ let body: Record<string, unknown>
505
+ try {
506
+ body = await readBody(req)
507
+ } catch (e) {
508
+ return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
509
+ }
510
+ if (typeof body.personality !== 'string' || !body.personality || typeof body.message !== 'string' || !body.message) {
511
+ return sendJson(res, 400, { error: 'send needs {personality, message} (+ optional runtime/topic/from)' })
512
+ }
513
+ let caller: ResolvedCaller
514
+ try {
515
+ caller = resolveSendCaller(typeof body.from === 'string' ? body.from : undefined, env)
516
+ } catch (e) {
517
+ return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
518
+ }
519
+ // The SAME path the MCP tool takes (routeSend + delivery.log + post-delivery
520
+ // hooks) — ctx.deps is the daemon's own wired CallToolDeps.
521
+ const result = await callTool(
522
+ caller,
523
+ 'send_to_peer',
524
+ {
525
+ personality: body.personality,
526
+ runtime: typeof body.runtime === 'string' ? body.runtime : undefined,
527
+ message: body.message,
528
+ topic: typeof body.topic === 'string' ? body.topic : undefined,
529
+ },
530
+ ctx.deps,
531
+ )
532
+ const text = result.content[0]?.text ?? ''
533
+ if (result.isError) return sendJson(res, 502, { error: text })
534
+ return sendJson(res, 200, { from: caller.address, result: safeParse(text) })
535
+ }
536
+ return sendJson(res, 404, {
537
+ error: `unknown fleet endpoint ${method} ${path}`,
538
+ endpoints: [
539
+ 'GET /fleet/v1/snapshot',
540
+ 'GET /fleet/v1/peers/<peer>',
541
+ 'GET /fleet/v1/events?replay=N',
542
+ 'POST /fleet/v1/peers/<peer>/(wake|stop|start|new|refresh|interrupt|compact)',
543
+ 'POST /fleet/v1/send',
544
+ ],
545
+ })
546
+ } catch (e) {
547
+ if (!res.headersSent) {
548
+ sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) })
549
+ } else {
550
+ try {
551
+ res.end()
552
+ } catch {
553
+ /* connection already gone */
554
+ }
555
+ }
556
+ }
557
+ }
558
+ }
559
+
560
+ function safeParse(text: string): unknown {
561
+ try {
562
+ return JSON.parse(text)
563
+ } catch {
564
+ return text
565
+ }
566
+ }
567
+
568
+ async function runCommand(
569
+ res: ServerResponse,
570
+ command: string,
571
+ args: { personality: string; runtime?: string; topic?: string },
572
+ cfg: LifecycleConfig,
573
+ env: NodeJS.ProcessEnv,
574
+ ops: FleetOps,
575
+ ): Promise<void> {
576
+ const { personality, runtime, topic } = args
577
+ switch (command) {
578
+ case 'wake': {
579
+ // Same H4 / crash-loop / wake-lock semantics as a message wake — wakeOrSpawn
580
+ // owns them. task:'' = bare bring-up (the seed/initial prompt is the first turn).
581
+ const r = await wakeOrSpawn({ personality, runtime, topic, task: '' }, { cfg, env })
582
+ return sendJson(res, r.status === 'FAILED' ? 502 : 200, { command, ...r })
583
+ }
584
+ case 'stop': {
585
+ const outcomes = ops.stopPeer(personality, runtime, { env })
586
+ const failed = outcomes.some(o => o.action === 'refused-foreign-launchd' || (o.action === 'bootout' && o.reason !== undefined))
587
+ return sendJson(res, failed ? 502 : 200, { command, outcomes })
588
+ }
589
+ case 'start': {
590
+ const outcomes = ops.startPeer(personality, runtime, { env })
591
+ const failed = outcomes.some(o => o.action === 'refused-foreign-launchd' || (o.action === 'bootstrap' && o.reason !== undefined))
592
+ return sendJson(res, failed ? 502 : 200, { command, outcomes })
593
+ }
594
+ case 'new': {
595
+ const o = await ops.newPeer(personality, runtime, { env })
596
+ return sendJson(res, o.action === 'fresh' ? 200 : 502, { command, ...o })
597
+ }
598
+ case 'refresh': {
599
+ const outcomes = ops.refreshPeer(personality, runtime, { env })
600
+ return sendJson(res, 200, { command, outcomes })
601
+ }
602
+ case 'interrupt': {
603
+ const r = await routeControl(personality, runtime, { name: 'interrupt' })
604
+ if (!r.ok) return sendJson(res, 502, { command, error: r.error.message })
605
+ return sendJson(res, 200, { ...r.value, command }) // routeControl echoes its own `command` — ours (the endpoint name) wins
606
+ }
607
+ case 'compact': {
608
+ const o = await ops.compactPeer(personality, runtime, { env })
609
+ return sendJson(res, o.action === 'compacted' ? 200 : 502, { command, ...o })
610
+ }
611
+ default:
612
+ return sendJson(res, 404, { error: `unknown command "${command}"` })
613
+ }
614
+ }
615
+
616
+ /** Serve the SSE event stream: optional replay of the newest N historical events,
617
+ * then live tail. At-least-once semantics (a reconnect may re-see events — `id`
618
+ * is the event's epoch-ms for client-side dedup); heartbeat comment every 15 s. */
619
+ function serveEvents(
620
+ req: IncomingMessage,
621
+ res: ServerResponse,
622
+ url: URL,
623
+ tailer: FleetEventTailer,
624
+ files: Array<{ path: string; src: string }>,
625
+ ): void {
626
+ res.writeHead(200, {
627
+ 'content-type': 'text/event-stream',
628
+ 'cache-control': 'no-cache',
629
+ connection: 'keep-alive',
630
+ })
631
+ const writeEvent = (e: FleetEvent): void => {
632
+ const data = JSON.stringify({ src: e.src, ev: e.ev, ...e.fields })
633
+ res.write(`event: ${e.ev}\nid: ${e.tsMs}\ndata: ${data}\n\n`)
634
+ }
635
+ const replayRaw = parseInt(url.searchParams.get('replay') ?? '', 10)
636
+ const replay = Number.isFinite(replayRaw) ? Math.max(0, Math.min(MAX_REPLAY, replayRaw)) : 0
637
+ if (replay > 0) for (const e of readRecentEvents(files, replay)) writeEvent(e)
638
+ res.write(`: connected\n\n`)
639
+ const unsubscribe = tailer.subscribe(writeEvent)
640
+ const heartbeat = setInterval(() => {
641
+ try {
642
+ res.write(`: hb\n\n`)
643
+ } catch {
644
+ /* torn connection — close handler cleans up */
645
+ }
646
+ }, SSE_HEARTBEAT_MS)
647
+ heartbeat.unref?.()
648
+ const cleanup = (): void => {
649
+ clearInterval(heartbeat)
650
+ unsubscribe()
651
+ }
652
+ res.on('close', cleanup)
653
+ req.on('close', cleanup)
654
+ }