pi-crew 0.11.11 → 0.11.13

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/index.mjs +551 -519
  3. package/package.json +1 -1
  4. package/src/config/config-validation.ts +9 -0
  5. package/src/config/defaults.ts +7 -0
  6. package/src/config/env-vars.ts +24 -0
  7. package/src/config/types.ts +11 -0
  8. package/src/extension/registration/tool-registration.ts +7 -0
  9. package/src/extension/registration/verify-gate-tool.ts +263 -0
  10. package/src/extension/team-tool/run-deadline.ts +22 -1
  11. package/src/extension/team-tool/run.ts +38 -2
  12. package/src/extension/team-tool-types.ts +4 -0
  13. package/src/extension/team-tool.ts +31 -3
  14. package/src/prompt/inbox-poll.ts +141 -3
  15. package/src/runtime/broker/crew-broker.ts +48 -2
  16. package/src/runtime/broker/protocol/lock-busy.ts +47 -1
  17. package/src/runtime/broker/protocol/request-parsers.ts +8 -1
  18. package/src/runtime/budget-enforcement.ts +36 -20
  19. package/src/runtime/child-pi/child-pi-constants.ts +24 -0
  20. package/src/runtime/child-pi/child-pi-kill.ts +59 -2
  21. package/src/runtime/child-pi/child-pi-rpc.ts +1021 -0
  22. package/src/runtime/child-pi/child-pi-steering.ts +10 -8
  23. package/src/runtime/child-pi/child-pi-streams.ts +69 -19
  24. package/src/runtime/child-pi/child-pi.ts +29 -30
  25. package/src/runtime/child-pi/owned-process.ts +434 -0
  26. package/src/runtime/child-pi/postmortem-registry.ts +134 -0
  27. package/src/runtime/classifier/ambient-noise-gate.ts +126 -0
  28. package/src/runtime/classifier/classifier-service.ts +277 -61
  29. package/src/runtime/dispatch-batch.ts +32 -0
  30. package/src/runtime/live-session/live-agent-control.ts +2 -0
  31. package/src/runtime/live-session/live-agent-manager.ts +72 -2
  32. package/src/runtime/live-session/live-session-runtime.ts +435 -40
  33. package/src/runtime/merge-loop.ts +63 -35
  34. package/src/runtime/recovery/crash-recovery.ts +141 -3
  35. package/src/runtime/recovery/retry-executor.ts +29 -0
  36. package/src/runtime/run-tracker.ts +28 -0
  37. package/src/runtime/stale-reconciler.ts +36 -1
  38. package/src/runtime/task-runner.ts +103 -0
  39. package/src/runtime/verification/classifier-pre-gate.ts +262 -0
  40. package/src/runtime/verification/verification-gates.ts +5 -1
  41. package/src/runtime/verification/verify-gate.ts +394 -0
  42. package/src/schema/config-schema.ts +13 -0
  43. package/src/state/contracts.ts +18 -0
  44. package/src/state/coordination/mailbox.ts +203 -55
  45. package/src/state/stores/sqlite-run-state.ts +378 -0
  46. package/src/state/stores/state-store.ts +134 -6
  47. package/src/ui/agents-jobs-browser.ts +154 -8
  48. package/src/ui/events-state-source.ts +358 -0
  49. package/src/ui/run-snapshot-cache.ts +87 -3
  50. package/src/ui/widget/widget-model.ts +13 -2
@@ -53,10 +53,14 @@ import { existsSync, readdirSync } from "node:fs";
53
53
  import { join } from "node:path";
54
54
  import { fileURLToPath } from "node:url";
55
55
  import { matchesKey } from "@earendil-works/pi-tui";
56
+ import { listRecentRuns } from "../extension/run-index.ts";
56
57
  import { getScheduledJobs, getScheduledJobsHiddenCountView } from "../extension/team-tool/handle-schedule.ts";
57
58
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
58
59
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
59
60
  import type { LiveAgentHandle } from "../runtime/live-session/live-agent-manager.ts";
61
+ import type { ManifestCache } from "../runtime/manifest-cache.ts";
62
+ import { createManifestCache } from "../runtime/manifest-cache.ts";
63
+ import { isDisplayActiveRun } from "../runtime/process-status.ts";
60
64
  import type { ScheduledJob } from "../runtime/scheduling/scheduler.ts";
