sentinelayer-cli 0.40.0 → 0.42.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 (69) hide show
  1. package/README.md +22 -4
  2. package/package.json +4 -4
  3. package/src/auth/admission-scope.js +34 -0
  4. package/src/auth/gate.js +6 -0
  5. package/src/auth/http.js +4 -0
  6. package/src/auth/service.js +6 -0
  7. package/src/board/done-gate.js +155 -0
  8. package/src/board/evidence-resolvers.js +138 -0
  9. package/src/cli.js +24 -1
  10. package/src/commands/board.js +116 -0
  11. package/src/commands/session.js +1094 -279
  12. package/src/engram/build-state.js +183 -0
  13. package/src/engram/canonical.js +75 -0
  14. package/src/engram/document.js +190 -0
  15. package/src/engram/governance.js +67 -0
  16. package/src/engram/index.js +73 -0
  17. package/src/engram/ingest-document.js +107 -0
  18. package/src/engram/namespace.js +89 -0
  19. package/src/engram/observations.js +34 -0
  20. package/src/engram/sla.js +104 -0
  21. package/src/engram/store.js +120 -0
  22. package/src/engram/tools.js +152 -0
  23. package/src/engram/trust.js +105 -0
  24. package/src/events/schema.js +18 -0
  25. package/src/legacy-cli.js +6 -3
  26. package/src/mcp/registry.js +27 -10
  27. package/src/mcp/session-stdio-server.js +209 -18
  28. package/src/net/test-egress-guard.js +158 -0
  29. package/src/review/investor-dd-orchestrator.js +14 -2
  30. package/src/session/admission-auth.js +312 -0
  31. package/src/session/admission-preimage.js +90 -0
  32. package/src/session/admission.js +620 -0
  33. package/src/session/agent-registry.js +22 -63
  34. package/src/session/checkpoints.js +25 -1
  35. package/src/session/codebase-context.js +54 -0
  36. package/src/session/coordination-guidance.js +6 -6
  37. package/src/session/daemon-spawn.js +20 -2
  38. package/src/session/daemon.js +26 -125
  39. package/src/session/event-identity.js +9 -2
  40. package/src/session/file-lease-integrations.js +1300 -0
  41. package/src/session/file-locks.js +967 -464
  42. package/src/session/first-message.js +11 -3
  43. package/src/session/listener.js +246 -20
  44. package/src/session/listeners.js +71 -80
  45. package/src/session/live-source.js +47 -16
  46. package/src/session/message-edits.js +89 -0
  47. package/src/session/paths.js +2 -0
  48. package/src/session/recall/embedder.js +181 -0
  49. package/src/session/recall/entities.js +0 -0
  50. package/src/session/recall/index-build.js +38 -0
  51. package/src/session/recall/index-core.js +108 -0
  52. package/src/session/recall/index.js +131 -0
  53. package/src/session/recall/needle-eval.js +279 -0
  54. package/src/session/recall/observation-core.js +183 -0
  55. package/src/session/recall/observations.js +21 -0
  56. package/src/session/recall/provenance.js +153 -0
  57. package/src/session/recall/retrieve.js +374 -0
  58. package/src/session/recall/text.js +0 -0
  59. package/src/session/recall/token-stats.js +81 -0
  60. package/src/session/recap.js +54 -26
  61. package/src/session/remote-hydrate.js +12 -20
  62. package/src/session/store.js +13 -14
  63. package/src/session/stream.js +23 -3
  64. package/src/session/sync.js +789 -22
  65. package/src/session/tickets.js +316 -0
  66. package/src/session/title-sync.js +2 -1
  67. package/src/session/usage-report.js +41 -2
  68. package/src/session/wake/pump.js +8 -3
  69. package/src/session/wake/resolve-target.js +10 -5
@@ -1,5 +1,6 @@
1
1
  import { createAgentEvent } from "../events/schema.js";
2
2
  import { appendToStream } from "./stream.js";
3
+ import { syncSessionEventToApi } from "./sync.js";
3
4
 
