@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.
@@ -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
@@ -100,6 +100,35 @@ function isFullscreenNagOptionRow(row: string): boolean {
100
100
  return row.includes('❯') && (row.includes(NAG_OPTION_YES) || /❯\s*1\.\s*Yes/.test(row))
101
101
  }
102
102
 
103
+ // Dangerous-rm circuit-breaker needles — split-literal (same В40 discipline: a peer reviewing THIS
104
+ // adapter / the doctrine must not self-trigger). Verified verbatim against claude 2.1.201 (Mach-O
105
+ // strings + a live pty capture): the breaker fires for `rm` AND `rmdir` on the cwd/an ancestor, above
106
+ // the permission layer, with a `❯ 1. Yes / 2. No` select whose default cursor is on YES.
107
+ const RM_ANCESTOR = 'working directory or its ' + 'ancestor'
108
+ const RM_PROCEED = 'Do you want to ' + 'proceed?'
109
+ /** FULL-signature + position match for the LIVE dangerous-rm/rmdir confirm (not a mere quote of it):
110
+ * the breaker phrase + the proceed prompt + the bottom-most `❯` cursor sitting on the "1. Yes" option. */
111
+ function isDangerousRmPrompt(pane: string): boolean {
112
+ if (!pane.includes(RM_ANCESTOR) || !pane.includes(RM_PROCEED)) return false
113
+ let lastCursorRow = ''
114
+ for (const line of pane.split(/\r?\n/)) if (line.includes('❯')) lastCursorRow = line
115
+ return /❯\s*1\.\s*Yes\b/.test(lastCursorRow) // live select on Yes; a quote has the ready composer `❯` below
116
+ }
117
+ /** Best-effort one-line trace of WHAT the breaker guarded — the rm/rmdir command line if the pane still
118
+ * shows the "Bash command" block, plus the target path printed after "…ancestor:". Owner's post-hoc
119
+ * audit hook (which peer / which command / when — `when` + `who` are added by the daemon). */
120
+ function dangerousRmDetail(pane: string): string {
121
+ const lines = pane.split(/\r?\n/).map(l => l.trim())
122
+ let target = ''
123
+ const ai = lines.findIndex(l => l.includes(RM_ANCESTOR))
124
+ if (ai >= 0) for (let j = ai + 1; j < lines.length; j++) if (lines[j]) { target = lines[j]; break }
125
+ // ANCHORED to a line that STARTS with rm/rmdir (the "Bash command" block's command line, e.g.
126
+ // "rm -rf /path"); never a mid-line match, or the composer's echoed user prompt ("…run: rm -rf")
127
+ // would be captured instead of the actual command.
128
+ const cmd = lines.find(l => /^rm(?:dir)?\s+\S/.test(l)) ?? ''
129
+ return [cmd && `cmd=${JSON.stringify(cmd)}`, target && `target=${JSON.stringify(target)}`].filter(Boolean).join(' ') || '(unparsed)'
130
+ }
131
+
103
132
  // ─────────────────────────────────────────────────────────────────────────────
104
133
  // Transcript activity proxy + resume uuid (lifecycle port, claude slug fix)
105
134
  // ─────────────────────────────────────────────────────────────────────────────
@@ -299,6 +328,21 @@ export const claudeAdapter: RuntimeAdapter = {
299
328
  return isFullscreenNagOptionRow(lastCursorRow) ? ['2', 'Enter'] : null
300
329
  },
301
330
 