61
65
  import { surfaceGateEnvSnapshot } from "../runtime/surface/resolve-surface.ts";
62
66
  import { getTaskUsage } from "../runtime/usage-tracker.ts";
@@ -70,6 +74,7 @@ import { renderAgentsPane } from "./dashboard-panes/agents-pane.ts";
70
74
  import { renderScheduleDetails, schedulesHiddenJobsHintLine } from "./dashboard-panes/schedules-pane.ts";
71
75
  import { computeLiveDurationMs } from "./live-duration.ts";
72
76
  import { CURSOR, canopyLine, formatHint, overflowHint, RAIL, railLeaders, railLine } from "./rail.ts";
77
+ import { createRunSnapshotCache, type RunSnapshotCache } from "./run-snapshot-cache.ts";
73
78
  import type { RunUiSnapshot } from "./snapshot-types.ts";
74
79
  import { spinnerFrame } from "./spinner.ts";
75
80
  import { iconForStatus } from "./status-colors.ts";
@@ -124,6 +129,17 @@ export interface AgentsJobsBrowserOptions {
124
129
  rows?: number;
125
130
  /** Test seam: replace the live-agent data source. */
126
131
  agentsProvider?: () => AgentsBrowserAgentEntry[];
132
+ /**
133
+ * U7 (spec 2026-10-09): committed-state caches for the DEFAULT data path
134
+ * (no agentsProvider). Inject to share the host's caches; when omitted the
135
+ * browser lazily creates its OWN pair — snapshot cache with the opt-in
136
+ * events-state layer (derived view over events.jsonl: snapshot + tail-follow
137
+ * frames via the existing readEventsCursor watermark cursor) — so the 400ms
138
+ * refresh tick reads COMMITTED snapshots instead of re-parsing
139
+ * manifests + agents.json from disk every tick.
140
+ */
141
+ manifestCache?: ManifestCache;
142
+ snapshotCache?: RunSnapshotCache;
127
143
  /** Wired by viewers.ts: open the DurableTranscriptViewer overlay for an agent (the `/crew transcript` experience). */
128
144
  onOpenTranscript?: (entry: { runId: string; taskId: string }) => void;
129
145
  /** Test seam: replace the job data source (G17 provider by default). */
@@ -142,6 +158,10 @@ export interface AgentsJobsBrowserOptions {
142
158
 
143
159
  const REFRESH_TTL_MS_DEFAULT = 600;
144
160
  const POLL_INTERVAL_MS = 400;
161
+ /** U7: legacy-discovery cadence — every Nth data reload (≈ N × refresh TTL
162
+ * while the overlay is open) also runs the lenient run-index scan for runs
163
+ * the cache validators cannot see. Steady-state ticks stay cache-only. */
164
+ const LEGACY_DISCOVERY_TICKS = 12;
145
165
  /** Body height clamp — mirrors the dashboard's stable-height lesson: a
146
166
  * fluctuating line count shifts the overlay anchor every frame. */
147
167
  const MIN_BODY = 8;
@@ -247,10 +267,32 @@ export class AgentsJobsBrowser {
247
267
  private notice: { text: string; until: number } | undefined;
248
268
  private pollTimer: ReturnType<typeof setInterval> | undefined;
249
269
  private readonly manifests = new Map<string, TeamRunManifest | null>();
270
+ /** U7: committed-state caches (default data path only). */
271
+ private readonly manifestCache?: ManifestCache;
272
+ private readonly snapshotCache?: RunSnapshotCache;
273
+ /** True when THIS browser created the cache (dispose must tear it down). */
274
+ private readonly ownsManifestCache: boolean;
275
+ private readonly ownsSnapshotCache: boolean;
276
+ /** Data reloads since construction (legacy-discovery cadence). */
277
+ private reloadCount = 0;
250
278
 
251
279
  constructor(options: AgentsJobsBrowserOptions) {
252
280
  this.options = options;
253
281
  this.theme = options.theme ?? asCrewTheme(undefined);
282
+ // U7: the DEFAULT data path reads committed snapshots (cache reads, no
283
+ // disk per tick) driven by the events-state layer's frames; the legacy
284
+ // per-tick disk re-parse stays ONLY for the agentsProvider test seam.
285
+ if (!options.agentsProvider) {
286
+ this.ownsManifestCache = options.manifestCache === undefined;
287
+ this.ownsSnapshotCache = options.snapshotCache === undefined;
288
+ this.manifestCache = options.manifestCache ?? createManifestCache(options.cwd);
289
+ this.snapshotCache =
290
+ options.snapshotCache ?? createRunSnapshotCache(options.cwd, { eventsState: { pollMs: POLL_INTERVAL_MS } });
291
+ this.discoverRuns();
292
+ } else {
293
+ this.ownsManifestCache = false;
294
+ this.ownsSnapshotCache = false;
295
+ }
254
296
  this.refreshData(true);
255
297
  this.pollTimer = setInterval(() => {
256
298
  if (this.closed) return;
@@ -298,6 +340,10 @@ export class AgentsJobsBrowser {
298
340
  clearInterval(this.pollTimer);
299
341
  this.pollTimer = undefined;
300
342
  }
343
+ // U7: tear down the caches this browser created (timer + events-state
344
+ // sources with it). Injected caches stay alive — the host still uses them.
345
+ if (this.ownsSnapshotCache) this.snapshotCache?.dispose?.();
346
+ if (this.ownsManifestCache) this.manifestCache?.dispose();
301
347
  }
302
348
 
303
349
  // Retired with the p key (2026-09-14): kept for the future "focus the
@@ -319,23 +365,68 @@ export class AgentsJobsBrowser {
319
365
  const at = this.nowMs();
320
366
  if (!force && ttl > 0 && at - this.cachedAt < ttl) return;
321
367
  this.cachedAt = at;
322
- const agents = this.loadAgents();
368
+ const agents = this.loadAgents(force);
323
369
  const { jobs, hiddenCount } = this.loadJobs();
324
370
  this.hiddenCount = hiddenCount;
325
371
  this.cachedEntries = [...agents, ...jobs.map((job) => ({ kind: "job" as const, job }))];
326
372
  if (this.selected >= this.cachedEntries.length) this.selected = Math.max(0, this.cachedEntries.length - 1);
327
373
  }
328
374
 
329
- private loadAgents(): AgentsBrowserAgentEntry[] {
375
+ private loadAgents(force = false): AgentsBrowserAgentEntry[] {
330
376
  if (this.options.agentsProvider) {
331
377
  return [...this.options.agentsProvider()].sort((a, b) => statusRank(a.status) - statusRank(b.status));
332
378
  }
333
- // FIX (live probe 2026-09-14): read the SAME pipeline the widget counts
334
- // use — activeWidgetRuns() over `.crew/state/runs/*/agents.json` — NOT the
335
- // in-process live-agent registry. Agent-tool subagents (foreground AND
336
- // background) run as child processes and never registerLiveAgent() in this
337
- // process, so the registry listed nothing while the widget said "1 running".
338
- // Source parity with the widget also means the counts can never disagree.
379
+ // U7 (spec 2026-10-09): committed-state path — the widget pipeline over
380
+ // the manifest cache (stat-gated parse-if-changed) + snapshot cache
381
+ // (events-state frames → coalesced async refresh). A refresh tick now
382
+ // costs in-memory snapshot reads + a few stats; the per-tick
383
+ // listRecentRuns + readCrewAgents disk re-parse is gone. Parity: the
384
+ // SAME activeWidgetRuns projection the old path used, so the counts can
385
+ // never disagree with the widget.
386
+ if (this.manifestCache && this.snapshotCache) {
387
+ try {
388
+ const runs = activeWidgetRuns(
389
+ this.options.cwd,
390
+ this.manifestCache,
391
+ this.snapshotCache,
392
+ undefined,
393
+ this.options.workspaceId,
394
+ );
395
+ this.discoverRuns();
396
+ const nowMs = this.nowMs();
397
+ // Legacy-parity fallback: runs the cache pipeline cannot SEE (run-index
398
+ // accepts hand-written/minimal manifests that state-store and
399
+ // manifest-cache validators reject). At construct/invalidate (force)
400
+ // plus every LEGACY_DISCOVERY_TICKS-th reload, run the legacy
401
+ // listRecentRuns discovery and read agents.json directly for uncovered
402
+ // active runs — steady-state ticks stay cache-only.
403
+ this.reloadCount += 1;
404
+ if (force || this.reloadCount % LEGACY_DISCOVERY_TICKS === 1) {
405
+ this.appendLegacyUncoveredRuns(runs, nowMs);
406
+ }
407
+ const entries: AgentsBrowserAgentEntry[] = [];
408
+ for (const { run, agents } of runs) {
409
+ for (const record of agents) {
410
+ entries.push({
411
+ kind: "agent",
412
+ runId: run.runId,
413
+ taskId: record.taskId,
414
+ role: record.role || record.agent || "agent",
415
+ agentName: record.agent,
416
+ status: record.status,
417
+ tokPerSec: recordTokPerSec(record, nowMs),
418
+ record,
419
+ manifest: run,
420
+ });
421
+ }
422
+ }
423
+ return entries.sort((a, b) => statusRank(a.status) - statusRank(b.status));
424
+ } catch (error) {
425
+ logInternalError("agents-browser.loadAgents", error as Error);
426
+ return [];
427
+ }
428
+ }
429
+ // Legacy path (agent-tool subagents etc.): direct disk read.
339
430
  let runs: ReturnType<typeof activeWidgetRuns> = [];
340
431
  try {
341
432
  runs = activeWidgetRuns(this.options.cwd, undefined, undefined, undefined, this.options.workspaceId);
@@ -363,6 +454,61 @@ export class AgentsJobsBrowser {
363
454
  return entries.sort((a, b) => statusRank(a.status) - statusRank(b.status));
364
455
  }
365
456
 
457
+ /**
458
+ * U7: legacy-parity discovery — listRecentRuns (the lenient run-index scan
459
+ * the pre-U7 default path used every tick) for ACTIVE runs the committed
460
+ * path did not cover, reading agents.json directly for each. Normally a
461
+ * no-op (the caches cover every validator-clean run); it exists so
462
+ * hand-written/minimal manifests that state-store + manifest-cache reject
463
+ * stay visible exactly as before.
464
+ */
465
+ private appendLegacyUncoveredRuns(runs: ReturnType<typeof activeWidgetRuns>, nowMs: number): void {
466
+ try {
467
+ const covered = new Set(runs.map((item) => item.run.runId));
468
+ for (const run of listRecentRuns(this.options.cwd, 20)) {
469
+ if (covered.has(run.runId)) continue;
470
+ if (run.status !== "running" && run.status !== "queued") continue;
471
+ try {
472
+ const agents = readCrewAgents(run);
473
+ if (isDisplayActiveRun(run, agents, nowMs)) runs.push({ run, agents });
474
+ } catch {
475
+ /* unreadable — skip */
476
+ }
477
+ }
478
+ } catch {
479
+ /* non-fatal — next cadence tick retries */
480
+ }
481
+ }
482
+
483
+ /**
484
+ * U7: seed the snapshot cache for runs not yet populated. Missing entries
485
+ * are primed through the SYNC build path ONCE per run (Tier 11a
486
+ * read-your-writes — the construct-time sync contract the legacy per-tick
487
+ * disk read provided); afterwards every tick reads committed snapshots and
488
+ * only the events-state frames / TTL refresh the entries.
489
+ */
490
+ private discoverRuns(): void {
491
+ if (!this.manifestCache || !this.snapshotCache) return;
492
+ try {
493
+ const runs = this.manifestCache.list(20).filter((run) => run.status === "running" || run.status === "queued");
494
+ for (const run of runs) {
495
+ if (this.snapshotCache.get(run.runId)) continue;
496
+ try {
497
+ this.snapshotCache.refreshIfStale(run.runId);
498
+ } catch {
499
+ /* unreadable run — the async preload below retries */
500
+ }
501
+ }
502
+ const runIds = runs.map((run) => run.runId);
503
+ if (runIds.length > 0)
504
+ void this.snapshotCache.preloadAllStale(runIds).catch(() => {
505
+ /* best-effort preload; next cadence tick retries */
506
+ });
507
+ } catch {
508
+ /* non-fatal — next tick retries */
509
+ }
510
+ }
511
+
366
512
  private loadJobs(): { jobs: ScheduledJob[]; hiddenCount: number } {
367
513
  if (this.options.jobsProvider) return this.options.jobsProvider();
368
514
  try {
@@ -0,0 +1,358 @@
1
+ /**
2
+ * U7 (upgrade spec 2026-10-09, §TIER 2): CommittedStateSource pattern for
3
+ * events.jsonl → views.
4
+ *
5
+ * Borrowed from pi-durable's mount layer: TaskGraph / ConversationView wrap
6
+ * the commit log in a `CommittedStateSource` → `ReplicatedStateSource`
7
+ * (task-graph.ts:83-92, view.ts:104-113). Three properties this adapter
8
+ * mirrors:
9
+ *
10
+ * 1. SNAPSHOT — the initial state is ONE parse of events.jsonl up to the
11
+ * current seq (a storage scan, O(live records), no event replay — the
12
+ * point the spec calls "điểm MẠNH").
13
+ * 2. FRAMES — afterwards the source tail-follows the log with the EXISTING
14
+ * `readEventsCursor` verified-watermark cursor (state/event-log/cursor.ts):
15
+ * each discovered batch of committed events becomes one frame applied to
16
+ * the state and fanned out to subscribers. An unchanged log costs one
17
+ * stat + zero event bytes (the cursor cache serves ring answers).
18
+ * 3. OVERFLOW — durable buffers frames and, when the buffer would exceed
19
+ * 100 frames, replaces the ROOT instead of replaying them
20
+ * (observation.ts:227-232). Here: a poll that needs MORE than
21
+ * `maxBufferedFrames` batches to drain the backlog performs ONE root
22
+ * replacement (fresh snapshot parse) and emits a single `resync` frame.
23
+ *
24
+ * events.jsonl is NEVER written by this module — it is the audit spine; this
25
+ * is a pure derived view layer (spec U7 constraint).
26
+ *
27
+ * DELIBERATE DIVERGENCE from run-snapshot-cache's own events slice: the
28
+ * cursor path only carries events with `metadata.seq` (documented limitation
29
+ * on readEventsCursor), while the cache's tail read keeps seq-less lines too.
30
+ * Consumers that must stay byte-identical to the legacy slice (the snapshot
31
+ * cache parity contract) therefore keep computing that slice themselves and
32
+ * use this source as the CHANGE SIGNAL (frames → refresh), not as the data.
33
+ */
34
+ import * as fs from "node:fs";
35
+ import type { TeamEvent } from "../state/event-log/event-log.ts";
36
+ import { readEventsCursor } from "../state/event-log/event-log.ts";
37
+
38
+ /** durable observation.ts:227-232 — buffer 100 frames, then root-replace. */
39
+ export const MAX_BUFFERED_FRAMES_DEFAULT = 100;
40
+ /** Recent-events window kept in the committed state (matches the snapshot
41
+ * cache's DEFAULT_RECENT_EVENTS so frames carry a comparable tail). */
42
+ export const DEFAULT_RECENT_EVENTS_WINDOW = 20;
43
+ /** Events per cursor batch = events per frame. Durable commits one frame per
44
+ * commit; pi-crew's buffered writer commits per batch, so one cursor batch
45
+ * (default 20 events) is the natural frame unit. */
46
+ const FRAME_BATCH_EVENTS_DEFAULT = 20;
47
+ /** Registry hard cap — live sources are small but must not grow unbounded. */
48
+ const REGISTRY_MAX_LIVE_SOURCES = 64;
49
+
50
+ export interface EventsStateSnapshot {
51
+ /** Bounded recent-events window (seq-ascending, newest last). */
52
+ events: TeamEvent[];
53
+ /** Last committed seq (0 while the log has no seq-carrying events). */
54
+ seq: number;
55
+ /** Monotonic version — bumped on every commit (frame or resync). */
56
+ version: number;
57
+ /** True when the last commit was a root replacement (resync). */
58
+ resynced: boolean;
59
+ }
60
+
61
+ export interface EventsStateFrame {
62
+ /** Committed seq BEFORE this frame. */
63
+ fromSeq: number;
64
+ /** Committed seq AFTER this frame. */
65
+ toSeq: number;
66
+ /** Delta events of this frame — the FULL new window when `resync`. */
67
+ events: TeamEvent[];
68
+ /** Snapshot version AFTER applying this frame. */
69
+ version: number;
70
+ /** Root-replacement frame (overflow resync): re-read snapshot(). */
71
+ resync: boolean;
72
+ }
73
+
74
+ export type EventsStateListener = (frame: EventsStateFrame) => void;
75
+
76
+ export interface EventsStateStats {
77
+ /** poll() calls (each = one stat + cursor read when enabled). */
78
+ polls: number;
79
+ /** Frames committed (a resync counts as one frame). */
80
+ frames: number;
81
+ /** Root replacements performed (overflow / inode-shrink / forced). */
82
+ resyncs: number;
83
+ /** Events delivered to listeners across all frames. */
84
+ eventsDelivered: number;
85
+ }
86
+
87
+ export interface EventsStateSourceOptions {
88
+ /** Bounded recent-events window (default 20). */
89
+ recentEvents?: number;
90
+ /** Max frames a single poll will apply before root-replacing (default 100). */
91
+ maxBufferedFrames?: number;
92
+ /** Events per cursor batch / frame (default 20). */
93
+ frameBatchEvents?: number;
94
+ }
95
+
96
+ export interface EventsStateSource {
97
+ readonly runId: string;
98
+ readonly eventsPath: string;
99
+ /** Committed state (defensive copy). */
100
+ snapshot(): EventsStateSnapshot;
101
+ /** Frames are emitted synchronously inside poll()/resync(). */
102
+ subscribe(listener: EventsStateListener): () => void;
103
+ /**
104
+ * Tail-follow now. Returns the number of frames committed (a resync
105
+ * counts as one). Cheap on an unchanged log: one stat + a ring-served
106
+ * cursor read (zero event bytes).
107
+ */
108
+ poll(): number;
109
+ /** Force a root replacement (fresh snapshot parse + resync frame). */
110
+ resync(): EventsStateSnapshot;
111
+ stats(): EventsStateStats;
112
+ /** Refcount release — the shared registry drops the source at zero refs. */
113
+ dispose(): void;
114
+ }
115
+
116
+ interface SourceInternals {
117
+ poll(): number;
118
+ resync(): EventsStateSnapshot;
119
+ /** Eviction-only hard teardown — bypasses the refcount (see below). */
120
+ forceDispose(): void;
121
+ }
122
+
123
+ function createEventsStateSource(
124
+ runId: string,
125
+ eventsPath: string,
126
+ options: EventsStateSourceOptions = {},
127
+ ): EventsStateSource & SourceInternals {
128
+ const windowSize = Math.max(1, options.recentEvents ?? DEFAULT_RECENT_EVENTS_WINDOW);
129
+ const maxBufferedFrames = Math.max(1, options.maxBufferedFrames ?? MAX_BUFFERED_FRAMES_DEFAULT);
130
+ const batchEvents = Math.max(1, options.frameBatchEvents ?? FRAME_BATCH_EVENTS_DEFAULT);
131
+ const listeners = new Set<EventsStateListener>();
132
+ let events: TeamEvent[] = [];
133
+ let seq = 0;
134
+ let version = 0;
135
+ let resynced = false;
136
+ let disposed = false;
137
+ let lastSize = -1;
138
+ let lastIno = -1;
139
+ const stats: EventsStateStats = { polls: 0, frames: 0, resyncs: 0, eventsDelivered: 0 };
140
+
141
+ function snapshot(): EventsStateSnapshot {
142
+ return { events: [...events], seq, version, resynced };
143
+ }
144
+
145
+ function emit(frame: EventsStateFrame): void {
146
+ for (const listener of listeners) {
147
+ try {
148
+ listener(frame);
149
+ } catch {
150
+ /* subscriber errors are non-fatal (run-event-bus convention) */
151
+ }
152
+ }
153
+ }
154
+
155
+ /** STORAGE SCAN (spec U7): one parse of the (tail-capped) log to the
156
+ * current seq. No event replay — the root is rebuilt from storage. */
157
+ function rootBuild(): void {
158
+ let scanned: TeamEvent[] | undefined;
159
+ let scannedSeq = 0;
160
+ try {
161
+ const cursor = readEventsCursor(eventsPath, {});
162
+ scanned = cursor.events;
163
+ scannedSeq = cursor.nextSeq;
164
+ } catch {
165
+ /* unreadable log (EISDIR/EACCES mid-rotation, …) — keep the last
166
+ * committed state (the empty root at creation) instead of wiping
167
+ * committed events/seq on a transient read error (review MINOR-1). */
168
+ }
169
+ if (scanned !== undefined && scanned.length === 0 && scannedSeq === 0 && (seq > 0 || events.length > 0) && !fs.existsSync(eventsPath)) {
170
+ // readEventsCursor reports a MISSING log as an empty cursor — but a
171
+ // log that carried committed events a beat ago and now does not exist
172
+ // is a transient disappearance (run dir moving / the rename→wx-create
173
+ // window of rotation), NOT a compaction to empty. Keep the committed
174
+ // state — same policy as poll()'s miss-file branch — and keep the inode
175
+ // stamp so the next poll re-detects the rewrite and retries the resync.
176
+ scanned = undefined;
177
+ }
178
+ // Stamp the inode/size the root was built from so the FIRST poll after
179
+ // an external rewrite (rotation/compaction) already triggers a resync
180
+ // instead of learning the inode one poll late. Stamp ONLY on a successful
181
+ // scan — on a failed scan keep the previous stamp so the next poll
182
+ // re-detects the rewrite and RETRIES the resync.
183
+ if (scanned !== undefined) {
184
+ try {
185
+ const stat = fs.statSync(eventsPath);
186
+ lastIno = stat.ino;
187
+ lastSize = stat.size;
188
+ } catch {
189
+ lastIno = -1;
190
+ lastSize = -1;
191
+ }
192
+ events = scanned.slice(-windowSize);
193
+ seq = scannedSeq;
194
+ }
195
+ resynced = true;
196
+ version += 1;
197
+ }
198
+
199
+ function resync(): EventsStateSnapshot {
200
+ const fromSeq = seq;
201
+ rootBuild();
202
+ stats.resyncs += 1;
203
+ stats.frames += 1;
204
+ const frame: EventsStateFrame = { fromSeq, toSeq: seq, events: [...events], version, resync: true };
205
+ stats.eventsDelivered += frame.events.length;
206
+ emit(frame);
207
+ return snapshot();
208
+ }
209
+
210
+ function poll(): number {
211
+ if (disposed) return 0;
212
+ stats.polls += 1;
213
+ // Root-replacement triggers BESIDES the frame-buffer overflow: an
214
+ // inode change or size shrink means the log was rewritten (rotation's
215
+ // rename+'wx' create, compaction's temp+rename, forget+import) — the
216
+ // committed window is no longer a suffix of the new file, so replay
217
+ // from the seq cursor alone would strand pre-rewrite events.
218
+ try {
219
+ const stat = fs.statSync(eventsPath);
220
+ if (lastIno !== -1 && (stat.ino !== lastIno || stat.size < lastSize)) {
221
+ lastIno = stat.ino;
222
+ lastSize = stat.size;
223
+ resync();
224
+ return 1;
225
+ }
226
+ lastIno = stat.ino;
227
+ lastSize = stat.size;
228
+ } catch {
229
+ // File gone (run dir moving / not created yet): keep the last
230
+ // committed state — the audit spine may reappear.
231
+ return 0;
232
+ }
233
+ let committed = 0;
234
+ for (;;) {
235
+ const cursor = readEventsCursor(eventsPath, { sinceSeq: seq, limit: batchEvents });
236
+ if (cursor.events.length === 0) break;
237
+ const toSeq = Math.max(seq, cursor.nextSeq);
238
+ if (toSeq <= seq) break; // no forward progress — stop (loop guard)
239
+ const frameEvents = cursor.events;
240
+ const fromSeq = seq;
241
+ seq = toSeq;
242
+ events = [...events, ...frameEvents].slice(-windowSize);
243
+ resynced = false;
244
+ version += 1;
245
+ committed += 1;
246
+ stats.frames += 1;
247
+ stats.eventsDelivered += frameEvents.length;
248
+ emit({ fromSeq, toSeq, events: [...frameEvents], version, resync: false });
249
+ if (committed >= maxBufferedFrames) {
250
+ // OVERFLOW (durable :227-232): only root-replace when a backlog
251
+ // still remains — an exact drain at the cap needs no resync.
252
+ let backlog = false;
253
+ try {
254
+ backlog = readEventsCursor(eventsPath, { sinceSeq: seq, limit: 1 }).events.length > 0;
255
+ } catch {
256
+ backlog = false;
257
+ }
258
+ if (backlog) {
259
+ resync();
260
+ return committed + 1;
261
+ }
262
+ break;
263
+ }
264
+ }
265
+ return committed;
266
+ }
267
+
268
+ // Initial snapshot: parse events.jsonl to the current seq at creation.
269
+ rootBuild();
270
+
271
+ const source: EventsStateSource & SourceInternals = {
272
+ runId,
273
+ eventsPath,
274
+ snapshot,
275
+ subscribe(listener: EventsStateListener): () => void {
276
+ listeners.add(listener);
277
+ return () => {
278
+ listeners.delete(listener);
279
+ };
280
+ },
281
+ poll,
282
+ resync,
283
+ stats(): EventsStateStats {
284
+ return { ...stats };
285
+ },
286
+ dispose(): void {
287
+ if (disposed) return;
288
+ // Refcount release (spec: sources are SHARED per events log — widget +
289
+ // surface panes tail-follow ONE cursor). While other holders remain,
290
+ // only drop this holder's reference; the instance keeps serving them.
291
+ const entry = registry.get(eventsPath);
292
+ if (entry && entry.source === source && entry.refs > 1) {
293
+ entry.refs -= 1;
294
+ return;
295
+ }
296
+ disposed = true;
297
+ listeners.clear();
298
+ if (entry && entry.source === source) registry.delete(eventsPath);
299
+ },
300
+ // Eviction path (review MINOR-2): the registry's hard cap must dispose
301
+ // the entry REGARDLESS of refcount. Going through the refcounted
302
+ // dispose() above would only decrement an entry the eviction already
303
+ // deleted from the registry — leaving holders with an "undead" source
304
+ // and making the next eventsStateSource() call open a DUPLICATE
305
+ // tail-follow cursor on the same log.
306
+ forceDispose(): void {
307
+ disposed = true;
308
+ listeners.clear();
309
+ const entry = registry.get(eventsPath);
310
+ if (entry && entry.source === source) registry.delete(eventsPath);
311
+ },
312
+ };
313
+ return source;
314
+ }
315
+
316
+ // ─── Shared registry (get-or-create per events log) ────────────────────────
317
+
318
+ interface RegistryEntry {
319
+ source: EventsStateSource & SourceInternals;
320
+ refs: number;
321
+ }
322
+
323
+ const registry = new Map<string, RegistryEntry>();
324
+
325
+ /**
326
+ * U7 adapter entry point: get-or-create the shared CommittedStateSource for
327
+ * one run's events.jsonl. Repeated calls with the same eventsPath return the
328
+ * SAME instance (refcounted) so widget + surface panes share one tail-follow
329
+ * cursor; dispose() each handle you take.
330
+ */
331
+ export function eventsStateSource(runId: string, eventsPath: string, options: EventsStateSourceOptions = {}): EventsStateSource {
332
+ const existing = registry.get(eventsPath);
333
+ if (existing) {
334
+ existing.refs += 1;
335
+ return existing.source;
336
+ }
337
+ // Hard cap: live sources are tiny, but a pathological session (hundreds
338
+ // of short-lived runs) must not accumulate. Evict the oldest-registered
339
+ // entry wholesale via forceDispose() (refcount-blind, review MINOR-2):
340
+ // its holders keep a disposed no-op source and the next
341
+ // eventsStateSource() call for that path creates a fresh one.
342
+ if (registry.size >= REGISTRY_MAX_LIVE_SOURCES) {
343
+ const oldestKey = registry.keys().next().value;
344
+ if (oldestKey !== undefined) {
345
+ const evicted = registry.get(oldestKey);
346
+ registry.delete(oldestKey);
347
+ evicted?.source.forceDispose();
348
+ }
349
+ }
350
+ const source = createEventsStateSource(runId, eventsPath, options);
351
+ registry.set(eventsPath, { source, refs: 1 });
352
+ return source;
353
+ }
354
+
355
+ /** Test/observability hook: number of live (refcounted) sources. */
356
+ export function eventsStateSourceRegistrySize(): number {
357
+ return registry.size;
358
+ }