sentinelayer-cli 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import fsp from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
+ import { setTimeout as sleep } from "node:timers/promises";
4
5
  import { createHash, randomUUID } from "node:crypto";
5
6
 
6
7
  import pc from "picocolors";
@@ -26,6 +27,7 @@ import { createAgentEvent } from "../events/schema.js";
26
27
  import {
27
28
  detectStaleAgents,
28
29
  listAgents,
30
+ rememberAgentIdentity,
29
31
  registerAgent,
30
32
  unregisterAgent,
31
33
  } from "../session/agent-registry.js";
@@ -64,6 +66,7 @@ import {
64
66
  listSessionMessageActions,
65
67
  listSessionsFromApi,
66
68
  probeSessionAccess,
69
+ pollSessionEvents,
67
70
  pollSessionEventsBefore,
68
71
  searchSessionEvents,
69
72
  syncSessionEventToApi,
@@ -85,8 +88,16 @@ import {
85
88
  import {
86
89
  createSessionCheckpoint,
87
90
  generateSessionCheckpoint,
91
+ generateSessionCheckpointBatch,
88
92
  listSessionCheckpoints,
89
93
  } from "../session/checkpoints.js";
94
+ import {
95
+ buildCodexExecResumeInvocation,
96
+ buildCodexWakePrompt,
97
+ recordCodexWakeRegistration,
98
+ runCodexExecResume,
99
+ } from "../session/wake/codex.js";
100
+ import createSentid from "../session/wake/sentid.js";
90
101
  import { authLoginHint, preferredCliCommand } from "../ui/command-hints.js";
91
102
  import { parseCsvTokens } from "./ai/shared.js";
92
103
 
@@ -101,6 +112,164 @@ function normalizeString(value) {
101
112
  return String(value || "").trim();
102
113
  }
103
114
 
115
+ const SESSION_SAY_CONFIRM_ATTEMPTS = 3;
116
+ const SESSION_SAY_CONFIRM_DELAY_MS = 250;
117
+ const SESSION_SAY_CONFIRM_PAGE_LIMIT = 200;
118
+ const SESSION_SAY_CONFIRM_MAX_PAGES = 10;
119
+ const SESSION_SAY_LOCAL_ONLY_REASONS = new Set([
120
+ "no_session",
121
+ "not_authenticated",
122
+ "remote_sync_disabled_env",
123
+ ]);
124
+
125
+ function isLocalOnlySessionSayReason(reason) {
126
+ return SESSION_SAY_LOCAL_ONLY_REASONS.has(normalizeString(reason));
127
+ }
128
+
129
+ function sessionEventMatchesClientMessageId(event, clientMessageId) {
130
+ const normalizedClientMessageId = normalizeString(clientMessageId);
131
+ if (!normalizedClientMessageId || !event || typeof event !== "object") {
132
+ return false;
133
+ }
134
+ const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
135
+ const candidates = [
136
+ event.id,
137
+ event.eventId,
138
+ event.event_id,
139
+ event.idempotencyToken,
140
+ event.idempotency_token,
141
+ event.clientMessageId,
142
+ event.client_message_id,
143
+ payload.id,
144
+ payload.messageId,
145
+ payload.message_id,
146
+ payload.eventId,
147
+ payload.event_id,
148
+ payload.idempotencyToken,
149
+ payload.idempotency_token,
150
+ payload.clientMessageId,
151
+ payload.client_message_id,
152
+ ];
153
+ return candidates.some((candidate) => normalizeString(candidate) === normalizedClientMessageId);
154
+ }
155
+
156
+ async function readSessionConfirmationAnchor(sessionId, { targetPath } = {}) {
157
+ const result = await pollSessionEventsBefore(sessionId, {
158
+ targetPath,
159
+ limit: 1,
160
+ forceCircuitProbe: true,
161
+ });
162
+ if (!result?.ok) {
163
+ return {
164
+ ok: false,
165
+ reason: normalizeString(result?.reason) || "confirmation_anchor_failed",
166
+ cursor: null,
167
+ };
168
+ }
169
+ const events = Array.isArray(result.events) ? result.events : [];
170
+ const lastEvent = events[events.length - 1] || null;
171
+ return {
172
+ ok: true,
173
+ reason: "",
174
+ cursor: normalizeString(lastEvent?.cursor) || normalizeString(result.cursor) || null,
175
+ sequenceId: Number(lastEvent?.sequenceId ?? lastEvent?.sequence_id) || null,
176
+ };
177
+ }
178
+
179
+ async function confirmSessionEventVisible(sessionId, clientMessageId, { targetPath, anchorCursor = null } = {}) {
180
+ let lastReason = "not_visible";
181
+ let checked = 0;
182
+ let pages = 0;
183
+ const normalizedAnchorCursor = normalizeString(anchorCursor) || null;
184
+ for (let attempt = 1; attempt <= SESSION_SAY_CONFIRM_ATTEMPTS; attempt += 1) {
185
+ let pageCursor = normalizedAnchorCursor;
186
+ for (let page = 1; page <= SESSION_SAY_CONFIRM_MAX_PAGES; page += 1) {
187
+ pages += 1;
188
+ const result = await pollSessionEvents(sessionId, {
189
+ targetPath,
190
+ since: pageCursor,
191
+ limit: SESSION_SAY_CONFIRM_PAGE_LIMIT,
192
+ forceCircuitProbe: true,
193
+ });
194
+ if (!result?.ok) {
195
+ lastReason = normalizeString(result?.reason) || "confirmation_poll_failed";
196
+ break;
197
+ }
198
+ const events = Array.isArray(result.events) ? result.events : [];
199
+ checked += events.length;
200
+ const confirmedEvent = events.find((candidate) => sessionEventMatchesClientMessageId(candidate, clientMessageId));
201
+ if (confirmedEvent) {
202
+ return {
203
+ confirmed: true,
204
+ reason: "",
205
+ checked,
206
+ pages,
207
+ anchorCursor: normalizedAnchorCursor,
208
+ event: confirmedEvent,
209
+ };
210
+ }
211
+ lastReason = "not_visible";
212
+ const nextCursor = normalizeString(result.cursor);
213
+ if (!events.length || !nextCursor || nextCursor === pageCursor) {
214
+ break;
215
+ }
216
+ pageCursor = nextCursor;
217
+ }
218
+ if (attempt < SESSION_SAY_CONFIRM_ATTEMPTS) {
219
+ await sleep(SESSION_SAY_CONFIRM_DELAY_MS);
220
+ }
221
+ }
222
+ return {
223
+ confirmed: false,
224
+ reason: lastReason,
225
+ checked,
226
+ pages,
227
+ anchorCursor: normalizedAnchorCursor,
228
+ };
229
+ }
230
+
231
+ function formatSessionListText(value, { maxLength = 80 } = {}) {
232
+ const normalized = normalizeString(value)
233
+ .replace(/[\r\n\t]+/g, " ")
234
+ .replace(/\s+/g, " ")
235
+ .replace(/"/g, "'");
236
+ if (!normalized) {
237
+ return "";
238
+ }
239
+ const limit = Math.max(8, Number(maxLength) || 80);
240
+ return normalized.length > limit
241
+ ? `${normalized.slice(0, Math.max(0, limit - 3)).trimEnd()}...`
242
+ : normalized;
243
+ }
244
+
245
+ function sessionListCodebaseLabel(value) {
246
+ const normalized = formatSessionListText(value, { maxLength: 120 });
247
+ if (!normalized) {
248
+ return "";
249
+ }
250
+ const segments = normalized.split(/[\\/]+/).filter(Boolean);
251
+ if (segments.length <= 2) {
252
+ return formatSessionListText(normalized, { maxLength: 80 });
253
+ }
254
+ return formatSessionListText(`.../${segments.slice(-2).join("/")}`, {
255
+ maxLength: 80,
256
+ });
257
+ }
258
+
259
+ export function formatSessionListLine(item = {}) {
260
+ const sessionId = normalizeString(item.sessionId) || "unknown-session";
261
+ const status = normalizeString(item.status) || "unknown";
262
+ const archiveStatus = normalizeString(item.archiveStatus);
263
+ const archive = archiveStatus ? ` archive=${archiveStatus}` : "";
264
+ const title = formatSessionListText(item.title || item.summaryText, { maxLength: 72 });
265
+ const codebase = sessionListCodebaseLabel(item.codebasePath);
266
+ const titlePart = title ? ` title="${title}"` : "";
267
+ const codebasePart = codebase ? ` codebase="${codebase}"` : "";
268
+ const created = item.createdAt || "?";
269
+ const lastActivity = item.lastActivityAt ? ` last=${item.lastActivityAt}` : "";
270
+ return `${sessionId} status=${status}${archive}${titlePart}${codebasePart} created=${created}${lastActivity}`;
271
+ }
272
+
104
273
  const SESSION_MESSAGE_ACTION_TYPES = new Set([
105
274
  "ack",
106
275
  "working_on",
@@ -150,7 +319,7 @@ const SESSION_MESSAGE_ACTION_DESCRIPTIONS = Object.freeze([
150
319
  {
151
320
  type: "view",
152
321
  command: "sl session view <id> <sequence>",
153
- description: "Record a read receipt for a target message.",
322
+ description: "Manually backfill a read receipt for a target message; remote reads record views automatically.",
154
323
  },
155
324
  ]);
156
325
 
@@ -219,6 +388,7 @@ function actionDisplayMessage(action = {}) {
219
388
  function buildSessionActionEvent(sessionId, action = {}) {
220
389
  const actionType = normalizeString(action.actionType ?? action.action_type).toLowerCase();
221
390
  if (!SESSION_MESSAGE_ACTION_TYPES.has(actionType)) return null;
391
+ if (actionType === "view") return null;
222
392
  const id =
223
393
  normalizeString(action.id) ||
224
394
  shortSha256(
@@ -265,6 +435,91 @@ function buildSessionActionEvents(sessionId, actions = []) {
265
435
  .filter(Boolean);
266
436
  }
267
437
 
438
+ function readProjectionUnacknowledgedHumanMessages(remoteActions = null) {
439
+ const messages = remoteActions?.projection?.unacknowledgedHumanMessages;
440
+ return Array.isArray(messages)
441
+ ? messages
442
+ .filter((event) => event && typeof event === "object")
443
+ .sort((left, right) => humanAskSortValue(right) - humanAskSortValue(left))
444
+ : [];
445
+ }
446
+
447
+ function messageActionCreatedMs(action = {}) {
448
+ const epoch = Date.parse(normalizeString(action.createdAt || action.created_at || action.ts || action.timestamp));
449
+ return Number.isFinite(epoch) ? epoch : 0;
450
+ }
451
+
452
+ function messageActionActorId(action = {}) {
453
+ return normalizeString(action.actorId || action.actor_id || action.agentId || action.agent_id) || "unknown";
454
+ }
455
+
456
+ function isHumanMessageAction(action = {}) {
457
+ if (action?.isHumanActivity === true) return true;
458
+ if (normalizeString(action.actorKind || action.actor_kind).toLowerCase() === "human") return true;
459
+ return messageActionActorId(action).startsWith("human-");
460
+ }
461
+
462
+ function readProjectionRecentHumanActivity(remoteActions = null) {
463
+ const projected = remoteActions?.projection?.recentActivity;
464
+ const source = Array.isArray(projected) && projected.length > 0 ? projected : remoteActions?.actions;
465
+ return Array.isArray(source)
466
+ ? source
467
+ .filter((action) => action && typeof action === "object" && isHumanMessageAction(action))
468
+ .sort((left, right) => {
469
+ const timeDiff = messageActionCreatedMs(right) - messageActionCreatedMs(left);
470
+ if (timeDiff !== 0) return timeDiff;
471
+ return normalizeString(right.id).localeCompare(normalizeString(left.id));
472
+ })
473
+ : [];
474
+ }
475
+
476
+ function formatMessageActionActivityLine(action = {}) {
477
+ const actionType = normalizeString(action.actionType || action.action_type) || "action";
478
+ const targetSequence = Number(action.targetSequenceId ?? action.target_sequence_id ?? 0);
479
+ const targetActionId = normalizeString(action.targetActionId || action.target_action_id);
480
+ const target = targetActionId
481
+ ? `action:${targetActionId}`
482
+ : targetSequence > 0
483
+ ? `#${Math.floor(targetSequence)}`
484
+ : normalizeString(action.targetCursor || action.target_cursor) || "unknown-target";
485
+ const ts = normalizeString(action.createdAt || action.created_at || action.ts || action.timestamp);
486
+ const note = normalizeString(action.note || action.message || "");
487
+ const shortNote = note.length > 220 ? `${note.slice(0, 217)}...` : note;
488
+ const suffix = shortNote ? `: ${shortNote}` : "";
489
+ return `${actionType} ${target} by ${messageActionActorId(action)}${ts ? ` ${ts}` : ""}${suffix}`;
490
+ }
491
+
492
+ function humanAskSequence(event = {}) {
493
+ const value = Number(event.sequenceId ?? event.sequence_id ?? event.sequence ?? 0);
494
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : null;
495
+ }
496
+
497
+ function humanAskAgentId(event = {}) {
498
+ return normalizeString(event.agent?.id || event.agentId || event.agent_id) || "human";
499
+ }
500
+
501
+ function humanAskMessage(event = {}) {
502
+ const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
503
+ return normalizeString(payload.message || payload.note || payload.reason || "");
504
+ }
505
+
506
+ function humanAskSortValue(event = {}) {
507
+ const sequence = humanAskSequence(event);
508
+ if (sequence) return sequence;
509
+ const epoch = Date.parse(normalizeString(event.ts || event.timestamp || event.createdAt || event.created_at));
510
+ return Number.isFinite(epoch) ? epoch : 0;
511
+ }
512
+
513
+ function formatHumanAskLine(event = {}) {
514
+ const sequence = humanAskSequence(event);
515
+ const sequenceLabel = sequence ? `#${sequence}` : normalizeString(event.cursor) || "unsequenced";
516
+ const ts = normalizeString(event.ts || event.timestamp || event.createdAt || event.created_at);
517
+ const message = humanAskMessage(event);
518
+ const shortMessage = message.length > 220 ? `${message.slice(0, 217)}...` : message;
519
+ const suffix = shortMessage ? `: ${shortMessage}` : "";
520
+ return `${sequenceLabel} ${humanAskAgentId(event)}${ts ? ` ${ts}` : ""}${suffix}`;
521
+ }
522
+
268
523
  function eventTimestampMs(event = {}) {
269
524
  for (const key of ["ts", "timestamp", "createdAt", "at"]) {
270
525
  const epoch = Date.parse(normalizeString(event?.[key]));
@@ -329,6 +584,99 @@ function defaultActionIdempotencyKey({
329
584
  return `cli:${normalizeString(actionType).toLowerCase()}:${target}:${actor}:${noteHash}`;
330
585
  }
331
586
 
587
+ function sessionReadViewTarget(event = {}) {
588
+ const eventType = normalizeString(event.event || event.type).toLowerCase();
589
+ if (eventType !== "session_message" || isSessionControlEvent(event)) {
590
+ return null;
591
+ }
592
+ const targetSequenceId = eventSequenceNumber(event);
593
+ const targetCursor = normalizeString(event.cursor);
594
+ if (!targetSequenceId && !targetCursor) {
595
+ return null;
596
+ }
597
+ return {
598
+ key: targetSequenceId ? `seq:${targetSequenceId}` : `cursor:${targetCursor}`,
599
+ targetSequenceId: targetSequenceId || null,
600
+ targetCursor,
601
+ };
602
+ }
603
+
604
+ async function recordSessionReadViews(sessionId, events = [], {
605
+ targetPath,
606
+ agentId,
607
+ enabled = false,
608
+ maxTargets = 50,
609
+ } = {}) {
610
+ const maxAutoViewTargets = Math.max(0, Math.min(200, Number(maxTargets) || 0));
611
+ const summary = {
612
+ enabled: Boolean(enabled),
613
+ agentId: normalizeString(agentId) || "cli-user",
614
+ targetCount: 0,
615
+ attempted: 0,
616
+ recorded: 0,
617
+ duplicates: 0,
618
+ failed: 0,
619
+ skipped: 0,
620
+ reason: "",
621
+ };
622
+ if (!summary.enabled) {
623
+ summary.reason = "disabled";
624
+ return summary;
625
+ }
626
+
627
+ const seenTargets = new Set();
628
+ const targets = [];
629
+ for (const event of Array.isArray(events) ? events : []) {
630
+ const target = sessionReadViewTarget(event);
631
+ if (!target || seenTargets.has(target.key)) {
632
+ continue;
633
+ }
634
+ seenTargets.add(target.key);
635
+ targets.push(target);
636
+ }
637
+ summary.targetCount = targets.length;
638
+
639
+ const writeTargets = maxAutoViewTargets > 0 ? targets.slice(-maxAutoViewTargets) : [];
640
+ summary.skipped = Math.max(0, targets.length - writeTargets.length);
641
+ if (summary.skipped > 0) {
642
+ summary.reason = "target_cap_reached";
643
+ }
644
+
645
+ for (const target of writeTargets) {
646
+ const result = await createSessionMessageAction(sessionId, {
647
+ actionType: "view",
648
+ targetPath,
649
+ targetSequenceId: target.targetSequenceId,
650
+ targetCursor: target.targetCursor,
651
+ metadata: {
652
+ source: "cli_read",
653
+ agentId: summary.agentId,
654
+ },
655
+ idempotencyKey: defaultActionIdempotencyKey({
656
+ actionType: "view",
657
+ targetSequenceId: target.targetSequenceId,
658
+ targetCursor: target.targetCursor,
659
+ agentId: summary.agentId,
660
+ }),
661
+ timeoutMs: 15_000,
662
+ });
663
+ summary.attempted += 1;
664
+ if (result?.ok && result.action) {
665
+ summary.recorded += 1;
666
+ if (result.duplicate) {
667
+ summary.duplicates += 1;
668
+ }
669
+ continue;
670
+ }
671
+ summary.failed += 1;
672
+ summary.reason = normalizeString(result?.reason) || "view_write_failed";
673
+ summary.skipped = Math.max(0, targets.length - summary.attempted);
674
+ break;
675
+ }
676
+
677
+ return summary;
678
+ }
679
+
332
680
  function compareIsoDesc(left = "", right = "") {
333
681
  return normalizeString(right).localeCompare(normalizeString(left));
334
682
  }
@@ -402,6 +750,10 @@ function parsePositiveInteger(rawValue, field, fallbackValue) {
402
750
  return Math.floor(normalized);
403
751
  }
404
752
 
753
+ function parsePositiveMillisecondsFromSeconds(rawValue, field, fallbackSeconds) {
754
+ return parsePositiveInteger(rawValue, field, fallbackSeconds) * 1000;
755
+ }
756
+
405
757
  function normalizeComparablePath(value) {
406
758
  return String(value || "")
407
759
  .trim()
@@ -961,6 +1313,140 @@ function normalizeAgentId(value, fallbackValue = "cli-user") {
961
1313
  return normalized || fallbackValue;
962
1314
  }
963
1315
 
1316
+ function canPublishListenerPresence(agentId) {
1317
+ const normalized = normalizeAgentId(agentId, "");
1318
+ if (!normalized) return false;
1319
+ if (["cli-user", "unknown", "human", "user", "operator"].includes(normalized)) return false;
1320
+ return !(
1321
+ normalized.startsWith("human-") ||
1322
+ normalized.startsWith("user-") ||
1323
+ normalized.startsWith("guest-")
1324
+ );
1325
+ }
1326
+
1327
+ function listenerLifecycleEventName(type = "") {
1328
+ const normalized = normalizeString(type).toLowerCase();
1329
+ if (normalized === "started") return "session_listener_started";
1330
+ if (normalized === "stopped") return "session_listener_stopped";
1331
+ return "session_listener_heartbeat";
1332
+ }
1333
+
1334
+ function compactPayload(record = {}) {
1335
+ return Object.fromEntries(
1336
+ Object.entries(record).filter(([, value]) => value !== undefined)
1337
+ );
1338
+ }
1339
+
1340
+ async function publishListenerPresenceEvent({
1341
+ sessionId,
1342
+ targetPath,
1343
+ agentId,
1344
+ agentModel = "cli",
1345
+ displayName = "",
1346
+ listenerId,
1347
+ lifecycle = {},
1348
+ } = {}) {
1349
+ const normalizedType = normalizeString(lifecycle.type) || "heartbeat";
1350
+ const eventName = listenerLifecycleEventName(normalizedType);
1351
+ const event = createAgentEvent({
1352
+ event: eventName,
1353
+ sessionId,
1354
+ agent: {
1355
+ id: agentId,
1356
+ model: normalizeString(agentModel) || "cli",
1357
+ role: "listener",
1358
+ displayName: normalizeString(displayName) || agentId,
1359
+ clientKind: "cli",
1360
+ },
1361
+ eventId: `session-listener-${listenerId}-${normalizedType}-${lifecycle.pollCount ?? 0}`,
1362
+ idempotencyToken: `session-listener:${listenerId}:${normalizedType}:${lifecycle.pollCount ?? 0}`,
1363
+ payload: compactPayload({
1364
+ source: "session_listen",
1365
+ listenerId,
1366
+ lifecycle: normalizedType,
1367
+ state: normalizeString(lifecycle.state) || normalizedType,
1368
+ active: lifecycle.active,
1369
+ cursor: lifecycle.cursor || null,
1370
+ cursorSuffix: lifecycle.cursorSuffix,
1371
+ cursorSource: lifecycle.cursorSource,
1372
+ pollCount: lifecycle.pollCount,
1373
+ matched: lifecycle.matched,
1374
+ emitted: lifecycle.emitted,
1375
+ persistedCursor: lifecycle.persistedCursor,
1376
+ idleIntervalSeconds: lifecycle.idleIntervalSeconds,
1377
+ activeIntervalSeconds: lifecycle.activeIntervalSeconds,
1378
+ activeWindowSeconds: lifecycle.activeWindowSeconds,
1379
+ lastHumanActivityAt: lifecycle.lastHumanActivityAt,
1380
+ lastSleepMs: lifecycle.lastSleepMs,
1381
+ nextPollMs: lifecycle.nextPollMs,
1382
+ reason: lifecycle.reason || null,
1383
+ startedAt: lifecycle.startedAt,
1384
+ stoppedAt: lifecycle.stoppedAt,
1385
+ aborted: lifecycle.aborted,
1386
+ stopping: lifecycle.stopping,
1387
+ }),
1388
+ });
1389
+ return syncSessionEventToApi(sessionId, event, { targetPath });
1390
+ }
1391
+
1392
+ function formatListenerCatchupNotice(catchup = {}) {
1393
+ const eventCount = Number(catchup.eventCount || 0);
1394
+ const matchingEventCount = Number(catchup.matchingEventCount || 0);
1395
+ const range = catchup.oldestEventAt && catchup.newestEventAt
1396
+ ? ` (${catchup.oldestEventAt} -> ${catchup.newestEventAt})`
1397
+ : "";
1398
+ return [
1399
+ `Listener catch-up from stored cursor ${catchup.cursor || "<none>"}:`,
1400
+ `${eventCount} event${eventCount === 1 ? "" : "s"} in this page`,
1401
+ `${matchingEventCount} addressed/broadcast to this agent${range}.`,
1402
+ "Use --from-now only when you intentionally want to skip old backlog.",
1403
+ ].join(" ");
1404
+ }
1405
+
1406
+ function buildListenerCatchupEvent({
1407
+ sessionId,
1408
+ agentId,
1409
+ agentModel = "cli",
1410
+ displayName = "",
1411
+ listenerId,
1412
+ catchup = {},
1413
+ } = {}) {
1414
+ const message = formatListenerCatchupNotice(catchup);
1415
+ const pollCount = Number(catchup.pollCount || 0);
1416
+ return createAgentEvent({
1417
+ event: "session_listen_catchup",
1418
+ sessionId,
1419
+ agent: {
1420
+ id: agentId,
1421
+ model: normalizeString(agentModel) || "cli",
1422
+ role: "listener",
1423
+ displayName: normalizeString(displayName) || agentId,
1424
+ clientKind: "cli",
1425
+ },
1426
+ eventId: `session-listener-${listenerId}-catchup-${pollCount}`,
1427
+ idempotencyToken: `session-listener:${listenerId}:catchup:${pollCount}`,
1428
+ payload: compactPayload({
1429
+ source: "session_listen",
1430
+ listenerId,
1431
+ lifecycle: "catchup",
1432
+ state: "catching_up",
1433
+ message,
1434
+ cursor: catchup.cursor || null,
1435
+ candidateCursor: catchup.candidateCursor || null,
1436
+ cursorSuffix: catchup.cursorSuffix,
1437
+ cursorSource: catchup.cursorSource,
1438
+ pollCount,
1439
+ eventCount: Number(catchup.eventCount || 0),
1440
+ matchingEventCount: Number(catchup.matchingEventCount || 0),
1441
+ preStartEventCount: Number(catchup.preStartEventCount || 0),
1442
+ limit: Number(catchup.limit || 0) || undefined,
1443
+ replay: Boolean(catchup.replay),
1444
+ oldestEventAt: catchup.oldestEventAt || null,
1445
+ newestEventAt: catchup.newestEventAt || null,
1446
+ }),
1447
+ });
1448
+ }
1449
+
964
1450
  // Preserve the literal default identity for `session say`. This command is
965
1451
  // often used by agents as a low-friction relay; silently rewriting the default
966
1452
  // `cli-user` to the authenticated human makes a forgotten --agent flag look
@@ -973,6 +1459,121 @@ async function defaultAgentId(value, _targetPath) {
973
1459
  return resolveSessionSayAgentId(value);
974
1460
  }
975
1461
 
1462
+ function formatAgentIdList(agentIds = []) {
1463
+ const normalized = agentIds.map((agentId) => normalizeString(agentId)).filter(Boolean);
1464
+ if (!normalized.length) return "";
1465
+ if (normalized.length <= 3) return normalized.join(", ");
1466
+ return `${normalized.slice(0, 3).join(", ")} +${normalized.length - 3} more`;
1467
+ }
1468
+
1469
+ export function sessionSayRegistryRole(value) {
1470
+ const normalized = normalizeString(value).toLowerCase();
1471
+ if (["coder", "reviewer", "tester", "daemon", "observer", "persona"].includes(normalized)) {
1472
+ return normalized;
1473
+ }
1474
+ return "coder";
1475
+ }
1476
+
1477
+ export async function resolveSessionSayIdentity({
1478
+ sessionId,
1479
+ agentId = "",
1480
+ targetPath = process.cwd(),
1481
+ env = process.env,
1482
+ } = {}) {
1483
+ const explicitAgentId = normalizeString(agentId);
1484
+ if (explicitAgentId) {
1485
+ return {
1486
+ agentId: resolveSessionSayAgentId(explicitAgentId),
1487
+ source: "option",
1488
+ identityWarning: "",
1489
+ candidateAgentIds: [],
1490
+ };
1491
+ }
1492
+
1493
+ const envAgentId = normalizeString(env?.SENTINELAYER_AGENT_ID || env?.SENTI_AGENT_ID);
1494
+ if (envAgentId && canPublishListenerPresence(envAgentId)) {
1495
+ return {
1496
+ agentId: resolveSessionSayAgentId(envAgentId),
1497
+ source: "env",
1498
+ identityWarning: "",
1499
+ candidateAgentIds: [],
1500
+ };
1501
+ }
1502
+
1503
+ let candidateAgentIds = [];
1504
+ try {
1505
+ const agents = await listAgents(sessionId, { targetPath, includeInactive: false });
1506
+ candidateAgentIds = agents
1507
+ .map((agent) => normalizeString(agent.agentId))
1508
+ .filter((candidate) => canPublishListenerPresence(candidate));
1509
+ } catch {
1510
+ candidateAgentIds = [];
1511
+ }
1512
+
1513
+ if (candidateAgentIds.length === 1) {
1514
+ return {
1515
+ agentId: resolveSessionSayAgentId(candidateAgentIds[0]),
1516
+ source: "local-agent",
1517
+ identityWarning: "",
1518
+ candidateAgentIds,
1519
+ };
1520
+ }
1521
+
1522
+ const warningReason = candidateAgentIds.length > 1
1523
+ ? `multiple local joined agents are active (${formatAgentIdList(candidateAgentIds)})`
1524
+ : envAgentId && !canPublishListenerPresence(envAgentId)
1525
+ ? `configured SENTINELAYER_AGENT_ID '${envAgentId}' is reserved or human-scoped`
1526
+ : "no agent identity is configured";
1527
+
1528
+ return {
1529
+ agentId: "cli-user",
1530
+ source: "fallback",
1531
+ identityWarning: `session say is sending as cli-user because ${warningReason}; pass --agent <id>, set SENTINELAYER_AGENT_ID, or run session join --agent <id> before posting.`,
1532
+ candidateAgentIds,
1533
+ };
1534
+ }
1535
+
1536
+ export function shouldBlockImplicitCliUserSessionSay(identity = {}) {
1537
+ return identity?.source === "fallback" && normalizeString(identity?.agentId) === "cli-user";
1538
+ }
1539
+
1540
+ async function ensureSessionSayAgentRegistered(
1541
+ sessionId,
1542
+ agent = {},
1543
+ { targetPath = process.cwd() } = {},
1544
+ ) {
1545
+ const agentId = normalizeString(agent.id);
1546
+ if (!canPublishListenerPresence(agentId)) {
1547
+ return { persisted: false, reason: "placeholder_agent" };
1548
+ }
1549
+
1550
+ try {
1551
+ const activeAgents = await listAgents(sessionId, { targetPath, includeInactive: false });
1552
+ if (
1553
+ activeAgents.some(
1554
+ (existing) => normalizeString(existing.agentId).toLowerCase() === agentId.toLowerCase(),
1555
+ )
1556
+ ) {
1557
+ return { persisted: false, reason: "already_registered" };
1558
+ }
1559
+ } catch {
1560
+ // If the local registry is unreadable, let rememberAgentIdentity surface the
1561
+ // filesystem problem with its normal error message.
1562
+ }
1563
+
1564
+ const registered = await rememberAgentIdentity(sessionId, {
1565
+ agentId,
1566
+ model: normalizeString(agent.model) || "cli",
1567
+ role: sessionSayRegistryRole(agent.role),
1568
+ targetPath,
1569
+ });
1570
+
1571
+ return {
1572
+ persisted: true,
1573
+ agentId: registered.agentId,
1574
+ };
1575
+ }
1576
+
976
1577
  async function resolveSessionAgentEnvelope(
977
1578
  sessionId,
978
1579
  agentId,
@@ -1062,6 +1663,13 @@ function formatEventLine(event = {}) {
1062
1663
  return `${ts} ${agentId} ${type}`;
1063
1664
  }
1064
1665
 
1666
+ function isSessionControlEvent(event = {}) {
1667
+ const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
1668
+ const type = normalizeString(event.event || event.type).toLowerCase();
1669
+ if (type.startsWith("session_listener_")) return true;
1670
+ return normalizeString(payload.source).toLowerCase() === "session_listen";
1671
+ }
1672
+
1065
1673
  function checkpointSequenceRange(checkpoint = {}) {
1066
1674
  const start = Number(checkpoint.startSequence || 0);
1067
1675
  const end = Number(checkpoint.endSequence || 0);
@@ -1926,6 +2534,7 @@ export function registerSessionCommand(program) {
1926
2534
  awaitRemoteSync: Boolean(explicitAgent),
1927
2535
  });
1928
2536
  const agentJoinRelayed =
2537
+ joined.emittedJoinEvent !== false &&
1929
2538
  Boolean(explicitAgent) &&
1930
2539
  Boolean(resolvedAgentId) &&
1931
2540
  resolvedAgentId !== "cli-user" &&
@@ -1980,7 +2589,10 @@ export function registerSessionCommand(program) {
1980
2589
  session
1981
2590
  .command("say <sessionId> <message>")
1982
2591
  .description("Send a message to the session")
1983
- .option("--agent <id>", "Agent id to emit from", "cli-user")
2592
+ .option(
2593
+ "--agent <id>",
2594
+ "Agent id to emit from; defaults to SENTINELAYER_AGENT_ID, then the sole local joined agent, then cli-user",
2595
+ )
1984
2596
  .option(
1985
2597
  "--model <model>",
1986
2598
  "Agent model/provider hint; defaults to local joined agent metadata or SENTINELAYER_AGENT_MODEL",
@@ -1999,6 +2611,8 @@ export function registerSessionCommand(program) {
1999
2611
  .option("--to <agent>", "Direct the message to a specific agent id")
2000
2612
  .option("--reply-to <sequence>", "Mark this message as a reply to a target sequence id")
2001
2613
  .option("--reply-cursor <cursor>", "Mark this message as a reply to a target event cursor")
2614
+ .option("--force-cli-user", "Allow fallback sends as cli-user when no agent identity can be resolved")
2615
+ .option("--local-only", "Append only to the local session cache without remote send confirmation")
2002
2616
  .option("--path <path>", "Workspace path for the session", ".")
2003
2617
  .option("--json", "Emit machine-readable output")
2004
2618
  .action(async (sessionId, message, options, command) => {
@@ -2011,16 +2625,28 @@ export function registerSessionCommand(program) {
2011
2625
  throw new Error("message is required.");
2012
2626
  }
2013
2627
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
2014
- const agentId = await defaultAgentId(options.agent, targetPath);
2628
+ const identity = await resolveSessionSayIdentity({
2629
+ sessionId: normalizedSessionId,
2630
+ agentId: options.agent,
2631
+ targetPath,
2632
+ });
2633
+ const agentId = identity.agentId;
2634
+ if (shouldBlockImplicitCliUserSessionSay(identity) && !options.forceCliUser) {
2635
+ throw new Error(
2636
+ `${identity.identityWarning} Re-run with --force-cli-user only for intentional anonymous/operator relay posts.`,
2637
+ );
2638
+ }
2015
2639
  const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
2016
2640
  targetPath,
2017
2641
  });
2018
2642
  const to = normalizeString(options.to);
2019
2643
  const replyToSequenceId = parseOptionalPositiveInteger(options.replyTo, "reply-to");
2020
2644
  const replyToCursor = normalizeString(options.replyCursor);
2645
+ const clientMessageId = `cli-${randomUUID()}`;
2021
2646
  const eventPayload = {
2022
2647
  message: normalizedMessage,
2023
2648
  channel: "session",
2649
+ clientMessageId,
2024
2650
  };
2025
2651
  if (to) {
2026
2652
  eventPayload.to = to;
@@ -2031,13 +2657,15 @@ export function registerSessionCommand(program) {
2031
2657
  if (replyToCursor) {
2032
2658
  eventPayload.replyToCursor = replyToCursor;
2033
2659
  }
2034
- const clientMessageId = `cli-${randomUUID()}`;
2035
2660
  const agent = await resolveSessionAgentEnvelope(normalizedSessionId, agentId, {
2036
2661
  targetPath,
2037
2662
  model: options.model,
2038
2663
  role: options.role,
2039
2664
  displayName: options.displayName,
2040
2665
  });
2666
+ const agentRegistration = await ensureSessionSayAgentRegistered(normalizedSessionId, agent, {
2667
+ targetPath,
2668
+ });
2041
2669
  const event = createAgentEvent({
2042
2670
  event: "session_message",
2043
2671
  agent,
@@ -2047,7 +2675,22 @@ export function registerSessionCommand(program) {
2047
2675
  event.eventId = clientMessageId;
2048
2676
  event.idempotencyToken = clientMessageId;
2049
2677
  let remoteSync = null;
2050
- if (localSession.materialized) {
2678
+ let remoteConfirmation = null;
2679
+ let remoteConfirmationAnchor = null;
2680
+ let localOnly = Boolean(options.localOnly) || remoteSessionLookupDisabled();
2681
+ if (!localOnly) {
2682
+ remoteConfirmationAnchor = await readSessionConfirmationAnchor(normalizedSessionId, { targetPath });
2683
+ if (!remoteConfirmationAnchor?.ok) {
2684
+ if (!localSession.materialized && isLocalOnlySessionSayReason(remoteConfirmationAnchor?.reason)) {
2685
+ localOnly = true;
2686
+ } else {
2687
+ throw new Error(
2688
+ `Remote send confirmation anchor failed (${remoteConfirmationAnchor?.reason || "unknown"}); local cache was not updated. Use --local-only only when you intentionally want an offline local note.`,
2689
+ );
2690
+ }
2691
+ }
2692
+ }
2693
+ if (!localOnly) {
2051
2694
  for (let attempt = 0; attempt < 2; attempt += 1) {
2052
2695
  remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
2053
2696
  targetPath,
@@ -2056,13 +2699,23 @@ export function registerSessionCommand(program) {
2056
2699
  }
2057
2700
  if (!remoteSync?.synced) {
2058
2701
  throw new Error(
2059
- `Remote send failed (${remoteSync?.reason || "unknown"}); local cache was not updated.`,
2702
+ `Remote send failed (${remoteSync?.reason || "unknown"}); local cache was not updated. Use --local-only only when you intentionally want an offline local note.`,
2060
2703
  );
2704
+ } else {
2705
+ remoteConfirmation = await confirmSessionEventVisible(normalizedSessionId, clientMessageId, {
2706
+ targetPath,
2707
+ anchorCursor: remoteConfirmationAnchor?.cursor,
2708
+ });
2709
+ if (!remoteConfirmation?.confirmed) {
2710
+ throw new Error(
2711
+ `Remote send was accepted but not visible in canonical session events (${remoteConfirmation?.reason || "not_visible"}); local cache was not updated.`,
2712
+ );
2713
+ }
2061
2714
  }
2062
2715
  }
2063
2716
  const persisted = await appendToStream(normalizedSessionId, event, {
2064
2717
  targetPath,
2065
- syncRemote: !localSession.materialized,
2718
+ syncRemote: false,
2066
2719
  });
2067
2720
  const payload = {
2068
2721
  command: "session say",
@@ -2072,12 +2725,21 @@ export function registerSessionCommand(program) {
2072
2725
  event: persisted,
2073
2726
  materializedLocalSession: localSession.materialized,
2074
2727
  refreshedLocalSession: Boolean(localSession.refreshed),
2728
+ identitySource: identity.source,
2729
+ identityWarning: identity.identityWarning || undefined,
2730
+ agentRegistration,
2075
2731
  remoteSync: remoteSync || undefined,
2732
+ remoteConfirmationAnchor: remoteConfirmationAnchor || undefined,
2733
+ remoteConfirmation: remoteConfirmation || undefined,
2734
+ localOnly,
2076
2735
  };
2077
2736
  if (shouldEmitJson(options, command)) {
2078
2737
  console.log(JSON.stringify(payload, null, 2));
2079
2738
  return;
2080
2739
  }
2740
+ if (identity.identityWarning) {
2741
+ console.error(pc.yellow(`Identity warning: ${identity.identityWarning}`));
2742
+ }
2081
2743
  console.log(formatEventLine(persisted));
2082
2744
  });
2083
2745
 
@@ -2109,11 +2771,13 @@ export function registerSessionCommand(program) {
2109
2771
  targetPath,
2110
2772
  });
2111
2773
  const to = normalizeString(options.to);
2774
+ const clientMessageId = `cli-agent-${randomUUID()}`;
2112
2775
  const eventPayload = {
2113
2776
  message: normalizedMessage,
2114
2777
  channel: "session",
2115
2778
  source: "agent",
2116
2779
  clientKind: "cli",
2780
+ clientMessageId,
2117
2781
  };
2118
2782
  if (to) {
2119
2783
  eventPayload.to = to;
@@ -2125,7 +2789,6 @@ export function registerSessionCommand(program) {
2125
2789
  role: normalizeString(options.role) || "coder",
2126
2790
  clientKind: "cli",
2127
2791
  };
2128
- const clientMessageId = `cli-agent-${randomUUID()}`;
2129
2792
  const event = createAgentEvent({
2130
2793
  event: "session_message",
2131
2794
  agent,
@@ -2135,6 +2798,19 @@ export function registerSessionCommand(program) {
2135
2798
  event.eventId = clientMessageId;
2136
2799
  event.idempotencyToken = clientMessageId;
2137
2800
 
2801
+ const remoteConfirmationAnchor = await readSessionConfirmationAnchor(normalizedSessionId, {
2802
+ targetPath,
2803
+ });
2804
+ if (!remoteConfirmationAnchor?.ok) {
2805
+ if (remoteConfirmationAnchor?.reason === "api_403") {
2806
+ throw new Error(
2807
+ `Agent post failed (api_403). Ensure this user has an active grant for '${agentId}'.`,
2808
+ );
2809
+ }
2810
+ throw new Error(
2811
+ `Agent post confirmation anchor failed (${remoteConfirmationAnchor?.reason || "unknown"}); local cache was not updated.`,
2812
+ );
2813
+ }
2138
2814
  const remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
2139
2815
  targetPath,
2140
2816
  });
@@ -2143,6 +2819,15 @@ export function registerSessionCommand(program) {
2143
2819
  `Agent post failed (${remoteSync?.reason || "unknown"}). Ensure this user has an active grant for '${agentId}'.`,
2144
2820
  );
2145
2821
  }
2822
+ const remoteConfirmation = await confirmSessionEventVisible(normalizedSessionId, clientMessageId, {
2823
+ targetPath,
2824
+ anchorCursor: remoteConfirmationAnchor?.cursor,
2825
+ });
2826
+ if (!remoteConfirmation?.confirmed) {
2827
+ throw new Error(
2828
+ `Agent post was accepted but not visible in canonical session events (${remoteConfirmation?.reason || "not_visible"}); local cache was not updated.`,
2829
+ );
2830
+ }
2146
2831
 
2147
2832
  const persisted = await appendToStream(normalizedSessionId, event, {
2148
2833
  targetPath,
@@ -2157,6 +2842,8 @@ export function registerSessionCommand(program) {
2157
2842
  materializedLocalSession: localSession.materialized,
2158
2843
  refreshedLocalSession: Boolean(localSession.refreshed),
2159
2844
  remoteSync,
2845
+ remoteConfirmationAnchor,
2846
+ remoteConfirmation,
2160
2847
  };
2161
2848
  if (shouldEmitJson(options, command)) {
2162
2849
  console.log(JSON.stringify(payload, null, 2));
@@ -2242,7 +2929,12 @@ export function registerSessionCommand(program) {
2242
2929
  console.log(JSON.stringify(payload, null, 2));
2243
2930
  return payload;
2244
2931
  }
2245
- console.log(formatEventLine(localAppend.event || actionEvent));
2932
+ if (localAppend.event || actionEvent) {
2933
+ console.log(formatEventLine(localAppend.event || actionEvent));
2934
+ } else {
2935
+ const targetLabel = targetSequenceId ? `#${targetSequenceId}` : targetCursor || targetActionId || "target";
2936
+ console.log(pc.green(`Recorded ${normalizedActionType} on ${targetLabel}.`));
2937
+ }
2246
2938
  return payload;
2247
2939
  }
2248
2940
 
@@ -2352,7 +3044,7 @@ export function registerSessionCommand(program) {
2352
3044
 
2353
3045
  session
2354
3046
  .command("view <sessionId> <targetSequenceId>")
2355
- .description("Record a read receipt for a target session event")
3047
+ .description("Manually backfill a read receipt for a target session event")
2356
3048
  .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
2357
3049
  .option("--idempotency-key <key>", "Explicit idempotency key")
2358
3050
  .option("--path <path>", "Workspace path for the session", ".")
@@ -2388,10 +3080,27 @@ export function registerSessionCommand(program) {
2388
3080
  "Seconds after a human message to keep the active interval (default 300)",
2389
3081
  "300",
2390
3082
  )
3083
+ .option(
3084
+ "--presence-interval <seconds>",
3085
+ "Minimum seconds between remote listener heartbeat events (default 60)",
3086
+ "60",
3087
+ )
3088
+ .option(
3089
+ "--no-presence",
3090
+ "Do not publish durable listener lifecycle/heartbeat events",
3091
+ )
3092
+ .option(
3093
+ "--model <model>",
3094
+ "Model/provider label to publish with listener presence",
3095
+ process.env.SENTINELAYER_AGENT_MODEL || process.env.SENTINELAYER_MODEL || "cli",
3096
+ )
3097
+ .option("--display-name <name>", "Human-readable listener name for presence")
2391
3098
  .option("--emit <format>", "Output format: ndjson or text", "ndjson")
3099
+ .option("--transport <mode>", "Listen transport: auto, stream, or poll (default auto)", "auto")
2392
3100
  .option("--limit <n>", "Maximum events to request per poll (default 200)", "200")
2393
3101
  .option("--path <path>", "Workspace path for the session", ".")
2394
3102
  .option("--since <cursor>", "Override the persisted listen cursor")
3103
+ .option("--from-now", "Advance the listen cursor to the latest durable event before polling")
2395
3104
  .option("--replay", "Emit matching historical events on the first poll")
2396
3105
  .option("--max-polls <n>", "Stop after N poll cycles (useful for tests and smoke checks)")
2397
3106
  .action(async (options) => {
@@ -2405,26 +3114,64 @@ export function registerSessionCommand(program) {
2405
3114
  5,
2406
3115
  );
2407
3116
  const activeWindowSeconds = parsePositiveInteger(options.activeWindow, "active-window", 300);
3117
+ const presenceIntervalSeconds = parsePositiveInteger(
3118
+ options.presenceInterval,
3119
+ "presence-interval",
3120
+ 60,
3121
+ );
3122
+ const agentModel = normalizeString(options.model) || "cli";
3123
+ const displayName = normalizeString(options.displayName) || agentId;
2408
3124
  const limit = parsePositiveInteger(options.limit, "limit", 200);
2409
3125
  const emitFormat = normalizeString(options.emit).toLowerCase() || "ndjson";
2410
3126
  if (!["ndjson", "text"].includes(emitFormat)) {
2411
3127
  throw new Error("--emit must be one of: ndjson, text.");
2412
3128
  }
3129
+ const requestedTransport = normalizeString(options.transport).toLowerCase() || "auto";
3130
+ if (!["auto", "stream", "poll"].includes(requestedTransport)) {
3131
+ throw new Error("--transport must be one of: auto, stream, poll.");
3132
+ }
2413
3133
  const maxPolls =
2414
3134
  options.maxPolls === undefined
2415
3135
  ? null
2416
3136
  : parsePositiveInteger(options.maxPolls, "max-polls", 1);
3137
+ const listenTransport = requestedTransport === "auto" && maxPolls !== null
3138
+ ? "poll"
3139
+ : requestedTransport;
2417
3140
  const since = options.since === undefined ? undefined : String(options.since);
3141
+ if (options.fromNow && options.since !== undefined) {
3142
+ throw new Error("Use either --from-now or --since, not both.");
3143
+ }
2418
3144
  const ac = new AbortController();
2419
3145
  const onSigint = () => ac.abort();
2420
3146
  process.on("SIGINT", onSigint);
3147
+ const listenerId = `listener-${agentId}-${randomUUID()}`;
3148
+ const durablePresenceEnabled = options.presence !== false;
3149
+ const publishPresence = durablePresenceEnabled && canPublishListenerPresence(agentId);
3150
+ const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
3151
+ let lastPresenceHeartbeatMs = 0;
2421
3152
 
2422
3153
  if (emitFormat === "text") {
2423
3154
  console.log(
2424
3155
  pc.gray(
2425
- `Listening to session ${normalizedSessionId} as ${agentId}; idle=${intervalSeconds}s active=${activeIntervalSeconds}s/${activeWindowSeconds}s. Press Ctrl+C to stop.`,
3156
+ `Listening to session ${normalizedSessionId} as ${agentId}; transport=${listenTransport} idle=${intervalSeconds}s active=${activeIntervalSeconds}s/${activeWindowSeconds}s. Press Ctrl+C to stop.`,
2426
3157
  ),
2427
3158
  );
3159
+ if (!durablePresenceEnabled) {
3160
+ console.log(
3161
+ pc.gray(
3162
+ "Remote listener presence is disabled; no durable lifecycle events will be written.",
3163
+ ),
3164
+ );
3165
+ } else if (!publishPresence) {
3166
+ console.log(
3167
+ pc.gray(
3168
+ "Listener presence is local-only for placeholder, human, and guest agent ids.",
3169
+ ),
3170
+ );
3171
+ }
3172
+ if (options.fromNow) {
3173
+ console.log(pc.gray("Priming listener from the latest durable event; old backlog will be skipped."));
3174
+ }
2428
3175
  }
2429
3176
 
2430
3177
  try {
@@ -2438,8 +3185,29 @@ export function registerSessionCommand(program) {
2438
3185
  limit,
2439
3186
  since,
2440
3187
  replay: Boolean(options.replay),
3188
+ fromNow: Boolean(options.fromNow),
3189
+ persistStartCursor: Boolean(options.fromNow),
3190
+ transport: listenTransport,
2441
3191
  maxPolls,
2442
3192
  signal: ac.signal,
3193
+ onCatchup: async (catchup) => {
3194
+ if (emitFormat === "ndjson") {
3195
+ console.log(
3196
+ JSON.stringify(
3197
+ buildListenerCatchupEvent({
3198
+ sessionId: normalizedSessionId,
3199
+ agentId,
3200
+ agentModel,
3201
+ displayName,
3202
+ listenerId,
3203
+ catchup,
3204
+ }),
3205
+ ),
3206
+ );
3207
+ } else {
3208
+ console.log(pc.yellow(formatListenerCatchupNotice(catchup)));
3209
+ }
3210
+ },
2443
3211
  onEvent: async (event) => {
2444
3212
  if (emitFormat === "ndjson") {
2445
3213
  console.log(JSON.stringify(event));
@@ -2467,12 +3235,394 @@ export function registerSessionCommand(program) {
2467
3235
  console.log(pc.yellow(`Listen poll skipped (${reason}).`));
2468
3236
  }
2469
3237
  },
3238
+ onLifecycle: async (lifecycle) => {
3239
+ if (!publishPresence) return;
3240
+ const lifecycleType = normalizeString(lifecycle?.type);
3241
+ if (lifecycleType === "heartbeat") {
3242
+ const nowMs = Date.now();
3243
+ if (lastPresenceHeartbeatMs > 0 && nowMs - lastPresenceHeartbeatMs < presenceIntervalMs) {
3244
+ return;
3245
+ }
3246
+ lastPresenceHeartbeatMs = nowMs;
3247
+ }
3248
+ await publishListenerPresenceEvent({
3249
+ sessionId: normalizedSessionId,
3250
+ targetPath,
3251
+ agentId,
3252
+ agentModel,
3253
+ displayName,
3254
+ listenerId,
3255
+ lifecycle,
3256
+ });
3257
+ },
2470
3258
  });
2471
3259
  } finally {
2472
3260
  process.removeListener("SIGINT", onSigint);
2473
3261
  }
2474
3262
  });
2475
3263
 
3264
+ session
3265
+ .command("daemon [sessionId]")
3266
+ .description("Run the Senti session daemon: hydrate events, emit recaps, and generate checkpoints")
3267
+ .option("--session <id>", "Session id to monitor")
3268
+ .option("--path <path>", "Workspace path for the session", ".")
3269
+ .option("--tick-interval <seconds>", "Seconds between health ticks (default 30)", "30")
3270
+ .option("--stale-agent-seconds <seconds>", "Seconds before an inactive agent is flagged stale (default 90)", "90")
3271
+ .option("--recap-interval <seconds>", "Seconds between periodic recaps when activity continues (default 300)", "300")
3272
+ .option("--recap-inactivity <seconds>", "Seconds of inactivity before a recap closeout (default 600)", "600")
3273
+ .option("--recap-event-threshold <n>", "Meaningful events required to force a recap before interval (default 5)", "5")
3274
+ .option("--checkpoint-interval <seconds>", "Minimum seconds between checkpoint attempts (default 60)", "60")
3275
+ .option("--checkpoint-min-events <n>", "Minimum events for generated checkpoint windows (default 20)", "20")
3276
+ .option("--checkpoint-max-events <n>", "Maximum events per generated checkpoint window (default 80)", "80")
3277
+ .option("--checkpoint-event-threshold <n>", "Meaningful events required to attempt a checkpoint (default 20)", "20")
3278
+ .option("--checkpoint-idle <seconds>", "Seconds after latest source event before idle checkpoint attempt (default 600)", "600")
3279
+ .option("--no-checkpoints", "Disable durable checkpoint generation")
3280
+ .option("--no-checkpoint-closeout", "Skip the final closeout checkpoint when the daemon stops")
3281
+ .option("--once", "Run one Senti health tick and exit (CI/dogfood smoke)")
3282
+ .option("--json", "Emit machine-readable output")
3283
+ .action(async (sessionId, options, command) => {
3284
+ const emitJson = shouldEmitJson(options, command);
3285
+ const normalizedSessionId = normalizeString(sessionId) || resolveSessionIdOption(options);
3286
+ if (!normalizedSessionId) {
3287
+ throw new Error("session daemon requires a session id (positional or --session).");
3288
+ }
3289
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3290
+ const tickIntervalMs = parsePositiveMillisecondsFromSeconds(
3291
+ options.tickInterval,
3292
+ "tick-interval",
3293
+ 30,
3294
+ );
3295
+ const daemon = await startSenti(normalizedSessionId, {
3296
+ targetPath,
3297
+ autoStart: false,
3298
+ tickIntervalMs,
3299
+ staleAgentSeconds: parsePositiveInteger(options.staleAgentSeconds, "stale-agent-seconds", 90),
3300
+ recapIntervalMs: parsePositiveMillisecondsFromSeconds(options.recapInterval, "recap-interval", 300),
3301
+ recapInactivityMs: parsePositiveMillisecondsFromSeconds(options.recapInactivity, "recap-inactivity", 600),
3302
+ recapActivityThreshold: parsePositiveInteger(options.recapEventThreshold, "recap-event-threshold", 5),
3303
+ checkpointGenerator: options.checkpoints === false ? null : undefined,
3304
+ checkpointIntervalMs: parsePositiveMillisecondsFromSeconds(options.checkpointInterval, "checkpoint-interval", 60),
3305
+ checkpointMinEvents: parsePositiveInteger(options.checkpointMinEvents, "checkpoint-min-events", 20),
3306
+ checkpointMaxEvents: parsePositiveInteger(options.checkpointMaxEvents, "checkpoint-max-events", 80),
3307
+ checkpointEventThreshold: parsePositiveInteger(options.checkpointEventThreshold, "checkpoint-event-threshold", 20),
3308
+ checkpointIdleMs: parsePositiveMillisecondsFromSeconds(options.checkpointIdle, "checkpoint-idle", 600),
3309
+ checkpointCloseoutOnStop: options.once ? false : options.checkpointCloseout !== false,
3310
+ });
3311
+
3312
+ const runTickAndBuildPayload = async () => {
3313
+ const summary = await daemon.runTick(new Date().toISOString());
3314
+ return {
3315
+ command: "session daemon",
3316
+ sessionId: normalizedSessionId,
3317
+ targetPath,
3318
+ once: Boolean(options.once),
3319
+ running: daemon.isRunning(),
3320
+ summary,
3321
+ state: daemon.getState(),
3322
+ };
3323
+ };
3324
+
3325
+ if (options.once) {
3326
+ const payload = await runTickAndBuildPayload();
3327
+ const stopped = await daemon.stop("once_complete");
3328
+ payload.running = false;
3329
+ payload.stopped = {
3330
+ stopped: Boolean(stopped?.stopped),
3331
+ reason: stopped?.reason || "once_complete",
3332
+ checkpointCloseout: stopped?.checkpointCloseout || null,
3333
+ };
3334
+ if (emitJson) {
3335
+ console.log(JSON.stringify(payload, null, 2));
3336
+ return;
3337
+ }
3338
+ const checkpoint = payload.summary?.checkpoint || {};
3339
+ const recap = payload.summary?.recap || {};
3340
+ console.log(
3341
+ pc.green(
3342
+ `senti tick: relayed=${Number(payload.summary?.humanMessages?.relayed || 0)} recap=${recap.emitted ? recap.mode || "emitted" : recap.reason || "none"} checkpoint=${checkpoint.created ? checkpoint.checkpointId || "created" : checkpoint.reason || "none"}`,
3343
+ ),
3344
+ );
3345
+ return;
3346
+ }
3347
+
3348
+ const controller = new AbortController();
3349
+ const stop = () => controller.abort();
3350
+ process.once("SIGINT", stop);
3351
+ process.once("SIGTERM", stop);
3352
+ const waitForNextTick = () =>
3353
+ new Promise((resolve) => {
3354
+ let timer = null;
3355
+ const cleanup = () => {
3356
+ controller.signal.removeEventListener("abort", onAbort);
3357
+ if (timer) {
3358
+ clearTimeout(timer);
3359
+ timer = null;
3360
+ }
3361
+ };
3362
+ const finish = () => {
3363
+ cleanup();
3364
+ resolve();
3365
+ };
3366
+ const onAbort = () => {
3367
+ finish();
3368
+ };
3369
+ controller.signal.addEventListener("abort", onAbort, { once: true });
3370
+ timer = setTimeout(finish, tickIntervalMs);
3371
+ });
3372
+
3373
+ try {
3374
+ if (!emitJson) {
3375
+ console.log(
3376
+ pc.green(
3377
+ `senti daemon: monitoring session ${normalizedSessionId}; tick=${Math.round(tickIntervalMs / 1000)}s. Ctrl-C to stop.`,
3378
+ ),
3379
+ );
3380
+ }
3381
+ let payload = await runTickAndBuildPayload();
3382
+ if (emitJson) {
3383
+ console.log(JSON.stringify(payload));
3384
+ } else {
3385
+ console.log(pc.gray(`tick ${payload.summary.generatedAt}: recap=${payload.summary.recap.reason || payload.summary.recap.mode || "ok"} checkpoint=${payload.summary.checkpoint.reason || payload.summary.checkpoint.checkpointId || "ok"}`));
3386
+ }
3387
+ while (!controller.signal.aborted) {
3388
+ await waitForNextTick();
3389
+ if (controller.signal.aborted) break;
3390
+ payload = await runTickAndBuildPayload();
3391
+ if (emitJson) {
3392
+ console.log(JSON.stringify(payload));
3393
+ } else {
3394
+ console.log(pc.gray(`tick ${payload.summary.generatedAt}: recap=${payload.summary.recap.reason || payload.summary.recap.mode || "ok"} checkpoint=${payload.summary.checkpoint.reason || payload.summary.checkpoint.checkpointId || "ok"}`));
3395
+ }
3396
+ }
3397
+ } finally {
3398
+ process.removeListener("SIGINT", stop);
3399
+ process.removeListener("SIGTERM", stop);
3400
+ const stopped = await daemon.stop("signal");
3401
+ if (emitJson) {
3402
+ console.log(
3403
+ JSON.stringify({
3404
+ command: "session daemon",
3405
+ sessionId: normalizedSessionId,
3406
+ targetPath,
3407
+ stopped: {
3408
+ stopped: Boolean(stopped?.stopped),
3409
+ reason: stopped?.reason || "signal",
3410
+ checkpointCloseout: stopped?.checkpointCloseout || null,
3411
+ },
3412
+ }),
3413
+ );
3414
+ } else {
3415
+ console.log(pc.gray(`senti daemon stopped (${stopped?.reason || "signal"}).`));
3416
+ }
3417
+ }
3418
+ });
3419
+
3420
+ const wake = session
3421
+ .command("wake")
3422
+ .description("Wake or register host CLI sessions for the Senti notification bus");
3423
+
3424
+ wake
3425
+ .command("codex [sessionId]")
3426
+ .description("Resume a Codex CLI session with a Senti wake prompt")
3427
+ .option("--session <id>", "Senti session id")
3428
+ .option("--codex-session <id>", "Codex rollout/session id to resume")
3429
+ .option("--last", "Resume the most recent Codex session instead of a specific id")
3430
+ .option("--message <text>", "Senti message body to inject into Codex")
3431
+ .option("--message-file <path>", "Read Senti message body from a file")
3432
+ .option("--from <id>", "Senti sender id", "senti")
3433
+ .option("--sequence <n>", "Senti source sequence id")
3434
+ .option("--cursor <cursor>", "Senti source cursor")
3435
+ .option("--priority <level>", "Senti source priority")
3436
+ .option("--dashboard-url <url>", "Dashboard URL for the Senti session")
3437
+ .option("--cwd <path>", "Workspace cwd for codex exec", ".")
3438
+ .option("--codex-bin <path>", "Codex executable", "codex")
3439
+ .option("--model <model>", "Optional Codex model override")
3440
+ .option("--codex-json", "Pass --json through to codex exec resume")
3441
+ .option("--skip-git-repo-check", "Pass --skip-git-repo-check through to codex")
3442
+ .option(
3443
+ "--dangerously-bypass-approvals-and-sandbox",
3444
+ "Pass Codex's dangerous no-approval/no-sandbox flag through to the resumed process",
3445
+ )
3446
+ .option("--timeout-ms <n>", "Wake process timeout in milliseconds", "600000")
3447
+ .option("--dry-run", "Print the resume invocation without spawning Codex")
3448
+ .option("--json", "Emit machine-readable output")
3449
+ .action(async (sessionId, options, command) => {
3450
+ const emitJson = shouldEmitJson(options, command);
3451
+ const normalizedSessionId = normalizeString(sessionId) || resolveSessionIdOption(options);
3452
+ const targetPath = path.resolve(process.cwd(), String(options.cwd || "."));
3453
+ if (options.message && options.messageFile) {
3454
+ throw new Error("Use either --message or --message-file, not both.");
3455
+ }
3456
+ const message = options.messageFile
3457
+ ? await fsp.readFile(path.resolve(process.cwd(), String(options.messageFile)), "utf-8")
3458
+ : normalizeString(options.message);
3459
+ const prompt = buildCodexWakePrompt({
3460
+ sentiSessionId: normalizedSessionId,
3461
+ message,
3462
+ from: options.from,
3463
+ sequenceId: parseOptionalPositiveInteger(options.sequence, "sequence"),
3464
+ cursor: options.cursor,
3465
+ priority: options.priority,
3466
+ dashboardUrl: options.dashboardUrl,
3467
+ });
3468
+ const invocation = buildCodexExecResumeInvocation({
3469
+ codexSessionId: options.codexSession,
3470
+ prompt,
3471
+ cwd: targetPath,
3472
+ codexBin: options.codexBin,
3473
+ useLast: Boolean(options.last),
3474
+ json: Boolean(options.codexJson),
3475
+ model: options.model,
3476
+ skipGitRepoCheck: Boolean(options.skipGitRepoCheck),
3477
+ dangerouslyBypassApprovalsAndSandbox: Boolean(options.dangerouslyBypassApprovalsAndSandbox),
3478
+ });
3479
+ const payload = {
3480
+ command: "session wake codex",
3481
+ sessionId: normalizedSessionId,
3482
+ dryRun: Boolean(options.dryRun),
3483
+ invocation,
3484
+ prompt,
3485
+ };
3486
+ if (options.dryRun) {
3487
+ if (emitJson) {
3488
+ console.log(JSON.stringify(payload, null, 2));
3489
+ return;
3490
+ }
3491
+ console.log(pc.green("Codex wake dry run"));
3492
+ console.log(`${payload.invocation.command} ${payload.invocation.args.map((arg) => JSON.stringify(arg)).join(" ")}`);
3493
+ return;
3494
+ }
3495
+ const result = await runCodexExecResume({
3496
+ invocation,
3497
+ timeoutMs: parsePositiveInteger(options.timeoutMs, "timeout-ms", 600000),
3498
+ });
3499
+ const output = {
3500
+ ...payload,
3501
+ result,
3502
+ };
3503
+ if (emitJson) {
3504
+ console.log(JSON.stringify(output, null, 2));
3505
+ return;
3506
+ }
3507
+ const color = result.exitCode === 0 ? pc.green : pc.yellow;
3508
+ console.log(color(`Codex wake completed with exit code ${result.exitCode}.`));
3509
+ if (normalizeString(result.stderr)) {
3510
+ console.log(pc.gray(result.stderr.trim()));
3511
+ }
3512
+ if (result.exitCode !== 0) {
3513
+ process.exitCode = Number(result.exitCode) || 1;
3514
+ }
3515
+ });
3516
+
3517
+ wake
3518
+ .command("codex-notify [sessionId] [notificationJson]")
3519
+ .description("Record Codex notify payloads so sentid can resume the correct Codex rollout")
3520
+ .option("--session <id>", "Senti session id")
3521
+ .option("--agent <id>", "Senti agent id", process.env.SENTINELAYER_AGENT_ID || "codex")
3522
+ .option("--notification <json>", "Notification JSON override")
3523
+ .option("--path <path>", "Workspace path for the Senti session", ".")
3524
+ .option("--json", "Emit machine-readable output")
3525
+ .action(async (sessionId, notificationJson, options, command) => {
3526
+ const emitJson = shouldEmitJson(options, command);
3527
+ const normalizedSessionId = normalizeString(sessionId) || resolveSessionIdOption(options);
3528
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3529
+ const payload = normalizeString(options.notification) || normalizeString(notificationJson);
3530
+ const result = await recordCodexWakeRegistration({
3531
+ sessionId: normalizedSessionId,
3532
+ agentId: options.agent,
3533
+ notificationPayload: payload,
3534
+ targetPath,
3535
+ });
3536
+ const output = {
3537
+ command: "session wake codex-notify",
3538
+ sessionId: normalizedSessionId,
3539
+ agentId: normalizeString(options.agent) || "codex",
3540
+ ...result,
3541
+ };
3542
+ if (emitJson) {
3543
+ console.log(JSON.stringify(output, null, 2));
3544
+ return;
3545
+ }
3546
+ if (!result.registered) {
3547
+ console.log(pc.gray(`Ignored Codex notification: ${result.reason}.`));
3548
+ return;
3549
+ }
3550
+ console.log(pc.green(`Registered Codex wake target: ${result.registryPath}`));
3551
+ });
3552
+
3553
+ wake
3554
+ .command("daemon [sessionId]")
3555
+ .description("Run the sentid wake daemon: watch the session stream and wake this agent on new messages")
3556
+ .option("--session <id>", "Senti session id")
3557
+ .option("--agent <id>", "Local agent this daemon wakes", process.env.SENTINELAYER_AGENT_ID || "")
3558
+ .option("--host <name>", "Host adapter to wake (claude|codex)", "claude")
3559
+ .option("--resume-session <id>", "Host session id to resume on wake")
3560
+ .option("--cwd <path>", "Workspace cwd", ".")
3561
+ .option("--idle-ms <n>", "Idle poll backoff in milliseconds", "1500")
3562
+ .option("--max-attempts <n>", "Wake retries before dead-letter", "5")
3563
+ .option("--once", "Run a single fetch/dispatch tick and exit (dogfood/CI)")
3564
+ .option("--json", "Emit machine-readable output")
3565
+ .action(async (sessionId, options, command) => {
3566
+ const emitJson = shouldEmitJson(options, command);
3567
+ const normalizedSessionId = normalizeString(sessionId) || resolveSessionIdOption(options);
3568
+ if (!normalizedSessionId) {
3569
+ throw new Error("session wake daemon requires a Senti session id (positional or --session).");
3570
+ }
3571
+ const agentId = normalizeString(options.agent);
3572
+ if (!agentId) {
3573
+ throw new Error("session wake daemon requires --agent (the local agent to wake).");
3574
+ }
3575
+ const host = normalizeString(options.host) || "claude";
3576
+ const resumeSessionId = normalizeString(options.resumeSession);
3577
+ if (!resumeSessionId) {
3578
+ throw new Error("session wake daemon requires --resume-session (the host session id to resume).");
3579
+ }
3580
+ const targetPath = path.resolve(process.cwd(), String(options.cwd || "."));
3581
+ const sentid = createSentid({
3582
+ sessionId: normalizedSessionId,
3583
+ agentId,
3584
+ host,
3585
+ resumeSessionId,
3586
+ targetPath,
3587
+ idleMs: parseOptionalPositiveInteger(options.idleMs, "idle-ms") || 1500,
3588
+ maxAttempts: parseOptionalPositiveInteger(options.maxAttempts, "max-attempts") || 5,
3589
+ logger: emitJson
3590
+ ? undefined
3591
+ : (level, msg, meta) => console.error(`[sentid:${level}] ${msg}${meta ? ` ${JSON.stringify(meta)}` : ""}`),
3592
+ });
3593
+
3594
+ if (options.once) {
3595
+ const tick = await sentid.tickOnce({ fetchCursor: null });
3596
+ const out = {
3597
+ command: "session wake daemon",
3598
+ once: true,
3599
+ sessionId: normalizedSessionId,
3600
+ agent: agentId,
3601
+ host,
3602
+ cursor: sentid.getCursor(),
3603
+ dispatched: Array.isArray(tick.results) ? tick.results.length : 0,
3604
+ idle: Boolean(tick.idle),
3605
+ };
3606
+ console.log(
3607
+ emitJson
3608
+ ? JSON.stringify(out, null, 2)
3609
+ : pc.green(`sentid tick: cursor=${out.cursor} dispatched=${out.dispatched}${out.idle ? " (idle)" : ""}`),
3610
+ );
3611
+ return;
3612
+ }
3613
+
3614
+ const ac = new AbortController();
3615
+ const stop = () => ac.abort();
3616
+ process.once("SIGINT", stop);
3617
+ process.once("SIGTERM", stop);
3618
+ if (!emitJson) {
3619
+ console.log(
3620
+ pc.green(`sentid daemon: waking ${agentId} (host=${host}) on session ${normalizedSessionId}. Ctrl-C to stop.`),
3621
+ );
3622
+ }
3623
+ await sentid.start({ signal: ac.signal });
3624
+ });
3625
+
2476
3626
  const recap = session
2477
3627
  .command("recap")
2478
3628
  .description("Build deterministic Senti session recaps");
@@ -2574,6 +3724,9 @@ export function registerSessionCommand(program) {
2574
3724
  )
2575
3725
  .option("--before-sequence <n>", "Remote page ending before this sequence id")
2576
3726
  .option("--no-actions", "Do not include remote message actions/replies/reactions")
3727
+ .option("--agent <id>", "Agent id for automatic view receipts", process.env.SENTINELAYER_AGENT_ID || "cli-user")
3728
+ .option("--no-view", "Do not record automatic view receipts for displayed remote messages")
3729
+ .option("--include-control-events", "Include listener lifecycle/control-plane events in transcript output")
2577
3730
  .option("--path <path>", "Workspace path for the session", ".")
2578
3731
  .option("--json", "Emit machine-readable output")
2579
3732
  .action(async (sessionId, options, command) => {
@@ -2585,6 +3738,11 @@ export function registerSessionCommand(program) {
2585
3738
  const tail = parsePositiveInteger(options.tail, "tail", 20);
2586
3739
  const beforeSequence = parseOptionalPositiveInteger(options.beforeSequence, "before-sequence");
2587
3740
  const emitJson = shouldEmitJson(options, command);
3741
+ const includeControlEvents = Boolean(options.includeControlEvents);
3742
+ const remoteTailLimit = includeControlEvents
3743
+ ? tail
3744
+ : Math.min(200, Math.max(tail, tail + 20));
3745
+ const readAgentId = await defaultAgentId(options.agent, targetPath);
2588
3746
 
2589
3747
  let hydration = null;
2590
3748
  let remoteTail = null;
@@ -2605,7 +3763,7 @@ export function registerSessionCommand(program) {
2605
3763
  remoteTail = await pollSessionEventsBefore(normalizedSessionId, {
2606
3764
  targetPath,
2607
3765
  beforeSequence,
2608
- limit: tail,
3766
+ limit: remoteTailLimit,
2609
3767
  timeoutMs: 15_000,
2610
3768
  });
2611
3769
  if (options.actions !== false) {
@@ -2681,7 +3839,23 @@ export function registerSessionCommand(program) {
2681
3839
  const actionEvents = remoteActions?.ok
2682
3840
  ? buildSessionActionEvents(normalizedSessionId, remoteActions.actions)
2683
3841
  : [];
2684
- const events = mergeSessionActionEvents(displayEvents, actionEvents).slice(-tail);
3842
+ const unacknowledgedHumanMessages = remoteActions?.ok
3843
+ ? readProjectionUnacknowledgedHumanMessages(remoteActions)
3844
+ : [];
3845
+ const recentHumanActivity = remoteActions?.ok
3846
+ ? readProjectionRecentHumanActivity(remoteActions)
3847
+ : [];
3848
+ const transcriptEvents = includeControlEvents
3849
+ ? displayEvents
3850
+ : displayEvents.filter((event) => !isSessionControlEvent(event));
3851
+ const hiddenControlEventCount = displayEvents.length - transcriptEvents.length;
3852
+ const events = mergeSessionActionEvents(transcriptEvents, actionEvents).slice(-tail);
3853
+ const autoView = await recordSessionReadViews(normalizedSessionId, events, {
3854
+ targetPath,
3855
+ agentId: readAgentId,
3856
+ enabled: Boolean(options.remote && options.view !== false),
3857
+ maxTargets: process.env.SENTINELAYER_SESSION_READ_VIEW_MAX_TARGETS || 50,
3858
+ });
2685
3859
  const remoteVerified = Boolean(
2686
3860
  options.remote &&
2687
3861
  ((hydration && hydration.ok) || (remoteTail && remoteTail.ok))
@@ -2694,6 +3868,13 @@ export function registerSessionCommand(program) {
2694
3868
  beforeSequence,
2695
3869
  count: events.length,
2696
3870
  events,
3871
+ includeControlEvents,
3872
+ hiddenControlEventCount,
3873
+ unacknowledgedHumanMessageCount: unacknowledgedHumanMessages.length,
3874
+ unacknowledgedHumanMessages,
3875
+ recentHumanActivityCount: recentHumanActivity.length,
3876
+ recentHumanActivity,
3877
+ autoView,
2697
3878
  displaySource: !options.remote
2698
3879
  ? "local"
2699
3880
  : remoteTail?.ok
@@ -2737,6 +3918,18 @@ export function registerSessionCommand(program) {
2737
3918
  console.log(JSON.stringify(payload, null, 2));
2738
3919
  return;
2739
3920
  }
3921
+ if (unacknowledgedHumanMessages.length > 0) {
3922
+ console.log(pc.yellow(`Unacknowledged human asks: ${unacknowledgedHumanMessages.length}`));
3923
+ for (const event of unacknowledgedHumanMessages.slice(0, 3)) {
3924
+ console.log(pc.yellow(`- ${formatHumanAskLine(event)}`));
3925
+ }
3926
+ }
3927
+ if (recentHumanActivity.length > 0) {
3928
+ console.log(pc.yellow(`Recent human activity: ${recentHumanActivity.length}`));
3929
+ for (const action of recentHumanActivity.slice(0, 3)) {
3930
+ console.log(pc.yellow(`- ${formatMessageActionActivityLine(action)}`));
3931
+ }
3932
+ }
2740
3933
  for (const event of events) {
2741
3934
  console.log(formatEventLine(event));
2742
3935
  }
@@ -3048,6 +4241,8 @@ export function registerSessionCommand(program) {
3048
4241
  .description("Generate a checkpoint from the next uncheckpointed durable event window")
3049
4242
  .option("--min-events <n>", "Minimum source events required before creating (default 20)", "20")
3050
4243
  .option("--max-events <n>", "Maximum source events to summarize (default 80, max 200)", "80")
4244
+ .option("--catch-up", "Generate multiple consecutive checkpoint windows until caught up or capped")
4245
+ .option("--max-checkpoints <n>", "Maximum checkpoint windows to create with --catch-up (default 5, max 50)", "5")
3051
4246
  .option("--operation-id <key>", "Explicit retry key for this generate invocation")
3052
4247
  .option("--agent <id>", "Optional agent id recorded as checkpoint creator")
3053
4248
  .option("--path <path>", "Workspace path for auth/session context", ".")
@@ -3061,6 +4256,49 @@ export function registerSessionCommand(program) {
3061
4256
  const agentId = normalizeString(options.agent)
3062
4257
  ? await defaultAgentId(options.agent, targetPath)
3063
4258
  : "";
4259
+ if (options.catchUp) {
4260
+ const result = await generateSessionCheckpointBatch(normalizedSessionId, {
4261
+ targetPath,
4262
+ minEvents: options.minEvents,
4263
+ maxEvents: options.maxEvents,
4264
+ maxCheckpoints: options.maxCheckpoints,
4265
+ idempotencyKey: options.operationId,
4266
+ createdByAgentId: agentId,
4267
+ });
4268
+ const hydration = result.createdCount > 0
4269
+ ? await hydrateAfterCheckpointMutation(normalizedSessionId, { targetPath })
4270
+ : null;
4271
+ const payload = {
4272
+ command: "session checkpoint generate",
4273
+ targetPath,
4274
+ ...result,
4275
+ hydration: hydration || undefined,
4276
+ };
4277
+ if (shouldEmitJson(options, command)) {
4278
+ console.log(JSON.stringify(payload, null, 2));
4279
+ return;
4280
+ }
4281
+ if (result.createdCount > 0) {
4282
+ console.log(pc.bold(`checkpoint catch-up generated ${result.createdCount} checkpoint${result.createdCount === 1 ? "" : "s"}`));
4283
+ for (const item of result.results) {
4284
+ if (item.checkpoint) {
4285
+ console.log(formatCheckpointLine(item.checkpoint));
4286
+ }
4287
+ }
4288
+ console.log(pc.gray(`Stopped: ${result.stoppedReason || "complete"} after ${result.attemptedCount} attempt${result.attemptedCount === 1 ? "" : "s"}.`));
4289
+ if (hydration && !hydration.ok) {
4290
+ console.log(pc.gray(`Local hydrate skipped: ${hydration.reason || "unknown"}`));
4291
+ }
4292
+ return;
4293
+ }
4294
+ const last = result.lastResult || {};
4295
+ console.log(
4296
+ pc.gray(
4297
+ `No checkpoint created: ${normalizeString(last.reason || result.stoppedReason) || "not_needed"} (${Number(last.eventCount || 0)} events, min ${Number(last.minEvents || options.minEvents || 0)}).`,
4298
+ ),
4299
+ );
4300
+ return;
4301
+ }
3064
4302
  const result = await generateSessionCheckpoint(normalizedSessionId, {
3065
4303
  targetPath,
3066
4304
  minEvents: options.minEvents,
@@ -3582,14 +4820,7 @@ export function registerSessionCommand(program) {
3582
4820
  return;
3583
4821
  }
3584
4822
  for (const item of trimmed) {
3585
- const archive = item.archiveStatus ? ` archive=${item.archiveStatus}` : "";
3586
- const created = item.createdAt || "?";
3587
- const lastActivity = item.lastActivityAt
3588
- ? ` last=${item.lastActivityAt}`
3589
- : "";
3590
- console.log(
3591
- `${item.sessionId} status=${item.status}${archive} created=${created}${lastActivity}`,
3592
- );
4823
+ console.log(formatSessionListLine(item));
3593
4824
  }
3594
4825
  if (remote.count > trimmed.length || remote.hasMore) {
3595
4826
  console.log(
@@ -3637,10 +4868,9 @@ export function registerSessionCommand(program) {
3637
4868
  return;
3638
4869
  }
3639
4870
  for (const item of trimmed) {
3640
- const archive = item.archiveStatus ? ` archive=${item.archiveStatus}` : "";
3641
- console.log(
3642
- `${item.sessionId} status=${item.status}${archive} created=${item.createdAt} expires=${item.expiresAt}`,
3643
- );
4871
+ const line = formatSessionListLine(item);
4872
+ const expires = item.expiresAt ? ` expires=${item.expiresAt}` : "";
4873
+ console.log(`${line}${expires}`);
3644
4874
  }
3645
4875
  if (sessions.length > trimmed.length) {
3646
4876
  console.log(