brl-subagent 2.3.4

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -0
  3. package/package.json +49 -0
  4. package/presets/code-reviewer.md +37 -0
  5. package/presets/data-analyst.md +33 -0
  6. package/presets/debugger.md +36 -0
  7. package/presets/dev-agent.md +30 -0
  8. package/presets/rapid-prototyper.md +25 -0
  9. package/presets/refactorer.md +34 -0
  10. package/presets/security-auditor.md +39 -0
  11. package/presets/tech-writer.md +33 -0
  12. package/presets/test-engineer.md +34 -0
  13. package/src/concurrency.ts +186 -0
  14. package/src/diff.ts +102 -0
  15. package/src/event-bus.ts +86 -0
  16. package/src/git.ts +187 -0
  17. package/src/history.ts +177 -0
  18. package/src/index.ts +3824 -0
  19. package/src/logging.ts +135 -0
  20. package/src/messaging.ts +164 -0
  21. package/src/metrics.ts +221 -0
  22. package/src/model-availability.ts +73 -0
  23. package/src/notify-completion.ts +207 -0
  24. package/src/params.ts +225 -0
  25. package/src/preflight.ts +150 -0
  26. package/src/presets.ts +425 -0
  27. package/src/prompt.ts +169 -0
  28. package/src/reports.ts +325 -0
  29. package/src/router.ts +133 -0
  30. package/src/runner.ts +877 -0
  31. package/src/sanitize.ts +365 -0
  32. package/src/scheduler.ts +187 -0
  33. package/src/session-manager.ts +1065 -0
  34. package/src/state.ts +590 -0
  35. package/src/templates.ts +469 -0
  36. package/src/transcript-tail.ts +327 -0
  37. package/src/transcript.ts +119 -0
  38. package/src/tui-format.ts +51 -0
  39. package/src/tui.ts +3103 -0
  40. package/src/types.ts +803 -0
  41. package/src/unit-run.ts +269 -0
  42. package/src/update.ts +109 -0
  43. package/src/validate.ts +302 -0
  44. package/templates/analyze-data.md +9 -0
  45. package/templates/code-review.md +10 -0
  46. package/templates/debug-issue.md +9 -0
  47. package/templates/implement-feature.md +9 -0
  48. package/templates/prototype.md +9 -0
  49. package/templates/refactor.md +9 -0
  50. package/templates/security-audit.md +10 -0
  51. package/templates/write-docs.md +9 -0
  52. package/templates/write-tests.md +10 -0