331
+ /**
332
+ * Dangerous-rm/rmdir circuit-breaker (RuntimeAdapter.blockingConfirm). Unlike the fullscreen nag
333
+ * (benign upsell → DECLINE), this guard sits ABOVE `--dangerously-skip-permissions` and hangs a
334
+ * headless peer forever with no human to answer. Owner decision (Артур, 04.07): auto-press YES —
335
+ * peers run on bypass, so this only restores the pre-2.1.x status quo, and a hang is real recurring
336
+ * harm. The default cursor is already on "1. Yes" (verified live), so ['1','Enter'] is the explicit,
337
+ * position-robust affirmative. Returns the class tag + a one-line command/target trace so the
338
+ * supervisor logs a post-hoc audit line. NB deliberately NOT a "smart" safe-decline/escalation — that
339
+ * lands later on the vault idea "Human-approval подтверждения действий пира через Telegram-кнопки".
340
+ */
341
+ blockingConfirm(pane: string): { keys: string[]; taxonomy: string; detail: string } | null {
342
+ if (!isDangerousRmPrompt(pane)) return null
343
+ return { keys: ['1', 'Enter'], taxonomy: 'dangerous-rm', detail: dangerousRmDetail(pane) }
344
+ },
345
+
302
346
  /**
303
347
  * Ready for the first message iff the input surface is rendered AND no startup
304
348
  * dialog is still up (lifecycle.claudeInputReady, index.ts:272-279;
@@ -299,6 +299,22 @@ export interface RuntimeAdapter {
299
299
  */
300
300
  nagDismissKeys?(pane: string): string[] | null
301
301
 
302
+ /**
303
+ * MID-SESSION blocking CIRCUIT-BREAKER confirm (livability + safety). A sibling of nagDismissKeys
304
+ * for a DIFFERENT class: an interactive confirm that even `--dangerously-skip-permissions` does NOT
305
+ * bypass because it sits ABOVE the permission layer — the "Dangerous rm operation on working
306
+ * directory or its ancestor / Do you want to proceed? 1.Yes 2.No" guard claude 2.1.x added (no env,
307
+ * settings, or flag disables it). A headless peer has no human to press 1/2, so it HANGS forever
308
+ * (live-observed on a verification session). Owner decision (Артур, 04.07): auto-press YES — peers
309
+ * already run on bypass, so this only restores the pre-2.1.x status quo; a hang is real recurring
310
+ * harm. Return `{keys, taxonomy, detail}` (the affirmative keys + a class tag + a one-line trace of
311
+ * WHAT was approved) so the supervisor can press it AND leave a post-hoc log; else null. Match the
312
+ * FULL signature + cursor position so ordinary content mentioning the phrase never fires. Optional
313
+ * (codex/router omit it). tui only. FUTURE: replace blind-YES with human approval via Telegram —
314
+ * see the vault idea "Human-approval подтверждения действий пира через Telegram-кнопки".
315
+ */
316
+ blockingConfirm?(pane: string): { keys: string[]; taxonomy: string; detail: string } | null
317
+
302
318
  /** Is the input surface ready for the first message? (tui: ready marker present
303
319
  * AND startup dialogs gone). Router runtimes return true (no input surface). */
304
320
  isInputReady(pane: string): boolean
Binary file
@@ -33,23 +33,33 @@ export function nextBootAction(adapter: BootPredicates, viewport: string, enc: K
33
33
  return { kind: 'wait' }
34
34
  }
35
35
 
36
- /** The slice of a RuntimeAdapter the MID-SESSION nag-watcher consumes — the optional
37
- * nagDismissKeys (claudeAdapter satisfies it; codex/router omit it → no nags). */
36
+ /** The slice of a RuntimeAdapter the MID-SESSION nag-watcher consumes — the optional nagDismissKeys
37
+ * (benign upsell → silent decline) + blockingConfirm (circuit-breaker → affirmative + audit log).
38
+ * claudeAdapter satisfies both; codex/router omit them → no action. */
38
39
  export interface NagPredicate {
39
40
  nagDismissKeys?(pane: string): string[] | null
41
+ blockingConfirm?(pane: string): { keys: string[]; taxonomy: string; detail: string } | null
40
42
  }
41
43
 
42
- export type NagAction = { kind: 'dismiss'; keys: string[]; bytes: Buffer } | { kind: 'none' }
44
+ export type NagAction =
45
+ | { kind: 'dismiss'; keys: string[]; bytes: Buffer } // benign upsell — press keys silently
46
+ | { kind: 'approve'; keys: string[]; bytes: Buffer; taxonomy: string; detail: string } // circuit-breaker — press keys + LOG
47
+ | { kind: 'none' }
43
48
 
