sentinelayer-cli 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +4 -4
  2. package/package.json +2 -3
  3. package/src/commands/session.js +611 -232
  4. package/src/engram/governance.js +67 -0
  5. package/src/engram/index.js +64 -0
  6. package/src/engram/namespace.js +89 -0
  7. package/src/engram/observations.js +34 -0
  8. package/src/engram/sla.js +104 -0
  9. package/src/engram/store.js +115 -0
  10. package/src/engram/tools.js +146 -0
  11. package/src/engram/trust.js +105 -0
  12. package/src/legacy-cli.js +4 -1
  13. package/src/mcp/registry.js +27 -10
  14. package/src/mcp/session-stdio-server.js +209 -18
  15. package/src/session/agent-registry.js +22 -63
  16. package/src/session/coordination-guidance.js +6 -6
  17. package/src/session/daemon.js +19 -124
  18. package/src/session/file-lease-integrations.js +1300 -0
  19. package/src/session/file-locks.js +967 -464
  20. package/src/session/listener.js +243 -20
  21. package/src/session/listeners.js +71 -80
  22. package/src/session/paths.js +2 -0
  23. package/src/session/recall/embedder.js +181 -0
  24. package/src/session/recall/entities.js +0 -0
  25. package/src/session/recall/index-build.js +38 -0
  26. package/src/session/recall/index-core.js +108 -0
  27. package/src/session/recall/index.js +131 -0
  28. package/src/session/recall/needle-eval.js +279 -0
  29. package/src/session/recall/observation-core.js +183 -0
  30. package/src/session/recall/observations.js +21 -0
  31. package/src/session/recall/provenance.js +153 -0
  32. package/src/session/recall/retrieve.js +374 -0
  33. package/src/session/recall/text.js +0 -0
  34. package/src/session/recall/token-stats.js +81 -0
  35. package/src/session/recap.js +29 -23
  36. package/src/session/remote-hydrate.js +5 -1
  37. package/src/session/sync.js +661 -11
  38. package/src/session/wake/pump.js +8 -3
@@ -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,
@@ -103,11 +112,15 @@ import {
103
112
  pollHumanMessages,
104
113
  pollSessionEvents,
105
114
  pollSessionEventsBefore,
115
+ requestSessionListenerStop,
116
+ renewSessionPresence,
106
117
  searchSessionEvents,
107
118
  syncSessionEventToApi,
108
119
  syncSessionMetadataToApi,
120
+ updateSessionReadCursor,
109
121
  } from "../session/sync.js";
110
122
  import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
123
+ import { runSessionRecall } from "../session/recall/index.js";
111
124
  import { mergeLiveSources } from "../session/live-source.js";
112
125
  import { listenSessionEvents } from "../session/listener.js";
113
126
  import { SESSION_LIVE_SUCCESS_TIPS } from "../session/coordination-guidance.js";
@@ -442,7 +455,9 @@ async function confirmSessionEventVisible(
442
455
  }
443
456
  lastReason = "not_visible";
444
457
  const nextCursor = normalizeString(result.cursor);
