sentinelayer-cli 0.8.12 → 0.9.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.
@@ -59,7 +59,11 @@ export function buildLegacyArgs(baseArgs, { commandOptions = {}, command } = {})
59
59
  appendPassthroughFlag(args, "--provider", commandOptions.provider);
60
60
  appendPassthroughFlag(args, "--reuse-omargate", commandOptions.reuseOmargate);
61
61
  appendPassthroughFlag(args, "--notify-email", commandOptions.notifyEmail);
62
+ appendPassthroughFlag(args, "--email-on-complete", commandOptions.emailOnComplete);
62
63
  appendPassthroughFlag(args, "--notify-session", commandOptions.notifySession);
64
+ appendPassthroughFlag(args, "--devtestbot-base-url", commandOptions.devtestbotBaseUrl);
65
+ appendPassthroughFlag(args, "--devtestbot-scope", commandOptions.devtestbotScope);
66
+ appendNegatedBooleanFlag(args, "--no-devtestbot", commandOptions.devtestbot);
63
67
  // Omar Gate per-persona filter flags (A-CLI-1).
64
68
  appendPassthroughFlag(args, "--persona", commandOptions.persona);
65
69
  appendPassthroughFlag(args, "--skip-persona", commandOptions.skipPersona);
@@ -48,9 +48,13 @@ export function registerOmarGateCommand(program, invokeLegacy) {
48
48
  .option("--skip-persona <csv>", "Skip these personas (comma-separated IDs)")
49
49
  .option("--stream", "Emit NDJSON events to stdout as personas work file-by-file")
50
50
  .option("--notify-email <addr>", "Send final report to this email (default: account email)")
51
+ .option("--email-on-complete <addr>", "Trigger the API-side DD report email after the run completes")
51
52
  .option("--notify-session <session-id>", "Stream progress into this Senti session (default: auto-start)")
52
53
  .option("--no-email", "Skip email dispatch")
53
54
  .option("--no-dashboard", "Skip dashboard card persistence")
55
+ .option("--devtestbot-base-url <url>", "Approved absolute URL for devTestBot browser lanes")
56
+ .option("--devtestbot-scope <scope>", "devTestBot runtime scope (default: orchestrator decides)")
57
+ .option("--no-devtestbot", "Skip the automated devTestBot phase")
54
58
  .option("--dry-run", "Validate config + emit plan.json; skip LLM calls")
55
59
  .option("--json", "Emit machine-readable final output")
56
60
  .action(async (options, command) => {
@@ -47,6 +47,7 @@ import {
47
47
  listActiveSessions,
48
48
  listAllSessions,
49
49
  recordSessionProvisionedIdentities,
50
+ updateSessionTitle,
50
51
  } from "../session/store.js";
51
52
  import { appendToStream, readStream, tailStream } from "../session/stream.js";
52
53
  import { readSessionPreview } from "../session/preview.js";
@@ -57,6 +58,7 @@ import {
57
58
  } from "../session/sync.js";
58
59
  import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
59
60
  import { mergeLiveSources } from "../session/live-source.js";
61
+ import { listenSessionEvents } from "../session/listener.js";
60
62
  import { deriveSessionTitle } from "../session/senti-naming.js";
61
63
  import {
62
64
  buildDashboardUrl,
@@ -89,6 +91,233 @@ function parsePositiveInteger(rawValue, field, fallbackValue) {
89
91
  return Math.floor(normalized);
90
92
  }
91
93
 
94
+ function normalizeComparablePath(value) {
95
+ return String(value || "")
96
+ .trim()
97
+ .replace(/\\/g, "/")
98
+ .replace(/\/+$/g, "")
99
+ .toLowerCase();
100
+ }
101
+
102
+ function latestSessionActivityMs(entry = {}) {
103
+ for (const key of ["lastInteractionAt", "lastActivityAt", "createdAt"]) {
104
+ const epoch = Date.parse(normalizeString(entry[key]));
105
+ if (Number.isFinite(epoch)) return epoch;
106
+ }
107
+ return 0;
108
+ }
109
+
110
+ function remoteSessionLookupDisabled() {
111
+ return String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1";
112
+ }
113
+
114
+ function mergeResumeCandidate(existing, incoming) {
115
+ if (!existing) return incoming;
116
+ const existingActivity = Number(existing._activityMs || 0);
117
+ const incomingActivity = Number(incoming._activityMs || 0);
118
+ const preferIncomingPaths = existing._source !== "local" && incoming._source === "local";
119
+ const base = preferIncomingPaths ? incoming : existing;
120
+ const other = preferIncomingPaths ? existing : incoming;
121
+ return {
122
+ ...base,
123
+ title: normalizeString(base.title) || normalizeString(other.title) || null,
124
+ lastActivityAt:
125
+ normalizeString(incoming.lastActivityAt) || normalizeString(existing.lastActivityAt) || null,
126
+ lastInteractionAt:
127
+ normalizeString(incoming.lastInteractionAt) || normalizeString(existing.lastInteractionAt) || null,
128
+ _activityMs: Math.max(existingActivity, incomingActivity),
129
+ };
130
+ }
131
+
132
+ async function findReusableSessionCandidate({
133
+ targetPath,
134
+ reuseWindowSeconds = 3600,
135
+ resume = true,
136
+ forceNew = false,
137
+ } = {}) {
138
+ if (forceNew || resume === false) return null;
139
+ const cutoffMs = Date.now() - reuseWindowSeconds * 1000;
140
+ const byId = new Map();
141
+
142
+ try {
143
+ const active = await listActiveSessions({ targetPath });
144
+ for (const entry of active) {
145
+ const activityMs = latestSessionActivityMs(entry);
146
+ if (!activityMs || activityMs < cutoffMs) continue;
147
+ const candidate = {
148
+ ...entry,
149
+ _source: "local",
150
+ _activityMs: activityMs,
151
+ };
152
+ byId.set(entry.sessionId, mergeResumeCandidate(byId.get(entry.sessionId), candidate));
153
+ }
154
+ } catch {
155
+ /* local lookup failure is non-fatal */
156
+ }
157
+
158
+ if (!remoteSessionLookupDisabled()) {
159
+ try {
160
+ const remote = await listSessionsFromApi({
161
+ targetPath,
162
+ includeArchived: false,
163
+ limit: 50,
164
+ });
165
+ if (remote && remote.ok) {
166
+ const normalizedTarget = normalizeComparablePath(targetPath);
167
+ for (const entry of remote.sessions || []) {
168
+ const codebase = normalizeComparablePath(entry.codebasePath || entry.targetPath);
169
+ if (!codebase || codebase !== normalizedTarget) continue;
170
+ if (entry.archiveStatus && entry.archiveStatus !== "active") continue;
171
+ const activityMs = latestSessionActivityMs(entry);
172
+ if (!activityMs || activityMs < cutoffMs) continue;
173
+ const candidate = {
174
+ sessionId: entry.sessionId,
175
+ createdAt: entry.createdAt,
176
+ lastActivityAt: entry.lastActivityAt,
177
+ expiresAt: entry.expiresAt,
178
+ status: entry.status || "active",
179
+ template: entry.templateName || null,
180
+ title: entry.title || null,
181
+ _source: "remote",
182
+ _activityMs: activityMs,
183
+ };
184
+ byId.set(entry.sessionId, mergeResumeCandidate(byId.get(entry.sessionId), candidate));
185
+ }
186
+ }
187
+ } catch {
188
+ /* remote lookup failure is non-fatal */
189
+ }
190
+ }
191
+
192
+ const candidates = [...byId.values()];
193
+ candidates.sort((left, right) => Number(right._activityMs || 0) - Number(left._activityMs || 0));
194
+ return candidates[0] || null;
195
+ }
196
+
197
+ async function pushSessionTitleToApi(sessionId, title, { targetPath } = {}) {
198
+ const normalizedTitle = normalizeString(title);
199
+ if (!normalizedTitle || remoteSessionLookupDisabled()) return;
200
+ try {
201
+ const session = await resolveActiveAuthSession({
202
+ cwd: targetPath,
203
+ env: process.env,
204
+ autoRotate: false,
205
+ });
206
+ if (!session?.token || !session?.apiUrl) return;
207
+ const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
208
+ await requestJsonMutation(
209
+ `${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/title`,
210
+ {
211
+ method: "POST",
212
+ operationName: "session.set_title",
213
+ headers: { Authorization: `Bearer ${session.token}` },
214
+ body: { title: normalizedTitle },
215
+ },
216
+ );
217
+ } catch {
218
+ /* best-effort */
219
+ }
220
+ }
221
+
222
+ async function ensureWorkspaceSession({
223
+ targetPath,
224
+ ttlSeconds = DEFAULT_TTL_SECONDS,
225
+ template = null,
226
+ title = "",
227
+ resume = true,
228
+ forceNew = false,
229
+ reuseWindowSeconds = 3600,
230
+ } = {}) {
231
+ const titleArg = normalizeString(title);
232
+ const fallbackTitle = deriveSessionTitle(targetPath);
233
+ const startedAt = Date.now();
234
+ const resumedCandidate = await findReusableSessionCandidate({
235
+ targetPath,
236
+ reuseWindowSeconds,
237
+ resume,
238
+ forceNew,
239
+ });
240
+ let created;
241
+ const resumeTitle =
242
+ titleArg || normalizeString(resumedCandidate?.title) || fallbackTitle;
243
+
244
+ if (resumedCandidate) {
245
+ if (resumedCandidate._source === "remote" && !resumedCandidate.sessionDir) {
246
+ created = await createSession({
247
+ targetPath,
248
+ ttlSeconds,
249
+ sessionId: resumedCandidate.sessionId,
250
+ title: resumeTitle,
251
+ createdAt: resumedCandidate.createdAt,
252
+ expiresAt: resumedCandidate.expiresAt,
253
+ lastInteractionAt:
254
+ resumedCandidate.lastInteractionAt ||
255
+ resumedCandidate.lastActivityAt ||
256
+ resumedCandidate.createdAt,
257
+ });
258
+ } else {
259
+ created = {
260
+ sessionId: resumedCandidate.sessionId,
261
+ sessionDir: resumedCandidate.sessionDir || null,
262
+ metadataPath: resumedCandidate.metadataPath || null,
263
+ streamPath: resumedCandidate.streamPath || null,
264
+ createdAt: resumedCandidate.createdAt,
265
+ updatedAt: resumedCandidate.updatedAt || null,
266
+ lastInteractionAt: resumedCandidate.lastInteractionAt || null,
267
+ expiresAt: resumedCandidate.expiresAt,
268
+ elapsedTimer: resumedCandidate.elapsedTimer || 0,
269
+ renewalCount: resumedCandidate.renewalCount || 0,
270
+ status: resumedCandidate.status || "active",
271
+ template: resumedCandidate.template || null,
272
+ title: normalizeString(resumedCandidate.title) || null,
273
+ codebaseContext: resumedCandidate.codebaseContext || null,
274
+ };
275
+ if (resumeTitle && resumeTitle !== created.title) {
276
+ const updated = await updateSessionTitle(created.sessionId, {
277
+ targetPath,
278
+ title: resumeTitle,
279
+ }).catch(() => null);
280
+ if (updated) {
281
+ created = {
282
+ ...created,
283
+ ...updated,
284
+ };
285
+ }
286
+ }
287
+ }
288
+ } else {
289
+ created = await createSession({
290
+ targetPath,
291
+ ttlSeconds,
292
+ template,
293
+ title: titleArg || fallbackTitle,
294
+ });
295
+ }
296
+
297
+ const effectiveTitle = titleArg || normalizeString(created.title) || fallbackTitle;
298
+ const titleAuto = !titleArg && !resumedCandidate;
299
+ const shouldPushTitle = Boolean(
300
+ titleArg ||
301
+ titleAuto ||
302
+ (resumedCandidate && effectiveTitle && !normalizeString(resumedCandidate.title))
303
+ );
304
+ if (shouldPushTitle) {
305
+ void pushSessionTitleToApi(created.sessionId, effectiveTitle, { targetPath });
306
+ }
307
+
308
+ return {
309
+ created: {
310
+ ...created,
311
+ title: effectiveTitle || null,
312
+ resumed: Boolean(resumedCandidate),
313
+ },
314
+ resumedCandidate,
315
+ durationMs: Date.now() - startedAt,
316
+ title: effectiveTitle || null,
317
+ titleAuto,
318
+ };
319
+ }
320
+
92
321
  function normalizeAgentId(value, fallbackValue = "cli-user") {
93
322
  const normalized = normalizeString(value)
94
323
  .toLowerCase()
@@ -279,6 +508,15 @@ export function registerSessionCommand(program) {
279
508
  "--force-new",
280
509
  "Always create a new session even if a recent active one exists for this workspace",
281
510
  )
511
+ .option(
512
+ "--resume",
513
+ "Reuse the most recent active session for this workspace when one is inside the reuse window",
514
+ true,
515
+ )
516
+ .option(
517
+ "--no-resume",
518
+ "Disable automatic resume and mint a new session unless --force-new is also present",
519
+ )
282
520
  .option(
283
521
  "--reuse-window-seconds <seconds>",
284
522
  "Window in which an existing active session for this workspace will be reused (default 3600 = 1h)",
@@ -302,149 +540,22 @@ export function registerSessionCommand(program) {
302
540
  "reuse-window-seconds",
303
541
  3600,
304
542
  );
305
-
306
- // Auto-resume: prefer an existing active session for this codebase
307
- // over minting a new one. We check both local filesystem state and the
308
- // remote registry — local-only resume meant a fresh checkout / second
309
- // machine would orphan the room each time, exactly the mess Carter
310
- // surfaced ("all of them look like one chat re-created").
311
- //
312
- // Order:
313
- // 1. Local session for the same targetPath inside the reuse window.
314
- // 2. Remote active session whose codebasePath matches the absolute
315
- // targetPath, sorted by last activity. We fold these into the
316
- // candidate pool so a session minted on another machine can be
317
- // rejoined rather than duplicated.
318
- // `--force-new` opts back into the old "always mint" behavior.
319
- let resumed = null;
320
- if (!options.forceNew) {
321
- const cutoffMs = Date.now() - reuseWindowSeconds * 1000;
322
- const candidates = [];
323
- try {
324
- const active = await listActiveSessions({ targetPath });
325
- for (const entry of active) {
326
- const createdMs = Date.parse(entry.createdAt || "");
327
- if (Number.isFinite(createdMs) && createdMs >= cutoffMs) {
328
- candidates.push({ ...entry, _source: "local" });
329
- }
330
- }
331
- } catch {
332
- /* local lookup failure is non-fatal */
333
- }
334
- try {
335
- const remote = await listSessionsFromApi({
336
- targetPath,
337
- includeArchived: false,
338
- limit: 50,
339
- });
340
- if (remote && remote.ok) {
341
- const normalizedTarget = String(targetPath).toLowerCase();
342
- for (const entry of remote.sessions || []) {
343
- const codebase = String(entry.codebasePath || "").toLowerCase();
344
- if (!codebase || codebase !== normalizedTarget) continue;
345
- if (entry.archiveStatus && entry.archiveStatus !== "active") continue;
346
- const lastMs = Date.parse(entry.lastActivityAt || entry.createdAt || "");
347
- if (Number.isFinite(lastMs) && lastMs >= cutoffMs) {
348
- candidates.push({
349
- sessionId: entry.sessionId,
350
- createdAt: entry.createdAt,
351
- lastActivityAt: entry.lastActivityAt,
352
- expiresAt: entry.expiresAt,
353
- status: entry.status || "active",
354
- template: entry.templateName || null,
355
- title: entry.title || null,
356
- _source: "remote",
357
- });
358
- }
359
- }
360
- }
361
- } catch {
362
- /* remote lookup failure is non-fatal */
363
- }
364
- if (candidates.length > 0) {
365
- // Prefer the most recent activity. Local + remote may name the
366
- // same session; dedupe on sessionId before picking.
367
- const seen = new Set();
368
- const deduped = [];
369
- for (const entry of candidates) {
370
- if (seen.has(entry.sessionId)) continue;
371
- seen.add(entry.sessionId);
372
- deduped.push(entry);
373
- }
374
- deduped.sort((a, b) =>
375
- String(b.lastActivityAt || b.createdAt || "").localeCompare(
376
- String(a.lastActivityAt || a.createdAt || ""),
377
- ),
378
- );
379
- resumed = deduped[0];
380
- }
381
- }
382
-
383
- const startedAt = Date.now();
384
- let created;
385
- if (resumed) {
386
- // Surface the resumed session's metadata in the same shape
387
- // createSession returns so downstream code stays unchanged.
388
- created = {
389
- sessionId: resumed.sessionId,
390
- sessionDir: resumed.sessionDir || null,
391
- metadataPath: resumed.metadataPath || null,
392
- streamPath: resumed.streamPath || null,
393
- createdAt: resumed.createdAt,
394
- expiresAt: resumed.expiresAt,
395
- elapsedTimer: 0,
396
- renewalCount: resumed.renewalCount || 0,
397
- status: resumed.status || "active",
398
- template: resumed.template || null,
399
- codebaseContext: resumed.codebaseContext || null,
400
- resumed: true,
401
- };
402
- } else {
403
- created = await createSession({
404
- targetPath,
405
- ttlSeconds,
406
- template,
407
- });
408
- }
409
- const durationMs = Date.now() - startedAt;
543
+ const titleArg = normalizeString(options.title);
544
+ const ensured = await ensureWorkspaceSession({
545
+ targetPath,
546
+ ttlSeconds,
547
+ template,
548
+ title: titleArg,
549
+ resume: options.resume !== false,
550
+ forceNew: Boolean(options.forceNew),
551
+ reuseWindowSeconds,
552
+ });
553
+ const created = ensured.created;
554
+ const resumed = Boolean(ensured.resumedCandidate);
555
+ const durationMs = ensured.durationMs;
410
556
  const launchPlan = template ? buildTemplateLaunchPlan(created.sessionId, template) : [];
411
557
  const dashboardUrl = buildDashboardUrl(created.sessionId);
412
- // Default the title to a stable codebase+date slug so the web sidebar
413
- // never fills with anonymous "<null>" rows. The caller can still
414
- // override with --title. We skip the auto-title for resumed sessions
415
- // because the room already has a name we don't want to clobber.
416
- const titleArg = normalizeString(options.title);
417
- const autoTitle = !resumed && !titleArg ? deriveSessionTitle(targetPath) : "";
418
- const effectiveTitle = titleArg || autoTitle;
419
-
420
- // If a title needs to land on the dashboard, push it. We always push
421
- // when the caller passed --title, AND we push the auto-derived title
422
- // for fresh (non-resumed) sessions so the room is never anonymous on
423
- // the web. Best-effort, non-blocking.
424
- if (effectiveTitle) {
425
- void (async () => {
426
- try {
427
- const session = await resolveActiveAuthSession({
428
- cwd: targetPath,
429
- env: process.env,
430
- autoRotate: false,
431
- });
432
- if (!session?.token || !session?.apiUrl) return;
433
- const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
434
- await requestJsonMutation(
435
- `${apiUrl}/api/v1/sessions/${encodeURIComponent(created.sessionId)}/title`,
436
- {
437
- method: "POST",
438
- operationName: "session.set_title",
439
- headers: { Authorization: `Bearer ${session.token}` },
440
- body: { title: effectiveTitle },
441
- },
442
- );
443
- } catch (_error) {
444
- /* best-effort */
445
- }
446
- })();
447
- }
558
+ const effectiveTitle = ensured.title;
448
559
 
449
560
  const payload = {
450
561
  command: "session start",
@@ -455,7 +566,9 @@ export function registerSessionCommand(program) {
455
566
  metadataPath: created.metadataPath,
456
567
  streamPath: created.streamPath,
457
568
  createdAt: created.createdAt,
569
+ updatedAt: created.updatedAt,
458
570
  expiresAt: created.expiresAt,
571
+ lastInteractionAt: created.lastInteractionAt,
459
572
  ttlSeconds,
460
573
  elapsedTimer: created.elapsedTimer,
461
574
  renewalCount: created.renewalCount,
@@ -463,9 +576,9 @@ export function registerSessionCommand(program) {
463
576
  template: created.template,
464
577
  launchPlan,
465
578
  dashboardUrl,
466
- resumed: Boolean(resumed),
579
+ resumed,
467
580
  title: effectiveTitle || null,
468
- titleAuto: Boolean(autoTitle && !titleArg),
581
+ titleAuto: Boolean(ensured.titleAuto),
469
582
  };
470
583
 
471
584
  // Best-effort admin visibility sync. Session creation remains local-first.
@@ -475,6 +588,7 @@ export function registerSessionCommand(program) {
475
588
  status: created.status,
476
589
  createdAt: created.createdAt,
477
590
  expiresAt: created.expiresAt,
591
+ title: effectiveTitle || null,
478
592
  ttlSeconds,
479
593
  template: created.template,
480
594
  codebaseContext: created.codebaseContext,
@@ -535,6 +649,62 @@ export function registerSessionCommand(program) {
535
649
  await program.parseAsync(args, { from: "user" });
536
650
  });
537
651
 
652
+ session
653
+ .command("ensure")
654
+ .description("Join or create the canonical session for this workspace and emit JSON")
655
+ .option("--path <path>", "Workspace path for the session", ".")
656
+ .option("--title <title>", "Title applied if a new or unnamed resumed session needs one")
657
+ .option(
658
+ "--ttl-seconds <seconds>",
659
+ `Session time-to-live in seconds when a new session is minted (default ${DEFAULT_TTL_SECONDS})`
660
+ )
661
+ .option(
662
+ "--force-new",
663
+ "Always create a new session even if a recent active one exists for this workspace",
664
+ )
665
+ .option(
666
+ "--resume",
667
+ "Reuse the most recent active session for this workspace when one is inside the reuse window",
668
+ true,
669
+ )
670
+ .option("--no-resume", "Disable automatic resume and mint a new session")
671
+ .option(
672
+ "--reuse-window-seconds <seconds>",
673
+ "Window in which an existing active session for this workspace will be reused (default 3600 = 1h)",
674
+ "3600",
675
+ )
676
+ .option("--json", "Emit machine-readable output (default for this command)")
677
+ .action(async (options) => {
678
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
679
+ const ttlSeconds = parsePositiveInteger(
680
+ options.ttlSeconds,
681
+ "ttl-seconds",
682
+ DEFAULT_TTL_SECONDS,
683
+ );
684
+ const reuseWindowSeconds = parsePositiveInteger(
685
+ options.reuseWindowSeconds,
686
+ "reuse-window-seconds",
687
+ 3600,
688
+ );
689
+ const ensured = await ensureWorkspaceSession({
690
+ targetPath,
691
+ ttlSeconds,
692
+ title: normalizeString(options.title),
693
+ resume: options.resume !== false,
694
+ forceNew: Boolean(options.forceNew),
695
+ reuseWindowSeconds,
696
+ });
697
+ const payload = {
698
+ command: "session ensure",
699
+ targetPath,
700
+ sessionId: ensured.created.sessionId,
701
+ title: ensured.title || null,
702
+ resumed: Boolean(ensured.resumedCandidate),
703
+ dashboardUrl: buildDashboardUrl(ensured.created.sessionId),
704
+ };
705
+ console.log(JSON.stringify(payload, null, 2));
706
+ });
707
+
538
708
  session
539
709
  .command("set-title <sessionId> <title>")
540
710
  .description("Set the human-readable title on a session (visible in web sidebar + transcript).")
@@ -564,10 +734,15 @@ export function registerSessionCommand(program) {
564
734
  body: { title: normalizedTitle },
565
735
  },
566
736
  );
737
+ const localUpdated = await updateSessionTitle(normalizedSessionId, {
738
+ targetPath,
739
+ title: normalizedTitle,
740
+ }).catch(() => null);
567
741
  const payload = {
568
742
  command: "session set-title",
569
743
  sessionId: normalizedSessionId,
570
744
  title: normalizedTitle,
745
+ localUpdated: Boolean(localUpdated),
571
746
  result,
572
747
  };
573
748
  if (shouldEmitJson(options, command)) {
@@ -697,6 +872,7 @@ export function registerSessionCommand(program) {
697
872
  .command("say <sessionId> <message>")
698
873
  .description("Send a message to the session")
699
874
  .option("--agent <id>", "Agent id to emit from", "cli-user")
875
+ .option("--to <agent>", "Direct the message to a specific agent id")
700
876
  .option("--path <path>", "Workspace path for the session", ".")
701
877
  .option("--json", "Emit machine-readable output")
702
878
  .action(async (sessionId, message, options, command) => {
@@ -710,14 +886,19 @@ export function registerSessionCommand(program) {
710
886
  }
711
887
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
712
888
  const agentId = normalizeAgentId(options.agent, "cli-user");
889
+ const to = normalizeString(options.to);
890
+ const eventPayload = {
891
+ message: normalizedMessage,
892
+ channel: "session",
893
+ };
894
+ if (to) {
895
+ eventPayload.to = to;
896
+ }
713
897
  const event = createAgentEvent({
714
898
  event: "session_message",
715
899
  agentId,
716
900
  sessionId: normalizedSessionId,
717
- payload: {
718
- message: normalizedMessage,
719
- channel: "session",
720
- },
901
+ payload: eventPayload,
721
902
  });
722
903
  const persisted = await appendToStream(normalizedSessionId, event, {
723
904
  targetPath,
@@ -736,6 +917,93 @@ export function registerSessionCommand(program) {
736
917
  console.log(formatEventLine(persisted));
737
918
  });
738
919
 
920
+ session
921
+ .command("listen")
922
+ .description("Background-poll a session for events addressed to this agent or broadcast")
923
+ .requiredOption("--session <id>", "Session id to listen to")
924
+ .option(
925
+ "--agent <id>",
926
+ "Agent id to receive messages for",
927
+ process.env.SENTINELAYER_AGENT_ID || "cli-user",
928
+ )
929
+ .option("--interval <seconds>", "Polling interval in seconds (default 60)", "60")
930
+ .option("--emit <format>", "Output format: ndjson or text", "ndjson")
931
+ .option("--limit <n>", "Maximum events to request per poll (default 200)", "200")
932
+ .option("--path <path>", "Workspace path for the session", ".")
933
+ .option("--since <cursor>", "Override the persisted listen cursor")
934
+ .option("--replay", "Emit matching historical events on the first poll")
935
+ .option("--max-polls <n>", "Stop after N poll cycles (useful for tests and smoke checks)")
936
+ .action(async (options) => {
937
+ const normalizedSessionId = resolveSessionIdOption(options);
938
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
939
+ const agentId = normalizeAgentId(options.agent, "cli-user");
940
+ const intervalSeconds = parsePositiveInteger(options.interval, "interval", 60);
941
+ const limit = parsePositiveInteger(options.limit, "limit", 200);
942
+ const emitFormat = normalizeString(options.emit).toLowerCase() || "ndjson";
943
+ if (!["ndjson", "text"].includes(emitFormat)) {
944
+ throw new Error("--emit must be one of: ndjson, text.");
945
+ }
946
+ const maxPolls =
947
+ options.maxPolls === undefined
948
+ ? null
949
+ : parsePositiveInteger(options.maxPolls, "max-polls", 1);
950
+ const since = options.since === undefined ? undefined : String(options.since);
951
+ const ac = new AbortController();
952
+ const onSigint = () => ac.abort();
953
+ process.on("SIGINT", onSigint);
954
+
955
+ if (emitFormat === "text") {
956
+ console.log(
957
+ pc.gray(
958
+ `Listening to session ${normalizedSessionId} as ${agentId}; interval=${intervalSeconds}s. Press Ctrl+C to stop.`,
959
+ ),
960
+ );
961
+ }
962
+
963
+ try {
964
+ await listenSessionEvents({
965
+ sessionId: normalizedSessionId,
966
+ targetPath,
967
+ agentId,
968
+ intervalSeconds,
969
+ limit,
970
+ since,
971
+ replay: Boolean(options.replay),
972
+ maxPolls,
973
+ signal: ac.signal,
974
+ onEvent: async (event) => {
975
+ if (emitFormat === "ndjson") {
976
+ console.log(JSON.stringify(event));
977
+ } else {
978
+ console.log(formatEventLine(event));
979
+ }
980
+ },
981
+ onError: async (result) => {
982
+ const reason = normalizeString(result?.reason) || "poll_failed";
983
+ if (emitFormat === "ndjson") {
984
+ console.log(
985
+ JSON.stringify(
986
+ createAgentEvent({
987
+ event: "session_listen_error",
988
+ agentId,
989
+ sessionId: normalizedSessionId,
990
+ payload: {
991
+ reason,
992
+ cursor: result?.cursor || null,
993
+ },
994
+ }),
995
+ ),
996
+ );
997
+ } else {
998
+ console.log(pc.yellow(`Listen poll skipped (${reason}).`));
999
+ }
1000
+ },
1001
+ });
1002
+ } finally {
1003
+ process.removeListener("SIGINT", onSigint);
1004
+ }
1005
+ });
1006
+
739
1007
  session
740
1008
  .command("read <sessionId>")
741
1009
  .description("Read recent session messages")