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
@@ -2,9 +2,18 @@ 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";
10
+ import { createSessionMutationHeaders } from "./invitations.js";
11
+ import { messageRevision } from "./message-edits.js";
12
+ import { installTestEgressGuard } from "../net/test-egress-guard.js";
13
+
14
+ // No-op outside tests; inside a test process (or a child it spawned) every fetch
15
+ // to a non-loopback host is refused before a socket opens.
16
+ installTestEgressGuard();
8
17
 
9
18
  const DEFAULT_API_BASE_URL = "https://api.sentinelayer.com";
10
19
  const DEFAULT_SYNC_TIMEOUT_MS = 5_000;
@@ -17,6 +26,26 @@ const HUMAN_MESSAGE_FETCH_LIMIT = 50;
17
26
  const SESSION_EVENT_FETCH_LIMIT = 200;
18
27
  const SESSION_ACTION_FETCH_LIMIT = 500;
19
28
  const SESSION_SEARCH_FETCH_LIMIT = 50;
29
+ const NON_SEMANTIC_TRANSCRIPT_EVENT_TYPES = new Set([
30
+ "agent_heartbeat",
31
+ "agent_identified",
32
+ "agent_identity",
33
+ "agent_join",
34
+ "agent_leave",
35
+ "agent_left",
36
+ "agent_status",
37
+ "context_briefing",
38
+ "session_recap",
39
+ "session_view",
40
+ "view",
41
+ ]);
42
+
43
+ function isNonSemanticTranscriptEvent(event, eventType) {
44
+ return (
45
+ NON_SEMANTIC_TRANSCRIPT_EVENT_TYPES.has(eventType) ||
46
+ isSessionControlEvent(event)
47
+ );
48
+ }
20
49
 
21
50
  // Audit §2.9: crash-recovery contract for in-memory circuit state.
22
51
  // Persist outbound/inbound circuit state to disk so a process restart
