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
@@ -2,9 +2,11 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import process from "node:process";
5
+ import { randomUUID } from "node:crypto";
5
6
 
6
7
  import { resolveActiveAuthSession } from "../auth/service.js";
7
8
  import { createAgentEvent } from "../events/schema.js";
9
+ import { isSessionControlEvent } from "./control-events.js";
8
10
 
9
11
  const DEFAULT_API_BASE_URL = "https://api.sentinelayer.com";
10
12
  const DEFAULT_SYNC_TIMEOUT_MS = 5_000;
@@ -17,6 +19,26 @@ const HUMAN_MESSAGE_FETCH_LIMIT = 50;
17
19
  const SESSION_EVENT_FETCH_LIMIT = 200;
18
20
  const SESSION_ACTION_FETCH_LIMIT = 500;
19
21
  const SESSION_SEARCH_FETCH_LIMIT = 50;
22
+ const NON_SEMANTIC_TRANSCRIPT_EVENT_TYPES = new Set([
23
+ "agent_heartbeat",
24
+ "agent_identified",
25
+ "agent_identity",
26
+ "agent_join",
27
+ "agent_leave",
28
+ "agent_left",
29
+ "agent_status",
30
+ "context_briefing",
31
+ "session_recap",
32
+ "session_view",
33
+ "view",
34
+ ]);
35
+
36
+ function isNonSemanticTranscriptEvent(event, eventType) {
37
+ return (
38
+ NON_SEMANTIC_TRANSCRIPT_EVENT_TYPES.has(eventType) ||
39
+ isSessionControlEvent(event)
40
+ );
41
+ }
20
42
 
21
43
  // Audit §2.9: crash-recovery contract for in-memory circuit state.
22
44
  // Persist outbound/inbound circuit state to disk so a process restart
