sentinelayer-cli 0.39.2 → 0.41.0

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 (62) hide show
  1. package/README.md +4 -4
  2. package/package.json +2 -3
  3. package/src/agents/ai-governance/tools/base.js +19 -166
  4. package/src/agents/backend/tools/base.js +33 -186
  5. package/src/agents/code-quality/tools/base.js +27 -154
  6. package/src/agents/data-layer/tools/base.js +35 -180
  7. package/src/agents/documentation/tools/base.js +18 -146
  8. package/src/agents/infrastructure/tools/base.js +17 -164
  9. package/src/agents/observability/tools/base.js +35 -180
  10. package/src/agents/performance/tools/base.js +35 -205
  11. package/src/agents/release/tools/base.js +35 -180
  12. package/src/agents/reliability/tools/base.js +35 -180
  13. package/src/agents/security/tools/base.js +33 -187
  14. package/src/agents/shared-tools/domain-base.js +246 -0
  15. package/src/agents/supply-chain/tools/base.js +15 -144
  16. package/src/agents/testing/tools/base.js +33 -184
  17. package/src/cli.js +5 -0
  18. package/src/commands/mcp.js +1 -1
  19. package/src/commands/ring-owner.js +58 -0
  20. package/src/commands/session.js +703 -263
  21. package/src/engram/governance.js +67 -0
  22. package/src/engram/index.js +64 -0
  23. package/src/engram/namespace.js +89 -0
  24. package/src/engram/observations.js +34 -0
  25. package/src/engram/sla.js +104 -0
  26. package/src/engram/store.js +115 -0
  27. package/src/engram/tools.js +146 -0
  28. package/src/engram/trust.js +105 -0
  29. package/src/legacy-cli.js +6 -3
  30. package/src/mcp/registry.js +27 -10
  31. package/src/mcp/session-stdio-server.js +209 -18
  32. package/src/mcp/smoke.js +9 -8
  33. package/src/pocket/ring-owner.js +97 -0
  34. package/src/review/local-review.js +9 -5
  35. package/src/review/omargate-orchestrator.js +111 -14
  36. package/src/review/spec-binding.js +16 -1
  37. package/src/scan/generator.js +1 -1
  38. package/src/session/agent-registry.js +22 -63
  39. package/src/session/checkpoints.js +194 -0
  40. package/src/session/coordination-guidance.js +6 -6
  41. package/src/session/daemon.js +19 -124
  42. package/src/session/file-lease-integrations.js +1300 -0
  43. package/src/session/file-locks.js +967 -464
  44. package/src/session/listener.js +243 -20
  45. package/src/session/listeners.js +71 -80
  46. package/src/session/paths.js +2 -0
  47. package/src/session/recall/embedder.js +181 -0
  48. package/src/session/recall/entities.js +0 -0
  49. package/src/session/recall/index-build.js +38 -0
  50. package/src/session/recall/index-core.js +108 -0
  51. package/src/session/recall/index.js +131 -0
  52. package/src/session/recall/needle-eval.js +279 -0
  53. package/src/session/recall/observation-core.js +183 -0
  54. package/src/session/recall/observations.js +21 -0
  55. package/src/session/recall/provenance.js +153 -0
  56. package/src/session/recall/retrieve.js +374 -0
  57. package/src/session/recall/text.js +0 -0
  58. package/src/session/recall/token-stats.js +81 -0
  59. package/src/session/recap.js +29 -23
  60. package/src/session/remote-hydrate.js +5 -1
  61. package/src/session/sync.js +661 -11
  62. package/src/session/wake/pump.js +8 -3
@@ -1,6 +1,11 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
2
 
