sentinelayer-cli 0.19.0 → 0.21.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,7 +1,9 @@
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";
6
+ import { spawn as defaultSpawn } from "node:child_process";
5
7
 
6
8
  import pc from "picocolors";
7
9
 
@@ -26,6 +28,7 @@ import { createAgentEvent } from "../events/schema.js";
26
28
  import {
27
29
  detectStaleAgents,
28
30
  listAgents,
31
+ rememberAgentIdentity,
29
32
  registerAgent,
30
33
  unregisterAgent,
31
34
  } from "../session/agent-registry.js";
@@ -61,9 +64,11 @@ import {
61
64
  import { readSessionPreview } from "../session/preview.js";
62
65
  import {
63
66
  createSessionMessageAction,
67
+ fetchSessionPinnedMessages,
64
68
  listSessionMessageActions,
65
69
  listSessionsFromApi,
66
70
  probeSessionAccess,
71
+ pollSessionEvents,
67
72
  pollSessionEventsBefore,
68
73
  searchSessionEvents,
69
74
  syncSessionEventToApi,
@@ -85,8 +90,16 @@ import {
85
90
  import {
86
91
  createSessionCheckpoint,
87
92
  generateSessionCheckpoint,
93
+ generateSessionCheckpointBatch,
88
94
  listSessionCheckpoints,
89
95
  } from "../session/checkpoints.js";
96
+ import {
97
+ buildCodexExecResumeInvocation,
98
+ buildCodexWakePrompt,
99
+ recordCodexWakeRegistration,
100
+ runCodexExecResume,
101
+ } from "../session/wake/codex.js";
102
+ import createSentid from "../session/wake/sentid.js";
90
103
  import { authLoginHint, preferredCliCommand } from "../ui/command-hints.js";
91
104
  import { parseCsvTokens } from "./ai/shared.js";
92
105
 
@@ -101,6 +114,164 @@ function normalizeString(value) {
101
114
  return String(value || "").trim();
102
115
  }
103
116
 
117
+ const SESSION_SAY_CONFIRM_ATTEMPTS = 3;
118
+ const SESSION_SAY_CONFIRM_DELAY_MS = 250;
119
+ const SESSION_SAY_CONFIRM_PAGE_LIMIT = 200;
120
+ const SESSION_SAY_CONFIRM_MAX_PAGES = 10;
121
+ const SESSION_SAY_LOCAL_ONLY_REASONS = new Set([
122
+ "no_session",
123
+ "not_authenticated",
124
+ "remote_sync_disabled_env",
125
+ ]);
126
+
127
+ function isLocalOnlySessionSayReason(reason) {
128
+ return SESSION_SAY_LOCAL_ONLY_REASONS.has(normalizeString(reason));
129
+ }
130
+
131
+ function sessionEventMatchesClientMessageId(event, clientMessageId) {
132
+ const normalizedClientMessageId = normalizeString(clientMessageId);
133
+ if (!normalizedClientMessageId || !event || typeof event !== "object") {
134
+ return false;
135
+ }
136
+ const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
137
+ const candidates = [
138
+ event.id,
139
+ event.eventId,
140
+ event.event_id,
141
+ event.idempotencyToken,
142
+ event.idempotency_token,
143
+ event.clientMessageId,
144
+ event.client_message_id,
145
+ payload.id,
146
+ payload.messageId,
147
+ payload.message_id,
148
+ payload.eventId,
149
+ payload.event_id,
150
+ payload.idempotencyToken,
151
+ payload.idempotency_token,
152
+ payload.clientMessageId,
153
+ payload.client_message_id,
154
+ ];
155
+ return candidates.some((candidate) => normalizeString(candidate) === normalizedClientMessageId);
156
+ }
157
+
158
+ async function readSessionConfirmationAnchor(sessionId, { targetPath } = {}) {
159
+ const result = await pollSessionEventsBefore(sessionId, {
160
+ targetPath,
161
+ limit: 1,
162
+ forceCircuitProbe: true,
163
+ });
164
+ if (!result?.ok) {
165
+ return {
166
+ ok: false,
167
+ reason: normalizeString(result?.reason) || "confirmation_anchor_failed",
168
+ cursor: null,
169
+ };
170
+ }
171
+ const events = Array.isArray(result.events) ? result.events : [];
172
+ const lastEvent = events[events.length - 1] || null;
173
+ return {
174
+ ok: true,
175
+ reason: "",
176
+ cursor: normalizeString(lastEvent?.cursor) || normalizeString(result.cursor) || null,
177
+ sequenceId: Number(lastEvent?.sequenceId ?? lastEvent?.sequence_id) || null,
178
+ };
179
+ }
180
+
181
+ async function confirmSessionEventVisible(sessionId, clientMessageId, { targetPath, anchorCursor = null } = {}) {
182
+ let lastReason = "not_visible";
183
+ let checked = 0;
184
+ let pages = 0;
185
+ const normalizedAnchorCursor = normalizeString(anchorCursor) || null;
186
+ for (let attempt = 1; attempt <= SESSION_SAY_CONFIRM_ATTEMPTS; attempt += 1) {
187
+ let pageCursor = normalizedAnchorCursor;
188
+ for (let page = 1; page <= SESSION_SAY_CONFIRM_MAX_PAGES; page += 1) {
189
+ pages += 1;
190
+ const result = await pollSessionEvents(sessionId, {
191
+ targetPath,
192
+ since: pageCursor,
193
+ limit: SESSION_SAY_CONFIRM_PAGE_LIMIT,
194
+ forceCircuitProbe: true,
195
+ });
196
+ if (!result?.ok) {
197
+ lastReason = normalizeString(result?.reason) || "confirmation_poll_failed";
198
+ break;
199
+ }
200
+ const events = Array.isArray(result.events) ? result.events : [];
201
+ checked += events.length;
202
+ const confirmedEvent = events.find((candidate) => sessionEventMatchesClientMessageId(candidate, clientMessageId));
203
+ if (confirmedEvent) {
204
+ return {
205
+ confirmed: true,
206
+ reason: "",
207
+ checked,
208
+ pages,
209
+ anchorCursor: normalizedAnchorCursor,
210
+ event: confirmedEvent,
211
+ };
212
+ }
213
+ lastReason = "not_visible";
214
+ const nextCursor = normalizeString(result.cursor);
215
+ if (!events.length || !nextCursor || nextCursor === pageCursor) {
216
+ break;
217
+ }
218
+ pageCursor = nextCursor;
219
+ }
220
+ if (attempt < SESSION_SAY_CONFIRM_ATTEMPTS) {
221
+ await sleep(SESSION_SAY_CONFIRM_DELAY_MS);
222
+ }
223
+ }
224
+ return {
225
+ confirmed: false,
226
+ reason: lastReason,
227
+ checked,
228
+ pages,
229
+ anchorCursor: normalizedAnchorCursor,
230
+ };
231
+ }
232
+
233
+ function formatSessionListText(value, { maxLength = 80 } = {}) {
234
+ const normalized = normalizeString(value)
235
+ .replace(/[\r\n\t]+/g, " ")
236
+ .replace(/\s+/g, " ")
237
+ .replace(/"/g, "'");
238
+ if (!normalized) {
239
+ return "";
240
+ }
241
+ const limit = Math.max(8, Number(maxLength) || 80);
242
+ return normalized.length > limit
243
+ ? `${normalized.slice(0, Math.max(0, limit - 3)).trimEnd()}...`
244
+ : normalized;
245
+ }
246
+
247
+ function sessionListCodebaseLabel(value) {
248
+ const normalized = formatSessionListText(value, { maxLength: 120 });
249
+ if (!normalized) {
250
+ return "";
251
+ }
252
+ const segments = normalized.split(/[\\/]+/).filter(Boolean);
253
+ if (segments.length <= 2) {
254
+ return formatSessionListText(normalized, { maxLength: 80 });
255
+ }
256
+ return formatSessionListText(`.../${segments.slice(-2).join("/")}`, {
257
+ maxLength: 80,
258
+ });
259
+ }
260
+
261
+ export function formatSessionListLine(item = {}) {
262
+ const sessionId = normalizeString(item.sessionId) || "unknown-session";
263
+ const status = normalizeString(item.status) || "unknown";
264
+ const archiveStatus = normalizeString(item.archiveStatus);
265
+ const archive = archiveStatus ? ` archive=${archiveStatus}` : "";
266
+ const title = formatSessionListText(item.title || item.summaryText, { maxLength: 72 });
267
+ const codebase = sessionListCodebaseLabel(item.codebasePath);
268
+ const titlePart = title ? ` title="${title}"` : "";
269
+ const codebasePart = codebase ? ` codebase="${codebase}"` : "";
270
+ const created = item.createdAt || "?";
271
+ const lastActivity = item.lastActivityAt ? ` last=${item.lastActivityAt}` : "";
272
+ return `${sessionId} status=${status}${archive}${titlePart}${codebasePart} created=${created}${lastActivity}`;
273
+ }
274
+
104
275
  const SESSION_MESSAGE_ACTION_TYPES = new Set([
105
276
  "ack",
106
277
  "working_on",
@@ -150,7 +321,7 @@ const SESSION_MESSAGE_ACTION_DESCRIPTIONS = Object.freeze([
150
321
  {
151
322
  type: "view",
152
323
  command: "sl session view <id> <sequence>",
153
- description: "Record a read receipt for a target message.",
324
+ description: "Manually backfill a read receipt for a target message; remote reads record views automatically.",
154
325
  },
155
326
  ]);
156
327
 
@@ -219,6 +390,7 @@ function actionDisplayMessage(action = {}) {
219
390
  function buildSessionActionEvent(sessionId, action = {}) {
220
391
  const actionType = normalizeString(action.actionType ?? action.action_type).toLowerCase();
221
392
  if (!SESSION_MESSAGE_ACTION_TYPES.has(actionType)) return null;
393
+ if (actionType === "view") return null;
222
394
  const id =
223
395
  normalizeString(action.id) ||
224
396
  shortSha256(
@@ -265,6 +437,91 @@ function buildSessionActionEvents(sessionId, actions = []) {
265
437
  .filter(Boolean);
266
438
  }
267
439
 
440
+ function readProjectionUnacknowledgedHumanMessages(remoteActions = null) {
441
+ const messages = remoteActions?.projection?.unacknowledgedHumanMessages;
442
+ return Array.isArray(messages)
443
+ ? messages
444
+ .filter((event) => event && typeof event === "object")
445
+ .sort((left, right) => humanAskSortValue(right) - humanAskSortValue(left))
446
+ : [];
447
+ }
448
+
449
+ function messageActionCreatedMs(action = {}) {
450
+ const epoch = Date.parse(normalizeString(action.createdAt || action.created_at || action.ts || action.timestamp));
451
+ return Number.isFinite(epoch) ? epoch : 0;
452
+ }
453
+
454
+ function messageActionActorId(action = {}) {
455
+ return normalizeString(action.actorId || action.actor_id || action.agentId || action.agent_id) || "unknown";
456
+ }
457
+
458
+ function isHumanMessageAction(action = {}) {
459
+ if (action?.isHumanActivity === true) return true;
460
+ if (normalizeString(action.actorKind || action.actor_kind).toLowerCase() === "human") return true;
461
+ return messageActionActorId(action).startsWith("human-");
462
+ }
463
+
464
+ function readProjectionRecentHumanActivity(remoteActions = null) {
465
+ const projected = remoteActions?.projection?.recentActivity;
466
+ const source = Array.isArray(projected) && projected.length > 0 ? projected : remoteActions?.actions;
467
+ return Array.isArray(source)
468
+ ? source
469
+ .filter((action) => action && typeof action === "object" && isHumanMessageAction(action))
470
+ .sort((left, right) => {
471
+ const timeDiff = messageActionCreatedMs(right) - messageActionCreatedMs(left);
472
+ if (timeDiff !== 0) return timeDiff;
473
+ return normalizeString(right.id).localeCompare(normalizeString(left.id));
474
+ })
475
+ : [];
476
+ }
477
+
478
+ function formatMessageActionActivityLine(action = {}) {
479
+ const actionType = normalizeString(action.actionType || action.action_type) || "action";
480
+ const targetSequence = Number(action.targetSequenceId ?? action.target_sequence_id ?? 0);
481
+ const targetActionId = normalizeString(action.targetActionId || action.target_action_id);
482
+ const target = targetActionId
483
+ ? `action:${targetActionId}`
484
+ : targetSequence > 0
485
+ ? `#${Math.floor(targetSequence)}`
486
+ : normalizeString(action.targetCursor || action.target_cursor) || "unknown-target";
487
+ const ts = normalizeString(action.createdAt || action.created_at || action.ts || action.timestamp);
488
+ const note = normalizeString(action.note || action.message || "");
489
+ const shortNote = note.length > 220 ? `${note.slice(0, 217)}...` : note;
490
+ const suffix = shortNote ? `: ${shortNote}` : "";
491
+ return `${actionType} ${target} by ${messageActionActorId(action)}${ts ? ` ${ts}` : ""}${suffix}`;
492
+ }
493
+
494
+ function humanAskSequence(event = {}) {
495
+ const value = Number(event.sequenceId ?? event.sequence_id ?? event.sequence ?? 0);
496
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : null;
497
+ }
498
+
499
+ function humanAskAgentId(event = {}) {
500
+ return normalizeString(event.agent?.id || event.agentId || event.agent_id) || "human";
501
+ }
502
+
503
+ function humanAskMessage(event = {}) {
504
+ const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
505
+ return normalizeString(payload.message || payload.note || payload.reason || "");
506
+ }
507
+
508
+ function humanAskSortValue(event = {}) {
509
+ const sequence = humanAskSequence(event);
510
+ if (sequence) return sequence;
511
+ const epoch = Date.parse(normalizeString(event.ts || event.timestamp || event.createdAt || event.created_at));
512
+ return Number.isFinite(epoch) ? epoch : 0;
513
+ }
514
+
515
+ function formatHumanAskLine(event = {}) {
516
+ const sequence = humanAskSequence(event);
517
+ const sequenceLabel = sequence ? `#${sequence}` : normalizeString(event.cursor) || "unsequenced";
518
+ const ts = normalizeString(event.ts || event.timestamp || event.createdAt || event.created_at);
519
+ const message = humanAskMessage(event);
520
+ const shortMessage = message.length > 220 ? `${message.slice(0, 217)}...` : message;
521
+ const suffix = shortMessage ? `: ${shortMessage}` : "";
522
+ return `${sequenceLabel} ${humanAskAgentId(event)}${ts ? ` ${ts}` : ""}${suffix}`;
523
+ }
524
+
268
525
  function eventTimestampMs(event = {}) {
269
526
  for (const key of ["ts", "timestamp", "createdAt", "at"]) {
270
527
  const epoch = Date.parse(normalizeString(event?.[key]));
@@ -329,6 +586,99 @@ function defaultActionIdempotencyKey({
329
586
  return `cli:${normalizeString(actionType).toLowerCase()}:${target}:${actor}:${noteHash}`;
330
587
  }
331
588
 
589
+ function sessionReadViewTarget(event = {}) {
590
+ const eventType = normalizeString(event.event || event.type).toLowerCase();
591
+ if (eventType !== "session_message" || isSessionControlEvent(event)) {
592
+ return null;
593
+ }
594
+ const targetSequenceId = eventSequenceNumber(event);
595
+ const targetCursor = normalizeString(event.cursor);
596
+ if (!targetSequenceId && !targetCursor) {
597
+ return null;
598
+ }
599
+ return {
600
+ key: targetSequenceId ? `seq:${targetSequenceId}` : `cursor:${targetCursor}`,
601
+ targetSequenceId: targetSequenceId || null,
602
+ targetCursor,
603
+ };
604
+ }
605
+
606
+ async function recordSessionReadViews(sessionId, events = [], {
607
+ targetPath,
608
+ agentId,
609
+ enabled = false,
610
+ maxTargets = 50,
611
+ } = {}) {
612
+ const maxAutoViewTargets = Math.max(0, Math.min(200, Number(maxTargets) || 0));
613
+ const summary = {
614
+ enabled: Boolean(enabled),
615
+ agentId: normalizeString(agentId) || "cli-user",
616
+ targetCount: 0,
617
+ attempted: 0,
618
+ recorded: 0,
619
+ duplicates: 0,
620
+ failed: 0,
621
+ skipped: 0,
622
+ reason: "",
623
+ };
624
+ if (!summary.enabled) {
625
+ summary.reason = "disabled";
626
+ return summary;
627
+ }
628
+
629
+ const seenTargets = new Set();
630
+ const targets = [];
631
+ for (const event of Array.isArray(events) ? events : []) {
632
+ const target = sessionReadViewTarget(event);
633
+ if (!target || seenTargets.has(target.key)) {
634
+ continue;
635
+ }
636
+ seenTargets.add(target.key);
637
+ targets.push(target);
638
+ }
639
+ summary.targetCount = targets.length;
640
+
641
+ const writeTargets = maxAutoViewTargets > 0 ? targets.slice(-maxAutoViewTargets) : [];
642
+ summary.skipped = Math.max(0, targets.length - writeTargets.length);
643
+ if (summary.skipped > 0) {
644
+ summary.reason = "target_cap_reached";
645
+ }
646
+
647
+ for (const target of writeTargets) {
648
+ const result = await createSessionMessageAction(sessionId, {
649
+ actionType: "view",
650
+ targetPath,
651
+ targetSequenceId: target.targetSequenceId,
652
+ targetCursor: target.targetCursor,
653
+ metadata: {
654
+ source: "cli_read",
655
+ agentId: summary.agentId,
656
+ },
657
+ idempotencyKey: defaultActionIdempotencyKey({
658
+ actionType: "view",
659
+ targetSequenceId: target.targetSequenceId,
660
+ targetCursor: target.targetCursor,
661
+ agentId: summary.agentId,
662
+ }),
663
+ timeoutMs: 15_000,
664
+ });
665
+ summary.attempted += 1;
666
+ if (result?.ok && result.action) {
667
+ summary.recorded += 1;
668
+ if (result.duplicate) {
669
+ summary.duplicates += 1;
670
+ }
671
+ continue;
672
+ }
673
+ summary.failed += 1;
674
+ summary.reason = normalizeString(result?.reason) || "view_write_failed";
675
+ summary.skipped = Math.max(0, targets.length - summary.attempted);
676
+ break;
677
+ }
678
+
679
+ return summary;
680
+ }
681
+
332
682
  function compareIsoDesc(left = "", right = "") {
333
683
  return normalizeString(right).localeCompare(normalizeString(left));
334
684
  }
@@ -402,6 +752,10 @@ function parsePositiveInteger(rawValue, field, fallbackValue) {
402
752
  return Math.floor(normalized);
403
753
  }
404
754
 
755
+ function parsePositiveMillisecondsFromSeconds(rawValue, field, fallbackSeconds) {
756
+ return parsePositiveInteger(rawValue, field, fallbackSeconds) * 1000;
757
+ }
758
+
405
759
  function normalizeComparablePath(value) {
406
760
  return String(value || "")
407
761
  .trim()
@@ -961,6 +1315,140 @@ function normalizeAgentId(value, fallbackValue = "cli-user") {
961
1315
  return normalized || fallbackValue;
962
1316
  }
963
1317
 
1318
+ function canPublishListenerPresence(agentId) {
1319
+ const normalized = normalizeAgentId(agentId, "");
1320
+ if (!normalized) return false;
1321
+ if (["cli-user", "unknown", "human", "user", "operator"].includes(normalized)) return false;
1322
+ return !(
1323
+ normalized.startsWith("human-") ||
1324
+ normalized.startsWith("user-") ||
1325
+ normalized.startsWith("guest-")
1326
+ );
1327
+ }
1328
+
1329
+ function listenerLifecycleEventName(type = "") {
1330
+ const normalized = normalizeString(type).toLowerCase();
1331
+ if (normalized === "started") return "session_listener_started";
1332
+ if (normalized === "stopped") return "session_listener_stopped";
1333
+ return "session_listener_heartbeat";
1334
+ }
1335
+
1336
+ function compactPayload(record = {}) {
1337
+ return Object.fromEntries(
1338
+ Object.entries(record).filter(([, value]) => value !== undefined)
1339
+ );
1340
+ }
1341
+
1342
+ async function publishListenerPresenceEvent({
1343
+ sessionId,
1344
+ targetPath,
1345
+ agentId,
1346
+ agentModel = "cli",
1347
+ displayName = "",
1348
+ listenerId,
1349
+ lifecycle = {},
1350
+ } = {}) {
1351
+ const normalizedType = normalizeString(lifecycle.type) || "heartbeat";
1352
+ const eventName = listenerLifecycleEventName(normalizedType);
1353
+ const event = createAgentEvent({
1354
+ event: eventName,
1355
+ sessionId,
1356
+ agent: {
1357
+ id: agentId,
1358
+ model: normalizeString(agentModel) || "cli",
1359
+ role: "listener",
1360
+ displayName: normalizeString(displayName) || agentId,
1361
+ clientKind: "cli",
1362
+ },
1363
+ eventId: `session-listener-${listenerId}-${normalizedType}-${lifecycle.pollCount ?? 0}`,
1364
+ idempotencyToken: `session-listener:${listenerId}:${normalizedType}:${lifecycle.pollCount ?? 0}`,
1365
+ payload: compactPayload({
1366
+ source: "session_listen",
1367
+ listenerId,
1368
+ lifecycle: normalizedType,
1369
+ state: normalizeString(lifecycle.state) || normalizedType,
1370
+ active: lifecycle.active,
1371
+ cursor: lifecycle.cursor || null,
1372
+ cursorSuffix: lifecycle.cursorSuffix,
1373
+ cursorSource: lifecycle.cursorSource,
1374
+ pollCount: lifecycle.pollCount,
1375
+ matched: lifecycle.matched,
1376
+ emitted: lifecycle.emitted,
1377
+ persistedCursor: lifecycle.persistedCursor,
1378
+ idleIntervalSeconds: lifecycle.idleIntervalSeconds,
1379
+ activeIntervalSeconds: lifecycle.activeIntervalSeconds,
1380
+ activeWindowSeconds: lifecycle.activeWindowSeconds,
1381
+ lastHumanActivityAt: lifecycle.lastHumanActivityAt,
1382
+ lastSleepMs: lifecycle.lastSleepMs,
1383
+ nextPollMs: lifecycle.nextPollMs,
1384
+ reason: lifecycle.reason || null,
1385
+ startedAt: lifecycle.startedAt,
1386
+ stoppedAt: lifecycle.stoppedAt,
1387
+ aborted: lifecycle.aborted,
1388
+ stopping: lifecycle.stopping,
1389
+ }),
1390
+ });
1391
+ return syncSessionEventToApi(sessionId, event, { targetPath });
1392
+ }
1393
+
1394
+ function formatListenerCatchupNotice(catchup = {}) {
1395
+ const eventCount = Number(catchup.eventCount || 0);
1396
+ const matchingEventCount = Number(catchup.matchingEventCount || 0);
1397
+ const range = catchup.oldestEventAt && catchup.newestEventAt
1398
+ ? ` (${catchup.oldestEventAt} -> ${catchup.newestEventAt})`
1399
+ : "";
1400
+ return [
1401
+ `Listener catch-up from stored cursor ${catchup.cursor || "<none>"}:`,
1402
+ `${eventCount} event${eventCount === 1 ? "" : "s"} in this page`,
1403
+ `${matchingEventCount} addressed/broadcast to this agent${range}.`,
1404
+ "Use --from-now only when you intentionally want to skip old backlog.",
1405
+ ].join(" ");
1406
+ }
1407
+
1408
+ function buildListenerCatchupEvent({
1409
+ sessionId,
1410
+ agentId,
1411
+ agentModel = "cli",
1412
+ displayName = "",
1413
+ listenerId,
1414
+ catchup = {},
1415
+ } = {}) {
1416
+ const message = formatListenerCatchupNotice(catchup);
1417
+ const pollCount = Number(catchup.pollCount || 0);
1418
+ return createAgentEvent({
1419
+ event: "session_listen_catchup",
1420
+ sessionId,
1421
+ agent: {
1422
+ id: agentId,
1423
+ model: normalizeString(agentModel) || "cli",
1424
+ role: "listener",
1425
+ displayName: normalizeString(displayName) || agentId,
1426
+ clientKind: "cli",
1427
+ },
1428
+ eventId: `session-listener-${listenerId}-catchup-${pollCount}`,
1429
+ idempotencyToken: `session-listener:${listenerId}:catchup:${pollCount}`,
1430
+ payload: compactPayload({
1431
+ source: "session_listen",
1432
+ listenerId,
1433
+ lifecycle: "catchup",
1434
+ state: "catching_up",
1435
+ message,
1436
+ cursor: catchup.cursor || null,
1437
+ candidateCursor: catchup.candidateCursor || null,
1438
+ cursorSuffix: catchup.cursorSuffix,
1439
+ cursorSource: catchup.cursorSource,
1440
+ pollCount,
1441
+ eventCount: Number(catchup.eventCount || 0),
1442
+ matchingEventCount: Number(catchup.matchingEventCount || 0),
1443
+ preStartEventCount: Number(catchup.preStartEventCount || 0),
1444
+ limit: Number(catchup.limit || 0) || undefined,
1445
+ replay: Boolean(catchup.replay),
1446
+ oldestEventAt: catchup.oldestEventAt || null,
1447
+ newestEventAt: catchup.newestEventAt || null,
1448
+ }),
1449
+ });
1450
+ }
1451
+
964
1452
  // Preserve the literal default identity for `session say`. This command is
965
1453
  // often used by agents as a low-friction relay; silently rewriting the default
966
1454
  // `cli-user` to the authenticated human makes a forgotten --agent flag look
@@ -973,6 +1461,217 @@ async function defaultAgentId(value, _targetPath) {
973
1461
  return resolveSessionSayAgentId(value);
974
1462
  }
975
1463
 
1464
+ function formatAgentIdList(agentIds = []) {
1465
+ const normalized = agentIds.map((agentId) => normalizeString(agentId)).filter(Boolean);
1466
+ if (!normalized.length) return "";
1467
+ if (normalized.length <= 3) return normalized.join(", ");
1468
+ return `${normalized.slice(0, 3).join(", ")} +${normalized.length - 3} more`;
1469
+ }
1470
+
1471
+ export function sessionSayRegistryRole(value) {
1472
+ const normalized = normalizeString(value).toLowerCase();
1473
+ if (["coder", "reviewer", "tester", "daemon", "observer", "persona"].includes(normalized)) {
1474
+ return normalized;
1475
+ }
1476
+ return "coder";
1477
+ }
1478
+
1479
+ export async function resolveSessionSayIdentity({
1480
+ sessionId,
1481
+ agentId = "",
1482
+ targetPath = process.cwd(),
1483
+ env = process.env,
1484
+ } = {}) {
1485
+ const explicitAgentId = normalizeString(agentId);
1486
+ if (explicitAgentId) {
1487
+ return {
1488
+ agentId: resolveSessionSayAgentId(explicitAgentId),
1489
+ source: "option",
1490
+ identityWarning: "",
1491
+ candidateAgentIds: [],
1492
+ };
1493
+ }
1494
+
1495
+ const envAgentId = normalizeString(env?.SENTINELAYER_AGENT_ID || env?.SENTI_AGENT_ID);
1496
+ if (envAgentId && canPublishListenerPresence(envAgentId)) {
1497
+ return {
1498
+ agentId: resolveSessionSayAgentId(envAgentId),
1499
+ source: "env",
1500
+ identityWarning: "",
1501
+ candidateAgentIds: [],
1502
+ };
1503
+ }
1504
+
1505
+ let candidateAgentIds = [];
1506
+ try {
1507
+ const agents = await listAgents(sessionId, { targetPath, includeInactive: false });
1508
+ candidateAgentIds = agents
1509
+ .map((agent) => normalizeString(agent.agentId))
1510
+ .filter((candidate) => canPublishListenerPresence(candidate));
1511
+ } catch {
1512
+ candidateAgentIds = [];
1513
+ }
1514
+
1515
+ if (candidateAgentIds.length === 1) {
1516
+ return {
1517
+ agentId: resolveSessionSayAgentId(candidateAgentIds[0]),
1518
+ source: "local-agent",
1519
+ identityWarning: "",
1520
+ candidateAgentIds,
1521
+ };
1522
+ }
1523
+
1524
+ const warningReason = candidateAgentIds.length > 1
1525
+ ? `multiple local joined agents are active (${formatAgentIdList(candidateAgentIds)})`
1526
+ : envAgentId && !canPublishListenerPresence(envAgentId)
1527
+ ? `configured SENTINELAYER_AGENT_ID '${envAgentId}' is reserved or human-scoped`
1528
+ : "no agent identity is configured";
1529
+
1530
+ return {
1531
+ agentId: "cli-user",
1532
+ source: "fallback",
1533
+ 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.`,
1534
+ candidateAgentIds,
1535
+ };
1536
+ }
1537
+
1538
+ export function shouldBlockImplicitCliUserSessionSay(identity = {}) {
1539
+ return identity?.source === "fallback" && normalizeString(identity?.agentId) === "cli-user";
1540
+ }
1541
+
1542
+ /**
1543
+ * Wake hook for `session listen --wake "<command>"`. This is the reusable
1544
+ * notify->resume bridge: when the listener emits an event addressed to this
1545
+ * agent (or broadcast — including low-noise actions like ack/like), it runs a
1546
+ * host command so the host can resume/wake its agent. The event JSON is piped
1547
+ * to the command's stdin and key fields are exposed as SL_WAKE_* env vars.
1548
+ *
1549
+ * Bursts are coalesced: if a wake is already running, the latest event is
1550
+ * queued and fired once when the current one finishes, so a flood of activity
1551
+ * triggers one trailing wake instead of a storm of processes.
1552
+ */
1553
+ export function createSessionWakeRunner({
1554
+ command,
1555
+ sessionId,
1556
+ agentId,
1557
+ emit = () => {},
1558
+ spawnImpl = defaultSpawn,
1559
+ } = {}) {
1560
+ const wakeCommand = normalizeString(command);
1561
+ let busy = false;
1562
+ let pending = null;
1563
+
1564
+ const run = (event) => {
1565
+ if (!wakeCommand) return;
1566
+ if (busy) {
1567
+ pending = event ?? {};
1568
+ return;
1569
+ }
1570
+ busy = true;
1571
+ const env = {
1572
+ ...process.env,
1573
+ SL_WAKE_SESSION_ID: normalizeString(sessionId),
1574
+ SL_WAKE_AGENT_ID: normalizeString(agentId),
1575
+ SL_WAKE_EVENT_TYPE: normalizeString(event?.event),
1576
+ SL_WAKE_EVENT_CURSOR: normalizeString(event?.cursor),
1577
+ SL_WAKE_EVENT_SEQUENCE: String(event?.sequenceId ?? event?.sequence_id ?? ""),
1578
+ SL_WAKE_ACTOR_ID: normalizeString(event?.agent?.id || event?.agentId),
1579
+ };
1580
+ let child;
1581
+ try {
1582
+ child = spawnImpl(wakeCommand, { shell: true, env, stdio: ["pipe", "ignore", "ignore"] });
1583
+ } catch (error) {
1584
+ busy = false;
1585
+ emit({ status: "error", reason: normalizeString(error?.message) || "spawn_failed" });
1586
+ return;
1587
+ }
1588
+ emit({
1589
+ status: "fired",
1590
+ eventType: env.SL_WAKE_EVENT_TYPE,
1591
+ cursor: env.SL_WAKE_EVENT_CURSOR,
1592
+ actorId: env.SL_WAKE_ACTOR_ID,
1593
+ });
1594
+ try {
1595
+ if (child && child.stdin) {
1596
+ child.stdin.write(JSON.stringify(event ?? {}));
1597
+ child.stdin.end();
1598
+ }
1599
+ } catch {
1600
+ // Broken pipe (command ignored stdin) is non-fatal for a wake hook.
1601
+ }
1602
+ const finish = (reason) => {
1603
+ busy = false;
1604
+ if (reason) emit({ status: "error", reason });
1605
+ const next = pending;
1606
+ pending = null;
1607
+ if (next !== null) run(next);
1608
+ };
1609
+ if (child && typeof child.on === "function") {
1610
+ child.on("error", (error) => finish(normalizeString(error?.message) || "wake_failed"));
1611
+ child.on("exit", (code) => finish(code && code !== 0 ? `exit_${code}` : ""));
1612
+ } else {
1613
+ finish("");
1614
+ }
1615
+ };
1616
+
1617
+ return { trigger: run, hasCommand: Boolean(wakeCommand) };
1618
+ }
1619
+
1620
+ // Message actions (ack/like/dislike/reply/view/working_on) must be authored by
1621
+ // a concrete agent identity. The CLI's bare `cli-user` default is a reserved
1622
+ // label the API rejects (api_422), so treat it as "unset" and resolve the real
1623
+ // agent the same way `session say` does (explicit --agent > SENTINELAYER_AGENT_ID
1624
+ // > the single joined agent). Returns the resolved identity; callers should use
1625
+ // shouldBlockImplicitCliUserSessionSay() to refuse the implicit cli-user
1626
+ // fallback before sending a request that is guaranteed to fail.
1627
+ export async function resolveMessageActionIdentity({
1628
+ sessionId,
1629
+ optionAgent = "",
1630
+ targetPath = process.cwd(),
1631
+ env = process.env,
1632
+ } = {}) {
1633
+ const explicit = normalizeString(optionAgent);
1634
+ const agentSeed = explicit && explicit.toLowerCase() !== "cli-user" ? explicit : "";
1635
+ return resolveSessionSayIdentity({ sessionId, agentId: agentSeed, targetPath, env });
1636
+ }
1637
+
1638
+ async function ensureSessionSayAgentRegistered(
1639
+ sessionId,
1640
+ agent = {},
1641
+ { targetPath = process.cwd() } = {},
1642
+ ) {
1643
+ const agentId = normalizeString(agent.id);
1644
+ if (!canPublishListenerPresence(agentId)) {
1645
+ return { persisted: false, reason: "placeholder_agent" };
1646
+ }
1647
+
1648
+ try {
1649
+ const activeAgents = await listAgents(sessionId, { targetPath, includeInactive: false });
1650
+ if (
1651
+ activeAgents.some(
1652
+ (existing) => normalizeString(existing.agentId).toLowerCase() === agentId.toLowerCase(),
1653
+ )
1654
+ ) {
1655
+ return { persisted: false, reason: "already_registered" };
1656
+ }
1657
+ } catch {
1658
+ // If the local registry is unreadable, let rememberAgentIdentity surface the
1659
+ // filesystem problem with its normal error message.
1660
+ }
1661
+
1662
+ const registered = await rememberAgentIdentity(sessionId, {
1663
+ agentId,
1664
+ model: normalizeString(agent.model) || "cli",
1665
+ role: sessionSayRegistryRole(agent.role),
1666
+ targetPath,
1667
+ });
1668
+
1669
+ return {
1670
+ persisted: true,
1671
+ agentId: registered.agentId,
1672
+ };
1673
+ }
1674
+
976
1675
  async function resolveSessionAgentEnvelope(
977
1676
  sessionId,
978
1677
  agentId,
@@ -1062,6 +1761,13 @@ function formatEventLine(event = {}) {
1062
1761
  return `${ts} ${agentId} ${type}`;
1063
1762
  }
1064
1763
 
1764
+ function isSessionControlEvent(event = {}) {
1765
+ const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
1766
+ const type = normalizeString(event.event || event.type).toLowerCase();
1767
+ if (type.startsWith("session_listener_")) return true;
1768
+ return normalizeString(payload.source).toLowerCase() === "session_listen";
1769
+ }
1770
+
1065
1771
  function checkpointSequenceRange(checkpoint = {}) {
1066
1772
  const start = Number(checkpoint.startSequence || 0);
1067
1773
  const end = Number(checkpoint.endSequence || 0);
@@ -1926,6 +2632,7 @@ export function registerSessionCommand(program) {
1926
2632
  awaitRemoteSync: Boolean(explicitAgent),
1927
2633
  });
1928
2634
  const agentJoinRelayed =
2635
+ joined.emittedJoinEvent !== false &&
1929
2636
  Boolean(explicitAgent) &&
1930
2637
  Boolean(resolvedAgentId) &&
1931
2638
  resolvedAgentId !== "cli-user" &&
@@ -1980,7 +2687,10 @@ export function registerSessionCommand(program) {
1980
2687
  session
1981
2688
  .command("say <sessionId> <message>")
1982
2689
  .description("Send a message to the session")
1983
- .option("--agent <id>", "Agent id to emit from", "cli-user")
2690
+ .option(
2691
+ "--agent <id>",
2692
+ "Agent id to emit from; defaults to SENTINELAYER_AGENT_ID, then the sole local joined agent, then cli-user",
2693
+ )
1984
2694
  .option(
1985
2695
  "--model <model>",
1986
2696
  "Agent model/provider hint; defaults to local joined agent metadata or SENTINELAYER_AGENT_MODEL",
@@ -1999,6 +2709,8 @@ export function registerSessionCommand(program) {
1999
2709
  .option("--to <agent>", "Direct the message to a specific agent id")
2000
2710
  .option("--reply-to <sequence>", "Mark this message as a reply to a target sequence id")
2001
2711
  .option("--reply-cursor <cursor>", "Mark this message as a reply to a target event cursor")
2712
+ .option("--force-cli-user", "Allow fallback sends as cli-user when no agent identity can be resolved")
2713
+ .option("--local-only", "Append only to the local session cache without remote send confirmation")
2002
2714
  .option("--path <path>", "Workspace path for the session", ".")
2003
2715
  .option("--json", "Emit machine-readable output")
2004
2716
  .action(async (sessionId, message, options, command) => {
@@ -2011,16 +2723,28 @@ export function registerSessionCommand(program) {
2011
2723
  throw new Error("message is required.");
2012
2724
  }
2013
2725
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
2014
- const agentId = await defaultAgentId(options.agent, targetPath);
2726
+ const identity = await resolveSessionSayIdentity({
2727
+ sessionId: normalizedSessionId,
2728
+ agentId: options.agent,
2729
+ targetPath,
2730
+ });
2731
+ const agentId = identity.agentId;
2732
+ if (shouldBlockImplicitCliUserSessionSay(identity) && !options.forceCliUser) {
2733
+ throw new Error(
2734
+ `${identity.identityWarning} Re-run with --force-cli-user only for intentional anonymous/operator relay posts.`,
2735
+ );
2736
+ }
2015
2737
  const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
2016
2738
  targetPath,
2017
2739
  });
2018
2740
  const to = normalizeString(options.to);
2019
2741
  const replyToSequenceId = parseOptionalPositiveInteger(options.replyTo, "reply-to");
2020
2742
  const replyToCursor = normalizeString(options.replyCursor);
2743
+ const clientMessageId = `cli-${randomUUID()}`;
2021
2744
  const eventPayload = {
2022
2745
  message: normalizedMessage,
2023
2746
  channel: "session",
2747
+ clientMessageId,
2024
2748
  };
2025
2749
  if (to) {
2026
2750
  eventPayload.to = to;
@@ -2031,13 +2755,15 @@ export function registerSessionCommand(program) {
2031
2755
  if (replyToCursor) {
2032
2756
  eventPayload.replyToCursor = replyToCursor;
2033
2757
  }
2034
- const clientMessageId = `cli-${randomUUID()}`;
2035
2758
  const agent = await resolveSessionAgentEnvelope(normalizedSessionId, agentId, {
2036
2759
  targetPath,
2037
2760
  model: options.model,
2038
2761
  role: options.role,
2039
2762
  displayName: options.displayName,
2040
2763
  });
2764
+ const agentRegistration = await ensureSessionSayAgentRegistered(normalizedSessionId, agent, {
2765
+ targetPath,
2766
+ });
2041
2767
  const event = createAgentEvent({
2042
2768
  event: "session_message",
2043
2769
  agent,
@@ -2047,7 +2773,22 @@ export function registerSessionCommand(program) {
2047
2773
  event.eventId = clientMessageId;
2048
2774
  event.idempotencyToken = clientMessageId;
2049
2775
  let remoteSync = null;
2050
- if (localSession.materialized) {
2776
+ let remoteConfirmation = null;
2777
+ let remoteConfirmationAnchor = null;
2778
+ let localOnly = Boolean(options.localOnly) || remoteSessionLookupDisabled();
2779
+ if (!localOnly) {
2780
+ remoteConfirmationAnchor = await readSessionConfirmationAnchor(normalizedSessionId, { targetPath });
2781
+ if (!remoteConfirmationAnchor?.ok) {
2782
+ if (!localSession.materialized && isLocalOnlySessionSayReason(remoteConfirmationAnchor?.reason)) {
2783
+ localOnly = true;
2784
+ } else {
2785
+ throw new Error(
2786
+ `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.`,
2787
+ );
2788
+ }
2789
+ }
2790
+ }
2791
+ if (!localOnly) {
2051
2792
  for (let attempt = 0; attempt < 2; attempt += 1) {
2052
2793
  remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
2053
2794
  targetPath,
@@ -2056,13 +2797,23 @@ export function registerSessionCommand(program) {
2056
2797
  }
2057
2798
  if (!remoteSync?.synced) {
2058
2799
  throw new Error(
2059
- `Remote send failed (${remoteSync?.reason || "unknown"}); local cache was not updated.`,
2800
+ `Remote send failed (${remoteSync?.reason || "unknown"}); local cache was not updated. Use --local-only only when you intentionally want an offline local note.`,
2060
2801
  );
2802
+ } else {
2803
+ remoteConfirmation = await confirmSessionEventVisible(normalizedSessionId, clientMessageId, {
2804
+ targetPath,
2805
+ anchorCursor: remoteConfirmationAnchor?.cursor,
2806
+ });
2807
+ if (!remoteConfirmation?.confirmed) {
2808
+ throw new Error(
2809
+ `Remote send was accepted but not visible in canonical session events (${remoteConfirmation?.reason || "not_visible"}); local cache was not updated.`,
2810
+ );
2811
+ }
2061
2812
  }
2062
2813
  }
2063
2814
  const persisted = await appendToStream(normalizedSessionId, event, {
2064
2815
  targetPath,
2065
- syncRemote: !localSession.materialized,
2816
+ syncRemote: false,
2066
2817
  });
2067
2818
  const payload = {
2068
2819
  command: "session say",
@@ -2072,12 +2823,21 @@ export function registerSessionCommand(program) {
2072
2823
  event: persisted,
2073
2824
  materializedLocalSession: localSession.materialized,
2074
2825
  refreshedLocalSession: Boolean(localSession.refreshed),
2826
+ identitySource: identity.source,
2827
+ identityWarning: identity.identityWarning || undefined,
2828
+ agentRegistration,
2075
2829
  remoteSync: remoteSync || undefined,
2830
+ remoteConfirmationAnchor: remoteConfirmationAnchor || undefined,
2831
+ remoteConfirmation: remoteConfirmation || undefined,
2832
+ localOnly,
2076
2833
  };
2077
2834
  if (shouldEmitJson(options, command)) {
2078
2835
  console.log(JSON.stringify(payload, null, 2));
2079
2836
  return;
2080
2837
  }
2838
+ if (identity.identityWarning) {
2839
+ console.error(pc.yellow(`Identity warning: ${identity.identityWarning}`));
2840
+ }
2081
2841
  console.log(formatEventLine(persisted));
2082
2842
  });
2083
2843
 
@@ -2109,11 +2869,13 @@ export function registerSessionCommand(program) {
2109
2869
  targetPath,
2110
2870
  });
2111
2871
  const to = normalizeString(options.to);
2872
+ const clientMessageId = `cli-agent-${randomUUID()}`;
2112
2873
  const eventPayload = {
2113
2874
  message: normalizedMessage,
2114
2875
  channel: "session",
2115
2876
  source: "agent",
2116
2877
  clientKind: "cli",
2878
+ clientMessageId,
2117
2879
  };
2118
2880
  if (to) {
2119
2881
  eventPayload.to = to;
@@ -2125,7 +2887,6 @@ export function registerSessionCommand(program) {
2125
2887
  role: normalizeString(options.role) || "coder",
2126
2888
  clientKind: "cli",
2127
2889
  };
2128
- const clientMessageId = `cli-agent-${randomUUID()}`;
2129
2890
  const event = createAgentEvent({
2130
2891
  event: "session_message",
2131
2892
  agent,
@@ -2135,6 +2896,19 @@ export function registerSessionCommand(program) {
2135
2896
  event.eventId = clientMessageId;
2136
2897
  event.idempotencyToken = clientMessageId;
2137
2898
 
2899
+ const remoteConfirmationAnchor = await readSessionConfirmationAnchor(normalizedSessionId, {
2900
+ targetPath,
2901
+ });
2902
+ if (!remoteConfirmationAnchor?.ok) {
2903
+ if (remoteConfirmationAnchor?.reason === "api_403") {
2904
+ throw new Error(
2905
+ `Agent post failed (api_403). Ensure this user has an active grant for '${agentId}'.`,
2906
+ );
2907
+ }
2908
+ throw new Error(
2909
+ `Agent post confirmation anchor failed (${remoteConfirmationAnchor?.reason || "unknown"}); local cache was not updated.`,
2910
+ );
2911
+ }
2138
2912
  const remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
2139
2913
  targetPath,
2140
2914
  });
@@ -2143,6 +2917,15 @@ export function registerSessionCommand(program) {
2143
2917
  `Agent post failed (${remoteSync?.reason || "unknown"}). Ensure this user has an active grant for '${agentId}'.`,
2144
2918
  );
2145
2919
  }
2920
+ const remoteConfirmation = await confirmSessionEventVisible(normalizedSessionId, clientMessageId, {
2921
+ targetPath,
2922
+ anchorCursor: remoteConfirmationAnchor?.cursor,
2923
+ });
2924
+ if (!remoteConfirmation?.confirmed) {
2925
+ throw new Error(
2926
+ `Agent post was accepted but not visible in canonical session events (${remoteConfirmation?.reason || "not_visible"}); local cache was not updated.`,
2927
+ );
2928
+ }
2146
2929
 
2147
2930
  const persisted = await appendToStream(normalizedSessionId, event, {
2148
2931
  targetPath,
@@ -2157,6 +2940,8 @@ export function registerSessionCommand(program) {
2157
2940
  materializedLocalSession: localSession.materialized,
2158
2941
  refreshedLocalSession: Boolean(localSession.refreshed),
2159
2942
  remoteSync,
2943
+ remoteConfirmationAnchor,
2944
+ remoteConfirmation,
2160
2945
  };
2161
2946
  if (shouldEmitJson(options, command)) {
2162
2947
  console.log(JSON.stringify(payload, null, 2));
@@ -2192,7 +2977,23 @@ export function registerSessionCommand(program) {
2192
2977
  }
2193
2978
  await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
2194
2979
  const note = normalizeString(noteOverride) || normalizeString(options.note);
2195
- const agentId = await defaultAgentId(options.agent, targetPath);
2980
+ // Resolve the authoring agent. The bare `cli-user` default is rejected by
2981
+ // the API (api_422); resolveMessageActionIdentity treats it as unset and
2982
+ // falls back to the joined agent. If no concrete identity resolves, fail
2983
+ // with actionable guidance instead of firing a request guaranteed to 422.
2984
+ const identity = await resolveMessageActionIdentity({
2985
+ sessionId: normalizedSessionId,
2986
+ optionAgent: options.agent,
2987
+ targetPath,
2988
+ env: process.env,
2989
+ });
2990
+ if (shouldBlockImplicitCliUserSessionSay(identity)) {
2991
+ throw new Error(
2992
+ identity.identityWarning ||
2993
+ `${commandName} requires an agent identity; pass --agent <id>, set SENTINELAYER_AGENT_ID, or run session join --agent <id> first.`,
2994
+ );
2995
+ }
2996
+ const agentId = identity.agentId;
2196
2997
  const idempotencyKey =
2197
2998
  normalizeString(options.idempotencyKey) ||
2198
2999
  defaultActionIdempotencyKey({
@@ -2242,7 +3043,12 @@ export function registerSessionCommand(program) {
2242
3043
  console.log(JSON.stringify(payload, null, 2));
2243
3044
  return payload;
2244
3045
  }
2245
- console.log(formatEventLine(localAppend.event || actionEvent));
3046
+ if (localAppend.event || actionEvent) {
3047
+ console.log(formatEventLine(localAppend.event || actionEvent));
3048
+ } else {
3049
+ const targetLabel = targetSequenceId ? `#${targetSequenceId}` : targetCursor || targetActionId || "target";
3050
+ console.log(pc.green(`Recorded ${normalizedActionType} on ${targetLabel}.`));
3051
+ }
2246
3052
  return payload;
2247
3053
  }
2248
3054
 
@@ -2278,7 +3084,7 @@ export function registerSessionCommand(program) {
2278
3084
  .option("--target-cursor <cursor>", "Target event cursor")
2279
3085
  .option("--target-action-id <uuid>", "Target a threaded reply/action by action UUID")
2280
3086
  .option("--note <text>", "Optional action note or reply body")
2281
- .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
3087
+ .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
2282
3088
  .option("--idempotency-key <key>", "Explicit idempotency key")
2283
3089
  .option("--path <path>", "Workspace path for the session", ".")
2284
3090
  .option("--json", "Emit machine-readable output")
@@ -2292,7 +3098,7 @@ export function registerSessionCommand(program) {
2292
3098
  .option("--target-sequence <n>", "Target event sequence id")
2293
3099
  .option("--target-cursor <cursor>", "Target event cursor")
2294
3100
  .option("--target-action-id <uuid>", "Target a threaded reply/action by action UUID")
2295
- .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
3101
+ .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
2296
3102
  .option("--idempotency-key <key>", "Explicit idempotency key")
2297
3103
  .option("--path <path>", "Workspace path for the session", ".")
2298
3104
  .option("--json", "Emit machine-readable output")
@@ -2313,7 +3119,7 @@ export function registerSessionCommand(program) {
2313
3119
  session
2314
3120
  .command("reply <sessionId> <targetSequenceId> <message...>")
2315
3121
  .description("Reply to a target session event using the message-action channel")
2316
- .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
3122
+ .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
2317
3123
  .option("--idempotency-key <key>", "Explicit idempotency key")
2318
3124
  .option("--path <path>", "Workspace path for the session", ".")
2319
3125
  .option("--json", "Emit machine-readable output")
@@ -2333,7 +3139,7 @@ export function registerSessionCommand(program) {
2333
3139
  session
2334
3140
  .command("comment <sessionId> <targetSequenceId> <message...>")
2335
3141
  .description("Alias for `session reply`; add a threaded comment to a target event")
2336
- .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
3142
+ .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
2337
3143
  .option("--idempotency-key <key>", "Explicit idempotency key")
2338
3144
  .option("--path <path>", "Workspace path for the session", ".")
2339
3145
  .option("--json", "Emit machine-readable output")
@@ -2352,8 +3158,8 @@ export function registerSessionCommand(program) {
2352
3158
 
2353
3159
  session
2354
3160
  .command("view <sessionId> <targetSequenceId>")
2355
- .description("Record a read receipt for a target session event")
2356
- .option("--agent <id>", "Agent id for local idempotency metadata", "cli-user")
3161
+ .description("Manually backfill a read receipt for a target session event")
3162
+ .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
2357
3163
  .option("--idempotency-key <key>", "Explicit idempotency key")
2358
3164
  .option("--path <path>", "Workspace path for the session", ".")
2359
3165
  .option("--json", "Emit machine-readable output")
@@ -2368,6 +3174,57 @@ export function registerSessionCommand(program) {
2368
3174
  });
2369
3175
  });
2370
3176
 
3177
+ session
3178
+ .command("pins <sessionId>")
3179
+ .description("List the session's pinned messages with their content so agents can read them")
3180
+ .option("--path <path>", "Workspace path for the session", ".")
3181
+ .option("--json", "Emit machine-readable output")
3182
+ .action(async (sessionId, options, command) => {
3183
+ const normalizedSessionId = normalizeString(sessionId);
3184
+ if (!normalizedSessionId) {
3185
+ throw new Error("session id is required.");
3186
+ }
3187
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3188
+ await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
3189
+ const result = await fetchSessionPinnedMessages(normalizedSessionId, { targetPath });
3190
+ if (!result.ok) {
3191
+ throw new Error(`Could not load pinned messages (${result.reason || "unknown"}).`);
3192
+ }
3193
+ const pinLimit = result.pinLimit || 10;
3194
+ const payload = {
3195
+ command: "session pins",
3196
+ sessionId: normalizedSessionId,
3197
+ pinLimit,
3198
+ count: result.count,
3199
+ pins: result.pins,
3200
+ };
3201
+ if (shouldEmitJson(options, command)) {
3202
+ console.log(JSON.stringify(payload, null, 2));
3203
+ return payload;
3204
+ }
3205
+ if (!result.count) {
3206
+ console.log(pc.gray("No pinned messages in this session."));
3207
+ return payload;
3208
+ }
3209
+ console.log(pc.bold(`📌 Pinned messages (${result.count}/${pinLimit})`));
3210
+ for (const pin of result.pins) {
3211
+ const seqLabel = pin.targetSequenceId ? `#${pin.targetSequenceId}` : "(unknown sequence)";
3212
+ const author = pin.author || "unknown";
3213
+ const pinnedBy = pin.pinnedBy ? ` · pinned by ${pin.pinnedBy}` : "";
3214
+ const when = pin.pinnedAt ? ` · ${pin.pinnedAt}` : "";
3215
+ console.log("");
3216
+ console.log(pc.cyan(`${seqLabel} ${author}${pinnedBy}${when}`));
3217
+ if (pin.content) {
3218
+ for (const line of String(pin.content).split("\n")) {
3219
+ console.log(` ${line}`);
3220
+ }
3221
+ } else {
3222
+ console.log(pc.gray(" (no readable text content for this pinned event)"));
3223
+ }
3224
+ }
3225
+ return payload;
3226
+ });
3227
+
2371
3228
  session
2372
3229
  .command("listen")
2373
3230
  .description("Background-poll a session for events addressed to this agent or broadcast")
@@ -2388,12 +3245,33 @@ export function registerSessionCommand(program) {
2388
3245
  "Seconds after a human message to keep the active interval (default 300)",
2389
3246
  "300",
2390
3247
  )
3248
+ .option(
3249
+ "--presence-interval <seconds>",
3250
+ "Minimum seconds between remote listener heartbeat events (default 60)",
3251
+ "60",
3252
+ )
3253
+ .option(
3254
+ "--no-presence",
3255
+ "Do not publish durable listener lifecycle/heartbeat events",
3256
+ )
3257
+ .option(
3258
+ "--model <model>",
3259
+ "Model/provider label to publish with listener presence",
3260
+ process.env.SENTINELAYER_AGENT_MODEL || process.env.SENTINELAYER_MODEL || "cli",
3261
+ )
3262
+ .option("--display-name <name>", "Human-readable listener name for presence")
2391
3263
  .option("--emit <format>", "Output format: ndjson or text", "ndjson")
3264
+ .option("--transport <mode>", "Listen transport: auto, stream, or poll (default auto)", "auto")
2392
3265
  .option("--limit <n>", "Maximum events to request per poll (default 200)", "200")
2393
3266
  .option("--path <path>", "Workspace path for the session", ".")
2394
3267
  .option("--since <cursor>", "Override the persisted listen cursor")
3268
+ .option("--from-now", "Advance the listen cursor to the latest durable event before polling")
2395
3269
  .option("--replay", "Emit matching historical events on the first poll")
2396
3270
  .option("--max-polls <n>", "Stop after N poll cycles (useful for tests and smoke checks)")
3271
+ .option(
3272
+ "--wake <command>",
3273
+ "Wake hook: run this shell command on each matched event (notify->resume bridge). Event JSON is piped to stdin; SL_WAKE_* env vars are set.",
3274
+ )
2397
3275
  .action(async (options) => {
2398
3276
  const normalizedSessionId = resolveSessionIdOption(options);
2399
3277
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
@@ -2405,26 +3283,90 @@ export function registerSessionCommand(program) {
2405
3283
  5,
2406
3284
  );
2407
3285
  const activeWindowSeconds = parsePositiveInteger(options.activeWindow, "active-window", 300);
3286
+ const presenceIntervalSeconds = parsePositiveInteger(
3287
+ options.presenceInterval,
3288
+ "presence-interval",
3289
+ 60,
3290
+ );
3291
+ const agentModel = normalizeString(options.model) || "cli";
3292
+ const displayName = normalizeString(options.displayName) || agentId;
2408
3293
  const limit = parsePositiveInteger(options.limit, "limit", 200);
2409
3294
  const emitFormat = normalizeString(options.emit).toLowerCase() || "ndjson";
2410
3295
  if (!["ndjson", "text"].includes(emitFormat)) {
2411
3296
  throw new Error("--emit must be one of: ndjson, text.");
2412
3297
  }
3298
+ // Optional wake hook: run a host command on each matched event so the
3299
+ // host can resume/wake its agent (the notify->resume bridge).
3300
+ const emitWakeNotice = (payload = {}) => {
3301
+ if (emitFormat === "ndjson") {
3302
+ console.log(
3303
+ JSON.stringify(
3304
+ createAgentEvent({
3305
+ event: "session_wake_hook",
3306
+ agentId,
3307
+ sessionId: normalizedSessionId,
3308
+ payload,
3309
+ }),
3310
+ ),
3311
+ );
3312
+ } else {
3313
+ const status = normalizeString(payload.status) || "fired";
3314
+ const detail = payload.reason ? ` (${payload.reason})` : payload.eventType ? ` ${payload.eventType}` : "";
3315
+ console.log(pc.cyan(`wake hook ${status}${detail}`));
3316
+ }
3317
+ };
3318
+ const wakeRunner = createSessionWakeRunner({
3319
+ command: options.wake,
3320
+ sessionId: normalizedSessionId,
3321
+ agentId,
3322
+ emit: emitWakeNotice,
3323
+ });
3324
+ const requestedTransport = normalizeString(options.transport).toLowerCase() || "auto";
3325
+ if (!["auto", "stream", "poll"].includes(requestedTransport)) {
3326
+ throw new Error("--transport must be one of: auto, stream, poll.");
3327
+ }
2413
3328
  const maxPolls =
2414
3329
  options.maxPolls === undefined
2415
3330
  ? null
2416
3331
  : parsePositiveInteger(options.maxPolls, "max-polls", 1);
3332
+ const listenTransport = requestedTransport === "auto" && maxPolls !== null
3333
+ ? "poll"
3334
+ : requestedTransport;
2417
3335
  const since = options.since === undefined ? undefined : String(options.since);
3336
+ if (options.fromNow && options.since !== undefined) {
3337
+ throw new Error("Use either --from-now or --since, not both.");
3338
+ }
2418
3339
  const ac = new AbortController();
2419
3340
  const onSigint = () => ac.abort();
2420
3341
  process.on("SIGINT", onSigint);
3342
+ const listenerId = `listener-${agentId}-${randomUUID()}`;
3343
+ const durablePresenceEnabled = options.presence !== false;
3344
+ const publishPresence = durablePresenceEnabled && canPublishListenerPresence(agentId);
3345
+ const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
3346
+ let lastPresenceHeartbeatMs = 0;
2421
3347
 
2422
3348
  if (emitFormat === "text") {
2423
3349
  console.log(
2424
3350
  pc.gray(
2425
- `Listening to session ${normalizedSessionId} as ${agentId}; idle=${intervalSeconds}s active=${activeIntervalSeconds}s/${activeWindowSeconds}s. Press Ctrl+C to stop.`,
3351
+ `Listening to session ${normalizedSessionId} as ${agentId}; transport=${listenTransport} idle=${intervalSeconds}s active=${activeIntervalSeconds}s/${activeWindowSeconds}s. Press Ctrl+C to stop.`,
2426
3352
  ),
2427
3353
  );
3354
+ if (!durablePresenceEnabled) {
3355
+ console.log(
3356
+ pc.gray(
3357
+ "Remote listener presence is disabled; no durable lifecycle events will be written.",
3358
+ ),
3359
+ );
3360
+ } else if (!publishPresence) {
3361
+ console.log(
3362
+ pc.gray(
3363
+ "Listener presence is local-only for placeholder, human, and guest agent ids.",
3364
+ ),
3365
+ );
3366
+ }
3367
+ if (options.fromNow) {
3368
+ console.log(pc.gray("Priming listener from the latest durable event; old backlog will be skipped."));
3369
+ }
2428
3370
  }
2429
3371
 
2430
3372
  try {
@@ -2438,14 +3380,38 @@ export function registerSessionCommand(program) {
2438
3380
  limit,
2439
3381
  since,
2440
3382
  replay: Boolean(options.replay),
3383
+ fromNow: Boolean(options.fromNow),
3384
+ persistStartCursor: Boolean(options.fromNow),
3385
+ transport: listenTransport,
2441
3386
  maxPolls,
2442
3387
  signal: ac.signal,
3388
+ onCatchup: async (catchup) => {
3389
+ if (emitFormat === "ndjson") {
3390
+ console.log(
3391
+ JSON.stringify(
3392
+ buildListenerCatchupEvent({
3393
+ sessionId: normalizedSessionId,
3394
+ agentId,
3395
+ agentModel,
3396
+ displayName,
3397
+ listenerId,
3398
+ catchup,
3399
+ }),
3400
+ ),
3401
+ );
3402
+ } else {
3403
+ console.log(pc.yellow(formatListenerCatchupNotice(catchup)));
3404
+ }
3405
+ },
2443
3406
  onEvent: async (event) => {
2444
3407
  if (emitFormat === "ndjson") {
2445
3408
  console.log(JSON.stringify(event));
2446
3409
  } else {
2447
3410
  console.log(formatEventLine(event));
2448
3411
  }
3412
+ // Fire the wake hook for any matched event (incl. ack/like) so the
3413
+ // host can resume its agent.
3414
+ wakeRunner.trigger(event);
2449
3415
  },
2450
3416
  onError: async (result) => {
2451
3417
  const reason = normalizeString(result?.reason) || "poll_failed";
@@ -2467,12 +3433,394 @@ export function registerSessionCommand(program) {
2467
3433
  console.log(pc.yellow(`Listen poll skipped (${reason}).`));
2468
3434
  }
2469
3435
  },
3436
+ onLifecycle: async (lifecycle) => {
3437
+ if (!publishPresence) return;
3438
+ const lifecycleType = normalizeString(lifecycle?.type);
3439
+ if (lifecycleType === "heartbeat") {
3440
+ const nowMs = Date.now();
3441
+ if (lastPresenceHeartbeatMs > 0 && nowMs - lastPresenceHeartbeatMs < presenceIntervalMs) {
3442
+ return;
3443
+ }
3444
+ lastPresenceHeartbeatMs = nowMs;
3445
+ }
3446
+ await publishListenerPresenceEvent({
3447
+ sessionId: normalizedSessionId,
3448
+ targetPath,
3449
+ agentId,
3450
+ agentModel,
3451
+ displayName,
3452
+ listenerId,
3453
+ lifecycle,
3454
+ });
3455
+ },
2470
3456
  });
2471
3457
  } finally {
2472
3458
  process.removeListener("SIGINT", onSigint);
2473
3459
  }
2474
3460
  });
2475
3461
 
3462
+ session
3463
+ .command("daemon [sessionId]")
3464
+ .description("Run the Senti session daemon: hydrate events, emit recaps, and generate checkpoints")
3465
+ .option("--session <id>", "Session id to monitor")
3466
+ .option("--path <path>", "Workspace path for the session", ".")
3467
+ .option("--tick-interval <seconds>", "Seconds between health ticks (default 30)", "30")
3468
+ .option("--stale-agent-seconds <seconds>", "Seconds before an inactive agent is flagged stale (default 90)", "90")
3469
+ .option("--recap-interval <seconds>", "Seconds between periodic recaps when activity continues (default 300)", "300")
3470
+ .option("--recap-inactivity <seconds>", "Seconds of inactivity before a recap closeout (default 600)", "600")
3471
+ .option("--recap-event-threshold <n>", "Meaningful events required to force a recap before interval (default 5)", "5")
3472
+ .option("--checkpoint-interval <seconds>", "Minimum seconds between checkpoint attempts (default 60)", "60")
3473
+ .option("--checkpoint-min-events <n>", "Minimum events for generated checkpoint windows (default 20)", "20")
3474
+ .option("--checkpoint-max-events <n>", "Maximum events per generated checkpoint window (default 80)", "80")
3475
+ .option("--checkpoint-event-threshold <n>", "Meaningful events required to attempt a checkpoint (default 20)", "20")
3476
+ .option("--checkpoint-idle <seconds>", "Seconds after latest source event before idle checkpoint attempt (default 600)", "600")
3477
+ .option("--no-checkpoints", "Disable durable checkpoint generation")
3478
+ .option("--no-checkpoint-closeout", "Skip the final closeout checkpoint when the daemon stops")
3479
+ .option("--once", "Run one Senti health tick and exit (CI/dogfood smoke)")
3480
+ .option("--json", "Emit machine-readable output")
3481
+ .action(async (sessionId, options, command) => {
3482
+ const emitJson = shouldEmitJson(options, command);
3483
+ const normalizedSessionId = normalizeString(sessionId) || resolveSessionIdOption(options);
3484
+ if (!normalizedSessionId) {
3485
+ throw new Error("session daemon requires a session id (positional or --session).");
3486
+ }
3487
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3488
+ const tickIntervalMs = parsePositiveMillisecondsFromSeconds(
3489
+ options.tickInterval,
3490
+ "tick-interval",
3491
+ 30,
3492
+ );
3493
+ const daemon = await startSenti(normalizedSessionId, {
3494
+ targetPath,
3495
+ autoStart: false,
3496
+ tickIntervalMs,
3497
+ staleAgentSeconds: parsePositiveInteger(options.staleAgentSeconds, "stale-agent-seconds", 90),
3498
+ recapIntervalMs: parsePositiveMillisecondsFromSeconds(options.recapInterval, "recap-interval", 300),
3499
+ recapInactivityMs: parsePositiveMillisecondsFromSeconds(options.recapInactivity, "recap-inactivity", 600),
3500
+ recapActivityThreshold: parsePositiveInteger(options.recapEventThreshold, "recap-event-threshold", 5),
3501
+ checkpointGenerator: options.checkpoints === false ? null : undefined,
3502
+ checkpointIntervalMs: parsePositiveMillisecondsFromSeconds(options.checkpointInterval, "checkpoint-interval", 60),
3503
+ checkpointMinEvents: parsePositiveInteger(options.checkpointMinEvents, "checkpoint-min-events", 20),
3504
+ checkpointMaxEvents: parsePositiveInteger(options.checkpointMaxEvents, "checkpoint-max-events", 80),
3505
+ checkpointEventThreshold: parsePositiveInteger(options.checkpointEventThreshold, "checkpoint-event-threshold", 20),
3506
+ checkpointIdleMs: parsePositiveMillisecondsFromSeconds(options.checkpointIdle, "checkpoint-idle", 600),
3507
+ checkpointCloseoutOnStop: options.once ? false : options.checkpointCloseout !== false,
3508
+ });
3509
+
3510
+ const runTickAndBuildPayload = async () => {
3511
+ const summary = await daemon.runTick(new Date().toISOString());
3512
+ return {
3513
+ command: "session daemon",
3514
+ sessionId: normalizedSessionId,
3515
+ targetPath,
3516
+ once: Boolean(options.once),
3517
+ running: daemon.isRunning(),
3518
+ summary,
3519
+ state: daemon.getState(),
3520
+ };
3521
+ };
3522
+
3523
+ if (options.once) {
3524
+ const payload = await runTickAndBuildPayload();
3525
+ const stopped = await daemon.stop("once_complete");
3526
+ payload.running = false;
3527
+ payload.stopped = {
3528
+ stopped: Boolean(stopped?.stopped),
3529
+ reason: stopped?.reason || "once_complete",
3530
+ checkpointCloseout: stopped?.checkpointCloseout || null,
3531
+ };
3532
+ if (emitJson) {
3533
+ console.log(JSON.stringify(payload, null, 2));
3534
+ return;
3535
+ }
3536
+ const checkpoint = payload.summary?.checkpoint || {};
3537
+ const recap = payload.summary?.recap || {};
3538
+ console.log(
3539
+ pc.green(
3540
+ `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"}`,
3541
+ ),
3542
+ );
3543
+ return;
3544
+ }
3545
+
3546
+ const controller = new AbortController();
3547
+ const stop = () => controller.abort();
3548
+ process.once("SIGINT", stop);
3549
+ process.once("SIGTERM", stop);
3550
+ const waitForNextTick = () =>
3551
+ new Promise((resolve) => {
3552
+ let timer = null;
3553
+ const cleanup = () => {
3554
+ controller.signal.removeEventListener("abort", onAbort);
3555
+ if (timer) {
3556
+ clearTimeout(timer);
3557
+ timer = null;
3558
+ }
3559
+ };
3560
+ const finish = () => {
3561
+ cleanup();
3562
+ resolve();
3563
+ };
3564
+ const onAbort = () => {
3565
+ finish();
3566
+ };
3567
+ controller.signal.addEventListener("abort", onAbort, { once: true });
3568
+ timer = setTimeout(finish, tickIntervalMs);
3569
+ });
3570
+
3571
+ try {
3572
+ if (!emitJson) {
3573
+ console.log(
3574
+ pc.green(
3575
+ `senti daemon: monitoring session ${normalizedSessionId}; tick=${Math.round(tickIntervalMs / 1000)}s. Ctrl-C to stop.`,
3576
+ ),
3577
+ );
3578
+ }
3579
+ let payload = await runTickAndBuildPayload();
3580
+ if (emitJson) {
3581
+ console.log(JSON.stringify(payload));
3582
+ } else {
3583
+ 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"}`));
3584
+ }
3585
+ while (!controller.signal.aborted) {
3586
+ await waitForNextTick();
3587
+ if (controller.signal.aborted) break;
3588
+ payload = await runTickAndBuildPayload();
3589
+ if (emitJson) {
3590
+ console.log(JSON.stringify(payload));
3591
+ } else {
3592
+ 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"}`));
3593
+ }
3594
+ }
3595
+ } finally {
3596
+ process.removeListener("SIGINT", stop);
3597
+ process.removeListener("SIGTERM", stop);
3598
+ const stopped = await daemon.stop("signal");
3599
+ if (emitJson) {
3600
+ console.log(
3601
+ JSON.stringify({
3602
+ command: "session daemon",
3603
+ sessionId: normalizedSessionId,
3604
+ targetPath,
3605
+ stopped: {
3606
+ stopped: Boolean(stopped?.stopped),
3607
+ reason: stopped?.reason || "signal",
3608
+ checkpointCloseout: stopped?.checkpointCloseout || null,
3609
+ },
3610
+ }),
3611
+ );
3612
+ } else {
3613
+ console.log(pc.gray(`senti daemon stopped (${stopped?.reason || "signal"}).`));
3614
+ }
3615
+ }
3616
+ });
3617
+
3618
+ const wake = session
3619
+ .command("wake")
3620
+ .description("Wake or register host CLI sessions for the Senti notification bus");
3621
+
3622
+ wake
3623
+ .command("codex [sessionId]")
3624
+ .description("Resume a Codex CLI session with a Senti wake prompt")
3625
+ .option("--session <id>", "Senti session id")
3626
+ .option("--codex-session <id>", "Codex rollout/session id to resume")
3627
+ .option("--last", "Resume the most recent Codex session instead of a specific id")
3628
+ .option("--message <text>", "Senti message body to inject into Codex")
3629
+ .option("--message-file <path>", "Read Senti message body from a file")
3630
+ .option("--from <id>", "Senti sender id", "senti")
3631
+ .option("--sequence <n>", "Senti source sequence id")
3632
+ .option("--cursor <cursor>", "Senti source cursor")
3633
+ .option("--priority <level>", "Senti source priority")
3634
+ .option("--dashboard-url <url>", "Dashboard URL for the Senti session")
3635
+ .option("--cwd <path>", "Workspace cwd for codex exec", ".")
3636
+ .option("--codex-bin <path>", "Codex executable", "codex")
3637
+ .option("--model <model>", "Optional Codex model override")
3638
+ .option("--codex-json", "Pass --json through to codex exec resume")
3639
+ .option("--skip-git-repo-check", "Pass --skip-git-repo-check through to codex")
3640
+ .option(
3641
+ "--dangerously-bypass-approvals-and-sandbox",
3642
+ "Pass Codex's dangerous no-approval/no-sandbox flag through to the resumed process",
3643
+ )
3644
+ .option("--timeout-ms <n>", "Wake process timeout in milliseconds", "600000")
3645
+ .option("--dry-run", "Print the resume invocation without spawning Codex")
3646
+ .option("--json", "Emit machine-readable output")
3647
+ .action(async (sessionId, options, command) => {
3648
+ const emitJson = shouldEmitJson(options, command);
3649
+ const normalizedSessionId = normalizeString(sessionId) || resolveSessionIdOption(options);
3650
+ const targetPath = path.resolve(process.cwd(), String(options.cwd || "."));
3651
+ if (options.message && options.messageFile) {
3652
+ throw new Error("Use either --message or --message-file, not both.");
3653
+ }
3654
+ const message = options.messageFile
3655
+ ? await fsp.readFile(path.resolve(process.cwd(), String(options.messageFile)), "utf-8")
3656
+ : normalizeString(options.message);
3657
+ const prompt = buildCodexWakePrompt({
3658
+ sentiSessionId: normalizedSessionId,
3659
+ message,
3660
+ from: options.from,
3661
+ sequenceId: parseOptionalPositiveInteger(options.sequence, "sequence"),
3662
+ cursor: options.cursor,
3663
+ priority: options.priority,
3664
+ dashboardUrl: options.dashboardUrl,
3665
+ });
3666
+ const invocation = buildCodexExecResumeInvocation({
3667
+ codexSessionId: options.codexSession,
3668
+ prompt,
3669
+ cwd: targetPath,
3670
+ codexBin: options.codexBin,
3671
+ useLast: Boolean(options.last),
3672
+ json: Boolean(options.codexJson),
3673
+ model: options.model,
3674
+ skipGitRepoCheck: Boolean(options.skipGitRepoCheck),
3675
+ dangerouslyBypassApprovalsAndSandbox: Boolean(options.dangerouslyBypassApprovalsAndSandbox),
3676
+ });
3677
+ const payload = {
3678
+ command: "session wake codex",
3679
+ sessionId: normalizedSessionId,
3680
+ dryRun: Boolean(options.dryRun),
3681
+ invocation,
3682
+ prompt,
3683
+ };
3684
+ if (options.dryRun) {
3685
+ if (emitJson) {
3686
+ console.log(JSON.stringify(payload, null, 2));
3687
+ return;
3688
+ }
3689
+ console.log(pc.green("Codex wake dry run"));
3690
+ console.log(`${payload.invocation.command} ${payload.invocation.args.map((arg) => JSON.stringify(arg)).join(" ")}`);
3691
+ return;
3692
+ }
3693
+ const result = await runCodexExecResume({
3694
+ invocation,
3695
+ timeoutMs: parsePositiveInteger(options.timeoutMs, "timeout-ms", 600000),
3696
+ });
3697
+ const output = {
3698
+ ...payload,
3699
+ result,
3700
+ };
3701
+ if (emitJson) {
3702
+ console.log(JSON.stringify(output, null, 2));
3703
+ return;
3704
+ }
3705
+ const color = result.exitCode === 0 ? pc.green : pc.yellow;
3706
+ console.log(color(`Codex wake completed with exit code ${result.exitCode}.`));
3707
+ if (normalizeString(result.stderr)) {
3708
+ console.log(pc.gray(result.stderr.trim()));
3709
+ }
3710
+ if (result.exitCode !== 0) {
3711
+ process.exitCode = Number(result.exitCode) || 1;
3712
+ }
3713
+ });
3714
+
3715
+ wake
3716
+ .command("codex-notify [sessionId] [notificationJson]")
3717
+ .description("Record Codex notify payloads so sentid can resume the correct Codex rollout")
3718
+ .option("--session <id>", "Senti session id")
3719
+ .option("--agent <id>", "Senti agent id", process.env.SENTINELAYER_AGENT_ID || "codex")
3720
+ .option("--notification <json>", "Notification JSON override")
3721
+ .option("--path <path>", "Workspace path for the Senti session", ".")
3722
+ .option("--json", "Emit machine-readable output")
3723
+ .action(async (sessionId, notificationJson, options, command) => {
3724
+ const emitJson = shouldEmitJson(options, command);
3725
+ const normalizedSessionId = normalizeString(sessionId) || resolveSessionIdOption(options);
3726
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3727
+ const payload = normalizeString(options.notification) || normalizeString(notificationJson);
3728
+ const result = await recordCodexWakeRegistration({
3729
+ sessionId: normalizedSessionId,
3730
+ agentId: options.agent,
3731
+ notificationPayload: payload,
3732
+ targetPath,
3733
+ });
3734
+ const output = {
3735
+ command: "session wake codex-notify",
3736
+ sessionId: normalizedSessionId,
3737
+ agentId: normalizeString(options.agent) || "codex",
3738
+ ...result,
3739
+ };
3740
+ if (emitJson) {
3741
+ console.log(JSON.stringify(output, null, 2));
3742
+ return;
3743
+ }
3744
+ if (!result.registered) {
3745
+ console.log(pc.gray(`Ignored Codex notification: ${result.reason}.`));
3746
+ return;
3747
+ }
3748
+ console.log(pc.green(`Registered Codex wake target: ${result.registryPath}`));
3749
+ });
3750
+
3751
+ wake
3752
+ .command("daemon [sessionId]")
3753
+ .description("Run the sentid wake daemon: watch the session stream and wake this agent on new messages")
3754
+ .option("--session <id>", "Senti session id")
3755
+ .option("--agent <id>", "Local agent this daemon wakes", process.env.SENTINELAYER_AGENT_ID || "")
3756
+ .option("--host <name>", "Host adapter to wake (claude|codex)", "claude")
3757
+ .option("--resume-session <id>", "Host session id to resume on wake")
3758
+ .option("--cwd <path>", "Workspace cwd", ".")
3759
+ .option("--idle-ms <n>", "Idle poll backoff in milliseconds", "1500")
3760
+ .option("--max-attempts <n>", "Wake retries before dead-letter", "5")
3761
+ .option("--once", "Run a single fetch/dispatch tick and exit (dogfood/CI)")
3762
+ .option("--json", "Emit machine-readable output")
3763
+ .action(async (sessionId, options, command) => {
3764
+ const emitJson = shouldEmitJson(options, command);
3765
+ const normalizedSessionId = normalizeString(sessionId) || resolveSessionIdOption(options);
3766
+ if (!normalizedSessionId) {
3767
+ throw new Error("session wake daemon requires a Senti session id (positional or --session).");
3768
+ }
3769
+ const agentId = normalizeString(options.agent);
3770
+ if (!agentId) {
3771
+ throw new Error("session wake daemon requires --agent (the local agent to wake).");
3772
+ }
3773
+ const host = normalizeString(options.host) || "claude";
3774
+ const resumeSessionId = normalizeString(options.resumeSession);
3775
+ if (!resumeSessionId) {
3776
+ throw new Error("session wake daemon requires --resume-session (the host session id to resume).");
3777
+ }
3778
+ const targetPath = path.resolve(process.cwd(), String(options.cwd || "."));
3779
+ const sentid = createSentid({
3780
+ sessionId: normalizedSessionId,
3781
+ agentId,
3782
+ host,
3783
+ resumeSessionId,
3784
+ targetPath,
3785
+ idleMs: parseOptionalPositiveInteger(options.idleMs, "idle-ms") || 1500,
3786
+ maxAttempts: parseOptionalPositiveInteger(options.maxAttempts, "max-attempts") || 5,
3787
+ logger: emitJson
3788
+ ? undefined
3789
+ : (level, msg, meta) => console.error(`[sentid:${level}] ${msg}${meta ? ` ${JSON.stringify(meta)}` : ""}`),
3790
+ });
3791
+
3792
+ if (options.once) {
3793
+ const tick = await sentid.tickOnce({ fetchCursor: null });
3794
+ const out = {
3795
+ command: "session wake daemon",
3796
+ once: true,
3797
+ sessionId: normalizedSessionId,
3798
+ agent: agentId,
3799
+ host,
3800
+ cursor: sentid.getCursor(),
3801
+ dispatched: Array.isArray(tick.results) ? tick.results.length : 0,
3802
+ idle: Boolean(tick.idle),
3803
+ };
3804
+ console.log(
3805
+ emitJson
3806
+ ? JSON.stringify(out, null, 2)
3807
+ : pc.green(`sentid tick: cursor=${out.cursor} dispatched=${out.dispatched}${out.idle ? " (idle)" : ""}`),
3808
+ );
3809
+ return;
3810
+ }
3811
+
3812
+ const ac = new AbortController();
3813
+ const stop = () => ac.abort();
3814
+ process.once("SIGINT", stop);
3815
+ process.once("SIGTERM", stop);
3816
+ if (!emitJson) {
3817
+ console.log(
3818
+ pc.green(`sentid daemon: waking ${agentId} (host=${host}) on session ${normalizedSessionId}. Ctrl-C to stop.`),
3819
+ );
3820
+ }
3821
+ await sentid.start({ signal: ac.signal });
3822
+ });
3823
+
2476
3824
  const recap = session
2477
3825
  .command("recap")
2478
3826
  .description("Build deterministic Senti session recaps");
@@ -2574,6 +3922,9 @@ export function registerSessionCommand(program) {
2574
3922
  )
2575
3923
  .option("--before-sequence <n>", "Remote page ending before this sequence id")
2576
3924
  .option("--no-actions", "Do not include remote message actions/replies/reactions")
3925
+ .option("--agent <id>", "Agent id for automatic view receipts", process.env.SENTINELAYER_AGENT_ID || "cli-user")
3926
+ .option("--no-view", "Do not record automatic view receipts for displayed remote messages")
3927
+ .option("--include-control-events", "Include listener lifecycle/control-plane events in transcript output")
2577
3928
  .option("--path <path>", "Workspace path for the session", ".")
2578
3929
  .option("--json", "Emit machine-readable output")
2579
3930
  .action(async (sessionId, options, command) => {
@@ -2585,6 +3936,11 @@ export function registerSessionCommand(program) {
2585
3936
  const tail = parsePositiveInteger(options.tail, "tail", 20);
2586
3937
  const beforeSequence = parseOptionalPositiveInteger(options.beforeSequence, "before-sequence");
2587
3938
  const emitJson = shouldEmitJson(options, command);
3939
+ const includeControlEvents = Boolean(options.includeControlEvents);
3940
+ const remoteTailLimit = includeControlEvents
3941
+ ? tail
3942
+ : Math.min(200, Math.max(tail, tail + 20));
3943
+ const readAgentId = await defaultAgentId(options.agent, targetPath);
2588
3944
 
2589
3945
  let hydration = null;
2590
3946
  let remoteTail = null;
@@ -2605,7 +3961,7 @@ export function registerSessionCommand(program) {
2605
3961
  remoteTail = await pollSessionEventsBefore(normalizedSessionId, {
2606
3962
  targetPath,
2607
3963
  beforeSequence,
2608
- limit: tail,
3964
+ limit: remoteTailLimit,
2609
3965
  timeoutMs: 15_000,
2610
3966
  });
2611
3967
  if (options.actions !== false) {
@@ -2681,7 +4037,23 @@ export function registerSessionCommand(program) {
2681
4037
  const actionEvents = remoteActions?.ok
2682
4038
  ? buildSessionActionEvents(normalizedSessionId, remoteActions.actions)
2683
4039
  : [];
2684
- const events = mergeSessionActionEvents(displayEvents, actionEvents).slice(-tail);
4040
+ const unacknowledgedHumanMessages = remoteActions?.ok
4041
+ ? readProjectionUnacknowledgedHumanMessages(remoteActions)
4042
+ : [];
4043
+ const recentHumanActivity = remoteActions?.ok
4044
+ ? readProjectionRecentHumanActivity(remoteActions)
4045
+ : [];
4046
+ const transcriptEvents = includeControlEvents
4047
+ ? displayEvents
4048
+ : displayEvents.filter((event) => !isSessionControlEvent(event));
4049
+ const hiddenControlEventCount = displayEvents.length - transcriptEvents.length;
4050
+ const events = mergeSessionActionEvents(transcriptEvents, actionEvents).slice(-tail);
4051
+ const autoView = await recordSessionReadViews(normalizedSessionId, events, {
4052
+ targetPath,
4053
+ agentId: readAgentId,
4054
+ enabled: Boolean(options.remote && options.view !== false),
4055
+ maxTargets: process.env.SENTINELAYER_SESSION_READ_VIEW_MAX_TARGETS || 50,
4056
+ });
2685
4057
  const remoteVerified = Boolean(
2686
4058
  options.remote &&
2687
4059
  ((hydration && hydration.ok) || (remoteTail && remoteTail.ok))
@@ -2694,6 +4066,13 @@ export function registerSessionCommand(program) {
2694
4066
  beforeSequence,
2695
4067
  count: events.length,
2696
4068
  events,
4069
+ includeControlEvents,
4070
+ hiddenControlEventCount,
4071
+ unacknowledgedHumanMessageCount: unacknowledgedHumanMessages.length,
4072
+ unacknowledgedHumanMessages,
4073
+ recentHumanActivityCount: recentHumanActivity.length,
4074
+ recentHumanActivity,
4075
+ autoView,
2697
4076
  displaySource: !options.remote
2698
4077
  ? "local"
2699
4078
  : remoteTail?.ok
@@ -2737,6 +4116,18 @@ export function registerSessionCommand(program) {
2737
4116
  console.log(JSON.stringify(payload, null, 2));
2738
4117
  return;
2739
4118
  }
4119
+ if (unacknowledgedHumanMessages.length > 0) {
4120
+ console.log(pc.yellow(`Unacknowledged human asks: ${unacknowledgedHumanMessages.length}`));
4121
+ for (const event of unacknowledgedHumanMessages.slice(0, 3)) {
4122
+ console.log(pc.yellow(`- ${formatHumanAskLine(event)}`));
4123
+ }
4124
+ }
4125
+ if (recentHumanActivity.length > 0) {
4126
+ console.log(pc.yellow(`Recent human activity: ${recentHumanActivity.length}`));
4127
+ for (const action of recentHumanActivity.slice(0, 3)) {
4128
+ console.log(pc.yellow(`- ${formatMessageActionActivityLine(action)}`));
4129
+ }
4130
+ }
2740
4131
  for (const event of events) {
2741
4132
  console.log(formatEventLine(event));
2742
4133
  }
@@ -3048,6 +4439,8 @@ export function registerSessionCommand(program) {
3048
4439
  .description("Generate a checkpoint from the next uncheckpointed durable event window")
3049
4440
  .option("--min-events <n>", "Minimum source events required before creating (default 20)", "20")
3050
4441
  .option("--max-events <n>", "Maximum source events to summarize (default 80, max 200)", "80")
4442
+ .option("--catch-up", "Generate multiple consecutive checkpoint windows until caught up or capped")
4443
+ .option("--max-checkpoints <n>", "Maximum checkpoint windows to create with --catch-up (default 5, max 50)", "5")
3051
4444
  .option("--operation-id <key>", "Explicit retry key for this generate invocation")
3052
4445
  .option("--agent <id>", "Optional agent id recorded as checkpoint creator")
3053
4446
  .option("--path <path>", "Workspace path for auth/session context", ".")
@@ -3061,6 +4454,49 @@ export function registerSessionCommand(program) {
3061
4454
  const agentId = normalizeString(options.agent)
3062
4455
  ? await defaultAgentId(options.agent, targetPath)
3063
4456
  : "";
4457
+ if (options.catchUp) {
4458
+ const result = await generateSessionCheckpointBatch(normalizedSessionId, {
4459
+ targetPath,
4460
+ minEvents: options.minEvents,
4461
+ maxEvents: options.maxEvents,
4462
+ maxCheckpoints: options.maxCheckpoints,
4463
+ idempotencyKey: options.operationId,
4464
+ createdByAgentId: agentId,
4465
+ });
4466
+ const hydration = result.createdCount > 0
4467
+ ? await hydrateAfterCheckpointMutation(normalizedSessionId, { targetPath })
4468
+ : null;
4469
+ const payload = {
4470
+ command: "session checkpoint generate",
4471
+ targetPath,
4472
+ ...result,
4473
+ hydration: hydration || undefined,
4474
+ };
4475
+ if (shouldEmitJson(options, command)) {
4476
+ console.log(JSON.stringify(payload, null, 2));
4477
+ return;
4478
+ }
4479
+ if (result.createdCount > 0) {
4480
+ console.log(pc.bold(`checkpoint catch-up generated ${result.createdCount} checkpoint${result.createdCount === 1 ? "" : "s"}`));
4481
+ for (const item of result.results) {
4482
+ if (item.checkpoint) {
4483
+ console.log(formatCheckpointLine(item.checkpoint));
4484
+ }
4485
+ }
4486
+ console.log(pc.gray(`Stopped: ${result.stoppedReason || "complete"} after ${result.attemptedCount} attempt${result.attemptedCount === 1 ? "" : "s"}.`));
4487
+ if (hydration && !hydration.ok) {
4488
+ console.log(pc.gray(`Local hydrate skipped: ${hydration.reason || "unknown"}`));
4489
+ }
4490
+ return;
4491
+ }
4492
+ const last = result.lastResult || {};
4493
+ console.log(
4494
+ pc.gray(
4495
+ `No checkpoint created: ${normalizeString(last.reason || result.stoppedReason) || "not_needed"} (${Number(last.eventCount || 0)} events, min ${Number(last.minEvents || options.minEvents || 0)}).`,
4496
+ ),
4497
+ );
4498
+ return;
4499
+ }
3064
4500
  const result = await generateSessionCheckpoint(normalizedSessionId, {
3065
4501
  targetPath,
3066
4502
  minEvents: options.minEvents,
@@ -3582,14 +5018,7 @@ export function registerSessionCommand(program) {
3582
5018
  return;
3583
5019
  }
3584
5020
  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
- );
5021
+ console.log(formatSessionListLine(item));
3593
5022
  }
3594
5023
  if (remote.count > trimmed.length || remote.hasMore) {
3595
5024
  console.log(
@@ -3637,10 +5066,9 @@ export function registerSessionCommand(program) {
3637
5066
  return;
3638
5067
  }
3639
5068
  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
- );
5069
+ const line = formatSessionListLine(item);
5070
+ const expires = item.expiresAt ? ` expires=${item.expiresAt}` : "";
5071
+ console.log(`${line}${expires}`);
3644
5072
  }
3645
5073
  if (sessions.length > trimmed.length) {
3646
5074
  console.log(