@@ -713,19 +735,29 @@ async function processSseBlock(block, handlers) {
713
735
  if (!normalizedBlock) return;
714
736
 
715
737
  const dataLines = [];
716
- let commentOnly = true;
738
+ let eventId = "";
739
+ let hasComment = false;
717
740
  for (const rawLine of String(block).split("\n")) {
718
741
  const line = rawLine.trimEnd();
719
742
  if (!line) continue;
720
- if (line.startsWith(":")) continue;
721
- commentOnly = false;
743
+ if (line.startsWith(":")) {
744
+ hasComment = true;
745
+ continue;
746
+ }
747
+ if (line.startsWith("id:")) {
748
+ eventId = line.slice(3).trimStart();
749
+ continue;
750
+ }
722
751
  if (line.startsWith("data:")) {
723
752
  dataLines.push(line.slice(5).trimStart());
724
753
  }
725
754
  }
726
755
 
727
756
  if (dataLines.length === 0) {
728
- if (commentOnly && typeof handlers.onHeartbeat === "function") {
757
+ if (eventId && typeof handlers.onCursor === "function") {
758
+ await handlers.onCursor(eventId);
759
+ }
760
+ if (hasComment && typeof handlers.onHeartbeat === "function") {
729
761
  await handlers.onHeartbeat();
730
762
  }
731
763
  return;
@@ -875,14 +907,22 @@ export async function syncSessionEventToApi(
875
907
  if (!normalizedSessionId || !event || typeof event !== "object" || Array.isArray(event)) {
876
908
  return { synced: false, reason: "invalid_input" };
877
909
  }
910
+ const eventType = normalizeString(event.event || event.type).toLowerCase();
911
+ if (isNonSemanticTranscriptEvent(event, eventType)) {
912
+ return {
913
+ synced: false,
914
+ reason: "non_semantic_transcript_event_rejected",
915
+ eventType,
916
+ };
917
+ }
878
918
 
879
919
  // Test-fixture leak guard. Tests in this repo (and downstream consumers)
880
920
  // create + tear down sessions using a temp workspace; on a developer
881
921
  // machine those calls inherit the user's stored auth and silently posted
882
922
  // hundreds of orphan rooms to prod (Carter saw ~200 "<null>" sessions).
883
923
  // Honoring SENTINELAYER_SKIP_REMOTE_SYNC=1 keeps everything local while
884
- // still exercising the appendToStream + agent_join code paths the tests
885
- // care about. Local NDJSON durability is unaffected.
924
+ // still exercising the appendToStream paths the tests care about. Local
925
+ // NDJSON durability is unaffected.
886
926
  if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
887
927
  return { synced: false, reason: "remote_sync_disabled_env" };
888
928
  }
@@ -1325,18 +1365,21 @@ export async function pollHumanMessages(
1325
1365
  * / `agent_response` events. The result was codex and claude talking
1326
1366
  * past each other ("Apologies — I missed your 5 updates").
1327
1367
  *
1328
- * Endpoint contract: `GET /api/v1/sessions/{id}/events?after=<cursor>&limit=N`.
1368
+ * Endpoint contract:
1369
+ * `GET /api/v1/sessions/{id}/events?after=<cursor>&limit=N&listenerAgentId=<id>`.
1329
1370
  * The API returns events in chronological order with cursor-based
1330
- * pagination. We map each row to the local NDJSON envelope shape so
1331
- * `appendToStream` accepts it without modification.
1371
+ * pagination. When `listenerAgentId` is present, Redis-backed listener
1372
+ * controls are returned separately in `listenerControls`; they never join
1373
+ * the durable event list or participate in its cursor.
1332
1374
  *
1333
1375
  * @param {string} sessionId
1334
1376
  * @param {object} [options]
1335
1377
  * @param {string} [options.targetPath]
1336
1378
  * @param {string|null} [options.since] - cursor to start after; null = full history
1337
1379
  * @param {number} [options.limit] - default 200 (max from API)
1380
+ * @param {string} [options.listenerAgentId] - optional listener control recipient
1338
1381
  * @param {number} [options.timeoutMs] - per-request deadline
1339
- * @returns {Promise<{ok: boolean, reason: string, events: Array<object>, cursor: string|null}>}
1382
+ * @returns {Promise<{ok: boolean, reason: string, events: Array<object>, listenerControls: Array<object>, cursor: string|null, scannedThroughSequence: number|null, scanExhausted: boolean|null}>}
1340
1383
  */
1341
1384
  export async function pollSessionEvents(
1342
1385
  sessionId,
@@ -1344,6 +1387,7 @@ export async function pollSessionEvents(
1344
1387
  targetPath = process.cwd(),
1345
1388
  since = null,
1346
1389
  limit = 200,
1390
+ listenerAgentId = "",
1347
1391
  timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1348
1392
  forceCircuitProbe = false,
1349
1393
  resolveAuthSession = resolveActiveAuthSession,
@@ -1357,6 +1401,7 @@ export async function pollSessionEvents(
1357
1401
  ok: false,
1358
1402
  reason: "invalid_session_id",
1359
1403
  events: [],
1404
+ listenerControls: [],
1360
1405
  cursor: normalizeString(since) || null,
1361
1406
  };
1362
1407
  }
@@ -1367,6 +1412,7 @@ export async function pollSessionEvents(
1367
1412
  ok: false,
1368
1413
  reason: "circuit_breaker_open",
1369
1414
  events: [],
1415
+ listenerControls: [],
1370
1416
  cursor: normalizeString(since) || null,
1371
1417
  };
1372
1418
  }
@@ -1383,6 +1429,7 @@ export async function pollSessionEvents(
1383
1429
  ok: false,
1384
1430
  reason: "no_session",
1385
1431
  events: [],
1432
+ listenerControls: [],
1386
1433
  cursor: normalizeString(since) || null,
1387
1434
  };
1388
1435
  }
@@ -1391,6 +1438,7 @@ export async function pollSessionEvents(
1391
1438
  ok: false,
1392
1439
  reason: "not_authenticated",
1393
1440
  events: [],
1441
+ listenerControls: [],
1394
1442
  cursor: normalizeString(since) || null,
1395
1443
  };
1396
1444
  }
@@ -1401,6 +1449,10 @@ export async function pollSessionEvents(
1401
1449
  if (normalizedSince) {
1402
1450
  query.set("after", normalizedSince);
1403
1451
  }
1452
+ const normalizedListenerAgentId = normalizeString(listenerAgentId);
1453
+ if (normalizedListenerAgentId) {
1454
+ query.set("listenerAgentId", normalizedListenerAgentId);
1455
+ }
1404
1456
  query.set(
1405
1457
  "limit",
1406
1458
  String(Math.max(1, Math.min(SESSION_EVENT_FETCH_LIMIT, normalizePositiveInteger(limit, SESSION_EVENT_FETCH_LIMIT))))
@@ -1426,6 +1478,7 @@ export async function pollSessionEvents(
1426
1478
  status: response?.status || 429,
1427
1479
  retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
1428
1480
  events: [],
1481
+ listenerControls: [],
1429
1482
  cursor: normalizedSince || null,
1430
1483
  };
1431
1484
  }
@@ -1434,12 +1487,23 @@ export async function pollSessionEvents(
1434
1487
  ok: false,
1435
1488
  reason: `api_${response ? response.status : "no_response"}`,
1436
1489
  events: [],
1490
+ listenerControls: [],
1437
1491
  cursor: normalizedSince || null,
1438
1492
  };
1439
1493
  }
1440
1494
  recordCircuitSuccess(inboundCircuit);
1441
1495
 
1442
1496
  const items = Array.isArray(payload?.events) ? payload.events : [];
1497
+ const listenerControlProjection =
1498
+ payload?.listenerControls ?? payload?.listener_controls;
1499
+ const listenerControlItems = Array.isArray(listenerControlProjection)
1500
+ ? listenerControlProjection
1501
+ : Array.isArray(listenerControlProjection?.items)
1502
+ ? listenerControlProjection.items
1503
+ : [];
1504
+ const listenerControls = listenerControlItems.filter(
1505
+ (control) => control && typeof control === "object" && !Array.isArray(control),
1506
+ );
1443
1507
  const acceptedEvents = [];
1444
1508
  let lastCursor = normalizedSince || null;
1445
1509
  for (const item of items) {
@@ -1450,12 +1514,23 @@ export async function pollSessionEvents(
1450
1514
  // envelope shape that appendToStream expects.
1451
1515
  acceptedEvents.push(item);
1452
1516
  }
1517
+ const responseCursor =
1518
+ normalizeString(payload?.nextCursor || payload?.next_cursor || payload?.cursor) ||
1519
+ lastCursor;
1520
+ const scannedThroughSequence = normalizePositiveInteger(
1521
+ payload?.scannedThroughSequence ?? payload?.scanned_through_sequence,
1522
+ null,
1523
+ );
1524
+ const rawScanExhausted = payload?.scanExhausted ?? payload?.scan_exhausted;
1453
1525
 
1454
1526
  return {
1455
1527
  ok: true,
1456
1528
  reason: "",
1457
1529
  events: acceptedEvents,
1458
- cursor: lastCursor,
1530
+ listenerControls,
1531
+ cursor: responseCursor,
1532
+ scannedThroughSequence,
1533
+ scanExhausted: typeof rawScanExhausted === "boolean" ? rawScanExhausted : null,
1459
1534
  };
1460
1535
  } catch (error) {
1461
1536
  recordCircuitFailure(inboundCircuit, normalizedNowMs);
@@ -1463,11 +1538,546 @@ export async function pollSessionEvents(
1463
1538
  ok: false,
1464
1539
  reason: normalizeString(error?.message) || "poll_failed",
1465
1540
  events: [],
1541
+ listenerControls: [],
1466
1542
  cursor: normalizedSince || null,
1467
1543
  };
1468
1544
  }
1469
1545
  }
1470
1546
 
1547
+ /**
1548
+ * Read the membership-gated, three-state ephemeral listener roster.
1549
+ *
1550
+ * Compatibility is deliberately fail-closed: an older server (404), a server
1551
+ * with presence disabled, or a degraded Redis dependency is reported as
1552
+ * unknown/unsupported. Callers must never reconstruct liveness by scanning
1553
+ * durable listener heartbeat events.
1554
+ */
1555
+ export async function fetchSessionPresence(
1556
+ sessionId,
1557
+ {
1558
+ targetPath = process.cwd(),
1559
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1560
+ forceCircuitProbe = false,
1561
+ resolveAuthSession = resolveActiveAuthSession,
1562
+ fetchImpl = fetchWithTimeout,
1563
+ nowMs = Date.now,
1564
+ } = {},
1565
+ ) {
1566
+ const normalizedSessionId = normalizeString(sessionId);
1567
+ if (!normalizedSessionId) {
1568
+ return {
1569
+ ok: false,
1570
+ reason: "invalid_session_id",
1571
+ status: "unsupported",
1572
+ enabled: false,
1573
+ present: [],
1574
+ };
1575
+ }
1576
+
1577
+ const normalizedNowMs = Number(nowMs()) || Date.now();
1578
+ if (!forceCircuitProbe && isCircuitOpen(inboundCircuit, normalizedNowMs)) {
1579
+ return {
1580
+ ok: false,
1581
+ reason: "circuit_breaker_open",
1582
+ status: "degraded",
1583
+ enabled: null,
1584
+ present: [],
1585
+ };
1586
+ }
1587
+
1588
+ let session = null;
1589
+ try {
1590
+ session = await resolveAuthSession({
1591
+ cwd: targetPath,
1592
+ env: process.env,
1593
+ autoRotate: false,
1594
+ });
1595
+ } catch {
1596
+ return {
1597
+ ok: false,
1598
+ reason: "no_session",
1599
+ status: "unsupported",
1600
+ enabled: null,
1601
+ present: [],
1602
+ };
1603
+ }
1604
+ if (!session?.token) {
1605
+ return {
1606
+ ok: false,
1607
+ reason: "not_authenticated",
1608
+ status: "unsupported",
1609
+ enabled: null,
1610
+ present: [],
1611
+ };
1612
+ }
1613
+
1614
+ const apiBaseUrl = resolveApiBaseUrl(session);
1615
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/presence`;
1616
+ try {
1617
+ const { response, payload } = await fetchJsonWithFullTimeout(
1618
+ endpoint,
1619
+ {
1620
+ method: "GET",
1621
+ headers: { Authorization: `Bearer ${session.token}` },
1622
+ },
1623
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
1624
+ fetchImpl,
1625
+ );
1626
+
1627
+ if (!response?.ok) {
1628
+ if (response?.status === 404) {
1629
+ return {
1630
+ ok: false,
1631
+ reason: "presence_unsupported",
1632
+ status: "unsupported",
1633
+ enabled: false,
1634
+ present: [],
1635
+ };
1636
+ }
1637
+ if (isRateLimitResponse(response, payload)) {
1638
+ return {
1639
+ ok: false,
1640
+ reason: "rate_limited",
1641
+ status: "degraded",
1642
+ enabled: null,
1643
+ retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
1644
+ present: [],
1645
+ };
1646
+ }
1647
+ recordCircuitFailure(inboundCircuit, normalizedNowMs);
1648
+ return {
1649
+ ok: false,
1650
+ reason: `api_${response ? response.status : "no_response"}`,
1651
+ status: "degraded",
1652
+ enabled: null,
1653
+ retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
1654
+ present: [],
1655
+ };
1656
+ }
1657
+
1658
+ recordCircuitSuccess(inboundCircuit);
1659
+ const enabled = payload?.enabled !== false;
1660
+ const status = enabled
1661
+ ? normalizeString(payload?.status).toLowerCase() || "ok"
1662
+ : "unsupported";
1663
+ const present = enabled && Array.isArray(payload?.present)
1664
+ ? payload.present.filter((entry) => entry && typeof entry === "object")
1665
+ : [];
1666
+ return {
1667
+ ok: enabled && status === "ok",
1668
+ reason: enabled
1669
+ ? status === "ok"
1670
+ ? ""
1671
+ : "presence_degraded"
1672
+ : "presence_disabled",
1673
+ status: status === "ok" ? "ok" : status === "unsupported" ? "unsupported" : "degraded",
1674
+ enabled,
1675
+ present,
1676
+ };
1677
+ } catch (error) {
1678
+ recordCircuitFailure(inboundCircuit, normalizedNowMs);
1679
+ return {
1680
+ ok: false,
1681
+ reason: normalizeString(error?.message) || "presence_read_failed",
1682
+ status: "degraded",
1683
+ enabled: null,
1684
+ present: [],
1685
+ };
1686
+ }
1687
+ }
1688
+
1689
+ /**
1690
+ * Renew one listener's ephemeral TTL. This endpoint is a hard protocol
1691
+ * boundary: it never falls back to POST /events on older or degraded servers.
1692
+ */
1693
+ export async function renewSessionPresence(
1694
+ sessionId,
1695
+ {
1696
+ targetPath = process.cwd(),
1697
+ agentId,
1698
+ state = "idle",
1699
+ listenerId = "",
1700
+ model = "",
1701
+ displayName = "",
1702
+ provider = "",
1703
+ clientKind = "cli",
1704
+ observedAt = new Date().toISOString(),
1705
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1706
+ signal = undefined,
1707
+ resolveAuthSession = resolveActiveAuthSession,
1708
+ fetchImpl = fetchWithTimeout,
1709
+ nowMs = Date.now,
1710
+ } = {},
1711
+ ) {
1712
+ const normalizedSessionId = normalizeString(sessionId);
1713
+ const normalizedAgentId = normalizeString(agentId);
1714
+ if (!normalizedSessionId || !normalizedAgentId) {
1715
+ return {
1716
+ ok: false,
1717
+ reason: "invalid_input",
1718
+ status: "unsupported",
1719
+ recorded: false,
1720
+ };
1721
+ }
1722
+ if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
1723
+ return {
1724
+ ok: false,
1725
+ reason: "remote_sync_disabled_env",
1726
+ status: "unsupported",
1727
+ recorded: false,
1728
+ };
1729
+ }
1730
+
1731
+ const normalizedNowMs = Number(nowMs()) || Date.now();
1732
+ if (isCircuitOpen(outboundCircuit, normalizedNowMs)) {
1733
+ return {
1734
+ ok: false,
1735
+ reason: "circuit_breaker_open",
1736
+ status: "degraded",
1737
+ recorded: false,
1738
+ };
1739
+ }
1740
+
1741
+ let session = null;
1742
+ try {
1743
+ session = await resolveAuthSession({
1744
+ cwd: targetPath,
1745
+ env: process.env,
1746
+ autoRotate: false,
1747
+ });
1748
+ } catch {
1749
+ return {
1750
+ ok: false,
1751
+ reason: "no_session",
1752
+ status: "unsupported",
1753
+ recorded: false,
1754
+ };
1755
+ }
1756
+ if (!session?.token) {
1757
+ return {
1758
+ ok: false,
1759
+ reason: "not_authenticated",
1760
+ status: "unsupported",
1761
+ recorded: false,
1762
+ };
1763
+ }
1764
+
1765
+ const apiBaseUrl = resolveApiBaseUrl(session);
1766
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/presence`;
1767
+ const body = {
1768
+ agentId: normalizedAgentId,
1769
+ state: normalizeString(state).toLowerCase() || "idle",
1770
+ listenerId: normalizeString(listenerId) || undefined,
1771
+ model: normalizeString(model) || undefined,
1772
+ displayName: normalizeString(displayName) || undefined,
1773
+ provider: normalizeString(provider) || undefined,
1774
+ clientKind: normalizeString(clientKind) || "cli",
1775
+ observedAt: normalizeIsoTimestamp(observedAt),
1776
+ };
1777
+
1778
+ try {
1779
+ const { response, payload } = await fetchJsonWithFullTimeout(
1780
+ endpoint,
1781
+ {
1782
+ method: "PUT",
1783
+ headers: {
1784
+ "Content-Type": "application/json",
1785
+ Authorization: `Bearer ${session.token}`,
1786
+ },
1787
+ body: JSON.stringify(body),
1788
+ signal,
1789
+ },
1790
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
1791
+ fetchImpl,
1792
+ );
1793
+ if (!response?.ok) {
1794
+ if (response?.status === 404) {
1795
+ return {
1796
+ ok: false,
1797
+ reason: "presence_unsupported",
1798
+ status: "unsupported",
1799
+ recorded: false,
1800
+ };
1801
+ }
1802
+ if (isRateLimitResponse(response, payload)) {
1803
+ return {
1804
+ ok: false,
1805
+ reason: "rate_limited",
1806
+ status: "degraded",
1807
+ recorded: false,
1808
+ retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
1809
+ };
1810
+ }
1811
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
1812
+ return {
1813
+ ok: false,
1814
+ reason: `api_${response ? response.status : "no_response"}`,
1815
+ status: "degraded",
1816
+ recorded: false,
1817
+ retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
1818
+ };
1819
+ }
1820
+ recordCircuitSuccess(outboundCircuit);
1821
+ const status = normalizeString(payload?.status).toLowerCase() || "ok";
1822
+ return {
1823
+ ok: status === "ok" && Boolean(payload?.recorded ?? true),
1824
+ reason: status === "ok" ? "" : "presence_degraded",
1825
+ status: status === "ok" ? "ok" : "degraded",
1826
+ recorded: Boolean(payload?.recorded ?? status === "ok"),
1827
+ ttlSeconds: normalizePositiveInteger(payload?.ttlSeconds || payload?.ttl_seconds, null),
1828
+ };
1829
+ } catch (error) {
1830
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
1831
+ return {
1832
+ ok: false,
1833
+ reason: normalizeString(error?.message) || "presence_write_failed",
1834
+ status: "degraded",
1835
+ recorded: false,
1836
+ };
1837
+ }
1838
+ }
1839
+
1840
+ /**
1841
+ * Enqueue an ephemeral Redis-backed listener stop control.
1842
+ *
1843
+ * This endpoint is the only remote delivery path for stop requests. It never
1844
+ * falls back to the durable session event stream.
1845
+ */
1846
+ export async function requestSessionListenerStop(
1847
+ sessionId,
1848
+ {
1849
+ targetPath = process.cwd(),
1850
+ targetAgentId = "",
1851
+ idempotencyKey = "",
1852
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1853
+ resolveAuthSession = resolveActiveAuthSession,
1854
+ fetchImpl = fetchWithTimeout,
1855
+ nowMs = Date.now,
1856
+ } = {},
1857
+ ) {
1858
+ const normalizedSessionId = normalizeString(sessionId);
1859
+ if (!normalizedSessionId) {
1860
+ return { ok: false, reason: "invalid_session_id", recorded: false };
1861
+ }
1862
+ if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
1863
+ return { ok: false, reason: "remote_sync_disabled_env", recorded: false };
1864
+ }
1865
+
1866
+ const normalizedNowMs = Number(nowMs()) || Date.now();
1867
+ if (isCircuitOpen(outboundCircuit, normalizedNowMs)) {
1868
+ return { ok: false, reason: "circuit_breaker_open", recorded: false };
1869
+ }
1870
+
1871
+ let session = null;
1872
+ try {
1873
+ session = await resolveAuthSession({
1874
+ cwd: targetPath,
1875
+ env: process.env,
1876
+ autoRotate: false,
1877
+ });
1878
+ } catch {
1879
+ return { ok: false, reason: "no_session", recorded: false };
1880
+ }
1881
+ if (!session?.token) {
1882
+ return { ok: false, reason: "not_authenticated", recorded: false };
1883
+ }
1884
+
1885
+ const normalizedTargetAgentId = normalizeString(targetAgentId);
1886
+ const normalizedIdempotencyKey =
1887
+ normalizeString(idempotencyKey) || `sl-listener-stop-${randomUUID()}`;
1888
+ const apiBaseUrl = resolveApiBaseUrl(session);
1889
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/listener-controls/stop`;
1890
+ const body = normalizedTargetAgentId
1891
+ ? { targetAgentId: normalizedTargetAgentId }
1892
+ : { broadcast: true };
1893
+
1894
+ try {
1895
+ const { response, payload } = await fetchJsonWithFullTimeout(
1896
+ endpoint,
1897
+ {
1898
+ method: "POST",
1899
+ headers: {
1900
+ "Content-Type": "application/json",
1901
+ Authorization: `Bearer ${session.token}`,
1902
+ "Idempotency-Key": normalizedIdempotencyKey,
1903
+ },
1904
+ body: JSON.stringify(body),
1905
+ },
1906
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
1907
+ fetchImpl,
1908
+ { readErrorBody: true },
1909
+ );
1910
+ if (!response?.ok) {
1911
+ if (response?.status === 404) {
1912
+ return {
1913
+ ok: false,
1914
+ reason: "listener_control_unsupported",
1915
+ recorded: false,
1916
+ };
1917
+ }
1918
+ if (isRateLimitResponse(response, payload)) {
1919
+ return {
1920
+ ok: false,
1921
+ reason: "rate_limited",
1922
+ recorded: false,
1923
+ retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
1924
+ };
1925
+ }
1926
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
1927
+ return {
1928
+ ok: false,
1929
+ reason: `api_${response ? response.status : "no_response"}`,
1930
+ recorded: false,
1931
+ };
1932
+ }
1933
+
1934
+ recordCircuitSuccess(outboundCircuit);
1935
+ const control =
1936
+ payload?.control && typeof payload.control === "object" && !Array.isArray(payload.control)
1937
+ ? payload.control
1938
+ : payload?.listenerControl &&
1939
+ typeof payload.listenerControl === "object" &&
1940
+ !Array.isArray(payload.listenerControl)
1941
+ ? payload.listenerControl
1942
+ : null;
1943
+ return {
1944
+ ok: true,
1945
+ reason: "",
1946
+ recorded: Boolean(payload?.recorded ?? true),
1947
+ control,
1948
+ };
1949
+ } catch (error) {
1950
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
1951
+ return {
1952
+ ok: false,
1953
+ reason: normalizeString(error?.message) || "listener_control_write_failed",
1954
+ recorded: false,
1955
+ };
1956
+ }
1957
+ }
1958
+
1959
+ /**
1960
+ * Monotonically advance one authenticated actor's read cursor in place.
1961
+ *
1962
+ * No compatibility fallback is allowed: calling the legacy `view` action
1963
+ * could append one durable row per displayed event when the server flag is
1964
+ * disabled or races a rollout.
1965
+ */
1966
+ export async function updateSessionReadCursor(
1967
+ sessionId,
1968
+ {
1969
+ targetPath = process.cwd(),
1970
+ targetSequenceId = null,
1971
+ targetCursor = "",
1972
+ agentId = "",
1973
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1974
+ signal = undefined,
1975
+ resolveAuthSession = resolveActiveAuthSession,
1976
+ fetchImpl = fetchWithTimeout,
1977
+ nowMs = Date.now,
1978
+ } = {},
1979
+ ) {
1980
+ const normalizedSessionId = normalizeString(sessionId);
1981
+ const normalizedTargetSequence = Number(targetSequenceId);
1982
+ const normalizedTargetCursor = normalizeString(targetCursor);
1983
+ if (
1984
+ !normalizedSessionId ||
1985
+ ((!Number.isFinite(normalizedTargetSequence) || normalizedTargetSequence <= 0) &&
1986
+ !normalizedTargetCursor)
1987
+ ) {
1988
+ return { ok: false, reason: "invalid_input", updated: false };
1989
+ }
1990
+ if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
1991
+ return { ok: false, reason: "remote_sync_disabled_env", updated: false };
1992
+ }
1993
+
1994
+ const normalizedNowMs = Number(nowMs()) || Date.now();
1995
+ if (isCircuitOpen(outboundCircuit, normalizedNowMs)) {
1996
+ return { ok: false, reason: "circuit_breaker_open", updated: false };
1997
+ }
1998
+
1999
+ let session = null;
2000
+ try {
2001
+ session = await resolveAuthSession({
2002
+ cwd: targetPath,
2003
+ env: process.env,
2004
+ autoRotate: false,
2005
+ });
2006
+ } catch {
2007
+ return { ok: false, reason: "no_session", updated: false };
2008
+ }
2009
+ if (!session?.token) {
2010
+ return { ok: false, reason: "not_authenticated", updated: false };
2011
+ }
2012
+
2013
+ const apiBaseUrl = resolveApiBaseUrl(session);
2014
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/read-cursor`;
2015
+ const body = {
2016
+ targetSequenceId:
2017
+ Number.isFinite(normalizedTargetSequence) && normalizedTargetSequence > 0
2018
+ ? Math.floor(normalizedTargetSequence)
2019
+ : undefined,
2020
+ targetCursor: normalizedTargetCursor || undefined,
2021
+ agentId: normalizeString(agentId) || undefined,
2022
+ };
2023
+ try {
2024
+ const { response, payload } = await fetchJsonWithFullTimeout(
2025
+ endpoint,
2026
+ {
2027
+ method: "PUT",
2028
+ headers: {
2029
+ "Content-Type": "application/json",
2030
+ Authorization: `Bearer ${session.token}`,
2031
+ },
2032
+ body: JSON.stringify(body),
2033
+ signal,
2034
+ },
2035
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
2036
+ fetchImpl,
2037
+ );
2038
+ if (!response?.ok) {
2039
+ if (response?.status === 404) {
2040
+ return { ok: false, reason: "read_cursor_unsupported", updated: false };
2041
+ }
2042
+ if (isRateLimitResponse(response, payload)) {
2043
+ return {
2044
+ ok: false,
2045
+ reason: "rate_limited",
2046
+ status: response?.status || 429,
2047
+ retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
2048
+ updated: false,
2049
+ };
2050
+ }
2051
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
2052
+ return {
2053
+ ok: false,
2054
+ reason: `api_${response ? response.status : "no_response"}`,
2055
+ retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
2056
+ updated: false,
2057
+ };
2058
+ }
2059
+ recordCircuitSuccess(outboundCircuit);
2060
+ return {
2061
+ ok: Boolean(payload?.ok ?? true),
2062
+ reason: "",
2063
+ updated: Boolean(payload?.updated ?? true),
2064
+ lastReadSequenceId: normalizePositiveInteger(
2065
+ payload?.lastReadSequenceId || payload?.last_read_sequence_id,
2066
+ Number.isFinite(normalizedTargetSequence) && normalizedTargetSequence > 0
2067
+ ? Math.floor(normalizedTargetSequence)
2068
+ : null,
2069
+ ),
2070
+ };
2071
+ } catch (error) {
2072
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
2073
+ return {
2074
+ ok: false,
2075
+ reason: normalizeString(error?.message) || "read_cursor_write_failed",
2076
+ updated: false,
2077
+ };
2078
+ }
2079
+ }
2080
+
1471
2081
  /**
1472
2082
  * Consume the API's durable session SSE stream.
1473
2083
  *
@@ -1483,6 +2093,7 @@ export async function pollSessionEvents(
1483
2093
  * @param {(event: object) => Promise<void>|void} [options.onEvent]
1484
2094
  * @param {(payload: object) => Promise<void>|void} [options.onError]
1485
2095
  * @param {() => Promise<void>|void} [options.onHeartbeat]
2096
+ * @param {(cursor: string) => Promise<void>|void} [options.onCursor]
1486
2097
  * @returns {Promise<{ok: boolean, reason: string, cursor: string|null, eventCount: number, errorCount: number, status?: number, aborted?: boolean}>}
1487
2098
  */