44
49
  /**
45
- * MID-SESSION nag/upsell step (livability) — the persistent sibling of nextBootAction. Unlike the
46
- * boot-driver, which STOPS at ready, the daemon loops this for the WHOLE session: a one-time CC upsell
47
- * modal (e.g. "Try the new fullscreen-renderer?") can pop AFTER the session is live and BLOCK the pty on
48
- * a keypress no headless peer answers. If the adapter recognizes the (FULL-signature) modal it returns
49
- * the verified-safe DECLINE keys; the caller writes `action.bytes` to the pty (cooldown-guarded so a
50
- * cleared modal is never double-answered). An adapter with no nagDismissKeys → always 'none'.
50
+ * MID-SESSION nag/confirm step (livability) — the persistent sibling of nextBootAction. Unlike the
51
+ * boot-driver (which STOPS at ready), the daemon loops this for the WHOLE session for TWO classes of
52
+ * mid-session blocking prompt no headless peer can answer:
53
+ * 1. a CIRCUIT-BREAKER confirm (blockingConfirm — e.g. the dangerous-rm guard above the permission
54
+ * layer) → press the affirmative AND surface a `detail`/`taxonomy` the caller logs (owner audit);
55
+ * 2. a benign UPSELL modal (nagDismissKeys — e.g. "Try the new fullscreen-renderer?") → decline silently.
56
+ * The circuit-breaker is checked FIRST (safety-relevant). The caller writes `action.bytes` (cooldown- +
57
+ * stuck-gate-guarded so a cleared prompt is never double-answered). An adapter with neither → 'none'.
51
58
  */
52
59
  export function nextNagAction(adapter: NagPredicate, viewport: string, enc: KeyEncoding = {}): NagAction {
60
+ const confirm = adapter.blockingConfirm?.(viewport)
61
+ if (confirm && confirm.keys.length)
62
+ return { kind: 'approve', keys: confirm.keys, bytes: keysToBytes(confirm.keys, enc), taxonomy: confirm.taxonomy, detail: confirm.detail }
53
63
  const keys = adapter.nagDismissKeys?.(viewport)
54
64
  if (keys && keys.length) return { kind: 'dismiss', keys, bytes: keysToBytes(keys, enc) }
55
65
  return { kind: 'none' }
@@ -643,7 +643,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
643
643
  // NAG_COOLDOWN_MS so a just-dismissed modal is never double-answered (a stray keystroke would land in
644
644
  // the now-visible composer). Only runs for a runtime whose adapter declares nagDismissKeys.
645
645
  const nagAdapter = BOOT_ADAPTERS[runtime] as NagPredicate | undefined
646
- if (nagAdapter?.nagDismissKeys) {
646
+ if (nagAdapter?.nagDismissKeys || nagAdapter?.blockingConfirm) {
647
647
  let nagFiredMs = 0
648
648
  // В60 — stuck-gate: a REAL blocked modal freezes the pty entirely, while a live peer merely
649
649
  // RENDERING the modal text (code review / quoted report — the В40 false-fire class) keeps
@@ -667,6 +667,22 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
667
667
  if (action.kind === 'dismiss') {
668
668
  child.terminal.write(action.bytes)
669
669
  nagFiredMs = Date.now()
670
+ } else if (action.kind === 'approve') {
671
+ // CIRCUIT-BREAKER confirm (e.g. dangerous-rm) — press the affirmative so a headless peer never
672
+ // hangs on it (owner decision: auto-YES restores the pre-2.1.x bypass status quo). Leave a
673
+ // post-hoc AUDIT line in the supervisor's own log (console → logPath): WHICH peer (session),
674
+ // WHAT (taxonomy + the parsed command/target), WHEN (ts). A rare bad rm is then at least visible
675
+ // after the fact — the interim before human-approval (Telegram) lands. Best-effort log, never
676
+ // gates the keypress.
677
+ child.terminal.write(action.bytes)
678
+ nagFiredMs = Date.now()
679
+ try {
680
+ console.warn(
681
+ `[supervisor] AUTO-CONFIRM ${action.taxonomy} identity=${session} ts=${new Date().toISOString()} ${action.detail}`,
682
+ )
683
+ } catch {
684
+ /* logging must never break the conduit */
685
+ }
670
686
  }
671
687
  }, NAG_POLL_MS)
672
688
  ;(nagWatch as { unref?: () => void }).unref?.()
@@ -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' } {