package/src/index.ts ADDED
@@ -0,0 +1,3824 @@
1
+ /**
2
+ * brl-subagent — multi-agent orchestration for pi.
3
+ * Entry point: tool registrations, command surface, and the multi-mode runners.
4
+ * See AGENT.md for the conductor-facing capability reference.
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import type {
10
+ AgentToolResult,
11
+ AgentToolUpdateCallback,
12
+ } from "@earendil-works/pi-agent-core";
13
+ import type { ExtensionAPI, ExtensionContext, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
14
+ import { Type } from "typebox";
15
+
16
+ import type {
17
+ SubagentPreset,
18
+ SubagentResult,
19
+ SubagentRun,
20
+ SubagentToolOptions,
21
+ ResolvedParams,
22
+ ThinkingLevel,
23
+ ApprovalMode,
24
+ SubTaskParams,
25
+ SubTaskResult,
26
+ ChainDetails,
27
+ ParallelDetails,
28
+ MultiSubagentDetails,
29
+ GraphTask,
30
+ GraphDetails,
31
+ GraphWave,
32
+ DelegateTaskDetails,
33
+ Priority,
34
+ TaskTemplate,
35
+ ToolResult,
36
+ SubagentEvent,
37
+ } from "./types";
38
+ import {
39
+ resolveThinkingLevel,
40
+ EMPTY_USAGE,
41
+ getFinalOutput,
42
+ isSubagentError,
43
+ classifyError,
44
+ MAX_CHAIN_STEPS,
45
+ MAX_PARALLEL_TASKS,
46
+ MAX_GRAPH_TASKS,
47
+ PREVIOUS_OUTPUT_PLACEHOLDER,
48
+ GRAPH_OUTPUT_PLACEHOLDER_RE,
49
+ DEFAULT_PRIORITY,
50
+ type GitMode,
51
+ type BackgroundAgent,
52
+ } from "./types";
53
+ import { validateGraph, topologicalSort } from "./scheduler";
54
+ import { resolveTemplate, loadAllTemplates, loadBuiltinTemplates, validateTemplatePresetRefs, extractParamNames } from "./templates";
55
+
56
+ import { sanitizeTask, validateCwd, validateOutputFile, stripAnsi, capOutput, getCurrentDepth, sanitizeErrorMessage, buildCrashResult } from "./sanitize";
57
+ import {
58
+ getCurrentBranch,
59
+ hasUncommittedChanges,
60
+ createWorkBranch,
61
+ captureDiff,
62
+ switchToBranch,
63
+ deleteBranch,
64
+ mergeWorkBranch,
65
+ } from "./git";
66
+ import { preflightCheck } from "./preflight";
67
+ import { loadBuiltinPresets, loadCustomPresets, getAllPresets, writePresetFile, formatPresetRestriction, formatToolRestriction } from "./presets";
68
+ import { modelIsAvailable } from "./model-availability";
69
+ import { validatePreTask, diagnoseFailure } from "./validate";
70
+ import { findUnknownParams, KNOWN_DELEGATE_KEYS, resolveSubagentParams, snapshotOriginalParams } from "./params";
71
+ import { createSessionState } from "./state";
72
+ import { makeLiveOnUpdate, createUnitRun, finalizeUnitRun, finalizeUnitRunCrash, pruneHistoryIfNeeded, registerLiveRun } from "./unit-run";
73
+ import { buildSubagentPrompt, describePromptMode } from "./prompt";
74
+ import { runSubagent, cleanupTempDirs } from "./runner";
75
+ import { acquireSlot, releaseSlot, updateStatus, updateProgressStatus } from "./concurrency";
76
+ import {
77
+ finalizeRunRecord,
78
+ resolveRetryParams,
79
+ createEmptyResult,
80
+ } from "./history";
81
+ import { computeSLAMetrics, computeDegradation } from "./metrics";
82
+ import {
83
+ showSelectList,
84
+ showModelSelector,
85
+ showThinkingSelector,
86
+ showConcurrencyInput,
87
+ showDepthInput,
88
+ showHistoryEntriesInput,
89
+ showCostLimitInput,
90
+ showApprovalModeSelector,
91
+ showApprovalDialog,
92
+ showCompletionNotifySelector,
93
+ showPresetManager,
94
+ showTemplateManager,
95
+ showUpdateCheckToggle,
96
+ showSLAConfig,
97
+ showSLAStats,
98
+ showConfigMenu,
99
+ showRunHistory,
100
+ showMonitor,
101
+ showDashboard,
102
+ showRetryMenu,
103
+ renderDelegateCall,
104
+ renderDelegateResult,
105
+ } from "./tui";
106
+ import { createLogger, type Logger } from "./logging";
107
+ import { Intercom } from "./messaging";
108
+ import { checkForUpdates } from "./update";
109
+ import { UPDATE_CHECK_INTERVAL_MS } from "./types";
110
+ import * as eventBus from "./event-bus";
111
+ import {
112
+ buildCompletionMessage,
113
+ resolveDelivery,
114
+ sendCompletionNotification,
115
+ markTerminalSeen,
116
+ normalizeCompletionStatus,
117
+ resolveRunEntry,
118
+ } from "./notify-completion";
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Helpers
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * Sanitize a text preview for markdown-rendered notifications: strip code
126
+ * fences (``` and ~~~) and backticks, keep whole non-empty lines with
127
+ * mid-line truncation for over-long lines, and never split surrogate pairs
128
+ * at the boundary.
129
+ */
130
+ function sanitizePreview(text: string, maxLen = 500): string {
131
+ // Strip markdown fences and backticks
132
+ const cleaned = text.replace(/```/g, '').replace(/~~~+/g, '').replace(/`/g, '');
133
+ const lines = cleaned.split('\n').filter(l => l.trim());
134
+ let out = '';
135
+ for (const line of lines) {
136
+ if (out.length + line.length + 1 > maxLen) {
137
+ // Mid-line truncation: keep as much of this line as fits
138
+ const remaining = maxLen - out.length - 1;
139
+ if (remaining > 10) {
140
+ out += line.slice(0, remaining - 3) + '...\n';
141
+ }
142
+ break;
143
+ }
144
+ out += line + '\n';
145
+ }
146
+ // Avoid splitting surrogate pairs at the boundary (reachable now via slice above)
147
+ const safe = out.slice(0, maxLen);
148
+ const lastChar = safe[safe.length - 1];
149
+ const stripped = lastChar && /[\uD800-\uDBFF]/.test(lastChar) ? safe.slice(0, -1) : safe;
150
+ return stripped.trimEnd();
151
+ }
152
+
153
+ /**
154
+ * DRY helper (issue #154 review): shape an inventory of items into a single
155
+ * comma-delimited summary line for the LLM-facing delegation guidance. Both
156
+ * the B1 preset-restriction summary and the B1b template summary load an
157
+ * inventory at registration, map each item to a formatted string, then join.
158
+ */
159
+ export function buildInventorySummary<T>(items: T[], formatItem: (item: T) => string): string {
160
+ return items.map(formatItem).join(", ");
161
+ }
162
+
163
+ /**
164
+ * Format one built-in template into a compact summary entry for the conductor:
165
+ * `name` (+ ` (lowercased description; slots ${a}, ${b})`) — the slot note is
166
+ * omitted when the template declares no params, and the description is omitted
167
+ * when it is empty. Mirrors the B1b summary shape (issue #154 review).
168
+ */
169
+ export function formatTemplateSummaryItem(t: TaskTemplate): string {
170
+ const slots = [...new Set([
171
+ ...extractParamNames(t.task),
172
+ ...(t.outputFile ? extractParamNames(t.outputFile) : []),
173
+ ])];
174
+ const slotNote = slots.length > 0
175
+ ? `; slots ${slots.map((s) => `\${${s}}`).join(", ")}`
176
+ : "";
177
+ const desc = t.description
178
+ ? `${t.description.charAt(0).toLowerCase()}${t.description.slice(1)}`
179
+ : "";
180
+ return `${t.name}${desc ? ` (${desc}${slotNote})` : slotNote}`;
181
+ }
182
+
183
+ /**
184
+ * Render the template guideline line with the built-in inventory summary
185
+ * interpolated. Exported so the guidance test can assert the RENDERED text
186
+ * rather than the literal `${templateSummary}` placeholder that a double-quoted
187
+ * string would leave in the conductor-facing guidance (issue #154 review).
188
+ */
189
+ export function buildTemplateGuideline(templateSummary: string): string {
190
+ return `Templates are file-backed task starters with \${param} slots. Built-in templates: ${templateSummary}. Custom templates are NOT listed here — inspect them via /brl-subagent templates before choosing one.`;
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Extension entry point
195
+ // ---------------------------------------------------------------------------
196
+
197
+ export default function (pi: ExtensionAPI) {
198
+ const log = createLogger("brl-subagent");
199
+
200
+ // Read current version from package.json
201
+ const currentVersion = (() => {
202
+ try {
203
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8"));
204
+ return pkg.version || "0.0.0";
205
+ } catch {
206
+ return "0.0.0";
207
+ }
208
+ })();
209
+
210
+ // F7: Session-bound state — initialized per session
211
+ let state = createSessionState(log);
212
+
213
+ // Issue #147: the completion-push subscriber needs the session context to
214
+ // resolve the run entry (state.getRunEntries(ctx)). The event-bus listener
215
+ // receives no ctx, so capture it in session_start where it is available.
216
+ let sessionCtx: ExtensionContext | undefined;
217
+
218
+ // -------------------------------------------------------------------
219
+ // Config change callback
220
+ // -------------------------------------------------------------------
221
+
222
+ function applyConfig(ctx: ExtensionContext, message: string) {
223
+ updateStatus(state, ctx);
224
+ state.persistState(pi);
225
+ ctx.ui.notify(message, "info");
226
+ }
227
+
228
+ function resetState(ctx: ExtensionContext) {
229
+ state.reset();
230
+ // Templates are file-backed (issue #66): state.reset() clears
231
+ // state.config.templates, so reload them from disk — otherwise
232
+ // template lookups stay empty until the next session_start.
233
+ // Full stack: custom (project+global) merged over builtins.
234
+ state.config.templates = loadAllTemplates(ctx.cwd, log);
235
+ // Issue #81: cross-check template `preset:` refs against the full
236
+ // preset universe now that both loads are complete — warn (never skip)
237
+ // for dangling references instead of running preset-less silently.
238
+ validateTemplatePresetRefs(
239
+ state.config.templates,
240
+ getAllPresets(state.builtinPresets, state.customPresets),
241
+ log,
242
+ );
243
+ updateStatus(state, ctx);
244
+ state.persistState(pi);
245
+ ctx.ui.notify("Subagent configuration reset", "info");
246
+ }
247
+
248
+ // -------------------------------------------------------------------
249
+ // Parameter resolution (extracted to src/params.ts — issue #59)
250
+ // -------------------------------------------------------------------
251
+
252
+ /** Parse "provider/model-id" into {provider, id}. Returns null on bad format. */
253
+ function parseModelString(s: string): { provider: string; id: string } | null {
254
+ const trimmed = s.trim();
255
+ const idx = trimmed.indexOf("/");
256
+ if (idx <= 0 || idx === trimmed.length - 1) return null;
257
+ return { provider: trimmed.slice(0, idx), id: trimmed.slice(idx + 1) };
258
+ }
259
+
260
+ function resolveSubagentModel(
261
+ ctx: ExtensionContext,
262
+ preset: SubagentPreset | undefined,
263
+ perCallModel?: string, // NEW: top-level delegate_task model param (issue #96)
264
+ ):
265
+ | { ok: true; model: { provider: string; id: string } }
266
+ | { ok: false; error: ToolResult<SubagentResult | undefined> } {
267
+ // Precedence: per-call model > preset.model > state.config.model > conductor model
268
+ let subagentModel: { provider: string; id: string } | undefined;
269
+
270
+ if (perCallModel) {
271
+ const parsed = parseModelString(perCallModel);
272
+ if (!parsed) {
273
+ log.warn("Model override is not a valid provider/model-id, falling back", { model: perCallModel });
274
+ } else if (modelIsAvailable(ctx.modelRegistry, parsed)) {
275
+ log.info("Using per-call model override", { model: perCallModel });
276
+ return { ok: true, model: parsed };
277
+ } else {
278
+ log.warn("Model override unavailable, falling back", { model: perCallModel });
279
+ }
280
+ }
281
+
282
+ if (preset?.model) {
283
+ const parsed = parseModelString(preset.model);
284
+ if (parsed && modelIsAvailable(ctx.modelRegistry, parsed)) {
285
+ subagentModel = parsed;
286
+ log.info("Using preset model", { preset: preset.name, model: preset.model });
287
+ } else {
288
+ // Fallback: preset model unavailable → configured model
289
+ log.warn("Preset model unavailable, falling back to configured model", {
290
+ preset: preset.name,
291
+ model: preset.model,
292
+ });
293
+ }
294
+ }
295
+
296
+ if (!subagentModel) {
297
+ subagentModel =
298
+ state.config.model ||
299
+ (ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined);
300
+ }
301
+
302
+ if (!subagentModel) {
303
+ return {
304
+ ok: false,
305
+ error: {
306
+ content: [
307
+ {
308
+ type: "text" as const,
309
+ text: "No model available. Configure API keys first, then use /brl-subagent to set a model.",
310
+ },
311
+ ],
312
+ details: undefined,
313
+ isError: true,
314
+ },
315
+ };
316
+ }
317
+
318
+ return { ok: true, model: subagentModel };
319
+ }
320
+
321
+ // C3: Resolve a step's model override (step.model > global resolved model).
322
+ // Parsed + availability-checked; falls back to the global model on failure.
323
+ function resolveStepModel(
324
+ ctx: ExtensionContext,
325
+ stepModel: string | undefined,
326
+ globalModel: { provider: string; id: string },
327
+ ): { provider: string; id: string } {
328
+ if (stepModel) {
329
+ const parsed = parseModelString(stepModel);
330
+ if (!parsed) {
331
+ log.warn("Step model is not a valid provider/model-id, falling back to global model", { model: stepModel });
332
+ } else if (modelIsAvailable(ctx.modelRegistry, parsed)) {
333
+ log.info("Using step model override", { model: stepModel });
334
+ return parsed;
335
+ } else {
336
+ log.warn("Step model unavailable, falling back to global model", { model: stepModel });
337
+ }
338
+ }
339
+ return globalModel;
340
+ }
341
+
342
+ // -------------------------------------------------------------------
343
+ // P1+P2: mergeSubTaskParams
344
+ // -------------------------------------------------------------------
345
+
346
+ /**
347
+ * Merge global resolved params with a SubTaskParams.
348
+ * SubTask fields override global fields. Tools/excludeTools/noBuiltinTools
349
+ * are replaced entirely if set in the subTask, otherwise inherited from global.
350
+ */
351
+ function mergeSubTaskParams(
352
+ globalParams: ResolvedParams & { resolvedGitMode: GitMode; resolvedApprovalMode: ApprovalMode },
353
+ subTask: SubTaskParams,
354
+ ): {
355
+ task: string;
356
+ label: string | undefined;
357
+ model: string | undefined;
358
+ inheritSP: boolean;
359
+ customSP: string | undefined;
360
+ outputFile: string | undefined;
361
+ timeout: number | undefined;
362
+ effectiveCwd: string;
363
+ thinkingLevel: ThinkingLevel;
364
+ priority: Priority | undefined;
365
+ toolOptions: SubagentToolOptions | undefined;
366
+ resolvedGitMode: GitMode;
367
+ resolvedApprovalMode: ApprovalMode;
368
+ } {
369
+ const mergedThinkingLevel = subTask.thinkingLevel
370
+ ? resolveThinkingLevel(
371
+ subTask.thinkingLevel as ThinkingLevel,
372
+ state.config.maxThinkingLevel,
373
+ )
374
+ : globalParams.thinkingLevel;
375
+
376
+ const mergedTools = subTask.tools ?? globalParams.toolOptions?.tools;
377
+ // Fix: edit depends on write in pi's tool system.
378
+ const resolvedTools = mergedTools && mergedTools.includes("edit") && !mergedTools.includes("write")
379
+ ? [...mergedTools, "write"]
380
+ : mergedTools;
381
+ const mergedExcludeTools = subTask.excludeTools ?? globalParams.toolOptions?.excludeTools;
382
+ const mergedNoBuiltinTools =
383
+ subTask.noBuiltinTools ?? globalParams.toolOptions?.noBuiltinTools;
384
+
385
+ const mergedToolOptions: SubagentToolOptions | undefined =
386
+ resolvedTools || mergedExcludeTools || mergedNoBuiltinTools
387
+ ? {
388
+ tools: resolvedTools,
389
+ excludeTools: mergedExcludeTools,
390
+ noBuiltinTools: mergedNoBuiltinTools,
391
+ }
392
+ : undefined;
393
+
394
+ return {
395
+ task: subTask.task || globalParams.task,
396
+ label: subTask.label ?? globalParams.label,
397
+ model: subTask.model,
398
+ inheritSP: subTask.inheritSystemPrompt ?? globalParams.inheritSP,
399
+ customSP: subTask.systemPrompt ?? globalParams.customSP,
400
+ outputFile: subTask.outputFile ?? globalParams.outputFile,
401
+ timeout: subTask.timeout ?? globalParams.timeout,
402
+ effectiveCwd: subTask.cwd ?? globalParams.effectiveCwd,
403
+ thinkingLevel: mergedThinkingLevel,
404
+ // Issue #114: per-unit priority — undefined when the step declares none
405
+ // (the caller then falls back to the call-level priority).
406
+ priority: subTask.priority && ["critical", "high", "normal", "low"].includes(subTask.priority)
407
+ ? (subTask.priority as Priority)
408
+ : undefined,
409
+ toolOptions: mergedToolOptions,
410
+ resolvedGitMode: globalParams.resolvedGitMode,
411
+ resolvedApprovalMode: globalParams.resolvedApprovalMode,
412
+ };
413
+ }
414
+
415
+ // -------------------------------------------------------------------
416
+ // P1: runChainMode
417
+ // -------------------------------------------------------------------
418
+
419
+ async function runChainMode(
420
+ params: Record<string, unknown>,
421
+ signal: AbortSignal | undefined,
422
+ onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
423
+ ctx: ExtensionContext,
424
+ ): Promise<ToolResult<SubagentResult | ChainDetails | undefined>> {
425
+ const chainSteps = params.chain as SubTaskParams[];
426
+
427
+ // Issue #133: dispatch-start timestamp — the aggregate run entry's
428
+ // startedAt (captured once, at mode entry, not at completion).
429
+ const chainModeStartedAt = new Date().toISOString();
430
+
431
+ // R5: Check session cost limit before spawning
432
+ const perTaskEstimate =
433
+ state.config.perTaskCostEstimate > 0
434
+ ? state.config.perTaskCostEstimate
435
+ : 0.05;
436
+ if (state.checkCostLimit(perTaskEstimate * chainSteps.length, ctx)) {
437
+ const currentTotal = state.getSessionTotalCost(ctx);
438
+ const limit = state.config.sessionCostLimit;
439
+ log.warn("Chain delegation rejected: session cost limit reached", {
440
+ currentTotal,
441
+ estimatedCost: perTaskEstimate * chainSteps.length,
442
+ limit,
443
+ });
444
+ return {
445
+ content: [
446
+ {
447
+ type: "text" as const,
448
+ text:
449
+ `Cannot delegate: session cost limit reached ` +
450
+ `($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
451
+ `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
452
+ },
453
+ ],
454
+ details: undefined,
455
+ isError: true,
456
+ };
457
+ }
458
+
459
+ // Reject delegation if recursion depth exceeds configured max
460
+ const currentDepth = getCurrentDepth();
461
+ if (currentDepth >= state.config.maxSubagentDepth) {
462
+ log.warn("Chain delegation rejected: max depth reached", {
463
+ currentDepth,
464
+ maxDepth: state.config.maxSubagentDepth,
465
+ });
466
+ return {
467
+ content: [
468
+ {
469
+ type: "text" as const,
470
+ text:
471
+ `Cannot delegate further: subagent recursion depth limit reached ` +
472
+ `(depth ${currentDepth}/${state.config.maxSubagentDepth}). ` +
473
+ `Subagents can delegate up to ${state.config.maxSubagentDepth} levels deep (configurable via /brl-subagent depth). Complete the remaining work directly.`,
474
+ },
475
+ ],
476
+ details: undefined,
477
+ isError: true,
478
+ };
479
+ }
480
+
481
+ // Resolve global params once
482
+ const globalParams = resolveSubagentParams(
483
+ params as {
484
+ task: string;
485
+ label?: string;
486
+ preset?: string;
487
+ systemPrompt?: string;
488
+ inheritSystemPrompt?: boolean;
489
+ thinkingLevel?: string;
490
+ outputFile?: string;
491
+ timeout?: number;
492
+ cwd?: string;
493
+ tools?: string[];
494
+ excludeTools?: string[];
495
+ noBuiltinTools?: boolean;
496
+ gitMode?: string;
497
+ },
498
+ state,
499
+ ctx,
500
+ log,
501
+ );
502
+
503
+ // Validate CWD once
504
+ const cwdResult = validateCwd(globalParams.effectiveCwd, ctx.cwd);
505
+ if (!cwdResult.ok) {
506
+ return {
507
+ content: [
508
+ { type: "text" as const, text: `Invalid cwd: ${cwdResult.error}` },
509
+ ],
510
+ details: undefined,
511
+ isError: true,
512
+ };
513
+ }
514
+ const resolvedCwd = cwdResult.value;
515
+
516
+ // Pre-flight checks — fail fast before consuming resources
517
+ const pfResult = preflightCheck(resolvedCwd);
518
+ if (!pfResult.ok) {
519
+ log.warn("Chain pre-flight check failed", { error: pfResult.error });
520
+ return {
521
+ content: [
522
+ {
523
+ type: "text" as const,
524
+ text: `Pre-flight check failed: ${pfResult.error}`,
525
+ },
526
+ ],
527
+ details: undefined,
528
+ isError: true,
529
+ };
530
+ }
531
+
532
+ // H1: Pre-task validation at mode entry — deterministic check that
533
+ // tools/thinking match the task (mirrors single/background modes, issue
534
+ // #32). Chain top-level tasks are empty (modeCount forbids task+chain), so
535
+ // keyword warnings skip on empty text and only the hard outputFile-vs-write
536
+ // conflict applies (issue #34). The mode-level outputFile on globalParams is
537
+ // the one validated; per-step outputFiles are future work (issue #3).
538
+ const validation = validatePreTask({
539
+ task: globalParams.task,
540
+ toolOptions: globalParams.toolOptions,
541
+ thinkingLevel: globalParams.thinkingLevel,
542
+ gitMode: globalParams.resolvedGitMode,
543
+ outputFile: globalParams.outputFile,
544
+ });
545
+ if (validation.warnings.length > 0) {
546
+ log.warn("Chain pre-task validation warnings", { warnings: validation.warnings });
547
+ }
548
+ if (!validation.valid) {
549
+ const errText = validation.errors.join("; ");
550
+ log.warn("Chain pre-task validation failed", { errors: validation.errors });
551
+ return {
552
+ content: [{ type: "text" as const, text: errText }],
553
+ details: undefined,
554
+ isError: true,
555
+ };
556
+ }
557
+
558
+ // Resolve model once (per-call top-level model override beats preset)
559
+ const modelResult = resolveSubagentModel(ctx, globalParams.resolvedPreset, params.model as string | undefined);
560
+ if (!modelResult.ok) return modelResult.error;
561
+ const subagentModel = modelResult.model;
562
+
563
+ const modelStr = `${subagentModel.provider}/${subagentModel.id}`.trim();
564
+ if (!modelStr || modelStr === "/") {
565
+ log.warn("Model string is empty after resolution", { model: subagentModel });
566
+ return {
567
+ content: [
568
+ {
569
+ type: "text" as const,
570
+ text:
571
+ "Subagent model is not configured. " +
572
+ "Use /brl-subagent to set a model, or ensure your current session has a valid model.",
573
+ },
574
+ ],
575
+ details: undefined,
576
+ isError: true,
577
+ };
578
+ }
579
+
580
+ // R1: Circuit breaker check — reject if circuit is open
581
+ const circuitCheck = state.checkCircuit();
582
+ if (circuitCheck.isOpen) {
583
+ return {
584
+ content: [
585
+ { type: "text" as const, text: circuitCheck.message! },
586
+ ],
587
+ details: undefined,
588
+ isError: true,
589
+ };
590
+ }
591
+
592
+ // Resolve priority (issue #114: no config knob — the call-level default)
593
+ const chainPriority: Priority = (
594
+ params.priority && ["critical", "high", "normal", "low"].includes(params.priority as string)
595
+ ? (params.priority as Priority)
596
+ : DEFAULT_PRIORITY
597
+ );
598
+
599
+ // Acquire concurrency slot for the chain
600
+ const acquired = await acquireSlot(state, ctx, signal, chainPriority);
601
+ if (!acquired) {
602
+ return {
603
+ content: [
604
+ {
605
+ type: "text" as const,
606
+ text: "Chain cancelled while waiting for concurrency slot.",
607
+ },
608
+ ],
609
+ details: undefined,
610
+ isError: true,
611
+ };
612
+ }
613
+
614
+ let chainSuccess = false;
615
+ try {
616
+ const basePrompt = ctx.getSystemPrompt();
617
+ let previousOutput = "";
618
+ const chainResults: SubTaskResult[] = [];
619
+ let stoppedEarly = false;
620
+
621
+ for (let i = 0; i < chainSteps.length; i++) {
622
+ const step = chainSteps[i];
623
+
624
+ // Merge params for this step
625
+ const merged = mergeSubTaskParams(globalParams, step);
626
+
627
+ // C3: Resolve this step's model override (step.model > global resolved model)
628
+ const stepModel = resolveStepModel(ctx, merged.model, subagentModel);
629
+
630
+ // Substitute {previous} placeholder
631
+ merged.task = merged.task.replaceAll(
632
+ PREVIOUS_OUTPUT_PLACEHOLDER,
633
+ previousOutput,
634
+ );
635
+
636
+ // Build system prompt
637
+ const subagentPrompt = buildSubagentPrompt(
638
+ basePrompt,
639
+ merged.inheritSP,
640
+ merged.customSP,
641
+ merged.outputFile,
642
+ merged.toolOptions?.tools,
643
+ globalParams.resolvedPreset?.promptGuideline,
644
+ );
645
+
646
+ // Create SubTaskResult for this step
647
+ const subTaskResult: SubTaskResult = {
648
+ step: i + 1,
649
+ task: merged.task,
650
+ label: merged.label,
651
+ exitCode: 0,
652
+ messages: [],
653
+ stderr: "",
654
+ usage: { ...EMPTY_USAGE },
655
+ };
656
+
657
+ // Issue #133: status-bar breakdown — the mode context shows the
658
+ // current step while the dispatch is in flight.
659
+ state.modeContext = {
660
+ kind: "chain",
661
+ step: i + 1,
662
+ totalSteps: chainSteps.length,
663
+ };
664
+ // Issue #133: render the breakdown — the chain holds ONE mode-level
665
+ // slot (acquired at mode start, released in the finally), so no
666
+ // per-step acquire/release calls updateProgressStatus while the
667
+ // mode context is set. Call it directly here (mirrors how the
668
+ // graph path's per-node slot operations trigger the render).
669
+ updateProgressStatus(state, ctx);
670
+
671
+ // Emit initial progress for this step
672
+ const modeInfo = describePromptMode(
673
+ merged.inheritSP,
674
+ Boolean(merged.customSP),
675
+ );
676
+ onUpdate?.({
677
+ content: [
678
+ {
679
+ type: "text" as const,
680
+ text: `Chain step ${i + 1}/${chainSteps.length} (${modeInfo})...`,
681
+ },
682
+ ],
683
+ details: {
684
+ messages: [],
685
+ usage: { ...EMPTY_USAGE },
686
+ exitCode: -1,
687
+ stderr: "",
688
+ },
689
+ });
690
+
691
+ // Issue #133: per-step run entry — mirrors runParallelMode's
692
+ // per-unit block (issue #119): the runId doubles as the live id,
693
+ // so sweepStaleLiveSubagents finds a 'running' record for every
694
+ // foreground live entry (the #130 invariant gap that swept
695
+ // graph/chain entries immediately).
696
+ const { runId, run } = createUnitRun(merged, stepModel, chainPriority, globalParams.resolvedPreset?.name);
697
+ state.persistRun(pi, run);
698
+
699
+ // R2: Prune old run entries if history exceeds limit
700
+ pruneHistoryIfNeeded(state, ctx, log);
701
+
702
+ // Register for live monitor — chain steps now appear in the
703
+ // drill-in under the runId (issue #133 single identity, mirrors
704
+ // parallel mode's issue #119 registration). Fall back to
705
+ // "Step N" when the step carries no label (liveRowName would
706
+ // otherwise show the truncated task).
707
+ registerLiveRun(state, run, ctx, merged.label ?? `Step ${i + 1}`);
708
+
709
+ // Wrap onUpdate so the live monitor sees per-step progress
710
+ // (mirrors parallel mode's taskOnUpdate).
711
+ const stepOnUpdate = makeLiveOnUpdate(state, runId, onUpdate);
712
+
713
+ // Run the subagent for this step — crash-protected (issue #130,
714
+ // #133 R1/C1 discipline): a throw must finalize the run entry as
715
+ // failed AND release the live entry, or the persisted record stays
716
+ // 'running' forever. finalizeLiveSubagent is idempotent; the
717
+ // rethrow is handled by the mode's existing outer catch (chain is
718
+ // sequential — no sibling wave to cancel).
719
+ let result: SubagentResult;
720
+ try {
721
+ result = await runSubagent(
722
+ resolvedCwd,
723
+ subagentPrompt,
724
+ stepModel,
725
+ merged.thinkingLevel,
726
+ merged.task,
727
+ signal,
728
+ stepOnUpdate,
729
+ merged.toolOptions,
730
+ merged.timeout,
731
+ getFinalOutput,
732
+ log,
733
+ currentDepth + 1,
734
+ );
735
+ } catch (err) {
736
+ // Issue #133 (mirrors the #119 R1/C1 discipline): finalize the
737
+ // run entry as failed — crashOutput comes from the live entry's
738
+ // last streamed output (the crash result itself has no
739
+ // messages), and errorCategory is classified onto originalParams.
740
+ finalizeUnitRunCrash(state, pi, run, err, "Chain step", resolvedCwd, log, {
741
+ step: i + 1,
742
+ });
743
+ }
744
+
745
+ // Issue #133: finalize the per-step run entry — status, duration,
746
+ // cost, tokens and sanitized output land on the entry (mirrors
747
+ // parallel's issue #119 finalize; result carries the SubagentResult
748
+ // shape finalizeRunRecord expects, errorCategory included).
749
+ finalizeUnitRun(state, pi, ctx, run, result, log);
750
+
751
+ // Fill SubTaskResult
752
+ subTaskResult.exitCode = result.exitCode;
753
+ subTaskResult.messages = result.messages;
754
+ subTaskResult.stderr = result.stderr;
755
+ subTaskResult.usage = result.usage;
756
+ subTaskResult.model = result.model;
757
+ subTaskResult.stopReason = result.stopReason;
758
+ subTaskResult.errorMessage = result.errorMessage;
759
+ subTaskResult.errorCategory = classifyError(result);
760
+ subTaskResult.gitBranch = result.gitBranch;
761
+ subTaskResult.gitDiff = result.gitDiff;
762
+
763
+ chainResults.push(subTaskResult);
764
+
765
+ // Emit update with current ChainDetails
766
+ const completedSteps = chainResults.length;
767
+ const currentDetails: ChainDetails = {
768
+ mode: "chain",
769
+ results: chainResults,
770
+ totalInput: chainResults.reduce(
771
+ (s, r) => s + r.usage.input,
772
+ 0,
773
+ ),
774
+ totalOutput: chainResults.reduce(
775
+ (s, r) => s + r.usage.output,
776
+ 0,
777
+ ),
778
+ totalCost: chainResults.reduce(
779
+ (s, r) => s + r.usage.cost,
780
+ 0,
781
+ ),
782
+ totalTurns: chainResults.reduce(
783
+ (s, r) => s + r.usage.turns,
784
+ 0,
785
+ ),
786
+ completedSteps,
787
+ totalSteps: chainSteps.length,
788
+ stoppedEarly: false,
789
+ };
790
+
791
+ const stepFailed = isSubagentError(result);
792
+ onUpdate?.({
793
+ content: [
794
+ {
795
+ type: "text" as const,
796
+ text: `Chain step ${completedSteps}/${chainSteps.length} completed${stepFailed ? " (failed)" : ""}`,
797
+ },
798
+ ],
799
+ details: currentDetails,
800
+ });
801
+
802
+ // Stop on failure (unless this was the last step)
803
+ if (stepFailed && i < chainSteps.length - 1) {
804
+ stoppedEarly = true;
805
+ break;
806
+ }
807
+
808
+ // Set previous output for the next step
809
+ previousOutput = getFinalOutput(result.messages);
810
+ }
811
+
812
+ chainSuccess = chainResults.every((r) => r.exitCode === 0);
813
+
814
+ // Compute aggregated totals
815
+ const totalInput = chainResults.reduce(
816
+ (s, r) => s + r.usage.input,
817
+ 0,
818
+ );
819
+ const totalOutput = chainResults.reduce(
820
+ (s, r) => s + r.usage.output,
821
+ 0,
822
+ );
823
+ const totalCost = chainResults.reduce(
824
+ (s, r) => s + r.usage.cost,
825
+ 0,
826
+ );
827
+ const totalTurns = chainResults.reduce(
828
+ (s, r) => s + r.usage.turns,
829
+ 0,
830
+ );
831
+
832
+ const chainDetails: ChainDetails = {
833
+ mode: "chain",
834
+ results: chainResults,
835
+ totalInput,
836
+ totalOutput,
837
+ totalCost,
838
+ totalTurns,
839
+ completedSteps: chainResults.length,
840
+ totalSteps: chainSteps.length,
841
+ stoppedEarly,
842
+ };
843
+
844
+ // R1: Record circuit breaker outcome
845
+ if (chainSuccess) {
846
+ state.recordSuccess();
847
+ } else {
848
+ state.recordFailure();
849
+ }
850
+
851
+ log.info("Chain completed", {
852
+ totalSteps: chainSteps.length,
853
+ completedSteps: chainResults.length,
854
+ stoppedEarly,
855
+ totalInput,
856
+ totalOutput,
857
+ totalCost,
858
+ });
859
+
860
+ // Issue #133: one aggregate run entry per chain dispatch — fresh id,
861
+ // persisted ONCE as done (no live entry: the drill-in shows the
862
+ // per-step entries while running; the aggregate is a post-hoc audit
863
+ // summary). Fields come from the dispatch-level resolved values and
864
+ // the aggregate totals; startedAt is the mode-entry timestamp.
865
+ const chainAggregateRun: SubagentRun = {
866
+ id: crypto.randomUUID(),
867
+ task: `Chain dispatch: ${chainSteps.length} steps`,
868
+ label: globalParams.label ?? "chain",
869
+ // Issue #133: persist the ACTUAL outcome — "failed" when any step
870
+ // failed (chainSuccess === false), "done" otherwise.
871
+ status: chainSuccess ? "done" : "failed",
872
+ model: `${subagentModel.provider}/${subagentModel.id}`,
873
+ thinkingLevel: globalParams.thinkingLevel,
874
+ priority: chainPriority,
875
+ startedAt: chainModeStartedAt,
876
+ finishedAt: new Date().toISOString(),
877
+ durationMs: Date.now() - new Date(chainModeStartedAt).getTime(),
878
+ cost: totalCost,
879
+ tokensIn: totalInput,
880
+ tokensOut: totalOutput,
881
+ outputSummary: capOutput(JSON.stringify(chainDetails, null, 2)),
882
+ };
883
+ state.persistRun(pi, chainAggregateRun);
884
+
885
+ return {
886
+ content: [
887
+ { type: "text" as const, text: JSON.stringify(chainDetails, null, 2) },
888
+ ],
889
+ details: chainDetails,
890
+ };
891
+ } catch (err) {
892
+ // F7 (issue #65): err.message may embed absolute paths — sanitize
893
+ // BEFORE it reaches the main agent's context (tool result content +
894
+ // details.errorMessage). resolvedCwd is the validated subagent cwd.
895
+ const result = buildCrashResult("Chain mode", err, resolvedCwd);
896
+ log.error("Chain mode crashed", { error: result.details.errorMessage });
897
+ return result;
898
+ } finally {
899
+ // Issue #133: clear the status-bar mode breakdown — the dispatch is
900
+ // over (success or outer-catch crash).
901
+ state.modeContext = undefined;
902
+ releaseSlot(state, chainSuccess, ctx);
903
+ }
904
+ }
905
+
906
+ // -------------------------------------------------------------------
907
+ // P2: runParallelMode
908
+ // -------------------------------------------------------------------
909
+
910
+ async function runParallelMode(
911
+ params: Record<string, unknown>,
912
+ signal: AbortSignal | undefined,
913
+ onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
914
+ ctx: ExtensionContext,
915
+ ): Promise<ToolResult<SubagentResult | ParallelDetails | undefined>> {
916
+ const taskList = params.tasks as SubTaskParams[];
917
+
918
+ // R5: Check session cost limit before spawning
919
+ const perTaskEstimate =
920
+ state.config.perTaskCostEstimate > 0
921
+ ? state.config.perTaskCostEstimate
922
+ : 0.05;
923
+ if (state.checkCostLimit(perTaskEstimate * taskList.length, ctx)) {
924
+ const currentTotal = state.getSessionTotalCost(ctx);
925
+ const limit = state.config.sessionCostLimit;
926
+ log.warn("Parallel delegation rejected: session cost limit reached", {
927
+ currentTotal,
928
+ estimatedCost: perTaskEstimate * taskList.length,
929
+ limit,
930
+ });
931
+ return {
932
+ content: [
933
+ {
934
+ type: "text" as const,
935
+ text:
936
+ `Cannot delegate: session cost limit reached ` +
937
+ `($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
938
+ `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
939
+ },
940
+ ],
941
+ details: undefined,
942
+ isError: true,
943
+ };
944
+ }
945
+
946
+ // Reject delegation if recursion depth exceeds configured max
947
+ const currentDepth = getCurrentDepth();
948
+ if (currentDepth >= state.config.maxSubagentDepth) {
949
+ log.warn("Parallel delegation rejected: max depth reached", {
950
+ currentDepth,
951
+ maxDepth: state.config.maxSubagentDepth,
952
+ });
953
+ return {
954
+ content: [
955
+ {
956
+ type: "text" as const,
957
+ text:
958
+ `Cannot delegate further: subagent recursion depth limit reached ` +
959
+ `(depth ${currentDepth}/${state.config.maxSubagentDepth}). ` +
960
+ `Subagents can delegate up to ${state.config.maxSubagentDepth} levels deep (configurable via /brl-subagent depth). Complete the remaining work directly.`,
961
+ },
962
+ ],
963
+ details: undefined,
964
+ isError: true,
965
+ };
966
+ }
967
+
968
+ // Resolve global params once
969
+ const globalParams = resolveSubagentParams(
970
+ params as {
971
+ task: string;
972
+ label?: string;
973
+ preset?: string;
974
+ systemPrompt?: string;
975
+ inheritSystemPrompt?: boolean;
976
+ thinkingLevel?: string;
977
+ outputFile?: string;
978
+ timeout?: number;
979
+ cwd?: string;
980
+ tools?: string[];
981
+ excludeTools?: string[];
982
+ noBuiltinTools?: boolean;
983
+ gitMode?: string;
984
+ },
985
+ state,
986
+ ctx,
987
+ log,
988
+ );
989
+
990
+ // Validate CWD once
991
+ const cwdResult = validateCwd(globalParams.effectiveCwd, ctx.cwd);
992
+ if (!cwdResult.ok) {
993
+ return {
994
+ content: [
995
+ { type: "text" as const, text: `Invalid cwd: ${cwdResult.error}` },
996
+ ],
997
+ details: undefined,
998
+ isError: true,
999
+ };
1000
+ }
1001
+ const resolvedCwd = cwdResult.value;
1002
+
1003
+ // Pre-flight checks — fail fast before consuming resources
1004
+ const pfResult = preflightCheck(resolvedCwd);
1005
+ if (!pfResult.ok) {
1006
+ log.warn("Parallel pre-flight check failed", { error: pfResult.error });
1007
+ return {
1008
+ content: [
1009
+ {
1010
+ type: "text" as const,
1011
+ text: `Pre-flight check failed: ${pfResult.error}`,
1012
+ },
1013
+ ],
1014
+ details: undefined,
1015
+ isError: true,
1016
+ };
1017
+ }
1018
+
1019
+ // H1: Pre-task validation at mode entry — deterministic check that
1020
+ // tools/thinking match the task (mirrors single/background modes, issue
1021
+ // #32). Parallel top-level tasks are empty (modeCount forbids task+tasks),
1022
+ // so keyword warnings skip on empty text and only the hard
1023
+ // outputFile-vs-write conflict applies (issue #34). The mode-level
1024
+ // outputFile on globalParams is the one validated; per-step outputFiles
1025
+ // are future work (issue #3).
1026
+ const validation = validatePreTask({
1027
+ task: globalParams.task,
1028
+ toolOptions: globalParams.toolOptions,
1029
+ thinkingLevel: globalParams.thinkingLevel,
1030
+ gitMode: globalParams.resolvedGitMode,
1031
+ outputFile: globalParams.outputFile,
1032
+ });
1033
+ if (validation.warnings.length > 0) {
1034
+ log.warn("Parallel pre-task validation warnings", { warnings: validation.warnings });
1035
+ }
1036
+ if (!validation.valid) {
1037
+ const errText = validation.errors.join("; ");
1038
+ log.warn("Parallel pre-task validation failed", { errors: validation.errors });
1039
+ return {
1040
+ content: [{ type: "text" as const, text: errText }],
1041
+ details: undefined,
1042
+ isError: true,
1043
+ };
1044
+ }
1045
+
1046
+ // Resolve model once (per-call top-level model override beats preset)
1047
+ const modelResult = resolveSubagentModel(ctx, globalParams.resolvedPreset, params.model as string | undefined);
1048
+ if (!modelResult.ok) return modelResult.error;
1049
+ const subagentModel = modelResult.model;
1050
+
1051
+ const modelStr = `${subagentModel.provider}/${subagentModel.id}`.trim();
1052
+ if (!modelStr || modelStr === "/") {
1053
+ log.warn("Model string is empty after resolution", { model: subagentModel });
1054
+ return {
1055
+ content: [
1056
+ {
1057
+ type: "text" as const,
1058
+ text:
1059
+ "Subagent model is not configured. " +
1060
+ "Use /brl-subagent to set a model, or ensure your current session has a valid model.",
1061
+ },
1062
+ ],
1063
+ details: undefined,
1064
+ isError: true,
1065
+ };
1066
+ }
1067
+
1068
+ // R1: Circuit breaker check — reject if circuit is open
1069
+ const circuitCheck = state.checkCircuit();
1070
+ if (circuitCheck.isOpen) {
1071
+ return {
1072
+ content: [
1073
+ { type: "text" as const, text: circuitCheck.message! },
1074
+ ],
1075
+ details: undefined,
1076
+ isError: true,
1077
+ };
1078
+ }
1079
+
1080
+ // Build base prompt once
1081
+ const basePrompt = ctx.getSystemPrompt();
1082
+
1083
+ // Results array — final positions match taskList indices
1084
+ const results: SubTaskResult[] = [];
1085
+ let completedCount = 0;
1086
+
1087
+ // E10: Intercom for subagent-to-subagent messaging
1088
+ const intercom = new Intercom();
1089
+
1090
+ // Individual task runner (captures merged params, runs subagent)
1091
+ const runTask = async (
1092
+ index: number,
1093
+ merged: ReturnType<typeof mergeSubTaskParams>,
1094
+ ): Promise<boolean> => {
1095
+
1096
+ // C3: Resolve this step's model override (step.model > global resolved model)
1097
+ const stepModel = resolveStepModel(ctx, merged.model, subagentModel);
1098
+
1099
+ // Build system prompt for this task
1100
+ const subagentPrompt = buildSubagentPrompt(
1101
+ basePrompt,
1102
+ merged.inheritSP,
1103
+ merged.customSP,
1104
+ merged.outputFile,
1105
+ merged.toolOptions?.tools,
1106
+ globalParams.resolvedPreset?.promptGuideline,
1107
+ );
1108
+
1109
+ // Create SubTaskResult
1110
+ const subTaskResult: SubTaskResult = {
1111
+ task: merged.task,
1112
+ label: merged.label,
1113
+ exitCode: 0,
1114
+ messages: [],
1115
+ stderr: "",
1116
+ usage: { ...EMPTY_USAGE },
1117
+ };
1118
+
1119
+ // E10: Register subagent ID for intercom
1120
+ const subagentId = merged.label ?? `parallel-${index}`;
1121
+ intercom.register(subagentId);
1122
+
1123
+ // Issue #119: per-subtask run entry — created at spawn so the
1124
+ // monitor drill-in shows per-unit priority and a post-hoc audit
1125
+ // trail exists per subtask (mirrors single-mode run persistence).
1126
+ const { runId, run } = createUnitRun(merged, stepModel, parallelPriority, globalParams.resolvedPreset?.name);
1127
+ state.persistRun(pi, run);
1128
+
1129
+ // R2: Prune old run entries if history exceeds limit
1130
+ pruneHistoryIfNeeded(state, ctx, log);
1131
+
1132
+ // Register for live monitor — parallel subtasks now appear in the
1133
+ // drill-in with their per-unit priority (issue #119).
1134
+ registerLiveRun(state, run, ctx);
1135
+
1136
+ log.debug("Parallel subtask run registered", { runId, index });
1137
+
1138
+ // Emit initial progress
1139
+ const modeInfo = describePromptMode(
1140
+ merged.inheritSP,
1141
+ Boolean(merged.customSP),
1142
+ );
1143
+ onUpdate?.({
1144
+ content: [
1145
+ {
1146
+ type: "text" as const,
1147
+ text: `Parallel task ${index + 1}/${taskList.length} (${modeInfo})...`,
1148
+ },
1149
+ ],
1150
+ details: {
1151
+ messages: [],
1152
+ usage: { ...EMPTY_USAGE },
1153
+ exitCode: -1,
1154
+ stderr: "",
1155
+ },
1156
+ });
1157
+
1158
+ // Wrap onUpdate for per-task progress — also feed the live monitor so
1159
+ // the drill-in streams output instead of showing "waiting for first
1160
+ // output…" for the whole run (mirrors single mode's liveOnUpdate).
1161
+ const taskOnUpdate = makeLiveOnUpdate(state, runId, onUpdate);
1162
+
1163
+ // Run subagent — the post-spawn body is crash-protected (issue #119
1164
+ // R1, C1 fix): a throw here (e.g. runner writeToTempFile) must
1165
+ // finalize the run entry and release the live monitor, or the entry
1166
+ // stays 'running' forever and sweepStaleLiveSubagents cannot reclaim
1167
+ // the live ghost (grace logic treats a 'running' record as live).
1168
+ try {
1169
+ const result = await runSubagent(
1170
+ resolvedCwd,
1171
+ subagentPrompt,
1172
+ stepModel,
1173
+ merged.thinkingLevel,
1174
+ merged.task,
1175
+ signal,
1176
+ taskOnUpdate,
1177
+ merged.toolOptions,
1178
+ merged.timeout,
1179
+ getFinalOutput,
1180
+ log,
1181
+ currentDepth + 1,
1182
+ intercom,
1183
+ subagentId,
1184
+ );
1185
+
1186
+ // Fill SubTaskResult
1187
+ subTaskResult.exitCode = result.exitCode;
1188
+ subTaskResult.messages = result.messages;
1189
+ subTaskResult.stderr = result.stderr;
1190
+ subTaskResult.usage = result.usage;
1191
+ subTaskResult.model = result.model;
1192
+ subTaskResult.stopReason = result.stopReason;
1193
+ subTaskResult.errorMessage = result.errorMessage;
1194
+ subTaskResult.errorCategory = classifyError(result);
1195
+ subTaskResult.gitBranch = result.gitBranch;
1196
+ subTaskResult.gitDiff = result.gitDiff;
1197
+
1198
+ results[index] = subTaskResult;
1199
+ completedCount++;
1200
+
1201
+ // Issue #119: finalize the per-subtask run entry — status, duration,
1202
+ // cost, tokens and sanitized output land on the entry (mirrors
1203
+ // single-mode finalization; result carries the SubagentResult shape
1204
+ // finalizeRunRecord expects, errorCategory included).
1205
+ finalizeUnitRun(state, pi, ctx, run, result, log);
1206
+ } catch (err) {
1207
+ // Issue #119 R1 (C1 fix): mirror the single-mode crash catch — a
1208
+ // post-spawn throw must finalize the entry as failed and release
1209
+ // the live monitor, or the persisted entry stays 'running' and
1210
+ // the live drill-in loops forever. finalizeLiveSubagent is
1211
+ // idempotent; the rethrow keeps Promise.allSettled semantics (the
1212
+ // caller's finally still releases the concurrency slot).
1213
+ finalizeUnitRunCrash(state, pi, run, err, "Parallel subtask", resolvedCwd, log, {
1214
+ index,
1215
+ });
1216
+ }
1217
+
1218
+ // Emit progress update
1219
+ const completed = results.filter(Boolean).length;
1220
+ const succeeded = results.filter(
1221
+ (r) => r && r.exitCode === 0,
1222
+ ).length;
1223
+ const failed = completed - succeeded;
1224
+
1225
+ const partialDetails: ParallelDetails = {
1226
+ mode: "parallel",
1227
+ results: results.filter((r) => r !== undefined),
1228
+ totalInput: results.reduce(
1229
+ (s, r) => s + (r?.usage.input ?? 0),
1230
+ 0,
1231
+ ),
1232
+ totalOutput: results.reduce(
1233
+ (s, r) => s + (r?.usage.output ?? 0),
1234
+ 0,
1235
+ ),
1236
+ totalCost: results.reduce(
1237
+ (s, r) => s + (r?.usage.cost ?? 0),
1238
+ 0,
1239
+ ),
1240
+ totalTurns: results.reduce(
1241
+ (s, r) => s + (r?.usage.turns ?? 0),
1242
+ 0,
1243
+ ),
1244
+ succeeded,
1245
+ failed,
1246
+ };
1247
+
1248
+ onUpdate?.({
1249
+ content: [
1250
+ {
1251
+ type: "text" as const,
1252
+ text: `Parallel: ${completed}/${taskList.length} completed (${succeeded} succeeded, ${failed} failed)`,
1253
+ },
1254
+ ],
1255
+ details: partialDetails,
1256
+ });
1257
+
1258
+ // Issue #137: return the unit's real success so the caller's slot
1259
+ // release is honest — the status bar's "N failed" count reflects the
1260
+ // true outcome (same check as the aggregate chainSuccess).
1261
+ return !isSubagentError(subTaskResult);
1262
+ };
1263
+
1264
+ // Resolve priority — the FALLBACK for per-unit priority (issue #114): a
1265
+ // task-level priority on tasks[] items wins per slot; this is the floor.
1266
+ const parallelPriority: Priority = (
1267
+ params.priority && ["critical", "high", "normal", "low"].includes(params.priority as string)
1268
+ ? (params.priority as Priority)
1269
+ : DEFAULT_PRIORITY
1270
+ );
1271
+
1272
+ // Launch all tasks concurrently using acquireSlot for natural concurrency limiting
1273
+ const promises = taskList.map(async (_, index) => {
1274
+ // Issue #114: per-unit priority wins the slot — the step's merged
1275
+ // priority (if any) beats the call-level parallelPriority fallback.
1276
+ const merged = mergeSubTaskParams(globalParams, taskList[index]);
1277
+ const acquired = await acquireSlot(state, ctx, signal, merged.priority ?? parallelPriority);
1278
+ if (!acquired) {
1279
+ results[index] = {
1280
+ task: taskList[index].task,
1281
+ exitCode: 1,
1282
+ messages: [],
1283
+ stderr: "",
1284
+ usage: { ...EMPTY_USAGE },
1285
+ errorMessage:
1286
+ "Cancelled while waiting for concurrency slot",
1287
+ errorCategory: "aborted",
1288
+ };
1289
+ completedCount++;
1290
+ return;
1291
+ }
1292
+ let taskSuccess = false;
1293
+ try {
1294
+ taskSuccess = await runTask(index, merged);
1295
+ } finally {
1296
+ // Issue #137: release with the unit's real success — the status
1297
+ // bar's "N failed" count reflects the true outcome.
1298
+ releaseSlot(state, taskSuccess, ctx);
1299
+ }
1300
+ });
1301
+
1302
+ await Promise.allSettled(promises);
1303
+
1304
+ // Compute final aggregates
1305
+ const finalResults = results.filter(Boolean);
1306
+ const totalInput = finalResults.reduce(
1307
+ (s, r) => s + r.usage.input,
1308
+ 0,
1309
+ );
1310
+ const totalOutput = finalResults.reduce(
1311
+ (s, r) => s + r.usage.output,
1312
+ 0,
1313
+ );
1314
+ const totalCost = finalResults.reduce(
1315
+ (s, r) => s + r.usage.cost,
1316
+ 0,
1317
+ );
1318
+ const totalTurns = finalResults.reduce(
1319
+ (s, r) => s + r.usage.turns,
1320
+ 0,
1321
+ );
1322
+ const succeeded = finalResults.filter((r) => r.exitCode === 0).length;
1323
+ const failed = finalResults.length - succeeded;
1324
+
1325
+ const parallelDetails: ParallelDetails = {
1326
+ mode: "parallel",
1327
+ results: finalResults,
1328
+ totalInput,
1329
+ totalOutput,
1330
+ totalCost,
1331
+ totalTurns,
1332
+ succeeded,
1333
+ failed,
1334
+ };
1335
+
1336
+ // R1: Record circuit breaker outcome
1337
+ if (failed === 0) {
1338
+ state.recordSuccess();
1339
+ } else {
1340
+ state.recordFailure();
1341
+ }
1342
+
1343
+ log.info("Parallel execution completed", {
1344
+ totalTasks: taskList.length,
1345
+ succeeded,
1346
+ failed,
1347
+ totalInput,
1348
+ totalOutput,
1349
+ totalCost,
1350
+ });
1351
+
1352
+ return {
1353
+ content: [
1354
+ { type: "text" as const, text: JSON.stringify(parallelDetails, null, 2) },
1355
+ ],
1356
+ details: parallelDetails,
1357
+ };
1358
+ }
1359
+
1360
+ // -------------------------------------------------------------------
1361
+ // P10: runGraphMode
1362
+ // -------------------------------------------------------------------
1363
+
1364
+ async function runGraphMode(
1365
+ params: Record<string, unknown>,
1366
+ signal: AbortSignal | undefined,
1367
+ onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
1368
+ ctx: ExtensionContext,
1369
+ ): Promise<ToolResult<SubagentResult | GraphDetails | undefined>> {
1370
+ const graphTasks = params.graph as GraphTask[];
1371
+
1372
+ // Issue #133: dispatch-start timestamp — the aggregate run entry's
1373
+ // startedAt (captured once, at mode entry, not at completion).
1374
+ const graphModeStartedAt = new Date().toISOString();
1375
+
1376
+ // Validate graph
1377
+ const errors = validateGraph(graphTasks);
1378
+ if (errors.length > 0) {
1379
+ return {
1380
+ content: [
1381
+ {
1382
+ type: "text" as const,
1383
+ text: `Graph validation failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`,
1384
+ },
1385
+ ],
1386
+ details: undefined,
1387
+ isError: true,
1388
+ };
1389
+ }
1390
+
1391
+ // R5: Check session cost limit before spawning
1392
+ const perTaskEstimate =
1393
+ state.config.perTaskCostEstimate > 0
1394
+ ? state.config.perTaskCostEstimate
1395
+ : 0.05;
1396
+ if (state.checkCostLimit(perTaskEstimate * graphTasks.length, ctx)) {
1397
+ const currentTotal = state.getSessionTotalCost(ctx);
1398
+ const limit = state.config.sessionCostLimit;
1399
+ log.warn("Graph delegation rejected: session cost limit reached", {
1400
+ currentTotal,
1401
+ estimatedCost: perTaskEstimate * graphTasks.length,
1402
+ limit,
1403
+ });
1404
+ return {
1405
+ content: [
1406
+ {
1407
+ type: "text" as const,
1408
+ text:
1409
+ `Cannot delegate: session cost limit reached ` +
1410
+ `($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
1411
+ `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
1412
+ },
1413
+ ],
1414
+ details: undefined,
1415
+ isError: true,
1416
+ };
1417
+ }
1418
+
1419
+ // Reject delegation if recursion depth exceeds configured max
1420
+ const currentDepth = getCurrentDepth();
1421
+ if (currentDepth >= state.config.maxSubagentDepth) {
1422
+ log.warn("Graph delegation rejected: max depth reached", {
1423
+ currentDepth,
1424
+ maxDepth: state.config.maxSubagentDepth,
1425
+ });
1426
+ return {
1427
+ content: [
1428
+ {
1429
+ type: "text" as const,
1430
+ text:
1431
+ `Cannot delegate further: subagent recursion depth limit reached ` +
1432
+ `(depth ${currentDepth}/${state.config.maxSubagentDepth}). ` +
1433
+ `Subagents can delegate up to ${state.config.maxSubagentDepth} levels deep (configurable via /brl-subagent depth). Complete the remaining work directly.`,
1434
+ },
1435
+ ],
1436
+ details: undefined,
1437
+ isError: true,
1438
+ };
1439
+ }
1440
+
1441
+ // Resolve global params once
1442
+ const globalParams = resolveSubagentParams(
1443
+ params as {
1444
+ task: string;
1445
+ label?: string;
1446
+ preset?: string;
1447
+ systemPrompt?: string;
1448
+ inheritSystemPrompt?: boolean;
1449
+ thinkingLevel?: string;
1450
+ outputFile?: string;
1451
+ timeout?: number;
1452
+ cwd?: string;
1453
+ tools?: string[];
1454
+ excludeTools?: string[];
1455
+ noBuiltinTools?: boolean;
1456
+ gitMode?: string;
1457
+ approvalMode?: string;
1458
+ },
1459
+ state,
1460
+ ctx,
1461
+ log,
1462
+ );
1463
+
1464
+ // Validate CWD once
1465
+ const cwdResult = validateCwd(globalParams.effectiveCwd, ctx.cwd);
1466
+ if (!cwdResult.ok) {
1467
+ return {
1468
+ content: [
1469
+ { type: "text" as const, text: `Invalid cwd: ${cwdResult.error}` },
1470
+ ],
1471
+ details: undefined,
1472
+ isError: true,
1473
+ };
1474
+ }
1475
+ const resolvedCwd = cwdResult.value;
1476
+
1477
+ // Pre-flight checks
1478
+ const pfResult = preflightCheck(resolvedCwd);
1479
+ if (!pfResult.ok) {
1480
+ log.warn("Graph pre-flight check failed", { error: pfResult.error });
1481
+ return {
1482
+ content: [
1483
+ {
1484
+ type: "text" as const,
1485
+ text: `Pre-flight check failed: ${pfResult.error}`,
1486
+ },
1487
+ ],
1488
+ details: undefined,
1489
+ isError: true,
1490
+ };
1491
+ }
1492
+
1493
+ // H1: Pre-task validation at mode entry — deterministic check that
1494
+ // tools/thinking match the task (mirrors single/background modes, issue
1495
+ // #32). Graph top-level tasks are empty (modeCount forbids task+graph),
1496
+ // so keyword warnings skip on empty text and only the hard
1497
+ // outputFile-vs-write conflict applies (issue #34). The mode-level
1498
+ // outputFile on globalParams is the one validated; per-step outputFiles
1499
+ // are future work (issue #3).
1500
+ const validation = validatePreTask({
1501
+ task: globalParams.task,
1502
+ toolOptions: globalParams.toolOptions,
1503
+ thinkingLevel: globalParams.thinkingLevel,
1504
+ gitMode: globalParams.resolvedGitMode,
1505
+ outputFile: globalParams.outputFile,
1506
+ });
1507
+ if (validation.warnings.length > 0) {
1508
+ log.warn("Graph pre-task validation warnings", { warnings: validation.warnings });
1509
+ }
1510
+ if (!validation.valid) {
1511
+ const errText = validation.errors.join("; ");
1512
+ log.warn("Graph pre-task validation failed", { errors: validation.errors });
1513
+ return {
1514
+ content: [{ type: "text" as const, text: errText }],
1515
+ details: undefined,
1516
+ isError: true,
1517
+ };
1518
+ }
1519
+
1520
+ // Resolve model once (per-call top-level model override beats preset)
1521
+ const modelResult = resolveSubagentModel(ctx, globalParams.resolvedPreset, params.model as string | undefined);
1522
+ if (!modelResult.ok) return modelResult.error;
1523
+ const subagentModel = modelResult.model;
1524
+
1525
+ const modelStr = `${subagentModel.provider}/${subagentModel.id}`.trim();
1526
+ if (!modelStr || modelStr === "/") {
1527
+ log.warn("Model string is empty after resolution", { model: subagentModel });
1528
+ return {
1529
+ content: [
1530
+ {
1531
+ type: "text" as const,
1532
+ text:
1533
+ "Subagent model is not configured. " +
1534
+ "Use /brl-subagent to set a model, or ensure your current session has a valid model.",
1535
+ },
1536
+ ],
1537
+ details: undefined,
1538
+ isError: true,
1539
+ };
1540
+ }
1541
+
1542
+ // R1: Circuit breaker check
1543
+ const circuitCheck = state.checkCircuit();
1544
+ if (circuitCheck.isOpen) {
1545
+ return {
1546
+ content: [
1547
+ { type: "text" as const, text: circuitCheck.message! },
1548
+ ],
1549
+ details: undefined,
1550
+ isError: true,
1551
+ };
1552
+ }
1553
+
1554
+ // Resolve priority — the FALLBACK for per-unit priority (issue #114): a
1555
+ // task-level priority on graph[] items wins per slot; this is the floor.
1556
+ const graphPriority: Priority = (
1557
+ params.priority && ["critical", "high", "normal", "low"].includes(params.priority as string)
1558
+ ? (params.priority as Priority)
1559
+ : DEFAULT_PRIORITY
1560
+ );
1561
+
1562
+ // Topological sort → execution waves
1563
+ const sortResult = topologicalSort(graphTasks);
1564
+ if (!sortResult.ok) {
1565
+ return {
1566
+ content: [
1567
+ { type: "text" as const, text: `Graph scheduling failed: ${sortResult.error}` },
1568
+ ],
1569
+ details: undefined,
1570
+ isError: true,
1571
+ };
1572
+ }
1573
+ const waves = sortResult.waves;
1574
+
1575
+ // Build base prompt once
1576
+ const basePrompt = ctx.getSystemPrompt();
1577
+
1578
+ // Results map: task id → SubTaskResult (populated as waves complete)
1579
+ const resultMap = new Map<string, SubTaskResult>();
1580
+ const allWaves: GraphWave[] = [];
1581
+
1582
+ // E10: Intercom for subagent-to-subagent messaging
1583
+ const intercom = new Intercom();
1584
+
1585
+ // Issue #133: global node counter — node positions are reported against
1586
+ // the WHOLE dispatch (dispatch-wide ordinal), not the current wave; a
1587
+ // later wave's first node is its true global position (e.g. node 3/5),
1588
+ // not "node 1/5". Incremented once per node as each wave's nodes run.
1589
+ let globalNodeCounter = 0;
1590
+ let chainSuccess = false;
1591
+ try {
1592
+ for (let w = 0; w < waves.length; w++) {
1593
+ const wave = waves[w];
1594
+ const waveIndex = w + 1;
1595
+ const isParallel = wave.length > 1;
1596
+
1597
+ // Issue #133: status-bar breakdown — wave-level context set at
1598
+ // each wave start (nodes within the wave refine it as they start).
1599
+ state.modeContext = {
1600
+ kind: "graph",
1601
+ wave: waveIndex,
1602
+ totalWaves: waves.length,
1603
+ node: 0,
1604
+ totalNodes: graphTasks.length,
1605
+ };
1606
+
1607
+ // Emit initial progress
1608
+ onUpdate?.({
1609
+ content: [
1610
+ {
1611
+ type: "text" as const,
1612
+ text: `Graph wave ${waveIndex}/${waves.length} (${wave.length} task${wave.length > 1 ? "s" : ""})...`,
1613
+ },
1614
+ ],
1615
+ details: {
1616
+ messages: [],
1617
+ usage: { ...EMPTY_USAGE },
1618
+ exitCode: -1,
1619
+ stderr: "",
1620
+ },
1621
+ });
1622
+
1623
+ // Run all tasks in this wave concurrently
1624
+ const wavePromises = wave.map(async (graphTask, nodeIndex) => {
1625
+ // Issue #133: capture this node's dispatch-wide ordinal before any
1626
+ // sibling node advances the counter (same-wave siblings run
1627
+ // concurrently, so the value must be pinned here).
1628
+ const globalNode = ++globalNodeCounter;
1629
+ const subTaskParams: SubTaskParams = {
1630
+ task: graphTask.task,
1631
+ label: graphTask.label,
1632
+ model: graphTask.model,
1633
+ thinkingLevel: graphTask.thinkingLevel,
1634
+ priority: graphTask.priority,
1635
+ cwd: graphTask.cwd,
1636
+ timeout: graphTask.timeout,
1637
+ outputFile: graphTask.outputFile,
1638
+ tools: graphTask.tools,
1639
+ excludeTools: graphTask.excludeTools,
1640
+ noBuiltinTools: graphTask.noBuiltinTools,
1641
+ systemPrompt: graphTask.systemPrompt,
1642
+ inheritSystemPrompt: graphTask.inheritSystemPrompt,
1643
+ };
1644
+
1645
+ const merged = mergeSubTaskParams(globalParams, subTaskParams);
1646
+
1647
+ // C3: Resolve this step's model override (step.model > global resolved model)
1648
+ const stepModel = resolveStepModel(ctx, merged.model, subagentModel);
1649
+
1650
+ // Substitute {id} placeholders with previous outputs
1651
+ merged.task = merged.task.replace(GRAPH_OUTPUT_PLACEHOLDER_RE, (_match, id) => {
1652
+ const depResult = resultMap.get(id);
1653
+ if (!depResult) return _match; // Leave unchanged if dep not found
1654
+ return getFinalOutput(depResult.messages);
1655
+ });
1656
+
1657
+ // Build system prompt
1658
+ const subagentPrompt = buildSubagentPrompt(
1659
+ basePrompt,
1660
+ merged.inheritSP,
1661
+ merged.customSP,
1662
+ merged.outputFile,
1663
+ merged.toolOptions?.tools,
1664
+ globalParams.resolvedPreset?.promptGuideline,
1665
+ );
1666
+
1667
+ // Acquire concurrency slot — issue #114: per-unit priority wins the
1668
+ // slot; the call-level graphPriority is the fallback.
1669
+ const acquired = await acquireSlot(state, ctx, signal, merged.priority ?? graphPriority);
1670
+ if (!acquired) {
1671
+ return {
1672
+ id: graphTask.id,
1673
+ result: {
1674
+ task: merged.task,
1675
+ label: merged.label,
1676
+ exitCode: 1,
1677
+ messages: [],
1678
+ stderr: "",
1679
+ usage: { ...EMPTY_USAGE },
1680
+ errorMessage: "Cancelled while waiting for concurrency slot",
1681
+ errorCategory: "aborted" as const,
1682
+ } satisfies SubTaskResult,
1683
+ };
1684
+ }
1685
+
1686
+ const subagentId = graphTask.id;
1687
+ intercom.register(subagentId);
1688
+
1689
+ // Issue #133: status-bar breakdown — the node's dispatch-wide
1690
+ // position (globalNode, not the wave-local nodeIndex) so a later
1691
+ // wave reports its true global ordinal.
1692
+ state.modeContext = {
1693
+ kind: "graph",
1694
+ wave: waveIndex,
1695
+ totalWaves: waves.length,
1696
+ node: globalNode,
1697
+ totalNodes: graphTasks.length,
1698
+ };
1699
+
1700
+ // Issue #133: per-node run entry — mirrors runParallelMode's
1701
+ // per-unit block (issue #119): the runId doubles as the live id,
1702
+ // so sweepStaleLiveSubagents finds a 'running' record for every
1703
+ // foreground live entry (the #130 invariant gap that swept
1704
+ // graph/chain entries immediately).
1705
+ const { runId, run } = createUnitRun(merged, stepModel, graphPriority, globalParams.resolvedPreset?.name);
1706
+ state.persistRun(pi, run);
1707
+
1708
+ // R2: Prune old run entries if history exceeds limit
1709
+ pruneHistoryIfNeeded(state, ctx, log);
1710
+
1711
+ // Register for live monitor — graph nodes now appear in the
1712
+ // drill-in under the runId with their per-unit priority
1713
+ // (issue #133 single identity, mirrors parallel mode's issue
1714
+ // #119 registration). A FRESH runId per node: subagentSessions
1715
+ // is host-global, so raw graph-task ids would collide across
1716
+ // concurrent graph runs (the intercom namespace is per-run and
1717
+ // stays untouched).
1718
+ registerLiveRun(state, run, ctx);
1719
+
1720
+ let nodeSuccess = false;
1721
+ try {
1722
+ // Wrap onUpdate for per-node progress — also feed the live
1723
+ // monitor so the drill-in streams output instead of showing
1724
+ // "waiting for first output…" (mirrors parallel mode's
1725
+ // taskOnUpdate).
1726
+ const stepOnUpdate = makeLiveOnUpdate(state, runId, onUpdate);
1727
+
1728
+ const result = await runSubagent(
1729
+ resolvedCwd,
1730
+ subagentPrompt,
1731
+ stepModel,
1732
+ merged.thinkingLevel,
1733
+ merged.task,
1734
+ signal,
1735
+ stepOnUpdate,
1736
+ merged.toolOptions,
1737
+ merged.timeout,
1738
+ getFinalOutput,
1739
+ log,
1740
+ currentDepth + 1,
1741
+ intercom,
1742
+ subagentId,
1743
+ );
1744
+
1745
+ const subTaskResult: SubTaskResult = {
1746
+ task: merged.task,
1747
+ label: merged.label,
1748
+ exitCode: result.exitCode,
1749
+ messages: result.messages,
1750
+ stderr: result.stderr,
1751
+ usage: result.usage,
1752
+ model: result.model,
1753
+ stopReason: result.stopReason,
1754
+ errorMessage: result.errorMessage,
1755
+ errorCategory: classifyError(result),
1756
+ gitBranch: result.gitBranch,
1757
+ gitDiff: result.gitDiff,
1758
+ };
1759
+
1760
+ // Issue #133: finalize the per-node run entry — status, duration,
1761
+ // cost, tokens and sanitized output land on the entry (mirrors
1762
+ // parallel's issue #119 finalize; result carries the
1763
+ // SubagentResult shape finalizeRunRecord expects, errorCategory
1764
+ // included).
1765
+ finalizeUnitRun(state, pi, ctx, run, result, log);
1766
+ // Issue #137: the node's real success — the status bar's
1767
+ // "N failed" count reflects the true outcome (same check as
1768
+ // the aggregate chainSuccess).
1769
+ nodeSuccess = !isSubagentError(result);
1770
+
1771
+ return { id: graphTask.id, result: subTaskResult };
1772
+ } catch (err) {
1773
+ // Issue #133 (mirrors the #119 R1/C1 discipline): crash-protected
1774
+ // — a post-spawn throw must finalize the run entry as failed AND
1775
+ // release the live entry, or the persisted record stays
1776
+ // 'running' forever and the sweep treats the ghost as live.
1777
+ // finalizeLiveSubagent is idempotent; the rethrow keeps
1778
+ // Promise.allSettled semantics (a failing node must NOT cancel
1779
+ // sibling nodes in the wave).
1780
+ finalizeUnitRunCrash(state, pi, run, err, "Graph node", resolvedCwd, log, {
1781
+ id: graphTask.id,
1782
+ });
1783
+ } finally {
1784
+ releaseSlot(state, nodeSuccess, ctx);
1785
+ }
1786
+ });
1787
+
1788
+ const waveResults = await Promise.allSettled(wavePromises);
1789
+ const graphWaveTasks: SubTaskResult[] = [];
1790
+
1791
+ for (const settled of waveResults) {
1792
+ if (settled.status === "fulfilled") {
1793
+ resultMap.set(settled.value.id, settled.value.result);
1794
+ graphWaveTasks.push(settled.value.result);
1795
+ } else {
1796
+ log.error("Graph wave task rejected", { error: String(settled.reason) });
1797
+ }
1798
+ }
1799
+
1800
+ const graphWave: GraphWave = {
1801
+ waveIndex,
1802
+ tasks: graphWaveTasks,
1803
+ parallel: isParallel,
1804
+ };
1805
+ allWaves.push(graphWave);
1806
+
1807
+ // Emit progress update
1808
+ onUpdate?.({
1809
+ details: undefined,
1810
+ content: [
1811
+ {
1812
+ type: "text" as const,
1813
+ text: `Graph wave ${waveIndex}/${waves.length} completed`,
1814
+ },
1815
+ ],
1816
+ });
1817
+ }
1818
+
1819
+ // Compute final aggregates
1820
+ const allResults = allWaves.flatMap((w) => w.tasks);
1821
+ const totalInput = allResults.reduce((s, r) => s + r.usage.input, 0);
1822
+ const totalOutput = allResults.reduce((s, r) => s + r.usage.output, 0);
1823
+ const totalCost = allResults.reduce((s, r) => s + r.usage.cost, 0);
1824
+ const totalTurns = allResults.reduce((s, r) => s + r.usage.turns, 0);
1825
+
1826
+ chainSuccess = allResults.every(
1827
+ (r) => r.exitCode === 0 && r.stopReason !== "error" && r.stopReason !== "aborted",
1828
+ );
1829
+
1830
+ const graphDetails: GraphDetails = {
1831
+ mode: "graph",
1832
+ waves: allWaves,
1833
+ totalInput,
1834
+ totalOutput,
1835
+ totalCost,
1836
+ totalTurns,
1837
+ };
1838
+
1839
+ // R1: Record circuit breaker outcome
1840
+ if (chainSuccess) {
1841
+ state.recordSuccess();
1842
+ } else {
1843
+ state.recordFailure();
1844
+ }
1845
+
1846
+ log.info("Graph completed", {
1847
+ totalTasks: graphTasks.length,
1848
+ waves: allWaves.length,
1849
+ totalInput,
1850
+ totalOutput,
1851
+ totalCost,
1852
+ });
1853
+
1854
+ // Issue #133: one aggregate run entry per graph dispatch — fresh id,
1855
+ // persisted ONCE as done (no live entry: the drill-in shows the
1856
+ // per-node entries while running; the aggregate is a post-hoc audit
1857
+ // summary). Fields come from the dispatch-level resolved values and
1858
+ // the aggregate totals; startedAt is the mode-entry timestamp.
1859
+ const graphAggregateRun: SubagentRun = {
1860
+ id: crypto.randomUUID(),
1861
+ task: `Graph dispatch: ${graphTasks.length} tasks in ${allWaves.length} waves`,
1862
+ label: globalParams.label ?? "graph",
1863
+ // Issue #133: persist the ACTUAL outcome — "failed" when any node
1864
+ // failed (chainSuccess === false), "done" otherwise.
1865
+ status: chainSuccess ? "done" : "failed",
1866
+ model: `${subagentModel.provider}/${subagentModel.id}`,
1867
+ thinkingLevel: globalParams.thinkingLevel,
1868
+ priority: graphPriority,
1869
+ startedAt: graphModeStartedAt,
1870
+ finishedAt: new Date().toISOString(),
1871
+ durationMs: Date.now() - new Date(graphModeStartedAt).getTime(),
1872
+ cost: totalCost,
1873
+ tokensIn: totalInput,
1874
+ tokensOut: totalOutput,
1875
+ outputSummary: capOutput(JSON.stringify(graphDetails, null, 2)),
1876
+ };
1877
+ state.persistRun(pi, graphAggregateRun);
1878
+
1879
+ return {
1880
+ content: [
1881
+ { type: "text" as const, text: JSON.stringify(graphDetails, null, 2) },
1882
+ ],
1883
+ details: graphDetails,
1884
+ };
1885
+ } catch (err) {
1886
+ // F7 (issue #65): err.message may embed absolute paths — sanitize
1887
+ // BEFORE it reaches the main agent's context (tool result content +
1888
+ // details.errorMessage). resolvedCwd is the validated subagent cwd.
1889
+ const result = buildCrashResult("Graph mode", err, resolvedCwd);
1890
+ log.error("Graph mode crashed", { error: result.details.errorMessage });
1891
+ return result;
1892
+ } finally {
1893
+ // Issue #133: clear the status-bar mode breakdown — the dispatch is
1894
+ // over (success or outer-catch crash).
1895
+ state.modeContext = undefined;
1896
+ releaseSlot(state, chainSuccess, ctx);
1897
+ }
1898
+ }
1899
+
1900
+ // -------------------------------------------------------------------
1901
+ // /brl-subagent command
1902
+ // -------------------------------------------------------------------
1903
+
1904
+ pi.registerCommand("brl-subagent", {
1905
+ description: "Configure subagent model and thinking level",
1906
+ getArgumentCompletions: (prefix: string) => {
1907
+ const options = [
1908
+ "history", "historyentries", "monitor", "dashboard", "preset", "templates", "retry", "completionnotify",
1909
+ ];
1910
+ const filtered = options.filter((o) => o.startsWith(prefix));
1911
+ return filtered.length > 0
1912
+ ? filtered.map((o) => ({ value: o, label: o }))
1913
+ : null;
1914
+ },
1915
+ handler: async (args, ctx) => {
1916
+ const trimmed = args?.trim();
1917
+
1918
+ const handlers: Record<string, () => Promise<void> | void> = {
1919
+ model: () => showModelSelector(ctx, state, applyConfig),
1920
+ thinking: () => showThinkingSelector(ctx, state, applyConfig),
1921
+ concurrency: () => showConcurrencyInput(ctx, state, applyConfig),
1922
+ depth: () => showDepthInput(ctx, state, applyConfig),
1923
+ approval: () => showApprovalModeSelector(ctx, state, applyConfig),
1924
+ completionnotify: () => showCompletionNotifySelector(ctx, state, applyConfig),
1925
+ costlimit: () => showCostLimitInput(ctx, state, applyConfig),
1926
+ reset: () => resetState(ctx),
1927
+ history: () => showRunHistory(ctx, state, () => state.persistState(pi)),
1928
+ historyentries: () => showHistoryEntriesInput(ctx, state, applyConfig),
1929
+ monitor: () => showMonitor(ctx, state),
1930
+ dashboard: () => showDashboard(ctx, state),
1931
+ preset: () => showPresetManager(ctx, state),
1932
+ templates: () => showTemplateManager(ctx, state),
1933
+ retry: () => showRetryMenu(ctx, state),
1934
+ "update-check": () => showUpdateCheckToggle(ctx, state, applyConfig),
1935
+ sla: () => showSLAConfig(ctx, state, applyConfig),
1936
+ "sla-stats": () => showSLAStats(ctx, state),
1937
+ };
1938
+
1939
+ if (trimmed && trimmed in handlers) {
1940
+ await handlers[trimmed]();
1941
+ } else if (trimmed?.startsWith("templates")) {
1942
+ await handlers.templates();
1943
+ } else if (trimmed?.startsWith("preset")) {
1944
+ await handlers.preset();
1945
+ } else {
1946
+ await showConfigMenu(ctx, state, handlers);
1947
+ }
1948
+ },
1949
+ });
1950
+
1951
+ // -------------------------------------------------------------------
1952
+ // delegate_task tool
1953
+ // -------------------------------------------------------------------
1954
+
1955
+ // B1: Load built-in presets once at registration to expose their tool
1956
+ // restrictions to the conductor before any delegate_task call happens.
1957
+ const registrationPresets = loadBuiltinPresets(path.join(__dirname, "..", "presets"));
1958
+ const presetRestrictionSummary = buildInventorySummary(registrationPresets, (p) => {
1959
+ const restricted = p.excludeTools?.length || p.tools?.length;
1960
+ if (!restricted) return p.name;
1961
+ return `${p.name} (${formatPresetRestriction(p)})`;
1962
+ });
1963
+
1964
+ // B1b: Load built-in templates once at registration to expose the built-in
1965
+ // inventory (name + purpose + ${param} slots) to the conductor before any
1966
+ // delegate_task call. Custom/user templates can't be enumerated statically,
1967
+ // so the summary covers built-ins only and points at /brl-subagent templates.
1968
+ const registrationTemplates = loadBuiltinTemplates(path.join(__dirname, "..", "templates"));
1969
+ const templateSummary = buildInventorySummary(registrationTemplates, formatTemplateSummaryItem);
1970
+
1971
+ pi.registerTool({
1972
+ name: "delegate_task",
1973
+ label: "Delegate Task",
1974
+ description: [
1975
+ "Delegate a task to the brl-subagent with isolated context.",
1976
+ "The subagent inherits your current system prompt and runs with",
1977
+ "its configured model and thinking level (set via /brl-subagent).",
1978
+ "If no subagent model is configured, your current model is used.",
1979
+ "The subagent reports what it did when complete.",
1980
+ "Optionally provide a custom systemPrompt and control inheritance via inheritSystemPrompt.",
1981
+ ].join(" "),
1982
+ promptSnippet:
1983
+ "Delegate tasks to a subagent for isolated, parallel or background work",
1984
+ promptGuidelines: [
1985
+ "Use delegate_task when the user asks you to hand off work to a subagent, or when a task would benefit from an isolated context window (e.g., deep investigation, parallel research, long-running analysis).",
1986
+ "The authoritative capability reference for delegation is the extension's AGENT.md — read it at ~/.pi/agent/extensions/brl-subagent/AGENT.md, or ./AGENT.md in the project root when present, when planning delegation-heavy work.",
1987
+ "The subagent inherits your system prompt and runs with its own model (configurable via /brl-subagent). It reports what it did when done.",
1988
+ "You can customize per-call via inheritSystemPrompt and systemPrompt: set inheritSystemPrompt: false to save context, provide a systemPrompt for custom instructions, or use both to add instructions on top of inheritance.",
1989
+ "Set thinkingLevel per call to match task complexity. The level is capped at the user's configured maximum. Map tasks to levels using this heuristic: off = file listing, grep, simple read. minimal = file diff, syntax check, find-and-replace. low = refactoring, test generation, documentation. medium = default — code review, debugging, moderate analysis. high = security audit, architecture review, complex debugging. xhigh = multi-step causal reasoning, research, novel problem solving. Default to 'off' or 'minimal' for trivial tasks — do not waste the user's budget.",
1990
+ "Use outputFile to have the subagent write full findings to disk and return only a structured summary — saves context tokens for large investigations.",
1991
+ "Set timeout (in ms) to limit how long a subagent can run. Useful for tasks that might hang or get stuck.",
1992
+ "Set cwd to override the subagent's working directory. Defaults to the current project directory.",
1993
+ "Set label to give the subagent a human-readable name (e.g., 'security-audit' or 'docs-review'). Labels appear in the status bar and tool call display.",
1994
+ `Use preset to apply a delegation configuration (built-in or custom via /brl-subagent preset). Preset values are defaults — explicit parameters override them. IMPORTANT: some presets restrict tools — e.g. outputFile requires the subagent's write tool, which security-auditor and code-reviewer exclude. Built-in presets: ${presetRestrictionSummary}. Custom presets are NOT listed here — inspect them via /brl-subagent preset before combining with outputFile or tool-dependent work. When combining a preset with outputFile or tool-dependent work, verify the preset allows the required tools.`,
1995
+ buildTemplateGuideline(templateSummary),
1996
+ "To retry a failed subagent, pass its run ID as retryRunId. The retried run uses the same task and parameters as the original. Parallel-origin entries retry as a single-subtask run carrying that subtask's task, label, and priority. Explicit parameters on this call override the original's. Use /brl-subagent retry to browse failed runs and get their IDs.",
1997
+ "Set retryOnTimeout: true to automatically retry a subagent that times out. Only retries once — the second timeout is treated as a final failure.",
1998
+ "Set background: true to run the subagent in the background without blocking. The tool returns immediately with an agent ID. Background runs wake the conductor with a structured completion message when they finish — do not poll: polling is only correct when completion notifications are disabled (completionNotify \"off\"); one status check as a stall check is legitimate.",
1999
+ "",
2000
+ "## Conductor Guardrails",
2001
+ "",
2002
+ "Before delegating, verify the subagent configuration matches the task:",
2003
+ "",
2004
+ "2. **Thinking level**: Match thinking level to task complexity: off/minimal for trivial tasks (file listing, grep), low for refactoring/docs, medium for code review/debugging, high for security audits/complex debugging, xhigh for multi-step reasoning/novel problems.",
2005
+ "3. **Git mode**: Use gitMode='branch' for tasks that create commits or PRs. Use gitMode='none' for read-only tasks.",
2006
+ "4. **Tools**: Verify the subagent has the tools it needs. If the task writes files, ensure write and edit are not excluded. If the task runs commands, ensure bash is not excluded.",
2007
+ "5. **Timeout**: Set timeout based on task complexity. Simple: 30s. Medium: 60s. Complex: 120s+. xhigh thinking: at least 120s.",
2008
+ "",
2009
+ "These guardrails prevent common misconfigurations. The extension also validates configuration before spawning (H1): tool warnings are informational, but outputFile with the write tool excluded is a HARD error and the delegation is rejected. Getting it right the first time is faster and more efficient.",
2010
+ "",
2011
+ "Before delegating, evaluate existing presets to find the best match for the task: tech-writer (documentation), code-reviewer (code review), security-auditor (security analysis), test-engineer (test writing), debugger (debugging), refactorer (refactoring), data-analyst (data analysis), rapid-prototyper (quick prototypes). Use the preset parameter to apply the best match. If no preset fits, use dev-agent for general development tasks.",
2012
+ "The autoRoutePreset() function can automatically select the best preset based on task keywords. Consider using it for preset selection.",
2013
+ "Prefer a preset when the delegation matches a standard shape — review/audit/test/docs; override per-call deliberately.",
2014
+ ],
2015
+ parameters: Type.Object({
2016
+ task: Type.Optional(Type.String({
2017
+ description: "Detailed description of the task for the subagent to complete (required for single mode, optional for chain/tasks/graph)",
2018
+ })),
2019
+ systemPrompt: Type.Optional(
2020
+ Type.String({
2021
+ description:
2022
+ "Custom system prompt or additional instructions for the subagent. " +
2023
+ "When inheritSystemPrompt is true (default), this is appended after the inherited prompt. " +
2024
+ "When inheritSystemPrompt is false, this replaces the inherited prompt entirely.",
2025
+ }),
2026
+ ),
2027
+ inheritSystemPrompt: Type.Optional(
2028
+ Type.Boolean({
2029
+ description:
2030
+ "Whether to inherit the main agent's system prompt. " +
2031
+ "Default: true. Set to false to use only your custom systemPrompt, " +
2032
+ "or to avoid passing a large inherited prompt to the subagent.",
2033
+ }),
2034
+ ),
2035
+ thinkingLevel: Type.Optional(
2036
+ Type.String({
2037
+ description:
2038
+ "Requested thinking level for this subagent call. " +
2039
+ "One of: off, minimal, low, medium, high, xhigh. " +
2040
+ "Capped at the user's configured maximum. If omitted, the user's configured level is used.",
2041
+ }),
2042
+ ),
2043
+ outputFile: Type.Optional(
2044
+ Type.String({
2045
+ description:
2046
+ "Project-relative path where the subagent should write its full findings. " +
2047
+ "When provided, the subagent is instructed to write complete output to this file " +
2048
+ "and return only a structured summary.",
2049
+ }),
2050
+ ),
2051
+ label: Type.Optional(
2052
+ Type.String({
2053
+ description:
2054
+ "Human-readable label for this subagent (e.g., 'security-audit', 'docs-review'). " +
2055
+ "Appears in the status bar and tool call display. " +
2056
+ "Omit to use the default anonymous counter.",
2057
+ }),
2058
+ ),
2059
+ model: Type.Optional(Type.String({ description: "Model override (provider/model-id). Defaults to the global subagent model." })),
2060
+ timeout: Type.Optional(
2061
+ Type.Number({
2062
+ description:
2063
+ "Maximum time in milliseconds the subagent is allowed to run. " +
2064
+ "If exceeded, the subagent is killed and an error is returned.",
2065
+ }),
2066
+ ),
2067
+ cwd: Type.Optional(
2068
+ Type.String({
2069
+ description:
2070
+ "Working directory for the subagent. Must be an existing directory. " +
2071
+ "Defaults to the conductor's current working directory.",
2072
+ }),
2073
+ ),
2074
+ tools: Type.Optional(
2075
+ Type.Array(Type.String(), {
2076
+ description:
2077
+ "Explicit allowlist of tool names for the subagent. Maps to pi's --tools flag.",
2078
+ }),
2079
+ ),
2080
+ excludeTools: Type.Optional(
2081
+ Type.Array(Type.String(), {
2082
+ description:
2083
+ "Tool names to disable for the subagent. Maps to pi's --exclude-tools flag.",
2084
+ }),
2085
+ ),
2086
+ noBuiltinTools: Type.Optional(
2087
+ Type.Boolean({
2088
+ description:
2089
+ "Disable all built-in tools for the subagent. Maps to pi's --no-builtin-tools flag.",
2090
+ }),
2091
+ ),
2092
+ preset: Type.Optional(
2093
+ Type.String({
2094
+ description:
2095
+ "Name of a saved delegation preset (created via /brl-subagent preset). " +
2096
+ "Preset values are used as defaults; explicit parameters on this call override them.",
2097
+ }),
2098
+ ),
2099
+ template: Type.Optional(
2100
+ Type.String({
2101
+ description:
2102
+ "Name of a saved task template. Use with params to fill template slots. " +
2103
+ "Templates are file-backed: 9 builtin templates ship with the extension " +
2104
+ "(browse via /brl-subagent templates); override or add via .md files in " +
2105
+ "~/.pi/agent/brl-subagent/templates/ or .pi/brl-subagent/templates/ " +
2106
+ "(project-local wins over user-global over builtin).",
2107
+ }),
2108
+ ),
2109
+ params: Type.Optional(
2110
+ Type.Record(Type.String(), Type.String(), {
2111
+ description:
2112
+ "Parameter values for template ${param} slots. " +
2113
+ "Keys are param names, values are the substitution text.",
2114
+ }),
2115
+ ),
2116
+ retryRunId: Type.Optional(
2117
+ Type.String({
2118
+ description:
2119
+ "ID of a previously failed subagent run to retry. " +
2120
+ "The retried run uses the same task and parameters as the original. " +
2121
+ "Only works with runs that ended in failure (exitCode != 0, timeout, error, or abort). " +
2122
+ "Explicit parameters on this call override the original's.",
2123
+ }),
2124
+ ),
2125
+ gitMode: Type.Optional(
2126
+ Type.String({
2127
+ description:
2128
+ "Git integration mode for this subagent call. " +
2129
+ "'branch' creates a work branch, captures the diff, and switches back. " +
2130
+ "'none' (default) does nothing. Falls back to the configured default.",
2131
+ }),
2132
+ ),
2133
+ retryOnTimeout: Type.Optional(
2134
+ Type.Boolean({
2135
+ description:
2136
+ "If true and the subagent times out, automatically retry with the same parameters. " +
2137
+ "Only retries once — the second timeout is treated as a final failure.",
2138
+ }),
2139
+ ),
2140
+ approvalMode: Type.Optional(
2141
+ Type.String({
2142
+ description:
2143
+ "Change approval mode: auto (never ask), writes (ask when files changed), " +
2144
+ "always (ask every time). Default is user config (/brl-subagent approval).",
2145
+ }),
2146
+ ),
2147
+ background: Type.Optional(
2148
+ Type.Boolean({
2149
+ description:
2150
+ "Run the subagent in the background without blocking the conductor. " +
2151
+ "When true, the tool returns immediately with an agent ID. " +
2152
+ "The conductor is woken with a completion message; use get_subagent_result for post-wake retrieval and stall checks. " +
2153
+ "Default: false (blocking mode).",
2154
+ }),
2155
+ ),
2156
+ priority: Type.Optional(
2157
+ Type.Union([
2158
+ Type.Literal("critical"),
2159
+ Type.Literal("high"),
2160
+ Type.Literal("normal"),
2161
+ Type.Literal("low"),
2162
+ ], {
2163
+ description: "Concurrency priority for this delegation: critical, high, normal, or low. Higher-priority delegations queue ahead. Defaults to normal.",
2164
+ })
2165
+ ),
2166
+ // Issue #114: NO per-step `priority` on chain[] — chain steps never
2167
+ // compete for concurrency slots (the chain holds ONE slot for its whole
2168
+ // duration); array order IS the priority.
2169
+ chain: Type.Optional(Type.Array(Type.Object({
2170
+ task: Type.String({ description: "Task description. Use {previous} to reference the previous step output." }),
2171
+ label: Type.Optional(Type.String({})),
2172
+ model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
2173
+ thinkingLevel: Type.Optional(Type.String({})),
2174
+ cwd: Type.Optional(Type.String({})),
2175
+ timeout: Type.Optional(Type.Number({})),
2176
+ outputFile: Type.Optional(Type.String({})),
2177
+ tools: Type.Optional(Type.Array(Type.String({}))),
2178
+ excludeTools: Type.Optional(Type.Array(Type.String({}))),
2179
+ noBuiltinTools: Type.Optional(Type.Boolean({})),
2180
+ systemPrompt: Type.Optional(Type.String({})),
2181
+ inheritSystemPrompt: Type.Optional(Type.Boolean({})),
2182
+ }), {
2183
+ description: "Sequential chain of tasks. Each step receives the previous step output via {previous} placeholder in the task string. Chain stops at the first failure. Max " + MAX_CHAIN_STEPS + " steps."
2184
+ })),
2185
+ tasks: Type.Optional(Type.Array(Type.Object({
2186
+ task: Type.String({ description: "Task description for this parallel subtask" }),
2187
+ label: Type.Optional(Type.String({})),
2188
+ model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
2189
+ thinkingLevel: Type.Optional(Type.String({})),
2190
+ priority: Type.Optional(
2191
+ Type.Union([
2192
+ Type.Literal("critical"),
2193
+ Type.Literal("high"),
2194
+ Type.Literal("normal"),
2195
+ Type.Literal("low"),
2196
+ ], { description: "Concurrency priority for this subtask (overrides the call-level default)." })
2197
+ ),
2198
+ cwd: Type.Optional(Type.String({})),
2199
+ timeout: Type.Optional(Type.Number({})),
2200
+ outputFile: Type.Optional(Type.String({})),
2201
+ tools: Type.Optional(Type.Array(Type.String({}))),
2202
+ excludeTools: Type.Optional(Type.Array(Type.String({}))),
2203
+ noBuiltinTools: Type.Optional(Type.Boolean({})),
2204
+ systemPrompt: Type.Optional(Type.String({})),
2205
+ inheritSystemPrompt: Type.Optional(Type.Boolean({})),
2206
+ }), {
2207
+ description: "Parallel tasks to execute concurrently. All tasks run regardless of individual failures. Max " + MAX_PARALLEL_TASKS + " tasks."
2208
+ })),
2209
+ graph: Type.Optional(Type.Array(Type.Object({
2210
+ id: Type.String({ description: "Unique identifier for this task node" }),
2211
+ task: Type.String({ description: "Task description. Use {otherId} to reference output from another task." }),
2212
+ label: Type.Optional(Type.String({})),
2213
+ model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
2214
+ dependsOn: Type.Optional(Type.Array(Type.String({}), { description: "IDs of tasks that must complete before this one starts" })),
2215
+ thinkingLevel: Type.Optional(Type.String({})),
2216
+ priority: Type.Optional(
2217
+ Type.Union([
2218
+ Type.Literal("critical"),
2219
+ Type.Literal("high"),
2220
+ Type.Literal("normal"),
2221
+ Type.Literal("low"),
2222
+ ], { description: "Concurrency priority for this subtask (overrides the call-level default)." })
2223
+ ),
2224
+ cwd: Type.Optional(Type.String({})),
2225
+ timeout: Type.Optional(Type.Number({})),
2226
+ outputFile: Type.Optional(Type.String({})),
2227
+ tools: Type.Optional(Type.Array(Type.String({}))),
2228
+ excludeTools: Type.Optional(Type.Array(Type.String({}))),
2229
+ noBuiltinTools: Type.Optional(Type.Boolean({})),
2230
+ systemPrompt: Type.Optional(Type.String({})),
2231
+ inheritSystemPrompt: Type.Optional(Type.Boolean({})),
2232
+ }), {
2233
+ description: "Declare tasks with dependencies. The scheduler parallelizes independent tasks and sequences dependent ones. Max " + MAX_GRAPH_TASKS + " tasks."
2234
+ })),
2235
+ }),
2236
+
2237
+ async execute(
2238
+ _toolCallId: string,
2239
+ params: {
2240
+ // Schema registers task as Type.Optional — required for single mode only,
2241
+ // omitted by chain/tasks/graph calls. Aligned with Static<TParams>.
2242
+ task?: string;
2243
+ label?: string;
2244
+ model?: string;
2245
+ preset?: string;
2246
+ systemPrompt?: string;
2247
+ inheritSystemPrompt?: boolean;
2248
+ thinkingLevel?: string;
2249
+ outputFile?: string;
2250
+ timeout?: number;
2251
+ cwd?: string;
2252
+ tools?: string[];
2253
+ excludeTools?: string[];
2254
+ noBuiltinTools?: boolean;
2255
+ template?: string;
2256
+ params?: Record<string, string>;
2257
+ retryRunId?: string;
2258
+ retryOnTimeout?: boolean;
2259
+ background?: boolean;
2260
+ gitMode?: string;
2261
+ priority?: string;
2262
+ chain?: Array<{
2263
+ task: string;
2264
+ label?: string;
2265
+ model?: string;
2266
+ thinkingLevel?: string;
2267
+ cwd?: string;
2268
+ timeout?: number;
2269
+ outputFile?: string;
2270
+ tools?: string[];
2271
+ excludeTools?: string[];
2272
+ noBuiltinTools?: boolean;
2273
+ systemPrompt?: string;
2274
+ inheritSystemPrompt?: boolean;
2275
+ }>;
2276
+ tasks?: Array<{
2277
+ task: string;
2278
+ label?: string;
2279
+ model?: string;
2280
+ thinkingLevel?: string;
2281
+ priority?: string;
2282
+ cwd?: string;
2283
+ timeout?: number;
2284
+ outputFile?: string;
2285
+ tools?: string[];
2286
+ excludeTools?: string[];
2287
+ noBuiltinTools?: boolean;
2288
+ systemPrompt?: string;
2289
+ inheritSystemPrompt?: boolean;
2290
+ }>;
2291
+ graph?: Array<{
2292
+ id: string;
2293
+ task: string;
2294
+ label?: string;
2295
+ model?: string;
2296
+ dependsOn?: string[];
2297
+ thinkingLevel?: string;
2298
+ priority?: string;
2299
+ cwd?: string;
2300
+ timeout?: number;
2301
+ outputFile?: string;
2302
+ tools?: string[];
2303
+ excludeTools?: string[];
2304
+ noBuiltinTools?: boolean;
2305
+ systemPrompt?: string;
2306
+ inheritSystemPrompt?: boolean;
2307
+ }>;
2308
+ },
2309
+ signal: AbortSignal | undefined,
2310
+ onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
2311
+ ctx: ExtensionContext,
2312
+ ) {
2313
+ // Issue #99: warn on unknown params — typebox allows additional properties
2314
+ // by default (pi's validateToolArguments passes unknown keys through), so a
2315
+ // typo like `thinkinglevel` or a schema-drifted param is otherwise silently
2316
+ // ignored. Warn-not-reject: never break a delegation.
2317
+ const unknownKeys = findUnknownParams(params, KNOWN_DELEGATE_KEYS);
2318
+ if (unknownKeys.length > 0) {
2319
+ log.warn("delegate_task: unknown param(s) ignored — check spelling or add to schema", { unknown: unknownKeys });
2320
+ // Issue #110: log.warn alone is invisible — pi's TUI swallows extension
2321
+ // console output. Route through pi.sendMessage (the subagent-notification
2322
+ // pattern, index.ts ~2312) so BOTH the LLM (followUp enters the
2323
+ // conversation) and the human (session transcript) see the correction.
2324
+ pi.sendMessage({
2325
+ customType: "delegate-notification",
2326
+ content: `delegate_task: unknown param(s) ignored — check spelling or add to schema: ${unknownKeys.join(", ")}`,
2327
+ display: true,
2328
+ details: { unknown: unknownKeys },
2329
+ }, { deliverAs: "followUp" });
2330
+ }
2331
+
2332
+ // F1: Sanitize task input — skip for chain/parallel modes
2333
+ const hasChain = params.chain && params.chain.length > 0;
2334
+ const hasParallel = params.tasks && params.tasks.length > 0;
2335
+ const hasGraph = params.graph && params.graph.length > 0;
2336
+ if (!hasChain && !hasParallel && !hasGraph) {
2337
+ // task is optional per the schema; single mode requires it. When absent,
2338
+ // feed "" to the sanitizer — same rejection path ("Task must not be
2339
+ // empty.") as when task was a required field.
2340
+ const taskResult = sanitizeTask(params.task ?? "");
2341
+ if (!taskResult.ok) {
2342
+ log.warn("Task rejected by sanitizer", { error: taskResult.error });
2343
+ return {
2344
+ content: [{ type: "text" as const, text: `Invalid task: ${taskResult.error}` }],
2345
+ details: undefined,
2346
+ isError: true,
2347
+ };
2348
+ }
2349
+ params.task = taskResult.value;
2350
+ }
2351
+
2352
+ // Handle retryRunId
2353
+ if (params.retryRunId) {
2354
+ const runEntry = state.findRunById(ctx, params.retryRunId);
2355
+ if (runEntry) {
2356
+ params = resolveRetryParams(params, runEntry);
2357
+ } else {
2358
+ // Issue #98: a silent no-op here made retries of background runs
2359
+ // (which previously never wrote run entries) start as FRESH runs
2360
+ // with no signal to the caller. Fail loudly instead.
2361
+ log.warn("Retry run ID not found", { retryRunId: params.retryRunId });
2362
+ return {
2363
+ content: [{
2364
+ type: "text" as const,
2365
+ text: `Retry run ID not found: ${params.retryRunId}. The run may have been pruned, or it was a background run created before the run-entry fix (issue #98). Pass the run's agent ID — for background runs the agent ID and run ID are now the same.`,
2366
+ }],
2367
+ details: undefined,
2368
+ isError: true,
2369
+ };
2370
+ }
2371
+ }
2372
+
2373
+ // Handle template resolution
2374
+ if (params.template) {
2375
+ const templateEntry = state.config.templates.find((t) => t.name === params.template);
2376
+ if (!templateEntry) {
2377
+ const available = state.config.templates.map((t) => t.name).join(", ") || "none";
2378
+ return {
2379
+ content: [
2380
+ {
2381
+ type: "text" as const,
2382
+ text: `Template '${params.template}' not found. Available: ${available}`,
2383
+ },
2384
+ ],
2385
+ details: undefined,
2386
+ isError: true,
2387
+ };
2388
+ }
2389
+
2390
+ const resolved = resolveTemplate(templateEntry, params.params ?? {});
2391
+ if (!resolved.ok) {
2392
+ return {
2393
+ content: [
2394
+ {
2395
+ type: "text" as const,
2396
+ text: `Template '${params.template}' resolution failed: ${resolved.error}`,
2397
+ },
2398
+ ],
2399
+ details: undefined,
2400
+ isError: true,
2401
+ };
2402
+ }
2403
+
2404
+ const tv = resolved.value;
2405
+ // Use resolved template fields as defaults, explicitly provided params override
2406
+ params.task = tv.task;
2407
+ if (tv.preset && !params.preset) params.preset = tv.preset;
2408
+ if (tv.thinkingLevel && !params.thinkingLevel) params.thinkingLevel = tv.thinkingLevel;
2409
+ if (tv.outputFile && !params.outputFile) params.outputFile = tv.outputFile;
2410
+ if (tv.timeout !== undefined && params.timeout === undefined) params.timeout = tv.timeout;
2411
+ if (tv.tools && !params.tools) params.tools = tv.tools;
2412
+ if (tv.excludeTools && !params.excludeTools) params.excludeTools = tv.excludeTools;
2413
+ if (tv.noBuiltinTools !== undefined && params.noBuiltinTools === undefined) params.noBuiltinTools = tv.noBuiltinTools;
2414
+ if (tv.inheritSystemPrompt !== undefined && params.inheritSystemPrompt === undefined) params.inheritSystemPrompt = tv.inheritSystemPrompt;
2415
+ }
2416
+
2417
+ // P1+P2+P10: Mode detection — graph > chain > parallel > single
2418
+ const isChain = hasChain;
2419
+ const isParallel = hasParallel;
2420
+ const isGraph = hasGraph;
2421
+ const isSingle = typeof params.task === "string" && params.task.length > 0;
2422
+
2423
+ const modeCount = [isChain, isParallel, isGraph, isSingle].filter(Boolean).length;
2424
+ if (modeCount !== 1) {
2425
+ return {
2426
+ content: [
2427
+ {
2428
+ type: "text" as const,
2429
+ text:
2430
+ "Provide exactly one of: task (single), chain (sequential), tasks (parallel), or graph (dependency graph).",
2431
+ },
2432
+ ],
2433
+ details: undefined,
2434
+ isError: true,
2435
+ };
2436
+ }
2437
+
2438
+ if (isChain) {
2439
+ if (params.chain!.length > MAX_CHAIN_STEPS) {
2440
+ return {
2441
+ content: [
2442
+ {
2443
+ type: "text" as const,
2444
+ text: `Chain exceeds max ${MAX_CHAIN_STEPS} steps.`,
2445
+ },
2446
+ ],
2447
+ details: undefined,
2448
+ isError: true,
2449
+ };
2450
+ }
2451
+ return runChainMode(params, signal, onUpdate, ctx);
2452
+ }
2453
+
2454
+ if (isParallel) {
2455
+ if (params.tasks!.length > MAX_PARALLEL_TASKS) {
2456
+ return {
2457
+ content: [
2458
+ {
2459
+ type: "text" as const,
2460
+ text: `Parallel exceeds max ${MAX_PARALLEL_TASKS} tasks.`,
2461
+ },
2462
+ ],
2463
+ details: undefined,
2464
+ isError: true,
2465
+ };
2466
+ }
2467
+ return runParallelMode(params, signal, onUpdate, ctx);
2468
+ }
2469
+
2470
+ if (isGraph) {
2471
+ return runGraphMode(params, signal, onUpdate, ctx);
2472
+ }
2473
+
2474
+ // Single mode: isSingle is true (modeCount===1, none of chain/parallel/graph),
2475
+ // so params.task is a non-empty string. TS can't infer this through the
2476
+ // boolean-array filter — assert, mirroring params.chain!/params.tasks! above.
2477
+ const singleTask = params.task!; // non-empty (sanitizer guarantees; isSingle confirms)
2478
+
2479
+ // Phase 6.5: Background execution — spawn session and return ID immediately.
2480
+ // Resolve the preset and its model BEFORE the background branch so the
2481
+ // preset's model (and system prompt) are honored in background mode.
2482
+ const bgResolved = resolveSubagentParams({ ...params, task: singleTask }, state, ctx, log);
2483
+ const { resolvedPreset: bgResolvedPreset, autoRoutedPreset: bgAutoRoutedPreset } = bgResolved;
2484
+ const bgModelResult = resolveSubagentModel(ctx, bgResolvedPreset, params.model);
2485
+ const bgModel = bgModelResult.ok
2486
+ ? `${bgModelResult.model.provider}/${bgModelResult.model.id}`
2487
+ : undefined;
2488
+
2489
+ if (params.background) {
2490
+ const { spawnBackgroundSession, setAgentFinalOutput, extractFinalOutput, updateAgentStatus } = await import('./session-manager');
2491
+ // Issue #31 (PR #76 review, DRY): extract final output from a
2492
+ // possibly-released ref — the poller may observe a terminal agent
2493
+ // whose ref was already nulled by the settlement path.
2494
+ const extractAgentFinalOutput = (a: BackgroundAgent): string =>
2495
+ extractFinalOutput(a._sessionRef ?? { messages: [] });
2496
+
2497
+ try {
2498
+ // W5 (issue #28): approvalMode 'always' cannot work in background —
2499
+ // there is no interactive dialog to approve the diff. Reject loudly
2500
+ // instead of silently running unattended with write access.
2501
+ if (bgResolved.resolvedApprovalMode === 'always') {
2502
+ return {
2503
+ content: [{ type: "text" as const, text:
2504
+ `Cannot spawn background agent with approvalMode 'always': background agents run unattended ` +
2505
+ `and cannot present the approval dialog. Use approvalMode 'auto' (default) or 'writes'.`
2506
+ }],
2507
+ details: undefined,
2508
+ isError: true,
2509
+ };
2510
+ }
2511
+ // W5: 'writes' in background silently auto-approves — warn once so the
2512
+ // caller knows the diff will NOT be gated on approval.
2513
+ if (bgResolved.resolvedApprovalMode === 'writes') {
2514
+ log.warn("Background agent spawned with approvalMode 'writes' — auto-approving (no dialog in background)", {
2515
+ task: params.task,
2516
+ });
2517
+ }
2518
+
2519
+ // W6 (issue #28): session cost limit must gate background spawns too —
2520
+ // the R5 check after the background branch never runs for them.
2521
+ // Same estimate/limit logic as single mode.
2522
+ const bgPerTaskEstimate = state.config.perTaskCostEstimate > 0
2523
+ ? state.config.perTaskCostEstimate
2524
+ : 0.05;
2525
+ if (state.checkCostLimit(bgPerTaskEstimate, ctx)) {
2526
+ const bgLimit = state.config.sessionCostLimit;
2527
+ const bgCurrentTotal = state.getSessionTotalCost(ctx);
2528
+ log.warn("Background delegation rejected: session cost limit reached", {
2529
+ currentTotal: bgCurrentTotal,
2530
+ estimatedCost: bgPerTaskEstimate,
2531
+ limit: bgLimit,
2532
+ });
2533
+ return {
2534
+ content: [
2535
+ {
2536
+ type: "text" as const,
2537
+ text:
2538
+ `Cannot delegate: session cost limit reached ` +
2539
+ `($${bgCurrentTotal.toFixed(4)} spent of $${bgLimit.toFixed(2)} limit). ` +
2540
+ `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
2541
+ },
2542
+ ],
2543
+ details: undefined,
2544
+ isError: true,
2545
+ };
2546
+ }
2547
+
2548
+ // F1: Validate cwd + outputFile the same way foreground single mode does —
2549
+ // an unvalidated outputFile would reach the prompt and could steer the
2550
+ // background agent's write tool outside the project root.
2551
+ const bgCwdResult = validateCwd(bgResolved.effectiveCwd, ctx.cwd);
2552
+ if (!bgCwdResult.ok) {
2553
+ return {
2554
+ content: [{ type: "text" as const, text: `Invalid cwd: ${bgCwdResult.error}` }],
2555
+ details: undefined,
2556
+ isError: true,
2557
+ };
2558
+ }
2559
+ let bgResolvedOutputFile: string | undefined;
2560
+ if (bgResolved.outputFile) {
2561
+ const ofResult = validateOutputFile(bgResolved.outputFile, bgCwdResult.value);
2562
+ if (!ofResult.ok) {
2563
+ return {
2564
+ content: [
2565
+ { type: "text" as const, text: `Invalid outputFile: ${ofResult.error}` },
2566
+ ],
2567
+ details: undefined,
2568
+ isError: true,
2569
+ };
2570
+ }
2571
+ bgResolvedOutputFile = ofResult.value;
2572
+ }
2573
+
2574
+ // C: H1 validation for background mode — reject outputFile-vs-write
2575
+ // conflicts before spawning (loud failure, same as single mode).
2576
+ const bgValidation = validatePreTask({
2577
+ task: singleTask,
2578
+ toolOptions: bgResolved.toolOptions,
2579
+ thinkingLevel: bgResolved.thinkingLevel,
2580
+ gitMode: bgResolved.resolvedGitMode,
2581
+ outputFile: bgResolvedOutputFile,
2582
+ });
2583
+ if (bgValidation.warnings.length > 0) {
2584
+ log.warn("Background pre-task validation warnings", { warnings: bgValidation.warnings });
2585
+ }
2586
+ if (!bgValidation.valid) {
2587
+ const errText = bgValidation.errors.join("; ");
2588
+ log.warn("Background pre-task validation failed", { errors: bgValidation.errors });
2589
+ return {
2590
+ content: [{ type: "text" as const, text: errText }],
2591
+ details: undefined,
2592
+ isError: true,
2593
+ };
2594
+ }
2595
+
2596
+ // Build the full prompt the same way foreground single mode does:
2597
+ // base prompt (optionally inherited) + custom prompt + preset guidance.
2598
+ const bgPrompt = buildSubagentPrompt(
2599
+ ctx.getSystemPrompt(),
2600
+ bgResolved.inheritSP,
2601
+ bgResolved.customSP,
2602
+ bgResolvedOutputFile,
2603
+ bgResolved.toolOptions?.tools,
2604
+ bgResolvedPreset?.promptGuideline,
2605
+ );
2606
+
2607
+ const agent = await spawnBackgroundSession(pi, ctx, {
2608
+ task: singleTask,
2609
+ type: params.preset || 'general-purpose',
2610
+ description: params.label,
2611
+ model: bgModel,
2612
+ thinkingLevel: bgResolved.thinkingLevel,
2613
+ // Issue #114: per-unit priority rides the spawn so the background
2614
+ // run entry + drill-in header carry it like foreground runs.
2615
+ priority: params.priority,
2616
+ systemPrompt: bgPrompt,
2617
+ cwd: bgCwdResult.value,
2618
+ toolOptions: bgResolved.toolOptions,
2619
+ timeout: bgResolved.timeout,
2620
+ gitMode: bgResolved.resolvedGitMode,
2621
+ // Issue #98: snapshot the caller's raw params on the run entry so a
2622
+ // retry of this background run restores them — single source of truth
2623
+ // snapshotOriginalParams (issue #108: no more duplicated 11-field literal).
2624
+ originalParams: snapshotOriginalParams(params),
2625
+ });
2626
+
2627
+ // Register for live monitor
2628
+ state.registerLiveSubagent(agent.id, {
2629
+ id: agent.id,
2630
+ label: agent.description,
2631
+ task: agent.task,
2632
+ model: agent.model,
2633
+ thinkingLevel: agent.thinkingLevel,
2634
+ priority: agent.priority,
2635
+ startedAt: agent.startedAt,
2636
+ ctx,
2637
+ });
2638
+
2639
+ // Update footer counters
2640
+ state.activeSubagents++;
2641
+ updateProgressStatus(state, ctx);
2642
+
2643
+ let completed = false;
2644
+
2645
+ // Poll for live progress
2646
+ const pollInterval = setInterval(() => {
2647
+ try {
2648
+ if (completed) return;
2649
+
2650
+ const session = agent._sessionRef;
2651
+ // Issue #31: a nulled ref on a TERMINAL agent is expected (the
2652
+ // settlement path releases the ref) — fall through to finalize
2653
+ // below instead of treating it as a crash. A nulled ref on a
2654
+ // live agent is still a crash.
2655
+ if (!session && !agent.completedAt) {
2656
+ // Session ref not available — session may have crashed.
2657
+ // Defensive: the ref is assigned synchronously before the poller starts
2658
+ // and is never nulled, so this is mostly unreachable — but keep the
2659
+ // bookkeeping safe regardless.
2660
+ completed = true;
2661
+ clearInterval(pollInterval);
2662
+ clearTimeout(hardCapHandle);
2663
+ // Capture whatever output exists (may be empty — fine).
2664
+ setAgentFinalOutput(agent.id, state.subagentSessions.get(agent.id)?.liveOutput ?? '');
2665
+ // Gate the counter on the finalize claim: if the
2666
+ // stale sweep already finalized this entry, the
2667
+ // decrement happened there (PR #71 review).
2668
+ if (state.finalizeLiveSubagent(agent.id)) {
2669
+ state.activeSubagents--;
2670
+ if (state.activeSubagents < 0) state.activeSubagents = 0;
2671
+ }
2672
+ state.failedSubagents++;
2673
+ updateProgressStatus(state, ctx);
2674
+ pi.sendMessage({
2675
+ customType: "subagent-notification",
2676
+ content: `Background agent "${agent.description}" crashed.`,
2677
+ display: true,
2678
+ details: { agentId: agent.id }
2679
+ }, { deliverAs: "followUp" });
2680
+ return;
2681
+ }
2682
+
2683
+ // While running, update the live monitor.
2684
+ // Once completedAt is set, skip straight to finalize below.
2685
+ // Issue #31: past the guard above, the only nulled-ref path left is a
2686
+ // TERMINAL agent (settlement releases the ref), so a live agent always
2687
+ // has its session — the guard already returned on the live-nulled-ref
2688
+ // crash path. The compiler can't correlate the two checks, so assert it.
2689
+ if (!agent.completedAt) {
2690
+ const finalOutput = extractFinalOutput(session!);
2691
+
2692
+ try {
2693
+ const stats = session!.getSessionStats();
2694
+ state.updateLiveSubagent(agent.id, finalOutput, stats.tokens.input, stats.tokens.output);
2695
+ } catch {
2696
+ state.updateLiveSubagent(agent.id, finalOutput, 0, 0);
2697
+ }
2698
+ }
2699
+
2700
+ // Authoritative completion: the prompt promise settled — status and
2701
+ // completedAt are set by .then()/.catch() in spawnBackgroundSession.
2702
+ // Do NOT rely on !session.isStreaming: it is false during prompt()
2703
+ // preflight (auth check, model resolution) and for failed starts.
2704
+ if (agent.completedAt) {
2705
+ completed = true;
2706
+ clearInterval(pollInterval);
2707
+ clearTimeout(hardCapHandle);
2708
+
2709
+ // Issue #31: prefer the output already captured at settlement;
2710
+ // the ref may already be nulled on terminal agents, so never
2711
+ // overwrite with empty and stay null-safe.
2712
+ const finalOutput = agent.finalOutput ?? extractAgentFinalOutput(agent);
2713
+ setAgentFinalOutput(agent.id, finalOutput);
2714
+
2715
+ // Gate the counter on the finalize claim: if the
2716
+ // stale sweep already finalized this entry, the
2717
+ // decrement happened there (PR #71 review).
2718
+ if (state.finalizeLiveSubagent(agent.id)) {
2719
+ state.activeSubagents--;
2720
+ if (state.activeSubagents < 0) state.activeSubagents = 0;
2721
+ }
2722
+
2723
+ if (agent.status === 'failed') {
2724
+ state.failedSubagents++;
2725
+ updateProgressStatus(state, ctx);
2726
+ pi.sendMessage({
2727
+ customType: "subagent-notification",
2728
+ content: `Background agent "${agent.description}" failed.`,
2729
+ display: true,
2730
+ details: { agentId: agent.id }
2731
+ }, { deliverAs: "followUp" });
2732
+ } else if (agent.status === 'stopped') {
2733
+ // User-initiated stop (stop_subagent) or deadline abort
2734
+ // (timeout/hard cap) — not a failure.
2735
+ updateProgressStatus(state, ctx);
2736
+ } else {
2737
+ state.completedSubagents++;
2738
+ state.unseenSubagents++;
2739
+ updateProgressStatus(state, ctx);
2740
+ }
2741
+ }
2742
+ } catch (err) {
2743
+ if (!completed) {
2744
+ completed = true;
2745
+ clearInterval(pollInterval);
2746
+ clearTimeout(hardCapHandle);
2747
+ try {
2748
+ setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
2749
+ } catch { /* ignore */ }
2750
+ // Gate the counter on the finalize claim: if the
2751
+ // stale sweep already finalized this entry, the
2752
+ // decrement happened there (PR #71 review).
2753
+ if (state.finalizeLiveSubagent(agent.id)) {
2754
+ state.activeSubagents--;
2755
+ if (state.activeSubagents < 0) state.activeSubagents = 0;
2756
+ }
2757
+ state.failedSubagents++;
2758
+ updateProgressStatus(state, ctx);
2759
+ pi.sendMessage({
2760
+ customType: "subagent-notification",
2761
+ content: `Background agent "${agent.description}" crashed: ${sanitizeErrorMessage((err as Error).message, bgResolved.effectiveCwd)}`,
2762
+ display: true,
2763
+ details: { agentId: agent.id }
2764
+ }, { deliverAs: "followUp" });
2765
+ }
2766
+ }
2767
+ }, 2000);
2768
+
2769
+ // Hard cap: stop polling AND abort the session after the deadline.
2770
+ // W2 (issue #28): previously this only stopped the poller — the pi
2771
+ // session kept running forever (orphaned). Now it pre-sets
2772
+ // 'stopped' (so the .then keeps it, per W1) and aborts the
2773
+ // session. The cap honors a shorter per-agent timeout.
2774
+ const hardCapMs = Math.min(bgResolved.timeout ?? 30 * 60 * 1000, 30 * 60 * 1000);
2775
+ const hardCapHandle = setTimeout(() => {
2776
+ if (!completed && !agent.completedAt) {
2777
+ try {
2778
+ completed = true;
2779
+ clearInterval(pollInterval);
2780
+ // Pre-set stopped BEFORE aborting so the .then in
2781
+ // spawnBackgroundSession keeps the stopped state.
2782
+ updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
2783
+ agent._sessionRef?.abort().catch(() => {
2784
+ // Abort may reject if the session is mid-dispose;
2785
+ // the status flip above is already recorded.
2786
+ });
2787
+ // Capture whatever final output exists in the session
2788
+ const hardCapSession = agent._sessionRef;
2789
+ const hardCapFinalOutput = hardCapSession ? extractFinalOutput(hardCapSession) : '';
2790
+ setAgentFinalOutput(agent.id, hardCapFinalOutput);
2791
+ // Gate the counter on the finalize claim: if the
2792
+ // stale sweep already finalized this entry, the
2793
+ // decrement happened there (PR #71 review).
2794
+ if (state.finalizeLiveSubagent(agent.id)) {
2795
+ state.activeSubagents--;
2796
+ if (state.activeSubagents < 0) state.activeSubagents = 0;
2797
+ }
2798
+ // m6: deadline abort is a stop, not a completion — mirror the
2799
+ // W3/poller stopped path (no completedSubagents increment).
2800
+ updateProgressStatus(state, ctx);
2801
+ } catch (err) {
2802
+ // Defensive: never let the hard-cap timer throw uncaught — that would
2803
+ // skip finalizeLiveSubagent, the counter decrement, and the notification.
2804
+ // Do all fallible work first (output capture), then mutate counters,
2805
+ // then notify — a throw mid-path can't double-fire mutations.
2806
+ completed = true;
2807
+ clearInterval(pollInterval);
2808
+ // m3: the try may have thrown BEFORE updateAgentStatus ran (e.g.
2809
+ // persistAgent fs failure) — leave the record terminal so
2810
+ // get_subagent_result doesn't report 'running' and the W3 timer
2811
+ // guard (!agent.completedAt) can't re-fire later.
2812
+ try {
2813
+ updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
2814
+ } catch { /* ignore */ }
2815
+ try {
2816
+ setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
2817
+ } catch { /* ignore */ }
2818
+ // Gate the counter on the finalize claim: if the
2819
+ // stale sweep already finalized this entry, the
2820
+ // decrement happened there (PR #71 review).
2821
+ if (state.finalizeLiveSubagent(agent.id)) {
2822
+ state.activeSubagents--;
2823
+ if (state.activeSubagents < 0) state.activeSubagents = 0;
2824
+ }
2825
+ state.failedSubagents++;
2826
+ updateProgressStatus(state, ctx);
2827
+ }
2828
+ }
2829
+ }, hardCapMs);
2830
+
2831
+ log.info("Background agent spawned", { agentId: agent.id, task: params.task, model: agent.model });
2832
+
2833
+ // B2: Surface auto-route decisions in background spawn result too.
2834
+ // Derive the restriction from the RESOLVED toolOptions (which reflects
2835
+ // per-call overrides) rather than the preset's declared values.
2836
+ const bgAutoRouteNote = bgAutoRoutedPreset
2837
+ ? `\n\n[auto-routed to preset '${bgAutoRoutedPreset.name}' — ${bgResolved.toolOptions ? formatToolRestriction(bgResolved.toolOptions) : formatPresetRestriction(bgAutoRoutedPreset)}]`
2838
+ : "";
2839
+
2840
+ return {
2841
+ content: [{
2842
+ type: "text" as const,
2843
+ text: `Background agent started: ${agent.id}\n\n` +
2844
+ `Description: ${agent.description}\n` +
2845
+ `Task: ${agent.task}\n` +
2846
+ `Status: ${agent.status}\n\n` +
2847
+ `You'll be woken with a completion message when it finishes; use get_subagent_result({ agent_id: "${agent.id}" }) for retrieval and stall checks.` +
2848
+ bgAutoRouteNote,
2849
+ }],
2850
+ details: undefined,
2851
+ };
2852
+ } catch (err) {
2853
+ const message = sanitizeErrorMessage(
2854
+ err instanceof Error ? err.message : String(err),
2855
+ bgResolved.effectiveCwd,
2856
+ );
2857
+ log.error("Failed to spawn background agent", { error: message });
2858
+ return {
2859
+ content: [{ type: "text" as const, text: `Failed to spawn background agent: ${message}` }],
2860
+ details: undefined,
2861
+ isError: true,
2862
+ };
2863
+ }
2864
+ }
2865
+
2866
+ // R5: Check session cost limit before spawning
2867
+ // Use a default per-task estimate of $0.05 if no perTaskCostEstimate is set
2868
+ const perTaskEstimate = state.config.perTaskCostEstimate > 0
2869
+ ? state.config.perTaskCostEstimate
2870
+ : 0.05;
2871
+ const currentTotal = state.getSessionTotalCost(ctx);
2872
+ if (state.checkCostLimit(perTaskEstimate, ctx)) {
2873
+ const limit = state.config.sessionCostLimit;
2874
+ log.warn("Subagent delegation rejected: session cost limit reached", {
2875
+ currentTotal,
2876
+ estimatedCost: perTaskEstimate,
2877
+ limit,
2878
+ });
2879
+ return {
2880
+ content: [
2881
+ {
2882
+ type: "text" as const,
2883
+ text:
2884
+ `Cannot delegate: session cost limit reached ` +
2885
+ `($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
2886
+ `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
2887
+ },
2888
+ ],
2889
+ details: undefined,
2890
+ isError: true,
2891
+ };
2892
+ }
2893
+
2894
+ // Reject delegation if recursion depth exceeds configured max.
2895
+ // This prevents subagents from spawning infinite sub-subagents while
2896
+ // still allowing other extensions to function normally in subprocesses.
2897
+ const currentDepth = getCurrentDepth();
2898
+ if (currentDepth >= state.config.maxSubagentDepth) {
2899
+ log.warn("Subagent delegation rejected: max depth reached", {
2900
+ currentDepth,
2901
+ maxDepth: state.config.maxSubagentDepth,
2902
+ });
2903
+ return {
2904
+ content: [
2905
+ {
2906
+ type: "text" as const,
2907
+ text:
2908
+ `Cannot delegate further: subagent recursion depth limit reached ` +
2909
+ `(depth ${currentDepth}/${state.config.maxSubagentDepth}). ` +
2910
+ `Subagents can delegate up to ${state.config.maxSubagentDepth} levels deep (configurable via /brl-subagent depth). Complete the remaining work directly.`,
2911
+ },
2912
+ ],
2913
+ details: undefined,
2914
+ isError: true,
2915
+ };
2916
+ }
2917
+
2918
+ const {
2919
+ task,
2920
+ label,
2921
+ inheritSP,
2922
+ customSP,
2923
+ outputFile,
2924
+ timeout,
2925
+ effectiveCwd,
2926
+ thinkingLevel,
2927
+ toolOptions,
2928
+ resolvedGitMode,
2929
+ resolvedApprovalMode,
2930
+ resolvedPreset,
2931
+ autoRoutedPreset,
2932
+ } = resolveSubagentParams({ ...params, task: singleTask }, state, ctx, log);
2933
+
2934
+ // F1: Validate CWD
2935
+ const cwdResult = validateCwd(effectiveCwd, ctx.cwd);
2936
+ if (!cwdResult.ok) {
2937
+ return {
2938
+ content: [{ type: "text" as const, text: `Invalid cwd: ${cwdResult.error}` }],
2939
+ details: undefined,
2940
+ isError: true,
2941
+ };
2942
+ }
2943
+ const resolvedCwd = cwdResult.value;
2944
+
2945
+ // F1: Validate outputFile
2946
+ let resolvedOutputFile: string | undefined;
2947
+ if (outputFile) {
2948
+ const ofResult = validateOutputFile(outputFile, resolvedCwd);
2949
+ if (!ofResult.ok) {
2950
+ return {
2951
+ content: [
2952
+ { type: "text" as const, text: `Invalid outputFile: ${ofResult.error}` },
2953
+ ],
2954
+ details: undefined,
2955
+ isError: true,
2956
+ };
2957
+ }
2958
+ resolvedOutputFile = ofResult.value;
2959
+ }
2960
+
2961
+ // R3: Pre-flight checks — fail fast before consuming resources
2962
+ const pfResult = preflightCheck(resolvedCwd);
2963
+ if (!pfResult.ok) {
2964
+ log.warn("Pre-flight check failed", { error: pfResult.error });
2965
+ return {
2966
+ content: [{ type: "text" as const, text: `Pre-flight check failed: ${pfResult.error}` }],
2967
+ details: undefined,
2968
+ isError: true,
2969
+ };
2970
+ }
2971
+
2972
+ // H1: Pre-task validation — deterministic check that tools/thinking match task
2973
+ const validation = validatePreTask({
2974
+ task,
2975
+ toolOptions,
2976
+ thinkingLevel,
2977
+ gitMode: resolvedGitMode,
2978
+ outputFile: resolvedOutputFile,
2979
+ });
2980
+ if (validation.warnings.length > 0) {
2981
+ log.warn("Pre-task validation warnings", { warnings: validation.warnings });
2982
+ }
2983
+ if (!validation.valid) {
2984
+ const errText = validation.errors.join("; ");
2985
+ log.warn("Pre-task validation failed", { errors: validation.errors });
2986
+ return {
2987
+ content: [{
2988
+ type: "text" as const,
2989
+ text: errText,
2990
+ }],
2991
+ details: undefined,
2992
+ isError: true,
2993
+ };
2994
+ }
2995
+
2996
+ // Resolve model (per-call top-level model override beats preset)
2997
+ const modelResult = resolveSubagentModel(ctx, resolvedPreset, params.model);
2998
+ if (!modelResult.ok) return modelResult.error;
2999
+ const subagentModel = modelResult.model;
3000
+
3001
+ // R3: Verify resolved model provider+id is non-empty
3002
+ const modelStr = `${subagentModel.provider}/${subagentModel.id}`.trim();
3003
+ if (!modelStr || modelStr === "/") {
3004
+ log.warn("Model string is empty after resolution", { model: subagentModel });
3005
+ return {
3006
+ content: [
3007
+ {
3008
+ type: "text" as const,
3009
+ text:
3010
+ "Subagent model is not configured. " +
3011
+ "Use /brl-subagent to set a model, or ensure your current session has a valid model.",
3012
+ },
3013
+ ],
3014
+ details: undefined,
3015
+ isError: true,
3016
+ };
3017
+ }
3018
+
3019
+ // Create run record
3020
+ const runId = crypto.randomUUID();
3021
+ const run: SubagentRun = {
3022
+ id: runId,
3023
+ task,
3024
+ label,
3025
+ status: "running",
3026
+ model: `${subagentModel.provider}/${subagentModel.id}`,
3027
+ thinkingLevel,
3028
+ // Issue #114: record-level visibility — the run's priority (absent
3029
+ // when the caller left it to the default).
3030
+ priority: params.priority,
3031
+ startedAt: new Date().toISOString(),
3032
+ originalParams: snapshotOriginalParams(params),
3033
+ };
3034
+ state.persistRun(pi, run);
3035
+
3036
+ // R2: Prune old run entries if history exceeds limit
3037
+ pruneHistoryIfNeeded(state, ctx, log);
3038
+
3039
+ // Register for live monitor
3040
+ registerLiveRun(state, run, ctx);
3041
+
3042
+ // P3: Git integration — set up work branch if gitMode is "branch"
3043
+ let originalBranch: string | undefined;
3044
+ let workBranchName: string | undefined;
3045
+ if (resolvedGitMode === "branch") {
3046
+ try {
3047
+ originalBranch = getCurrentBranch(resolvedCwd);
3048
+
3049
+ if (hasUncommittedChanges(resolvedCwd)) {
3050
+ log.warn("Uncommitted changes detected; proceeding with branch-based workflow anyway", {
3051
+ cwd: resolvedCwd,
3052
+ });
3053
+ }
3054
+
3055
+ const branchResult = createWorkBranch(resolvedCwd, originalBranch);
3056
+ if (branchResult.ok) {
3057
+ workBranchName = branchResult.branch;
3058
+ log.info("Created work branch for subagent", {
3059
+ branch: workBranchName,
3060
+ base: originalBranch,
3061
+ });
3062
+ } else {
3063
+ log.error("Failed to create work branch, falling back to 'none'", {
3064
+ error: branchResult.error,
3065
+ });
3066
+ originalBranch = undefined;
3067
+ }
3068
+ } catch (err) {
3069
+ log.warn("Not a git repository or git error; falling back to gitMode 'none'", {
3070
+ error: (err as Error).message,
3071
+ });
3072
+ originalBranch = undefined;
3073
+ workBranchName = undefined;
3074
+ }
3075
+ }
3076
+
3077
+ // Helper to switch back to original branch and optionally delete work branch
3078
+ const cleanupGitBranch = () => {
3079
+ if (workBranchName && originalBranch) {
3080
+ try {
3081
+ switchToBranch(resolvedCwd, originalBranch);
3082
+ log.info("Switched back to original branch", { branch: originalBranch });
3083
+ // Attempt to delete the work branch (non-critical)
3084
+ deleteBranch(resolvedCwd, workBranchName);
3085
+ } catch {
3086
+ // Non-fatal: best-effort cleanup
3087
+ }
3088
+ }
3089
+ };
3090
+
3091
+ // R1: Circuit breaker check — reject if circuit is open
3092
+ const circuitCheck = state.checkCircuit();
3093
+ if (circuitCheck.isOpen) {
3094
+ cleanupGitBranch();
3095
+ return {
3096
+ content: [{ type: "text" as const, text: circuitCheck.message! }],
3097
+ details: undefined,
3098
+ isError: true,
3099
+ };
3100
+ }
3101
+
3102
+ // Resolve priority (issue #99 R2: single mode honors priority like chain/parallel/graph)
3103
+ const singlePriority: Priority = (
3104
+ params.priority && ["critical", "high", "normal", "low"].includes(params.priority)
3105
+ ? (params.priority as Priority)
3106
+ : DEFAULT_PRIORITY
3107
+ );
3108
+
3109
+ // Acquire concurrency slot
3110
+ const acquired = await acquireSlot(state, ctx, signal, singlePriority);
3111
+ if (!acquired) {
3112
+ cleanupGitBranch();
3113
+ return {
3114
+ content: [
3115
+ {
3116
+ type: "text" as const,
3117
+ text: "Subagent cancelled while waiting for concurrency slot.",
3118
+ },
3119
+ ],
3120
+ details: undefined,
3121
+ isError: true,
3122
+ };
3123
+ }
3124
+
3125
+ let success = false;
3126
+
3127
+ // F7 (issue #65): import BEFORE the try — the crash-path catch calls
3128
+ // completeTranscript, and block-scoped consts declared inside the try
3129
+ // are invisible in its catch (pre-fix, a crash here threw
3130
+ // "completeTranscript is not defined" instead of returning the
3131
+ // sanitized crash result).
3132
+ const { startTranscript, completeTranscript } = await import('./transcript');
3133
+
3134
+ try {
3135
+ // Build system prompt
3136
+ const basePrompt = ctx.getSystemPrompt();
3137
+ const subagentPrompt = buildSubagentPrompt(
3138
+ basePrompt,
3139
+ inheritSP,
3140
+ customSP,
3141
+ resolvedOutputFile,
3142
+ toolOptions?.tools,
3143
+ resolvedPreset?.promptGuideline,
3144
+ );
3145
+
3146
+ // Emit initial progress
3147
+ const modeInfo = describePromptMode(inheritSP, Boolean(customSP));
3148
+ onUpdate?.({
3149
+ content: [
3150
+ {
3151
+ type: "text" as const,
3152
+ text: `Starting subagent (${modeInfo})...`,
3153
+ },
3154
+ ],
3155
+ details: {
3156
+ messages: [],
3157
+ usage: { ...EMPTY_USAGE },
3158
+ exitCode: -1,
3159
+ stderr: "",
3160
+ },
3161
+ });
3162
+
3163
+ // Wrap onUpdate to feed live monitor
3164
+ const liveOnUpdate = makeLiveOnUpdate(state, runId, onUpdate);
3165
+
3166
+ const childDepth = currentDepth + 1;
3167
+
3168
+ // Start transcript for audit trail
3169
+ startTranscript(runId, task);
3170
+
3171
+ let result = await runSubagent(
3172
+ resolvedCwd,
3173
+ subagentPrompt,
3174
+ subagentModel,
3175
+ thinkingLevel,
3176
+ task,
3177
+ signal,
3178
+ liveOnUpdate,
3179
+ toolOptions,
3180
+ timeout,
3181
+ getFinalOutput,
3182
+ log,
3183
+ childDepth,
3184
+ undefined, // intercom
3185
+ undefined, // subagentId
3186
+ );
3187
+
3188
+ // Auto-retry on timeout
3189
+ if (
3190
+ params.retryOnTimeout &&
3191
+ isSubagentError(result) &&
3192
+ result.errorMessage?.includes("timed out")
3193
+ ) {
3194
+ log.info("Auto-retrying after timeout", { runId });
3195
+ state.registerLiveSubagent(runId, {
3196
+ id: runId,
3197
+ label,
3198
+ task,
3199
+ model: run.model,
3200
+ thinkingLevel,
3201
+ priority: params.priority,
3202
+ startedAt: Date.now(),
3203
+ ctx,
3204
+ });
3205
+
3206
+ onUpdate?.({
3207
+ content: [
3208
+ { type: "text" as const, text: "Retrying after timeout..." },
3209
+ ],
3210
+ details: {
3211
+ messages: [],
3212
+ usage: { ...EMPTY_USAGE },
3213
+ exitCode: -1,
3214
+ stderr: "",
3215
+ },
3216
+ });
3217
+
3218
+ result = await runSubagent(
3219
+ resolvedCwd,
3220
+ subagentPrompt,
3221
+ subagentModel,
3222
+ thinkingLevel,
3223
+ task,
3224
+ signal,
3225
+ liveOnUpdate,
3226
+ toolOptions,
3227
+ timeout,
3228
+ getFinalOutput,
3229
+ log,
3230
+ childDepth,
3231
+ undefined, // intercom
3232
+ undefined, // subagentId
3233
+ );
3234
+ }
3235
+
3236
+ // F3: Sanitize and cap output
3237
+ let finalOutput = getFinalOutput(result.messages);
3238
+ finalOutput = stripAnsi(finalOutput);
3239
+ finalOutput = capOutput(finalOutput);
3240
+
3241
+ // Attach label for display
3242
+ result.label = label;
3243
+
3244
+ // P3: Capture git diff if we created a work branch
3245
+ if (workBranchName && originalBranch) {
3246
+ const diffResult = captureDiff(resolvedCwd, originalBranch);
3247
+ if (diffResult.ok) {
3248
+ result.gitBranch = workBranchName;
3249
+ result.gitDiff = diffResult.diff;
3250
+ }
3251
+
3252
+ // Switch back to the original branch
3253
+ const switchResult = switchToBranch(resolvedCwd, originalBranch);
3254
+ if (switchResult.ok) {
3255
+ log.info("Switched back to original branch", { branch: originalBranch });
3256
+
3257
+ // P4: Change approval workflow — let user review the diff
3258
+ const diffContent = (result.gitDiff ?? "").trim();
3259
+ const hasChanges = diffContent.length > 0;
3260
+ const shouldPrompt =
3261
+ ctx.hasUI !== false &&
3262
+ (resolvedApprovalMode === "always" ||
3263
+ (resolvedApprovalMode === "writes" && hasChanges));
3264
+
3265
+ if (shouldPrompt) {
3266
+ const choice = await showApprovalDialog(ctx, label, result.gitDiff ?? "", workBranchName);
3267
+
3268
+ if (choice === "apply") {
3269
+ const mergeResult = mergeWorkBranch(resolvedCwd, workBranchName);
3270
+ if (mergeResult.ok) {
3271
+ log.info("Merged work branch (approved)", { branch: workBranchName });
3272
+ result.approved = true;
3273
+ } else {
3274
+ log.error("Failed to merge work branch", {
3275
+ branch: workBranchName,
3276
+ error: mergeResult.error,
3277
+ });
3278
+ result.approved = false;
3279
+ }
3280
+ } else {
3281
+ // Discard or cancelled
3282
+ deleteBranch(resolvedCwd, workBranchName);
3283
+ log.info("Discarded work branch", { branch: workBranchName });
3284
+ result.approved = false;
3285
+ }
3286
+ workBranchName = undefined; // Prevent double-cleanup
3287
+ } else if (hasChanges) {
3288
+ // Auto-approve: merge the work branch
3289
+ const mergeResult = mergeWorkBranch(resolvedCwd, workBranchName);
3290
+ if (mergeResult.ok) {
3291
+ log.info("Merged work branch (auto-approve)", { branch: workBranchName });
3292
+ result.approved = true;
3293
+ } else {
3294
+ log.error("Failed to merge work branch (auto-approve)", {
3295
+ branch: workBranchName,
3296
+ error: mergeResult.error,
3297
+ });
3298
+ }
3299
+ deleteBranch(resolvedCwd, workBranchName);
3300
+ workBranchName = undefined;
3301
+ } else {
3302
+ // No changes — just delete the empty branch
3303
+ deleteBranch(resolvedCwd, workBranchName);
3304
+ workBranchName = undefined;
3305
+ }
3306
+ }
3307
+ }
3308
+
3309
+ // Finalize run record
3310
+ finalizeRunRecord(
3311
+ run,
3312
+ result,
3313
+ finalOutput,
3314
+ new Date(run.startedAt).getTime(),
3315
+ );
3316
+ run.originalParams = {
3317
+ ...run.originalParams,
3318
+ errorCategory: result.errorCategory,
3319
+ };
3320
+ state.persistRun(pi, run);
3321
+
3322
+ // R2: Prune old run entries
3323
+ pruneHistoryIfNeeded(state, ctx, log);
3324
+
3325
+ // Finalize live monitor
3326
+ state.finalizeLiveSubagent(runId);
3327
+
3328
+ // E4: SLA tracking — compute metrics if enabled
3329
+ if (state.config.slaTrackingEnabled) {
3330
+ const recentRuns = state.getRunEntries(ctx).slice(0, state.config.slaWindowSize);
3331
+ const metrics = computeSLAMetrics(recentRuns);
3332
+ log.info("SLA metrics computed", {
3333
+ totalRuns: metrics.totalRuns,
3334
+ successRate: metrics.successRate,
3335
+ p95DurationMs: metrics.p95DurationMs,
3336
+ totalCost: metrics.totalCost,
3337
+ });
3338
+ if (state.config.lastSLAMetrics) {
3339
+ const report = computeDegradation(metrics, state.config.lastSLAMetrics);
3340
+ if (report.degraded) {
3341
+ log.warn("SLA degradation detected", {
3342
+ successRateChange: report.successRateChange,
3343
+ p95Change: report.p95Change,
3344
+ recommendations: report.recommendations,
3345
+ });
3346
+ }
3347
+ }
3348
+ state.config.lastSLAMetrics = metrics;
3349
+ }
3350
+
3351
+ if (isSubagentError(result)) {
3352
+ const errorMsg =
3353
+ result.errorMessage ||
3354
+ result.stderr ||
3355
+ finalOutput ||
3356
+ "(no output from subagent)";
3357
+ log.warn("Subagent failed", {
3358
+ runId,
3359
+ error: errorMsg,
3360
+ exitCode: result.exitCode,
3361
+ errorCategory: result.errorCategory,
3362
+ });
3363
+
3364
+ // H3: Post-mortem diagnostics — suggest fixes for the failure
3365
+ const suggestions = diagnoseFailure({
3366
+ task,
3367
+ toolOptions,
3368
+ thinkingLevel,
3369
+ gitMode: resolvedGitMode,
3370
+ errorMessage: result.errorMessage,
3371
+ exitCode: result.exitCode,
3372
+ timeout,
3373
+ });
3374
+
3375
+ // R1: Record failure in circuit breaker
3376
+ state.recordFailure();
3377
+
3378
+ let finalMsg = `Subagent failed: ${errorMsg}`;
3379
+ if (suggestions.length > 0) {
3380
+ finalMsg += "\n\nSuggestions:\n" + suggestions.map((s) => `- ${s}`).join("\n");
3381
+ }
3382
+
3383
+ completeTranscript(runId, 'failed');
3384
+
3385
+ return {
3386
+ content: [
3387
+ { type: "text" as const, text: finalMsg },
3388
+ ],
3389
+ details: result,
3390
+ isError: true,
3391
+ };
3392
+ }
3393
+
3394
+ success = true;
3395
+ completeTranscript(runId, 'completed');
3396
+ log.info("Subagent completed successfully",{
3397
+ runId,
3398
+ tokensIn: result.usage.input,
3399
+ tokensOut: result.usage.output,
3400
+ cost: result.usage.cost,
3401
+ sessionTotalCost: state.getSessionTotalCost(ctx),
3402
+ });
3403
+
3404
+ // R1: Record success in circuit breaker
3405
+ state.recordSuccess();
3406
+
3407
+ // B2: Surface auto-route decisions in the tool result so the conductor
3408
+ // sees which preset was applied and what it restricts. Derive the
3409
+ // restriction from the RESOLVED toolOptions (reflects per-call
3410
+ // overrides) rather than the preset's declared values.
3411
+ const autoRouteNote = autoRoutedPreset
3412
+ ? `\n\n[auto-routed to preset '${autoRoutedPreset.name}' — ${toolOptions ? formatToolRestriction(toolOptions) : formatPresetRestriction(autoRoutedPreset)}]`
3413
+ : "";
3414
+
3415
+ return {
3416
+ content: [
3417
+ { type: "text" as const, text: (finalOutput || "(no output)") + autoRouteNote },
3418
+ ],
3419
+ details: result,
3420
+ };
3421
+ } catch (err) {
3422
+ // P3: Clean up git branch on crash
3423
+ cleanupGitBranch();
3424
+ completeTranscript(runId, 'failed');
3425
+
3426
+ const result = buildCrashResult("Subagent", err, resolvedCwd);
3427
+ log.error("Subagent crashed", { runId, error: result.details.errorMessage });
3428
+
3429
+ // R2 (review): mirror the completion path — finalize the live entry
3430
+ // and persist a FAILED run record. Without this the live entry stays
3431
+ // 'running' forever, the stale sweep never reclaims it, and the
3432
+ // drill-in loops on the live branch indefinitely. finalizeLiveSubagent
3433
+ // is idempotent (true = THIS call claimed it; the foreground path has
3434
+ // no activeSubagents counters to gate).
3435
+ state.finalizeLiveSubagent(runId);
3436
+ const crashOutput = state.subagentSessions.get(runId)?.liveOutput ?? "";
3437
+ finalizeRunRecord(
3438
+ run,
3439
+ result.details,
3440
+ crashOutput,
3441
+ new Date(run.startedAt).getTime(),
3442
+ );
3443
+ state.persistRun(pi, run);
3444
+
3445
+ return result;
3446
+ } finally {
3447
+ releaseSlot(state, success, ctx);
3448
+ }
3449
+ },
3450
+
3451
+ renderCall(
3452
+ args: {
3453
+ task?: string;
3454
+ label?: string;
3455
+ systemPrompt?: string;
3456
+ inheritSystemPrompt?: boolean;
3457
+ thinkingLevel?: string;
3458
+ outputFile?: string;
3459
+ timeout?: number;
3460
+ cwd?: string;
3461
+ tools?: string[];
3462
+ excludeTools?: string[];
3463
+ noBuiltinTools?: boolean;
3464
+ },
3465
+ theme: Theme,
3466
+ _context: unknown,
3467
+ ) {
3468
+ return renderDelegateCall(args, theme);
3469
+ },
3470
+
3471
+ renderResult(
3472
+ result: AgentToolResult<DelegateTaskDetails>,
3473
+ options: ToolRenderResultOptions,
3474
+ theme: Theme,
3475
+ _context: unknown,
3476
+ ) {
3477
+ return renderDelegateResult(result, options, theme);
3478
+ },
3479
+ });
3480
+
3481
+ // -------------------------------------------------------------------
3482
+ // get_subagent_result tool — retrieve terminal status/result on demand
3483
+ // -------------------------------------------------------------------
3484
+
3485
+ pi.registerTool({
3486
+ name: "get_subagent_result",
3487
+ label: "Get Subagent Result",
3488
+ description: [
3489
+ "Check the status of a background agent and retrieve its result.",
3490
+ "The conductor is woken with a completion message when the agent finishes; use this for post-wake retrieval, stall checks, and detail access — not for polling.",
3491
+ "Returns the agent's status, result (if completed), and transcript path.",
3492
+ ].join(" "),
3493
+ parameters: Type.Object({
3494
+ agent_id: Type.String({
3495
+ description: "The agent ID returned by delegate_task when background=true",
3496
+ }),
3497
+ wait: Type.Optional(
3498
+ Type.Boolean({
3499
+ description: "If true, wait for the agent to complete before returning. Default: false.",
3500
+ }),
3501
+ ),
3502
+ verbose: Type.Optional(
3503
+ Type.Boolean({
3504
+ description: "If true, include the full conversation log. Default: false.",
3505
+ }),
3506
+ ),
3507
+ }),
3508
+ execute: async (toolCallId, params) => {
3509
+ const { getAgent } = await import('./session-manager');
3510
+ const { getTranscript } = await import('./transcript');
3511
+
3512
+ const agent = getAgent(params.agent_id);
3513
+
3514
+ if (!agent) {
3515
+ return {
3516
+ content: [{ type: "text" as const, text: `Agent ${params.agent_id} not found` }],
3517
+ details: undefined,
3518
+ isError: true,
3519
+ };
3520
+ }
3521
+
3522
+ let resultText = `Agent: ${agent.description}\n`;
3523
+ resultText += `Status: ${agent.status}\n`;
3524
+ resultText += `Started: ${new Date(agent.startedAt).toISOString()}\n`;
3525
+
3526
+ if (agent.completedAt) {
3527
+ resultText += `Completed: ${new Date(agent.completedAt).toISOString()}\n`;
3528
+ resultText += `Duration: ${agent.completedAt - agent.startedAt}ms\n`;
3529
+ }
3530
+
3531
+ if (agent.error) {
3532
+ resultText += `Error: ${agent.error}\n`;
3533
+ }
3534
+
3535
+ if (agent.result) {
3536
+ resultText += `\nResult:\n`;
3537
+ if (agent.result.messages && agent.result.messages.length > 0) {
3538
+ resultText += agent.result.messages.join('\n');
3539
+ }
3540
+ // W4: surface the background agent's git work-branch diff so the
3541
+ // captured changes are reviewable (the branch itself is discarded).
3542
+ if (agent.result.gitBranch) {
3543
+ resultText += `\nGit branch: ${agent.result.gitBranch}\n`;
3544
+ }
3545
+ if (agent.result.gitDiff) {
3546
+ const diff = agent.result.gitDiff;
3547
+ const truncated = diff.length > 8000 ? diff.slice(0, 8000) + "\n…[diff truncated]" : diff;
3548
+ resultText += `\nGit diff (branch ${agent.result.gitBranch ?? 'work'}):\n${truncated}\n`;
3549
+ }
3550
+ }
3551
+
3552
+ if (agent.finalOutput) {
3553
+ const full = agent.finalOutput;
3554
+ const truncated = full.length > 8000 ? full.slice(0, 8000) + "\n…[truncated — full output in transcript]" : full;
3555
+ resultText += `\n\nFinal output:\n${truncated}`;
3556
+ }
3557
+
3558
+ // Include transcript if verbose
3559
+ if (params.verbose) {
3560
+ const transcript = getTranscript(params.agent_id);
3561
+ if (transcript.length > 0) {
3562
+ resultText += `\n\nTranscript (${transcript.length} entries):\n`;
3563
+ for (const entry of transcript.slice(-10)) { // Last 10 entries
3564
+ resultText += `[${new Date(entry.timestamp).toISOString()}] ${entry.type}: ${entry.content.slice(0, 100)}\n`;
3565
+ }
3566
+ }
3567
+ }
3568
+
3569
+ // Include transcript path
3570
+ const { getTranscriptPath } = await import('./session-manager');
3571
+ resultText += `\nTranscript: ${getTranscriptPath(params.agent_id)}`;
3572
+
3573
+ return {
3574
+ content: [{ type: "text" as const, text: resultText }],
3575
+ details: undefined,
3576
+ };
3577
+ },
3578
+ });
3579
+
3580
+ // -------------------------------------------------------------------
3581
+ // steer_subagent tool — inject messages into running agents
3582
+ // -------------------------------------------------------------------
3583
+
3584
+ // steer_subagent tool — inject messages into running agents
3585
+ pi.registerTool({
3586
+ name: "steer_subagent",
3587
+ label: "Steer Subagent",
3588
+ description: [
3589
+ "Send a steering message to a running background agent.",
3590
+ "The message interrupts after the current tool execution.",
3591
+ "Use this to redirect an agent's work without restarting it.",
3592
+ ].join(" "),
3593
+ parameters: Type.Object({
3594
+ agent_id: Type.String({
3595
+ description: "The agent ID to steer",
3596
+ }),
3597
+ message: Type.String({
3598
+ description: "The message to inject into the agent's conversation",
3599
+ }),
3600
+ }),
3601
+ execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
3602
+ const { steerAgent } = await import('./session-manager');
3603
+
3604
+ try {
3605
+ const agent = steerAgent(params.agent_id, params.message);
3606
+
3607
+ if (!agent) {
3608
+ return {
3609
+ content: [{ type: "text" as const, text: `Agent ${params.agent_id} not found` }],
3610
+ details: undefined,
3611
+ isError: true,
3612
+ };
3613
+ }
3614
+
3615
+ return {
3616
+ content: [{
3617
+ type: "text" as const,
3618
+ text: `Steered agent ${params.agent_id}: "${params.message.slice(0, 50)}${params.message.length > 50 ? '...' : ''}"`,
3619
+ }],
3620
+ details: undefined,
3621
+ };
3622
+ } catch (err) {
3623
+ // F7 (issue #65): sanitize BEFORE the message reaches the main
3624
+ // agent's context. pi passes ctx as the 5th arg to tool execute,
3625
+ // so ctx.cwd is in scope — sibling-project paths are properly
3626
+ // masked (defensive process.cwd() fallback if ctx is undefined).
3627
+ const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err), ctx?.cwd);
3628
+ return {
3629
+ content: [{ type: "text" as const, text: `Failed to steer agent: ${message}` }],
3630
+ details: undefined,
3631
+ isError: true,
3632
+ };
3633
+ }
3634
+ },
3635
+ });
3636
+
3637
+ pi.registerTool({
3638
+ name: "stop_subagent",
3639
+ label: "Stop Subagent",
3640
+ description: [
3641
+ "Stop a running background agent by aborting its session.",
3642
+ "The agent's current operation is aborted and the session is marked stopped.",
3643
+ "Use this to halt a background agent that is no longer needed.",
3644
+ ].join(" "),
3645
+ parameters: Type.Object({
3646
+ agent_id: Type.String({
3647
+ description: "The agent ID to stop",
3648
+ }),
3649
+ }),
3650
+ execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
3651
+ const { stopAgent } = await import('./session-manager');
3652
+
3653
+ try {
3654
+ const agent = await stopAgent(params.agent_id);
3655
+
3656
+ if (!agent) {
3657
+ return {
3658
+ content: [{ type: "text" as const, text: `Agent ${params.agent_id} not found` }],
3659
+ details: undefined,
3660
+ isError: true,
3661
+ };
3662
+ }
3663
+
3664
+ return {
3665
+ content: [{
3666
+ type: "text" as const,
3667
+ text: `Stopped agent ${params.agent_id} (${agent.description}).`,
3668
+ }],
3669
+ details: undefined,
3670
+ };
3671
+ } catch (err) {
3672
+ // F7 (issue #65): sanitize BEFORE the message reaches the main
3673
+ // agent's context. pi passes ctx as the 5th arg to tool execute,
3674
+ // so ctx.cwd is in scope — sibling-project paths are properly
3675
+ // masked (defensive process.cwd() fallback if ctx is undefined).
3676
+ const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err), ctx?.cwd);
3677
+ return {
3678
+ content: [{ type: "text" as const, text: `Failed to stop agent: ${message}` }],
3679
+ details: undefined,
3680
+ isError: true,
3681
+ };
3682
+ }
3683
+ },
3684
+ });
3685
+
3686
+ // -------------------------------------------------------------------
3687
+ // Session lifecycle — F7: Session-bound state initialization
3688
+ // -------------------------------------------------------------------
3689
+
3690
+ pi.on("session_start", async (_event, ctx) => {
3691
+ // Issue #147: capture the session context for the completion-push subscriber.
3692
+ sessionCtx = ctx;
3693
+ // Load built-in presets
3694
+ const presetsDir = path.join(__dirname, "..", "presets");
3695
+ state.builtinPresets = loadBuiltinPresets(presetsDir, log);
3696
+ state.customPresets = loadCustomPresets(ctx.cwd, log);
3697
+ // Builtin templates + merged full stack (custom overrides builtin)
3698
+ const templatesDir = path.join(__dirname, "..", "templates");
3699
+ state.builtinTemplates = loadBuiltinTemplates(templatesDir, log);
3700
+ state.config.templates = loadAllTemplates(ctx.cwd, log, templatesDir);
3701
+
3702
+ // Issue #81: cross-check template `preset:` refs against the full
3703
+ // preset universe now that both loads are complete — warn (never skip)
3704
+ // for dangling references instead of running preset-less silently with
3705
+ // auto-route suppressed.
3706
+ validateTemplatePresetRefs(
3707
+ state.config.templates,
3708
+ getAllPresets(state.builtinPresets, state.customPresets),
3709
+ log,
3710
+ );
3711
+
3712
+ // Migration: write any session-persisted presets to files
3713
+ if (state._migratedPresets && state._migratedPresets.length > 0) {
3714
+ for (const preset of state._migratedPresets) {
3715
+ writePresetFile(preset, path.join(ctx.cwd, ".pi", "brl-subagent", "presets"));
3716
+ }
3717
+ state._migratedPresets = undefined;
3718
+ state.customPresets = loadCustomPresets(ctx.cwd, log);
3719
+ state.config.templates = loadAllTemplates(ctx.cwd, log);
3720
+ log?.info("Migrated presets from session to files");
3721
+ }
3722
+
3723
+ // R2: Clean stale temp dirs from previous sessions
3724
+ cleanupTempDirs(ctx.cwd).then((count) => {
3725
+ if (count > 0) log.info("Cleaned stale temp directories", { count });
3726
+ });
3727
+
3728
+ log.info("Session started", {
3729
+ builtinPresets: state.builtinPresets.length,
3730
+ builtinTemplates: state.builtinTemplates.length,
3731
+ presetsDir,
3732
+ });
3733
+
3734
+ // F5/F9: Safe state restoration with type guards
3735
+ state.restoreFromSession(ctx);
3736
+
3737
+ // Check for updates (non-blocking, once per 24h)
3738
+ if (state.config.updateCheckEnabled) {
3739
+ const now = Date.now();
3740
+ if (now - state.config.lastUpdateCheck > UPDATE_CHECK_INTERVAL_MS) {
3741
+ state.config.lastUpdateCheck = now;
3742
+ checkForUpdates(currentVersion, log).then((result) => {
3743
+ if (result?.available) {
3744
+ ctx.ui.notify(
3745
+ "brl-subagent " + result.version + " available (current: " + currentVersion + "). Visit " + result.url + " to update. /brl-subagent update-check to disable.",
3746
+ "info"
3747
+ );
3748
+ log.info("Update available", { current: currentVersion, latest: result.version });
3749
+ }
3750
+ }).catch(() => {}); // silently ignore errors
3751
+ }
3752
+ }
3753
+
3754
+ updateStatus(state, ctx);
3755
+ });
3756
+
3757
+ // F7: Clean up session-bound state on shutdown
3758
+ pi.on("session_shutdown", async (_event, _ctx) => {
3759
+ log.info("Session shutting down", {
3760
+ activeSubagents: state.activeSubagents,
3761
+ });
3762
+
3763
+ // Clear all live subagent sessions
3764
+ state.subagentSessions.clear();
3765
+ // Drop pending finalize claims too (PR #71 review: the claim set must
3766
+ // not outlive the map it guards — a fresh session could otherwise
3767
+ // no-op on an id a stale claim still remembers).
3768
+ state.resetLiveFinalizeClaims();
3769
+ // Reset counters
3770
+ state.activeSubagents = 0;
3771
+ state.completedSubagents = 0;
3772
+ state.failedSubagents = 0;
3773
+ state.unseenSubagents = 0;
3774
+ // Clear pending queue
3775
+ state.pendingQueue.length = 0;
3776
+ });
3777
+
3778
+ // -------------------------------------------------------------------
3779
+ // Issue #147: completion-push wake — subscribe to terminal event-bus events
3780
+ // -------------------------------------------------------------------
3781
+ // The event-bus is the complete terminal chokepoint (subagent:completed /
3782
+ // subagent:failed / subagent:stopped), so the conductor is woken via
3783
+ // pi.sendMessage + triggerTurn when a background run reaches a terminal state.
3784
+ // Delivery is always-on (minimum nextTurn); the completionNotify knob controls
3785
+ // the WAKE (D1+D2 — see notify-completion.ts). The dedupe set defends the
3786
+ // pathological double-emit path (first terminal event per id wins); it is
3787
+ // capped simply (cleared when it exceeds 200 entries).
3788
+ //
3789
+ // Stopped-run degradation — ACCEPTED BY DESIGN (review #2, adjudicated
3790
+ // 2026-09-01): subagent:stopped fires at stop time (updateAgentStatus) BEFORE
3791
+ // finalizeRunEntry stamps cost/duration/errorCategory and before finalOutput
3792
+ // is captured. So the stopped shape may lack those fields and the run entry
3793
+ // resolveRunEntry returns may be the pre-finalize spawn entry. The wake is
3794
+ // the point; details are best-effort — the notification must never throw or
3795
+ // block on their absence.
3796
+ const terminalSeen = new Set<string>();
3797
+ const terminalTypes = ["subagent:completed", "subagent:failed", "subagent:stopped"] as const;
3798
+ for (const type of terminalTypes) {
3799
+ eventBus.on(type, (event) => {
3800
+ void deliverCompletionAlert(event);
3801
+ });
3802
+ }
3803
+
3804
+ async function deliverCompletionAlert(event: SubagentEvent): Promise<void> {
3805
+ const id = event.agentId;
3806
+ if (!markTerminalSeen(terminalSeen, id)) return;
3807
+ const ctx = sessionCtx;
3808
+ if (!ctx) return;
3809
+ try {
3810
+ const { getAgent } = await import("./session-manager");
3811
+ const agent = getAgent(id);
3812
+ if (!agent) return; // pruned or foreign id — nothing to notify
3813
+ const knob = state.config.completionNotify ?? "all";
3814
+ const run = resolveRunEntry(state.getRunEntries(ctx), id);
3815
+ const message = buildCompletionMessage(agent, run);
3816
+ const delivery = resolveDelivery(normalizeCompletionStatus(agent.status), knob);
3817
+ sendCompletionNotification(pi, message, delivery);
3818
+ } catch (err) {
3819
+ log.warn(`completion-push handler failed for ${id}`, {
3820
+ error: (err as Error).message,
3821
+ });
3822
+ }
3823
+ }
3824
+ }