sentinelayer-cli 0.18.2 → 0.20.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.
@@ -17,6 +17,9 @@ const DEFAULT_RECAP_INTERVAL_MS = 300_000;
17
17
  const DEFAULT_RECAP_INACTIVITY_MS = 600_000;
18
18
  const DEFAULT_RECAP_ACTIVITY_THRESHOLD = 5;
19
19
  const DEFAULT_TASK_SUMMARY_LIMIT = 3;
20
+ const DEFAULT_WORK_PLAN_SUMMARY_LIMIT = 5;
21
+ const MAX_WORK_PLAN_BYTES = 128_000;
22
+ const WORK_PLAN_RELATIVE_PATH = "tasks/todo.md";
20
23
  const RECAP_SOURCE_IGNORED_EVENTS = new Set([
21
24
  "agent_heartbeat",
22
25
  "agent_join",
@@ -399,6 +402,105 @@ function emptyTaskLedgerSummary() {
399
402
  };
400
403
  }
401
404
 
405
+ function emptyWorkPlanSummary() {
406
+ return {
407
+ path: WORK_PLAN_RELATIVE_PATH,
408
+ exists: false,
409
+ truncated: false,
410
+ total: 0,
411
+ open: 0,
412
+ completed: 0,
413
+ currentSection: "",
414
+ recentOpen: [],
415
+ recent: [],
416
+ };
417
+ }
418
+
419
+ function shortWorkPlanText(value) {
420
+ const text = normalizeString(value)
421
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
422
+ .replace(/`([^`]+)`/g, "$1")
423
+ .replace(/\s+/g, " ");
424
+ if (text.length <= 100) {
425
+ return text;
426
+ }
427
+ return `${text.slice(0, 97)}...`;
428
+ }
429
+
430
+ function summarizeWorkPlanMarkdown(raw = "", { limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT, truncated = false } = {}) {
431
+ const summary = emptyWorkPlanSummary();
432
+ summary.exists = true;
433
+ summary.truncated = Boolean(truncated);
434
+
435
+ const records = [];
436
+ let section = "";
437
+ for (const line of String(raw || "").split(/\r?\n/)) {
438
+ const headingMatch = /^(#{1,4})\s+(.+?)\s*$/.exec(line);
439
+ if (headingMatch) {
440
+ section = shortWorkPlanText(headingMatch[2]);
441
+ continue;
442
+ }
443
+
444
+ const taskMatch = /^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$/.exec(line);
445
+ if (!taskMatch) {
446
+ continue;
447
+ }
448
+ const completed = taskMatch[1].toLowerCase() === "x";
449
+ const record = {
450
+ status: completed ? "completed" : "open",
451
+ section,
452
+ task: shortWorkPlanText(taskMatch[2]),
453
+ };
454
+ if (!record.task) {
455
+ continue;
456
+ }
457
+ records.push(record);
458
+ summary.total += 1;
459
+ if (completed) {
460
+ summary.completed += 1;
461
+ } else {
462
+ summary.open += 1;
463
+ }
464
+ summary.currentSection = section || summary.currentSection;
465
+ }
466
+
467
+ const normalizedLimit = Math.max(1, normalizePositiveInteger(limit, DEFAULT_WORK_PLAN_SUMMARY_LIMIT));
468
+ summary.recentOpen = records
469
+ .filter((record) => record.status === "open")
470
+ .slice(-normalizedLimit);
471
+ summary.recent = records.slice(-normalizedLimit);
472
+ return summary;
473
+ }
474
+
475
+ async function readWorkPlanSummary({ targetPath = process.cwd(), limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT } = {}) {
476
+ const filePath = path.join(path.resolve(String(targetPath || ".")), WORK_PLAN_RELATIVE_PATH);
477
+ try {
478
+ const stats = await fsp.stat(filePath);
479
+ let source = "";
480
+ let truncated = false;
481
+ if (stats.size > MAX_WORK_PLAN_BYTES) {
482
+ truncated = true;
483
+ const handle = await fsp.open(filePath, "r");
484
+ try {
485
+ const buffer = Buffer.alloc(MAX_WORK_PLAN_BYTES);
486
+ const position = Math.max(0, stats.size - MAX_WORK_PLAN_BYTES);
487
+ const { bytesRead } = await handle.read(buffer, 0, MAX_WORK_PLAN_BYTES, position);
488
+ source = buffer.subarray(0, bytesRead).toString("utf-8");
489
+ } finally {
490
+ await handle.close();
491
+ }
492
+ } else {
493
+ source = await fsp.readFile(filePath, "utf-8");
494
+ }
495
+ return summarizeWorkPlanMarkdown(source, { limit, truncated });
496
+ } catch (error) {
497
+ if (error && typeof error === "object" && error.code === "ENOENT") {
498
+ return emptyWorkPlanSummary();
499
+ }
500
+ return emptyWorkPlanSummary();
501
+ }
502
+ }
503
+
402
504
  function summarizeTaskLedger(tasks = [], { limit = DEFAULT_TASK_SUMMARY_LIMIT } = {}) {
403
505
  const summary = emptyTaskLedgerSummary();
404
506
  const owners = new Map();
@@ -541,6 +643,7 @@ function buildRecapText({
541
643
  activeLocks = 0,
542
644
  pendingTasks = 0,
543
645
  taskLedger = emptyTaskLedgerSummary(),
646
+ workPlan = emptyWorkPlanSummary(),
544
647
  usageSummary = normalizeUsageSummary(),
545
648
  snippets = [],
546
649
  } = {}) {
@@ -554,8 +657,9 @@ function buildRecapText({
554
657
  pendingTasks > 0 ? `You have ${pendingTasks} pending task${pendingTasks === 1 ? "" : "s"}.` : "";
555
658
  const taskText = buildTaskLedgerText(taskLedger);
556
659
  const usageText = buildUsageLedgerText(usageSummary);
660
+ const workPlanText = buildWorkPlanText(workPlan);
557
661
  const snippetText = snippets.length > 0 ? `Recent: ${snippets.join(" | ")}` : "";
558
- return `While you were away: ${agentText}. ${findingText}. ${lockText}. ${pendingText} ${taskText}. ${usageText} ${snippetText}`.replace(
662
+ return `While you were away: ${agentText}. ${findingText}. ${lockText}. ${pendingText} ${taskText}. ${workPlanText} ${usageText} ${snippetText}`.replace(
559
663
  /\s+/g,
560
664
  " "
561
665
  ).trim();
@@ -596,6 +700,31 @@ function buildTaskLedgerText(taskLedger = emptyTaskLedgerSummary()) {
596
700
  .join(". ");
597
701
  }
598
702
 
703
+ function buildWorkPlanText(workPlan = emptyWorkPlanSummary()) {
704
+ if (!workPlan || typeof workPlan !== "object" || !workPlan.exists) {
705
+ return "";
706
+ }
707
+ const open = Number(workPlan.open || 0);
708
+ const completed = Number(workPlan.completed || 0);
709
+ const pathText = normalizeString(workPlan.path) || WORK_PLAN_RELATIVE_PATH;
710
+ const currentSection = normalizeString(workPlan.currentSection);
711
+ const currentText = currentSection ? ` Current: ${currentSection}.` : "";
712
+ const recentOpen = Array.isArray(workPlan.recentOpen) ? workPlan.recentOpen : [];
713
+ const nextText =
714
+ recentOpen.length > 0
715
+ ? ` Next: ${recentOpen
716
+ .map((item) => {
717
+ const section = normalizeString(item.section);
718
+ const task = normalizeString(item.task);
719
+ return section ? `${section} - ${task}` : task;
720
+ })
721
+ .filter(Boolean)
722
+ .join("; ")}.`
723
+ : "";
724
+ const truncatedText = workPlan.truncated ? " Recent window only." : "";
725
+ return `Plan: ${open} open / ${completed} done in ${pathText}.${currentText}${nextText}${truncatedText}`;
726
+ }
727
+
599
728
  function roundCurrency(value) {
600
729
  const normalized = Number(value || 0);
601
730
  if (!Number.isFinite(normalized) || normalized < 0) {
@@ -672,11 +801,13 @@ const AGENT_JOIN_RULES = [
672
801
  "",
673
802
  "**Reading the room** — When you join, the recap above summarizes activity since the last quiet stretch. To read further back, run `sl session read --remote --tail 50 --json` (bump `--tail` if you need more). Do this BEFORE responding so you don't repeat questions or miss a lock-and-claim someone else already opened.",
674
803
  "",
675
- "**Polling cadence** — Poll new events at most once per 60s (`sl session listen` or `sl session read --remote --tail N`). More frequent than that wastes budget and can hit per-user rate limits. Less frequent than ~5min and peers may think you went idle.",
804
+ "**Polling cadence** — Poll new events at most once per 60s (`sl session listen` or `sl session read --remote --tail N`). `session listen` is only a delivery cursor, not a grounding command; join or recap before acting. More frequent than that wastes budget and can hit per-user rate limits. Less frequent than ~5min and peers may think you went idle.",
805
+ "",
806
+ "**Session grounding** — Long-lived rooms should have one visible daemon owner running `sl session daemon --session <id> --recap-interval 300 --checkpoint-interval 60`. If no durable `session_recap` or `session_checkpoint` is appearing, run `sl session recap now <id> --remote --agent <your-name> --json` before posting a long plan.",
676
807
  "",
677
808
  "**Writing back** — You can use **markdown**: bold, italic, lists, fenced code, and `inline code`. The web dashboard renders it. Plain text also works. Keep posts terse and technical — link to the work, don't recap it.",
678
809
  "",
679
- "**Actions and threading** — ACK, view, react, or claim work with message actions instead of top-level chatter: `sl session react <id> ack --target-sequence <n>`, `sl session view <id> <sequence>`, or `sl session action <id> working_on --target-sequence <n>`. Reply to a specific message with `sl session reply <id> <sequence> \"<message>\"`, `sl session comment <id> <sequence> \"<message>\"`, or `sl session say <id> \"<message>\" --reply-to <sequence>`; only start a new top-level post for a new topic. Run `sl session actions` for the full list.",
810
+ "**Actions and threading** — Use message actions instead of top-level ACK chatter: `sl session react <id> ack --target-sequence <n>` only when an explicit ACK matters, and `sl session action <id> working_on --target-sequence <n>` for ownership. Read receipts are automatic when you run `sl session read <id> --remote --agent <your-name>`; reserve `sl session view <id> <sequence>` for repair/backfill. Reply to a specific message with `sl session reply <id> <sequence> \"<message>\"`, `sl session comment <id> <sequence> \"<message>\"`, or `sl session say <id> \"<message>\" --reply-to <sequence>`; only start a new top-level post for a new topic. Run `sl session actions` for the full list.",
680
811
  "",
681
812
  "**Search before asking** — Use `sl session search <id> \"<topic>\" --limit 10` to recover old context before asking another agent to re-paste or summarize what is already in the transcript.",
682
813
  "",
@@ -702,11 +833,12 @@ function buildPeriodicText(recap = {}) {
702
833
  const lastActor = normalizeString(summary.lastActorId);
703
834
  const actorText = lastActor ? `${lastActor} active` : "no active actor";
704
835
  const taskText = buildTaskLedgerText(summary.taskLedger);
836
+ const workPlanText = buildWorkPlanText(summary.workPlan);
705
837
  const usageText = buildUsageLedgerText({
706
838
  totals: summary.usageTotals,
707
839
  topAgents: summary.usageTopAgents,
708
840
  });
709
- return `Session active for ${elapsedMinutes}m. ${activeAgents} agents. ${totalFindings} findings. ${activeLocks} locks. ${taskText}. ${usageText} ${actorText}.`.replace(
841
+ return `Session active for ${elapsedMinutes}m. ${activeAgents} agents. ${totalFindings} findings. ${activeLocks} locks. ${taskText}. ${workPlanText} ${usageText} ${actorText}.`.replace(
710
842
  /\s+/g,
711
843
  " ",
712
844
  ).trim();
@@ -774,6 +906,9 @@ export async function buildSessionRecap(
774
906
  const taskLedger = await readTaskLedgerSummary(normalizedSessionId, {
775
907
  targetPath: normalizedTargetPath,
776
908
  });
909
+ const workPlan = await readWorkPlanSummary({
910
+ targetPath: normalizedTargetPath,
911
+ });
777
912
  const snippets = summarizeRecentActivity(visibleEvents, {
778
913
  forAgentId: normalizedForAgentId,
779
914
  limit: 2,
@@ -789,6 +924,7 @@ export async function buildSessionRecap(
789
924
  activeLocks,
790
925
  pendingTasks,
791
926
  taskLedger,
927
+ workPlan,
792
928
  usageSummary,
793
929
  snippets,
794
930
  });
@@ -809,6 +945,7 @@ export async function buildSessionRecap(
809
945
  activeLocks,
810
946
  pendingTasksForAgent: pendingTasks,
811
947
  taskLedger,
948
+ workPlan,
812
949
  usageTotals: usageSummary.totals,
813
950
  usageTopAgents: usageSummary.topAgents,
814
951
  snippets,
@@ -243,6 +243,23 @@ function eventSequenceNumber(event = {}) {
243
243
  return 0;
244
244
  }
245
245
 
246
+ function nextBeforeSequenceFromPayload(payload = {}, events = []) {
247
+ const explicit = Number(payload?.next_before_sequence ?? payload?.nextBeforeSequence);
248
+ if (Number.isFinite(explicit) && explicit > 0) {
249
+ return Math.floor(explicit);
250
+ }
251
+
252
+ let minimumSequence = 0;
253
+ for (const event of Array.isArray(events) ? events : []) {
254
+ const sequence = eventSequenceNumber(event);
255
+ if (sequence <= 0) continue;
256
+ if (minimumSequence === 0 || sequence < minimumSequence) {
257
+ minimumSequence = sequence;
258
+ }
259
+ }
260
+ return minimumSequence || null;
261
+ }
262
+
246
263
  function chronologicalSessionEvents(events = []) {
247
264
  return (Array.isArray(events) ? events : [])
248
265
  .map((event, index) => ({ event, index }))
@@ -411,6 +428,95 @@ async function fetchWithTimeout(url, options, timeoutMs) {
411
428
  }
412
429
  }
413
430
 
431
+ function isAbortLike(error) {
432
+ return Boolean(error?.name === "AbortError" || error?.code === "ABORT_ERR");
433
+ }
434
+
435
+ async function* readResponseTextChunks(response) {
436
+ const body = response?.body;
437
+ if (!body) return;
438
+
439
+ if (typeof body.getReader === "function") {
440
+ const reader = body.getReader();
441
+ try {
442
+ while (true) {
443
+ const { done, value } = await reader.read();
444
+ if (done) break;
445
+ if (value) yield value;
446
+ }
447
+ } finally {
448
+ try {
449
+ reader.releaseLock();
450
+ } catch {
451
+ // Best-effort cleanup only.
452
+ }
453
+ }
454
+ return;
455
+ }
456
+
457
+ if (typeof body[Symbol.asyncIterator] === "function") {
458
+ for await (const chunk of body) {
459
+ if (chunk) yield chunk;
460
+ }
461
+ }
462
+ }
463
+
464
+ function extractSseErrorReason(parsed) {
465
+ const error = parsed?.error && typeof parsed.error === "object" ? parsed.error : {};
466
+ return (
467
+ normalizeString(error.code) ||
468
+ normalizeString(error.message) ||
469
+ normalizeString(error.detail) ||
470
+ "session_stream_error"
471
+ );
472
+ }
473
+
474
+ async function processSseBlock(block, handlers) {
475
+ const normalizedBlock = normalizeString(block);
476
+ if (!normalizedBlock) return;
477
+
478
+ const dataLines = [];
479
+ let commentOnly = true;
480
+ for (const rawLine of String(block).split("\n")) {
481
+ const line = rawLine.trimEnd();
482
+ if (!line) continue;
483
+ if (line.startsWith(":")) continue;
484
+ commentOnly = false;
485
+ if (line.startsWith("data:")) {
486
+ dataLines.push(line.slice(5).trimStart());
487
+ }
488
+ }
489
+
490
+ if (dataLines.length === 0) {
491
+ if (commentOnly && typeof handlers.onHeartbeat === "function") {
492
+ await handlers.onHeartbeat();
493
+ }
494
+ return;
495
+ }
496
+
497
+ const rawData = dataLines.join("\n").trim();
498
+ if (!rawData) return;
499
+
500
+ let parsed;
501
+ try {
502
+ parsed = JSON.parse(rawData);
503
+ } catch {
504
+ await handlers.onError?.({ reason: "malformed_stream_event", cursor: handlers.cursor() });
505
+ return;
506
+ }
507
+
508
+ if (parsed?.type === "error") {
509
+ await handlers.onError?.({
510
+ reason: extractSseErrorReason(parsed),
511
+ cursor: handlers.cursor(),
512
+ error: parsed.error || null,
513
+ });
514
+ return;
515
+ }
516
+
517
+ await handlers.onEvent?.(parsed);
518
+ }
519
+
414
520
  function sanitizeHumanMessage(rawMessage) {
415
521
  const stripped = String(rawMessage || "")
416
522
  .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
@@ -1114,6 +1220,195 @@ export async function pollSessionEvents(
1114
1220
  }
1115
1221
  }
1116
1222
 
1223
+ /**
1224
+ * Consume the API's durable session SSE stream.
1225
+ *
1226
+ * This is the wakeup-first companion to `pollSessionEvents`: the stream uses
1227
+ * Redis wakeups server-side, while the listener can still fall back to durable
1228
+ * `/events` polling if the stream is unavailable or closes.
1229
+ *
1230
+ * @param {string} sessionId
1231
+ * @param {object} [options]
1232
+ * @param {string|null} [options.since] - durable cursor to resume after
1233
+ * @param {AbortSignal} [options.signal]
1234
+ * @param {(event: object) => Promise<void>|void} [options.onEvent]
1235
+ * @param {(payload: object) => Promise<void>|void} [options.onError]
1236
+ * @param {() => Promise<void>|void} [options.onHeartbeat]
1237
+ * @returns {Promise<{ok: boolean, reason: string, cursor: string|null, eventCount: number, errorCount: number, status?: number, aborted?: boolean}>}
1238
+ */
1239
+ export async function streamSessionEvents(
1240
+ sessionId,
1241
+ {
1242
+ targetPath = process.cwd(),
1243
+ since = null,
1244
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1245
+ signal = undefined,
1246
+ resolveAuthSession = resolveActiveAuthSession,
1247
+ fetchImpl = fetch,
1248
+ onEvent = async () => {},
1249
+ onError = async () => {},
1250
+ onHeartbeat = async () => {},
1251
+ } = {}
1252
+ ) {
1253
+ const normalizedSessionId = normalizeString(sessionId);
1254
+ const normalizedSince = normalizeString(since) || null;
1255
+ if (!normalizedSessionId) {
1256
+ return {
1257
+ ok: false,
1258
+ reason: "invalid_session_id",
1259
+ cursor: normalizedSince,
1260
+ eventCount: 0,
1261
+ errorCount: 0,
1262
+ };
1263
+ }
1264
+
1265
+ let session = null;
1266
+ try {
1267
+ session = await resolveAuthSession({
1268
+ cwd: targetPath,
1269
+ env: process.env,
1270
+ autoRotate: false,
1271
+ });
1272
+ } catch {
1273
+ return {
1274
+ ok: false,
1275
+ reason: "no_session",
1276
+ cursor: normalizedSince,
1277
+ eventCount: 0,
1278
+ errorCount: 0,
1279
+ };
1280
+ }
1281
+ if (!session || !session.token) {
1282
+ return {
1283
+ ok: false,
1284
+ reason: "not_authenticated",
1285
+ cursor: normalizedSince,
1286
+ eventCount: 0,
1287
+ errorCount: 0,
1288
+ };
1289
+ }
1290
+
1291
+ const apiBaseUrl = resolveApiBaseUrl(session);
1292
+ const query = new URLSearchParams();
1293
+ if (normalizedSince) {
1294
+ query.set("after", normalizedSince);
1295
+ }
1296
+ const suffix = query.toString() ? `?${query.toString()}` : "";
1297
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/stream${suffix}`;
1298
+ const controller = new AbortController();
1299
+ const normalizedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
1300
+ const timeoutHandle = setTimeout(() => controller.abort(), normalizedTimeoutMs);
1301
+ if (typeof timeoutHandle.unref === "function") {
1302
+ timeoutHandle.unref();
1303
+ }
1304
+ const forwardAbort = () => controller.abort(signal?.reason);
1305
+ if (signal) {
1306
+ if (signal.aborted) {
1307
+ controller.abort(signal.reason);
1308
+ } else {
1309
+ signal.addEventListener("abort", forwardAbort, { once: true });
1310
+ }
1311
+ }
1312
+
1313
+ let response;
1314
+ try {
1315
+ response = await fetchImpl(
1316
+ endpoint,
1317
+ {
1318
+ method: "GET",
1319
+ headers: {
1320
+ Accept: "text/event-stream",
1321
+ Authorization: `Bearer ${session.token}`,
1322
+ },
1323
+ signal: controller.signal,
1324
+ },
1325
+ normalizedTimeoutMs
1326
+ );
1327
+ } catch (error) {
1328
+ clearTimeout(timeoutHandle);
1329
+ if (signal) signal.removeEventListener("abort", forwardAbort);
1330
+ return {
1331
+ ok: false,
1332
+ reason: isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
1333
+ cursor: normalizedSince,
1334
+ eventCount: 0,
1335
+ errorCount: 0,
1336
+ aborted: Boolean(signal?.aborted || isAbortLike(error)),
1337
+ };
1338
+ }
1339
+ clearTimeout(timeoutHandle);
1340
+
1341
+ let cursor = normalizedSince;
1342
+ let eventCount = 0;
1343
+ let errorCount = 0;
1344
+ let lastErrorReason = "";
1345
+ const decoder = new TextDecoder();
1346
+ let buffer = "";
1347
+
1348
+ const handlers = {
1349
+ cursor: () => cursor,
1350
+ onHeartbeat,
1351
+ onError: async (payload) => {
1352
+ errorCount += 1;
1353
+ lastErrorReason = normalizeString(payload?.reason) || "session_stream_error";
1354
+ await onError(payload);
1355
+ },
1356
+ onEvent: async (event) => {
1357
+ const eventCursor = normalizeString(event?.cursor);
1358
+ if (eventCursor) cursor = eventCursor;
1359
+ eventCount += 1;
1360
+ await onEvent(event);
1361
+ },
1362
+ };
1363
+
1364
+ try {
1365
+ if (!response || !response.ok || !response.body) {
1366
+ return {
1367
+ ok: false,
1368
+ reason: `api_${response ? response.status : "no_response"}`,
1369
+ cursor,
1370
+ eventCount,
1371
+ errorCount,
1372
+ status: response?.status,
1373
+ };
1374
+ }
1375
+
1376
+ for await (const chunk of readResponseTextChunks(response)) {
1377
+ buffer += decoder.decode(chunk, { stream: true });
1378
+ buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1379
+ const blocks = buffer.split("\n\n");
1380
+ buffer = blocks.pop() || "";
1381
+ for (const block of blocks) {
1382
+ await processSseBlock(block, handlers);
1383
+ }
1384
+ }
1385
+ buffer += decoder.decode();
1386
+ if (normalizeString(buffer)) {
1387
+ await processSseBlock(buffer, handlers);
1388
+ }
1389
+ } catch (error) {
1390
+ return {
1391
+ ok: false,
1392
+ reason: isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
1393
+ cursor,
1394
+ eventCount,
1395
+ errorCount,
1396
+ aborted: Boolean(signal?.aborted || isAbortLike(error)),
1397
+ };
1398
+ } finally {
1399
+ if (signal) signal.removeEventListener("abort", forwardAbort);
1400
+ }
1401
+
1402
+ return {
1403
+ ok: !lastErrorReason,
1404
+ reason: lastErrorReason,
1405
+ cursor,
1406
+ eventCount,
1407
+ errorCount,
1408
+ aborted: Boolean(signal?.aborted),
1409
+ };
1410
+ }
1411
+
1117
1412
  /**
1118
1413
  * Poll the latest durable session events page via the reverse-history endpoint.
1119
1414
  *
@@ -1219,13 +1514,12 @@ export async function pollSessionEventsBefore(
1219
1514
 
1220
1515
  const events = chronologicalSessionEvents(payload?.events || []);
1221
1516
  const lastEvent = events[events.length - 1] || null;
1222
- const firstEvent = events[0] || null;
1223
1517
  return {
1224
1518
  ok: true,
1225
1519
  reason: "",
1226
1520
  events,
1227
1521
  cursor: normalizeString(lastEvent?.cursor) || null,
1228
- beforeSequence: eventSequenceNumber(firstEvent) || null,
1522
+ beforeSequence: nextBeforeSequenceFromPayload(payload, payload?.events || []),
1229
1523
  };
1230
1524
  } catch (error) {
1231
1525
  recordCircuitFailure(inboundCircuit, normalizedNowMs);