4
5
  export const FIRST_MESSAGE_AGENT = Object.freeze({
5
6
  id: "senti",
@@ -72,7 +73,7 @@ export function buildFirstSentiMessage({ sessionId } = {}) {
72
73
 
73
74
  /**
74
75
  * Post the first-Senti-message as the opening event of a freshly created
75
- * session. Best-effort + non-blocking — a failure never fails session
76
+ * session. Best-effort with bounded remote sync — a failure never fails session
76
77
  * creation. Returns { posted, reason }.
77
78
  */
78
79
  export async function postFirstSentiMessage({ sessionId, targetPath = process.cwd() } = {}) {
@@ -91,8 +92,15 @@ export async function postFirstSentiMessage({ sessionId, targetPath = process.cw
91
92
  },
92
93
  });
93
94
  try {
94
- await appendToStream(sid, event, { targetPath, awaitRemoteSync: true });
95
- return { posted: true, reason: "posted" };
95
+ const persisted = await appendToStream(sid, event, {
96
+ targetPath, syncRemote: false, lockTimeoutMs: 500,
97
+ });
98
+ const remoteSync = await syncSessionEventToApi(sid, persisted, {
99
+ targetPath, timeoutMs: 2_000,
100
+ }).catch(() => ({ synced: false, reason: "sync_failed" }));
101
+ // Keep posted's existing local-durability meaning; remote acknowledgement
102
+ // is explicit and must never be inferred from this compatibility field.
103
+ return { posted: true, reason: "posted", remoteSynced: Boolean(remoteSync.synced), remoteSync };
96
104
  } catch (error) {
97
105
  return { posted: false, reason: normalizeString(error?.message) || "append_failed" };
98
106
  }
@@ -1,8 +1,14 @@
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";
11
+ import { sessionMessageRoutingEvent } from "./message-edits.js";
6
12
 
7
13
  const BROADCAST_RECIPIENTS = new Set([
8
14
  "*",
@@ -14,8 +20,10 @@ const BROADCAST_RECIPIENTS = new Set([
14
20
  "all-agents",
15
21
  ]);
16
22
 
17
- const DEFAULT_ACTIVE_INTERVAL_SECONDS = 5;
23
+ const DEFAULT_ACTIVE_INTERVAL_SECONDS = 60;
18
24
  const DEFAULT_ACTIVE_WINDOW_SECONDS = 300;
25
+ const DEFAULT_POLL_JITTER_RATIO = 0.2;
26
+ const DEFAULT_MAX_POLL_BACKOFF_SECONDS = 300;
19
27
  const MAX_CLOCK_SKEW_MS = 60_000;
20
28
 
21
29
  function normalizeString(value) {
@@ -38,10 +46,6 @@ function normalizePositiveInteger(value, fallbackValue) {
38
46
  return Math.floor(normalized);
39
47
  }
40
48
 
41
- function isListenerStopDirective(event = {}) {
42
- return normalizeString(event?.event) === "listener_stop";
43
- }
44
-
45
49
  function isPlainObject(value) {
46
50
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
47
51
  }
@@ -65,6 +69,7 @@ function addRecipientValue(values, value) {
65
69
  }
66
70
 
67
71
  export function collectSessionEventRecipients(event = {}) {
72
+ event = sessionMessageRoutingEvent(event);
68
73
  const values = [];
69
74
  if (!isPlainObject(event)) return values;
70
75
  const payload = isPlainObject(event.payload) ? event.payload : {};
@@ -87,6 +92,7 @@ export function collectSessionEventRecipients(event = {}) {
87
92
 
88
93
  export function eventMatchesAgent(event = {}, agentId = "") {
89
94
  if (!isPlainObject(event)) return false;
95
+ event = sessionMessageRoutingEvent(event);
90
96
  const normalizedAgentId = normalizeComparableId(agentId);
91
97
  if (!normalizedAgentId) return false;
92
98
 
@@ -128,6 +134,46 @@ function cursorFromEvents(events = [], fallbackCursor = null) {
128
134
  return cursor;
129
135
  }
130
136
 
137
+ function latestSequenceTarget(events = [], fallbackCursor = null) {
138
+ let targetSequenceId = null;
139
+ let targetCursor = normalizeString(fallbackCursor) || "";
140
+ for (const event of Array.isArray(events) ? events : []) {
141
+ const sequence = Number(event?.sequenceId ?? event?.sequence_id ?? event?.sequence);
142
+ if (Number.isFinite(sequence) && sequence > 0) {
143
+ if (targetSequenceId === null || sequence > targetSequenceId) {
144
+ targetSequenceId = Math.floor(sequence);
145
+ targetCursor = normalizeString(event?.cursor) || targetCursor;
146
+ }
147
+ continue;
148
+ }
149
+ const cursor = normalizeString(event?.cursor);
150
+ if (cursor) targetCursor = cursor;
151
+ }
152
+ return { targetSequenceId, targetCursor };
153
+ }
154
+
155
+ export function computeListenerPollDelay({
156
+ baseDelayMs,
157
+ consecutiveFailures = 0,
158
+ retryAfterMs = null,
159
+ jitterRatio = DEFAULT_POLL_JITTER_RATIO,
160
+ maxBackoffMs = DEFAULT_MAX_POLL_BACKOFF_SECONDS * 1000,
161
+ randomValue = Math.random(),
162
+ } = {}) {
163
+ const base = Math.max(1, Math.floor(Number(baseDelayMs) || 1));
164
+ const failures = Math.max(0, Math.floor(Number(consecutiveFailures) || 0));
165
+ const maxBackoff = Math.max(base, Math.floor(Number(maxBackoffMs) || base));
166
+ const exponential = failures > 0
167
+ ? Math.min(maxBackoff, base * (2 ** Math.min(failures - 1, 20)))
168
+ : base;
169
+ const retryFloor = Math.max(0, Math.ceil(Number(retryAfterMs) || 0));
170
+ const boundedJitterRatio = Math.max(0, Math.min(0.5, Number(jitterRatio) || 0));
171
+ const boundedRandom = Math.max(0, Math.min(1, Number(randomValue) || 0));
172
+ const jitter = Math.floor(exponential * boundedJitterRatio * boundedRandom);
173
+ const jitteredBackoff = Math.min(maxBackoff, exponential + jitter);
174
+ return Math.max(retryFloor, jitteredBackoff);
175
+ }
176
+
131
177
  function eventIdentityKey(event = {}) {
132
178
  const cursor = normalizeString(event?.cursor);
133
179
  if (cursor) return `cursor:${cursor}`;
@@ -141,6 +187,41 @@ function eventIdentityKey(event = {}) {
141
187
  });
142
188
  }
143
189
 
190
+ function listenerControlType(control = {}) {
191
+ return normalizeLower(control?.type || control?.action || control?.event);
192
+ }
193
+
194
+ function listenerControlIssuedAtMs(control = {}) {
195
+ for (const key of ["issuedAtMs", "issued_at_ms"]) {
196
+ const epoch = Number(control?.[key]);
197
+ if (Number.isFinite(epoch) && epoch > 0) return epoch;
198
+ }
199
+ for (const key of ["issuedAt", "issued_at"]) {
200
+ const epoch = Date.parse(normalizeString(control?.[key]));
201
+ if (Number.isFinite(epoch)) return epoch;
202
+ }
203
+ return 0;
204
+ }
205
+
206
+ function listenerControlIdentityKey(control = {}) {
207
+ const explicitId = normalizeString(
208
+ control?.controlId || control?.control_id || control?.id,
209
+ );
210
+ if (explicitId) return `control:${explicitId}`;
211
+ return JSON.stringify({
212
+ type: listenerControlType(control),
213
+ issuedAt: normalizeString(control?.issuedAt || control?.issued_at),
214
+ targetAgentId: normalizeComparableId(
215
+ control?.targetAgentId ||
216
+ control?.target_agent_id ||
217
+ control?.payload?.targetAgentId ||
218
+ control?.payload?.target_agent_id,
219
+ ),
220
+ broadcast: Boolean(control?.broadcast || control?.payload?.broadcast),
221
+ reason: normalizeString(control?.reason || control?.payload?.reason),
222
+ });
223
+ }
224
+
144
225
  function eventTimestampMs(event = {}) {
145
226
  for (const key of ["ts", "timestamp", "createdAt", "at"]) {
146
227
  const epoch = Date.parse(normalizeString(event?.[key]));
@@ -241,6 +322,8 @@ export async function listenSessionEvents({
241
322
  intervalSeconds = 60,
242
323
  activeIntervalSeconds = DEFAULT_ACTIVE_INTERVAL_SECONDS,
243
324
  activeWindowSeconds = DEFAULT_ACTIVE_WINDOW_SECONDS,
325
+ jitterRatio = DEFAULT_POLL_JITTER_RATIO,
326
+ maxBackoffSeconds = DEFAULT_MAX_POLL_BACKOFF_SECONDS,
244
327
  limit = 200,
245
328
  since = undefined,
246
329
  replay = false,
@@ -249,6 +332,7 @@ export async function listenSessionEvents({
249
332
  maxPolls = null,
250
333
  signal,
251
334
  onEvent = async () => {},
335
+ onControl = async () => {},
252
336
  onError = async () => {},
253
337
  onCatchup = async () => {},
254
338
  onLifecycle = async () => {},
@@ -256,12 +340,14 @@ export async function listenSessionEvents({
256
340
  _poll = pollSessionEvents,
257
341
  _pollLatest = pollSessionEventsBefore,
258
342
  _stream = streamSessionEvents,
343
+ _updateReadCursor = updateSessionReadCursor,
259
344
  _readCursor = readSyncCursor,
260
345
  _writeCursor = writeSyncCursor,
261
346
  _sleep = defaultSleep,
262
347
  _setInterval = setInterval,
263
348
  _clearInterval = clearInterval,
264
349
  _nowMs = Date.now,
350
+ _random = Math.random,
265
351
  } = {}) {
266
352
  const normalizedSessionId = normalizeString(sessionId);
267
353
  const normalizedAgentId = normalizeComparableId(agentId) || "cli-user";
@@ -293,10 +379,16 @@ export async function listenSessionEvents({
293
379
  let streamAttempted = false;
294
380
  let streamFallbackReason = "";
295
381
  let heartbeatCount = 0;
382
+ let consecutivePollFailures = 0;
383
+ let readCursorUpdates = 0;
384
+ let readCursorReason = "";
385
+ let deliveredControls = 0;
386
+ let ignoredControls = 0;
296
387
  let catchupNotified = false;
297
388
  let catchupEventCount = 0;
298
389
  let catchupMatchingEventCount = 0;
299
390
  const emittedKeys = new Set();
391
+ const deliveredControlKeys = new Set();
300
392
  const maxPollCount = normalizePositiveInteger(maxPolls, 0);
301
393
  const pollLimit = normalizePositiveInteger(limit, 200);
302
394
  const idleSleepMs = Math.max(1, normalizePositiveInteger(intervalSeconds, 60)) * 1000;
@@ -306,6 +398,9 @@ export async function listenSessionEvents({
306
398
  const activeWindowMs =
307
399
  Math.max(1, normalizePositiveInteger(activeWindowSeconds, DEFAULT_ACTIVE_WINDOW_SECONDS)) *
308
400
  1000;
401
+ const maxBackoffMs =
402
+ Math.max(1, normalizePositiveInteger(maxBackoffSeconds, DEFAULT_MAX_POLL_BACKOFF_SECONDS)) *
403
+ 1000;
309
404
  const autoStreamIdleTimeoutMs =
310
405
  normalizedTransport === "auto" ? Math.max(5_000, Math.min(idleSleepMs, 30_000)) : 0;
311
406
 
@@ -354,6 +449,11 @@ export async function listenSessionEvents({
354
449
  ? new Date(lastHumanActivityMs).toISOString()
355
450
  : null,
356
451
  lastSleepMs,
452
+ consecutivePollFailures,
453
+ readCursorUpdates,
454
+ readCursorReason,
455
+ deliveredControls,
456
+ ignoredControls,
357
457
  reason: lastReason,
358
458
  ...extra,
359
459
  });
@@ -397,7 +497,7 @@ export async function listenSessionEvents({
397
497
  cursor: cursor || null,
398
498
  candidateCursor: nextCursor,
399
499
  });
400
- return false;
500
+ return { processed: false, retryAfterMs: null };
401
501
  }
402
502
 
403
503
  const observedAtMs = Number(_nowMs()) || Date.now();
@@ -413,7 +513,6 @@ export async function listenSessionEvents({
413
513
  if (isRecentActivity(activityMs, observedAtMs, activeWindowMs)) {
414
514
  lastHumanActivityMs = Math.max(lastHumanActivityMs, activityMs);
415
515
  }
416
- if (timestampMs && timestampMs < startedAtMs && isListenerStopDirective(event)) continue;
417
516
  if (isSessionListenerLifecycleEvent(event)) continue;
418
517
  if (!eventMatchesAgent(event, normalizedAgentId)) continue;
419
518
  visibleEvents.push(event);
@@ -471,8 +570,71 @@ export async function listenSessionEvents({
471
570
  persistedCursor = Boolean(writeResult?.written) || persistedCursor;
472
571
  cursor = nextCursor;
473
572
  }
573
+ // A fetch cursor echoed on an empty page is transport state, not new read
574
+ // progress. Writing it would recreate one operational write per idle poll.
575
+ const readTarget = events.length > 0
576
+ ? latestSequenceTarget(events, nextCursor)
577
+ : { targetSequenceId: null, targetCursor: "" };
578
+ let readCursorResult = null;
579
+ if (readTarget.targetSequenceId || readTarget.targetCursor) {
580
+ readCursorResult = await _updateReadCursor(normalizedSessionId, {
581
+ targetPath,
582
+ targetSequenceId: readTarget.targetSequenceId,
583
+ targetCursor: readTarget.targetCursor,
584
+ agentId: normalizedAgentId,
585
+ }).catch((error) => ({
586
+ ok: false,
587
+ reason: normalizeString(error?.message) || "read_cursor_write_failed",
588
+ }));
589
+ if (readCursorResult?.ok) {
590
+ readCursorUpdates += 1;
591
+ readCursorReason = "";
592
+ } else {
593
+ readCursorReason = normalizeString(readCursorResult?.reason) || "read_cursor_write_failed";
594
+ }
595
+ }
474
596
  primed = true;
475
- return true;
597
+ return {
598
+ processed: true,
599
+ retryAfterMs: Number(readCursorResult?.retryAfterMs) || null,
600
+ };
601
+ }
602
+
603
+ async function processControlBatch(controlsInput = []) {
604
+ const controls = Array.isArray(controlsInput) ? controlsInput : [];
605
+ const observedAtMs = Number(_nowMs()) || Date.now();
606
+ for (const control of controls) {
607
+ if (!isPlainObject(control)) {
608
+ ignoredControls += 1;
609
+ continue;
610
+ }
611
+ const issuedAtMs = listenerControlIssuedAtMs(control);
612
+ const fresh =
613
+ issuedAtMs >= startedAtMs - MAX_CLOCK_SKEW_MS &&
614
+ issuedAtMs <= observedAtMs + MAX_CLOCK_SKEW_MS;
615
+ if (
616
+ !listenerControlType(control) ||
617
+ !fresh ||
618
+ !eventMatchesAgent(control, normalizedAgentId)
619
+ ) {
620
+ ignoredControls += 1;
621
+ continue;
622
+ }
623
+ const key = listenerControlIdentityKey(control);
624
+ if (deliveredControlKeys.has(key)) {
625
+ ignoredControls += 1;
626
+ continue;
627
+ }
628
+ deliveredControlKeys.add(key);
629
+ try {
630
+ await onControl(control);
631
+ deliveredControls += 1;
632
+ } catch (error) {
633
+ deliveredControlKeys.delete(key);
634
+ throw error;
635
+ }
636
+ if (signal?.aborted) break;
637
+ }
476
638
  }
477
639
 
478
640
  async function notifyHeartbeat({ stopping = false, nextPollMs = null } = {}) {
@@ -492,7 +654,7 @@ export async function listenSessionEvents({
492
654
  }
493
655
 
494
656
  let streamHeartbeatTimer = null;
495
- let streamHeartbeatInFlight = false;
657
+ let streamMaintenanceInFlight = null;
496
658
  const streamHeartbeatMs = Math.max(1_000, Math.min(idleSleepMs, 60_000));
497
659
 
498
660
  function stopStreamHeartbeatTimer() {
@@ -503,14 +665,49 @@ export async function listenSessionEvents({
503
665
 
504
666
  function startStreamHeartbeatTimer() {
505
667
  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;
668
+ streamHeartbeatTimer = _setInterval(() => {
669
+ if (signal?.aborted || streamMaintenanceInFlight) {
670
+ return streamMaintenanceInFlight;
513
671
  }
672
+ const maintenance = (async () => {
673
+ const result = await _poll(normalizedSessionId, {
674
+ targetPath,
675
+ since: cursor,
676
+ limit: 1,
677
+ listenerAgentId: normalizedAgentId,
678
+ signal,
679
+ });
680
+ if (signal?.aborted) return;
681
+ if (result?.ok) {
682
+ await processControlBatch(result.listenerControls);
683
+ } else {
684
+ await onError({
685
+ ok: false,
686
+ reason: `control_poll_${normalizeString(result?.reason) || "failed"}`,
687
+ cursor: cursor || null,
688
+ retryAfterMs: Number(result?.retryAfterMs) || null,
689
+ status: Number(result?.status) || null,
690
+ });
691
+ }
692
+ if (!signal?.aborted) {
693
+ await notifyHeartbeat({ nextPollMs: streamHeartbeatMs });
694
+ }
695
+ })().catch(async (error) => {
696
+ if (signal?.aborted || shouldAbort(error, signal)) return;
697
+ await onError({
698
+ ok: false,
699
+ reason: "control_poll_failed",
700
+ cursor: cursor || null,
701
+ detail: normalizeString(error?.message),
702
+ }).catch(() => {});
703
+ });
704
+ streamMaintenanceInFlight = maintenance;
705
+ maintenance.finally(() => {
706
+ if (streamMaintenanceInFlight === maintenance) {
707
+ streamMaintenanceInFlight = null;
708
+ }
709
+ });
710
+ return maintenance;
514
711
  }, streamHeartbeatMs);
515
712
  }
516
713
 
@@ -534,6 +731,9 @@ export async function listenSessionEvents({
534
731
  onEvent: async (event) => {
535
732
  await processEventBatch([event], normalizeString(event?.cursor) || cursor);
536
733
  },
734
+ onCursor: async (streamCursor) => {
735
+ await processEventBatch([], streamCursor);
736
+ },
537
737
  onError: async (error) => {
538
738
  lastReason = `stream_${normalizeString(error?.reason) || "error"}`;
539
739
  await onError({
@@ -545,8 +745,9 @@ export async function listenSessionEvents({
545
745
  onHeartbeat: async () => {
546
746
  await notifyHeartbeat({ nextPollMs: streamHeartbeatMs });
547
747
  },
548
- }).finally(() => {
748
+ }).finally(async () => {
549
749
  stopStreamHeartbeatTimer();
750
+ await streamMaintenanceInFlight;
550
751
  });
551
752
  if (!streamResult?.ok) {
552
753
  streamFallbackReason = normalizeString(streamResult?.reason) || lastReason || "stream_failed";
@@ -567,24 +768,44 @@ export async function listenSessionEvents({
567
768
  targetPath,
568
769
  since: cursor,
569
770
  limit: pollLimit,
771
+ listenerAgentId: normalizedAgentId,
570
772
  });
571
773
 
774
+ let cursorRetryAfterMs = null;
572
775
  if (result?.ok) {
573
776
  lastReason = "";
574
- await processEventBatch(result.events, result.cursor);
777
+ consecutivePollFailures = 0;
778
+ await processControlBatch(result.listenerControls);
779
+ if (signal?.aborted) break;
780
+ const processed = await processEventBatch(result.events, result.cursor);
781
+ cursorRetryAfterMs = Number(processed?.retryAfterMs) || null;
575
782
  } else {
576
783
  lastReason = normalizeString(result?.reason) || "poll_failed";
784
+ consecutivePollFailures += 1;
577
785
  await onError({
578
786
  ok: false,
579
787
  reason: lastReason,
580
788
  cursor: result?.cursor || cursor || null,
789
+ retryAfterMs: Number(result?.retryAfterMs) || null,
790
+ status: Number(result?.status) || null,
581
791
  });
582
792
  }
583
793
 
584
794
  const willStop = maxPollCount > 0 && pollCount >= maxPollCount;
585
795
  const heartbeatAtMs = Number(_nowMs()) || Date.now();
586
796
  const humanActive = isRecentActivity(lastHumanActivityMs, heartbeatAtMs, activeWindowMs);
587
- const nextSleepMs = humanActive ? Math.min(idleSleepMs, activeSleepMs) : idleSleepMs;
797
+ const baseSleepMs = humanActive ? Math.min(idleSleepMs, activeSleepMs) : idleSleepMs;
798
+ const nextSleepMs = computeListenerPollDelay({
799
+ baseDelayMs: baseSleepMs,
800
+ consecutiveFailures: consecutivePollFailures,
801
+ retryAfterMs: Math.max(
802
+ Number(result?.retryAfterMs) || 0,
803
+ Number(cursorRetryAfterMs) || 0,
804
+ ),
805
+ jitterRatio,
806
+ maxBackoffMs,
807
+ randomValue: _random(),
808
+ });
588
809
  await notifyHeartbeat({ nextPollMs: willStop ? null : nextSleepMs, stopping: willStop });
589
810
 
590
811
  if (willStop) break;
@@ -619,6 +840,11 @@ export async function listenSessionEvents({
619
840
  matched,
620
841
  emitted,
621
842
  persistedCursor,
843
+ readCursorUpdates,
844
+ readCursorReason,
845
+ deliveredControls,
846
+ ignoredControls,
847
+ consecutivePollFailures,
622
848
  catchupNotified,
623
849
  catchupEventCount,
624
850
  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"