1488
2099
  export async function streamSessionEvents(
@@ -1498,6 +2109,7 @@ export async function streamSessionEvents(
1498
2109
  onEvent = async () => {},
1499
2110
  onError = async () => {},
1500
2111
  onHeartbeat = async () => {},
2112
+ onCursor = async () => {},
1501
2113
  } = {}
1502
2114
  ) {
1503
2115
  const normalizedSessionId = normalizeString(sessionId);
@@ -1585,6 +2197,7 @@ export async function streamSessionEvents(
1585
2197
  headers: {
1586
2198
  Accept: "text/event-stream",
1587
2199
  Authorization: `Bearer ${session.token}`,
2200
+ ...(normalizedSince ? { "Last-Event-ID": normalizedSince } : {}),
1588
2201
  },
1589
2202
  signal: controller.signal,
1590
2203
  },
@@ -1616,6 +2229,12 @@ export async function streamSessionEvents(
1616
2229
 
1617
2230
  const handlers = {
1618
2231
  cursor: () => cursor,
2232
+ onCursor: async (candidate) => {
2233
+ const normalizedCandidate = normalizeString(candidate);
2234
+ if (!normalizedCandidate) return;
2235
+ cursor = normalizedCandidate;
2236
+ await onCursor(normalizedCandidate);
2237
+ },
1619
2238
  onHeartbeat,
1620
2239
  onError: async (payload) => {
1621
2240
  errorCount += 1;
@@ -2159,6 +2778,37 @@ export async function createSessionMessageAction(
2159
2778
  if (!normalizedSessionId || !normalizedActionType) {
2160
2779
  return { ok: false, reason: "invalid_input", action: null };
2161
2780
  }
2781
+ if (normalizedActionType === "view") {
2782
+ const actorAgentId = normalizeString(
2783
+ metadata?.agentId ||
2784
+ metadata?.agent_id ||
2785
+ metadata?.actorAgentId ||
2786
+ metadata?.actor_agent_id,
2787
+ );
2788
+ const readCursor = await updateSessionReadCursor(normalizedSessionId, {
2789
+ targetPath,
2790
+ targetSequenceId,
2791
+ targetCursor,
2792
+ agentId: actorAgentId,
2793
+ timeoutMs,
2794
+ signal,
2795
+ resolveAuthSession,
2796
+ fetchImpl,
2797
+ nowMs,
2798
+ });
2799
+ return {
2800
+ ...readCursor,
2801
+ duplicate: false,
2802
+ readCursor: readCursor.ok,
2803
+ action: readCursor.ok
2804
+ ? {
2805
+ actionType: "view",
2806
+ targetSequenceId: readCursor.lastReadSequenceId || targetSequenceId || null,
2807
+ actorId: actorAgentId || null,
2808
+ }
2809
+ : null,
2810
+ };
2811
+ }
2162
2812
  if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
2163
2813
  return { ok: false, reason: "remote_sync_disabled_env", action: null };
2164
2814
  }