3
- import { pollSessionEvents, pollSessionEventsBefore, streamSessionEvents } from "./sync.js";
3
+ import {
4
+ pollSessionEvents,
5
+ pollSessionEventsBefore,
6
+ streamSessionEvents,
7
+ updateSessionReadCursor,
8
+ } from "./sync.js";
4
9
  import { cursorAdvances, readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
5
10
  import { isSessionListenerLifecycleEvent } from "./control-events.js";
6
11
 
@@ -14,8 +19,10 @@ const BROADCAST_RECIPIENTS = new Set([
14
19
  "all-agents",
15
20
  ]);
16
21
 
17
- const DEFAULT_ACTIVE_INTERVAL_SECONDS = 5;
22
+ const DEFAULT_ACTIVE_INTERVAL_SECONDS = 60;
18
23
  const DEFAULT_ACTIVE_WINDOW_SECONDS = 300;
24
+ const DEFAULT_POLL_JITTER_RATIO = 0.2;
25
+ const DEFAULT_MAX_POLL_BACKOFF_SECONDS = 300;
19
26
  const MAX_CLOCK_SKEW_MS = 60_000;
20
27
 
21
28
  function normalizeString(value) {
@@ -38,10 +45,6 @@ function normalizePositiveInteger(value, fallbackValue) {
38
45
  return Math.floor(normalized);
39
46
  }
40
47
 
41
- function isListenerStopDirective(event = {}) {
42
- return normalizeString(event?.event) === "listener_stop";
43
- }
44
-
45
48
  function isPlainObject(value) {
46
49
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
47
50
  }
@@ -128,6 +131,46 @@ function cursorFromEvents(events = [], fallbackCursor = null) {
128
131
  return cursor;
129
132
  }
130
133
 
134
+ function latestSequenceTarget(events = [], fallbackCursor = null) {
135
+ let targetSequenceId = null;
136
+ let targetCursor = normalizeString(fallbackCursor) || "";
137
+ for (const event of Array.isArray(events) ? events : []) {
138
+ const sequence = Number(event?.sequenceId ?? event?.sequence_id ?? event?.sequence);
139
+ if (Number.isFinite(sequence) && sequence > 0) {
140
+ if (targetSequenceId === null || sequence > targetSequenceId) {
141
+ targetSequenceId = Math.floor(sequence);
142
+ targetCursor = normalizeString(event?.cursor) || targetCursor;
143
+ }
144
+ continue;
145
+ }
146
+ const cursor = normalizeString(event?.cursor);
147
+ if (cursor) targetCursor = cursor;
148
+ }
149
+ return { targetSequenceId, targetCursor };
150
+ }
151
+
152
+ export function computeListenerPollDelay({
153
+ baseDelayMs,
154
+ consecutiveFailures = 0,
155
+ retryAfterMs = null,
156
+ jitterRatio = DEFAULT_POLL_JITTER_RATIO,
157
+ maxBackoffMs = DEFAULT_MAX_POLL_BACKOFF_SECONDS * 1000,
158
+ randomValue = Math.random(),
159
+ } = {}) {
160
+ const base = Math.max(1, Math.floor(Number(baseDelayMs) || 1));
161
+ const failures = Math.max(0, Math.floor(Number(consecutiveFailures) || 0));
162
+ const maxBackoff = Math.max(base, Math.floor(Number(maxBackoffMs) || base));
163
+ const exponential = failures > 0
164
+ ? Math.min(maxBackoff, base * (2 ** Math.min(failures - 1, 20)))
165
+ : base;
166
+ const retryFloor = Math.max(0, Math.ceil(Number(retryAfterMs) || 0));
167
+ const boundedJitterRatio = Math.max(0, Math.min(0.5, Number(jitterRatio) || 0));
168
+ const boundedRandom = Math.max(0, Math.min(1, Number(randomValue) || 0));
169
+ const jitter = Math.floor(exponential * boundedJitterRatio * boundedRandom);
170
+ const jitteredBackoff = Math.min(maxBackoff, exponential + jitter);
171
+ return Math.max(retryFloor, jitteredBackoff);
172
+ }
173
+
131
174
  function eventIdentityKey(event = {}) {
132
175
  const cursor = normalizeString(event?.cursor);
133
176
  if (cursor) return `cursor:${cursor}`;
@@ -141,6 +184,41 @@ function eventIdentityKey(event = {}) {
141
184
  });
142
185
  }
143
186
 
187
+ function listenerControlType(control = {}) {
188
+ return normalizeLower(control?.type || control?.action || control?.event);
189
+ }
190
+
191
+ function listenerControlIssuedAtMs(control = {}) {
192
+ for (const key of ["issuedAtMs", "issued_at_ms"]) {
193
+ const epoch = Number(control?.[key]);
194
+ if (Number.isFinite(epoch) && epoch > 0) return epoch;
195
+ }
196
+ for (const key of ["issuedAt", "issued_at"]) {
197
+ const epoch = Date.parse(normalizeString(control?.[key]));
198
+ if (Number.isFinite(epoch)) return epoch;
199
+ }
200
+ return 0;
201
+ }
202
+
203
+ function listenerControlIdentityKey(control = {}) {
204
+ const explicitId = normalizeString(
205
+ control?.controlId || control?.control_id || control?.id,
206
+ );
207
+ if (explicitId) return `control:${explicitId}`;
208
+ return JSON.stringify({
209
+ type: listenerControlType(control),
210
+ issuedAt: normalizeString(control?.issuedAt || control?.issued_at),
211
+ targetAgentId: normalizeComparableId(
212
+ control?.targetAgentId ||
213
+ control?.target_agent_id ||
214
+ control?.payload?.targetAgentId ||
215
+ control?.payload?.target_agent_id,
216
+ ),
217
+ broadcast: Boolean(control?.broadcast || control?.payload?.broadcast),
218
+ reason: normalizeString(control?.reason || control?.payload?.reason),
219
+ });
220
+ }
221
+
144
222
  function eventTimestampMs(event = {}) {
145
223
  for (const key of ["ts", "timestamp", "createdAt", "at"]) {
146
224
  const epoch = Date.parse(normalizeString(event?.[key]));
@@ -241,6 +319,8 @@ export async function listenSessionEvents({
241
319
  intervalSeconds = 60,
242
320
  activeIntervalSeconds = DEFAULT_ACTIVE_INTERVAL_SECONDS,
243
321
  activeWindowSeconds = DEFAULT_ACTIVE_WINDOW_SECONDS,
322
+ jitterRatio = DEFAULT_POLL_JITTER_RATIO,
323
+ maxBackoffSeconds = DEFAULT_MAX_POLL_BACKOFF_SECONDS,
244
324
  limit = 200,
245
325
  since = undefined,
246
326
  replay = false,
@@ -249,6 +329,7 @@ export async function listenSessionEvents({
249
329
  maxPolls = null,
250
330
  signal,
251
331
  onEvent = async () => {},
332
+ onControl = async () => {},
252
333
  onError = async () => {},
253
334
  onCatchup = async () => {},
254
335
  onLifecycle = async () => {},
@@ -256,12 +337,14 @@ export async function listenSessionEvents({
256
337
  _poll = pollSessionEvents,
257
338
  _pollLatest = pollSessionEventsBefore,
258
339
  _stream = streamSessionEvents,
340
+ _updateReadCursor = updateSessionReadCursor,
259
341
  _readCursor = readSyncCursor,
260
342
  _writeCursor = writeSyncCursor,
261
343
  _sleep = defaultSleep,
262
344
  _setInterval = setInterval,
263
345
  _clearInterval = clearInterval,
264
346
  _nowMs = Date.now,
347
+ _random = Math.random,
265
348
  } = {}) {
266
349
  const normalizedSessionId = normalizeString(sessionId);
267
350
  const normalizedAgentId = normalizeComparableId(agentId) || "cli-user";
@@ -293,10 +376,16 @@ export async function listenSessionEvents({
293
376
  let streamAttempted = false;
294
377
  let streamFallbackReason = "";
295
378
  let heartbeatCount = 0;
379
+ let consecutivePollFailures = 0;
380
+ let readCursorUpdates = 0;
381
+ let readCursorReason = "";
382
+ let deliveredControls = 0;
383
+ let ignoredControls = 0;
296
384
  let catchupNotified = false;
297
385
  let catchupEventCount = 0;
298
386
  let catchupMatchingEventCount = 0;
299
387
  const emittedKeys = new Set();
388
+ const deliveredControlKeys = new Set();
300
389
  const maxPollCount = normalizePositiveInteger(maxPolls, 0);
301
390
  const pollLimit = normalizePositiveInteger(limit, 200);
302
391
  const idleSleepMs = Math.max(1, normalizePositiveInteger(intervalSeconds, 60)) * 1000;
@@ -306,6 +395,9 @@ export async function listenSessionEvents({
306
395
  const activeWindowMs =
307
396
  Math.max(1, normalizePositiveInteger(activeWindowSeconds, DEFAULT_ACTIVE_WINDOW_SECONDS)) *
308
397
  1000;
398
+ const maxBackoffMs =
399
+ Math.max(1, normalizePositiveInteger(maxBackoffSeconds, DEFAULT_MAX_POLL_BACKOFF_SECONDS)) *
400
+ 1000;
309
401
  const autoStreamIdleTimeoutMs =
310
402
  normalizedTransport === "auto" ? Math.max(5_000, Math.min(idleSleepMs, 30_000)) : 0;
311
403
 
@@ -354,6 +446,11 @@ export async function listenSessionEvents({
354
446
  ? new Date(lastHumanActivityMs).toISOString()
355
447
  : null,
356
448
  lastSleepMs,
449
+ consecutivePollFailures,
450
+ readCursorUpdates,
451
+ readCursorReason,
452
+ deliveredControls,
453
+ ignoredControls,
357
454
  reason: lastReason,
358
455
  ...extra,
359
456
  });
@@ -397,7 +494,7 @@ export async function listenSessionEvents({
397
494
  cursor: cursor || null,
398
495
  candidateCursor: nextCursor,
399
496
  });
400
- return false;
497
+ return { processed: false, retryAfterMs: null };
401
498
  }
402
499
 
403
500
  const observedAtMs = Number(_nowMs()) || Date.now();
@@ -413,7 +510,6 @@ export async function listenSessionEvents({
413
510
  if (isRecentActivity(activityMs, observedAtMs, activeWindowMs)) {
414
511
  lastHumanActivityMs = Math.max(lastHumanActivityMs, activityMs);
415
512
  }
416
- if (timestampMs && timestampMs < startedAtMs && isListenerStopDirective(event)) continue;
417
513
  if (isSessionListenerLifecycleEvent(event)) continue;
418
514
  if (!eventMatchesAgent(event, normalizedAgentId)) continue;
419
515
  visibleEvents.push(event);
@@ -471,8 +567,71 @@ export async function listenSessionEvents({
471
567
  persistedCursor = Boolean(writeResult?.written) || persistedCursor;
472
568
  cursor = nextCursor;
473
569
  }
570
+ // A fetch cursor echoed on an empty page is transport state, not new read
571
+ // progress. Writing it would recreate one operational write per idle poll.
572
+ const readTarget = events.length > 0
573
+ ? latestSequenceTarget(events, nextCursor)
574
+ : { targetSequenceId: null, targetCursor: "" };
575
+ let readCursorResult = null;
576
+ if (readTarget.targetSequenceId || readTarget.targetCursor) {
577
+ readCursorResult = await _updateReadCursor(normalizedSessionId, {
578
+ targetPath,
579
+ targetSequenceId: readTarget.targetSequenceId,
580
+ targetCursor: readTarget.targetCursor,
581
+ agentId: normalizedAgentId,
582
+ }).catch((error) => ({
583
+ ok: false,
584
+ reason: normalizeString(error?.message) || "read_cursor_write_failed",
585
+ }));
586
+ if (readCursorResult?.ok) {
587
+ readCursorUpdates += 1;
588
+ readCursorReason = "";
589
+ } else {
590
+ readCursorReason = normalizeString(readCursorResult?.reason) || "read_cursor_write_failed";
591
+ }
592
+ }
474
593
  primed = true;
475
- return true;
594
+ return {
595
+ processed: true,
596
+ retryAfterMs: Number(readCursorResult?.retryAfterMs) || null,
597
+ };
598
+ }
599
+
600
+ async function processControlBatch(controlsInput = []) {
601
+ const controls = Array.isArray(controlsInput) ? controlsInput : [];
602
+ const observedAtMs = Number(_nowMs()) || Date.now();
603
+ for (const control of controls) {
604
+ if (!isPlainObject(control)) {
605
+ ignoredControls += 1;
606
+ continue;
607
+ }
608
+ const issuedAtMs = listenerControlIssuedAtMs(control);
609
+ const fresh =
610
+ issuedAtMs >= startedAtMs - MAX_CLOCK_SKEW_MS &&
611
+ issuedAtMs <= observedAtMs + MAX_CLOCK_SKEW_MS;
612
+ if (
613
+ !listenerControlType(control) ||
614
+ !fresh ||
615
+ !eventMatchesAgent(control, normalizedAgentId)
616
+ ) {
617
+ ignoredControls += 1;
618
+ continue;
619
+ }
620
+ const key = listenerControlIdentityKey(control);
621
+ if (deliveredControlKeys.has(key)) {
622
+ ignoredControls += 1;
623
+ continue;
624
+ }
625
+ deliveredControlKeys.add(key);
626
+ try {
627
+ await onControl(control);
628
+ deliveredControls += 1;
629
+ } catch (error) {
630
+ deliveredControlKeys.delete(key);
631
+ throw error;
632
+ }
633
+ if (signal?.aborted) break;
634
+ }
476
635
  }
477
636
 
478
637
  async function notifyHeartbeat({ stopping = false, nextPollMs = null } = {}) {
@@ -492,7 +651,7 @@ export async function listenSessionEvents({
492
651
  }
493
652
 
494
653
  let streamHeartbeatTimer = null;
495
- let streamHeartbeatInFlight = false;
654
+ let streamMaintenanceInFlight = null;
496
655
  const streamHeartbeatMs = Math.max(1_000, Math.min(idleSleepMs, 60_000));
497
656
 
498
657
  function stopStreamHeartbeatTimer() {
@@ -503,14 +662,49 @@ export async function listenSessionEvents({
503
662
 
504
663
  function startStreamHeartbeatTimer() {
505
664
  stopStreamHeartbeatTimer();
506
- streamHeartbeatTimer = _setInterval(async () => {
507
- if (signal?.aborted || streamHeartbeatInFlight) return;
508
- streamHeartbeatInFlight = true;
509
- try {
510
- await notifyHeartbeat({ nextPollMs: streamHeartbeatMs });
511
- } finally {
512
- streamHeartbeatInFlight = false;
665
+ streamHeartbeatTimer = _setInterval(() => {
666
+ if (signal?.aborted || streamMaintenanceInFlight) {
667
+ return streamMaintenanceInFlight;
513
668
  }
669
+ const maintenance = (async () => {
670
+ const result = await _poll(normalizedSessionId, {
671
+ targetPath,
672
+ since: cursor,
673
+ limit: 1,
674
+ listenerAgentId: normalizedAgentId,
675
+ signal,
676
+ });
677
+ if (signal?.aborted) return;
678
+ if (result?.ok) {
679
+ await processControlBatch(result.listenerControls);
680
+ } else {
681
+ await onError({
682
+ ok: false,
683
+ reason: `control_poll_${normalizeString(result?.reason) || "failed"}`,
684
+ cursor: cursor || null,
685
+ retryAfterMs: Number(result?.retryAfterMs) || null,
686
+ status: Number(result?.status) || null,
687
+ });
688
+ }
689
+ if (!signal?.aborted) {
690
+ await notifyHeartbeat({ nextPollMs: streamHeartbeatMs });
691
+ }
692
+ })().catch(async (error) => {
693
+ if (signal?.aborted || shouldAbort(error, signal)) return;
694
+ await onError({
695
+ ok: false,
696
+ reason: "control_poll_failed",
697
+ cursor: cursor || null,
698
+ detail: normalizeString(error?.message),
699
+ }).catch(() => {});
700
+ });
701
+ streamMaintenanceInFlight = maintenance;
702
+ maintenance.finally(() => {
703
+ if (streamMaintenanceInFlight === maintenance) {
704
+ streamMaintenanceInFlight = null;
705
+ }
706
+ });
707
+ return maintenance;
514
708
  }, streamHeartbeatMs);
515
709
  }
516
710
 
@@ -534,6 +728,9 @@ export async function listenSessionEvents({
534
728
  onEvent: async (event) => {
535
729
  await processEventBatch([event], normalizeString(event?.cursor) || cursor);
536
730
  },
731
+ onCursor: async (streamCursor) => {
732
+ await processEventBatch([], streamCursor);
733
+ },
537
734
  onError: async (error) => {
538
735
  lastReason = `stream_${normalizeString(error?.reason) || "error"}`;
539
736
  await onError({
@@ -545,8 +742,9 @@ export async function listenSessionEvents({
545
742
  onHeartbeat: async () => {
546
743
  await notifyHeartbeat({ nextPollMs: streamHeartbeatMs });
547
744
  },
548
- }).finally(() => {
745
+ }).finally(async () => {
549
746
  stopStreamHeartbeatTimer();
747
+ await streamMaintenanceInFlight;
550
748
  });
551
749
  if (!streamResult?.ok) {
552
750
  streamFallbackReason = normalizeString(streamResult?.reason) || lastReason || "stream_failed";
@@ -567,24 +765,44 @@ export async function listenSessionEvents({
567
765
  targetPath,
568
766
  since: cursor,
569
767
  limit: pollLimit,
768
+ listenerAgentId: normalizedAgentId,
570
769
  });
571
770
 
771
+ let cursorRetryAfterMs = null;
572
772
  if (result?.ok) {
573
773
  lastReason = "";
574
- await processEventBatch(result.events, result.cursor);
774
+ consecutivePollFailures = 0;
775
+ await processControlBatch(result.listenerControls);
776
+ if (signal?.aborted) break;
777
+ const processed = await processEventBatch(result.events, result.cursor);
778
+ cursorRetryAfterMs = Number(processed?.retryAfterMs) || null;
575
779
  } else {
576
780
  lastReason = normalizeString(result?.reason) || "poll_failed";
781
+ consecutivePollFailures += 1;
577
782
  await onError({
578
783
  ok: false,
579
784
  reason: lastReason,
580
785
  cursor: result?.cursor || cursor || null,
786
+ retryAfterMs: Number(result?.retryAfterMs) || null,
787
+ status: Number(result?.status) || null,
581
788
  });
582
789
  }
583
790
 
584
791
  const willStop = maxPollCount > 0 && pollCount >= maxPollCount;
585
792
  const heartbeatAtMs = Number(_nowMs()) || Date.now();
586
793
  const humanActive = isRecentActivity(lastHumanActivityMs, heartbeatAtMs, activeWindowMs);
587
- const nextSleepMs = humanActive ? Math.min(idleSleepMs, activeSleepMs) : idleSleepMs;
794
+ const baseSleepMs = humanActive ? Math.min(idleSleepMs, activeSleepMs) : idleSleepMs;
795
+ const nextSleepMs = computeListenerPollDelay({
796
+ baseDelayMs: baseSleepMs,
797
+ consecutiveFailures: consecutivePollFailures,
798
+ retryAfterMs: Math.max(
799
+ Number(result?.retryAfterMs) || 0,
800
+ Number(cursorRetryAfterMs) || 0,
801
+ ),
802
+ jitterRatio,
803
+ maxBackoffMs,
804
+ randomValue: _random(),
805
+ });
588
806
  await notifyHeartbeat({ nextPollMs: willStop ? null : nextSleepMs, stopping: willStop });
589
807
 
590
808
  if (willStop) break;
@@ -619,6 +837,11 @@ export async function listenSessionEvents({
619
837
  matched,
620
838
  emitted,
621
839
  persistedCursor,
840
+ readCursorUpdates,
841
+ readCursorReason,
842
+ deliveredControls,
843
+ ignoredControls,
844
+ consecutivePollFailures,
622
845
  catchupNotified,
623
846
  catchupEventCount,
624
847
  catchupMatchingEventCount,
@@ -1,4 +1,4 @@
1
- import { pollSessionEventsBefore } from "./sync.js";
1
+ import { fetchSessionPresence } from "./sync.js";
2
2
 
3
3
  const LISTENER_EVENT_TYPES = new Set([
4
4
  "session_listener_started",
@@ -10,9 +10,6 @@ const LISTENER_EVENT_TYPES = new Set([
10
10
  // listener likely died without a clean stop — show it as stale, not live.
11
11
  const DEFAULT_STALE_AFTER_MS = 180_000;
12
12
  const MAX_STALE_GRACE_MS = 60_000;
13
- const DEFAULT_LISTENER_FETCH_LIMIT = 200;
14
- const DEFAULT_LISTENER_FETCH_MAX_PAGES = 5;
15
- const MAX_LISTENER_FETCH_MAX_PAGES = 25;
16
13
 
17
14
  function normalizeString(value) {
18
15
  return String(value || "").trim();
@@ -144,94 +141,86 @@ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfter
144
141
  return rows;
145
142
  }
146
143
 
144
+ function presenceEntryEpochMs(entry = {}) {
145
+ const explicit = Number(entry.lastSeenMs ?? entry.last_seen_ms);
146
+ if (Number.isFinite(explicit) && explicit > 0) {
147
+ return Math.floor(explicit);
148
+ }
149
+ const parsed = Date.parse(
150
+ normalizeString(entry.lastSeen || entry.last_seen || entry.updatedAt || entry.updated_at),
151
+ );
152
+ return Number.isFinite(parsed) ? parsed : null;
153
+ }
154
+
155
+ function presenceRows(entries = [], nowMs = Date.now()) {
156
+ return (Array.isArray(entries) ? entries : [])
157
+ .map((entry) => {
158
+ const agentId = normalizeString(entry?.agentId || entry?.agent_id || entry?.id);
159
+ if (!agentId) return null;
160
+ const seenAtMs = presenceEntryEpochMs(entry);
161
+ const ageMs = seenAtMs === null ? null : Math.max(0, nowMs - seenAtMs);
162
+ return {
163
+ agentId,
164
+ displayName: normalizeString(entry?.displayName || entry?.display_name) || agentId,
165
+ model: normalizeString(entry?.model),
166
+ status: "present",
167
+ active: null,
168
+ cadenceSeconds: null,
169
+ idleIntervalSeconds: null,
170
+ activeIntervalSeconds: null,
171
+ presenceIntervalSeconds: null,
172
+ presenceKeepaliveSeconds: null,
173
+ nextPollSeconds: null,
174
+ staleAfterSeconds: null,
175
+ lastSeenAt: seenAtMs ? new Date(seenAtMs).toISOString() : null,
176
+ lastSeenAgoSeconds: ageMs === null ? null : Math.round(ageMs / 1000),
177
+ lastHumanActivityAt: null,
178
+ };
179
+ })
180
+ .filter(Boolean)
181
+ .sort((left, right) => (right.lastSeenAt || "").localeCompare(left.lastSeenAt || ""));
182
+ }
183
+
147
184
  /**
148
- * Fetch recent session events from the API and summarize the listeners.
149
- * `limit` controls the raw API page size. Busy rooms can push durable
150
- * keepalive heartbeats out of a single tail page, so we walk a small bounded
151
- * number of older pages while the tail contains no listener lifecycle events
152
- * and the sequence cursor advances.
185
+ * Fetch the authoritative ephemeral presence roster.
186
+ *
187
+ * There is intentionally no event-tail compatibility path. A server without
188
+ * the presence endpoint, a disabled rollout flag, or degraded Redis produces
189
+ * an unknown roster instead of silently making every listener a durable
190
+ * heartbeat writer again.
153
191
  */
154
192
  export async function fetchSessionListeners(
155
193
  sessionId,
156
194
  {
157
195
  targetPath = process.cwd(),
158
- limit = DEFAULT_LISTENER_FETCH_LIMIT,
159
- maxPages = DEFAULT_LISTENER_FETCH_MAX_PAGES,
160
196
  nowMs = Date.now,
161
197
  forceCircuitProbe = true,
162
- poll = pollSessionEventsBefore,
198
+ fetchPresence = fetchSessionPresence,
163
199
  } = {}
164
200
  ) {
165
- const pageLimit = Math.max(
166
- 1,
167
- Math.min(DEFAULT_LISTENER_FETCH_LIMIT, positiveInt(limit) || DEFAULT_LISTENER_FETCH_LIMIT),
168
- );
169
- const maxPageCount = Math.max(
170
- 1,
171
- Math.min(MAX_LISTENER_FETCH_MAX_PAGES, positiveInt(maxPages) || DEFAULT_LISTENER_FETCH_MAX_PAGES),
172
- );
173
- let beforeSequence = null;
174
- let latestResult = null;
175
- let allEvents = [];
176
- let pageCount = 0;
177
- let listenerEventCount = 0;
178
- let partial = false;
179
- let partialReason = "";
180
-
181
- for (let page = 0; page < maxPageCount; page += 1) {
182
- const result = await poll(sessionId, {
183
- targetPath,
184
- beforeSequence,
185
- limit: pageLimit,
186
- forceCircuitProbe,
187
- });
188
- pageCount += 1;
189
-
190
- if (!result?.ok) {
191
- if (!latestResult) {
192
- return {
193
- ok: false,
194
- reason: normalizeString(result?.reason) || "fetch_failed",
195
- listeners: [],
196
- pageCount,
197
- scannedEventCount: allEvents.length,
198
- listenerEventCount: 0,
199
- partial: false,
200
- };
201
- }
202
- partial = true;
203
- partialReason = normalizeString(result?.reason) || "partial_fetch";
204
- break;
205
- }
206
-
207
- latestResult = result;
208
- const pageEvents = Array.isArray(result.events) ? result.events : [];
209
- allEvents = [...pageEvents, ...allEvents];
210
- listenerEventCount += pageEvents.filter((event) =>
211
- LISTENER_EVENT_TYPES.has(normalizeString(event?.event))
212
- ).length;
213
-
214
- if (pageEvents.length < pageLimit) break;
215
- if (listenerEventCount > 0) break;
216
-
217
- const candidateBeforeSequence = Number(result.beforeSequence || 0);
218
- if (!Number.isFinite(candidateBeforeSequence) || candidateBeforeSequence <= 0) break;
219
- const currentBeforeSequence = Number(beforeSequence || 0);
220
- if (currentBeforeSequence > 0 && candidateBeforeSequence >= currentBeforeSequence) break;
221
- beforeSequence = Math.floor(candidateBeforeSequence);
222
- }
223
-
224
- const listeners = summarizeListeners(allEvents, { nowMs: nowMs() });
201
+ const result = await fetchPresence(sessionId, {
202
+ targetPath,
203
+ forceCircuitProbe,
204
+ });
205
+ const presenceStatus = normalizeString(result?.status).toLowerCase() || "degraded";
206
+ const authoritative = Boolean(result?.ok && result?.enabled !== false && presenceStatus === "ok");
207
+ const listeners = authoritative
208
+ ? presenceRows(result.present, Number(nowMs()) || Date.now())
209
+ : [];
225
210
  return {
226
- ok: true,
211
+ ok: authoritative,
212
+ authoritative,
213
+ enabled: result?.enabled ?? null,
214
+ presenceStatus,
215
+ reason: authoritative ? "" : normalizeString(result?.reason) || "presence_unknown",
216
+ retryAfterMs: Number(result?.retryAfterMs) || null,
227
217
  sessionId: normalizeString(sessionId),
228
218
  listeners,
229
- pageCount,
230
- scannedEventCount: allEvents.length,
231
- listenerEventCount,
232
- beforeSequence: latestResult?.beforeSequence || beforeSequence || null,
233
- partial,
234
- reason: partial ? partialReason : "",
219
+ pageCount: 0,
220
+ scannedEventCount: 0,
221
+ listenerEventCount: 0,
222
+ beforeSequence: null,
223
+ partial: false,
235
224
  };
236
225
  }
237
226
 
@@ -239,7 +228,9 @@ export function formatListenerLine(row) {
239
228
  const cadence = row.cadenceSeconds ? `${row.cadenceSeconds}s` : "—";
240
229
  const seen = row.lastSeenAgoSeconds === null ? "never" : `${row.lastSeenAgoSeconds}s ago`;
241
230
  const statusLabel =
242
- row.status === "active"
231
+ row.status === "present"
232
+ ? "● present"
233
+ : row.status === "active"
243
234
  ? "● active"
244
235
  : row.status === "idle"
245
236
  ? "○ idle"
@@ -28,6 +28,8 @@ export function resolveSessionPaths(sessionId, { targetPath = process.cwd() } =
28
28
  lockPath: path.join(sessionDir, ".stream.lock"),
29
29
  fileLocksPath: path.join(sessionDir, "file-locks.json"),
30
30
  fileLocksLockPath: path.join(sessionDir, ".file-locks.lock"),
31
+ fileLeaseCapabilitiesPath: path.join(sessionDir, "file-lease-capabilities.json"),
32
+ fileLeaseCapabilitiesLockPath: path.join(sessionDir, ".file-lease-capabilities.lock"),
31
33
  tasksPath: path.join(sessionDir, "tasks.json"),
32
34
  tasksLockPath: path.join(sessionDir, ".tasks.lock"),
33
35
  agentsDir: path.join(sessionDir, "agents"),