sentinelayer-cli 0.8.11 → 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.
Files changed (38) hide show
  1. package/package.json +10 -5
  2. package/src/agents/devtestbot/config/definition.js +100 -0
  3. package/src/agents/devtestbot/config/system-prompt.js +92 -0
  4. package/src/agents/devtestbot/index.js +9 -0
  5. package/src/agents/devtestbot/runner.js +769 -0
  6. package/src/agents/devtestbot/tool.js +707 -0
  7. package/src/agents/jules/stream.js +2 -12
  8. package/src/audit/orchestrator.js +471 -114
  9. package/src/audit/persona-loop.js +1342 -0
  10. package/src/audit/registry.js +58 -2
  11. package/src/commands/audit.js +42 -1
  12. package/src/commands/legacy-args.js +32 -1
  13. package/src/commands/omargate.js +4 -0
  14. package/src/commands/session.js +417 -89
  15. package/src/commands/swarm.js +11 -2
  16. package/src/cost/history.js +41 -21
  17. package/src/events/schema.js +27 -1
  18. package/src/guide/generator.js +14 -0
  19. package/src/legacy-cli.js +110 -18
  20. package/src/prompt/generator.js +4 -16
  21. package/src/review/ai-review.js +95 -6
  22. package/src/review/dd-report-email-client.js +148 -0
  23. package/src/review/investor-dd-devtestbot.js +599 -0
  24. package/src/review/investor-dd-orchestrator.js +135 -3
  25. package/src/review/omargate-cache.js +285 -0
  26. package/src/review/omargate-orchestrator.js +605 -4
  27. package/src/review/persona-prompts.js +34 -1
  28. package/src/review/report.js +189 -4
  29. package/src/session/coordination-guidance.js +48 -0
  30. package/src/session/daemon.js +3 -2
  31. package/src/session/listener.js +236 -0
  32. package/src/session/senti-naming.js +36 -0
  33. package/src/session/setup-guides.js +3 -15
  34. package/src/session/store.js +54 -5
  35. package/src/session/sync.js +23 -0
  36. package/src/spec/generator.js +8 -10
  37. package/src/swarm/registry.js +20 -0
  38. package/src/swarm/runtime.js +139 -1
