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.
@@ -0,0 +1,236 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+
3
+ import { pollSessionEvents } from "./sync.js";
4
+ import { readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
5
+
6
+ const BROADCAST_RECIPIENTS = new Set([
7
+ "*",
8
+ "all",
9
+ "broadcast",
10
+ "everyone",
11
+ "anyone",
12
+ "agents",
13
+ "all-agents",
14
+ ]);
15
+
16
+ function normalizeString(value) {
17
+ return String(value || "").trim();
18
+ }
19
+
20
+ function normalizeComparableId(value) {
21
+ return normalizeString(value)
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9._-]+/g, "-")
24
+ .replace(/^-+|-+$/g, "");
25
+ }
26
+
27
+ function normalizePositiveInteger(value, fallbackValue) {
28
+ if (value === undefined || value === null || String(value).trim() === "") {
29
+ return fallbackValue;
30
+ }
31
+ const normalized = Number(value);
32
+ if (!Number.isFinite(normalized) || normalized <= 0) return fallbackValue;
33
+ return Math.floor(normalized);
34
+ }
35
+
36
+ function isPlainObject(value) {
37
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
38
+ }
39
+
40
+ function addRecipientValue(values, value) {
41
+ if (value === undefined || value === null) return;
42
+ if (Array.isArray(value)) {
43
+ for (const item of value) addRecipientValue(values, item);
44
+ return;
45
+ }
46
+ if (isPlainObject(value)) {
47
+ addRecipientValue(values, value.id || value.agentId || value.name);
48
+ return;
49
+ }
50
+ const raw = normalizeString(value);
51
+ if (!raw) return;
52
+ for (const token of raw.split(/[\s,;]+/g)) {
53
+ const normalized = normalizeString(token);
54
+ if (normalized) values.push(normalized);
55
+ }
56
+ }
57
+
58
+ export function collectSessionEventRecipients(event = {}) {
59
+ const values = [];
60
+ if (!isPlainObject(event)) return values;
61
+ const payload = isPlainObject(event.payload) ? event.payload : {};
62
+ for (const source of [
63
+ event.to,
64
+ event.recipient,
65
+ event.recipients,
66
+ event.targetAgent,
67
+ event.targetAgentId,
68
+ payload.to,
69
+ payload.recipient,
70
+ payload.recipients,
71
+ payload.targetAgent,
72
+ payload.targetAgentId,
73
+ ]) {
74
+ addRecipientValue(values, source);
75
+ }
76
+ return values;
77
+ }
78
+
79
+ export function eventMatchesAgent(event = {}, agentId = "") {
80
+ if (!isPlainObject(event)) return false;
81
+ const normalizedAgentId = normalizeComparableId(agentId);
82
+ if (!normalizedAgentId) return false;
83
+
84
+ const payload = isPlainObject(event.payload) ? event.payload : {};
85
+ if (event.broadcast === true || payload.broadcast === true) return true;
86
+
87
+ const recipients = collectSessionEventRecipients(event);
88
+ if (recipients.length === 0) return true;
89
+
90
+ for (const recipient of recipients) {
91
+ const rawRecipient = normalizeString(recipient).toLowerCase();
92
+ if (BROADCAST_RECIPIENTS.has(rawRecipient)) return true;
93
+ const normalizedRecipient = normalizeComparableId(recipient);
94
+ if (!normalizedRecipient) continue;
95
+ if (BROADCAST_RECIPIENTS.has(normalizedRecipient)) return true;
96
+ if (normalizedRecipient === normalizedAgentId) return true;
97
+ }
98
+ return false;
99
+ }
100
+
101
+ export function listenCursorSuffix(agentId = "") {
102
+ return `listen-${normalizeComparableId(agentId) || "agent"}`;
103
+ }
104
+
105
+ async function defaultSleep(ms, { signal } = {}) {
106
+ await delay(ms, undefined, { signal });
107
+ }
108
+
109
+ function shouldAbort(error, signal) {
110
+ return Boolean(signal?.aborted || error?.name === "AbortError" || error?.code === "ABORT_ERR");
111
+ }
112
+
113
+ function cursorFromEvents(events = [], fallbackCursor = null) {
114
+ let cursor = normalizeString(fallbackCursor) || null;
115
+ for (const event of events) {
116
+ const candidate = normalizeString(event?.cursor);
117
+ if (candidate) cursor = candidate;
118
+ }
119
+ return cursor;
120
+ }
121
+
122
+ function eventTimestampMs(event = {}) {
123
+ for (const key of ["ts", "timestamp", "createdAt", "at"]) {
124
+ const epoch = Date.parse(normalizeString(event?.[key]));
125
+ if (Number.isFinite(epoch)) return epoch;
126
+ }
127
+ return 0;
128
+ }
129
+
130
+ /**
131
+ * Poll session events in the background and emit only events addressed to
132
+ * the current agent or broadcast to everyone. The loop advances its cursor
133
+ * across non-matching events so direct listeners do not replay unrelated
134
+ * traffic forever.
135
+ */
136
+ export async function listenSessionEvents({
137
+ sessionId,
138
+ targetPath = process.cwd(),
139
+ agentId = "cli-user",
140
+ intervalSeconds = 60,
141
+ limit = 200,
142
+ since = undefined,
143
+ replay = false,
144
+ maxPolls = null,
145
+ signal,
146
+ onEvent = async () => {},
147
+ onError = async () => {},
148
+ _poll = pollSessionEvents,
149
+ _readCursor = readSyncCursor,
150
+ _writeCursor = writeSyncCursor,
151
+ _sleep = defaultSleep,
152
+ _nowMs = Date.now,
153
+ } = {}) {
154
+ const normalizedSessionId = normalizeString(sessionId);
155
+ const normalizedAgentId = normalizeComparableId(agentId) || "cli-user";
156
+ if (!normalizedSessionId) {
157
+ throw new Error("session id is required.");
158
+ }
159
+
160
+ const cursorSuffix = listenCursorSuffix(normalizedAgentId);
161
+ let cursor =
162
+ typeof since === "string" || since === null
163
+ ? normalizeString(since) || null
164
+ : await _readCursor(normalizedSessionId, { targetPath, suffix: cursorSuffix });
165
+ let primed = Boolean(cursor) || Boolean(replay);
166
+ let pollCount = 0;
167
+ let emitted = 0;
168
+ let matched = 0;
169
+ let persistedCursor = false;
170
+ let lastReason = "";
171
+ const maxPollCount = normalizePositiveInteger(maxPolls, 0);
172
+ const pollLimit = normalizePositiveInteger(limit, 200);
173
+ const sleepMs = Math.max(1, normalizePositiveInteger(intervalSeconds, 60)) * 1000;
174
+ const startedAtMs = Number(_nowMs()) || Date.now();
175
+
176
+ while (!signal?.aborted) {
177
+ pollCount += 1;
178
+ const result = await _poll(normalizedSessionId, {
179
+ targetPath,
180
+ since: cursor,
181
+ limit: pollLimit,
182
+ });
183
+
184
+ if (result?.ok) {
185
+ lastReason = "";
186
+ const events = Array.isArray(result.events) ? result.events : [];
187
+ const shouldEmitBatch = primed || Boolean(replay);
188
+ for (const event of events) {
189
+ if (!eventMatchesAgent(event, normalizedAgentId)) continue;
190
+ matched += 1;
191
+ if (!shouldEmitBatch && eventTimestampMs(event) < startedAtMs) continue;
192
+ await onEvent(event);
193
+ emitted += 1;
194
+ }
195
+
196
+ const nextCursor = normalizeString(result.cursor) || cursorFromEvents(events, cursor);
197
+ if (nextCursor && nextCursor !== cursor) {
198
+ const writeResult = await _writeCursor(normalizedSessionId, nextCursor, {
199
+ targetPath,
200
+ suffix: cursorSuffix,
201
+ }).catch(() => null);
202
+ persistedCursor = Boolean(writeResult?.written) || persistedCursor;
203
+ cursor = nextCursor;
204
+ }
205
+ primed = true;
206
+ } else {
207
+ lastReason = normalizeString(result?.reason) || "poll_failed";
208
+ await onError({
209
+ ok: false,
210
+ reason: lastReason,
211
+ cursor: result?.cursor || cursor || null,
212
+ });
213
+ }
214
+
215
+ if (maxPollCount > 0 && pollCount >= maxPollCount) break;
216
+ try {
217
+ await _sleep(sleepMs, { signal });
218
+ } catch (error) {
219
+ if (shouldAbort(error, signal)) break;
220
+ throw error;
221
+ }
222
+ }
223
+
224
+ return {
225
+ ok: true,
226
+ sessionId: normalizedSessionId,
227
+ agentId: normalizedAgentId,
228
+ cursor,
229
+ cursorSuffix,
230
+ pollCount,
231
+ matched,
232
+ emitted,
233
+ persistedCursor,
234
+ reason: lastReason,
235
+ };
236
+ }
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import process from "node:process";
4
4
 
