@agfpd/iapeer 0.4.62 → 0.4.64

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.
@@ -359,6 +359,23 @@ export interface DaemonHandle {
359
359
  close: () => Promise<void>
360
360
  }
361
361
 
362
+ /**
363
+ * The fleet-API request handler seam (Ф0 iapeer-tray) — the OPERATOR-CLIENT HTTP
364
+ * surface (/fleet/v1/*: snapshot + SSE events + commands), served on the SAME
365
+ * listeners behind the SAME bearer gate as the MCP path. Injected by the
366
+ * composition point (daemon/main.ts wires daemon/fleet.ts's buildFleetHandler) so
367
+ * the daemon library never imports lifecycle/CLI — the same seam pattern as
368
+ * wake/supervise. `ctx.deps` is the daemon's wired CallToolDeps: the fleet send
369
+ * command routes through the SAME callTool path as the MCP tool (delivery.log +
370
+ * post-delivery hooks included). OFF by default (library/tests): without the seam,
371
+ * /fleet/* URLs fall through to the MCP handler like any other request.
372
+ */
373
+ export type FleetRequestHandler = (
374
+ req: IncomingMessage,
375
+ res: ServerResponse,
376
+ ctx: { deps: CallToolDeps },
377
+ ) => Promise<void>
378
+
362
379
  export interface StartDaemonOptions extends StorageOptions {
363
380
  /**
364
381
  * Unix-socket path (H8 base: 0600, same-uid local callers — CLI / pane /
@@ -439,6 +456,12 @@ export interface StartDaemonOptions extends StorageOptions {
439
456
  /** H4 launchd-managed detector — see CallToolDeps.isLaunchdManaged / the launchd-revive
440
457
  * delivery retry. OFF by default (library/tests); production main wires the real one. */
441
458
  isLaunchdManaged?: (personality: string) => boolean
459
+ /**
460
+ * Fleet-API handler (see FleetRequestHandler) — serves /fleet/v1/* on both
461
+ * listeners, behind the bearer gate. OFF by default; the production main wires
462
+ * daemon/fleet.ts's buildFleetHandler. Advertised in router.json as `fleet: 1`.
463
+ */
464
+ fleet?: FleetRequestHandler
442
465
  /**
443
466
  * Write the discovery file (router.json) at <root>/state/iapeer/router.json with
444
467
  * the active addresses `{sock, tcp}` — atomically on listen, removed on close.
@@ -456,27 +479,51 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
456
479
  const host = opts.host ?? '127.0.0.1'
457
480
  const bearer = opts.bearerToken?.trim() || undefined
458
481
 
459
- // ONE MCP request handler, shared by EVERY listener (socket + TCP). Stateless:
482
+ // The ONE wired CallToolDeps object — shared by the per-request MCP servers AND
483
+ // the fleet-API handler (its send command routes through the SAME callTool path,
484
+ // so a fleet-originated send gets the identical delivery.log line + hooks).
485
+ const callDeps: CallToolDeps = {
486
+ env: opts.env, // Б7 — thread the daemon's storage env into the request path (registry read + routeSend)
487
+ wake: opts.wake,
488
+ deliveryLogDir: opts.deliveryLogDir,
489
+ onDelivered: opts.onDelivered,
490
+ ephemeral: opts.ephemeral,
491
+ noteLiveTopic: opts.noteLiveTopic,
492
+ noteDelivered: opts.noteDelivered,
493
+ composerQueue: opts.composerQueue,
494
+ isLaunchdManaged: opts.isLaunchdManaged,
495
+ }
496
+
497
+ // ONE HTTP request handler, shared by EVERY listener (socket + TCP): the bearer
498
+ // gate first, then the fleet-API branch (when wired), then stateless MCP —
460
499
  // a fresh Server + transport per request, fully isolated.
461
500
  const handle = (req: IncomingMessage, res: ServerResponse): void => {
462
- // H8 bearer layer (FIRST, before any MCP work): when a token is configured,
501
+ // H8 bearer layer (FIRST, before any MCP/fleet work): when a token is configured,
463
502
  // reject anything not carrying `Authorization: Bearer <token>`. Off when unset.
464
503
  if (bearer && req.headers.authorization !== `Bearer ${bearer}`) {
465
504
  res.writeHead(401, { 'content-type': 'application/json', 'www-authenticate': 'Bearer' })
466
505
  res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'unauthorized' } }))
467
506
  return
468
507
  }
469
- const server = createMcpServer({
470
- env: opts.env, // Б7 — thread the daemon's storage env into the request path (registry read + routeSend)
471
- wake: opts.wake,
472
- deliveryLogDir: opts.deliveryLogDir,
473
- onDelivered: opts.onDelivered,
474
- ephemeral: opts.ephemeral,
475
- noteLiveTopic: opts.noteLiveTopic,
476
- noteDelivered: opts.noteDelivered,
477
- composerQueue: opts.composerQueue,
478
- isLaunchdManaged: opts.isLaunchdManaged,
479
- })
508
+ // Fleet-API branch (Ф0 iapeer-tray): /fleet/* is the operator-client surface.
509
+ // Deliberately AFTER the bearer gate and BEFORE MCP — the two surfaces share
510
+ // listeners but never overlap in URL space.
511
+ if (opts.fleet && req.url && (req.url === '/fleet' || req.url.startsWith('/fleet/'))) {
512
+ void opts.fleet(req, res, { deps: callDeps }).catch(() => {
513
+ if (!res.headersSent) {
514
+ res.writeHead(500, { 'content-type': 'application/json' })
515
+ res.end(JSON.stringify({ error: 'internal error' }))
516
+ } else {
517
+ try {
518
+ res.end()
519
+ } catch {
520
+ /* connection already gone */
521
+ }
522
+ }
523
+ })
524
+ return
525
+ }
526
+ const server = createMcpServer(callDeps)
480
527
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
481
528
  res.on('close', () => {
482
529
  transport.close()
@@ -568,7 +615,12 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
568
615
  // `version` (В53): the RUNNING daemon's baked version — lets `iapeer update`
569
616
  // detect an interrupted update (binary already at latest, live daemon still on
570
617
  // the old code) and heal it with a restart instead of a false "already-latest".
571
- writeFileAtomic(discoveryPath, `${JSON.stringify({ sock: socketPath ?? null, tcp: url ?? null, version: IAPEER_VERSION })}\n`)
618
+ // `fleet` (Ф0 iapeer-tray): the fleet-API capability marker — a client reads it
619
+ // to know this daemon serves /fleet/v1/* (absent on pre-fleet daemons).
620
+ writeFileAtomic(
621
+ discoveryPath,
622
+ `${JSON.stringify({ sock: socketPath ?? null, tcp: url ?? null, version: IAPEER_VERSION, ...(opts.fleet ? { fleet: 1 } : {}) })}\n`,
623
+ )
572
624
  }
573
625
 
574
626
  const removeArtifacts = (): void => {
@@ -66,6 +66,7 @@ import {
66
66
  } from '../transport/index.ts'
67
67
  import { iapeerBinPath } from '../install/index.ts'
68
68
  import { appendDeliveryEvent } from './deliverylog.ts'
69
+ import { buildFleetHandler } from './fleet.ts'
69
70
  import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
70
71
 
71
72
  /** Default TCP loopback port for the always-on router. Real http MCP clients
@@ -362,6 +363,11 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
362
363
  // launchd-managed target (the daemon can't wake it, but KeepAlive revives it) → retry-
363
364
  // resolve for a bounded window instead of failing in ~16ms — bridges a router restart.
364
365
  isLaunchdManaged: (personality: string) => isLaunchdManaged(personality, env),
366
+ // Fleet-API (Ф0 iapeer-tray): the operator-client surface /fleet/v1/* — snapshot
367
+ // (the same listPeers truth as `iapeer list`), SSE events (tail of the durable
368
+ // logs), commands over the existing verb functions. Same listeners, same bearer
369
+ // gate; advertised in router.json as fleet:1. Contract: docs/15-fleet-api.md.
370
+ fleet: buildFleetHandler({ env }),
365
371
  supervise: {
366
372
  intervalMs: opts.superviseIntervalMs ?? DEFAULT_SUPERVISE_INTERVAL_MS,
367
373
  // idle-reap / zombie-sweep, THEN the eager fresh re-launch for any peer whose
Binary file
@@ -25,6 +25,7 @@ import {
25
25
  splitDanglingEscape,
26
26
  stripQueries,
27
27
  } from './protocol.ts'
28
+ import { formatEventLine } from '../storage/rotatelog.ts'
28
29
  import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry, writePidFile } from './paths.ts'
29
30
  import { newStuckGate, nextBootAction, nextNagAction, paneIsStuck, type BootPredicates, type NagPredicate } from './boot.ts'
30
31
  import { modelToPlainText } from './render.ts'
@@ -289,6 +290,30 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
289
290
  return ok
290
291
  }
291
292
 
293
+ // ── death-trace (fleet-API follow-up, 05.07) ──────────────────────────────────
294
+ // exits.log is the durable «why did this session end» record. Before this, ONLY a
295
+ // child that died on its own (exit callback below) wrote a line; a SIGTERM'd
296
+ // supervisor (reap / stop / new / self-fresh — EVERY daemon-initiated teardown)
297
+ // killed the child and process.exit'ed before the callback could run, and a
298
+ // supervisor crash left nothing at all — exits.log stayed silent for weeks while
299
+ // sessions died (live gap: claude-boris server-dead 04.07 23:59 with ZERO forensics;
300
+ // the supervise classification even points the investigator at exits.log). Now every
301
+ // in-process exit path writes exactly one line: `exitLogged` dedups the child-exit
302
+ // callback vs the shutdown path. logfmt via the shared writer (quoted values parse
303
+ // intact in the fleet SSE stream). Out-of-process deaths (SIGKILL / OOM / Bun panic)
304
+ // remain unrecordable from here by nature — for those the supervise-tick reaped-gone
305
+ // classification in lifecycle.log stays the only trace.
306
+ let exitLogged = false
307
+ const appendExitEvent = (fields: Record<string, string | number | undefined>): void => {
308
+ if (!opts.exitLogPath) return
309
+ try {
310
+ mkdirSync(dirname(opts.exitLogPath), { recursive: true, mode: 0o700 })
311
+ appendFileSync(opts.exitLogPath, `${formatEventLine(Date.now(), fields)}\n`)
312
+ } catch {
313
+ /* best-effort — the trace must never block serving/teardown */
314
+ }
315
+ }
316
+
292
317
  const child = Bun.spawn(CMD, {
293
318
  cwd: opts.cwd ?? runDir,
294
319
  env,
@@ -337,20 +362,18 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
337
362
  // supervise-tick death classification + crash-loop accounting have the cause. host=supervisor
338
363
  // distinguishes the source. The Bun-native pty exit callback passes (exitCode, signalCode) like
339
364
  // onExit; fall back to the subprocess props. Best-effort — never blocks shutdown.
340
- if (opts.exitLogPath) {
341
- try {
342
- mkdirSync(dirname(opts.exitLogPath), { recursive: true, mode: 0o700 }) // ensure the exits.log dir exists
343
- const c = child as { exitCode?: number | null; signalCode?: number | null }
344
- const status = exitCode ?? c.exitCode ?? ''
345
- const signal = signalCode ?? c.signalCode ?? ''
346
- appendFileSync(
347
- opts.exitLogPath,
348
- `ts=${new Date().toISOString()} ev=session-exit identity=${session} dead_status=${status} dead_signal=${signal} host=supervisor\n`,
349
- )
350
- } catch {
351
- /* best-effort */
352
- }
353
- }
365
+ const c = child as { exitCode?: number | null; signalCode?: number | null }
366
+ const status = exitCode ?? c.exitCode
367
+ const signal = signalCode ?? c.signalCode
368
+ exitLogged = true // this IS the death record — shutdown below must not write a second one
369
+ appendExitEvent({
370
+ ev: 'session-exit',
371
+ identity: session,
372
+ dead_status: status ?? '',
373
+ dead_signal: signal ?? '',
374
+ cause: 'child-exit',
375
+ host: 'supervisor',
376
+ })
354
377
  shutdown()
355
378
  },
356
379
  },