@@ -305,6 +334,11 @@ function isApiHostAllowed(urlString) {
305
334
  function resolveApiBaseUrl(session = {}) {
306
335
  const apiUrl = normalizeString(session.apiUrl) || DEFAULT_API_BASE_URL;
307
336
  const normalized = apiUrl.replace(/\/+$/, "");
337
+ if (!isApiHostAllowed(normalized) && session.source === "session_admission") {
338
+ // An admission credential is bound to the API that issued it. Redirecting it to
339
+ // the default host would hand it to an API it was never meant for: refuse.
340
+ throw new Error("Refusing to send an agent admission credential to a host other than the API that issued it.");
341
+ }
308
342
  if (!isApiHostAllowed(normalized)) {
309
343
  // Reject tampered session.apiUrl and fall back to the default.
310
344
  // Caller will see API calls land on the canonical host, not an attacker's.
@@ -612,12 +646,12 @@ async function readResponseJsonSafely(response, timeoutMs = DEFAULT_SYNC_TIMEOUT
612
646
  }
613
647
  }
614
648
 
615
- async function fetchJsonWithFullTimeout(
649
+ export async function fetchJsonWithFullTimeout(
616
650
  url,
617
651
  options,
618
652
  timeoutMs,
619
653
  fetchImpl = fetchWithTimeout,
620
- { readErrorBody = false } = {},
654
+ { readErrorBody = false, readSuccessBody = true, errorBodyStatuses = [] } = {},
621
655
  ) {
622
656
  const resolvedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
623
657
  const controller = new AbortController();
@@ -638,13 +672,15 @@ async function fetchJsonWithFullTimeout(
638
672
  );
639
673
 
640
674
  let payload = {};
641
- if (response?.ok || readErrorBody) {
675
+ if ((response?.ok && readSuccessBody) || readErrorBody || errorBodyStatuses.includes(response?.status)) {
642
676
  const elapsedMs = Math.max(0, Date.now() - startedAtMs);
643
677
  const remainingMs = Math.max(1, resolvedTimeoutMs - elapsedMs);
644
678
  payload = await readResponseJsonWithTimeout(response, remainingMs, {
645
679
  fallback: {},
646
680
  onTimeout: () => abortRequestController(controller, "response_body_timeout"),
647
681
  });
682
+ } else {
683
+ cancelResponseBody(response);
648
684
  }
649
685
  return { response, payload };
650
686
  }
@@ -713,19 +749,29 @@ async function processSseBlock(block, handlers) {
713
749
  if (!normalizedBlock) return;
714
750
 
715
751
  const dataLines = [];
716
- let commentOnly = true;
752
+ let eventId = "";
753
+ let hasComment = false;
717
754
  for (const rawLine of String(block).split("\n")) {
718
755
  const line = rawLine.trimEnd();
719
756
  if (!line) continue;
720
- if (line.startsWith(":")) continue;
721
- commentOnly = false;
757
+ if (line.startsWith(":")) {
758
+ hasComment = true;
759
+ continue;
760
+ }
761
+ if (line.startsWith("id:")) {
762
+ eventId = line.slice(3).trimStart();
763
+ continue;
764
+ }
722
765
  if (line.startsWith("data:")) {
723
766
  dataLines.push(line.slice(5).trimStart());
724
767
  }
725
768
  }
726
769
 
727
770
  if (dataLines.length === 0) {
728
- if (commentOnly && typeof handlers.onHeartbeat === "function") {
771
+ if (eventId && typeof handlers.onCursor === "function") {
772
+ await handlers.onCursor(eventId);
773
+ }
774
+ if (hasComment && typeof handlers.onHeartbeat === "function") {
729
775
  await handlers.onHeartbeat();
730
776
  }
731
777
  return;
@@ -835,6 +881,18 @@ function buildHumanRelayEvent(sessionId, message = {}) {
835
881
  agentId,
836
882
  sessionId,
837
883
  ts,
884
+ // The compatibility human-poll lane must preserve canonical revision and
885
+ // identity too; otherwise an edited body is mistaken for a fresh directive
886
+ // when its paired durable edit notification has not reached this poller.
887
+ id: message.eventId || message.event_id || undefined,
888
+ eventId: message.eventId || message.event_id || undefined,
889
+ cursor: message.cursor || undefined,
890
+ sequenceId: message.sequenceId ?? message.sequence_id,
891
+ messageRevision: messageRevision(message),
892
+ editedAt: message.editedAt,
893
+ editedBy: message.editedBy,
894
+ canEdit: message.canEdit,
895
+ revisionEvidence: message.revisionEvidence,
838
896
  payload: {
839
897
  message: sanitization.message,
840
898
  channel: "session",
@@ -875,14 +933,22 @@ export async function syncSessionEventToApi(
875
933
  if (!normalizedSessionId || !event || typeof event !== "object" || Array.isArray(event)) {
876
934
  return { synced: false, reason: "invalid_input" };
877
935
  }
936
+ const eventType = normalizeString(event.event || event.type).toLowerCase();
937
+ if (isNonSemanticTranscriptEvent(event, eventType)) {
938
+ return {
939
+ synced: false,
940
+ reason: "non_semantic_transcript_event_rejected",
941
+ eventType,
942
+ };
943
+ }
878
944
 
879
945
  // Test-fixture leak guard. Tests in this repo (and downstream consumers)
880
946
  // create + tear down sessions using a temp workspace; on a developer
881
947
  // machine those calls inherit the user's stored auth and silently posted
882
948
  // hundreds of orphan rooms to prod (Carter saw ~200 "<null>" sessions).
883
949
  // 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.
950
+ // still exercising the appendToStream paths the tests care about. Local
951
+ // NDJSON durability is unaffected.
886
952
  if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
887
953
  return { synced: false, reason: "remote_sync_disabled_env" };
888
954
  }
@@ -942,9 +1008,14 @@ export async function syncSessionEventToApi(
942
1008
  body: requestBody,
943
1009
  };
944
1010
  const resolvedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
1011
+ const deadlineMs = Date.now() + resolvedTimeoutMs;
1012
+ const remainingMs = () => Math.max(1, deadlineMs - Date.now());
945
1013
 
946
1014
  try {
947
- const response = await fetchImpl(endpoint, requestInit, resolvedTimeoutMs);
1015
+ const { response, payload: forbiddenBody } = await fetchJsonWithFullTimeout(
1016
+ endpoint, requestInit, remainingMs(), fetchImpl,
1017
+ { readSuccessBody: false, errorBodyStatuses: [403] },
1018
+ );
948
1019
 
949
1020
  if (response && response.ok) {
950
1021
  recordCircuitSuccess(outboundCircuit);
@@ -960,7 +1031,7 @@ export async function syncSessionEventToApi(
960
1031
  // their agents "talking" locally while the API has zero record. We attempt
961
1032
  // the grant once per agentId per process, then retry the event POST once.
962
1033
  if (response && response.status === 403) {
963
- const body = await readResponseJsonSafely(response);
1034
+ const body = forbiddenBody;
964
1035
  if (isIdentityForgeryBody(body)) {
965
1036
  const agentId = normalizeString(event?.agent?.id);
966
1037
  if (!agentId || isReservedAgentIdForGrant(agentId)) {
@@ -982,7 +1053,7 @@ export async function syncSessionEventToApi(
982
1053
  const grantEndpoint = `${apiBaseUrl}/api/v1/sessions/agent-grants`;
983
1054
  let grantResponse = null;
984
1055
  try {
985
- grantResponse = await fetchImpl(
1056
+ ({ response: grantResponse } = await fetchJsonWithFullTimeout(
986
1057
  grantEndpoint,
987
1058
  {
988
1059
  method: "POST",
@@ -995,8 +1066,8 @@ export async function syncSessionEventToApi(
995
1066
  role: grantRole,
996
1067
  }),
997
1068
  },
998
- resolvedTimeoutMs
999
- );
1069
+ remainingMs(), fetchImpl, { readSuccessBody: false },
1070
+ ));
1000
1071
  } catch (grantError) {
1001
1072
  recordCircuitFailure(outboundCircuit, normalizedNowMs);
1002
1073
  return {
@@ -1020,7 +1091,9 @@ export async function syncSessionEventToApi(
1020
1091
  // Retry the original event POST exactly once.
1021
1092
  let retryResponse;
1022
1093
  try {
1023
- retryResponse = await fetchImpl(endpoint, requestInit, resolvedTimeoutMs);
1094
+ ({ response: retryResponse } = await fetchJsonWithFullTimeout(
1095
+ endpoint, requestInit, remainingMs(), fetchImpl, { readSuccessBody: false },
1096
+ ));
1024
1097
  } catch (retryError) {
1025
1098
  recordCircuitFailure(outboundCircuit, normalizedNowMs);
1026
1099
  return {
@@ -1116,7 +1189,7 @@ async function syncSessionAuxPayload(
1116
1189
  const apiBaseUrl = resolveApiBaseUrl(session);
1117
1190
  const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}${pathSuffix}`;
1118
1191
  try {
1119
- const response = await fetchImpl(
1192
+ const { response } = await fetchJsonWithFullTimeout(
1120
1193
  endpoint,
1121
1194
  {
1122
1195
  method: "POST",
@@ -1129,7 +1202,9 @@ async function syncSessionAuxPayload(
1129
1202
  source: "cli",
1130
1203
  }),
1131
1204
  },
1132
- normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS)
1205
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
1206
+ fetchImpl,
1207
+ { readSuccessBody: false },
1133
1208
  );
1134
1209
  if (!response || !response.ok) {
1135
1210
  recordCircuitFailure(outboundCircuit, normalizedNowMs);
@@ -1325,18 +1400,21 @@ export async function pollHumanMessages(
1325
1400
  * / `agent_response` events. The result was codex and claude talking
1326
1401
  * past each other ("Apologies — I missed your 5 updates").
1327
1402
  *
1328
- * Endpoint contract: `GET /api/v1/sessions/{id}/events?after=<cursor>&limit=N`.
1403
+ * Endpoint contract:
1404
+ * `GET /api/v1/sessions/{id}/events?after=<cursor>&limit=N&listenerAgentId=<id>`.
1329
1405
  * 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.
1406
+ * pagination. When `listenerAgentId` is present, Redis-backed listener
1407
+ * controls are returned separately in `listenerControls`; they never join
1408
+ * the durable event list or participate in its cursor.
1332
1409
  *
1333
1410
  * @param {string} sessionId
1334
1411
  * @param {object} [options]
1335
1412
  * @param {string} [options.targetPath]
1336
1413
  * @param {string|null} [options.since] - cursor to start after; null = full history
1337
1414
  * @param {number} [options.limit] - default 200 (max from API)
1415
+ * @param {string} [options.listenerAgentId] - optional listener control recipient
1338
1416
  * @param {number} [options.timeoutMs] - per-request deadline
1339
- * @returns {Promise<{ok: boolean, reason: string, events: Array<object>, cursor: string|null}>}
1417
+ * @returns {Promise<{ok: boolean, reason: string, events: Array<object>, listenerControls: Array<object>, cursor: string|null, scannedThroughSequence: number|null, scanExhausted: boolean|null}>}
1340
1418
  */
1341
1419
  export async function pollSessionEvents(
1342
1420
  sessionId,
@@ -1344,6 +1422,7 @@ export async function pollSessionEvents(
1344
1422
  targetPath = process.cwd(),
1345
1423
  since = null,
1346
1424
  limit = 200,
1425
+ listenerAgentId = "",
1347
1426
  timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1348
1427
  forceCircuitProbe = false,
1349
1428
  resolveAuthSession = resolveActiveAuthSession,
@@ -1357,6 +1436,7 @@ export async function pollSessionEvents(
1357
1436
  ok: false,
1358
1437
  reason: "invalid_session_id",
1359
1438
  events: [],
1439
+ listenerControls: [],
1360
1440
  cursor: normalizeString(since) || null,
1361
1441
  };
1362
1442
  }
@@ -1367,6 +1447,7 @@ export async function pollSessionEvents(
1367
1447
  ok: false,
1368
1448
  reason: "circuit_breaker_open",
1369
1449
  events: [],
1450
+ listenerControls: [],
1370
1451
  cursor: normalizeString(since) || null,
1371
1452
  };
1372
1453
  }
@@ -1383,6 +1464,7 @@ export async function pollSessionEvents(
1383
1464
  ok: false,
1384
1465
  reason: "no_session",
1385
1466
  events: [],
1467
+ listenerControls: [],
1386
1468
  cursor: normalizeString(since) || null,
1387
1469
  };
1388
1470
  }
@@ -1391,6 +1473,7 @@ export async function pollSessionEvents(
1391
1473
  ok: false,
1392
1474
  reason: "not_authenticated",
1393
1475
  events: [],
1476
+ listenerControls: [],
1394
1477
  cursor: normalizeString(since) || null,
1395
1478
  };
1396
1479
  }
@@ -1401,6 +1484,10 @@ export async function pollSessionEvents(
1401
1484
  if (normalizedSince) {
1402
1485
  query.set("after", normalizedSince);
1403
1486
  }
1487
+ const normalizedListenerAgentId = normalizeString(listenerAgentId);
1488
+ if (normalizedListenerAgentId) {
1489
+ query.set("listenerAgentId", normalizedListenerAgentId);
1490
+ }
1404
1491
  query.set(
1405
1492
  "limit",
1406
1493
  String(Math.max(1, Math.min(SESSION_EVENT_FETCH_LIMIT, normalizePositiveInteger(limit, SESSION_EVENT_FETCH_LIMIT))))
@@ -1426,6 +1513,7 @@ export async function pollSessionEvents(
1426
1513
  status: response?.status || 429,
1427
1514
  retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
1428
1515
  events: [],
1516
+ listenerControls: [],
1429
1517
  cursor: normalizedSince || null,
1430
1518
  };
1431
1519
  }
@@ -1434,12 +1522,23 @@ export async function pollSessionEvents(
1434
1522
  ok: false,
1435
1523
  reason: `api_${response ? response.status : "no_response"}`,
1436
1524
  events: [],
1525
+ listenerControls: [],
1437
1526
  cursor: normalizedSince || null,
1438
1527
  };
1439
1528
  }
1440
1529
  recordCircuitSuccess(inboundCircuit);
1441
1530
 
1442
1531
  const items = Array.isArray(payload?.events) ? payload.events : [];
1532
+ const listenerControlProjection =
1533
+ payload?.listenerControls ?? payload?.listener_controls;
1534
+ const listenerControlItems = Array.isArray(listenerControlProjection)
1535
+ ? listenerControlProjection
1536
+ : Array.isArray(listenerControlProjection?.items)
1537
+ ? listenerControlProjection.items
1538
+ : [];
1539
+ const listenerControls = listenerControlItems.filter(
1540
+ (control) => control && typeof control === "object" && !Array.isArray(control),
1541
+ );
1443
1542
  const acceptedEvents = [];
1444
1543
  let lastCursor = normalizedSince || null;
1445
1544
  for (const item of items) {
@@ -1450,12 +1549,23 @@ export async function pollSessionEvents(
1450
1549
  // envelope shape that appendToStream expects.
1451
1550
  acceptedEvents.push(item);
1452
1551
  }
1552
+ const responseCursor =
1553
+ normalizeString(payload?.nextCursor || payload?.next_cursor || payload?.cursor) ||
1554
+ lastCursor;
1555
+ const scannedThroughSequence = normalizePositiveInteger(
1556
+ payload?.scannedThroughSequence ?? payload?.scanned_through_sequence,
1557
+ null,
1558
+ );
1559
+ const rawScanExhausted = payload?.scanExhausted ?? payload?.scan_exhausted;
1453
1560
 
1454
1561
  return {
1455
1562
  ok: true,
1456
1563
  reason: "",
1457
1564
  events: acceptedEvents,
1458
- cursor: lastCursor,
1565
+ listenerControls,
1566
+ cursor: responseCursor,
1567
+ scannedThroughSequence,
1568
+ scanExhausted: typeof rawScanExhausted === "boolean" ? rawScanExhausted : null,
1459
1569
  };
1460
1570
  } catch (error) {
1461
1571
  recordCircuitFailure(inboundCircuit, normalizedNowMs);
@@ -1463,11 +1573,546 @@ export async function pollSessionEvents(
1463
1573
  ok: false,
1464
1574
  reason: normalizeString(error?.message) || "poll_failed",
1465
1575
  events: [],
1576
+ listenerControls: [],
1466
1577
  cursor: normalizedSince || null,
1467
1578
  };
1468
1579
  }
1469
1580
  }
1470
1581
 
1582
+ /**
1583
+ * Read the membership-gated, three-state ephemeral listener roster.
1584
+ *
1585
+ * Compatibility is deliberately fail-closed: an older server (404), a server
1586
+ * with presence disabled, or a degraded Redis dependency is reported as
1587
+ * unknown/unsupported. Callers must never reconstruct liveness by scanning
1588
+ * durable listener heartbeat events.
1589
+ */
1590
+ export async function fetchSessionPresence(
1591
+ sessionId,
1592
+ {
1593
+ targetPath = process.cwd(),
1594
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1595
+ forceCircuitProbe = false,
1596
+ resolveAuthSession = resolveActiveAuthSession,
1597
+ fetchImpl = fetchWithTimeout,
1598
+ nowMs = Date.now,
1599
+ } = {},
1600
+ ) {
1601
+ const normalizedSessionId = normalizeString(sessionId);
1602
+ if (!normalizedSessionId) {
1603
+ return {
1604
+ ok: false,
1605
+ reason: "invalid_session_id",
1606
+ status: "unsupported",
1607
+ enabled: false,
1608
+ present: [],
1609
+ };
1610
+ }
1611
+
1612
+ const normalizedNowMs = Number(nowMs()) || Date.now();
1613
+ if (!forceCircuitProbe && isCircuitOpen(inboundCircuit, normalizedNowMs)) {
1614
+ return {
1615
+ ok: false,
1616
+ reason: "circuit_breaker_open",
1617
+ status: "degraded",
1618
+ enabled: null,
1619
+ present: [],
1620
+ };
1621
+ }
1622
+
1623
+ let session = null;
1624
+ try {
1625
+ session = await resolveAuthSession({
1626
+ cwd: targetPath,
1627
+ env: process.env,
1628
+ autoRotate: false,
1629
+ });
1630
+ } catch {
1631
+ return {
1632
+ ok: false,
1633
+ reason: "no_session",
1634
+ status: "unsupported",
1635
+ enabled: null,
1636
+ present: [],
1637
+ };
1638
+ }
1639
+ if (!session?.token) {
1640
+ return {
1641
+ ok: false,
1642
+ reason: "not_authenticated",
1643
+ status: "unsupported",
1644
+ enabled: null,
1645
+ present: [],
1646
+ };
1647
+ }
1648
+
1649
+ const apiBaseUrl = resolveApiBaseUrl(session);
1650
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/presence`;
1651
+ try {
1652
+ const { response, payload } = await fetchJsonWithFullTimeout(
1653
+ endpoint,
1654
+ {
1655
+ method: "GET",
1656
+ headers: { Authorization: `Bearer ${session.token}` },
1657
+ },
1658
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
1659
+ fetchImpl,
1660
+ );
1661
+
1662
+ if (!response?.ok) {
1663
+ if (response?.status === 404) {
1664
+ return {
1665
+ ok: false,
1666
+ reason: "presence_unsupported",
1667
+ status: "unsupported",
1668
+ enabled: false,
1669
+ present: [],
1670
+ };
1671
+ }
1672
+ if (isRateLimitResponse(response, payload)) {
1673
+ return {
1674
+ ok: false,
1675
+ reason: "rate_limited",
1676
+ status: "degraded",
1677
+ enabled: null,
1678
+ retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
1679
+ present: [],
1680
+ };
1681
+ }
1682
+ recordCircuitFailure(inboundCircuit, normalizedNowMs);
1683
+ return {
1684
+ ok: false,
1685
+ reason: `api_${response ? response.status : "no_response"}`,
1686
+ status: "degraded",
1687
+ enabled: null,
1688
+ retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
1689
+ present: [],
1690
+ };
1691
+ }
1692
+
1693
+ recordCircuitSuccess(inboundCircuit);
1694
+ const enabled = payload?.enabled !== false;
1695
+ const status = enabled
1696
+ ? normalizeString(payload?.status).toLowerCase() || "ok"
1697
+ : "unsupported";
1698
+ const present = enabled && Array.isArray(payload?.present)
1699
+ ? payload.present.filter((entry) => entry && typeof entry === "object")
1700
+ : [];
1701
+ return {
1702
+ ok: enabled && status === "ok",
1703
+ reason: enabled
1704
+ ? status === "ok"
1705
+ ? ""
1706
+ : "presence_degraded"
1707
+ : "presence_disabled",
1708
+ status: status === "ok" ? "ok" : status === "unsupported" ? "unsupported" : "degraded",
1709
+ enabled,
1710
+ present,
1711
+ };
1712
+ } catch (error) {
1713
+ recordCircuitFailure(inboundCircuit, normalizedNowMs);
1714
+ return {
1715
+ ok: false,
1716
+ reason: normalizeString(error?.message) || "presence_read_failed",
1717
+ status: "degraded",
1718
+ enabled: null,
1719
+ present: [],
1720
+ };
1721
+ }
1722
+ }
1723
+
1724
+ /**
1725
+ * Renew one listener's ephemeral TTL. This endpoint is a hard protocol
1726
+ * boundary: it never falls back to POST /events on older or degraded servers.
1727
+ */
1728
+ export async function renewSessionPresence(
1729
+ sessionId,
1730
+ {
1731
+ targetPath = process.cwd(),
1732
+ agentId,
1733
+ state = "idle",
1734
+ listenerId = "",
1735
+ model = "",
1736
+ displayName = "",
1737
+ provider = "",
1738
+ clientKind = "cli",
1739
+ observedAt = new Date().toISOString(),
1740
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1741
+ signal = undefined,
1742
+ resolveAuthSession = resolveActiveAuthSession,
1743
+ fetchImpl = fetchWithTimeout,
1744
+ nowMs = Date.now,
1745
+ } = {},
1746
+ ) {
1747
+ const normalizedSessionId = normalizeString(sessionId);
1748
+ const normalizedAgentId = normalizeString(agentId);
1749
+ if (!normalizedSessionId || !normalizedAgentId) {
1750
+ return {
1751
+ ok: false,
1752
+ reason: "invalid_input",
1753
+ status: "unsupported",
1754
+ recorded: false,
1755
+ };
1756
+ }
1757
+ if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
1758
+ return {
1759
+ ok: false,
1760
+ reason: "remote_sync_disabled_env",
1761
+ status: "unsupported",
1762
+ recorded: false,
1763
+ };
1764
+ }
1765
+
1766
+ const normalizedNowMs = Number(nowMs()) || Date.now();
1767
+ if (isCircuitOpen(outboundCircuit, normalizedNowMs)) {
1768
+ return {
1769
+ ok: false,
1770
+ reason: "circuit_breaker_open",
1771
+ status: "degraded",
1772
+ recorded: false,
1773
+ };
1774
+ }
1775
+
1776
+ let session = null;
1777
+ try {
1778
+ session = await resolveAuthSession({
1779
+ cwd: targetPath,
1780
+ env: process.env,
1781
+ autoRotate: false,
1782
+ });
1783
+ } catch {
1784
+ return {
1785
+ ok: false,
1786
+ reason: "no_session",
1787
+ status: "unsupported",
1788
+ recorded: false,
1789
+ };
1790
+ }
1791
+ if (!session?.token) {
1792
+ return {
1793
+ ok: false,
1794
+ reason: "not_authenticated",
1795
+ status: "unsupported",
1796
+ recorded: false,
1797
+ };
1798
+ }
1799
+
1800
+ const apiBaseUrl = resolveApiBaseUrl(session);
1801
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/presence`;
1802
+ const body = {
1803
+ agentId: normalizedAgentId,
1804
+ state: normalizeString(state).toLowerCase() || "idle",
1805
+ listenerId: normalizeString(listenerId) || undefined,
1806
+ model: normalizeString(model) || undefined,
1807
+ displayName: normalizeString(displayName) || undefined,
1808
+ provider: normalizeString(provider) || undefined,
1809
+ clientKind: normalizeString(clientKind) || "cli",
1810
+ observedAt: normalizeIsoTimestamp(observedAt),
1811
+ };
1812
+
1813
+ try {
1814
+ const { response, payload } = await fetchJsonWithFullTimeout(
1815
+ endpoint,
1816
+ {
1817
+ method: "PUT",
1818
+ headers: {
1819
+ "Content-Type": "application/json",
1820
+ Authorization: `Bearer ${session.token}`,
1821
+ },
1822
+ body: JSON.stringify(body),
1823
+ signal,
1824
+ },
1825
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
1826
+ fetchImpl,
1827
+ );
1828
+ if (!response?.ok) {
1829
+ if (response?.status === 404) {
1830
+ return {
1831
+ ok: false,
1832
+ reason: "presence_unsupported",
1833
+ status: "unsupported",
1834
+ recorded: false,
1835
+ };
1836
+ }
1837
+ if (isRateLimitResponse(response, payload)) {
1838
+ return {
1839
+ ok: false,
1840
+ reason: "rate_limited",
1841
+ status: "degraded",
1842
+ recorded: false,
1843
+ retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
1844
+ };
1845
+ }
1846
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
1847
+ return {
1848
+ ok: false,
1849
+ reason: `api_${response ? response.status : "no_response"}`,
1850
+ status: "degraded",
1851
+ recorded: false,
1852
+ retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
1853
+ };
1854
+ }
1855
+ recordCircuitSuccess(outboundCircuit);
1856
+ const status = normalizeString(payload?.status).toLowerCase() || "ok";
1857
+ return {
1858
+ ok: status === "ok" && Boolean(payload?.recorded ?? true),
1859
+ reason: status === "ok" ? "" : "presence_degraded",
1860
+ status: status === "ok" ? "ok" : "degraded",
1861
+ recorded: Boolean(payload?.recorded ?? status === "ok"),
1862
+ ttlSeconds: normalizePositiveInteger(payload?.ttlSeconds || payload?.ttl_seconds, null),
1863
+ };
1864
+ } catch (error) {
1865
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
1866
+ return {
1867
+ ok: false,
1868
+ reason: normalizeString(error?.message) || "presence_write_failed",
1869
+ status: "degraded",
1870
+ recorded: false,
1871
+ };
1872
+ }
1873
+ }
1874
+
1875
+ /**
1876
+ * Enqueue an ephemeral Redis-backed listener stop control.
1877
+ *
1878
+ * This endpoint is the only remote delivery path for stop requests. It never
1879
+ * falls back to the durable session event stream.
1880
+ */
1881
+ export async function requestSessionListenerStop(
1882
+ sessionId,
1883
+ {
1884
+ targetPath = process.cwd(),
1885
+ targetAgentId = "",
1886
+ idempotencyKey = "",
1887
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1888
+ resolveAuthSession = resolveActiveAuthSession,
1889
+ fetchImpl = fetchWithTimeout,
1890
+ nowMs = Date.now,
1891
+ } = {},
1892
+ ) {
1893
+ const normalizedSessionId = normalizeString(sessionId);
1894
+ if (!normalizedSessionId) {
1895
+ return { ok: false, reason: "invalid_session_id", recorded: false };
1896
+ }
1897
+ if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
1898
+ return { ok: false, reason: "remote_sync_disabled_env", recorded: false };
1899
+ }
1900
+
1901
+ const normalizedNowMs = Number(nowMs()) || Date.now();
1902
+ if (isCircuitOpen(outboundCircuit, normalizedNowMs)) {
1903
+ return { ok: false, reason: "circuit_breaker_open", recorded: false };
1904
+ }
1905
+
1906
+ let session = null;
1907
+ try {
1908
+ session = await resolveAuthSession({
1909
+ cwd: targetPath,
1910
+ env: process.env,
1911
+ autoRotate: false,
1912
+ });
1913
+ } catch {
1914
+ return { ok: false, reason: "no_session", recorded: false };
1915
+ }
1916
+ if (!session?.token) {
1917
+ return { ok: false, reason: "not_authenticated", recorded: false };
1918
+ }
1919
+
1920
+ const normalizedTargetAgentId = normalizeString(targetAgentId);
1921
+ const normalizedIdempotencyKey =
1922
+ normalizeString(idempotencyKey) || `sl-listener-stop-${randomUUID()}`;
1923
+ const apiBaseUrl = resolveApiBaseUrl(session);
1924
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/listener-controls/stop`;
1925
+ const body = normalizedTargetAgentId
1926
+ ? { targetAgentId: normalizedTargetAgentId }
1927
+ : { broadcast: true };
1928
+
1929
+ try {
1930
+ const { response, payload } = await fetchJsonWithFullTimeout(
1931
+ endpoint,
1932
+ {
1933
+ method: "POST",
1934
+ headers: {
1935
+ "Content-Type": "application/json",
1936
+ Authorization: `Bearer ${session.token}`,
1937
+ "Idempotency-Key": normalizedIdempotencyKey,
1938
+ },
1939
+ body: JSON.stringify(body),
1940
+ },
1941
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
1942
+ fetchImpl,
1943
+ { readErrorBody: true },
1944
+ );
1945
+ if (!response?.ok) {
1946
+ if (response?.status === 404) {
1947
+ return {
1948
+ ok: false,
1949
+ reason: "listener_control_unsupported",
1950
+ recorded: false,
1951
+ };
1952
+ }
1953
+ if (isRateLimitResponse(response, payload)) {
1954
+ return {
1955
+ ok: false,
1956
+ reason: "rate_limited",
1957
+ recorded: false,
1958
+ retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
1959
+ };
1960
+ }
1961
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
1962
+ return {
1963
+ ok: false,
1964
+ reason: `api_${response ? response.status : "no_response"}`,
1965
+ recorded: false,
1966
+ };
1967
+ }
1968
+
1969
+ recordCircuitSuccess(outboundCircuit);
1970
+ const control =
1971
+ payload?.control && typeof payload.control === "object" && !Array.isArray(payload.control)
1972
+ ? payload.control
1973
+ : payload?.listenerControl &&
1974
+ typeof payload.listenerControl === "object" &&
1975
+ !Array.isArray(payload.listenerControl)
1976
+ ? payload.listenerControl
1977
+ : null;
1978
+ return {
1979
+ ok: true,
1980
+ reason: "",
1981
+ recorded: Boolean(payload?.recorded ?? true),
1982
+ control,
1983
+ };
1984
+ } catch (error) {
1985
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
1986
+ return {
1987
+ ok: false,
1988
+ reason: normalizeString(error?.message) || "listener_control_write_failed",
1989
+ recorded: false,
1990
+ };
1991
+ }
1992
+ }
1993
+
1994
+ /**
1995
+ * Monotonically advance one authenticated actor's read cursor in place.
1996
+ *
1997
+ * No compatibility fallback is allowed: calling the legacy `view` action
1998
+ * could append one durable row per displayed event when the server flag is
1999
+ * disabled or races a rollout.
2000
+ */
2001
+ export async function updateSessionReadCursor(
2002
+ sessionId,
2003
+ {
2004
+ targetPath = process.cwd(),
2005
+ targetSequenceId = null,
2006
+ targetCursor = "",
2007
+ agentId = "",
2008
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
2009
+ signal = undefined,
2010
+ resolveAuthSession = resolveActiveAuthSession,
2011
+ fetchImpl = fetchWithTimeout,
2012
+ nowMs = Date.now,
2013
+ } = {},
2014
+ ) {
2015
+ const normalizedSessionId = normalizeString(sessionId);
2016
+ const normalizedTargetSequence = Number(targetSequenceId);
2017
+ const normalizedTargetCursor = normalizeString(targetCursor);
2018
+ if (
2019
+ !normalizedSessionId ||
2020
+ ((!Number.isFinite(normalizedTargetSequence) || normalizedTargetSequence <= 0) &&
2021
+ !normalizedTargetCursor)
2022
+ ) {
2023
+ return { ok: false, reason: "invalid_input", updated: false };
2024
+ }
2025
+ if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
2026
+ return { ok: false, reason: "remote_sync_disabled_env", updated: false };
2027
+ }
2028
+
2029
+ const normalizedNowMs = Number(nowMs()) || Date.now();
2030
+ if (isCircuitOpen(outboundCircuit, normalizedNowMs)) {
2031
+ return { ok: false, reason: "circuit_breaker_open", updated: false };
2032
+ }
2033
+
2034
+ let session = null;
2035
+ try {
2036
+ session = await resolveAuthSession({
2037
+ cwd: targetPath,
2038
+ env: process.env,
2039
+ autoRotate: false,
2040
+ });
2041
+ } catch {
2042
+ return { ok: false, reason: "no_session", updated: false };
2043
+ }
2044
+ if (!session?.token) {
2045
+ return { ok: false, reason: "not_authenticated", updated: false };
2046
+ }
2047
+
2048
+ const apiBaseUrl = resolveApiBaseUrl(session);
2049
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/read-cursor`;
2050
+ const body = {
2051
+ targetSequenceId:
2052
+ Number.isFinite(normalizedTargetSequence) && normalizedTargetSequence > 0
2053
+ ? Math.floor(normalizedTargetSequence)
2054
+ : undefined,
2055
+ targetCursor: normalizedTargetCursor || undefined,
2056
+ agentId: normalizeString(agentId) || undefined,
2057
+ };
2058
+ try {
2059
+ const { response, payload } = await fetchJsonWithFullTimeout(
2060
+ endpoint,
2061
+ {
2062
+ method: "PUT",
2063
+ headers: {
2064
+ "Content-Type": "application/json",
2065
+ Authorization: `Bearer ${session.token}`,
2066
+ },
2067
+ body: JSON.stringify(body),
2068
+ signal,
2069
+ },
2070
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
2071
+ fetchImpl,
2072
+ );
2073
+ if (!response?.ok) {
2074
+ if (response?.status === 404) {
2075
+ return { ok: false, reason: "read_cursor_unsupported", updated: false };
2076
+ }
2077
+ if (isRateLimitResponse(response, payload)) {
2078
+ return {
2079
+ ok: false,
2080
+ reason: "rate_limited",
2081
+ status: response?.status || 429,
2082
+ retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
2083
+ updated: false,
2084
+ };
2085
+ }
2086
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
2087
+ return {
2088
+ ok: false,
2089
+ reason: `api_${response ? response.status : "no_response"}`,
2090
+ retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
2091
+ updated: false,
2092
+ };
2093
+ }
2094
+ recordCircuitSuccess(outboundCircuit);
2095
+ return {
2096
+ ok: Boolean(payload?.ok ?? true),
2097
+ reason: "",
2098
+ updated: Boolean(payload?.updated ?? true),
2099
+ lastReadSequenceId: normalizePositiveInteger(
2100
+ payload?.lastReadSequenceId || payload?.last_read_sequence_id,
2101
+ Number.isFinite(normalizedTargetSequence) && normalizedTargetSequence > 0
2102
+ ? Math.floor(normalizedTargetSequence)
2103
+ : null,
2104
+ ),
2105
+ };
2106
+ } catch (error) {
2107
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
2108
+ return {
2109
+ ok: false,
2110
+ reason: normalizeString(error?.message) || "read_cursor_write_failed",
2111
+ updated: false,
2112
+ };
2113
+ }
2114
+ }
2115
+
1471
2116
  /**
1472
2117
  * Consume the API's durable session SSE stream.
1473
2118
  *
@@ -1483,6 +2128,7 @@ export async function pollSessionEvents(
1483
2128
  * @param {(event: object) => Promise<void>|void} [options.onEvent]
1484
2129
  * @param {(payload: object) => Promise<void>|void} [options.onError]
1485
2130
  * @param {() => Promise<void>|void} [options.onHeartbeat]
2131
+ * @param {(cursor: string) => Promise<void>|void} [options.onCursor]
1486
2132
  * @returns {Promise<{ok: boolean, reason: string, cursor: string|null, eventCount: number, errorCount: number, status?: number, aborted?: boolean}>}
1487
2133
  */
1488
2134
  export async function streamSessionEvents(
@@ -1498,6 +2144,7 @@ export async function streamSessionEvents(
1498
2144
  onEvent = async () => {},
1499
2145
  onError = async () => {},
1500
2146
  onHeartbeat = async () => {},
2147
+ onCursor = async () => {},
1501
2148
  } = {}
1502
2149
  ) {
1503
2150
  const normalizedSessionId = normalizeString(sessionId);
@@ -1585,6 +2232,7 @@ export async function streamSessionEvents(
1585
2232
  headers: {
1586
2233
  Accept: "text/event-stream",
1587
2234
  Authorization: `Bearer ${session.token}`,
2235
+ ...(normalizedSince ? { "Last-Event-ID": normalizedSince } : {}),
1588
2236
  },
1589
2237
  signal: controller.signal,
1590
2238
  },
@@ -1616,6 +2264,12 @@ export async function streamSessionEvents(
1616
2264
 
1617
2265
  const handlers = {
1618
2266
  cursor: () => cursor,
2267
+ onCursor: async (candidate) => {
2268
+ const normalizedCandidate = normalizeString(candidate);
2269
+ if (!normalizedCandidate) return;
2270
+ cursor = normalizedCandidate;
2271
+ await onCursor(normalizedCandidate);
2272
+ },
1619
2273
  onHeartbeat,
1620
2274
  onError: async (payload) => {
1621
2275
  errorCount += 1;
@@ -2136,6 +2790,88 @@ export async function fetchSessionPinnedMessages(
2136
2790
  };
2137
2791
  }
2138
2792
 
2793
+ // Native edits never fall back to POST /events or mutate an optimistic local row.
2794
+ export async function editSessionMessage(sessionId, {
2795
+ targetSequenceId = null,
2796
+ targetMessageId = "",
2797
+ targetActionId = "",
2798
+ text,
2799
+ expectedRevision = null,
2800
+ agentId = "",
2801
+ idempotencyKey = "",
2802
+ targetPath = process.cwd(),
2803
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
2804
+ signal,
2805
+ resolveAuthSession = resolveActiveAuthSession,
2806
+ fetchImpl = fetchWithTimeout,
2807
+ nowMs = Date.now,
2808
+ } = {}) {
2809
+ const sid = normalizeString(sessionId);
2810
+ const sequence = Number(targetSequenceId);
2811
+ const replyId = normalizeString(targetActionId);
2812
+ let messageId = normalizeString(targetMessageId);
2813
+ let revision = expectedRevision === null || expectedRevision === undefined ? null : Number(expectedRevision);
2814
+ const validSequence = Number.isSafeInteger(sequence) && sequence > 0;
2815
+ const mutationKey = normalizeString(idempotencyKey) || `sl-message-edit-${randomUUID()}`;
2816
+ if (!sid || typeof text !== "string" || !text.trim() || (!replyId && !messageId && !validSequence) ||
2817
+ (revision !== null && (!Number.isSafeInteger(revision) || revision <= 0)) || mutationKey.length > 128) {
2818
+ return { ok: false, reason: "invalid_input" };
2819
+ }
2820
+ if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") return { ok: false, reason: "remote_sync_disabled_env" };
2821
+ const timestamp = Number(nowMs()) || Date.now();
2822
+ if (isCircuitOpen(outboundCircuit, timestamp)) return { ok: false, reason: "circuit_breaker_open" };
2823
+ let auth;
2824
+ try { auth = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false }); }
2825
+ catch { return { ok: false, reason: "no_session" }; }
2826
+ if (!auth?.token) return { ok: false, reason: "not_authenticated" };
2827
+ const base = `${resolveApiBaseUrl(auth)}/api/v1/sessions/${encodeURIComponent(sid)}`;
2828
+ const headers = { Authorization: `Bearer ${auth.token}` };
2829
+ const failure = (response, payload) => {
2830
+ const code = normalizeString(payload?.detail?.code || payload?.error?.code || payload?.code);
2831
+ // Client/schema/ownership conflicts are terminal and must not poison unrelated writes.
2832
+ if (!response || response.status >= 500) recordCircuitFailure(outboundCircuit, timestamp);
2833
+ return { ok: false, reason: code || `api_${response?.status || "no_response"}`, status: response?.status || null };
2834
+ };
2835
+ try {
2836
+ // Sequence resolution and omitted-revision lookup are one authenticated GET.
2837
+ if ((!replyId && !messageId) || revision === null) {
2838
+ const resource = replyId ? `replies/${encodeURIComponent(replyId)}` : messageId
2839
+ ? `messages/${encodeURIComponent(messageId)}` : `messages/by-sequence/${sequence}`;
2840
+ const authorQuery = normalizeString(agentId) ? `?agentId=${encodeURIComponent(normalizeString(agentId))}` : "";
2841
+ const { response, payload } = await fetchJsonWithFullTimeout(`${base}/${resource}${authorQuery}`, { method: "GET", headers, signal }, timeoutMs, fetchImpl, { readErrorBody: true });
2842
+ if (!response?.ok || payload?.ok === false) return failure(response, payload);
2843
+ const current = replyId ? payload?.reply : payload?.event;
2844
+ const fetchedRevision = Number(current?.messageRevision ?? 1);
2845
+ if (!current?.id || (replyId && current.id !== replyId) || !Number.isSafeInteger(fetchedRevision) || fetchedRevision <= 0) return { ok: false, reason: "invalid_edit_target" };
2846
+ if (current.canEdit === false) return { ok: false, reason: "MESSAGE_EDIT_FORBIDDEN", status: 403 };
2847
+ if (!replyId) messageId = current.id;
2848
+ if (revision === null) revision = fetchedRevision;
2849
+ }
2850
+ const resource = replyId ? `replies/${encodeURIComponent(replyId)}` : `messages/${encodeURIComponent(messageId)}`;
2851
+ const body = { text, expectedRevision: revision, ...(normalizeString(agentId) ? { agentId: normalizeString(agentId) } : {}) };
2852
+ const { response, payload } = await fetchJsonWithFullTimeout(`${base}/${resource}`, {
2853
+ method: "PATCH",
2854
+ headers: {
2855
+ ...createSessionMutationHeaders({ bearerToken: auth.token, sessionId: sid, routeId: replyId
2856
+ ? "PATCH /api/v1/sessions/{session_id}/replies/{action_id}"
2857
+ : "PATCH /api/v1/sessions/{session_id}/messages/{message_id}", idempotencyKey: mutationKey }),
2858
+ "Content-Type": "application/json", "Idempotency-Key": mutationKey,
2859
+ },
2860
+ body: JSON.stringify(body),
2861
+ signal,
2862
+ }, timeoutMs, fetchImpl, { readErrorBody: true });
2863
+ if (!response?.ok || payload?.ok !== true) return failure(response, payload);
2864
+ const current = replyId ? payload.reply : payload.event;
2865
+ const currentRevision = Number(current?.messageRevision ?? 1);
2866
+ if (!current?.id || current.id !== (replyId || messageId) || !Number.isSafeInteger(currentRevision) || currentRevision <= 0) return { ok: false, reason: "invalid_edit_response" };
2867
+ recordCircuitSuccess(outboundCircuit);
2868
+ return { ok: true, changed: Boolean(payload.changed), replayed: Boolean(payload.replayed), event: payload.event || null, reply: payload.reply || null, notification: payload.notification || null };
2869
+ } catch (error) {
2870
+ recordCircuitFailure(outboundCircuit, timestamp);
2871
+ return { ok: false, reason: normalizeString(error?.message) || "message_edit_failed" };
2872
+ }
2873
+ }
2874
+
2139
2875
  export async function createSessionMessageAction(
2140
2876
  sessionId,
2141
2877
  {
@@ -2159,6 +2895,37 @@ export async function createSessionMessageAction(
2159
2895
  if (!normalizedSessionId || !normalizedActionType) {
2160
2896
  return { ok: false, reason: "invalid_input", action: null };
2161
2897
  }
2898
+ if (normalizedActionType === "view") {
2899
+ const actorAgentId = normalizeString(
2900
+ metadata?.agentId ||
2901
+ metadata?.agent_id ||
2902
+ metadata?.actorAgentId ||
2903
+ metadata?.actor_agent_id,
2904
+ );
2905
+ const readCursor = await updateSessionReadCursor(normalizedSessionId, {
2906
+ targetPath,
2907
+ targetSequenceId,
2908
+ targetCursor,
2909
+ agentId: actorAgentId,
2910
+ timeoutMs,
2911
+ signal,
2912
+ resolveAuthSession,
2913
+ fetchImpl,
2914
+ nowMs,
2915
+ });
2916
+ return {
2917
+ ...readCursor,
2918
+ duplicate: false,
2919
+ readCursor: readCursor.ok,
2920
+ action: readCursor.ok
2921
+ ? {
2922
+ actionType: "view",
2923
+ targetSequenceId: readCursor.lastReadSequenceId || targetSequenceId || null,
2924
+ actorId: actorAgentId || null,
2925
+ }
2926
+ : null,
2927
+ };
2928
+ }
2162
2929
  if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
2163
2930
  return { ok: false, reason: "remote_sync_disabled_env", action: null };
2164
2931
  }