@@ -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,8 @@ 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";
62
+ import { deriveSessionTitle } from "../session/senti-naming.js";
60
63
  import {
61
64
  buildDashboardUrl,
62
65
  buildTemplateLaunchPlan,
@@ -88,6 +91,233 @@ function parsePositiveInteger(rawValue, field, fallbackValue) {
88
91
  return Math.floor(normalized);
89
92
  }
90
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
+
91
321
  function normalizeAgentId(value, fallbackValue = "cli-user") {
92
322
  const normalized = normalizeString(value)
93
323
  .toLowerCase()
@@ -278,6 +508,15 @@ export function registerSessionCommand(program) {
278
508
  "--force-new",
279
509
  "Always create a new session even if a recent active one exists for this workspace",
280
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
+ )
281
520
  .option(
282
521
  "--reuse-window-seconds <seconds>",
283
522
  "Window in which an existing active session for this workspace will be reused (default 3600 = 1h)",
@@ -301,91 +540,22 @@ export function registerSessionCommand(program) {
301
540
  "reuse-window-seconds",
302
541
  3600,
303
542
  );
304
-
305
- // Auto-resume: if there's an active local session for this same
306
- // workspace path created in the last `reuseWindowSeconds`, reuse
307
- // it instead of minting a new id. Kills the orphan-creation
308
- // pattern where every CLI invocation produced a fresh empty
309
- // session. `--force-new` opts back into the old behavior.
310
- let resumed = null;
311
- if (!options.forceNew) {
312
- try {
313
- const active = await listActiveSessions({ targetPath });
314
- const cutoffMs = Date.now() - reuseWindowSeconds * 1000;
315
- const candidates = active.filter((entry) => {
316
- const createdMs = Date.parse(entry.createdAt || "");
317
- return Number.isFinite(createdMs) && createdMs >= cutoffMs;
318
- });
319
- candidates.sort((a, b) =>
320
- String(b.lastActivityAt || b.createdAt || "").localeCompare(
321
- String(a.lastActivityAt || a.createdAt || ""),
322
- ),
323
- );
324
- if (candidates.length > 0) {
325
- resumed = candidates[0];
326
- }
327
- } catch (error) {
328
- // listActiveSessions failure is non-fatal; fall through to fresh create.
329
- }
330
- }
331
-
332
- const startedAt = Date.now();
333
- let created;
334
- if (resumed) {
335
- // Surface the resumed session's metadata in the same shape
336
- // createSession returns so downstream code stays unchanged.
337
- created = {
338
- sessionId: resumed.sessionId,
339
- sessionDir: resumed.sessionDir || null,
340
- metadataPath: resumed.metadataPath || null,
341
- streamPath: resumed.streamPath || null,
342
- createdAt: resumed.createdAt,
343
- expiresAt: resumed.expiresAt,
344
- elapsedTimer: 0,
345
- renewalCount: resumed.renewalCount || 0,
346
- status: resumed.status || "active",
347
- template: resumed.template || null,
348
- codebaseContext: resumed.codebaseContext || null,
349
- resumed: true,
350
- };
351
- } else {
352
- created = await createSession({
353
- targetPath,
354
- ttlSeconds,
355
- template,
356
- });
357
- }
358
- 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;
359
556
  const launchPlan = template ? buildTemplateLaunchPlan(created.sessionId, template) : [];
360
557
  const dashboardUrl = buildDashboardUrl(created.sessionId);
361
- const titleArg = normalizeString(options.title);
362
-
363
- // If the caller passed --title, push it to the API so the web
364
- // sidebar shows the label (best-effort, non-blocking).
365
- if (titleArg) {
366
- void (async () => {
367
- try {
368
- const session = await resolveActiveAuthSession({
369
- cwd: targetPath,
370
- env: process.env,
371
- autoRotate: false,
372
- });
373
- if (!session?.token || !session?.apiUrl) return;
374
- const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
375
- await requestJsonMutation(
376
- `${apiUrl}/api/v1/sessions/${encodeURIComponent(created.sessionId)}/title`,
377
- {
378
- method: "POST",
379
- operationName: "session.set_title",
380
- headers: { Authorization: `Bearer ${session.token}` },
381
- body: { title: titleArg },
382
- },
383
- );
384
- } catch (_error) {
385
- /* best-effort */
386
- }
387
- })();
388
- }
558
+ const effectiveTitle = ensured.title;
389
559
 
390
560
  const payload = {
391
561
  command: "session start",
@@ -396,7 +566,9 @@ export function registerSessionCommand(program) {
396
566
  metadataPath: created.metadataPath,
397
567
  streamPath: created.streamPath,
398
568
  createdAt: created.createdAt,
569
+ updatedAt: created.updatedAt,
399
570
  expiresAt: created.expiresAt,
571
+ lastInteractionAt: created.lastInteractionAt,
400
572
  ttlSeconds,
401
573
  elapsedTimer: created.elapsedTimer,
402
574
  renewalCount: created.renewalCount,
@@ -404,8 +576,9 @@ export function registerSessionCommand(program) {
404
576
  template: created.template,
405
577
  launchPlan,
406
578
  dashboardUrl,
407
- resumed: Boolean(resumed),
408
- title: titleArg || null,
579
+ resumed,
580
+ title: effectiveTitle || null,
581
+ titleAuto: Boolean(ensured.titleAuto),
409
582
  };
410
583
 
411
584
  // Best-effort admin visibility sync. Session creation remains local-first.
@@ -415,6 +588,7 @@ export function registerSessionCommand(program) {
415
588
  status: created.status,
416
589
  createdAt: created.createdAt,
417
590
  expiresAt: created.expiresAt,
591
+ title: effectiveTitle || null,
418
592
  ttlSeconds,
419
593
  template: created.template,
420
594
  codebaseContext: created.codebaseContext,
@@ -475,6 +649,62 @@ export function registerSessionCommand(program) {
475
649
  await program.parseAsync(args, { from: "user" });
476
650
  });
477
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
+
478
708
  session
479
709
  .command("set-title <sessionId> <title>")
480
710
  .description("Set the human-readable title on a session (visible in web sidebar + transcript).")
@@ -504,10 +734,15 @@ export function registerSessionCommand(program) {
504
734
  body: { title: normalizedTitle },
505
735
  },
506
736
  );
737
+ const localUpdated = await updateSessionTitle(normalizedSessionId, {
738
+ targetPath,
739
+ title: normalizedTitle,
740
+ }).catch(() => null);
507
741
  const payload = {
508
742
  command: "session set-title",
509
743
  sessionId: normalizedSessionId,
510
744
  title: normalizedTitle,
745
+ localUpdated: Boolean(localUpdated),
511
746
  result,
512
747
  };
513
748
  if (shouldEmitJson(options, command)) {
@@ -637,6 +872,7 @@ export function registerSessionCommand(program) {
637
872
  .command("say <sessionId> <message>")
638
873
  .description("Send a message to the session")
639
874
  .option("--agent <id>", "Agent id to emit from", "cli-user")
875
+ .option("--to <agent>", "Direct the message to a specific agent id")
640
876
  .option("--path <path>", "Workspace path for the session", ".")
641
877
  .option("--json", "Emit machine-readable output")
642
878
  .action(async (sessionId, message, options, command) => {
@@ -650,14 +886,19 @@ export function registerSessionCommand(program) {
650
886
  }
651
887
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
652
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
+ }
653
897
  const event = createAgentEvent({
654
898
  event: "session_message",
655
899
  agentId,
656
900
  sessionId: normalizedSessionId,
657
- payload: {
658
- message: normalizedMessage,
659
- channel: "session",
660
- },
901
+ payload: eventPayload,
661
902
  });
662
903
  const persisted = await appendToStream(normalizedSessionId, event, {
663
904
  targetPath,
@@ -676,6 +917,93 @@ export function registerSessionCommand(program) {
676
917
  console.log(formatEventLine(persisted));
677
918
  });
678
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
+
679
1007
  session
680
1008
  .command("read <sessionId>")
681
1009
  .description("Read recent session messages")
@@ -517,6 +517,9 @@ export function registerSwarmCommand(program) {
517
517
  .option("--scenario-file <path>", "Scenario DSL file (.sls) for runtime actions")
518
518
  .option("--registry-file <path>", "Optional custom swarm registry file (when building plan inline)")
519
519
  .option("--agents <ids>", "Comma-separated agent ids for inline plan mode", "security,testing,reliability")
520
+ .option("--agent <id>", "Single agent id alias for --agents")
521
+ .option("--scope <scope>", "Runtime scope alias for --scenario, used by devTestBot")
522
+ .option("--identity-id <id>", "AIdenID identity id for devTestBot runtime")
520
523
  .option("--scenario <id>", "Scenario identifier for inline plan mode", "qa_audit")
521
524
  .option(
522
525
  "--objective <text>",
@@ -571,7 +574,7 @@ export function registerSwarmCommand(program) {
571
574
  const registry = await loadSwarmRegistry({
572
575
  registryFile: options.registryFile,
573
576
  });
574
- const selected = selectSwarmAgents(registry.agents, options.agents);
577
+ const selected = selectSwarmAgents(registry.agents, options.agent || options.agents);
575
578
  if (selected.missing.length > 0) {
576
579
  throw new Error(`Unknown agent id(s): ${selected.missing.join(", ")}`);
577
580
  }
@@ -581,7 +584,7 @@ export function registerSwarmCommand(program) {
581
584
  const selectedAgents = ensureOmarIncluded(registry.agents, selected.selected);
582
585
  plan = buildSwarmExecutionPlan({
583
586
  targetPath,
584
- scenario: scenarioIdOverride || options.scenario,
587
+ scenario: scenarioIdOverride || options.scope || options.scenario,
585
588
  objective: options.objective,
586
589
  agents: selectedAgents,
587
590
  maxParallel: parseMaxParallel(options.maxParallel),
@@ -612,6 +615,8 @@ export function registerSwarmCommand(program) {
612
615
  execute: Boolean(options.execute),
613
616
  maxSteps: parseMaxSteps(options.maxSteps),
614
617
  startUrl: startUrlOverride || options.startUrl,
618
+ identityId: options.identityId,
619
+ devTestBotScope: options.scope || scenarioIdOverride || options.scenario,
615
620
  playbookActions,
616
621
  outputDir: options.outputDir,
617
622
  env: process.env,
@@ -631,6 +636,10 @@ export function registerSwarmCommand(program) {
631
636
  stop: runtime.stop,
632
637
  usage: runtime.usage,
633
638
  eventCount: runtime.eventCount,
639
+ findingCount: runtime.findingCount,
640
+ findings: runtime.findings,
641
+ artifactBundles: runtime.artifactBundles,
642
+ devTestBotRuns: runtime.devTestBotRuns,
634
643
  runtimeDirectory: runtime.runtimeDirectory,
635
644
  runtimeJsonPath: runtime.runtimeJsonPath,
636
645
  runtimeMarkdownPath: runtime.runtimeMarkdownPath,