5
5
  import { buildAgentsSessionGuideContent } from "../legacy-cli.js";
6
+ import { renderCoordinationBulletList } from "./coordination-guidance.js";
6
7
  import { getSession } from "./store.js";
7
8
 
8
9
  const SECTION_START_MARKER = "<!-- SENTINELAYER_SESSION_COORDINATION:START -->";
@@ -26,21 +27,8 @@ function buildSessionCoordinationSection() {
26
27
  return `${SECTION_START_MARKER}
27
28
  ${SECTION_HEADING}
28
29
 
29
- ### Before You Start
30
- - Check for active sessions: \`sl session list\`
31
- - Join if one exists: \`sl session join <id> --name <your-name>\`
32
- - Read recent context: \`sl session read <id> --tail 20\`
33
-
34
- ### While Working
35
- - Emit status every 5 min: \`sl session say <id> "status: <what you're doing>"\`
36
- - Lock files before editing: \`sl session say <id> "lock: <file> - <intent>"\`
37
- - Unlock when done: \`sl session say <id> "unlock: <file> - done"\`
38
- - Post findings: \`sl session say <id> "finding: [P2] <title> in <file>:<line>"\`
39
-
40
- ### On Problems
41
- - Unexpected file change? Ask: \`sl session say <id> "help: unexpected change in <file>"\`
42
- - Need another agent's work? Request: \`sl session say <id> "handoff: @<agent> <description>"\`
43
- - Found issues for others? Assign: \`sl session say <id> "assign: @<agent> <task>"\`
30
+ ### Required Etiquette
31
+ ${renderCoordinationBulletList()}
44
32
 
45
33
  ### What Not To Do
46
34
  - Do not break your autonomous loop on unexpected file changes; ask in session first.
@@ -43,6 +43,21 @@ function normalizeNonNegativeInteger(value, fallbackValue = 0) {
43
43
  return Math.floor(normalized);
44
44
  }
45
45
 
46
+ function normalizeCreateSessionId(value) {
47
+ const normalized = normalizeString(value);
48
+ if (!normalized) return randomUUID();
49
+ if (
50
+ normalized === "." ||
51
+ normalized === ".." ||
52
+ normalized.includes("/") ||
53
+ normalized.includes("\\") ||
54
+ normalized.includes("..")
55
+ ) {
56
+ throw new Error("sessionId must not contain path traversal segments.");
57
+ }
58
+ return normalized;
59
+ }
60
+
46
61
  function normalizeIsoTimestamp(value, fallbackIso = new Date().toISOString()) {
47
62
  const normalized = normalizeString(value);
48
63
  if (!normalized) {
@@ -148,7 +163,7 @@ function toRelativePosix(baseDir, absolutePath) {
148
163
 
149
164
  function normalizeDateKeyFromCloseoutPath(closeoutPath = "", fallbackIso = new Date().toISOString()) {
150
165
  const normalized = toPosixPath(closeoutPath);
151
- const match = /\/observability\/(\d{4}-\d{2}-\d{2})\//.exec(`/${normalized}`);
166
+ const match = /\/observability\/(\d{4}-\d{2}-\d{2})\//.exec("/" + normalized);
152
167
  if (match) {
153
168
  return match[1];
154
169
  }
@@ -330,6 +345,7 @@ function normalizeMetadata(raw = {}, { sessionId, targetPath, nowIso } = {}) {
330
345
  createdAt,
331
346
  updatedAt: normalizeIsoTimestamp(raw.updatedAt, nowIso),
332
347
  expiresAt,
348
+ title: normalizeString(raw.title) || null,
333
349
  ttlSeconds,
334
350
  renewalCount: Math.max(0, Number(raw.renewalCount || 0)),
335
351
  maxLifetimeSeconds: normalizePositiveInteger(raw.maxLifetimeSeconds, MAX_SESSION_LIFETIME_SECONDS),
@@ -364,7 +380,10 @@ function buildSessionPayload(metadata, paths, nowIso = new Date().toISOString())
364
380
  metadataPath: paths.metadataPath,
365
381
  streamPath: paths.streamPath,
366
382
  createdAt: metadata.createdAt,
383
+ updatedAt: metadata.updatedAt,
367
384
  expiresAt: metadata.expiresAt,
385
+ lastInteractionAt: metadata.lastInteractionAt,
386
+ title: metadata.title,
368
387
  elapsedTimer: buildElapsedTimer(metadata.createdAt, nowIso),
369
388
  renewalCount: metadata.renewalCount,
370
389
  status: metadata.status,
@@ -406,11 +425,22 @@ export async function createSession({
406
425
  targetPath = process.cwd(),
407
426
  ttlSeconds = DEFAULT_TTL_SECONDS,
408
427
  template = null,
428
+ sessionId: requestedSessionId = "",
429
+ title = "",
430
+ createdAt = "",
431
+ expiresAt = "",
432
+ lastInteractionAt = "",
409
433
  } = {}) {
410
434
  const resolvedTargetPath = path.resolve(String(targetPath || "."));
411
435
  const normalizedTtlSeconds = normalizePositiveInteger(ttlSeconds, DEFAULT_TTL_SECONDS);
412
- const sessionId = randomUUID();
436
+ const sessionId = normalizeCreateSessionId(requestedSessionId);
413
437
  const nowIso = new Date().toISOString();
438
+ const createdIso = normalizeIsoTimestamp(createdAt, nowIso);
439
+ const expiresIso = normalizeIsoTimestamp(
440
+ expiresAt,
441
+ toIsoAfterSeconds(createdIso, normalizedTtlSeconds)
442
+ );
443
+ const interactionIso = normalizeIsoTimestamp(lastInteractionAt, createdIso);
414
444
  const paths = resolveSessionPaths(sessionId, { targetPath: resolvedTargetPath });
415
445
  const codebaseContext = await collectSessionCodebaseContext(resolvedTargetPath);
416
446
 
@@ -419,14 +449,15 @@ export async function createSession({
419
449
  schemaVersion: SESSION_SCHEMA_VERSION,
420
450
  sessionId,
421
451
  targetPath: resolvedTargetPath,
422
- createdAt: nowIso,
452
+ createdAt: createdIso,
423
453
  updatedAt: nowIso,
424
- expiresAt: toIsoAfterSeconds(nowIso, normalizedTtlSeconds),
454
+ expiresAt: expiresIso,
455
+ title: normalizeString(title) || null,
425
456
  ttlSeconds: normalizedTtlSeconds,
426
457
  renewalCount: 0,
427
458
  maxLifetimeSeconds: MAX_SESSION_LIFETIME_SECONDS,
428
459
  status: SESSION_STATUS_ACTIVE,
429
- lastInteractionAt: nowIso,
460
+ lastInteractionAt: interactionIso,
430
461
  expiredAt: null,
431
462
  archivedAt: null,
432
463
  s3Path: null,
@@ -449,6 +480,24 @@ export async function createSession({
449
480
  return buildSessionPayload(metadata, paths, nowIso);
450
481
  }
451
482
 
483
+ export async function updateSessionTitle(
484
+ sessionId,
485
+ { targetPath = process.cwd(), title = "" } = {}
486
+ ) {
487
+ const loaded = await loadMetadata(sessionId, { targetPath });
488
+ if (!loaded) {
489
+ return null;
490
+ }
491
+ const nowIso = new Date().toISOString();
492
+ const metadata = {
493
+ ...loaded.metadata,
494
+ title: normalizeString(title) || null,
495
+ updatedAt: nowIso,
496
+ };
497
+ const saved = await saveMetadata(metadata, loaded.paths);
498
+ return buildSessionPayload(saved, loaded.paths, nowIso);
499
+ }
500
+
452
501
  export async function getSession(sessionId, { targetPath = process.cwd() } = {}) {
453
502
  const loaded = await loadMetadata(sessionId, { targetPath });
454
503
  if (!loaded) {
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
 
3
+ import { getCoordinationEtiquetteItems } from "../session/coordination-guidance.js";
3
4
  import { getDefaultTemplate, getTemplateById } from "./templates.js";
4
5
 
5
6
  const VALID_PROJECT_TYPES = new Set(["greenfield", "add_feature", "bugfix"]);
@@ -494,7 +495,11 @@ function shouldIncludeCoordinationPhase({
494
495
  description = "",
495
496
  agentsMarkdown = "",
496
497
  sessionActive = false,
498
+ sessionToolsAvailable = true,
497
499
  } = {}) {
500
+ if (sessionToolsAvailable === true) {
501
+ return true;
502
+ }
498
503
  if (sessionActive === true) {
499
504
  return true;
500
505
  }
@@ -507,16 +512,7 @@ function shouldIncludeCoordinationPhase({
507
512
  function buildCoordinationPhase(phaseNumber, previousPhaseTitle = "") {
508
513
  return {
509
514
  title: `Phase ${phaseNumber}: Multi-Agent Coordination Protocol`,
510
- items: [
511
- "Check for active sessions: `sl session list`.",
512
- "If a session exists, join it: `sl session join <id> --name <your-name> --role coder`.",
513
- "Emit status updates every 5 minutes: `sl session say <id> \"status: <what you're doing>\"`.",
514
- "Before modifying a shared file, check recent session activity for that file.",
515
- "On unexpected file changes, ask in-session instead of stopping: `sl session say <id> \"help: <question>\"`.",
516
- "Post findings in-session: `sl session say <id> \"finding: [P2] <title> in <file>:<line>\"`.",
517
- "On completion, update `tasks/todo.md` and emit completion status in-session.",
518
- "Leave the session when done: `sl session leave <id>`.",
519
- ],
515
+ items: getCoordinationEtiquetteItems(),
520
516
  dependencies: previousPhaseTitle ? [previousPhaseTitle] : [],