@@ -554,9 +577,17 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
554
577
  writeGeometry(runDir, session, serveCols, serveRows) // initial geometry sidecar (= serve); updated on every real child resize
555
578
 
556
579
  let down = false
557
- function shutdown(): void {
580
+ function shutdown(cause = 'shutdown'): void {
558
581
  if (down) return
559
582
  down = true
583
+ // Death-trace: a signalled shutdown kills the child below and process.exit()s
584
+ // immediately — the child exit callback never runs, so THIS is the only chance
585
+ // to record the session's end. `exitLogged` is already set when the child died
586
+ // on its own (the callback wrote the authoritative dead_status/dead_signal line).
587
+ if (!exitLogged) {
588
+ exitLogged = true
589
+ appendExitEvent({ ev: 'session-exit', identity: session, cause, host: 'supervisor' })
590
+ }
560
591
  if (paneLogFd >= 0)
561
592
  try {
562
593
  closeSync(paneLogFd)
@@ -582,18 +613,31 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
582
613
  }
583
614
  process.exit(0)
584
615
  }
585
- process.on('SIGTERM', shutdown)
586
- process.on('SIGINT', shutdown)
616
+ process.on('SIGTERM', () => shutdown('shutdown-sigterm'))
617
+ process.on('SIGINT', () => shutdown('shutdown-sigint'))
587
618
  process.on('SIGHUP', () => {
588
619
  /* daemon survives client window close */
589
620
  })
590
- process.on('uncaughtException', e => {
621
+ // A survivable fault must still leave a trace: the console.error goes to the
622
+ // detached daemon's session log, which sat at 0 bytes for weeks on the live fleet —
623
+ // effectively invisible. Record it in exits.log too (ev=supervisor-uncaught; the
624
+ // session keeps serving — killing it here would regress the detach-crash robustness),
625
+ // so a later wedge/death postmortem can see the fault that preceded it.
626
+ const traceFault = (kind: string, e: unknown): void => {
591
627
  try {
592
- console.error('[supervisor-daemon] uncaught', (e && (e as Error).stack) || e)
628
+ console.error(`[supervisor-daemon] ${kind}`, (e && (e as Error).stack) || e)
593
629
  } catch {
594
630
  /* */
595
631
  }
596
- })
632
+ appendExitEvent({
633
+ ev: 'supervisor-uncaught',
634
+ identity: session,
635
+ kind,
636
+ reason: String((e as Error)?.stack ?? e).replace(/\s+/g, ' ').slice(0, 300),
637
+ })
638
+ }
639
+ process.on('uncaughtException', e => traceFault('exception', e))
640
+ process.on('unhandledRejection', e => traceFault('rejection', e))
597
641
 
598
642
  // ── boot-dialog driver (serving slice a) ────────────────────────────────────────
599
643
  // For a real runtime, answer the startup dialogs (codex trust/update/hooks, claude trust/resume)
@@ -6,12 +6,12 @@
6
6
  // All paths resolve through the injected env → the same IAPEER_ROOT isolation as
7
7
  // every other side-effect (sandbox rule). Reads only — the dashboard mutates nothing.
8
8
 
9
- import { closeSync, openSync, readSync, statSync } from 'node:fs'
10
9
  import { join } from 'node:path'
11
10
  import { listPeers, type PeerListing } from '../../cli/index.ts'
12
11
  import { loadLifecycleConfig } from '../../lifecycle/index.ts'
13
12
  import { deliveryLogPath } from '../../daemon/deliverylog.ts'
14
13
  import { lifecycleLogPath } from '../../lifecycle/eventlog.ts'
14
+ import { readLogTail } from '../../storage/rotatelog.ts'
15
15
  import { assemblePeerLog } from './model.ts'
16
16
 
17
17
  /** One peers snapshot (the poll unit). listPeers is synchronous and cheap: registry
@@ -42,26 +42,10 @@ export async function takeHostHeader(env: NodeJS.ProcessEnv): Promise<HostHeader
42
42
  }
43
43
  }
44
44
 
45
- /** Read the last `bytes` of a file without loading the whole log (delivery.log can
46
- * be MBs before rotation). Missing/unreadable file → ''. */
47
- export function readTail(path: string, bytes = 64 * 1024): string {
48
- try {
49
- const size = statSync(path).size
50
- const fd = openSync(path, 'r')
51
- try {
52
- const start = Math.max(0, size - bytes)
53
- const buf = Buffer.alloc(size - start)
54
- readSync(fd, buf, 0, buf.length, start)
55
- // drop the leading partial line when we started mid-file
56
- const s = buf.toString('utf8')
57
- return start > 0 ? s.slice(s.indexOf('\n') + 1) : s
58
- } finally {
59
- closeSync(fd)
60
- }
61
- } catch {
62
- return ''
63
- }
64
- }
45
+ /** Read the last `bytes` of a file without loading the whole log. Promoted to the
46
+ * storage layer (readLogTail — the fleet-API replay reader shares it); kept as a
47
+ * re-export so dashboard call sites/tests are untouched. */
48
+ export const readTail: (path: string, bytes?: number) => string = readLogTail
65
49
 
66
50
  /** The per-peer log panel content: newest `limit` events concerning the peer from
67
51
  * delivery.log + lifecycle.log (merged by timestamp). */
@@ -8,6 +8,7 @@
8
8
  // under `bun test` with zero terminal plumbing.
9
9
 
10
10
  import type { PeerListing } from '../../cli/index.ts'
11
+ import { eventConcernsPeer as concernsPeer, parseEventLine as parseLogfmtLine } from '../../storage/rotatelog.ts'
11
12
 
12
13
  // ─── row filtering ───────────────────────────────────────────────────────────
13
14
 
@@ -55,36 +56,22 @@ export interface LogEvent {
55
56
  fields: Record<string, string>
56
57
  }
57
58
 
58
- /** Parse one `ts=<iso> ev=<kind> k=v …` line; null for anything else (corrupt tail). */
59
+ /** Parse one `ts=<iso> ev=<kind> k=v …` line; null for anything else (corrupt tail).
60
+ * Delegates to the storage-layer QUOTE-AWARE parser (the writer's symmetric read
61
+ * half) and adds the display time — the earlier display-only tokenizer split quoted
62
+ * multi-word values (reason="idle 3700s") mid-value. */
59
63
  export function parseEventLine(line: string): LogEvent | null {
60
- if (!line.startsWith('ts=')) return null
61
- const fields: Record<string, string> = {}
62
- // k=v tokens; values are token-atomic in our append format (no embedded spaces).
63
- for (const tok of line.split(' ')) {
64
- const i = tok.indexOf('=')
65
- if (i > 0) fields[tok.slice(0, i)] = tok.slice(i + 1)
66
- }
67
- const ts = fields.ts
68
- const ev = fields.ev
69
- if (!ts || !ev) return null
70
- const tsMs = Date.parse(ts)
71
- if (!Number.isFinite(tsMs)) return null
72
- const d = new Date(tsMs)
64
+ const p = parseLogfmtLine(line)
65
+ if (!p) return null
66
+ const d = new Date(p.tsMs)
73
67
  const pad = (n: number): string => String(n).padStart(2, '0')
74
- return { tsMs, time: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`, ev, fields }
68
+ return { tsMs: p.tsMs, time: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`, ev: p.ev, fields: p.fields }
75
69
  }
76
70
 
77
71
  /** Does this event concern the peer? Matches bare personality and any `<rt>-<personality>`
78
- * identity in the fields the two logs actually carry (to/caller/via/personality/identity). */
79
- export function eventConcernsPeer(e: LogEvent, personality: string): boolean {
80
- const idSuffix = `-${personality}`
81
- for (const key of ['to', 'caller', 'via', 'personality', 'identity', 'from', 'peer'] as const) {
82
- const v = e.fields[key]
83
- if (!v) continue
84
- if (v === personality || v.endsWith(idSuffix)) return true
85
- }
86
- return false
87
- }
72
+ * identity in the fields the two logs actually carry (to/caller/via/personality/identity).
73
+ * Re-exported from the storage layer (the fleet peer-detail endpoint shares it). */
74
+ export const eventConcernsPeer: (e: LogEvent, personality: string) => boolean = concernsPeer
88
75
 
89
76
  /** One display line per event — compact, the interesting fields only. */
90
77
  export function formatEvent(e: LogEvent): { text: string; tone: 'ok' | 'fail' | 'info' } {