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
@@ -42,11 +42,20 @@ import {
42
42
  } from "../session/daemon-spawn.js";
43
43
  import { listRuntimeRuns } from "../session/runtime-bridge.js";
44
44
  import {
45
+ guardFileLeases,
45
46
  listFileLocks,
46
47
  lockFile,
47
48
  releaseFileLocksForAgent,
49
+ renewFileLease,
48
50
  unlockFile,
49
51
  } from "../session/file-locks.js";
52
+ import {
53
+ formatFileLeaseDenialSummary,
54
+ installFileLeaseIntegrations,
55
+ runFileLeaseGuardHook,
56
+ safeFileLeaseErrorMessage,
57
+ uninstallFileLeaseIntegrations,
58
+ } from "../session/file-lease-integrations.js";
50
59
  import {
51
60
  injectSessionGuides,
52
61
  setupSessionGuides,
@@ -64,7 +73,7 @@ import {
64
73
  refreshSessionCacheForRemoteActivity,
65
74
  updateSessionTitle,
66
75
  } from "../session/store.js";
67
- import { fetchSessionListeners, formatListenerLine, summarizeListeners } from "../session/listeners.js";
76
+ import { fetchSessionListeners, formatListenerLine } from "../session/listeners.js";
68
77
  import {
69
78
  getListenerProcessStatus,
70
79
  removeListenerPidRecord,
@@ -82,6 +91,27 @@ import {
82
91
  normalizeSessionOnboarding,
83
92
  writeSessionOnboardingBrief,
84
93
  } from "../session/invitations.js";
94
+ import {
95
+ cancelAdmission,
96
+ fetchOwnAdmissionReceipt,
97
+ parseScope as parseAdmissionScope,
98
+ parseTtlSeconds as parseAdmissionTtlSeconds,
99
+ runAdmissionJoin,
100
+ } from "../session/admission.js";
101
+ import {
102
+ assertCanonicalAgentId,
103
+ canonicalAgentId,
104
+ currentAdmittedAgent,
105
+ withAgentAdmission,
106
+ } from "../session/admission-auth.js";
107
+ import {
108
+ claimTicket,
109
+ listTickets,
110
+ releaseTicketLease,
111
+ renewTicketLease,
112
+ submitTicket,
113
+ ticketEvents,
114
+ } from "../session/tickets.js";
85
115
  import { postFirstSentiMessage } from "../session/first-message.js";
86
116
  import { createListenerHostWake } from "../session/wake/listen-wake.js";
87
117
  import { appendToStream, readStream, tailStream } from "../session/stream.js";
@@ -95,6 +125,8 @@ import {
95
125
  import { readSessionPreview } from "../session/preview.js";
96
126
  import {
97
127
  createSessionMessageAction,
128
+ editSessionMessage,
129
+ fetchJsonWithFullTimeout,
98
130
  fetchSessionUsageLedger,
99
131
  fetchSessionPinnedMessages,
100
132
  listSessionMessageActions,
@@ -103,11 +135,16 @@ import {
103
135
  pollHumanMessages,
104
136
  pollSessionEvents,
105
137
  pollSessionEventsBefore,
138
+ requestSessionListenerStop,
139
+ renewSessionPresence,
106
140
  searchSessionEvents,
107
141
  syncSessionEventToApi,
108
142
  syncSessionMetadataToApi,
143
+ updateSessionReadCursor,
109
144
  } from "../session/sync.js";
110
145
  import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
146
+ import { messageRevision, replyRevisionEvent, sessionMessageRoutingEvent } from "../session/message-edits.js";
147
+ import { runSessionRecall } from "../session/recall/index.js";
111
148
  import { mergeLiveSources } from "../session/live-source.js";
112
149
  import { listenSessionEvents } from "../session/listener.js";
113
150
  import { SESSION_LIVE_SUCCESS_TIPS } from "../session/coordination-guidance.js";
@@ -442,7 +479,9 @@ async function confirmSessionEventVisible(
442
479
  }
443
480
  lastReason = "not_visible";
444
481
  const nextCursor = normalizeString(result.cursor);
445
- if (!events.length || !nextCursor || nextCursor === pageCursor) {
482
+ const scanExhausted = result.scanExhausted ?? result.scan_exhausted;
483
+ const emptyScanCanContinue = !events.length && scanExhausted === false;
484
+ if (!nextCursor || nextCursor === pageCursor || (!events.length && !emptyScanCanContinue)) {
446
485
  break;
447
486
  }
448
487
  pageCursor = nextCursor;
@@ -558,11 +597,6 @@ const SESSION_MESSAGE_ACTION_DESCRIPTIONS = Object.freeze([
558
597
  command: "sl session action <id> disregard --target-sequence <n>",
559
598
  description: "Mark a message as intentionally ignored or superseded.",
560
599
  },
561
- {
562
- type: "view",
563
- command: "sl session view <id> <sequence>",
564
- description: "Manually backfill a read receipt for a target message; remote reads record views automatically.",
565
- },
566
600
  ]);
567
601
 
568
602
  function normalizeSessionMessageActionType(value) {
@@ -654,6 +688,11 @@ function buildSessionActionEvent(sessionId, action = {}) {
654
688
  },
655
689
  sessionId,
656
690
  ts: actionCreatedAt(action),
691
+ messageRevision: messageRevision(action),
692
+ editedAt: action.editedAt,
693
+ editedBy: action.editedBy,
694
+ canEdit: action.canEdit,
695
+ revisionEvidence: action.revisionEvidence,
657
696
  payload: {
658
697
  actionId: id,
659
698
  actionType,
@@ -661,6 +700,7 @@ function buildSessionActionEvent(sessionId, action = {}) {
661
700
  targetCursor: actionTargetCursor(action) || null,
662
701
  targetActionId: actionTargetActionId(action) || null,
663
702
  note: normalizeString(action.note) || null,
703
+ metadata: action.metadata && typeof action.metadata === "object" ? action.metadata : undefined,
664
704
  message: actionDisplayMessage(action),
665
705
  source: "session_action",
666
706
  },
@@ -960,12 +1000,12 @@ function normalizeAutoViewTimeoutMs(value) {
960
1000
  return Math.max(50, Math.min(5_000, Math.floor(parsed)));
961
1001
  }
962
1002
 
963
- async function writeSessionReadViews(sessionId, writeTargets = [], {
1003
+ async function writeSessionReadCursor(sessionId, target = null, {
964
1004
  targetPath,
965
1005
  agentId,
966
1006
  timeoutMs = SESSION_READ_AUTO_VIEW_TIMEOUT_MS,
967
1007
  } = {}) {
968
- if (!Array.isArray(writeTargets) || writeTargets.length === 0) {
1008
+ if (!target) {
969
1009
  return;
970
1010
  }
971
1011
  const totalTimeoutMs = normalizeAutoViewTimeoutMs(timeoutMs);
@@ -983,50 +1023,31 @@ async function writeSessionReadViews(sessionId, writeTargets = [], {
983
1023
  }
984
1024
 
985
1025
  try {
986
- for (const target of writeTargets) {
987
- if (controller.signal.aborted) {
988
- break;
989
- }
990
- const elapsedMs = Math.max(0, Date.now() - startedAt);
991
- const remainingMs = Math.max(1, totalTimeoutMs - elapsedMs);
992
- const result = await createSessionMessageAction(sessionId, {
993
- actionType: "view",
994
- targetPath,
995
- targetSequenceId: target.targetSequenceId,
996
- targetCursor: target.targetCursor,
997
- metadata: {
998
- source: "cli_read",
999
- agentId,
1000
- },
1001
- idempotencyKey: defaultActionIdempotencyKey({
1002
- actionType: "view",
1003
- targetSequenceId: target.targetSequenceId,
1004
- targetCursor: target.targetCursor,
1005
- agentId,
1006
- }),
1007
- timeoutMs: remainingMs,
1008
- signal: controller.signal,
1009
- });
1010
- if (!result?.ok) {
1011
- break;
1012
- }
1013
- }
1026
+ const elapsedMs = Math.max(0, Date.now() - startedAt);
1027
+ const remainingMs = Math.max(1, totalTimeoutMs - elapsedMs);
1028
+ await updateSessionReadCursor(sessionId, {
1029
+ targetPath,
1030
+ targetSequenceId: target.targetSequenceId,
1031
+ targetCursor: target.targetCursor,
1032
+ agentId,
1033
+ timeoutMs: remainingMs,
1034
+ signal: controller.signal,
1035
+ });
1014
1036
  } finally {
1015
1037
  clearTimeout(timeoutHandle);
1016
1038
  }
1017
1039
  }
1018
1040
 
1019
- function recordSessionReadViews(sessionId, events = [], {
1041
+ function recordSessionReadCursor(sessionId, events = [], {
1020
1042
  targetPath,
1021
1043
  agentId,
1022
1044
  enabled = false,
1023
- maxTargets = 50,
1024
1045
  timeoutMs = SESSION_READ_AUTO_VIEW_TIMEOUT_MS,
1025
1046
  } = {}) {
1026
- const maxAutoViewTargets = Math.max(0, Math.min(200, Number(maxTargets) || 0));
1047
+ const normalizedAgentId = normalizeString(agentId);
1027
1048
  const summary = {
1028
1049
  enabled: Boolean(enabled),
1029
- agentId: normalizeString(agentId) || "cli-user",
1050
+ agentId: normalizedAgentId || "cli-user",
1030
1051
  targetCount: 0,
1031
1052
  attempted: 0,
1032
1053
  recorded: 0,
@@ -1042,26 +1063,22 @@ function recordSessionReadViews(sessionId, events = [], {
1042
1063
  return summary;
1043
1064
  }
1044
1065
 
1045
- const seenTargets = new Set();
1046
- const targets = [];
1066
+ let target = null;
1047
1067
  for (const event of Array.isArray(events) ? events : []) {
1048
- const target = sessionReadViewTarget(event);
1049
- if (!target || seenTargets.has(target.key)) {
1050
- continue;
1068
+ const candidate = sessionReadViewTarget(event);
1069
+ if (!candidate) continue;
1070
+ summary.targetCount += 1;
1071
+ if (
1072
+ !target ||
1073
+ Number(candidate.targetSequenceId || 0) >= Number(target.targetSequenceId || 0)
1074
+ ) {
1075
+ target = candidate;
1051
1076
  }
1052
- seenTargets.add(target.key);
1053
- targets.push(target);
1054
1077
  }
1055
- summary.targetCount = targets.length;
1078
+ summary.skipped = Math.max(0, summary.targetCount - (target ? 1 : 0));
1079
+ summary.queued = target ? 1 : 0;
1056
1080
 
1057
- const writeTargets = maxAutoViewTargets > 0 ? targets.slice(-maxAutoViewTargets) : [];
1058
- summary.skipped = Math.max(0, targets.length - writeTargets.length);
1059
- summary.queued = writeTargets.length;
1060
- if (summary.skipped > 0) {
1061
- summary.reason = "target_cap_reached";
1062
- }
1063
-
1064
- if (writeTargets.length === 0) {
1081
+ if (!target) {
1065
1082
  if (!summary.reason) {
1066
1083
  summary.reason = "no_targets";
1067
1084
  }
@@ -1069,13 +1086,13 @@ function recordSessionReadViews(sessionId, events = [], {
1069
1086
  }
1070
1087
 
1071
1088
  summary.background = true;
1072
- if (!summary.reason) {
1073
- summary.reason = "queued_best_effort";
1074
- }
1089
+ summary.reason = "queued_monotonic_upsert";
1075
1090
 
1076
- void writeSessionReadViews(sessionId, writeTargets, {
1091
+ void writeSessionReadCursor(sessionId, target, {
1077
1092
  targetPath,
1078
- agentId: summary.agentId,
1093
+ // Omit a placeholder identity so the server can bind human reads to the
1094
+ // authenticated principal. Explicit agent reads still carry their grant.
1095
+ agentId: normalizedAgentId,
1079
1096
  timeoutMs,
1080
1097
  }).catch(() => {
1081
1098
  // Best-effort view receipts must never affect transcript rendering.
@@ -1412,19 +1429,22 @@ async function findReusableSessionCandidate({
1412
1429
  // can exercise the local materialization path without a real API.
1413
1430
  async function fetchRemoteSessionDetail(endpoint, headers) {
1414
1431
  let response;
1432
+ let payload;
1415
1433
  try {
1416
- response = await fetch(endpoint, { method: "GET", headers });
1434
+ ({ response, payload } = await fetchJsonWithFullTimeout(
1435
+ endpoint, { method: "GET", headers, redirect: "error" }, 2_000,
1436
+ ));
1417
1437
  } catch (err) {
1418
1438
  return {
1419
1439
  ok: false,
1420
1440
  response: null,
1421
1441
  status: 0,
1422
- reason: normalizeString(err?.message) || "fetch_failed",
1442
+ reason: err?.name === "AbortError" ? "request_timeout" : (normalizeString(err?.message) || "fetch_failed"),
1423
1443
  retryable: true,
1424
1444
  };
1425
1445
  }
1426
1446
  if (response && response.ok) {
1427
- return { ok: true, response, status: response.status, reason: "", retryable: false };
1447
+ return { ok: true, payload, status: response.status, reason: "", retryable: false };
1428
1448
  }
1429
1449
  if (!response) {
1430
1450
  return { ok: false, response: null, status: 0, reason: "no_response", retryable: true };
@@ -1439,8 +1459,7 @@ async function fetchRemoteSessionDetail(endpoint, headers) {
1439
1459
  };
1440
1460
  }
1441
1461
 
1442
- async function parseRemoteSessionDetailResponse(response) {
1443
- const body = await response.json().catch(() => ({}));
1462
+ function parseRemoteSessionDetailResponse(body) {
1444
1463
  return body && body.session && typeof body.session === "object"
1445
1464
  ? body.session
1446
1465
  : body && typeof body === "object"
@@ -1483,7 +1502,7 @@ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
1483
1502
  return {
1484
1503
  ok: true,
1485
1504
  source: "singleton",
1486
- session: await parseRemoteSessionDetailResponse(detail.response),
1505
+ session: parseRemoteSessionDetailResponse(detail.payload),
1487
1506
  status: detail.status,
1488
1507
  };
1489
1508
  }
@@ -1777,7 +1796,13 @@ export async function ensureWorkspaceSession({
1777
1796
  );
1778
1797
  let titleSync = null;
1779
1798
  if (shouldPushTitle) {
1780
- titleSync = await pushSessionTitleToApi(created.sessionId, effectiveTitle, { targetPath });
1799
+ titleSync = await pushSessionTitleToApi(created.sessionId, effectiveTitle, {
1800
+ targetPath,
1801
+ // Startup uses the already validated credential; rotation and Retry-After
1802
+ // sleeps belong to explicit auth/title operations, not optional startup.
1803
+ autoRotate: false,
1804
+ maxRetries: 0,
1805
+ });
1781
1806
  }
1782
1807
 
1783
1808
  return {
@@ -1796,11 +1821,8 @@ export async function ensureWorkspaceSession({
1796
1821
  }
1797
1822
 
1798
1823
  function normalizeAgentId(value, fallbackValue = "cli-user") {
1799
- const normalized = normalizeString(value)
1800
- .toLowerCase()
1801
- .replace(/[^a-z0-9._-]+/g, "-")
1802
- .replace(/^-+|-+$/g, "");
1803
- return normalized || fallbackValue;
1824
+ // One canonicalisation for authorisation and execution (see admission-auth.js).
1825
+ return canonicalAgentId(value) || fallbackValue;
1804
1826
  }
1805
1827
 
1806
1828
  function canPublishListenerPresence(agentId) {
@@ -1855,13 +1877,6 @@ function normalizeSessionObservationText(rawValue, fieldName, maxLength) {
1855
1877
  return value;
1856
1878
  }
1857
1879
 
1858
- function listenerLifecycleEventName(type = "") {
1859
- const normalized = normalizeString(type).toLowerCase();
1860
- if (normalized === "started") return "session_listener_started";
1861
- if (normalized === "stopped") return "session_listener_stopped";
1862
- return "session_listener_heartbeat";
1863
- }
1864
-
1865
1880
  function compactPayload(record = {}) {
1866
1881
  return Object.fromEntries(
1867
1882
  Object.entries(record).filter(([, value]) => value !== undefined)
@@ -1948,11 +1963,6 @@ async function publishListenerPresenceEvent({
1948
1963
  lifecycle = {},
1949
1964
  } = {}) {
1950
1965
  const normalizedType = normalizeString(lifecycle.type) || "heartbeat";
1951
- const eventName = listenerLifecycleEventName(normalizedType);
1952
- const idempotencyStep = listenerLifecycleIdempotencyStep({
1953
- ...lifecycle,
1954
- type: normalizedType,
1955
- });
1956
1966
  const identity = inferSessionAgentIdentity({
1957
1967
  agentId,
1958
1968
  model: agentModel,
@@ -1960,49 +1970,23 @@ async function publishListenerPresenceEvent({
1960
1970
  provider,
1961
1971
  clientKind,
1962
1972
  });
1963
- const event = createAgentEvent({
1964
- event: eventName,
1965
- sessionId,
1966
- agent: {
1967
- id: agentId,
1968
- model: normalizeString(identity.model) || "cli",
1969
- role: "listener",
1970
- displayName: normalizeString(identity.displayName) || agentId,
1971
- provider: normalizeString(identity.provider) || undefined,
1972
- clientKind: normalizeString(identity.clientKind) || "cli",
1973
- },
1974
- eventId: `session-listener-${listenerId}-${normalizedType}-${idempotencyStep}`,
1975
- idempotencyToken: `session-listener:${listenerId}:${normalizedType}:${idempotencyStep}`,
1976
- payload: compactPayload({
1977
- source: "session_listen",
1978
- listenerId,
1979
- lifecycle: normalizedType,
1980
- state: normalizeString(lifecycle.state) || normalizedType,
1981
- active: lifecycle.active,
1982
- cursor: lifecycle.cursor || null,
1983
- cursorSuffix: lifecycle.cursorSuffix,
1984
- cursorSource: lifecycle.cursorSource,
1985
- pollCount: lifecycle.pollCount,
1986
- heartbeatCount: lifecycle.heartbeatCount,
1987
- matched: lifecycle.matched,
1988
- emitted: lifecycle.emitted,
1989
- persistedCursor: lifecycle.persistedCursor,
1990
- idleIntervalSeconds: lifecycle.idleIntervalSeconds,
1991
- activeIntervalSeconds: lifecycle.activeIntervalSeconds,
1992
- activeWindowSeconds: lifecycle.activeWindowSeconds,
1993
- presenceIntervalSeconds: lifecycle.presenceIntervalSeconds,
1994
- presenceKeepaliveSeconds: lifecycle.presenceKeepaliveSeconds,
1995
- lastHumanActivityAt: lifecycle.lastHumanActivityAt,
1996
- lastSleepMs: lifecycle.lastSleepMs,
1997
- nextPollMs: lifecycle.nextPollMs,
1998
- reason: lifecycle.reason || null,
1999
- startedAt: lifecycle.startedAt,
2000
- stoppedAt: lifecycle.stoppedAt,
2001
- aborted: lifecycle.aborted,
2002
- stopping: lifecycle.stopping,
2003
- }),
1973
+ return renewSessionPresence(sessionId, {
1974
+ targetPath,
1975
+ agentId,
1976
+ state:
1977
+ normalizedType === "stopped"
1978
+ ? "stopped"
1979
+ : normalizeString(lifecycle.state) || normalizedType,
1980
+ listenerId,
1981
+ model: normalizeString(identity.model) || "cli",
1982
+ displayName: normalizeString(identity.displayName) || agentId,
1983
+ provider: normalizeString(identity.provider),
1984
+ clientKind: normalizeString(identity.clientKind) || "cli",
1985
+ observedAt:
1986
+ lifecycle.stoppedAt ||
1987
+ lifecycle.startedAt ||
1988
+ new Date().toISOString(),
2004
1989
  });
2005
- return syncSessionEventToApi(sessionId, event, { targetPath });
2006
1990
  }
2007
1991
 
2008
1992
  function formatListenerCatchupNotice(catchup = {}) {
@@ -2400,17 +2384,6 @@ async function resolveSessionAgentEnvelope(
2400
2384
  return Object.fromEntries(Object.entries(envelope).filter(([, value]) => value !== undefined));
2401
2385
  }
2402
2386
 
2403
- // Builds the lock/unlock say-convention directive the session daemon parses
2404
- // into the authoritative file-lock registry. Kept pure + exported for testing.
2405
- export function buildSessionLockDirective(verb, file, intent = "") {
2406
- const normalizedFile = normalizeString(file);
2407
- const normalizedIntent = normalizeString(intent);
2408
- if (verb === "unlock") {
2409
- return `unlock: ${normalizedFile} - ${normalizedIntent || "done"}`;
2410
- }
2411
- return normalizedIntent ? `lock: ${normalizedFile} - ${normalizedIntent}` : `lock: ${normalizedFile}`;
2412
- }
2413
-
2414
2387
  async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
2415
2388
  const normalizedItems = Array.isArray(items) ? items : [];
2416
2389
  const normalizedConcurrency = Math.max(
@@ -2446,6 +2419,11 @@ function formatEventLine(event = {}) {
2446
2419
  const ts = normalizeString(event.ts || event.timestamp);
2447
2420
  const type = normalizeString(event.event || event.type) || "event";
2448
2421
  const agentId = normalizeString(event.agent?.id || event.agentId || "unknown");
2422
+ if (type === "session_message_edited") {
2423
+ const routed = sessionMessageRoutingEvent(event);
2424
+ const target = event.payload?.targetActionId ? `reply ${event.payload.targetActionId}` : `#${event.payload?.targetSequenceId || "?"}`;
2425
+ return `${ts} ${agentId} ${type} ${target} revision ${event.payload?.messageRevision || "?"}: ${normalizeString(routed.payload?.message || routed.payload?.text || "")}`;
2426
+ }
2449
2427
  const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
2450
2428
  if (type === "session_action" || type === "session_reply" || type === "session_reaction") {
2451
2429
  const actionType = normalizeString(payload.actionType) || type.replace(/^session_/, "");
@@ -2866,6 +2844,43 @@ async function emitAgentKilledEvent(sessionId, agentId, {
2866
2844
  return event;
2867
2845
  }
2868
2846
 
2847
+ function collectOption(value, previous) {
2848
+ return [...(previous || []), value];
2849
+ }
2850
+
2851
+ /**
2852
+ * Wrap a command action so it runs as its agent: inside that agent's admission when
2853
+ * this machine holds one (every session API call then uses the admission credential
2854
+ * and never the human token), on the legacy path when it holds none, and refused when
2855
+ * what it holds is expired or unusable. `resolveIds` names the (session, agent) pair
2856
+ * exactly the way the action itself resolves it.
2857
+ */
2858
+ function asAdmittedAgent(resolveIds, action) {
2859
+ return async (...args) => {
2860
+ const { sessionId, agentId } = await resolveIds(...args);
2861
+ return withAgentAdmission(sessionId, agentId, () => action(...args));
2862
+ };
2863
+ }
2864
+
2865
+ async function sessionSayAgent(sessionId, _messageParts, options) {
2866
+ const sid = normalizeString(sessionId);
2867
+ const identity = await resolveSessionSayIdentity({
2868
+ sessionId: sid,
2869
+ agentId: options.agent,
2870
+ targetPath: path.resolve(process.cwd(), String(options.path || ".")),
2871
+ });
2872
+ return { sessionId: sid, agentId: identity.agentId };
2873
+ }
2874
+
2875
+ async function sessionReadAgent(sessionId, options) {
2876
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
2877
+ return { sessionId: normalizeString(sessionId), agentId: await defaultAgentId(options.agent, targetPath) };
2878
+ }
2879
+
2880
+ async function sessionListenAgent(options) {
2881
+ return { sessionId: resolveSessionIdOption(options), agentId: normalizeAgentId(options.agent, "cli-user") };
2882
+ }
2883
+
2869
2884
  export function registerSessionCommand(program) {
2870
2885
  const session = program
2871
2886
  .command("session")
@@ -2980,8 +2995,9 @@ export function registerSessionCommand(program) {
2980
2995
  };
2981
2996
 
2982
2997
  // Best-effort admin visibility sync. Session creation remains local-first.
2998
+ let metadataSync = Promise.resolve(null);
2983
2999
  if (remoteSync.attempted) {
2984
- void syncSessionMetadataToApi(created.sessionId, {
3000
+ metadataSync = syncSessionMetadataToApi(created.sessionId, {
2985
3001
  targetPath,
2986
3002
  sessionId: created.sessionId,
2987
3003
  status: created.status,
@@ -2991,7 +3007,7 @@ export function registerSessionCommand(program) {
2991
3007
  ttlSeconds,
2992
3008
  template: created.template,
2993
3009
  codebaseContext: created.codebaseContext,
2994
- }).catch(() => {});
3010
+ }, { targetPath, timeoutMs: 2_000 }).catch(() => null);
2995
3011
  }
2996
3012
 
2997
3013
  // Make the session managed by default: spawn the Senti daemon as a
@@ -3028,6 +3044,14 @@ export function registerSessionCommand(program) {
3028
3044
  }).catch((error) => ({ posted: false, reason: normalizeString(error?.message) || "error" }));
3029
3045
  }
3030
3046
  payload.firstMessage = firstMessage;
3047
+ // Own the request lifecycle; do not leave a referenced fetch running
3048
+ // after emitting the single JSON document (even with --no-daemon).
3049
+ const metadataResult = await metadataSync;
3050
+ if (remoteSync.attempted) {
3051
+ remoteSync.status = metadataResult?.synced ? "synced" : "degraded";
3052
+ remoteSync.metadataSynced = Boolean(metadataResult?.synced);
3053
+ remoteSync.reason = metadataResult?.synced ? "metadata_acknowledged" : (metadataResult?.reason || "metadata_sync_failed");
3054
+ }
3031
3055
 
3032
3056
  if (shouldEmitJson(options, command)) {
3033
3057
  console.log(JSON.stringify(payload, null, 2));
@@ -3368,7 +3392,7 @@ export function registerSessionCommand(program) {
3368
3392
  .option("--name <name>", "Agent display name (legacy alias for --agent)")
3369
3393
  .option(
3370
3394
  "--agent <id>",
3371
- "Granted agent id to emit an agent_join event as. Behaves like post-agent for human/placeholder ids — those are recorded in the local registry only.",
3395
+ "Granted agent id to register in the participant roster. Join state and onboarding are returned locally and never appended to the transcript.",
3372
3396
  )
3373
3397
  .option("--invite-token <token>", "Invitation token to accept before joining the session")
3374
3398
  .option("--seat-key <key>", "Reserved session seat key to claim while accepting an invite")
@@ -3376,6 +3400,21 @@ export function registerSessionCommand(program) {
3376
3400
  .option("--role <role>", "Agent role: coder, reviewer, tester, observer", "coder")
3377
3401
  .option("--model <model>", "Agent model hint", "cli")
3378
3402
  .option("--path <path>", "Workspace path for the session", ".")
3403
+ .option(
3404
+ "--goal <text>",
3405
+ "Request goal-and-scope admission: say what this agent is coming to do. A room owner approves it on the web; the agent then receives its own scoped, expiring identity.",
3406
+ )
3407
+ .option("--deliverable <text>", "An output this agent will produce (repeatable)", collectOption, [])
3408
+ .option("--stop-when <text>", "A condition that ends this agent's work (repeatable)", collectOption, [])
3409
+ .option(
3410
+ "--scope <actions>",
3411
+ "Comma-separated actions to request: session.read, session.post, session.react, tickets.read, tickets.work",
3412
+ )
3413
+ .option("--ttl <duration>", "How long the grant should last, e.g. 90m or 2h (5m..24h)", "2h")
3414
+ .option("--provider <name>", "Model provider hint for the admission request")
3415
+ .option("--no-wait", "Request admission and return immediately with the approval URL")
3416
+ .option("--wait-seconds <n>", "How long to wait for approval before returning pending", "900")
3417
+ .option("--cancel-admission", "Cancel this workspace's pending admission request for the session")
3379
3418
  .option("--json", "Emit machine-readable output")
3380
3419
  .action(async (sessionId, options, command) => {
3381
3420
  const normalizedSessionId = normalizeString(sessionId);
@@ -3386,6 +3425,34 @@ export function registerSessionCommand(program) {
3386
3425
  const explicitAgent = normalizeString(options.agent);
3387
3426
  const legacyName = normalizeString(options.name);
3388
3427
  const inviteToken = normalizeString(options.inviteToken);
3428
+ const emitJson = shouldEmitJson(options, command);
3429
+ // ONE requested identity for the whole join, validated before any side effect.
3430
+ // --name is the identity only when --agent is absent (its legacy alias); beside an
3431
+ // explicit --agent it is a free-text display label. Either way the identity is
3432
+ // used in its canonical spelling from here on, so the admission, its receipt,
3433
+ // the local registration and every later implicit command name the same agent.
3434
+ if (!explicitAgent) assertCanonicalAgentId(legacyName, "--name");
3435
+ const requestedAgent = canonicalAgentId(explicitAgent || legacyName);
3436
+ if (options.cancelAdmission) {
3437
+ const cancelled = await cancelAdmission(normalizedSessionId, {
3438
+ agentId: requestedAgent,
3439
+ targetPath,
3440
+ });
3441
+ const payload = {
3442
+ command: "session join",
3443
+ joined: false,
3444
+ sessionId: normalizedSessionId,
3445
+ admission: {
3446
+ status: cancelled?.status || "cancelled",
3447
+ admissionId: cancelled?.admissionId || null,
3448
+ },
3449
+ };
3450
+ if (emitJson) console.log(JSON.stringify(payload, null, 2));
3451
+ else console.log(`Admission request ${payload.admission.admissionId || ""} cancelled.`);
3452
+ return;
3453
+ }
3454
+ // Membership first. Only a member may ask for an agent to be admitted, so an
3455
+ // invitation is accepted BEFORE the admission request, never after.
3389
3456
  let invitationAccept = null;
3390
3457
  let invitationAcceptResult = null;
3391
3458
  if (inviteToken) {
@@ -3393,7 +3460,7 @@ export function registerSessionCommand(program) {
3393
3460
  targetPath,
3394
3461
  invitationToken: inviteToken,
3395
3462
  seatKey: options.seatKey,
3396
- agentId: explicitAgent || legacyName,
3463
+ agentId: requestedAgent,
3397
3464
  idempotencyKey: options.idempotencyKey,
3398
3465
  });
3399
3466
  invitationAcceptResult =
@@ -3401,6 +3468,55 @@ export function registerSessionCommand(program) {
3401
3468
  ? invitationAccept.result
3402
3469
  : null;
3403
3470
  }
3471
+ let admission = null;
3472
+ if (normalizeString(options.goal)) {
3473
+ const admissionAgent = requestedAgent;
3474
+ if (!admissionAgent) {
3475
+ throw new Error("--goal requires --agent <id>: admission is requested for one named agent.");
3476
+ }
3477
+ const waitSeconds = Number.parseInt(String(options.waitSeconds ?? "900"), 10);
3478
+ admission = await runAdmissionJoin(normalizedSessionId, {
3479
+ agentId: admissionAgent,
3480
+ displayName: legacyName && explicitAgent ? legacyName : "",
3481
+ model: options.model,
3482
+ provider: options.provider,
3483
+ goal: options.goal,
3484
+ deliverables: options.deliverable || [],
3485
+ stopConditions: options.stopWhen || [],
3486
+ actions: parseAdmissionScope(options.scope),
3487
+ ttlSeconds: parseAdmissionTtlSeconds(options.ttl),
3488
+ wait: options.wait !== false,
3489
+ waitTimeoutMs: (Number.isFinite(waitSeconds) && waitSeconds >= 0 ? waitSeconds : 900) * 1000,
3490
+ targetPath,
3491
+ onPending: ({ approveUrl }) => {
3492
+ // stderr, so --json stdout stays a single parseable document.
3493
+ const line = `Waiting for a room owner to approve this agent: ${approveUrl}`;
3494
+ if (emitJson) process.stderr.write(`${line}\n`);
3495
+ else console.log(pc.yellow(line));
3496
+ },
3497
+ });
3498
+ if (admission.status !== "active") {
3499
+ // Only an ACTIVE admission joins. Pending, denied, cancelled or expired never
3500
+ // registers the agent or materializes the room for it.
3501
+ process.exitCode = admission.status === "pending" ? 3 : 4;
3502
+ const payload = {
3503
+ command: "session join",
3504
+ joined: false,
3505
+ sessionId: normalizedSessionId,
3506
+ agentId: admissionAgent,
3507
+ invitationAccepted: Boolean(invitationAcceptResult),
3508
+ admission,
3509
+ };
3510
+ if (emitJson) {
3511
+ console.log(JSON.stringify(payload, null, 2));
3512
+ } else if (admission.status === "pending") {
3513
+ console.log(pc.yellow(`Admission pending. A room owner approves it here: ${admission.approveUrl || "(see the session page)"}`));
3514
+ } else {
3515
+ console.log(pc.red(`Admission ${admission.status}. The agent was not joined.`));
3516
+ }
3517
+ return;
3518
+ }
3519
+ }
3404
3520
 
3405
3521
  // PR #483 contract: verify the session exists and the caller has access
3406
3522
  // BEFORE materializing local cache state. Without this we'd silently
@@ -3412,7 +3528,48 @@ export function registerSessionCommand(program) {
3412
3528
  // before the invite is accepted the user may legitimately receive 403,
3413
3529
  // so --invite-token performs the guarded accept mutation first and this
3414
3530
  // verification proves membership immediately afterward.
3415
- const verification = await verifyRemoteSession(normalizedSessionId, { targetPath });
3531
+ // An admitted agent proves its grant is live with ITS OWN credential, via the
3532
+ // grant's receipt, and never with the human token. A cached "active" is not
3533
+ // reported until this succeeds (a revoked grant is not joined), and nothing
3534
+ // about the room is fetched on the human's authority.
3535
+ const admittedAgentId = admission ? requestedAgent : "";
3536
+ let admissionReceipt = null;
3537
+ if (admission) {
3538
+ try {
3539
+ admissionReceipt = await withAgentAdmission(normalizedSessionId, admittedAgentId, () =>
3540
+ fetchOwnAdmissionReceipt(normalizedSessionId, { targetPath }),
3541
+ );
3542
+ // The grant must be for EXACTLY the identity this join registers, byte for
3543
+ // byte: a receipt for any other spelling is a different agent's authority.
3544
+ const receiptAgent = String(admissionReceipt?.agentId ?? "");
3545
+ if (receiptAgent !== admittedAgentId) {
3546
+ throw new Error(
3547
+ `the admission is for agent "${receiptAgent || "(none)"}", not "${admittedAgentId}"`,
3548
+ );
3549
+ }
3550
+ } catch (error) {
3551
+ process.exitCode = 4;
3552
+ const payload = {
3553
+ command: "session join",
3554
+ joined: false,
3555
+ sessionId: normalizedSessionId,
3556
+ agentId: admittedAgentId,
3557
+ admission: {
3558
+ ...admission,
3559
+ status: "not_accepted",
3560
+ verified: false,
3561
+ reason: error instanceof Error ? error.message : String(error),
3562
+ },
3563
+ };
3564
+ if (emitJson) console.log(JSON.stringify(payload, null, 2));
3565
+ else console.log(pc.red(`The API no longer accepts this agent's admission; it was not joined.`));
3566
+ return;
3567
+ }
3568
+ admission = { ...admission, verified: true, receipt: admissionReceipt };
3569
+ }
3570
+ const verification = admission
3571
+ ? { ok: true, source: "admission_receipt", session: { sessionId: normalizedSessionId } }
3572
+ : await verifyRemoteSession(normalizedSessionId, { targetPath });
3416
3573
  if (!verification.ok) {
3417
3574
  if (verification.status === 404 || verification.reason === "not_found") {
3418
3575
  throw new Error(
@@ -3439,17 +3596,31 @@ export function registerSessionCommand(program) {
3439
3596
  skipRemoteProbe: true,
3440
3597
  remoteSession,
3441
3598
  });
3442
- const joinHydration = await hydrateJoinBriefingContext(normalizedSessionId, {
3443
- targetPath,
3444
- });
3599
+ // History only for a grant that may read it, and then on the admission.
3600
+ const joinHydration = !admission
3601
+ ? await hydrateJoinBriefingContext(normalizedSessionId, { targetPath })
3602
+ : (admissionReceipt?.actions || []).includes("session.read")
3603
+ ? await withAgentAdmission(normalizedSessionId, admittedAgentId, () =>
3604
+ hydrateJoinBriefingContext(normalizedSessionId, { targetPath }),
3605
+ )
3606
+ : { skipped: true, reason: "the grant does not include session.read" };
3445
3607
 
3446
3608
  const acceptedOnboarding = normalizeSessionOnboarding(
3447
3609
  invitationAcceptResult?.onboarding,
3448
3610
  invitationAcceptResult?.claimedSeat,
3449
3611
  );
3450
3612
  const acceptedAgentId = normalizeString(acceptedOnboarding?.agentId);
3451
- const agentSeed = explicitAgent || acceptedAgentId || legacyName;
3613
+ // An admitted join registers the admitted identity and nothing else. Otherwise an
3614
+ // invitation's onboarding agent id is the identity only when none was given, and
3615
+ // it is held to the same canonical rule before anything is registered locally.
3616
+ if (!admission && !explicitAgent && acceptedAgentId) {
3617
+ assertCanonicalAgentId(acceptedAgentId, "the invitation's agent id");
3618
+ }
3619
+ const agentSeed = admission ? admittedAgentId : explicitAgent || acceptedAgentId || legacyName;
3452
3620
  const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
3621
+ if (admission && resolvedAgentId !== admittedAgentId) {
3622
+ throw new Error(`join would register "${resolvedAgentId}", not the admitted agent "${admittedAgentId}".`);
3623
+ }
3453
3624
  const roleWasExplicit = optionWasSetByCli(command, "role");
3454
3625
  const role =
3455
3626
  normalizeString(roleWasExplicit ? options.role : acceptedOnboarding?.role || options.role) ||
@@ -3457,13 +3628,9 @@ export function registerSessionCommand(program) {
3457
3628
  const model = normalizeString(options.model) || "cli";
3458
3629
  const hasConcreteAgentIdentity = Boolean(explicitAgent || acceptedAgentId);
3459
3630
 
3460
- // `registerAgent` already writes the canonical `agent_join` event to the
3461
- // local NDJSON and best-effort relays it to /events via appendToStream
3462
- // → syncSessionEventToApi. That gives us the exact `post-agent` parity
3463
- // the spec calls for when `--agent <granted>` or an accepted reserved
3464
- // seat provides a concrete agent id. We don't double-emit; we record
3465
- // whether a durable agent identity path was used
3466
- // so the JSON output can advertise it to callers (and tests).
3631
+ // Registration updates the local participant projection and returns
3632
+ // onboarding directly to this process. It never writes join, identity,
3633
+ // or coaching rows into the transcript.
3467
3634
  const joined = await registerAgent(normalizedSessionId, {
3468
3635
  targetPath,
3469
3636
  agentId: resolvedAgentId,
@@ -3474,13 +3641,12 @@ export function registerSessionCommand(program) {
3474
3641
  trackProcessExit: false,
3475
3642
  awaitRemoteSync: hasConcreteAgentIdentity,
3476
3643
  });
3477
- const agentJoinRelayed =
3478
- joined.emittedJoinEvent !== false &&
3479
- hasConcreteAgentIdentity &&
3480
- Boolean(resolvedAgentId) &&
3481
- resolvedAgentId !== "cli-user" &&
3482
- resolvedAgentId !== "unknown" &&
3483
- !resolvedAgentId.startsWith("human-");
3644
+ const agentJoinRelayed = false;
3645
+ const localBriefingPayload =
3646
+ joined.onboarding?.contextBriefing?.event?.payload &&
3647
+ typeof joined.onboarding.contextBriefing.event.payload === "object"
3648
+ ? joined.onboarding.contextBriefing.event.payload
3649
+ : null;
3484
3650
  const onboardingBrief = await writeSessionOnboardingBrief(normalizedSessionId, {
3485
3651
  targetPath,
3486
3652
  onboarding: acceptedOnboarding,
@@ -3521,6 +3687,17 @@ export function registerSessionCommand(program) {
3521
3687
  agentCount: Number.isFinite(agentCount) ? agentCount : 0,
3522
3688
  lastActivityAt: lastActivityIso || null,
3523
3689
  agentJoinRelayed,
3690
+ onboardingBriefing: localBriefingPayload
3691
+ ? {
3692
+ forAgent: localBriefingPayload.forAgent || joined.agentId,
3693
+ message: localBriefingPayload.message || "",
3694
+ recap: localBriefingPayload.recap || "",
3695
+ rules: localBriefingPayload.rules || null,
3696
+ generatedAt: localBriefingPayload.generatedAt || null,
3697
+ delivery: "joining_process",
3698
+ persisted: false,
3699
+ }
3700
+ : null,
3524
3701
  invitationAccepted: Boolean(invitationAcceptResult?.ok || invitationAccept),
3525
3702
  invitationAccept: invitationAccept
3526
3703
  ? {
@@ -3536,6 +3713,7 @@ export function registerSessionCommand(program) {
3536
3713
  jsonPath: onboardingBrief.jsonPath,
3537
3714
  }
3538
3715
  : null,
3716
+ admission,
3539
3717
  };
3540
3718
  if (shouldEmitJson(options, command)) {
3541
3719
  console.log(JSON.stringify(payload, null, 2));
@@ -3556,9 +3734,29 @@ export function registerSessionCommand(program) {
3556
3734
  console.log(pc.green(`Invitation accepted${seatLabel}`));
3557
3735
  }
3558
3736
  console.log(pc.gray(`agent=${joined.agentId} role=${joined.role} model=${joined.model}`));
3737
+ if (admission) {
3738
+ if (admission.status === "active") {
3739
+ const expires = new Date(admission.grant.expiresAt * 1000).toISOString();
3740
+ console.log(
3741
+ pc.green(
3742
+ `Admitted${admission.reused ? " (existing grant)" : ""}: ${admission.grant.actions.join(", ")} until ${expires}`,
3743
+ ),
3744
+ );
3745
+ if (admission.identity?.subject) console.log(pc.gray(`identity=${admission.identity.subject}`));
3746
+ console.log(pc.gray(`credential stored at ${admission.credential.storage} (never printed)`));
3747
+ } else if (admission.status === "pending") {
3748
+ console.log(pc.yellow(`Admission pending approval: ${admission.approveUrl}`));
3749
+ console.log(pc.gray("Re-run the same command to resume waiting."));
3750
+ } else {
3751
+ console.log(pc.red(`Admission ${admission.status}.`));
3752
+ }
3753
+ }
3559
3754
  if (payload.onboardingGuide?.markdownPath) {
3560
3755
  console.log(pc.gray(`onboarding=${payload.onboardingGuide.markdownPath}`));
3561
3756
  }
3757
+ if (payload.onboardingBriefing?.recap) {
3758
+ console.log(payload.onboardingBriefing.recap);
3759
+ }
3562
3760
  });
3563
3761
 
3564
3762
  session
@@ -3592,7 +3790,7 @@ export function registerSessionCommand(program) {
3592
3790
  .option("--local-only", "Append only to the local session cache without remote send confirmation")
3593
3791
  .option("--path <path>", "Workspace path for the session", ".")
3594
3792
  .option("--json", "Emit machine-readable output")
3595
- .action(async (sessionId, messageParts, options, command) => {
3793
+ .action(asAdmittedAgent(sessionSayAgent, async (sessionId, messageParts, options, command) => {
3596
3794
  const normalizedSessionId = normalizeString(sessionId);
3597
3795
  if (!normalizedSessionId) {
3598
3796
  throw new Error("session id is required.");
@@ -3720,7 +3918,7 @@ export function registerSessionCommand(program) {
3720
3918
  console.error(pc.yellow(`Identity warning: ${identity.identityWarning}`));
3721
3919
  }
3722
3920
  console.log(formatEventLine(persisted));
3723
- });
3921
+ }));
3724
3922
 
3725
3923
  session
3726
3924
  .command("post-agent <sessionId> <message>")
@@ -4010,7 +4208,20 @@ export function registerSessionCommand(program) {
4010
4208
  console.log(formatEventLine(persisted));
4011
4209
  });
4012
4210
 
4013
- async function runMessageActionCommand({
4211
+ // Every message-action command (react, reply, ack, ...) runs as its authoring
4212
+ // agent: inside that agent's admission when this machine holds one.
4213
+ async function runMessageActionCommand(args = {}) {
4214
+ const sid = normalizeString(args.sessionId);
4215
+ const identity = await resolveMessageActionIdentity({
4216
+ sessionId: sid,
4217
+ optionAgent: args.options?.agent,
4218
+ targetPath: path.resolve(process.cwd(), String(args.options?.path || ".")),
4219
+ env: process.env,
4220
+ });
4221
+ return withAgentAdmission(sid, identity.agentId, () => runMessageActionCommandUnscoped(args));
4222
+ }
4223
+
4224
+ async function runMessageActionCommandUnscoped({
4014
4225
  sessionId,
4015
4226
  actionType,
4016
4227
  options,
@@ -4082,10 +4293,23 @@ export function registerSessionCommand(program) {
4082
4293
  if (!result.ok || !result.action) {
4083
4294
  throw new Error(`Session action failed (${result.reason || "unknown"}).`);
4084
4295
  }
4085
- const actionEvent = buildSessionActionEvent(normalizedSessionId, result.action);
4086
- const localAppend = await appendActionEventIfMissing(normalizedSessionId, actionEvent, {
4087
- targetPath,
4088
- });
4296
+ const isReadCursorProjection = normalizedActionType === "view";
4297
+ const actionEvent = isReadCursorProjection
4298
+ ? null
4299
+ : buildSessionActionEvent(normalizedSessionId, result.action);
4300
+ const localAppend = isReadCursorProjection
4301
+ ? { appended: false, reason: "read_cursor_projection", event: null }
4302
+ : await appendActionEventIfMissing(normalizedSessionId, actionEvent, {
4303
+ targetPath,
4304
+ });
4305
+ const readCursorProjection = isReadCursorProjection
4306
+ ? {
4307
+ updated: Boolean(result.updated),
4308
+ lastReadSequenceId:
4309
+ Number(result.lastReadSequenceId || result.action.targetSequenceId || 0) || null,
4310
+ targetCursor: targetCursor || null,
4311
+ }
4312
+ : null;
4089
4313
  const payload = {
4090
4314
  command: commandName,
4091
4315
  targetPath,
@@ -4098,11 +4322,22 @@ export function registerSessionCommand(program) {
4098
4322
  appended: Boolean(localAppend.appended),
4099
4323
  reason: localAppend.reason || "",
4100
4324
  },
4325
+ ...(readCursorProjection ? { readCursorProjection } : {}),
4101
4326
  };
4102
4327
  if (shouldEmitJson(options, command)) {
4103
4328
  console.log(JSON.stringify(payload, null, 2));
4104
4329
  return payload;
4105
4330
  }
4331
+ if (readCursorProjection) {
4332
+ const targetLabel = readCursorProjection.lastReadSequenceId
4333
+ ? `#${readCursorProjection.lastReadSequenceId}`
4334
+ : readCursorProjection.targetCursor || "target";
4335
+ const status = readCursorProjection.updated ? "Advanced" : "Confirmed";
4336
+ console.log(
4337
+ pc.green(`${status} read cursor through ${targetLabel}; no transcript event appended.`),
4338
+ );
4339
+ return payload;
4340
+ }
4106
4341
  if (localAppend.event || actionEvent) {
4107
4342
  console.log(formatEventLine(localAppend.event || actionEvent));
4108
4343
  } else {
@@ -4217,20 +4452,50 @@ export function registerSessionCommand(program) {
4217
4452
  });
4218
4453
 
4219
4454
  session
4220
- .command("view <sessionId> <targetSequenceId>")
4221
- .description("Manually backfill a read receipt for a target session event")
4222
- .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
4223
- .option("--idempotency-key <key>", "Explicit idempotency key")
4455
+ .command("edit <sessionId> <target> <message...>")
4456
+ .description("Edit your message by sequence or threaded reply UUID; preserves its audit history")
4457
+ .option("--agent <id>", "Authoring agent (defaults to the joined session agent)")
4458
+ .option("--expected-revision <n>", "Guard against concurrent edits; otherwise fetch the current revision once")
4459
+ .option("--idempotency-key <key>", "Reuse an explicit key when retrying the same edit")
4224
4460
  .option("--path <path>", "Workspace path for the session", ".")
4225
4461
  .option("--json", "Emit machine-readable output")
4226
- .action(async (sessionId, targetSequenceId, options, command) => {
4227
- await runMessageActionCommand({
4228
- sessionId,
4229
- actionType: "view",
4230
- options,
4231
- command,
4232
- commandName: "session view",
4233
- targetSequenceId: parsePositiveInteger(targetSequenceId, "targetSequenceId", 0),
4462
+ .action(async (sessionId, target, messageParts, options, command) => {
4463
+ const sid = normalizeString(sessionId);
4464
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4465
+ const text = Array.isArray(messageParts) ? messageParts.join(" ") : String(messageParts || "");
4466
+ const sequence = /^\d+$/.test(target) ? Number(target) : null;
4467
+ if (sequence !== null && (!Number.isSafeInteger(sequence) || sequence <= 0)) throw new Error("target sequence must be a positive safe integer.");
4468
+ if (!sequence && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(target)) {
4469
+ throw new Error("Edit target must be a positive message sequence or a threaded reply UUID.");
4470
+ }
4471
+ const identity = await resolveMessageActionIdentity({ sessionId: sid, optionAgent: options.agent, targetPath, env: process.env });
4472
+ if (shouldBlockImplicitCliUserSessionSay(identity)) throw new Error(identity.identityWarning || "session edit requires an authoring agent identity; pass --agent <id>.");
4473
+ const expectedRevision = options.expectedRevision === undefined ? null : Number(options.expectedRevision);
4474
+ if (expectedRevision !== null && (!Number.isSafeInteger(expectedRevision) || expectedRevision <= 0)) throw new Error("expected-revision must be a positive safe integer.");
4475
+ return withAgentAdmission(sid, identity.agentId, async () => {
4476
+ const result = await editSessionMessage(sid, {
4477
+ targetPath,
4478
+ targetSequenceId: sequence,
4479
+ targetActionId: sequence ? "" : target,
4480
+ text,
4481
+ agentId: identity.agentId,
4482
+ expectedRevision,
4483
+ idempotencyKey: options.idempotencyKey,
4484
+ timeoutMs: 15_000,
4485
+ });
4486
+ if (!result.ok) throw new Error(`Session edit failed (${result.reason || "unknown"}). No local message was changed.`);
4487
+ let localAppend = { appended: 0, upgraded: 0, failed: 0 };
4488
+ try {
4489
+ await ensureLocalSessionForRemoteCommand(sid, { targetPath });
4490
+ const current = result.reply ? replyRevisionEvent(sid, result.reply) : result.event;
4491
+ localAppend = await appendMissingRemoteEvents(sid, [current, result.notification].filter(Boolean), { targetPath });
4492
+ } catch {
4493
+ localAppend = { appended: 0, upgraded: 0, failed: 1 };
4494
+ }
4495
+ const payload = { command: "session edit", sessionId: sid, target, ...result, localAppend, localAppendComplete: localAppend.failed === 0 };
4496
+ if (shouldEmitJson(options, command)) console.log(JSON.stringify(payload, null, 2));
4497
+ else console.log(`${result.changed ? "Edited" : "Confirmed"} ${sequence ? `#${sequence}` : `reply ${target}`} at revision ${messageRevision(result.reply || result.event)}.${localAppend.failed ? " Remote edit succeeded; local cache needs sync." : ""}`);
4498
+ return payload;
4234
4499
  });
4235
4500
  });
4236
4501
 
@@ -4319,15 +4584,17 @@ export function registerSessionCommand(program) {
4319
4584
  if (verb === "lock") {
4320
4585
  const result = await lockFile(normalizedSessionId, identity.agentId, file, {
4321
4586
  intent,
4587
+ ttlSeconds: options.ttl,
4322
4588
  targetPath,
4323
- awaitRemoteSync: true,
4324
4589
  });
4325
4590
  const record = {
4326
4591
  file: result.file || file,
4327
4592
  locked: Boolean(result.locked),
4328
4593
  reason: result.reason || (result.locked ? "locked" : "held_by_other_agent"),
4329
4594
  heldBy: result.heldBy || null,
4595
+ intent: result.lock?.intent || result.lease?.intent || null,
4330
4596
  since: result.since || null,
4597
+ expiresAt: result.lock?.expiresAt || result.lease?.expiresAt || null,
4331
4598
  };
4332
4599
  results.push(record);
4333
4600
  if (result.locked) {
@@ -4341,7 +4608,6 @@ export function registerSessionCommand(program) {
4341
4608
  const result = await unlockFile(normalizedSessionId, identity.agentId, file, {
4342
4609
  reason: intent || "manual_release",
4343
4610
  targetPath,
4344
- awaitRemoteSync: true,
4345
4611
  });
4346
4612
  const record = {
4347
4613
  file: result.file || file,
@@ -4349,6 +4615,7 @@ export function registerSessionCommand(program) {
4349
4615
  reason: result.reason || (result.unlocked ? "unlocked" : "not_locked"),
4350
4616
  heldBy: result.heldBy || null,
4351
4617
  since: result.since || null,
4618
+ expiresAt: result.lock?.expiresAt || result.lease?.expiresAt || null,
4352
4619
  };
4353
4620
  results.push(record);
4354
4621
  if (result.unlocked) {
@@ -4360,12 +4627,6 @@ export function registerSessionCommand(program) {
4360
4627
  }
4361
4628
  }
4362
4629
 
4363
- if (failed.length > 0) {
4364
- const summary = failed
4365
- .map((item) => `${item.file}${item.heldBy ? ` held by ${item.heldBy}` : ""}`)
4366
- .join(", ");
4367
- throw new Error(`session ${verb} failed for ${summary}`);
4368
- }
4369
4630
  const payload = {
4370
4631
  command: `session ${verb}`,
4371
4632
  sessionId: normalizedSessionId,
@@ -4373,16 +4634,38 @@ export function registerSessionCommand(program) {
4373
4634
  files: processed,
4374
4635
  results,
4375
4636
  skipped,
4637
+ failed,
4376
4638
  };
4377
4639
  if (shouldEmitJson(options, command)) {
4378
4640
  console.log(JSON.stringify(payload, null, 2));
4641
+ if (failed.length > 0) {
4642
+ process.exitCode = 2;
4643
+ }
4644
+ return payload;
4645
+ }
4646
+ if (failed.length > 0) {
4647
+ for (const item of failed) {
4648
+ const details = [
4649
+ item.heldBy ? `held by ${item.heldBy}` : "",
4650
+ item.intent ? `intent: ${item.intent}` : "",
4651
+ item.expiresAt ? `expires ${item.expiresAt}` : "",
4652
+ ].filter(Boolean);
4653
+ console.error(
4654
+ pc.red(
4655
+ `Blocked ${item.file}: ${item.reason}${details.length > 0 ? ` (${details.join(", ")})` : ""}`,
4656
+ ),
4657
+ );
4658
+ }
4659
+ process.exitCode = 2;
4379
4660
  return payload;
4380
4661
  }
4381
- const action = verb === "lock" ? "Requested lock on" : "Released";
4662
+ const action = verb === "lock" ? "Acquired lease for" : "Released";
4382
4663
  console.log(pc.green(`${action} ${processed.length} file(s) as ${identity.agentId}: ${processed.join(", ")}`));
4383
4664
  if (verb === "lock") {
4384
4665
  console.log(
4385
- pc.gray("Senti enforces fail-closed; locks auto-release on TTL. Release with `sl session unlock`."),
4666
+ pc.gray(
4667
+ "The SentinelLayer API is authoritative; leases expire by TTL and can be released with `sl session unlock`.",
4668
+ ),
4386
4669
  );
4387
4670
  }
4388
4671
  return payload;
@@ -4390,8 +4673,9 @@ export function registerSessionCommand(program) {
4390
4673
 
4391
4674
  session
4392
4675
  .command("lock <sessionId> <files...>")
4393
- .description("Claim exclusive file locks via Senti (fail-closed, TTL auto-release)")
4676
+ .description("Claim exclusive API-backed file leases (fail-closed, TTL expiry)")
4394
4677
  .option("--intent <text>", "Why you're locking these files (shown to peers)")
4678
+ .option("--ttl <seconds>", "Lease TTL in seconds (15-3600)", "300")
4395
4679
  .option("--agent <id>", "Agent id claiming the lock (defaults to the joined session agent)")
4396
4680
  .option("--path <path>", "Workspace path for the session", ".")
4397
4681
  .option("--json", "Emit machine-readable output")
@@ -4401,7 +4685,7 @@ export function registerSessionCommand(program) {
4401
4685
 
4402
4686
  session
4403
4687
  .command("unlock <sessionId> <files...>")
4404
- .description("Release file locks you hold (Senti only lets the holder release)")
4688
+ .description("Release API-backed file leases you hold")
4405
4689
  .option("--intent <text>", "Optional note on the release")
4406
4690
  .option("--agent <id>", "Agent id releasing the lock (defaults to the joined session agent)")
4407
4691
  .option("--path <path>", "Workspace path for the session", ".")
@@ -4410,9 +4694,264 @@ export function registerSessionCommand(program) {
4410
4694
  await runFileLockCommand("unlock", sessionId, files, options, command);
4411
4695
  });
4412
4696
 
4697
+ session
4698
+ .command("renew <sessionId> <files...>")
4699
+ .description("Renew holder-bound file leases without writing session events")
4700
+ .option("--ttl <seconds>", "Renewed lease TTL in seconds (15-3600)", "300")
4701
+ .option("--agent <id>", "Agent id renewing the lease (defaults to the joined session agent)")
4702
+ .option("--path <path>", "Workspace path for the session", ".")
4703
+ .option("--json", "Emit machine-readable output")
4704
+ .action(async (sessionId, files, options, command) => {
4705
+ const normalizedSessionId = normalizeString(sessionId);
4706
+ if (!normalizedSessionId) {
4707
+ throw new Error("session id is required.");
4708
+ }
4709
+ const fileList = (Array.isArray(files) ? files : [files])
4710
+ .map((file) => normalizeString(file))
4711
+ .filter(Boolean);
4712
+ if (fileList.length === 0) {
4713
+ throw new Error("session renew requires at least one file path.");
4714
+ }
4715
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4716
+ await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
4717
+ const identity = await resolveMessageActionIdentity({
4718
+ sessionId: normalizedSessionId,
4719
+ optionAgent: options.agent,
4720
+ targetPath,
4721
+ env: process.env,
4722
+ });
4723
+ if (shouldBlockImplicitCliUserSessionSay(identity)) {
4724
+ throw new Error(
4725
+ identity.identityWarning ||
4726
+ "session renew requires an agent identity; pass --agent <id>.",
4727
+ );
4728
+ }
4729
+ const results = [];
4730
+ for (const file of fileList) {
4731
+ results.push(
4732
+ await renewFileLease(normalizedSessionId, identity.agentId, file, {
4733
+ ttlSeconds: options.ttl,
4734
+ targetPath,
4735
+ }),
4736
+ );
4737
+ }
4738
+ const payload = {
4739
+ command: "session renew",
4740
+ sessionId: normalizedSessionId,
4741
+ agentId: identity.agentId,
4742
+ allowed: results.every((result) => result.renewed),
4743
+ results,
4744
+ };
4745
+ if (shouldEmitJson(options, command)) {
4746
+ console.log(JSON.stringify(payload, null, 2));
4747
+ } else if (payload.allowed) {
4748
+ console.log(
4749
+ pc.green(`Renewed ${results.length} file lease(s) as ${identity.agentId}.`),
4750
+ );
4751
+ } else {
4752
+ console.error(
4753
+ pc.red("One or more file leases could not be renewed; edit access is not assured."),
4754
+ );
4755
+ }
4756
+ if (!payload.allowed) {
4757
+ process.exitCode = 2;
4758
+ }
4759
+ return payload;
4760
+ });
4761
+
4762
+ session
4763
+ .command("guard <sessionId> <files...>")
4764
+ .description("Fail-closed authoritative preflight for file edits")
4765
+ .requiredOption("--agent <id>", "Agent id presenting holder capabilities")
4766
+ .option("--path <path>", "Workspace path for the session", ".")
4767
+ .option("--json", "Emit machine-readable output")
4768
+ .action(async (sessionId, files, options, command) => {
4769
+ const normalizedSessionId = normalizeString(sessionId);
4770
+ if (!normalizedSessionId) {
4771
+ throw new Error("session id is required.");
4772
+ }
4773
+ const fileList = (Array.isArray(files) ? files : [files])
4774
+ .map((file) => normalizeString(file))
4775
+ .filter(Boolean);
4776
+ if (fileList.length === 0) {
4777
+ throw new Error("session guard requires at least one file path.");
4778
+ }
4779
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4780
+ const requestedAgentId = normalizeString(options.agent).toLowerCase();
4781
+ let result;
4782
+ try {
4783
+ result = await guardFileLeases(
4784
+ normalizedSessionId,
4785
+ requestedAgentId,
4786
+ fileList,
4787
+ { targetPath },
4788
+ );
4789
+ } catch (error) {
4790
+ const message = safeFileLeaseErrorMessage(error);
4791
+ result = {
4792
+ ok: false,
4793
+ allowed: false,
4794
+ authoritative: false,
4795
+ sessionId: normalizedSessionId,
4796
+ holderId: requestedAgentId || null,
4797
+ checkedAt: new Date().toISOString(),
4798
+ validUntil: null,
4799
+ files: fileList,
4800
+ guarded: [],
4801
+ denials: fileList.map((file) => ({
4802
+ path: file,
4803
+ reason: "guard_failed",
4804
+ })),
4805
+ error: {
4806
+ code: normalizeString(error?.code) || "FILE_LEASE_GUARD_FAILED",
4807
+ message,
4808
+ },
4809
+ };
4810
+ }
4811
+ const payload = {
4812
+ command: "session guard",
4813
+ ...result,
4814
+ };
4815
+ if (shouldEmitJson(options, command)) {
4816
+ console.log(JSON.stringify(payload, null, 2));
4817
+ } else if (result.allowed) {
4818
+ console.log(
4819
+ pc.green(`File-lease guard allowed ${fileList.length} edit target(s).`),
4820
+ );
4821
+ } else {
4822
+ if (result.error?.message) {
4823
+ console.error(pc.red(`Authoritative guard failed: ${result.error.message}`));
4824
+ }
4825
+ if ((result.denials || []).length > 0) {
4826
+ console.error(
4827
+ pc.red(`Edit blocked: ${formatFileLeaseDenialSummary(result)}`),
4828
+ );
4829
+ }
4830
+ }
4831
+ if (!result.allowed) {
4832
+ process.exitCode = 2;
4833
+ }
4834
+ return payload;
4835
+ });
4836
+
4837
+ session
4838
+ .command("guard-hook <sessionId>")
4839
+ .description("Claude PreToolUse hook: read tool JSON on stdin and block denied edits")
4840
+ .requiredOption("--agent <id>", "Agent id presenting holder capabilities")
4841
+ .option("--path <path>", "Workspace root", ".")
4842
+ .action(async (sessionId, options) => {
4843
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4844
+ const result = await runFileLeaseGuardHook({
4845
+ sessionId,
4846
+ agentId: options.agent,
4847
+ targetPath,
4848
+ });
4849
+ process.exitCode = result.exitCode;
4850
+ return result;
4851
+ });
4852
+
4853
+ session
4854
+ .command("guard-install <sessionId>")
4855
+ .description("Install Claude, terminal, and VS Code authoritative lease preflights")
4856
+ .requiredOption("--agent <id>", "Agent id presenting holder capabilities")
4857
+ .option("--path <path>", "Workspace root", ".")
4858
+ .option("--json", "Emit machine-readable output")
4859
+ .action(async (sessionId, options, command) => {
4860
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4861
+ const result = await installFileLeaseIntegrations(
4862
+ sessionId,
4863
+ options.agent,
4864
+ { targetPath },
4865
+ );
4866
+ if (shouldEmitJson(options, command)) {
4867
+ console.log(JSON.stringify({
4868
+ command: "session guard-install",
4869
+ ...result,
4870
+ }, null, 2));
4871
+ } else {
4872
+ console.log(
4873
+ pc.green(
4874
+ "Installed Claude PreToolUse blocking plus terminal and VS Code lease preflights.",
4875
+ ),
4876
+ );
4877
+ console.log(
4878
+ pc.yellow(
4879
+ "VS Code native Save cannot be cancelled reliably; run the generated guard task before edits or make it a dependency of mutation tasks.",
4880
+ ),
4881
+ );
4882
+ }
4883
+ return result;
4884
+ });
4885
+
4886
+ session
4887
+ .command("guard-uninstall <sessionId>")
4888
+ .description("Safely remove only fingerprinted SentinelLayer lease preflights before CLI rollback")
4889
+ .requiredOption("--agent <id>", "Agent id that owns the managed integration")
4890
+ .option("--path <path>", "Workspace root", ".")
4891
+ .option("--json", "Emit machine-readable output")
4892
+ .action(async (sessionId, options, command) => {
4893
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4894
+ let result;
4895
+ try {
4896
+ result = await uninstallFileLeaseIntegrations(
4897
+ sessionId,
4898
+ options.agent,
4899
+ { targetPath },
4900
+ );
4901
+ } catch (error) {
4902
+ result = {
4903
+ ok: false,
4904
+ uninstalled: false,
4905
+ reason: "guard_uninstall_failed",
4906
+ sessionId: normalizeString(sessionId),
4907
+ agentId: normalizeString(options.agent),
4908
+ activeLeases: [],
4909
+ residuals: [],
4910
+ error: {
4911
+ code: normalizeString(error?.code) || "FILE_LEASE_GUARD_UNINSTALL_FAILED",
4912
+ message: safeFileLeaseErrorMessage(error),
4913
+ },
4914
+ };
4915
+ }
4916
+ const payload = {
4917
+ command: "session guard-uninstall",
4918
+ ...result,
4919
+ };
4920
+ if (shouldEmitJson(options, command)) {
4921
+ console.log(JSON.stringify(payload, null, 2));
4922
+ } else if (result.ok) {
4923
+ console.log(pc.green("Removed the managed SentinelLayer lease preflights."));
4924
+ } else {
4925
+ if (result.error?.message) {
4926
+ console.error(pc.red(`Guard uninstall blocked: ${result.error.message}`));
4927
+ }
4928
+ for (const lease of result.activeLeases || []) {
4929
+ const file = normalizeString(lease.file || lease.path) || "(unknown path)";
4930
+ const holder = normalizeString(lease.agentId || lease.holderId) || "unknown";
4931
+ const expiresAt = normalizeString(lease.expiresAt);
4932
+ console.error(
4933
+ pc.red(
4934
+ `Guard uninstall blocked by ${file} (held by ${holder}${expiresAt ? `, expires ${expiresAt}` : ""}).`,
4935
+ ),
4936
+ );
4937
+ }
4938
+ for (const residual of result.residuals || []) {
4939
+ console.error(
4940
+ pc.red(
4941
+ `Managed artifact retained: ${residual.path || residual.label || residual.artifact} (${residual.reason}).`,
4942
+ ),
4943
+ );
4944
+ }
4945
+ }
4946
+ if (!result.ok) {
4947
+ process.exitCode = 2;
4948
+ }
4949
+ return payload;
4950
+ });
4951
+
4413
4952
  session
4414
4953
  .command("locks <sessionId>")
4415
- .description("List active file locks for the session (who holds what, and when they expire)")
4954
+ .description("List authoritative active file leases for the session")
4416
4955
  .option("--path <path>", "Workspace path for the session", ".")
4417
4956
  .option("--json", "Emit machine-readable output")
4418
4957
  .action(async (sessionId, options, command) => {
@@ -4451,11 +4990,11 @@ export function registerSessionCommand(program) {
4451
4990
  session
4452
4991
  .command("listeners <sessionId>")
4453
4992
  .description(
4454
- "List who is actively listening to the session and at what poll cadence (active/idle/stale/stopped), derived from listener presence heartbeats. Mirrors the web roster.",
4993
+ "List the authoritative ephemeral listener presence roster. Presence is unknown when the server capability or Redis is unavailable; transcript heartbeats are never scanned.",
4455
4994
  )
4456
4995
  .option("--path <path>", "Workspace path for the session", ".")
4457
- .option("--limit <n>", "Recent events to scan for heartbeats (default 200)", "200")
4458
- .option("--max-pages <n>", "Maximum older event pages to scan for heartbeats (default 5)", "5")
4996
+ .option("--limit <n>", "Deprecated compatibility option; no event rows are scanned", "200")
4997
+ .option("--max-pages <n>", "Deprecated compatibility option; no event rows are scanned", "5")
4459
4998
  .option("--json", "Emit machine-readable output")
4460
4999
  .action(async (sessionId, options, command) => {
4461
5000
  const normalizedSessionId = normalizeString(sessionId);
@@ -4512,12 +5051,18 @@ export function registerSessionCommand(program) {
4512
5051
  };
4513
5052
  }
4514
5053
  }
4515
- const live = listeners.filter((row) => row.status === "active" || row.status === "idle").length;
5054
+ const live = listeners.filter(
5055
+ (row) => row.status === "present" || row.status === "active" || row.status === "idle",
5056
+ ).length;
4516
5057
  const payload = {
4517
5058
  command: "session listeners",
4518
5059
  sessionId: normalizedSessionId,
4519
5060
  ok: Boolean(result.ok),
4520
5061
  reason: result.ok ? undefined : result.reason,
5062
+ authoritative: Boolean(result.authoritative),
5063
+ presenceStatus: result.presenceStatus || "degraded",
5064
+ presenceEnabled: result.enabled ?? null,
5065
+ retryAfterMs: result.retryAfterMs || null,
4521
5066
  count: listeners.length,
4522
5067
  liveCount: live,
4523
5068
  pageCount: Number(result.pageCount || 0),
@@ -4533,17 +5078,21 @@ export function registerSessionCommand(program) {
4533
5078
  return payload;
4534
5079
  }
4535
5080
  if (!result.ok) {
4536
- console.log(pc.yellow(`Could not read listeners (${result.reason}).`));
5081
+ console.log(
5082
+ pc.yellow(
5083
+ `Listener presence is unknown (${result.reason}); durable heartbeat history was not scanned.`,
5084
+ ),
5085
+ );
4537
5086
  return payload;
4538
5087
  }
4539
5088
  if (listeners.length === 0) {
4540
- console.log(pc.gray("No listeners detected (no recent presence heartbeats)."));
5089
+ console.log(pc.gray("No listeners are currently present."));
4541
5090
  return payload;
4542
5091
  }
4543
5092
  console.log(pc.bold(`Listeners (${live} live / ${listeners.length} seen)`));
4544
5093
  for (const row of listeners) {
4545
5094
  const line = formatListenerLine(row);
4546
- if (row.status === "active") console.log(pc.green(` ${line}`));
5095
+ if (row.status === "present" || row.status === "active") console.log(pc.green(` ${line}`));
4547
5096
  else if (row.status === "idle") console.log(pc.cyan(` ${line}`));
4548
5097
  else console.log(pc.gray(` ${line}`));
4549
5098
  }
@@ -4563,7 +5112,7 @@ export function registerSessionCommand(program) {
4563
5112
  session
4564
5113
  .command("stop-listener <sessionId>")
4565
5114
  .description(
4566
- "Ask an agent's listener to stop (save energy). Posts a listener_stop directive the listener honors on its next poll, then exits cleanly. Targets one agent with --agent; omit it to stop every listener in the room.",
5115
+ "Ask an agent's listener to stop through the ephemeral listener-control plane. Targets one agent with --agent; omit it to stop every listener in the room.",
4567
5116
  )
4568
5117
  .option("--agent <id>", "Agent whose listener to stop (omit to stop all listeners in the room)")
4569
5118
  .option("--path <path>", "Workspace path for the session", ".")
@@ -4576,27 +5125,21 @@ export function registerSessionCommand(program) {
4576
5125
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4577
5126
  const targetAgent = normalizeString(options.agent);
4578
5127
  await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
4579
- const event = createAgentEvent({
4580
- event: "listener_stop",
4581
- agent: { id: "session-control", model: "control", persona: "Session Control" },
4582
- sessionId: normalizedSessionId,
4583
- payload: {
4584
- // targetAgentId routes the directive to that agent's listener (an
4585
- // event recipient); omitting it broadcasts to every listener.
4586
- ...(targetAgent ? { targetAgentId: targetAgent } : { broadcast: true }),
4587
- reason: "operator_stop",
4588
- },
5128
+ const listenerControl = await requestSessionListenerStop(normalizedSessionId, {
5129
+ targetPath,
5130
+ targetAgentId: targetAgent,
4589
5131
  });
4590
- const remoteSync = await syncSessionEventToApi(normalizedSessionId, event, { targetPath }).catch(
4591
- (error) => ({ synced: false, reason: normalizeString(error?.message) || "sync_failed" }),
4592
- );
4593
- await appendToStream(normalizedSessionId, event, { targetPath, syncRemote: false }).catch(() => {});
5132
+ if (!listenerControl?.ok || !listenerControl?.recorded) {
5133
+ throw new Error(
5134
+ `Listener stop request failed (${normalizeString(listenerControl?.reason) || "not_recorded"}).`,
5135
+ );
5136
+ }
4594
5137
  const payload = {
4595
5138
  command: "session stop-listener",
4596
5139
  sessionId: normalizedSessionId,
4597
5140
  targetAgent: targetAgent || null,
4598
5141
  scope: targetAgent ? "agent" : "all",
4599
- remoteSync: remoteSync || undefined,
5142
+ listenerControl,
4600
5143
  };
4601
5144
  if (shouldEmitJson(options, command)) {
4602
5145
  console.log(JSON.stringify(payload, null, 2));
@@ -4624,8 +5167,8 @@ export function registerSessionCommand(program) {
4624
5167
  .option("--interval <seconds>", "Idle polling interval in seconds (default 60)", "60")
4625
5168
  .option(
4626
5169
  "--active-interval <seconds>",
4627
- "Polling interval after recent human activity (default 5)",
4628
- "5",
5170
+ "Polling interval after recent human activity (default 60)",
5171
+ "60",
4629
5172
  )
4630
5173
  .option(
4631
5174
  "--active-window <seconds>",
@@ -4634,17 +5177,17 @@ export function registerSessionCommand(program) {
4634
5177
  )
4635
5178
  .option(
4636
5179
  "--presence-interval <seconds>",
4637
- "Minimum seconds between remote listener heartbeat events (default 60)",
5180
+ "Minimum seconds between ephemeral presence renewals (default 60)",
4638
5181
  "60",
4639
5182
  )
4640
5183
  .option(
4641
5184
  "--presence-keepalive <seconds>",
4642
- "Maximum seconds between unchanged remote listener heartbeat events (default 180)",
5185
+ "Maximum seconds between unchanged ephemeral presence renewals (default 180)",
4643
5186
  "180",
4644
5187
  )
4645
5188
  .option(
4646
5189
  "--no-presence",
4647
- "Do not publish durable listener lifecycle/heartbeat events",
5190
+ "Do not renew ephemeral remote presence",
4648
5191
  )
4649
5192
  .option(
4650
5193
  "--model <model>",
@@ -4653,7 +5196,7 @@ export function registerSessionCommand(program) {
4653
5196
  )
4654
5197
  .option("--display-name <name>", "Human-readable listener name for presence")
4655
5198
  .option("--emit <format>", "Output format: ndjson or text", "ndjson")
4656
- .option("--transport <mode>", "Listen transport: auto, stream, or poll (default auto)", "auto")
5199
+ .option("--transport <mode>", "Listen transport: poll, auto, or stream (default poll)", "poll")
4657
5200
  .option("--limit <n>", "Maximum events to request per poll (default 200)", "200")
4658
5201
  .option("--path <path>", "Workspace path for the session", ".")
4659
5202
  .option("--since <cursor>", "Override the persisted listen cursor")
@@ -4697,7 +5240,7 @@ export function registerSessionCommand(program) {
4697
5240
  `Total log files to retain including the active file (default ${DEFAULT_ROTATING_LOG_MAX_FILES})`,
4698
5241
  String(DEFAULT_ROTATING_LOG_MAX_FILES),
4699
5242
  )
4700
- .action(async (options) => {
5243
+ .action(asAdmittedAgent(sessionListenAgent, async (options) => {
4701
5244
  const normalizedSessionId = resolveSessionIdOption(options);
4702
5245
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4703
5246
  const agentId = normalizeAgentId(options.agent, "cli-user");
@@ -4705,7 +5248,7 @@ export function registerSessionCommand(program) {
4705
5248
  const activeIntervalSeconds = parsePositiveInteger(
4706
5249
  options.activeInterval,
4707
5250
  "active-interval",
4708
- 5,
5251
+ 60,
4709
5252
  );
4710
5253
  const activeWindowSeconds = parsePositiveInteger(options.activeWindow, "active-window", 300);
4711
5254
  const presenceIntervalSeconds = parsePositiveInteger(
@@ -4781,7 +5324,7 @@ export function registerSessionCommand(program) {
4781
5324
  "--wake-host requires a valid host (claude|codex) and --resume-session <host-session-id>.",
4782
5325
  );
4783
5326
  }
4784
- const requestedTransport = normalizeString(options.transport).toLowerCase() || "auto";
5327
+ const requestedTransport = normalizeString(options.transport).toLowerCase() || "poll";
4785
5328
  if (!["auto", "stream", "poll"].includes(requestedTransport)) {
4786
5329
  throw new Error("--transport must be one of: auto, stream, poll.");
4787
5330
  }
@@ -4789,9 +5332,15 @@ export function registerSessionCommand(program) {
4789
5332
  options.maxPolls === undefined
4790
5333
  ? null
4791
5334
  : parsePositiveInteger(options.maxPolls, "max-polls", 1);
4792
- const listenTransport = requestedTransport === "auto" && maxPolls !== null
4793
- ? "poll"
4794
- : requestedTransport;
5335
+ if (currentAdmittedAgent() && requestedTransport === "stream") {
5336
+ throw new Error(
5337
+ "An admitted agent listens by polling: the event stream is not part of an admission grant, so a revoke never has to chase an open stream. Use --transport poll.",
5338
+ );
5339
+ }
5340
+ const listenTransport =
5341
+ requestedTransport === "auto" && (maxPolls !== null || currentAdmittedAgent())
5342
+ ? "poll"
5343
+ : requestedTransport;
4795
5344
  const since = options.since === undefined ? undefined : String(options.since);
4796
5345
  if (options.fromNow && options.since !== undefined) {
4797
5346
  throw new Error("Use either --from-now or --since, not both.");
@@ -4865,14 +5414,15 @@ export function registerSessionCommand(program) {
4865
5414
  const ac = new AbortController();
4866
5415
  const onSigint = () => ac.abort();
4867
5416
  process.on("SIGINT", onSigint);
4868
- const durablePresenceEnabled = options.presence !== false;
4869
- const publishPresence = durablePresenceEnabled && canPublishListenerPresence(agentId);
5417
+ const remotePresenceEnabled = options.presence !== false;
5418
+ const publishPresence = remotePresenceEnabled && canPublishListenerPresence(agentId);
4870
5419
  const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
4871
5420
  const effectivePresenceKeepaliveSeconds =
4872
5421
  Math.max(presenceIntervalSeconds, presenceKeepaliveSeconds, intervalSeconds, 1);
4873
5422
  const presenceKeepaliveMs = effectivePresenceKeepaliveSeconds * 1000;
4874
5423
  let lastPresenceHeartbeatMs = 0;
4875
5424
  let lastPresenceHeartbeatFingerprint = "";
5425
+ let nextPresenceAttemptMs = 0;
4876
5426
 
4877
5427
  // Periodic in-session success reminders (ack, claim work, reply
4878
5428
  // in-thread). Long-running interactive listeners only — skipped under
@@ -4920,10 +5470,10 @@ export function registerSessionCommand(program) {
4920
5470
  `Listening to session ${normalizedSessionId} as ${agentId}; transport=${listenTransport} idle=${intervalSeconds}s active=${activeIntervalSeconds}s/${activeWindowSeconds}s. Press Ctrl+C to stop.`,
4921
5471
  ),
4922
5472
  );
4923
- if (!durablePresenceEnabled) {
5473
+ if (!remotePresenceEnabled) {
4924
5474
  console.log(
4925
5475
  pc.gray(
4926
- "Remote listener presence is disabled; no durable lifecycle events will be written.",
5476
+ "Remote listener presence is disabled; no liveness writes will be made.",
4927
5477
  ),
4928
5478
  );
4929
5479
  } else if (!publishPresence) {
@@ -4974,21 +5524,17 @@ export function registerSessionCommand(program) {
4974
5524
  console.log(pc.yellow(formatListenerCatchupNotice(catchup)));
4975
5525
  }
4976
5526
  },
4977
- onEvent: async (event) => {
4978
- // Cut-listener: a `listener_stop` directive addressed to this
4979
- // agent (from the web "stop listening" control or
4980
- // `sl session stop-listener`) cleanly exits this listener to save
4981
- // energy. Untargeted (no targetAgentId) stops every listener.
4982
- if (normalizeString(event?.event) === "listener_stop") {
4983
- const target = normalizeString(event?.payload?.targetAgentId);
4984
- if (!target || target === agentId) {
4985
- if (emitFormat !== "ndjson") {
4986
- console.log(pc.yellow(`Listener stop requested for ${agentId}; exiting.`));
4987
- }
4988
- ac.abort();
4989
- return;
4990
- }
5527
+ onControl: async (control) => {
5528
+ const controlType = normalizeString(
5529
+ control?.type || control?.action || control?.event,
5530
+ ).toLowerCase();
5531
+ if (!["stop", "listener_stop"].includes(controlType)) return;
5532
+ if (emitFormat !== "ndjson") {
5533
+ console.log(pc.yellow(`Listener stop requested for ${agentId}; exiting.`));
4991
5534
  }
5535
+ ac.abort();
5536
+ },
5537
+ onEvent: async (event) => {
4992
5538
  if (emitFormat === "ndjson") {
4993
5539
  console.log(JSON.stringify(event));
4994
5540
  } else {
@@ -5054,13 +5600,17 @@ export function registerSessionCommand(program) {
5054
5600
  }
5055
5601
  if (!publishPresence) return;
5056
5602
  const lifecycleType = normalizeString(lifecycle?.type);
5603
+ const presenceAttemptMs = Date.now();
5604
+ if (lifecycleType !== "stopped" && presenceAttemptMs < nextPresenceAttemptMs) {
5605
+ return;
5606
+ }
5057
5607
  const publishLifecycle = {
5058
5608
  ...lifecycle,
5059
5609
  presenceIntervalSeconds,
5060
5610
  presenceKeepaliveSeconds: effectivePresenceKeepaliveSeconds,
5061
5611
  };
5062
5612
  if (lifecycleType === "heartbeat") {
5063
- const nowMs = Date.now();
5613
+ const nowMs = presenceAttemptMs;
5064
5614
  const heartbeatDecision = shouldPublishListenerPresenceHeartbeat({
5065
5615
  lifecycle: publishLifecycle,
5066
5616
  nowMs,
@@ -5075,7 +5625,7 @@ export function registerSessionCommand(program) {
5075
5625
  lastPresenceHeartbeatFingerprint = heartbeatDecision.fingerprint;
5076
5626
  lastPresenceHeartbeatMs = nowMs;
5077
5627
  }
5078
- await publishListenerPresenceEvent({
5628
+ const presenceResult = await publishListenerPresenceEvent({
5079
5629
  sessionId: normalizedSessionId,
5080
5630
  targetPath,
5081
5631
  agentId,
@@ -5086,6 +5636,15 @@ export function registerSessionCommand(program) {
5086
5636
  listenerId,
5087
5637
  lifecycle: publishLifecycle,
5088
5638
  });
5639
+ if (presenceResult?.ok) {
5640
+ nextPresenceAttemptMs = 0;
5641
+ return;
5642
+ }
5643
+ const retryAfterMs = Math.max(
5644
+ presenceIntervalMs,
5645
+ Number(presenceResult?.retryAfterMs) || 0,
5646
+ );
5647
+ nextPresenceAttemptMs = presenceAttemptMs + retryAfterMs;
5089
5648
  },
5090
5649
  });
5091
5650
  } finally {
@@ -5103,7 +5662,7 @@ export function registerSessionCommand(program) {
5103
5662
  }).catch(() => {});
5104
5663
  }
5105
5664
  }
5106
- });
5665
+ }));
5107
5666
 
5108
5667
  session
5109
5668
  .command("daemon [sessionId]")
@@ -5565,6 +6124,8 @@ export function registerSessionCommand(program) {
5565
6124
  let hydration = null;
5566
6125
  let remoteTail = null;
5567
6126
  let remoteAppend = null;
6127
+ let listenerPresence = null;
6128
+ let readCursor = null;
5568
6129
  if (options.remote) {
5569
6130
  hydration = await hydrateSessionFromRemote({
5570
6131
  sessionId: normalizedSessionId,
@@ -5592,12 +6153,27 @@ export function registerSessionCommand(program) {
5592
6153
  remoteAppend = await appendMissingRemoteEvents(normalizedSessionId, remoteTail.events, {
5593
6154
  targetPath,
5594
6155
  });
6156
+ readCursor = recordSessionReadCursor(normalizedSessionId, remoteTail.events, {
6157
+ targetPath,
6158
+ agentId,
6159
+ enabled: true,
6160
+ });
5595
6161
  }
6162
+ listenerPresence = await fetchSessionListeners(normalizedSessionId, {
6163
+ targetPath,
6164
+ }).catch((error) => ({
6165
+ ok: false,
6166
+ authoritative: false,
6167
+ presenceStatus: "degraded",
6168
+ reason: normalizeString(error?.message) || "presence_read_failed",
6169
+ listeners: [],
6170
+ }));
5596
6171
  }
5597
6172
  const current = await buildSessionRecap(normalizedSessionId, {
5598
6173
  forAgentId: agentId,
5599
6174
  maxEvents,
5600
6175
  targetPath,
6176
+ listenerPresence,
5601
6177
  });
5602
6178
  const payload = {
5603
6179
  command: "session recap now",
@@ -5622,6 +6198,8 @@ export function registerSessionCommand(program) {
5622
6198
  }
5623
6199
  : null,
5624
6200
  appendedTail: remoteAppend,
6201
+ listenerPresence,
6202
+ readCursor,
5625
6203
  }
5626
6204
  : null,
5627
6205
  };
@@ -5656,7 +6234,7 @@ export function registerSessionCommand(program) {
5656
6234
  .option("--include-control-events", "Include listener lifecycle/control-plane events in transcript output")
5657
6235
  .option("--path <path>", "Workspace path for the session", ".")
5658
6236
  .option("--json", "Emit machine-readable output")
5659
- .action(async (sessionId, options, command) => {
6237
+ .action(asAdmittedAgent(sessionReadAgent, async (sessionId, options, command) => {
5660
6238
  const normalizedSessionId = normalizeString(sessionId);
5661
6239
  if (!normalizedSessionId) {
5662
6240
  throw new Error("session id is required.");
@@ -5841,11 +6419,10 @@ export function registerSessionCommand(program) {
5841
6419
  ? filterSessionActionEventsForTranscriptWindow(visibleActionEvents, transcriptEvents)
5842
6420
  : visibleActionEvents;
5843
6421
  const events = mergeSessionActionEvents(transcriptEvents, actionEvents).slice(-tail);
5844
- const autoView = recordSessionReadViews(normalizedSessionId, events, {
6422
+ const autoView = recordSessionReadCursor(normalizedSessionId, events, {
5845
6423
  targetPath,
5846
6424
  agentId: readAgentId,
5847
6425
  enabled: Boolean(options.remote && options.view !== false),
5848
- maxTargets: process.env.SENTINELAYER_SESSION_READ_VIEW_MAX_TARGETS || 50,
5849
6426
  timeoutMs: process.env.SENTINELAYER_SESSION_READ_VIEW_TIMEOUT_MS || SESSION_READ_AUTO_VIEW_TIMEOUT_MS,
5850
6427
  });
5851
6428
  const remoteVerified = Boolean(
@@ -6001,7 +6578,7 @@ export function registerSessionCommand(program) {
6001
6578
  console.log(formatEventLine(event));
6002
6579
  }
6003
6580
  }
6004
- });
6581
+ }));
6005
6582
 
6006
6583
  session
6007
6584
  .command("search <sessionId> <query>")
@@ -6054,6 +6631,66 @@ export function registerSessionCommand(program) {
6054
6631
  }
6055
6632
  });
6056
6633
 
6634
+ session
6635
+ .command("recall <need>")
6636
+ .description(
6637
+ "Hydrate a session by RETRIEVAL, not full-tail replay: return the top-K relevant memories (dense int8 exact-scan + BM25 + RRF + graph diffusion + ACT-R) each with a one-line provenance path (ENGRAM §1)",
6638
+ )
6639
+ .requiredOption("--session <id>", "Session id to recall from")
6640
+ .option("--role <role>", "Optional role lens folded into the query")
6641
+ .option("--k <n>", "Number of memories to return (default 12, max 50)", "12")
6642
+ .option("--path <path>", "Workspace path for the session", ".")
6643
+ .option("--no-remote", "Skip remote hydrate; recall over the local mirror only")
6644
+ .option("--include-control-events", "Index control-plane events as memories too")
6645
+ .option("--json", "Emit machine-readable output")
6646
+ .action(async (need, options, command) => {
6647
+ const normalizedNeed = normalizeString(need);
6648
+ if (normalizedNeed.length < 2) {
6649
+ throw new Error("need must be at least 2 characters.");
6650
+ }
6651
+ const normalizedSessionId = normalizeString(options.session);
6652
+ if (!normalizedSessionId) {
6653
+ throw new Error("--session <id> is required.");
6654
+ }
6655
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6656
+ const k = Math.min(50, parsePositiveInteger(options.k, "k", 12));
6657
+ const emitJson = shouldEmitJson(options, command);
6658
+
6659
+ const result = await runSessionRecall({
6660
+ sessionId: normalizedSessionId,
6661
+ need: normalizedNeed,
6662
+ targetPath,
6663
+ k,
6664
+ role: normalizeString(options.role),
6665
+ remote: options.remote !== false,
6666
+ includeControlEvents: Boolean(options.includeControlEvents),
6667
+ });
6668
+
6669
+ if (emitJson) {
6670
+ console.log(JSON.stringify({ command: "session recall", targetPath, ...result }, null, 2));
6671
+ return;
6672
+ }
6673
+ if (!result.ok) {
6674
+ throw new Error(`Session recall failed (${result.reason || "unknown"}).`);
6675
+ }
6676
+ if (result.backfill && result.backfill.warning) {
6677
+ console.log(pc.yellow(result.backfill.warning));
6678
+ }
6679
+ console.log(
6680
+ pc.gray(
6681
+ `Recalled top ${result.results.length} of ${result.meta.observationCount} memories · pool ${result.poolSize} · ~${result.tokenCut.reductionRatio}x fewer tokens than full replay (${result.tokenCut.recallPackTokens} vs ${result.tokenCut.fullReplayTokens}).`,
6682
+ ),
6683
+ );
6684
+ result.results.forEach((item, index) => {
6685
+ const sequence = item.sequenceId ? `#${item.sequenceId}` : "";
6686
+ console.log(`${String(index + 1).padStart(2)}. ${sequence} ${item.agentId} ${item.kind} (score ${item.score})`);
6687
+ if (item.snippet) {
6688
+ console.log(` ${item.snippet}`);
6689
+ }
6690
+ console.log(pc.gray(` why: ${item.provenance}`));
6691
+ });
6692
+ });
6693
+
6057
6694
  session
6058
6695
  .command("sync <sessionId>")
6059
6696
  .description(
@@ -6150,6 +6787,147 @@ export function registerSessionCommand(program) {
6150
6787
  }
6151
6788
  });
6152
6789
 
6790
+ // ---------------------------------------------------------------- tickets
6791
+ // The room's shared backlog (API "Ticket Lease Contract (T2)"). Every subcommand is
6792
+ // an ACTOR command: the runCli choke point runs it on the agent's admission.
6793
+ const ticket = session
6794
+ .command("ticket")
6795
+ .description("The room's shared backlog: list, claim, renew, release and submit tickets");
6796
+
6797
+ // Ticket work is attributed by the CREDENTIAL, never by a label: the lease routes take the
6798
+ // holder from authentication alone. So an agent identity is allowed only with that agent's
6799
+ // live admission in scope; without it, the request would go out on the human token and
6800
+ // claim as the PERSON while this command reported the agent. Refused before any request.
6801
+ async function ticketIdentity(sessionId, options, targetPath) {
6802
+ const identity = await resolveSessionSayIdentity({ sessionId, agentId: options.agent, targetPath });
6803
+ const agent = normalizeString(identity.agentId);
6804
+ if (!agent || agent === "cli-user") return "human";
6805
+ const admitted = currentAdmittedAgent();
6806
+ if (!admitted || admitted.sessionId !== sessionId || admitted.agentId !== agent) {
6807
+ throw new Error(
6808
+ `Agent "${agent}" has no live admission in this room. Ticket work is attributed by the credential, so ` +
6809
+ `without one this would act as you, not as "${agent}". Admit it first: ` +
6810
+ `sl session join ${sessionId} --agent ${agent} --goal "<what it is here to do>"`
6811
+ );
6812
+ }
6813
+ return agent;
6814
+ }
6815
+
6816
+ function ticketOutput(options, command, payload, line) {
6817
+ if (shouldEmitJson(options, command)) {
6818
+ console.log(JSON.stringify(payload, null, 2));
6819
+ return;
6820
+ }
6821
+ console.log(line);
6822
+ }
6823
+
6824
+ ticket
6825
+ .command("list <sessionId>")
6826
+ .description("List the room's tickets (--available: only those claimable now)")
6827
+ .option("--available", "Only tickets that can be claimed now (never blocked work)")
6828
+ .option("--agent <id>", "Agent id reading the backlog (defaults to the joined session agent)")
6829
+ .option("--path <path>", "Workspace path for the session", ".")
6830
+ .option("--json", "Emit machine-readable output")
6831
+ .action(async (sessionId, options, command) => {
6832
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6833
+ const result = await listTickets(normalizeString(sessionId), { targetPath, available: Boolean(options.available) });
6834
+ const lines = result.items.map((t) => {
6835
+ const holder = t.lease ? ` · ${t.lease.live ? "leased by" : "lease EXPIRED, was"} ${t.lease.holder}` : "";
6836
+ const done = t.completion && !t.completion.verified ? " (manual, unverified)" : "";
6837
+ return `${t.id} ${t.status}${done} v${t.version} ${t.title}${holder}`;
6838
+ });
6839
+ ticketOutput(options, command, { command: "session ticket list", ...result }, lines.join("\n") || "No tickets.");
6840
+ });
6841
+
6842
+ ticket
6843
+ .command("claim <sessionId> <ticketId>")
6844
+ .description("Claim a ticket: take its fenced lease")
6845
+ .option("--expected-version <n>", "Refuse if the ticket has changed since this version")
6846
+ .option("--agent <id>", "Agent id claiming the ticket (defaults to the joined session agent)")
6847
+ .option("--path <path>", "Workspace path for the session", ".")
6848
+ .option("--json", "Emit machine-readable output")
6849
+ .action(async (sessionId, ticketId, options, command) => {
6850
+ const sid = normalizeString(sessionId);
6851
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6852
+ const identity = await ticketIdentity(sid, options, targetPath);
6853
+ const expectedVersion =
6854
+ options.expectedVersion === undefined ? null : parsePositiveInteger(options.expectedVersion, "expected-version", 1);
6855
+ const result = await claimTicket(sid, normalizeString(ticketId), { identity, expectedVersion, targetPath });
6856
+ ticketOutput(options, command, { command: "session ticket claim", identity, ...result },
6857
+ `Claimed ${ticketId} (fence ${result.lease.fence}, until ${result.lease.expiresAt}).`);
6858
+ });
6859
+
6860
+ ticket
6861
+ .command("renew <sessionId> <ticketId>")
6862
+ .description("Renew the lease this identity holds on a ticket")
6863
+ .option("--agent <id>", "Agent id holding the lease (defaults to the joined session agent)")
6864
+ .option("--path <path>", "Workspace path for the session", ".")
6865
+ .option("--json", "Emit machine-readable output")
6866
+ .action(async (sessionId, ticketId, options, command) => {
6867
+ const sid = normalizeString(sessionId);
6868
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6869
+ const identity = await ticketIdentity(sid, options, targetPath);
6870
+ const result = await renewTicketLease(sid, normalizeString(ticketId), { identity, targetPath });
6871
+ ticketOutput(options, command, { command: "session ticket renew", identity, ...result },
6872
+ `Renewed ${ticketId} until ${result.lease.expiresAt}.`);
6873
+ });
6874
+
6875
+ ticket
6876
+ .command("release <sessionId> <ticketId>")
6877
+ .description("Give a ticket back: it returns to open for the next claimant")
6878
+ .option("--reason <text>", "Why it is being released")
6879
+ .option("--agent <id>", "Agent id holding the lease (defaults to the joined session agent)")
6880
+ .option("--path <path>", "Workspace path for the session", ".")
6881
+ .option("--json", "Emit machine-readable output")
6882
+ .action(async (sessionId, ticketId, options, command) => {
6883
+ const sid = normalizeString(sessionId);
6884
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6885
+ const identity = await ticketIdentity(sid, options, targetPath);
6886
+ const result = await releaseTicketLease(sid, normalizeString(ticketId), {
6887
+ identity, targetPath, reason: normalizeString(options.reason) || null,
6888
+ });
6889
+ ticketOutput(options, command, { command: "session ticket release", identity, ...result }, `Released ${ticketId}.`);
6890
+ });
6891
+
6892
+ ticket
6893
+ .command("submit <sessionId> <ticketId>")
6894
+ .description("Submit the work for review (in_review). Completion needs verification; this is not done.")
6895
+ .option("--sha <sha>", "The implementation commit")
6896
+ .option("--evidence-file <path>", "JSON array of re-checkable evidence claims")
6897
+ .option("--agent <id>", "Agent id holding the lease (defaults to the joined session agent)")
6898
+ .option("--path <path>", "Workspace path for the session", ".")
6899
+ .option("--json", "Emit machine-readable output")
6900
+ .action(async (sessionId, ticketId, options, command) => {
6901
+ const sid = normalizeString(sessionId);
6902
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6903
+ const identity = await ticketIdentity(sid, options, targetPath);
6904
+ let evidence = [];
6905
+ if (options.evidenceFile) {
6906
+ evidence = JSON.parse(await fsp.readFile(path.resolve(targetPath, String(options.evidenceFile)), "utf8"));
6907
+ if (!Array.isArray(evidence)) throw new Error("--evidence-file must contain a JSON array.");
6908
+ }
6909
+ const result = await submitTicket(sid, normalizeString(ticketId), {
6910
+ identity, targetPath, implementationSha: normalizeString(options.sha) || null, evidence,
6911
+ });
6912
+ ticketOutput(options, command, { command: "session ticket submit", identity, ...result },
6913
+ `Submitted ${ticketId} for review.`);
6914
+ });
6915
+
6916
+ ticket
6917
+ .command("events <sessionId>")
6918
+ .description("The room's ticket events after a cursor, in commit order")
6919
+ .option("--after <cursor>", "Only events after this cursor", "0")
6920
+ .option("--agent <id>", "Agent id reading the feed (defaults to the joined session agent)")
6921
+ .option("--path <path>", "Workspace path for the session", ".")
6922
+ .option("--json", "Emit machine-readable output")
6923
+ .action(async (sessionId, options, command) => {
6924
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6925
+ const after = Number.parseInt(String(options.after ?? "0"), 10) || 0;
6926
+ const result = await ticketEvents(normalizeString(sessionId), { after, targetPath });
6927
+ const lines = (result.events || []).map((e) => `#${e.cursor} ${e.kind} ${e.ticketId} v${e.ticketVersion} by ${e.actorId}`);
6928
+ ticketOutput(options, command, { command: "session ticket events", ...result }, lines.join("\n") || "No new ticket events.");
6929
+ });
6930
+
6153
6931
  const checkpoint = session
6154
6932
  .command("checkpoint")
6155
6933
  .description("List, show, create, and generate durable session checkpoints");
@@ -6422,7 +7200,14 @@ export function registerSessionCommand(program) {
6422
7200
  throw new Error(`Session '${normalizedSessionId}' was not found.`);
6423
7201
  }
6424
7202
 
6425
- const [agents, runtimeRuns, leases, fileLocks, activeTasks, recentEvents] = await Promise.all([
7203
+ const [
7204
+ agents,
7205
+ runtimeRuns,
7206
+ leases,
7207
+ fileLeaseStatus,
7208
+ activeTasks,
7209
+ recentEvents,
7210
+ ] = await Promise.all([
6426
7211
  listAgents(normalizedSessionId, {
6427
7212
  targetPath,
6428
7213
  includeInactive: false,
@@ -6441,10 +7226,22 @@ export function registerSessionCommand(program) {
6441
7226
  includeExpired: true,
6442
7227
  limit: 100,
6443
7228
  }),
6444
- listFileLocks(normalizedSessionId, {
6445
- targetPath,
6446
- emitExpiredEvents: false,
6447
- }),
7229
+ listFileLocks(normalizedSessionId, { targetPath })
7230
+ .then((activeFileLocks) => ({
7231
+ ok: true,
7232
+ authoritative: true,
7233
+ activeFileLocks,
7234
+ error: null,
7235
+ }))
7236
+ .catch((error) => ({
7237
+ ok: false,
7238
+ authoritative: false,
7239
+ activeFileLocks: [],
7240
+ error: {
7241
+ code: normalizeString(error?.code) || "FILE_LEASE_LIST_FAILED",
7242
+ message: safeFileLeaseErrorMessage(error),
7243
+ },
7244
+ })),
6448
7245
  listSessionTasks(normalizedSessionId, {
6449
7246
  targetPath,
6450
7247
  statuses: ["PENDING", "ACCEPTED"],
@@ -6455,42 +7252,51 @@ export function registerSessionCommand(program) {
6455
7252
  tail: 10,
6456
7253
  }),
6457
7254
  ]);
7255
+ const fileLocks = fileLeaseStatus.activeFileLocks;
6458
7256
 
6459
7257
  const staleAgents = detectStaleAgents(agents, {});
6460
7258
  const staleAgentIds = new Set(staleAgents.map((agent) => normalizeString(agent.agentId)));
6461
7259
  const activeAgents = agents.filter((agent) => !staleAgentIds.has(normalizeString(agent.agentId)));
6462
- let listenerRows = summarizeListeners(recentEvents);
7260
+ const materialRecentEvents = filterSessionMaterialEvents(recentEvents);
7261
+ const hiddenControlEventCount = recentEvents.length - materialRecentEvents.length;
7262
+ let listenerRows = [];
6463
7263
  let listenerPresence = {
6464
- source: "local_recent_events",
6465
- ok: true,
6466
- count: listenerRows.length,
6467
- liveCount: listenerRows.filter((row) => row.status === "active" || row.status === "idle").length,
6468
- listeners: listenerRows,
7264
+ source: "remote_presence",
7265
+ ok: false,
7266
+ authoritative: false,
7267
+ presenceStatus: "degraded",
7268
+ reason: "presence_unknown",
7269
+ count: 0,
7270
+ liveCount: 0,
7271
+ listeners: [],
6469
7272
  };
6470
7273
  try {
6471
7274
  const remoteListeners = await fetchSessionListeners(normalizedSessionId, {
6472
7275
  targetPath,
6473
- limit: 200,
6474
- maxPages: 5,
6475
7276
  });
6476
- if (remoteListeners?.ok && Array.isArray(remoteListeners.listeners) && remoteListeners.listeners.length > 0) {
7277
+ if (remoteListeners?.ok && Array.isArray(remoteListeners.listeners)) {
6477
7278
  listenerRows = remoteListeners.listeners;
6478
7279
  listenerPresence = {
6479
- source: "remote_events",
7280
+ source: "remote_presence",
6480
7281
  ok: true,
7282
+ authoritative: true,
7283
+ presenceStatus: remoteListeners.presenceStatus || "ok",
6481
7284
  count: listenerRows.length,
6482
- liveCount: listenerRows.filter((row) => row.status === "active" || row.status === "idle").length,
6483
- pageCount: Number(remoteListeners.pageCount || 0),
6484
- scannedEventCount: Number(remoteListeners.scannedEventCount || 0),
6485
- listenerEventCount: Number(remoteListeners.listenerEventCount || 0),
6486
- partial: Boolean(remoteListeners.partial),
7285
+ liveCount: listenerRows.filter(
7286
+ (row) => row.status === "present" || row.status === "active" || row.status === "idle",
7287
+ ).length,
6487
7288
  listeners: listenerRows,
6488
7289
  };
6489
7290
  } else if (remoteListeners && remoteListeners.ok === false) {
6490
- listenerPresence.remoteReason = normalizeString(remoteListeners.reason) || "remote_unavailable";
7291
+ listenerPresence = {
7292
+ ...listenerPresence,
7293
+ presenceStatus: remoteListeners.presenceStatus || "degraded",
7294
+ reason: normalizeString(remoteListeners.reason) || "remote_unavailable",
7295
+ retryAfterMs: remoteListeners.retryAfterMs || null,
7296
+ };
6491
7297
  }
6492
7298
  } catch (error) {
6493
- listenerPresence.remoteReason = normalizeString(error?.message) || "remote_unavailable";
7299
+ listenerPresence.reason = normalizeString(error?.message) || "remote_unavailable";
6494
7300
  }
6495
7301
  const liveListenerCount = listenerPresence.liveCount;
6496
7302
  const payload = {
@@ -6505,8 +7311,14 @@ export function registerSessionCommand(program) {
6505
7311
  runtimeRuns,
6506
7312
  activeLeases: leases.assignments,
6507
7313
  activeFileLocks: fileLocks,
7314
+ fileLeaseAuthority: {
7315
+ ok: fileLeaseStatus.ok,
7316
+ authoritative: fileLeaseStatus.authoritative,
7317
+ error: fileLeaseStatus.error,
7318
+ },
6508
7319
  activeTasks: activeTasks.tasks,
6509
- recentEvents,
7320
+ recentEvents: materialRecentEvents,
7321
+ hiddenControlEventCount,
6510
7322
  };
6511
7323
  if (shouldEmitJson(options, command)) {
6512
7324
  console.log(JSON.stringify(payload, null, 2));
@@ -6514,12 +7326,15 @@ export function registerSessionCommand(program) {
6514
7326
  }
6515
7327
 
6516
7328
  console.log(pc.bold(`Session ${normalizedSessionId}`));
7329
+ const listenerText = listenerPresence.authoritative
7330
+ ? `${liveListenerCount}/${listenerRows.length}`
7331
+ : `unknown(${listenerPresence.presenceStatus || "degraded"})`;
6517
7332
  console.log(
6518
7333
  pc.gray(
6519
- `status=${sessionPayload.status} active=${activeAgents.length} registered=${agents.length} stale=${staleAgents.length} listeners=${liveListenerCount}/${listenerRows.length} runs=${runtimeRuns.length} leases=${leases.assignments.length} locks=${fileLocks.length} tasks=${activeTasks.tasks.length}`
7334
+ `status=${sessionPayload.status} active=${activeAgents.length} registered=${agents.length} stale=${staleAgents.length} listeners=${listenerText} runs=${runtimeRuns.length} leases=${leases.assignments.length} locks=${fileLocks.length} tasks=${activeTasks.tasks.length}`
6520
7335
  )
6521
7336
  );
6522
- for (const event of recentEvents) {
7337
+ for (const event of materialRecentEvents) {
6523
7338
  console.log(formatEventLine(event));
6524
7339
  }
6525
7340
  });