521
517
  effort: "4-8 hours",
522
518
  acceptanceCriteria: [
@@ -535,6 +531,7 @@ export function generateSpecMarkdown({
535
531
  projectType,
536
532
  agentsMarkdown = "",
537
533
  sessionActive = false,
534
+ sessionToolsAvailable = true,
538
535
  generatedAt = new Date().toISOString(),
539
536
  } = {}) {
540
537
  const resolvedTemplate = template || getDefaultTemplate();
@@ -566,6 +563,7 @@ export function generateSpecMarkdown({
566
563
  description,
567
564
  agentsMarkdown,
568
565
  sessionActive,
566
+ sessionToolsAvailable,
569
567
  })
570
568
  ) {
571
569
  phases.push(buildCoordinationPhase(phases.length + 1, phases[phases.length - 1]?.title || ""));
@@ -204,6 +204,26 @@ const BUILTIN_SWARM_AGENTS = Object.freeze([
204
204
  evidenceRequirements: ["dependency_refs", "version_risks"],
205
205
  escalationTargets: ["security", "release"],
206
206
  },
207
+ {
208
+ id: "devtestbot",
209
+ persona: "AIdenID devTestBot",
210
+ role: "specialist",
211
+ domain: "Browser/System E2E",
212
+ tools: ["devtestbot.run_session"],
213
+ permissionMode: "runtime-readonly",
214
+ maxTurns: 8,
215
+ confidenceFloor: 0.8,
216
+ allowedPaths: ["."],
217
+ networkMode: "enabled",
218
+ defaultBudget: {
219
+ maxCostUsd: 1.5,
220
+ maxOutputTokens: 6000,
221
+ maxRuntimeMs: 600000,
222
+ maxToolCalls: 40,
223
+ },
224
+ evidenceRequirements: ["artifact_path", "runtime_evidence", "reproduction", "confidence"],
225
+ escalationTargets: ["testing", "frontend", "reliability"],
226
+ },
207
227
  {
208
228
  id: "frontend",
209
229
  persona: "Jules Tanaka",
@@ -10,6 +10,13 @@ function normalizeString(value) {
10
10
  return String(value || "").trim();
11
11
  }
12
12
 
13
+ function sanitizeRuntimeError(error) {
14
+ return String(error?.message || error || "Runtime failed.")
15
+ .replace(/\b(?:authorization|cookie|token|secret|password|otp|reset)\s*[:=]\s*["']?[^"'\s&]+/gi, (match) =>
16
+ match.replace(/[:=]\s*["']?.*$/u, "=[REDACTED]")
17
+ );
18
+ }
19
+
13
20
  function formatTimestampToken() {
14
21
  const now = new Date();
15
22
  const pad = (value) => String(value).padStart(2, "0");
@@ -298,6 +305,9 @@ export async function runSwarmRuntime({
298
305
  execute = false,
299
306
  maxSteps = 20,
300
307
  startUrl = "about:blank",
308
+ identityId = "",
309
+ devTestBotScope = "",
310
+ devTestBotRunSession = null,
301
311
  playbookActions = [],
302
312
  outputDir = "",
303
313
  env,
@@ -321,6 +331,9 @@ export async function runSwarmRuntime({
321
331
  const runtimeRunDirectory = path.join(resolvedOutputRoot, "swarms", runId);
322
332
  const runStartedAt = Date.now();
323
333
  const events = [];
334
+ const findings = [];
335
+ const artifactBundles = [];
336
+ const devTestBotRuns = [];
324
337
  let step = 0;
325
338
 
326
339
  const usage = {
@@ -409,7 +422,128 @@ export async function runSwarmRuntime({
409
422
  })
410
423
  );
411
424
 
412
- if (normalizedEngine === "mock" || !execute) {
425
+ if (assignment.agentId === "devtestbot") {
426
+ const scope = normalizeString(devTestBotScope || plan.scenario || "smoke") || "smoke";
427
+ const toolInput = {
428
+ scope,
429
+ identityId: normalizeString(identityId),
430
+ baseUrl: normalizeString(startUrl),
431
+ recordVideo: Boolean(execute),
432
+ execute: Boolean(execute),
433
+ targetPath: normalizedTargetPath,
434
+ outputRoot: resolvedOutputRoot,
435
+ outputDir: path.join(runtimeRunDirectory, "devtestbot", assignment.assignmentId),
436
+ runId: `${runId}-${assignment.assignmentId}`,
437
+ };
438
+
439
+ usage.toolCalls += 1;
440
+ usage.outputTokens += estimateTokens(`devtestbot.run_session:${scope}:${Boolean(execute)}`);
441
+ step += 1;
442
+ events.push(
443
+ createEvent({
444
+ runId,
445
+ step,
446
+ eventType: "tool_call",
447
+ agentId: assignment.agentId,
448
+ message: "devtestbot.run_session started",
449
+ metadata: {
450
+ tool: "devtestbot.run_session",
451
+ scope,
452
+ identityId: toolInput.identityId || null,
453
+ baseUrl: toolInput.baseUrl,
454
+ execute: toolInput.execute,
455
+ recordVideo: toolInput.recordVideo,
456
+ },
457
+ usage,
458
+ })
459
+ );
460
+
461
+ try {
462
+ const runner = devTestBotRunSession || (await import("../agents/devtestbot/tool.js")).runDevTestBotSession;
463
+ const result = await runner(toolInput, {
464
+ targetPath: normalizedTargetPath,
465
+ outputRoot: resolvedOutputRoot,
466
+ runId: toolInput.runId,
467
+ execute: Boolean(execute),
468
+ env,
469
+ });
470
+ const resultFindings = Array.isArray(result.findings) ? result.findings : [];
471
+ findings.push(...resultFindings);
472
+ if (result.artifactBundle) {
473
+ artifactBundles.push(result.artifactBundle);
474
+ }
475
+ devTestBotRuns.push({
476
+ assignmentId: assignment.assignmentId,
477
+ runId: result.runId || toolInput.runId,
478
+ completed: Boolean(result.completed),
479
+ dryRun: Boolean(result.dryRun),
480
+ findingCount: resultFindings.length,
481
+ artifactBundle: result.artifactBundle || null,
482
+ });
483
+ usage.outputTokens += estimateTokens(
484
+ JSON.stringify({
485
+ findingCount: resultFindings.length,
486
+ artifactBundle: result.artifactBundle ? "present" : "missing",
487
+ })
488
+ );
489
+ step += 1;
490
+ events.push(
491
+ createEvent({
492
+ runId,
493
+ step,
494
+ eventType: "tool_result",
495
+ agentId: assignment.agentId,
496
+ message: "devtestbot.run_session completed",
497
+ metadata: {
498
+ tool: "devtestbot.run_session",
499
+ success: true,
500
+ dryRun: Boolean(result.dryRun),
501
+ findingCount: resultFindings.length,
502
+ artifactBundle: result.artifactBundle || null,
503
+ },
504
+ usage,
505
+ })
506
+ );
507
+ for (const finding of resultFindings) {
508
+ step += 1;
509
+ events.push(
510
+ createEvent({
511
+ runId,
512
+ step,
513
+ eventType: "finding",
514
+ agentId: assignment.agentId,
515
+ message: normalizeString(finding.title || "devTestBot finding"),
516
+ metadata: {
517
+ finding,
518
+ },
519
+ usage,
520
+ })
521
+ );
522
+ }
523
+ } catch (error) {
524
+ stop = {
525
+ stopClass: error?.code || "DEVTESTBOT_RUN_FAILED",
526
+ reason: sanitizeRuntimeError(error),
527
+ blocking: true,
528
+ };
529
+ step += 1;
530
+ events.push(
531
+ createEvent({
532
+ runId,
533
+ step,
534
+ eventType: "agent_error",
535
+ agentId: assignment.agentId,
536
+ message: stop.reason,
537
+ metadata: {
538
+ tool: "devtestbot.run_session",
539
+ stopClass: stop.stopClass,
540
+ },
541
+ usage,
542
+ })
543
+ );
544
+ break;
545
+ }
546
+ } else if (normalizedEngine === "mock" || !execute) {
413
547
  usage.toolCalls += 1;
414
548
  usage.outputTokens += estimateTokens(`mock:${assignment.agentId}`);
415
549
  step += 1;
@@ -558,6 +692,10 @@ export async function runSwarmRuntime({
558
692
  usage,
559
693
  eventCount: events.length,
560
694
  selectedAgents: Array.isArray(plan.selectedAgents) ? [...plan.selectedAgents] : [],
695
+ findingCount: findings.length,
696
+ findings,
697
+ artifactBundles,
698
+ devTestBotRuns,
561
699
  };
562
700
 
563
701
  return writeRuntimeArtifacts({