445
- if (!events.length || !nextCursor || nextCursor === pageCursor) {
458
+ const scanExhausted = result.scanExhausted ?? result.scan_exhausted;
459
+ const emptyScanCanContinue = !events.length && scanExhausted === false;
460
+ if (!nextCursor || nextCursor === pageCursor || (!events.length && !emptyScanCanContinue)) {
446
461
  break;
447
462
  }
448
463
  pageCursor = nextCursor;
@@ -960,12 +975,12 @@ function normalizeAutoViewTimeoutMs(value) {
960
975
  return Math.max(50, Math.min(5_000, Math.floor(parsed)));
961
976
  }
962
977
 
963
- async function writeSessionReadViews(sessionId, writeTargets = [], {
978
+ async function writeSessionReadCursor(sessionId, target = null, {
964
979
  targetPath,
965
980
  agentId,
966
981
  timeoutMs = SESSION_READ_AUTO_VIEW_TIMEOUT_MS,
967
982
  } = {}) {
968
- if (!Array.isArray(writeTargets) || writeTargets.length === 0) {
983
+ if (!target) {
969
984
  return;
970
985
  }
971
986
  const totalTimeoutMs = normalizeAutoViewTimeoutMs(timeoutMs);
@@ -983,50 +998,31 @@ async function writeSessionReadViews(sessionId, writeTargets = [], {
983
998
  }
984
999
 
985
1000
  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
- }
1001
+ const elapsedMs = Math.max(0, Date.now() - startedAt);
1002
+ const remainingMs = Math.max(1, totalTimeoutMs - elapsedMs);
1003
+ await updateSessionReadCursor(sessionId, {
1004
+ targetPath,
1005
+ targetSequenceId: target.targetSequenceId,
1006
+ targetCursor: target.targetCursor,
1007
+ agentId,
1008
+ timeoutMs: remainingMs,
1009
+ signal: controller.signal,
1010
+ });
1014
1011
  } finally {
1015
1012
  clearTimeout(timeoutHandle);
1016
1013
  }
1017
1014
  }
1018
1015
 
1019
- function recordSessionReadViews(sessionId, events = [], {
1016
+ function recordSessionReadCursor(sessionId, events = [], {
1020
1017
  targetPath,
1021
1018
  agentId,
1022
1019
  enabled = false,
1023
- maxTargets = 50,
1024
1020
  timeoutMs = SESSION_READ_AUTO_VIEW_TIMEOUT_MS,
1025
1021
  } = {}) {
1026
- const maxAutoViewTargets = Math.max(0, Math.min(200, Number(maxTargets) || 0));
1022
+ const normalizedAgentId = normalizeString(agentId);
1027
1023
  const summary = {
1028
1024
  enabled: Boolean(enabled),
1029
- agentId: normalizeString(agentId) || "cli-user",
1025
+ agentId: normalizedAgentId || "cli-user",
1030
1026
  targetCount: 0,
1031
1027
  attempted: 0,
1032
1028
  recorded: 0,
@@ -1042,26 +1038,22 @@ function recordSessionReadViews(sessionId, events = [], {
1042
1038
  return summary;
1043
1039
  }
1044
1040
 
1045
- const seenTargets = new Set();
1046
- const targets = [];
1041
+ let target = null;
1047
1042
  for (const event of Array.isArray(events) ? events : []) {
1048
- const target = sessionReadViewTarget(event);
1049
- if (!target || seenTargets.has(target.key)) {
1050
- continue;
1043
+ const candidate = sessionReadViewTarget(event);
1044
+ if (!candidate) continue;
1045
+ summary.targetCount += 1;
1046
+ if (
1047
+ !target ||
1048
+ Number(candidate.targetSequenceId || 0) >= Number(target.targetSequenceId || 0)
1049
+ ) {
1050
+ target = candidate;
1051
1051
  }
1052
- seenTargets.add(target.key);
1053
- targets.push(target);
1054
1052
  }
1055
- summary.targetCount = targets.length;
1053
+ summary.skipped = Math.max(0, summary.targetCount - (target ? 1 : 0));
1054
+ summary.queued = target ? 1 : 0;
1056
1055
 
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) {
1056
+ if (!target) {
1065
1057
  if (!summary.reason) {
1066
1058
  summary.reason = "no_targets";
1067
1059
  }
@@ -1069,13 +1061,13 @@ function recordSessionReadViews(sessionId, events = [], {
1069
1061
  }
1070
1062
 
1071
1063
  summary.background = true;
1072
- if (!summary.reason) {
1073
- summary.reason = "queued_best_effort";
1074
- }
1064
+ summary.reason = "queued_monotonic_upsert";
1075
1065
 
1076
- void writeSessionReadViews(sessionId, writeTargets, {
1066
+ void writeSessionReadCursor(sessionId, target, {
1077
1067
  targetPath,
1078
- agentId: summary.agentId,
1068
+ // Omit a placeholder identity so the server can bind human reads to the
1069
+ // authenticated principal. Explicit agent reads still carry their grant.
1070
+ agentId: normalizedAgentId,
1079
1071
  timeoutMs,
1080
1072
  }).catch(() => {
1081
1073
  // Best-effort view receipts must never affect transcript rendering.
@@ -1855,13 +1847,6 @@ function normalizeSessionObservationText(rawValue, fieldName, maxLength) {
1855
1847
  return value;
1856
1848
  }
1857
1849
 
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
1850
  function compactPayload(record = {}) {
1866
1851
  return Object.fromEntries(
1867
1852
  Object.entries(record).filter(([, value]) => value !== undefined)
@@ -1948,11 +1933,6 @@ async function publishListenerPresenceEvent({
1948
1933
  lifecycle = {},
1949
1934
  } = {}) {
1950
1935
  const normalizedType = normalizeString(lifecycle.type) || "heartbeat";
1951
- const eventName = listenerLifecycleEventName(normalizedType);
1952
- const idempotencyStep = listenerLifecycleIdempotencyStep({
1953
- ...lifecycle,
1954
- type: normalizedType,
1955
- });
1956
1936
  const identity = inferSessionAgentIdentity({
1957
1937
  agentId,
1958
1938
  model: agentModel,
@@ -1960,49 +1940,23 @@ async function publishListenerPresenceEvent({
1960
1940
  provider,
1961
1941
  clientKind,
1962
1942
  });
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
- }),
1943
+ return renewSessionPresence(sessionId, {
1944
+ targetPath,
1945
+ agentId,
1946
+ state:
1947
+ normalizedType === "stopped"
1948
+ ? "stopped"
1949
+ : normalizeString(lifecycle.state) || normalizedType,
1950
+ listenerId,
1951
+ model: normalizeString(identity.model) || "cli",
1952
+ displayName: normalizeString(identity.displayName) || agentId,
1953
+ provider: normalizeString(identity.provider),
1954
+ clientKind: normalizeString(identity.clientKind) || "cli",
1955
+ observedAt:
1956
+ lifecycle.stoppedAt ||
1957
+ lifecycle.startedAt ||
1958
+ new Date().toISOString(),
2004
1959
  });
2005
- return syncSessionEventToApi(sessionId, event, { targetPath });
2006
1960
  }
2007
1961
 
2008
1962
  function formatListenerCatchupNotice(catchup = {}) {
@@ -2400,17 +2354,6 @@ async function resolveSessionAgentEnvelope(
2400
2354
  return Object.fromEntries(Object.entries(envelope).filter(([, value]) => value !== undefined));
2401
2355
  }
2402
2356
 
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
2357
  async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
2415
2358
  const normalizedItems = Array.isArray(items) ? items : [];
2416
2359
  const normalizedConcurrency = Math.max(
@@ -3368,7 +3311,7 @@ export function registerSessionCommand(program) {
3368
3311
  .option("--name <name>", "Agent display name (legacy alias for --agent)")
3369
3312
  .option(
3370
3313
  "--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.",
3314
+ "Granted agent id to register in the participant roster. Join state and onboarding are returned locally and never appended to the transcript.",
3372
3315
  )
3373
3316
  .option("--invite-token <token>", "Invitation token to accept before joining the session")
3374
3317
  .option("--seat-key <key>", "Reserved session seat key to claim while accepting an invite")
@@ -3457,13 +3400,9 @@ export function registerSessionCommand(program) {
3457
3400
  const model = normalizeString(options.model) || "cli";
3458
3401
  const hasConcreteAgentIdentity = Boolean(explicitAgent || acceptedAgentId);
3459
3402
 
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).
3403
+ // Registration updates the local participant projection and returns
3404
+ // onboarding directly to this process. It never writes join, identity,
3405
+ // or coaching rows into the transcript.
3467
3406
  const joined = await registerAgent(normalizedSessionId, {
3468
3407
  targetPath,
3469
3408
  agentId: resolvedAgentId,
@@ -3474,13 +3413,12 @@ export function registerSessionCommand(program) {
3474
3413
  trackProcessExit: false,
3475
3414
  awaitRemoteSync: hasConcreteAgentIdentity,
3476
3415
  });
3477
- const agentJoinRelayed =
3478
- joined.emittedJoinEvent !== false &&
3479
- hasConcreteAgentIdentity &&
3480
- Boolean(resolvedAgentId) &&
3481
- resolvedAgentId !== "cli-user" &&
3482
- resolvedAgentId !== "unknown" &&
3483
- !resolvedAgentId.startsWith("human-");
3416
+ const agentJoinRelayed = false;
3417
+ const localBriefingPayload =
3418
+ joined.onboarding?.contextBriefing?.event?.payload &&
3419
+ typeof joined.onboarding.contextBriefing.event.payload === "object"
3420
+ ? joined.onboarding.contextBriefing.event.payload
3421
+ : null;
3484
3422
  const onboardingBrief = await writeSessionOnboardingBrief(normalizedSessionId, {
3485
3423
  targetPath,
3486
3424
  onboarding: acceptedOnboarding,
@@ -3521,6 +3459,17 @@ export function registerSessionCommand(program) {
3521
3459
  agentCount: Number.isFinite(agentCount) ? agentCount : 0,
3522
3460
  lastActivityAt: lastActivityIso || null,
3523
3461
  agentJoinRelayed,
3462
+ onboardingBriefing: localBriefingPayload
3463
+ ? {
3464
+ forAgent: localBriefingPayload.forAgent || joined.agentId,
3465
+ message: localBriefingPayload.message || "",
3466
+ recap: localBriefingPayload.recap || "",
3467
+ rules: localBriefingPayload.rules || null,
3468
+ generatedAt: localBriefingPayload.generatedAt || null,
3469
+ delivery: "joining_process",
3470
+ persisted: false,
3471
+ }
3472
+ : null,
3524
3473
  invitationAccepted: Boolean(invitationAcceptResult?.ok || invitationAccept),
3525
3474
  invitationAccept: invitationAccept
3526
3475
  ? {
@@ -3559,6 +3508,9 @@ export function registerSessionCommand(program) {
3559
3508
  if (payload.onboardingGuide?.markdownPath) {
3560
3509
  console.log(pc.gray(`onboarding=${payload.onboardingGuide.markdownPath}`));
3561
3510
  }
3511
+ if (payload.onboardingBriefing?.recap) {
3512
+ console.log(payload.onboardingBriefing.recap);
3513
+ }
3562
3514
  });
3563
3515
 
3564
3516
  session
@@ -4082,10 +4034,23 @@ export function registerSessionCommand(program) {
4082
4034
  if (!result.ok || !result.action) {
4083
4035
  throw new Error(`Session action failed (${result.reason || "unknown"}).`);
4084
4036
  }
4085
- const actionEvent = buildSessionActionEvent(normalizedSessionId, result.action);
4086
- const localAppend = await appendActionEventIfMissing(normalizedSessionId, actionEvent, {
4087
- targetPath,
4088
- });
4037
+ const isReadCursorProjection = normalizedActionType === "view";
4038
+ const actionEvent = isReadCursorProjection
4039
+ ? null
4040
+ : buildSessionActionEvent(normalizedSessionId, result.action);
4041
+ const localAppend = isReadCursorProjection
4042
+ ? { appended: false, reason: "read_cursor_projection", event: null }
4043
+ : await appendActionEventIfMissing(normalizedSessionId, actionEvent, {
4044
+ targetPath,
4045
+ });
4046
+ const readCursorProjection = isReadCursorProjection
4047
+ ? {
4048
+ updated: Boolean(result.updated),
4049
+ lastReadSequenceId:
4050
+ Number(result.lastReadSequenceId || result.action.targetSequenceId || 0) || null,
4051
+ targetCursor: targetCursor || null,
4052
+ }
4053
+ : null;
4089
4054
  const payload = {
4090
4055
  command: commandName,
4091
4056
  targetPath,
@@ -4098,11 +4063,22 @@ export function registerSessionCommand(program) {
4098
4063
  appended: Boolean(localAppend.appended),
4099
4064
  reason: localAppend.reason || "",
4100
4065
  },
4066
+ ...(readCursorProjection ? { readCursorProjection } : {}),
4101
4067
  };
4102
4068
  if (shouldEmitJson(options, command)) {
4103
4069
  console.log(JSON.stringify(payload, null, 2));
4104
4070
  return payload;
4105
4071
  }
4072
+ if (readCursorProjection) {
4073
+ const targetLabel = readCursorProjection.lastReadSequenceId
4074
+ ? `#${readCursorProjection.lastReadSequenceId}`
4075
+ : readCursorProjection.targetCursor || "target";
4076
+ const status = readCursorProjection.updated ? "Advanced" : "Confirmed";
4077
+ console.log(
4078
+ pc.green(`${status} read cursor through ${targetLabel}; no transcript event appended.`),
4079
+ );
4080
+ return payload;
4081
+ }
4106
4082
  if (localAppend.event || actionEvent) {
4107
4083
  console.log(formatEventLine(localAppend.event || actionEvent));
4108
4084
  } else {
@@ -4319,15 +4295,17 @@ export function registerSessionCommand(program) {
4319
4295
  if (verb === "lock") {
4320
4296
  const result = await lockFile(normalizedSessionId, identity.agentId, file, {
4321
4297
  intent,
4298
+ ttlSeconds: options.ttl,
4322
4299
  targetPath,
4323
- awaitRemoteSync: true,
4324
4300
  });
4325
4301
  const record = {
4326
4302
  file: result.file || file,
4327
4303
  locked: Boolean(result.locked),
4328
4304
  reason: result.reason || (result.locked ? "locked" : "held_by_other_agent"),
4329
4305
  heldBy: result.heldBy || null,
4306
+ intent: result.lock?.intent || result.lease?.intent || null,
4330
4307
  since: result.since || null,
4308
+ expiresAt: result.lock?.expiresAt || result.lease?.expiresAt || null,
4331
4309
  };
4332
4310
  results.push(record);
4333
4311
  if (result.locked) {
@@ -4341,7 +4319,6 @@ export function registerSessionCommand(program) {
4341
4319
  const result = await unlockFile(normalizedSessionId, identity.agentId, file, {
4342
4320
  reason: intent || "manual_release",
4343
4321
  targetPath,
4344
- awaitRemoteSync: true,
4345
4322
  });
4346
4323
  const record = {
4347
4324
  file: result.file || file,
@@ -4349,6 +4326,7 @@ export function registerSessionCommand(program) {
4349
4326
  reason: result.reason || (result.unlocked ? "unlocked" : "not_locked"),
4350
4327
  heldBy: result.heldBy || null,
4351
4328
  since: result.since || null,
4329
+ expiresAt: result.lock?.expiresAt || result.lease?.expiresAt || null,
4352
4330
  };
4353
4331
  results.push(record);
4354
4332
  if (result.unlocked) {
@@ -4360,12 +4338,6 @@ export function registerSessionCommand(program) {
4360
4338
  }
4361
4339
  }
4362
4340
 
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
4341
  const payload = {
4370
4342
  command: `session ${verb}`,
4371
4343
  sessionId: normalizedSessionId,
@@ -4373,16 +4345,38 @@ export function registerSessionCommand(program) {
4373
4345
  files: processed,
4374
4346
  results,
4375
4347
  skipped,
4348
+ failed,
4376
4349
  };
4377
4350
  if (shouldEmitJson(options, command)) {
4378
4351
  console.log(JSON.stringify(payload, null, 2));
4352
+ if (failed.length > 0) {
4353
+ process.exitCode = 2;
4354
+ }
4355
+ return payload;
4356
+ }
4357
+ if (failed.length > 0) {
4358
+ for (const item of failed) {
4359
+ const details = [
4360
+ item.heldBy ? `held by ${item.heldBy}` : "",
4361
+ item.intent ? `intent: ${item.intent}` : "",
4362
+ item.expiresAt ? `expires ${item.expiresAt}` : "",
4363
+ ].filter(Boolean);
4364
+ console.error(
4365
+ pc.red(
4366
+ `Blocked ${item.file}: ${item.reason}${details.length > 0 ? ` (${details.join(", ")})` : ""}`,
4367
+ ),
4368
+ );
4369
+ }
4370
+ process.exitCode = 2;
4379
4371
  return payload;
4380
4372
  }
4381
- const action = verb === "lock" ? "Requested lock on" : "Released";
4373
+ const action = verb === "lock" ? "Acquired lease for" : "Released";
4382
4374
  console.log(pc.green(`${action} ${processed.length} file(s) as ${identity.agentId}: ${processed.join(", ")}`));
4383
4375
  if (verb === "lock") {
4384
4376
  console.log(
4385
- pc.gray("Senti enforces fail-closed; locks auto-release on TTL. Release with `sl session unlock`."),
4377
+ pc.gray(
4378
+ "The SentinelLayer API is authoritative; leases expire by TTL and can be released with `sl session unlock`.",
4379
+ ),
4386
4380
  );
4387
4381
  }
4388
4382
  return payload;
@@ -4390,8 +4384,9 @@ export function registerSessionCommand(program) {
4390
4384
 
4391
4385
  session
4392
4386
  .command("lock <sessionId> <files...>")
4393
- .description("Claim exclusive file locks via Senti (fail-closed, TTL auto-release)")
4387
+ .description("Claim exclusive API-backed file leases (fail-closed, TTL expiry)")
4394
4388
  .option("--intent <text>", "Why you're locking these files (shown to peers)")
4389
+ .option("--ttl <seconds>", "Lease TTL in seconds (15-3600)", "300")
4395
4390
  .option("--agent <id>", "Agent id claiming the lock (defaults to the joined session agent)")
4396
4391
  .option("--path <path>", "Workspace path for the session", ".")
4397
4392
  .option("--json", "Emit machine-readable output")
@@ -4401,7 +4396,7 @@ export function registerSessionCommand(program) {
4401
4396
 
4402
4397
  session
4403
4398
  .command("unlock <sessionId> <files...>")
4404
- .description("Release file locks you hold (Senti only lets the holder release)")
4399
+ .description("Release API-backed file leases you hold")
4405
4400
  .option("--intent <text>", "Optional note on the release")
4406
4401
  .option("--agent <id>", "Agent id releasing the lock (defaults to the joined session agent)")
4407
4402
  .option("--path <path>", "Workspace path for the session", ".")
@@ -4410,9 +4405,264 @@ export function registerSessionCommand(program) {
4410
4405
  await runFileLockCommand("unlock", sessionId, files, options, command);
4411
4406
  });
4412
4407
 
4408
+ session
4409
+ .command("renew <sessionId> <files...>")
4410
+ .description("Renew holder-bound file leases without writing session events")
4411
+ .option("--ttl <seconds>", "Renewed lease TTL in seconds (15-3600)", "300")
4412
+ .option("--agent <id>", "Agent id renewing the lease (defaults to the joined session agent)")
4413
+ .option("--path <path>", "Workspace path for the session", ".")
4414
+ .option("--json", "Emit machine-readable output")
4415
+ .action(async (sessionId, files, options, command) => {
4416
+ const normalizedSessionId = normalizeString(sessionId);
4417
+ if (!normalizedSessionId) {
4418
+ throw new Error("session id is required.");
4419
+ }
4420
+ const fileList = (Array.isArray(files) ? files : [files])
4421
+ .map((file) => normalizeString(file))
4422
+ .filter(Boolean);
4423
+ if (fileList.length === 0) {
4424
+ throw new Error("session renew requires at least one file path.");
4425
+ }
4426
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4427
+ await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
4428
+ const identity = await resolveMessageActionIdentity({
4429
+ sessionId: normalizedSessionId,
4430
+ optionAgent: options.agent,
4431
+ targetPath,
4432
+ env: process.env,
4433
+ });
4434
+ if (shouldBlockImplicitCliUserSessionSay(identity)) {
4435
+ throw new Error(
4436
+ identity.identityWarning ||
4437
+ "session renew requires an agent identity; pass --agent <id>.",
4438
+ );
4439
+ }
4440
+ const results = [];
4441
+ for (const file of fileList) {
4442
+ results.push(
4443
+ await renewFileLease(normalizedSessionId, identity.agentId, file, {
4444
+ ttlSeconds: options.ttl,
4445
+ targetPath,
4446
+ }),
4447
+ );
4448
+ }
4449
+ const payload = {
4450
+ command: "session renew",
4451
+ sessionId: normalizedSessionId,
4452
+ agentId: identity.agentId,
4453
+ allowed: results.every((result) => result.renewed),
4454
+ results,
4455
+ };
4456
+ if (shouldEmitJson(options, command)) {
4457
+ console.log(JSON.stringify(payload, null, 2));
4458
+ } else if (payload.allowed) {
4459
+ console.log(
4460
+ pc.green(`Renewed ${results.length} file lease(s) as ${identity.agentId}.`),
4461
+ );
4462
+ } else {
4463
+ console.error(
4464
+ pc.red("One or more file leases could not be renewed; edit access is not assured."),
4465
+ );
4466
+ }
4467
+ if (!payload.allowed) {
4468
+ process.exitCode = 2;
4469
+ }
4470
+ return payload;
4471
+ });
4472
+
4473
+ session
4474
+ .command("guard <sessionId> <files...>")
4475
+ .description("Fail-closed authoritative preflight for file edits")
4476
+ .requiredOption("--agent <id>", "Agent id presenting holder capabilities")
4477
+ .option("--path <path>", "Workspace path for the session", ".")
4478
+ .option("--json", "Emit machine-readable output")
4479
+ .action(async (sessionId, files, options, command) => {
4480
+ const normalizedSessionId = normalizeString(sessionId);
4481
+ if (!normalizedSessionId) {
4482
+ throw new Error("session id is required.");
4483
+ }
4484
+ const fileList = (Array.isArray(files) ? files : [files])
4485
+ .map((file) => normalizeString(file))
4486
+ .filter(Boolean);
4487
+ if (fileList.length === 0) {
4488
+ throw new Error("session guard requires at least one file path.");
4489
+ }
4490
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4491
+ const requestedAgentId = normalizeString(options.agent).toLowerCase();
4492
+ let result;
4493
+ try {
4494
+ result = await guardFileLeases(
4495
+ normalizedSessionId,
4496
+ requestedAgentId,
4497
+ fileList,
4498
+ { targetPath },
4499
+ );
4500
+ } catch (error) {
4501
+ const message = safeFileLeaseErrorMessage(error);
4502
+ result = {
4503
+ ok: false,
4504
+ allowed: false,
4505
+ authoritative: false,
4506
+ sessionId: normalizedSessionId,
4507
+ holderId: requestedAgentId || null,
4508
+ checkedAt: new Date().toISOString(),
4509
+ validUntil: null,
4510
+ files: fileList,
4511
+ guarded: [],
4512
+ denials: fileList.map((file) => ({
4513
+ path: file,
4514
+ reason: "guard_failed",
4515
+ })),
4516
+ error: {
4517
+ code: normalizeString(error?.code) || "FILE_LEASE_GUARD_FAILED",
4518
+ message,
4519
+ },
4520
+ };
4521
+ }
4522
+ const payload = {
4523
+ command: "session guard",
4524
+ ...result,
4525
+ };
4526
+ if (shouldEmitJson(options, command)) {
4527
+ console.log(JSON.stringify(payload, null, 2));
4528
+ } else if (result.allowed) {
4529
+ console.log(
4530
+ pc.green(`File-lease guard allowed ${fileList.length} edit target(s).`),
4531
+ );
4532
+ } else {
4533
+ if (result.error?.message) {
4534
+ console.error(pc.red(`Authoritative guard failed: ${result.error.message}`));
4535
+ }
4536
+ if ((result.denials || []).length > 0) {
4537
+ console.error(
4538
+ pc.red(`Edit blocked: ${formatFileLeaseDenialSummary(result)}`),
4539
+ );
4540
+ }
4541
+ }
4542
+ if (!result.allowed) {
4543
+ process.exitCode = 2;
4544
+ }
4545
+ return payload;
4546
+ });
4547
+
4548
+ session
4549
+ .command("guard-hook <sessionId>")
4550
+ .description("Claude PreToolUse hook: read tool JSON on stdin and block denied edits")
4551
+ .requiredOption("--agent <id>", "Agent id presenting holder capabilities")
4552
+ .option("--path <path>", "Workspace root", ".")
4553
+ .action(async (sessionId, options) => {
4554
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4555
+ const result = await runFileLeaseGuardHook({
4556
+ sessionId,
4557
+ agentId: options.agent,
4558
+ targetPath,
4559
+ });
4560
+ process.exitCode = result.exitCode;
4561
+ return result;
4562
+ });
4563
+
4564
+ session
4565
+ .command("guard-install <sessionId>")
4566
+ .description("Install Claude, terminal, and VS Code authoritative lease preflights")
4567
+ .requiredOption("--agent <id>", "Agent id presenting holder capabilities")
4568
+ .option("--path <path>", "Workspace root", ".")
4569
+ .option("--json", "Emit machine-readable output")
4570
+ .action(async (sessionId, options, command) => {
4571
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4572
+ const result = await installFileLeaseIntegrations(
4573
+ sessionId,
4574
+ options.agent,
4575
+ { targetPath },
4576
+ );
4577
+ if (shouldEmitJson(options, command)) {
4578
+ console.log(JSON.stringify({
4579
+ command: "session guard-install",
4580
+ ...result,
4581
+ }, null, 2));
4582
+ } else {
4583
+ console.log(
4584
+ pc.green(
4585
+ "Installed Claude PreToolUse blocking plus terminal and VS Code lease preflights.",
4586
+ ),
4587
+ );
4588
+ console.log(
4589
+ pc.yellow(
4590
+ "VS Code native Save cannot be cancelled reliably; run the generated guard task before edits or make it a dependency of mutation tasks.",
4591
+ ),
4592
+ );
4593
+ }
4594
+ return result;
4595
+ });
4596
+
4597
+ session
4598
+ .command("guard-uninstall <sessionId>")
4599
+ .description("Safely remove only fingerprinted SentinelLayer lease preflights before CLI rollback")
4600
+ .requiredOption("--agent <id>", "Agent id that owns the managed integration")
4601
+ .option("--path <path>", "Workspace root", ".")
4602
+ .option("--json", "Emit machine-readable output")
4603
+ .action(async (sessionId, options, command) => {
4604
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4605
+ let result;
4606
+ try {
4607
+ result = await uninstallFileLeaseIntegrations(
4608
+ sessionId,
4609
+ options.agent,
4610
+ { targetPath },
4611
+ );
4612
+ } catch (error) {
4613
+ result = {
4614
+ ok: false,
4615
+ uninstalled: false,
4616
+ reason: "guard_uninstall_failed",
4617
+ sessionId: normalizeString(sessionId),
4618
+ agentId: normalizeString(options.agent),
4619
+ activeLeases: [],
4620
+ residuals: [],
4621
+ error: {
4622
+ code: normalizeString(error?.code) || "FILE_LEASE_GUARD_UNINSTALL_FAILED",
4623
+ message: safeFileLeaseErrorMessage(error),
4624
+ },
4625
+ };
4626
+ }
4627
+ const payload = {
4628
+ command: "session guard-uninstall",
4629
+ ...result,
4630
+ };
4631
+ if (shouldEmitJson(options, command)) {
4632
+ console.log(JSON.stringify(payload, null, 2));
4633
+ } else if (result.ok) {
4634
+ console.log(pc.green("Removed the managed SentinelLayer lease preflights."));
4635
+ } else {
4636
+ if (result.error?.message) {
4637
+ console.error(pc.red(`Guard uninstall blocked: ${result.error.message}`));
4638
+ }
4639
+ for (const lease of result.activeLeases || []) {
4640
+ const file = normalizeString(lease.file || lease.path) || "(unknown path)";
4641
+ const holder = normalizeString(lease.agentId || lease.holderId) || "unknown";
4642
+ const expiresAt = normalizeString(lease.expiresAt);
4643
+ console.error(
4644
+ pc.red(
4645
+ `Guard uninstall blocked by ${file} (held by ${holder}${expiresAt ? `, expires ${expiresAt}` : ""}).`,
4646
+ ),
4647
+ );
4648
+ }
4649
+ for (const residual of result.residuals || []) {
4650
+ console.error(
4651
+ pc.red(
4652
+ `Managed artifact retained: ${residual.path || residual.label || residual.artifact} (${residual.reason}).`,
4653
+ ),
4654
+ );
4655
+ }
4656
+ }
4657
+ if (!result.ok) {
4658
+ process.exitCode = 2;
4659
+ }
4660
+ return payload;
4661
+ });
4662
+
4413
4663
  session
4414
4664
  .command("locks <sessionId>")
4415
- .description("List active file locks for the session (who holds what, and when they expire)")
4665
+ .description("List authoritative active file leases for the session")
4416
4666
  .option("--path <path>", "Workspace path for the session", ".")
4417
4667
  .option("--json", "Emit machine-readable output")
4418
4668
  .action(async (sessionId, options, command) => {
@@ -4451,11 +4701,11 @@ export function registerSessionCommand(program) {
4451
4701
  session
4452
4702
  .command("listeners <sessionId>")
4453
4703
  .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.",
4704
+ "List the authoritative ephemeral listener presence roster. Presence is unknown when the server capability or Redis is unavailable; transcript heartbeats are never scanned.",
4455
4705
  )
4456
4706
  .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")
4707
+ .option("--limit <n>", "Deprecated compatibility option; no event rows are scanned", "200")
4708
+ .option("--max-pages <n>", "Deprecated compatibility option; no event rows are scanned", "5")
4459
4709
  .option("--json", "Emit machine-readable output")
4460
4710
  .action(async (sessionId, options, command) => {
4461
4711
  const normalizedSessionId = normalizeString(sessionId);
@@ -4512,12 +4762,18 @@ export function registerSessionCommand(program) {
4512
4762
  };
4513
4763
  }
4514
4764
  }
4515
- const live = listeners.filter((row) => row.status === "active" || row.status === "idle").length;
4765
+ const live = listeners.filter(
4766
+ (row) => row.status === "present" || row.status === "active" || row.status === "idle",
4767
+ ).length;
4516
4768
  const payload = {
4517
4769
  command: "session listeners",
4518
4770
  sessionId: normalizedSessionId,
4519
4771
  ok: Boolean(result.ok),
4520
4772
  reason: result.ok ? undefined : result.reason,
4773
+ authoritative: Boolean(result.authoritative),
4774
+ presenceStatus: result.presenceStatus || "degraded",
4775
+ presenceEnabled: result.enabled ?? null,
4776
+ retryAfterMs: result.retryAfterMs || null,
4521
4777
  count: listeners.length,
4522
4778
  liveCount: live,
4523
4779
  pageCount: Number(result.pageCount || 0),
@@ -4533,17 +4789,21 @@ export function registerSessionCommand(program) {
4533
4789
  return payload;
4534
4790
  }
4535
4791
  if (!result.ok) {
4536
- console.log(pc.yellow(`Could not read listeners (${result.reason}).`));
4792
+ console.log(
4793
+ pc.yellow(
4794
+ `Listener presence is unknown (${result.reason}); durable heartbeat history was not scanned.`,
4795
+ ),
4796
+ );
4537
4797
  return payload;
4538
4798
  }
4539
4799
  if (listeners.length === 0) {
4540
- console.log(pc.gray("No listeners detected (no recent presence heartbeats)."));
4800
+ console.log(pc.gray("No listeners are currently present."));
4541
4801
  return payload;
4542
4802
  }
4543
4803
  console.log(pc.bold(`Listeners (${live} live / ${listeners.length} seen)`));
4544
4804
  for (const row of listeners) {
4545
4805
  const line = formatListenerLine(row);
4546
- if (row.status === "active") console.log(pc.green(` ${line}`));
4806
+ if (row.status === "present" || row.status === "active") console.log(pc.green(` ${line}`));
4547
4807
  else if (row.status === "idle") console.log(pc.cyan(` ${line}`));
4548
4808
  else console.log(pc.gray(` ${line}`));
4549
4809
  }
@@ -4563,7 +4823,7 @@ export function registerSessionCommand(program) {
4563
4823
  session
4564
4824
  .command("stop-listener <sessionId>")
4565
4825
  .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.",
4826
+ "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
4827
  )
4568
4828
  .option("--agent <id>", "Agent whose listener to stop (omit to stop all listeners in the room)")
4569
4829
  .option("--path <path>", "Workspace path for the session", ".")
@@ -4576,27 +4836,21 @@ export function registerSessionCommand(program) {
4576
4836
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4577
4837
  const targetAgent = normalizeString(options.agent);
4578
4838
  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
- },
4839
+ const listenerControl = await requestSessionListenerStop(normalizedSessionId, {
4840
+ targetPath,
4841
+ targetAgentId: targetAgent,
4589
4842
  });
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(() => {});
4843
+ if (!listenerControl?.ok || !listenerControl?.recorded) {
4844
+ throw new Error(
4845
+ `Listener stop request failed (${normalizeString(listenerControl?.reason) || "not_recorded"}).`,
4846
+ );
4847
+ }
4594
4848
  const payload = {
4595
4849
  command: "session stop-listener",
4596
4850
  sessionId: normalizedSessionId,
4597
4851
  targetAgent: targetAgent || null,
4598
4852
  scope: targetAgent ? "agent" : "all",
4599
- remoteSync: remoteSync || undefined,
4853
+ listenerControl,
4600
4854
  };
4601
4855
  if (shouldEmitJson(options, command)) {
4602
4856
  console.log(JSON.stringify(payload, null, 2));
@@ -4624,8 +4878,8 @@ export function registerSessionCommand(program) {
4624
4878
  .option("--interval <seconds>", "Idle polling interval in seconds (default 60)", "60")
4625
4879
  .option(
4626
4880
  "--active-interval <seconds>",
4627
- "Polling interval after recent human activity (default 5)",
4628
- "5",
4881
+ "Polling interval after recent human activity (default 60)",
4882
+ "60",
4629
4883
  )
4630
4884
  .option(
4631
4885
  "--active-window <seconds>",
@@ -4634,17 +4888,17 @@ export function registerSessionCommand(program) {
4634
4888
  )
4635
4889
  .option(
4636
4890
  "--presence-interval <seconds>",
4637
- "Minimum seconds between remote listener heartbeat events (default 60)",
4891
+ "Minimum seconds between ephemeral presence renewals (default 60)",
4638
4892
  "60",
4639
4893
  )
4640
4894
  .option(
4641
4895
  "--presence-keepalive <seconds>",
4642
- "Maximum seconds between unchanged remote listener heartbeat events (default 180)",
4896
+ "Maximum seconds between unchanged ephemeral presence renewals (default 180)",
4643
4897
  "180",
4644
4898
  )
4645
4899
  .option(
4646
4900
  "--no-presence",
4647
- "Do not publish durable listener lifecycle/heartbeat events",
4901
+ "Do not renew ephemeral remote presence",
4648
4902
  )
4649
4903
  .option(
4650
4904
  "--model <model>",
@@ -4653,7 +4907,7 @@ export function registerSessionCommand(program) {
4653
4907
  )
4654
4908
  .option("--display-name <name>", "Human-readable listener name for presence")
4655
4909
  .option("--emit <format>", "Output format: ndjson or text", "ndjson")
4656
- .option("--transport <mode>", "Listen transport: auto, stream, or poll (default auto)", "auto")
4910
+ .option("--transport <mode>", "Listen transport: poll, auto, or stream (default poll)", "poll")
4657
4911
  .option("--limit <n>", "Maximum events to request per poll (default 200)", "200")
4658
4912
  .option("--path <path>", "Workspace path for the session", ".")
4659
4913
  .option("--since <cursor>", "Override the persisted listen cursor")
@@ -4705,7 +4959,7 @@ export function registerSessionCommand(program) {
4705
4959
  const activeIntervalSeconds = parsePositiveInteger(
4706
4960
  options.activeInterval,
4707
4961
  "active-interval",
4708
- 5,
4962
+ 60,
4709
4963
  );
4710
4964
  const activeWindowSeconds = parsePositiveInteger(options.activeWindow, "active-window", 300);
4711
4965
  const presenceIntervalSeconds = parsePositiveInteger(
@@ -4781,7 +5035,7 @@ export function registerSessionCommand(program) {
4781
5035
  "--wake-host requires a valid host (claude|codex) and --resume-session <host-session-id>.",
4782
5036
  );
4783
5037
  }
4784
- const requestedTransport = normalizeString(options.transport).toLowerCase() || "auto";
5038
+ const requestedTransport = normalizeString(options.transport).toLowerCase() || "poll";
4785
5039
  if (!["auto", "stream", "poll"].includes(requestedTransport)) {
4786
5040
  throw new Error("--transport must be one of: auto, stream, poll.");
4787
5041
  }
@@ -4865,14 +5119,15 @@ export function registerSessionCommand(program) {
4865
5119
  const ac = new AbortController();
4866
5120
  const onSigint = () => ac.abort();
4867
5121
  process.on("SIGINT", onSigint);
4868
- const durablePresenceEnabled = options.presence !== false;
4869
- const publishPresence = durablePresenceEnabled && canPublishListenerPresence(agentId);
5122
+ const remotePresenceEnabled = options.presence !== false;
5123
+ const publishPresence = remotePresenceEnabled && canPublishListenerPresence(agentId);
4870
5124
  const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
4871
5125
  const effectivePresenceKeepaliveSeconds =
4872
5126
  Math.max(presenceIntervalSeconds, presenceKeepaliveSeconds, intervalSeconds, 1);
4873
5127
  const presenceKeepaliveMs = effectivePresenceKeepaliveSeconds * 1000;
4874
5128
  let lastPresenceHeartbeatMs = 0;
4875
5129
  let lastPresenceHeartbeatFingerprint = "";
5130
+ let nextPresenceAttemptMs = 0;
4876
5131
 
4877
5132
  // Periodic in-session success reminders (ack, claim work, reply
4878
5133
  // in-thread). Long-running interactive listeners only — skipped under
@@ -4920,10 +5175,10 @@ export function registerSessionCommand(program) {
4920
5175
  `Listening to session ${normalizedSessionId} as ${agentId}; transport=${listenTransport} idle=${intervalSeconds}s active=${activeIntervalSeconds}s/${activeWindowSeconds}s. Press Ctrl+C to stop.`,
4921
5176
  ),
4922
5177
  );
4923
- if (!durablePresenceEnabled) {
5178
+ if (!remotePresenceEnabled) {
4924
5179
  console.log(
4925
5180
  pc.gray(
4926
- "Remote listener presence is disabled; no durable lifecycle events will be written.",
5181
+ "Remote listener presence is disabled; no liveness writes will be made.",
4927
5182
  ),
4928
5183
  );
4929
5184
  } else if (!publishPresence) {
@@ -4974,21 +5229,17 @@ export function registerSessionCommand(program) {
4974
5229
  console.log(pc.yellow(formatListenerCatchupNotice(catchup)));
4975
5230
  }
4976
5231
  },
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
- }
5232
+ onControl: async (control) => {
5233
+ const controlType = normalizeString(
5234
+ control?.type || control?.action || control?.event,
5235
+ ).toLowerCase();
5236
+ if (!["stop", "listener_stop"].includes(controlType)) return;
5237
+ if (emitFormat !== "ndjson") {
5238
+ console.log(pc.yellow(`Listener stop requested for ${agentId}; exiting.`));
4991
5239
  }
5240
+ ac.abort();
5241
+ },
5242
+ onEvent: async (event) => {
4992
5243
  if (emitFormat === "ndjson") {
4993
5244
  console.log(JSON.stringify(event));
4994
5245
  } else {
@@ -5054,13 +5305,17 @@ export function registerSessionCommand(program) {
5054
5305
  }
5055
5306
  if (!publishPresence) return;
5056
5307
  const lifecycleType = normalizeString(lifecycle?.type);
5308
+ const presenceAttemptMs = Date.now();
5309
+ if (lifecycleType !== "stopped" && presenceAttemptMs < nextPresenceAttemptMs) {
5310
+ return;
5311
+ }
5057
5312
  const publishLifecycle = {
5058
5313
  ...lifecycle,
5059
5314
  presenceIntervalSeconds,
5060
5315
  presenceKeepaliveSeconds: effectivePresenceKeepaliveSeconds,
5061
5316
  };
5062
5317
  if (lifecycleType === "heartbeat") {
5063
- const nowMs = Date.now();
5318
+ const nowMs = presenceAttemptMs;
5064
5319
  const heartbeatDecision = shouldPublishListenerPresenceHeartbeat({
5065
5320
  lifecycle: publishLifecycle,
5066
5321
  nowMs,
@@ -5075,7 +5330,7 @@ export function registerSessionCommand(program) {
5075
5330
  lastPresenceHeartbeatFingerprint = heartbeatDecision.fingerprint;
5076
5331
  lastPresenceHeartbeatMs = nowMs;
5077
5332
  }
5078
- await publishListenerPresenceEvent({
5333
+ const presenceResult = await publishListenerPresenceEvent({
5079
5334
  sessionId: normalizedSessionId,
5080
5335
  targetPath,
5081
5336
  agentId,
@@ -5086,6 +5341,15 @@ export function registerSessionCommand(program) {
5086
5341
  listenerId,
5087
5342
  lifecycle: publishLifecycle,
5088
5343
  });
5344
+ if (presenceResult?.ok) {
5345
+ nextPresenceAttemptMs = 0;
5346
+ return;
5347
+ }
5348
+ const retryAfterMs = Math.max(
5349
+ presenceIntervalMs,
5350
+ Number(presenceResult?.retryAfterMs) || 0,
5351
+ );
5352
+ nextPresenceAttemptMs = presenceAttemptMs + retryAfterMs;
5089
5353
  },
5090
5354
  });
5091
5355
  } finally {
@@ -5565,6 +5829,8 @@ export function registerSessionCommand(program) {
5565
5829
  let hydration = null;
5566
5830
  let remoteTail = null;
5567
5831
  let remoteAppend = null;
5832
+ let listenerPresence = null;
5833
+ let readCursor = null;
5568
5834
  if (options.remote) {
5569
5835
  hydration = await hydrateSessionFromRemote({
5570
5836
  sessionId: normalizedSessionId,
@@ -5592,12 +5858,27 @@ export function registerSessionCommand(program) {
5592
5858
  remoteAppend = await appendMissingRemoteEvents(normalizedSessionId, remoteTail.events, {
5593
5859
  targetPath,
5594
5860
  });
5861
+ readCursor = recordSessionReadCursor(normalizedSessionId, remoteTail.events, {
5862
+ targetPath,
5863
+ agentId,
5864
+ enabled: true,
5865
+ });
5595
5866
  }
5867
+ listenerPresence = await fetchSessionListeners(normalizedSessionId, {
5868
+ targetPath,
5869
+ }).catch((error) => ({
5870
+ ok: false,
5871
+ authoritative: false,
5872
+ presenceStatus: "degraded",
5873
+ reason: normalizeString(error?.message) || "presence_read_failed",
5874
+ listeners: [],
5875
+ }));
5596
5876
  }
5597
5877
  const current = await buildSessionRecap(normalizedSessionId, {
5598
5878
  forAgentId: agentId,
5599
5879
  maxEvents,
5600
5880
  targetPath,
5881
+ listenerPresence,
5601
5882
  });
5602
5883
  const payload = {
5603
5884
  command: "session recap now",
@@ -5622,6 +5903,8 @@ export function registerSessionCommand(program) {
5622
5903
  }
5623
5904
  : null,
5624
5905
  appendedTail: remoteAppend,
5906
+ listenerPresence,
5907
+ readCursor,
5625
5908
  }
5626
5909
  : null,
5627
5910
  };
@@ -5841,11 +6124,10 @@ export function registerSessionCommand(program) {
5841
6124
  ? filterSessionActionEventsForTranscriptWindow(visibleActionEvents, transcriptEvents)
5842
6125
  : visibleActionEvents;
5843
6126
  const events = mergeSessionActionEvents(transcriptEvents, actionEvents).slice(-tail);
5844
- const autoView = recordSessionReadViews(normalizedSessionId, events, {
6127
+ const autoView = recordSessionReadCursor(normalizedSessionId, events, {
5845
6128
  targetPath,
5846
6129
  agentId: readAgentId,
5847
6130
  enabled: Boolean(options.remote && options.view !== false),
5848
- maxTargets: process.env.SENTINELAYER_SESSION_READ_VIEW_MAX_TARGETS || 50,
5849
6131
  timeoutMs: process.env.SENTINELAYER_SESSION_READ_VIEW_TIMEOUT_MS || SESSION_READ_AUTO_VIEW_TIMEOUT_MS,
5850
6132
  });
5851
6133
  const remoteVerified = Boolean(
@@ -6054,6 +6336,66 @@ export function registerSessionCommand(program) {
6054
6336
  }
6055
6337
  });
6056
6338
 
6339
+ session
6340
+ .command("recall <need>")
6341
+ .description(
6342
+ "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)",
6343
+ )
6344
+ .requiredOption("--session <id>", "Session id to recall from")
6345
+ .option("--role <role>", "Optional role lens folded into the query")
6346
+ .option("--k <n>", "Number of memories to return (default 12, max 50)", "12")
6347
+ .option("--path <path>", "Workspace path for the session", ".")
6348
+ .option("--no-remote", "Skip remote hydrate; recall over the local mirror only")
6349
+ .option("--include-control-events", "Index control-plane events as memories too")
6350
+ .option("--json", "Emit machine-readable output")
6351
+ .action(async (need, options, command) => {
6352
+ const normalizedNeed = normalizeString(need);
6353
+ if (normalizedNeed.length < 2) {
6354
+ throw new Error("need must be at least 2 characters.");
6355
+ }
6356
+ const normalizedSessionId = normalizeString(options.session);
6357
+ if (!normalizedSessionId) {
6358
+ throw new Error("--session <id> is required.");
6359
+ }
6360
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6361
+ const k = Math.min(50, parsePositiveInteger(options.k, "k", 12));
6362
+ const emitJson = shouldEmitJson(options, command);
6363
+
6364
+ const result = await runSessionRecall({
6365
+ sessionId: normalizedSessionId,
6366
+ need: normalizedNeed,
6367
+ targetPath,
6368
+ k,
6369
+ role: normalizeString(options.role),
6370
+ remote: options.remote !== false,
6371
+ includeControlEvents: Boolean(options.includeControlEvents),
6372
+ });
6373
+
6374
+ if (emitJson) {
6375
+ console.log(JSON.stringify({ command: "session recall", targetPath, ...result }, null, 2));
6376
+ return;
6377
+ }
6378
+ if (!result.ok) {
6379
+ throw new Error(`Session recall failed (${result.reason || "unknown"}).`);
6380
+ }
6381
+ if (result.backfill && result.backfill.warning) {
6382
+ console.log(pc.yellow(result.backfill.warning));
6383
+ }
6384
+ console.log(
6385
+ pc.gray(
6386
+ `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}).`,
6387
+ ),
6388
+ );
6389
+ result.results.forEach((item, index) => {
6390
+ const sequence = item.sequenceId ? `#${item.sequenceId}` : "";
6391
+ console.log(`${String(index + 1).padStart(2)}. ${sequence} ${item.agentId} ${item.kind} (score ${item.score})`);
6392
+ if (item.snippet) {
6393
+ console.log(` ${item.snippet}`);
6394
+ }
6395
+ console.log(pc.gray(` why: ${item.provenance}`));
6396
+ });
6397
+ });
6398
+
6057
6399
  session
6058
6400
  .command("sync <sessionId>")
6059
6401
  .description(
@@ -6422,7 +6764,14 @@ export function registerSessionCommand(program) {
6422
6764
  throw new Error(`Session '${normalizedSessionId}' was not found.`);
6423
6765
  }
6424
6766
 
6425
- const [agents, runtimeRuns, leases, fileLocks, activeTasks, recentEvents] = await Promise.all([
6767
+ const [
6768
+ agents,
6769
+ runtimeRuns,
6770
+ leases,
6771
+ fileLeaseStatus,
6772
+ activeTasks,
6773
+ recentEvents,
6774
+ ] = await Promise.all([
6426
6775
  listAgents(normalizedSessionId, {
6427
6776
  targetPath,
6428
6777
  includeInactive: false,
@@ -6441,10 +6790,22 @@ export function registerSessionCommand(program) {
6441
6790
  includeExpired: true,
6442
6791
  limit: 100,
6443
6792
  }),
6444
- listFileLocks(normalizedSessionId, {
6445
- targetPath,
6446
- emitExpiredEvents: false,
6447
- }),
6793
+ listFileLocks(normalizedSessionId, { targetPath })
6794
+ .then((activeFileLocks) => ({
6795
+ ok: true,
6796
+ authoritative: true,
6797
+ activeFileLocks,
6798
+ error: null,
6799
+ }))
6800
+ .catch((error) => ({
6801
+ ok: false,
6802
+ authoritative: false,
6803
+ activeFileLocks: [],
6804
+ error: {
6805
+ code: normalizeString(error?.code) || "FILE_LEASE_LIST_FAILED",
6806
+ message: safeFileLeaseErrorMessage(error),
6807
+ },
6808
+ })),
6448
6809
  listSessionTasks(normalizedSessionId, {
6449
6810
  targetPath,
6450
6811
  statuses: ["PENDING", "ACCEPTED"],
@@ -6455,42 +6816,51 @@ export function registerSessionCommand(program) {
6455
6816
  tail: 10,
6456
6817
  }),
6457
6818
  ]);
6819
+ const fileLocks = fileLeaseStatus.activeFileLocks;
6458
6820
 
6459
6821
  const staleAgents = detectStaleAgents(agents, {});
6460
6822
  const staleAgentIds = new Set(staleAgents.map((agent) => normalizeString(agent.agentId)));
6461
6823
  const activeAgents = agents.filter((agent) => !staleAgentIds.has(normalizeString(agent.agentId)));
6462
- let listenerRows = summarizeListeners(recentEvents);
6824
+ const materialRecentEvents = filterSessionMaterialEvents(recentEvents);
6825
+ const hiddenControlEventCount = recentEvents.length - materialRecentEvents.length;
6826
+ let listenerRows = [];
6463
6827
  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,
6828
+ source: "remote_presence",
6829
+ ok: false,
6830
+ authoritative: false,
6831
+ presenceStatus: "degraded",
6832
+ reason: "presence_unknown",
6833
+ count: 0,
6834
+ liveCount: 0,
6835
+ listeners: [],
6469
6836
  };
6470
6837
  try {
6471
6838
  const remoteListeners = await fetchSessionListeners(normalizedSessionId, {
6472
6839
  targetPath,
6473
- limit: 200,
6474
- maxPages: 5,
6475
6840
  });
6476
- if (remoteListeners?.ok && Array.isArray(remoteListeners.listeners) && remoteListeners.listeners.length > 0) {
6841
+ if (remoteListeners?.ok && Array.isArray(remoteListeners.listeners)) {
6477
6842
  listenerRows = remoteListeners.listeners;
6478
6843
  listenerPresence = {
6479
- source: "remote_events",
6844
+ source: "remote_presence",
6480
6845
  ok: true,
6846
+ authoritative: true,
6847
+ presenceStatus: remoteListeners.presenceStatus || "ok",
6481
6848
  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),
6849
+ liveCount: listenerRows.filter(
6850
+ (row) => row.status === "present" || row.status === "active" || row.status === "idle",
6851
+ ).length,
6487
6852
  listeners: listenerRows,
6488
6853
  };
6489
6854
  } else if (remoteListeners && remoteListeners.ok === false) {
6490
- listenerPresence.remoteReason = normalizeString(remoteListeners.reason) || "remote_unavailable";
6855
+ listenerPresence = {
6856
+ ...listenerPresence,
6857
+ presenceStatus: remoteListeners.presenceStatus || "degraded",
6858
+ reason: normalizeString(remoteListeners.reason) || "remote_unavailable",
6859
+ retryAfterMs: remoteListeners.retryAfterMs || null,
6860
+ };
6491
6861
  }
6492
6862
  } catch (error) {
6493
- listenerPresence.remoteReason = normalizeString(error?.message) || "remote_unavailable";
6863
+ listenerPresence.reason = normalizeString(error?.message) || "remote_unavailable";
6494
6864
  }
6495
6865
  const liveListenerCount = listenerPresence.liveCount;
6496
6866
  const payload = {
@@ -6505,8 +6875,14 @@ export function registerSessionCommand(program) {
6505
6875
  runtimeRuns,
6506
6876
  activeLeases: leases.assignments,
6507
6877
  activeFileLocks: fileLocks,
6878
+ fileLeaseAuthority: {
6879
+ ok: fileLeaseStatus.ok,
6880
+ authoritative: fileLeaseStatus.authoritative,
6881
+ error: fileLeaseStatus.error,
6882
+ },
6508
6883
  activeTasks: activeTasks.tasks,
6509
- recentEvents,
6884
+ recentEvents: materialRecentEvents,
6885
+ hiddenControlEventCount,
6510
6886
  };
6511
6887
  if (shouldEmitJson(options, command)) {
6512
6888
  console.log(JSON.stringify(payload, null, 2));
@@ -6514,12 +6890,15 @@ export function registerSessionCommand(program) {
6514
6890
  }
6515
6891
 
6516
6892
  console.log(pc.bold(`Session ${normalizedSessionId}`));
6893
+ const listenerText = listenerPresence.authoritative
6894
+ ? `${liveListenerCount}/${listenerRows.length}`
6895
+ : `unknown(${listenerPresence.presenceStatus || "degraded"})`;
6517
6896
  console.log(
6518
6897
  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}`
6898
+ `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
6899
  )
6521
6900
  );
6522
- for (const event of recentEvents) {
6901
+ for (const event of materialRecentEvents) {
6523
6902
  console.log(formatEventLine(event));
6524
6903
  }
6525
6904
  });