brl-subagent 2.3.8 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Entry point: tool/command registration, the delegate_task handlers, and the execution-mode runners.
1
2
  /**
2
3
  * brl-subagent — multi-agent orchestration for pi.
3
4
  * Entry point: tool registrations, command surface, and the multi-mode runners.
@@ -29,6 +30,7 @@ import type {
29
30
  GraphDetails,
30
31
  GraphWave,
31
32
  DelegateTaskDetails,
33
+ DelegateTaskParams,
32
34
  Priority,
33
35
  TaskTemplate,
34
36
  ToolResult,
@@ -65,10 +67,12 @@ import {
65
67
  mergeWorkBranch,
66
68
  } from "./git";
67
69
  import { preflightCheck } from "./preflight";
70
+ import { gateSessionCost, rejectApprovalAlwaysInBackground, runPreTaskValidation, validateDelegationTargets, formatValidationWarnings } from "./prelude";
68
71
  import { loadBuiltinPresets, loadCustomPresets, getAllPresets, writePresetFile, formatPresetRestriction, formatToolRestriction } from "./presets";
69
72
  import { modelIsAvailable } from "./model-availability";
70
- import { validatePreTask, diagnoseFailure } from "./validate";
73
+ import { validatePreTask, diagnoseFailure, DEFAULT_BACKGROUND_DEADLINE_MS } from "./validate";
71
74
  import { findUnknownParams, KNOWN_DELEGATE_KEYS, resolveSubagentParams, snapshotOriginalParams } from "./params";
75
+ import { delegateTaskParamsSchema } from "./schema";
72
76
  import { createSessionState } from "./state";
73
77
  import { makeLiveOnUpdate, createUnitRun, finalizeUnitRun, finalizeUnitRunCrash, pruneHistoryIfNeeded, registerLiveRun } from "./unit-run";
74
78
  import { buildSubagentPrompt, describePromptMode } from "./prompt";
@@ -99,6 +103,7 @@ import {
99
103
  showRunHistory,
100
104
  showMonitor,
101
105
  showDashboard,
106
+ showComplianceMenu,
102
107
  showRetryMenu,
103
108
  renderDelegateCall,
104
109
  renderDelegateResult,
@@ -118,35 +123,6 @@ import {
118
123
  // Helpers
119
124
  // ---------------------------------------------------------------------------
120
125
 
121
- /**
122
- * Sanitize a text preview for markdown-rendered notifications: strip code
123
- * fences (``` and ~~~) and backticks, keep whole non-empty lines with
124
- * mid-line truncation for over-long lines, and never split surrogate pairs
125
- * at the boundary.
126
- */
127
- function sanitizePreview(text: string, maxLen = 500): string {
128
- // Strip markdown fences and backticks
129
- const cleaned = text.replace(/```/g, '').replace(/~~~+/g, '').replace(/`/g, '');
130
- const lines = cleaned.split('\n').filter(l => l.trim());
131
- let out = '';
132
- for (const line of lines) {
133
- if (out.length + line.length + 1 > maxLen) {
134
- // Mid-line truncation: keep as much of this line as fits
135
- const remaining = maxLen - out.length - 1;
136
- if (remaining > 10) {
137
- out += line.slice(0, remaining - 3) + '...\n';
138
- }
139
- break;
140
- }
141
- out += line + '\n';
142
- }
143
- // Avoid splitting surrogate pairs at the boundary (reachable now via slice above)
144
- const safe = out.slice(0, maxLen);
145
- const lastChar = safe[safe.length - 1];
146
- const stripped = lastChar && /[\uD800-\uDBFF]/.test(lastChar) ? safe.slice(0, -1) : safe;
147
- return stripped.trimEnd();
148
- }
149
-
150
126
  /**
151
127
  * DRY helper (issue #154 review): shape an inventory of items into a single
152
128
  * comma-delimited summary line for the LLM-facing delegation guidance. Both
@@ -191,6 +167,17 @@ export function buildTemplateGuideline(templateSummary: string): string {
191
167
  // Extension entry point
192
168
  // ---------------------------------------------------------------------------
193
169
 
170
+ /**
171
+ * Compile-time coverage (issue #229 item 4): fails to compile if
172
+ * DelegateTaskParams omits any KNOWN_DELEGATE_KEYS entry. Runtime drift is
173
+ * invisible (the schema passes the key through, the handler ignores it), so
174
+ * only the compiler can catch it. Together with per-step-model.test.ts's
175
+ * runtime ratchet this closes the chain: registered schema <-> KNOWN_DELEGATE_KEYS <-> DelegateTaskParams.
176
+ */
177
+ type _KnownDelegateKey = (typeof KNOWN_DELEGATE_KEYS extends Set<infer K> ? K : never) & string;
178
+ type _ExpectNever<T extends never> = T;
179
+ type _DelegateParamCoverage = _ExpectNever<Exclude<_KnownDelegateKey, keyof DelegateTaskParams>>;
180
+
194
181
  export default function (pi: ExtensionAPI) {
195
182
  const log = createLogger("brl-subagent");
196
183
 
@@ -399,49 +386,39 @@ export default function (pi: ExtensionAPI) {
399
386
  };
400
387
  }
401
388
 
389
+ // Per-unit display name for validation prefixes: the label when present,
390
+ // else the task truncated to 60 chars. Shared by the foreground parallel
391
+ // pre-pass and the background fan-out pre-pass (issue #220) so a unit
392
+ // renders identically in either shape.
393
+ const displayTaskName = (merged: ReturnType<typeof mergeSubTaskParams>): string =>
394
+ merged.label ??
395
+ (merged.task.length > 60 ? `${merged.task.slice(0, 57)}...` : merged.task);
396
+
402
397
  // -------------------------------------------------------------------
403
398
  // P1: runChainMode
404
399
  // -------------------------------------------------------------------
405
400
 
406
401
  async function runChainMode(
407
- params: Record<string, unknown>,
402
+ params: DelegateTaskParams,
408
403
  signal: AbortSignal | undefined,
409
404
  onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
410
405
  ctx: ExtensionContext,
411
406
  ): Promise<ToolResult<SubagentResult | ChainDetails | undefined>> {
412
- const chainSteps = params.chain as SubTaskParams[];
407
+ const chainSteps: SubTaskParams[] = params.chain!;
413
408
 
414
409
  // Issue #133: dispatch-start timestamp — the aggregate run entry's
415
410
  // startedAt (captured once, at mode entry, not at completion).
416
411
  const chainModeStartedAt = new Date().toISOString();
417
412
 
418
413
  // R5: Check session cost limit before spawning
419
- const perTaskEstimate =
420
- state.config.perTaskCostEstimate > 0
421
- ? state.config.perTaskCostEstimate
422
- : 0.05;
423
- if (state.checkCostLimit(perTaskEstimate * chainSteps.length, ctx)) {
424
- const currentTotal = state.getSessionTotalCost(ctx);
425
- const limit = state.config.sessionCostLimit;
426
- log.warn("Chain delegation rejected: session cost limit reached", {
427
- currentTotal,
428
- estimatedCost: perTaskEstimate * chainSteps.length,
429
- limit,
430
- });
431
- return {
432
- content: [
433
- {
434
- type: "text" as const,
435
- text:
436
- `Cannot delegate: session cost limit reached ` +
437
- `($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
438
- `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
439
- },
440
- ],
441
- details: undefined,
442
- isError: true,
443
- };
444
- }
414
+ const costError = gateSessionCost({
415
+ state,
416
+ ctx,
417
+ log,
418
+ units: chainSteps.length,
419
+ label: "Chain delegation",
420
+ });
421
+ if (costError) return costError;
445
422
 
446
423
  // Reject delegation if recursion depth exceeds configured max
447
424
  const currentDepth = getCurrentDepth();
@@ -521,7 +498,8 @@ export default function (pi: ExtensionAPI) {
521
498
  // #32). Chain top-level tasks are empty (modeCount forbids task+chain), so
522
499
  // keyword warnings skip on empty text and only the hard outputFile-vs-write
523
500
  // conflict applies (issue #34). The mode-level outputFile on globalParams is
524
- // the one validated; per-step outputFiles are future work (issue #3).
501
+ // the one validated here; every step's own cwd/outputFile/tools are
502
+ // validated by the per-step pre-pass below (issue #222).
525
503
  const validation = validatePreTask({
526
504
  task: globalParams.task,
527
505
  toolOptions: globalParams.toolOptions,
@@ -542,6 +520,47 @@ export default function (pi: ExtensionAPI) {
542
520
  };
543
521
  }
544
522
 
523
+ // Per-step pre-pass (issue #222): resolve + validate EVERY step before
524
+ // any spawn, so a single bad step rejects the chain with nothing
525
+ // started — the same all-before-any-spawn semantics the background
526
+ // fan-out and the parallel `tasks` shape use. Chain steps are fully
527
+ // known up front and a step's cwd/outputFile never depend on a previous
528
+ // step's output (only its TASK text consumes {previous}), so the pass
529
+ // can run up front — and must, because discovering step 5's bad cwd
530
+ // after four steps already ran would waste their whole budget.
531
+ const stepCwds: string[] = [];
532
+ const stepWarnings: string[] = [];
533
+ for (let i = 0; i < chainSteps.length; i++) {
534
+ const merged = mergeSubTaskParams(globalParams, chainSteps[i]);
535
+ const stepPrefix = `Step ${i + 1} ("${displayTaskName(merged)}")`;
536
+
537
+ const stepTargets = validateDelegationTargets({
538
+ ctx,
539
+ effectiveCwd: merged.effectiveCwd,
540
+ outputFile: merged.outputFile,
541
+ prefix: stepPrefix,
542
+ });
543
+ if (!stepTargets.ok) return stepTargets.error;
544
+ stepCwds.push(stepTargets.cwd);
545
+
546
+ const stepValidation = runPreTaskValidation({
547
+ log,
548
+ label: "Chain",
549
+ prefix: stepPrefix,
550
+ logContext: { step: i + 1 },
551
+ preTask: {
552
+ task: merged.task,
553
+ toolOptions: merged.toolOptions,
554
+ thinkingLevel: merged.thinkingLevel,
555
+ gitMode: globalParams.resolvedGitMode,
556
+ outputFile: merged.outputFile,
557
+ force: params.force as boolean | undefined,
558
+ },
559
+ });
560
+ if (stepValidation.error) return stepValidation.error;
561
+ for (const w of stepValidation.warnings) stepWarnings.push(`${stepPrefix}: ${w}`);
562
+ }
563
+
545
564
  // Resolve model once (per-call top-level model override beats preset)
546
565
  const modelResult = resolveSubagentModel(ctx, globalParams.resolvedPreset, params.model as string | undefined);
547
566
  if (!modelResult.ok) return modelResult.error;
@@ -706,7 +725,10 @@ export default function (pi: ExtensionAPI) {
706
725
  let result: SubagentResult;
707
726
  try {
708
727
  result = await runSubagent(
709
- resolvedCwd,
728
+ // Issue #222: this step's OWN validated cwd (the pre-pass
729
+ // resolved it against ctx.cwd) — the mode-level
730
+ // resolvedCwd is the fallback for steps that declare none.
731
+ stepCwds[i],
710
732
  subagentPrompt,
711
733
  stepModel,
712
734
  merged.thinkingLevel,
@@ -724,7 +746,7 @@ export default function (pi: ExtensionAPI) {
724
746
  // run entry as failed — crashOutput comes from the live entry's
725
747
  // last streamed output (the crash result itself has no
726
748
  // messages), and errorCategory is classified onto originalParams.
727
- finalizeUnitRunCrash(state, pi, run, err, "Chain step", resolvedCwd, log, {
749
+ finalizeUnitRunCrash(state, pi, run, err, "Chain step", stepCwds[i], log, {
728
750
  step: i + 1,
729
751
  });
730
752
  }
@@ -869,9 +891,15 @@ export default function (pi: ExtensionAPI) {
869
891
  };
870
892
  state.persistRun(pi, chainAggregateRun);
871
893
 
894
+ // Per-step validation warnings ride the result, prefixed with their
895
+ // step (issue #222), after the JSON payload — empty renders nothing so
896
+ // warning-free results stay byte-identical.
872
897
  return {
873
898
  content: [
874
- { type: "text" as const, text: JSON.stringify(chainDetails, null, 2) },
899
+ {
900
+ type: "text" as const,
901
+ text: JSON.stringify(chainDetails, null, 2) + formatValidationWarnings(stepWarnings),
902
+ },
875
903
  ],
876
904
  details: chainDetails,
877
905
  };
@@ -895,40 +923,22 @@ export default function (pi: ExtensionAPI) {
895
923
  // -------------------------------------------------------------------
896
924
 
897
925
  async function runParallelMode(
898
- params: Record<string, unknown>,
926
+ params: DelegateTaskParams,
899
927
  signal: AbortSignal | undefined,
900
928
  onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
901
929
  ctx: ExtensionContext,
902
930
  ): Promise<ToolResult<SubagentResult | ParallelDetails | undefined>> {
903
- const taskList = params.tasks as SubTaskParams[];
931
+ const taskList: SubTaskParams[] = params.tasks!;
904
932
 
905
933
  // R5: Check session cost limit before spawning
906
- const perTaskEstimate =
907
- state.config.perTaskCostEstimate > 0
908
- ? state.config.perTaskCostEstimate
909
- : 0.05;
910
- if (state.checkCostLimit(perTaskEstimate * taskList.length, ctx)) {
911
- const currentTotal = state.getSessionTotalCost(ctx);
912
- const limit = state.config.sessionCostLimit;
913
- log.warn("Parallel delegation rejected: session cost limit reached", {
914
- currentTotal,
915
- estimatedCost: perTaskEstimate * taskList.length,
916
- limit,
917
- });
918
- return {
919
- content: [
920
- {
921
- type: "text" as const,
922
- text:
923
- `Cannot delegate: session cost limit reached ` +
924
- `($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
925
- `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
926
- },
927
- ],
928
- details: undefined,
929
- isError: true,
930
- };
931
- }
934
+ const costError = gateSessionCost({
935
+ state,
936
+ ctx,
937
+ log,
938
+ units: taskList.length,
939
+ label: "Parallel delegation",
940
+ });
941
+ if (costError) return costError;
932
942
 
933
943
  // Reject delegation if recursion depth exceeds configured max
934
944
  const currentDepth = getCurrentDepth();
@@ -975,17 +985,9 @@ export default function (pi: ExtensionAPI) {
975
985
  );
976
986
 
977
987
  // Validate CWD once
978
- const cwdResult = validateCwd(globalParams.effectiveCwd, ctx.cwd);
979
- if (!cwdResult.ok) {
980
- return {
981
- content: [
982
- { type: "text" as const, text: `Invalid cwd: ${cwdResult.error}` },
983
- ],
984
- details: undefined,
985
- isError: true,
986
- };
987
- }
988
- const resolvedCwd = cwdResult.value;
988
+ const targets = validateDelegationTargets({ ctx, effectiveCwd: globalParams.effectiveCwd });
989
+ if (!targets.ok) return targets.error;
990
+ const resolvedCwd = targets.cwd;
989
991
 
990
992
  // Pre-flight checks — fail fast before consuming resources
991
993
  const pfResult = preflightCheck(resolvedCwd);
@@ -1008,26 +1010,64 @@ export default function (pi: ExtensionAPI) {
1008
1010
  // #32). Parallel top-level tasks are empty (modeCount forbids task+tasks),
1009
1011
  // so keyword warnings skip on empty text and only the hard
1010
1012
  // outputFile-vs-write conflict applies (issue #34). The mode-level
1011
- // outputFile on globalParams is the one validated; per-step outputFiles
1012
- // are future work (issue #3).
1013
- const validation = validatePreTask({
1014
- task: globalParams.task,
1015
- toolOptions: globalParams.toolOptions,
1016
- thinkingLevel: globalParams.thinkingLevel,
1017
- gitMode: globalParams.resolvedGitMode,
1018
- outputFile: globalParams.outputFile,
1013
+ // outputFile on globalParams is the one validated here; every task's own
1014
+ // cwd/outputFile/tools are validated by the per-task pre-pass below
1015
+ // (issue #222).
1016
+ const preTask = runPreTaskValidation({
1017
+ log,
1018
+ label: "Parallel",
1019
+ preTask: {
1020
+ task: globalParams.task,
1021
+ toolOptions: globalParams.toolOptions,
1022
+ thinkingLevel: globalParams.thinkingLevel,
1023
+ gitMode: globalParams.resolvedGitMode,
1024
+ outputFile: globalParams.outputFile,
1025
+ },
1019
1026
  });
1020
- if (validation.warnings.length > 0) {
1021
- log.warn("Parallel pre-task validation warnings", { warnings: validation.warnings });
1022
- }
1023
- if (!validation.valid) {
1024
- const errText = validation.errors.join("; ");
1025
- log.warn("Parallel pre-task validation failed", { errors: validation.errors });
1026
- return {
1027
- content: [{ type: "text" as const, text: errText }],
1028
- details: undefined,
1029
- isError: true,
1030
- };
1027
+ if (preTask.error) return preTask.error;
1028
+
1029
+ // Per-task pre-pass (issue #220, extended by #222): validate EVERY task
1030
+ // before any spawn, so a single bad task rejects the batch with nothing
1031
+ // started (`force` degrades capability errors to warnings) — the same
1032
+ // semantics runBackgroundFanOut applies. Each task's OWN cwd is resolved
1033
+ // and validated here (with its outputFile, resolved against that cwd) and
1034
+ // the resolved value is what that task spawns in — issue #222 closed the
1035
+ // gap where a per-task `cwd` was merged but the spawn still used the
1036
+ // mode-level cwd. Per-task warnings are prefixed with their unit and
1037
+ // collected here to ride the final result. The mode-entry validation
1038
+ // above stays for the mode-level outputFile (issue #34).
1039
+ const taskWarnings: string[] = [];
1040
+ // Parallel index → the task's own validated cwd (issue #222).
1041
+ const taskCwds: string[] = [];
1042
+ for (let i = 0; i < taskList.length; i++) {
1043
+ const merged = mergeSubTaskParams(globalParams, taskList[i]);
1044
+ const taskPrefix = `Task ${i + 1} ("${displayTaskName(merged)}")`;
1045
+
1046
+ const taskTargets = validateDelegationTargets({
1047
+ ctx,
1048
+ effectiveCwd: merged.effectiveCwd,
1049
+ outputFile: merged.outputFile,
1050
+ prefix: taskPrefix,
1051
+ });
1052
+ if (!taskTargets.ok) return taskTargets.error;
1053
+ taskCwds.push(taskTargets.cwd);
1054
+
1055
+ const unitValidation = runPreTaskValidation({
1056
+ log,
1057
+ label: "Parallel",
1058
+ prefix: taskPrefix,
1059
+ logContext: { task: i + 1 },
1060
+ preTask: {
1061
+ task: merged.task,
1062
+ toolOptions: merged.toolOptions,
1063
+ thinkingLevel: merged.thinkingLevel,
1064
+ gitMode: globalParams.resolvedGitMode,
1065
+ outputFile: merged.outputFile,
1066
+ force: params.force as boolean | undefined,
1067
+ },
1068
+ });
1069
+ if (unitValidation.error) return unitValidation.error;
1070
+ for (const w of unitValidation.warnings) taskWarnings.push(`${taskPrefix}: ${w}`);
1031
1071
  }
1032
1072
 
1033
1073
  // Resolve model once (per-call top-level model override beats preset)
@@ -1078,6 +1118,7 @@ export default function (pi: ExtensionAPI) {
1078
1118
  const runTask = async (
1079
1119
  index: number,
1080
1120
  merged: ReturnType<typeof mergeSubTaskParams>,
1121
+ unitCwd: string,
1081
1122
  ): Promise<boolean> => {
1082
1123
 
1083
1124
  // C3: Resolve this step's model override (step.model > global resolved model)
@@ -1154,7 +1195,8 @@ export default function (pi: ExtensionAPI) {
1154
1195
  // the live ghost (grace logic treats a 'running' record as live).
1155
1196
  try {
1156
1197
  const result = await runSubagent(
1157
- resolvedCwd,
1198
+ // Issue #222: this task's OWN validated cwd.
1199
+ unitCwd,
1158
1200
  subagentPrompt,
1159
1201
  stepModel,
1160
1202
  merged.thinkingLevel,
@@ -1197,7 +1239,7 @@ export default function (pi: ExtensionAPI) {
1197
1239
  // the live drill-in loops forever. finalizeLiveSubagent is
1198
1240
  // idempotent; the rethrow keeps Promise.allSettled semantics (the
1199
1241
  // caller's finally still releases the concurrency slot).
1200
- finalizeUnitRunCrash(state, pi, run, err, "Parallel subtask", resolvedCwd, log, {
1242
+ finalizeUnitRunCrash(state, pi, run, err, "Parallel subtask", unitCwd, log, {
1201
1243
  index,
1202
1244
  });
1203
1245
  }
@@ -1275,7 +1317,7 @@ export default function (pi: ExtensionAPI) {
1275
1317
  }
1276
1318
  let taskSuccess = false;
1277
1319
  try {
1278
- taskSuccess = await runTask(index, merged);
1320
+ taskSuccess = await runTask(index, merged, taskCwds[index]);
1279
1321
  } finally {
1280
1322
  // Issue #137: release with the unit's real success — the status
1281
1323
  // bar's "N failed" count reflects the true outcome.
@@ -1332,9 +1374,15 @@ export default function (pi: ExtensionAPI) {
1332
1374
  totalCost,
1333
1375
  });
1334
1376
 
1377
+ // C: per-task validation warnings ride the result, prefixed with their
1378
+ // task (issue #220), after the JSON payload — empty renders nothing so
1379
+ // warning-free results stay byte-identical.
1335
1380
  return {
1336
1381
  content: [
1337
- { type: "text" as const, text: JSON.stringify(parallelDetails, null, 2) },
1382
+ {
1383
+ type: "text" as const,
1384
+ text: JSON.stringify(parallelDetails, null, 2) + formatValidationWarnings(taskWarnings),
1385
+ },
1338
1386
  ],
1339
1387
  details: parallelDetails,
1340
1388
  };
@@ -1345,12 +1393,12 @@ export default function (pi: ExtensionAPI) {
1345
1393
  // -------------------------------------------------------------------
1346
1394
 
1347
1395
  async function runGraphMode(
1348
- params: Record<string, unknown>,
1396
+ params: DelegateTaskParams,
1349
1397
  signal: AbortSignal | undefined,
1350
1398
  onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
1351
1399
  ctx: ExtensionContext,
1352
1400
  ): Promise<ToolResult<SubagentResult | GraphDetails | undefined>> {
1353
- const graphTasks = params.graph as GraphTask[];
1401
+ const graphTasks: GraphTask[] = params.graph!;
1354
1402
 
1355
1403
  // Issue #133: dispatch-start timestamp — the aggregate run entry's
1356
1404
  // startedAt (captured once, at mode entry, not at completion).
@@ -1372,32 +1420,14 @@ export default function (pi: ExtensionAPI) {
1372
1420
  }
1373
1421
 
1374
1422
  // R5: Check session cost limit before spawning
1375
- const perTaskEstimate =
1376
- state.config.perTaskCostEstimate > 0
1377
- ? state.config.perTaskCostEstimate
1378
- : 0.05;
1379
- if (state.checkCostLimit(perTaskEstimate * graphTasks.length, ctx)) {
1380
- const currentTotal = state.getSessionTotalCost(ctx);
1381
- const limit = state.config.sessionCostLimit;
1382
- log.warn("Graph delegation rejected: session cost limit reached", {
1383
- currentTotal,
1384
- estimatedCost: perTaskEstimate * graphTasks.length,
1385
- limit,
1386
- });
1387
- return {
1388
- content: [
1389
- {
1390
- type: "text" as const,
1391
- text:
1392
- `Cannot delegate: session cost limit reached ` +
1393
- `($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
1394
- `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
1395
- },
1396
- ],
1397
- details: undefined,
1398
- isError: true,
1399
- };
1400
- }
1423
+ const costError = gateSessionCost({
1424
+ state,
1425
+ ctx,
1426
+ log,
1427
+ units: graphTasks.length,
1428
+ label: "Graph delegation",
1429
+ });
1430
+ if (costError) return costError;
1401
1431
 
1402
1432
  // Reject delegation if recursion depth exceeds configured max
1403
1433
  const currentDepth = getCurrentDepth();
@@ -1478,8 +1508,9 @@ export default function (pi: ExtensionAPI) {
1478
1508
  // #32). Graph top-level tasks are empty (modeCount forbids task+graph),
1479
1509
  // so keyword warnings skip on empty text and only the hard
1480
1510
  // outputFile-vs-write conflict applies (issue #34). The mode-level
1481
- // outputFile on globalParams is the one validated; per-step outputFiles
1482
- // are future work (issue #3).
1511
+ // outputFile on globalParams is the one validated here; every node's own
1512
+ // cwd/outputFile/tools are validated by the per-node pre-pass below
1513
+ // (issue #222).
1483
1514
  const validation = validatePreTask({
1484
1515
  task: globalParams.task,
1485
1516
  toolOptions: globalParams.toolOptions,
@@ -1555,6 +1586,73 @@ export default function (pi: ExtensionAPI) {
1555
1586
  }
1556
1587
  const waves = sortResult.waves;
1557
1588
 
1589
+ // Per-node pre-pass (issue #222): resolve + validate EVERY node before
1590
+ // any spawn, so a single bad node rejects the whole graph with nothing
1591
+ // started — the same all-before-any-spawn semantics the background
1592
+ // fan-out, the parallel `tasks` shape and the chain use. Each node's OWN
1593
+ // cwd is resolved and validated here (with its outputFile, resolved
1594
+ // against that cwd) and the resolved value is what that node spawns in.
1595
+ // The pre-pass runs on the DECLARED task text, before {id} substitution
1596
+ // (the substituted text is only known mid-dispatch, once a dependency's
1597
+ // output exists) — the declared text is validated with the placeholder
1598
+ // TOKENS stripped (a dependency whose id happens to be a blocking
1599
+ // keyword must not make `{<id>}` itself trip the keyword rules), while
1600
+ // the SUBSTITUTED content stays unvalidated by design. The host's own
1601
+ // wording is still validated, and refusing to validate up front would
1602
+ // mean a bad node in the LAST wave is discovered after every earlier
1603
+ // wave already spent its budget.
1604
+ const nodeCwds = new Map<string, string>();
1605
+ const nodeWarnings: string[] = [];
1606
+ for (const graphTask of graphTasks) {
1607
+ const merged = mergeSubTaskParams(globalParams, {
1608
+ task: graphTask.task,
1609
+ label: graphTask.label,
1610
+ model: graphTask.model,
1611
+ thinkingLevel: graphTask.thinkingLevel,
1612
+ priority: graphTask.priority,
1613
+ cwd: graphTask.cwd,
1614
+ timeout: graphTask.timeout,
1615
+ outputFile: graphTask.outputFile,
1616
+ tools: graphTask.tools,
1617
+ excludeTools: graphTask.excludeTools,
1618
+ noBuiltinTools: graphTask.noBuiltinTools,
1619
+ systemPrompt: graphTask.systemPrompt,
1620
+ inheritSystemPrompt: graphTask.inheritSystemPrompt,
1621
+ });
1622
+ const nodePrefix = `Node "${graphTask.id}"`;
1623
+
1624
+ const nodeTargets = validateDelegationTargets({
1625
+ ctx,
1626
+ effectiveCwd: merged.effectiveCwd,
1627
+ outputFile: merged.outputFile,
1628
+ prefix: nodePrefix,
1629
+ });
1630
+ if (!nodeTargets.ok) return nodeTargets.error;
1631
+ nodeCwds.set(graphTask.id, nodeTargets.cwd);
1632
+
1633
+ const nodeValidation = runPreTaskValidation({
1634
+ log,
1635
+ label: "Graph",
1636
+ prefix: nodePrefix,
1637
+ logContext: { id: graphTask.id },
1638
+ preTask: {
1639
+ // Placeholder TOKENS stripped (issue #222 review): the rules
1640
+ // read the declared string, so a `{<id>}` whose dependency id
1641
+ // is a blocking keyword would block on a token, not on text
1642
+ // the host wrote. The declared wording around it is unchanged
1643
+ // and still validated.
1644
+ task: merged.task.replace(GRAPH_OUTPUT_PLACEHOLDER_RE, " "),
1645
+ toolOptions: merged.toolOptions,
1646
+ thinkingLevel: merged.thinkingLevel,
1647
+ gitMode: globalParams.resolvedGitMode,
1648
+ outputFile: merged.outputFile,
1649
+ force: params.force as boolean | undefined,
1650
+ },
1651
+ });
1652
+ if (nodeValidation.error) return nodeValidation.error;
1653
+ for (const w of nodeValidation.warnings) nodeWarnings.push(`${nodePrefix}: ${w}`);
1654
+ }
1655
+
1558
1656
  // Build base prompt once
1559
1657
  const basePrompt = ctx.getSystemPrompt();
1560
1658
 
@@ -1626,6 +1724,10 @@ export default function (pi: ExtensionAPI) {
1626
1724
  };
1627
1725
 
1628
1726
  const merged = mergeSubTaskParams(globalParams, subTaskParams);
1727
+ // Issue #222: the node's OWN validated cwd (pre-pass);
1728
+ // resolvedCwd (the mode-level cwd) is the guaranteed
1729
+ // fallback, so a node that declares none still resolves.
1730
+ const nodeCwd = nodeCwds.get(graphTask.id) ?? resolvedCwd;
1629
1731
 
1630
1732
  // C3: Resolve this step's model override (step.model > global resolved model)
1631
1733
  const stepModel = resolveStepModel(ctx, merged.model, subagentModel);
@@ -1709,7 +1811,7 @@ export default function (pi: ExtensionAPI) {
1709
1811
  const stepOnUpdate = makeLiveOnUpdate(state, runId, onUpdate);
1710
1812
 
1711
1813
  const result = await runSubagent(
1712
- resolvedCwd,
1814
+ nodeCwd,
1713
1815
  subagentPrompt,
1714
1816
  stepModel,
1715
1817
  merged.thinkingLevel,
@@ -1760,7 +1862,7 @@ export default function (pi: ExtensionAPI) {
1760
1862
  // finalizeLiveSubagent is idempotent; the rethrow keeps
1761
1863
  // Promise.allSettled semantics (a failing node must NOT cancel
1762
1864
  // sibling nodes in the wave).
1763
- finalizeUnitRunCrash(state, pi, run, err, "Graph node", resolvedCwd, log, {
1865
+ finalizeUnitRunCrash(state, pi, run, err, "Graph node", nodeCwd, log, {
1764
1866
  id: graphTask.id,
1765
1867
  });
1766
1868
  } finally {
@@ -1857,9 +1959,15 @@ export default function (pi: ExtensionAPI) {
1857
1959
  };
1858
1960
  state.persistRun(pi, graphAggregateRun);
1859
1961
 
1962
+ // Per-node validation warnings ride the result, prefixed with their
1963
+ // node id (issue #222), after the JSON payload — empty renders
1964
+ // nothing so warning-free results stay byte-identical.
1860
1965
  return {
1861
1966
  content: [
1862
- { type: "text" as const, text: JSON.stringify(graphDetails, null, 2) },
1967
+ {
1968
+ type: "text" as const,
1969
+ text: JSON.stringify(graphDetails, null, 2) + formatValidationWarnings(nodeWarnings),
1970
+ },
1863
1971
  ],
1864
1972
  details: graphDetails,
1865
1973
  };
@@ -1890,39 +1998,21 @@ export default function (pi: ExtensionAPI) {
1890
1998
  async function runBackgroundFanOut(
1891
1999
  pi: ExtensionAPI,
1892
2000
  ctx: ExtensionContext,
1893
- params: Record<string, unknown>,
2001
+ params: DelegateTaskParams,
1894
2002
  signal: AbortSignal | undefined,
1895
2003
  ): Promise<ToolResult<SubagentResult | undefined>> {
1896
- const taskList = params.tasks as SubTaskParams[];
2004
+ const taskList: SubTaskParams[] = params.tasks!;
1897
2005
 
1898
2006
  // Session cost limit gates the WHOLE batch before any spawn — same
1899
2007
  // estimate/limit logic as runParallelMode, scaled by task count.
1900
- const perTaskEstimate =
1901
- state.config.perTaskCostEstimate > 0
1902
- ? state.config.perTaskCostEstimate
1903
- : 0.05;
1904
- if (state.checkCostLimit(perTaskEstimate * taskList.length, ctx)) {
1905
- const currentTotal = state.getSessionTotalCost(ctx);
1906
- const limit = state.config.sessionCostLimit;
1907
- log.warn("Background fan-out rejected: session cost limit reached", {
1908
- currentTotal,
1909
- estimatedCost: perTaskEstimate * taskList.length,
1910
- limit,
1911
- });
1912
- return {
1913
- content: [
1914
- {
1915
- type: "text" as const,
1916
- text:
1917
- `Cannot delegate: session cost limit reached ` +
1918
- `($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
1919
- `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
1920
- },
1921
- ],
1922
- details: undefined,
1923
- isError: true,
1924
- };
1925
- }
2008
+ const costError = gateSessionCost({
2009
+ state,
2010
+ ctx,
2011
+ log,
2012
+ units: taskList.length,
2013
+ label: "Background fan-out",
2014
+ });
2015
+ if (costError) return costError;
1926
2016
 
1927
2017
  // Resolve global params once — same call shape as runParallelMode.
1928
2018
  const globalParams = resolveSubagentParams(
@@ -1958,16 +2048,8 @@ export default function (pi: ExtensionAPI) {
1958
2048
  // Same rejection as the single-background path: approvalMode 'always'
1959
2049
  // cannot work in background — there is no interactive dialog to approve
1960
2050
  // the diff, for any of the N agents.
1961
- if (globalParams.resolvedApprovalMode === 'always') {
1962
- return {
1963
- content: [{ type: "text" as const, text:
1964
- `Cannot spawn background agent with approvalMode 'always': background agents run unattended ` +
1965
- `and cannot present the approval dialog. Use approvalMode 'auto' (default) or 'writes'.`
1966
- }],
1967
- details: undefined,
1968
- isError: true,
1969
- };
1970
- }
2051
+ const approvalError = rejectApprovalAlwaysInBackground(globalParams.resolvedApprovalMode);
2052
+ if (approvalError) return approvalError;
1971
2053
  // W5 parity with the single-background path: 'writes' silently
1972
2054
  // auto-approves in background — warn once so the caller knows none of
1973
2055
  // the N agents' diffs will be gated on approval.
@@ -1981,9 +2063,9 @@ export default function (pi: ExtensionAPI) {
1981
2063
  // git lock is awaited inside the first spawn and held until that agent
1982
2064
  // settles, so same-repository branch-mode spawns would serialize and
1983
2065
  // block this call for the whole chain. The rule is blanket (not
1984
- // same-cwd-only) because the lock is keyed by the cwd string, not the
1985
- // repository root — different subdirectories of one repo share a working
1986
- // tree without sharing a lock.
2066
+ // per-repository-checked) because the lock is keyed by the resolved
2067
+ // repository root: units in different directories of one repo share the
2068
+ // working tree and contend for the same lock.
1987
2069
  if (globalParams.resolvedGitMode === 'branch') {
1988
2070
  return {
1989
2071
  content: [{ type: "text" as const, text:
@@ -2006,11 +2088,10 @@ export default function (pi: ExtensionAPI) {
2006
2088
  cwd: string;
2007
2089
  outputFile: string | undefined;
2008
2090
  };
2009
- const displayTaskName = (merged: ReturnType<typeof mergeSubTaskParams>): string =>
2010
- merged.label ??
2011
- (merged.task.length > 60 ? `${merged.task.slice(0, 57)}...` : merged.task);
2012
2091
 
2013
2092
  const fanOutTasks: FanOutTask[] = [];
2093
+ // C: per-task warnings collect here to ride the immediate spawn result.
2094
+ const taskWarnings: string[] = [];
2014
2095
  for (let i = 0; i < taskList.length; i++) {
2015
2096
  const merged = mergeSubTaskParams(globalParams, taskList[i]);
2016
2097
  const name = displayTaskName(merged);
@@ -2018,48 +2099,32 @@ export default function (pi: ExtensionAPI) {
2018
2099
  // Per-task model override wins; the global model is the fallback.
2019
2100
  const stepModel = resolveStepModel(ctx, merged.model, globalModel);
2020
2101
 
2021
- const cwdResult = validateCwd(merged.effectiveCwd, ctx.cwd);
2022
- if (!cwdResult.ok) {
2023
- return {
2024
- content: [{ type: "text" as const, text: `${taskPrefix}: Invalid cwd: ${cwdResult.error}` }],
2025
- details: undefined,
2026
- isError: true,
2027
- };
2028
- }
2029
- let resolvedOutputFile: string | undefined;
2030
- if (merged.outputFile) {
2031
- const ofResult = validateOutputFile(merged.outputFile, cwdResult.value);
2032
- if (!ofResult.ok) {
2033
- return {
2034
- content: [{ type: "text" as const, text: `${taskPrefix}: Invalid outputFile: ${ofResult.error}` }],
2035
- details: undefined,
2036
- isError: true,
2037
- };
2038
- }
2039
- resolvedOutputFile = ofResult.value;
2040
- }
2041
-
2042
- const validation = validatePreTask({
2043
- task: merged.task,
2044
- toolOptions: merged.toolOptions,
2045
- thinkingLevel: merged.thinkingLevel,
2046
- gitMode: globalParams.resolvedGitMode,
2102
+ const targets = validateDelegationTargets({
2103
+ ctx,
2104
+ effectiveCwd: merged.effectiveCwd,
2047
2105
  outputFile: merged.outputFile,
2106
+ prefix: taskPrefix,
2048
2107
  });
2049
- if (validation.warnings.length > 0) {
2050
- log.warn("Background fan-out pre-task validation warnings", { task: i + 1, warnings: validation.warnings });
2051
- }
2052
- if (!validation.valid) {
2053
- const errText = validation.errors.join("; ");
2054
- log.warn("Background fan-out pre-task validation failed", { task: i + 1, errors: validation.errors });
2055
- return {
2056
- content: [{ type: "text" as const, text: `${taskPrefix}: ${errText}` }],
2057
- details: undefined,
2058
- isError: true,
2059
- };
2060
- }
2108
+ if (!targets.ok) return targets.error;
2109
+
2110
+ const preTask = runPreTaskValidation({
2111
+ log,
2112
+ label: "Background fan-out",
2113
+ prefix: taskPrefix,
2114
+ logContext: { task: i + 1 },
2115
+ preTask: {
2116
+ task: merged.task,
2117
+ toolOptions: merged.toolOptions,
2118
+ thinkingLevel: merged.thinkingLevel,
2119
+ gitMode: globalParams.resolvedGitMode,
2120
+ outputFile: merged.outputFile,
2121
+ force: params.force as boolean | undefined,
2122
+ },
2123
+ });
2124
+ if (preTask.error) return preTask.error;
2125
+ for (const w of preTask.warnings) taskWarnings.push(`${taskPrefix}: ${w}`);
2061
2126
 
2062
- fanOutTasks.push({ merged, model: stepModel, cwd: cwdResult.value, outputFile: resolvedOutputFile });
2127
+ fanOutTasks.push({ merged, model: stepModel, cwd: targets.cwd, outputFile: targets.outputFile });
2063
2128
  }
2064
2129
 
2065
2130
  // Spawn pass — sequential, in task order, through the shared tail.
@@ -2112,7 +2177,11 @@ export default function (pi: ExtensionAPI) {
2112
2177
  timeout: merged.timeout,
2113
2178
  gitMode: globalParams.resolvedGitMode,
2114
2179
  // Retry parity with createUnitRun: snapshot this unit's resolved
2115
- // values so a retry restores them.
2180
+ // values so a retry restores them. The execution-shape fields
2181
+ // (background/gitMode/approvalMode/force) are deliberately NOT
2182
+ // passed here — a fan-out unit is spec'd to retry as a single
2183
+ // run, so it must not inherit this spawn's background-ness.
2184
+ // Same omission in unit-run.ts's createUnitRun.
2116
2185
  originalParams: snapshotOriginalParams({
2117
2186
  systemPrompt: merged.customSP,
2118
2187
  inheritSystemPrompt: merged.inheritSP,
@@ -2149,7 +2218,9 @@ export default function (pi: ExtensionAPI) {
2149
2218
  started.push({ id: agent.id, label: merged.label });
2150
2219
  }
2151
2220
 
2152
- // Text-only result: ids in task order + the wake hint.
2221
+ // Text-only result: ids in task order + the wake hint. C: per-task
2222
+ // validation warnings ride the immediate SPAWN result (prefixed with
2223
+ // their task) so the conductor can stop/re-dispatch in the same turn.
2153
2224
  const lines = started
2154
2225
  .map((s, i) => `${i + 1}. ${s.label ? `"${s.label}" ` : ""}— ${s.id}`)
2155
2226
  .join("\n");
@@ -2157,7 +2228,8 @@ export default function (pi: ExtensionAPI) {
2157
2228
  content: [{ type: "text" as const, text:
2158
2229
  `Background agents started: ${started.length}\n\n` +
2159
2230
  `${lines}\n\n` +
2160
- `You'll be woken with a completion message as each finishes; use get_subagent_result({ agent_id }) for retrieval and stall checks.`
2231
+ `You'll be woken with a completion message as each finishes; use get_subagent_result({ agent_id }) for retrieval and stall checks.` +
2232
+ formatValidationWarnings(taskWarnings)
2161
2233
  }],
2162
2234
  details: undefined,
2163
2235
  };
@@ -2167,10 +2239,10 @@ export default function (pi: ExtensionAPI) {
2167
2239
  // startBackgroundAgent — the shared background-spawn tail.
2168
2240
  // Used by BOTH spawnBackgroundRun (single task) and runBackgroundFanOut
2169
2241
  // (tasks fan-out): the session-manager import, the spawn call, live-monitor
2170
- // registration, footer counters, the 2s progress poller and the hard-cap
2171
- // timer live here exactly once. Callers keep their own prelude, checks,
2172
- // validations and result formatting. THROWS on spawn failure — callers
2173
- // translate the error into their own result shape.
2242
+ // registration, footer counters, the 2s progress poller and the default
2243
+ // deadline timer live here exactly once. Callers keep their own prelude,
2244
+ // checks, validations and result formatting. THROWS on spawn failure —
2245
+ // callers translate the error into their own result shape.
2174
2246
  // -------------------------------------------------------------------
2175
2247
 
2176
2248
  async function startBackgroundAgent(
@@ -2326,7 +2398,7 @@ export default function (pi: ExtensionAPI) {
2326
2398
  }, { deliverAs: "followUp" });
2327
2399
  } else if (agent.status === 'stopped') {
2328
2400
  // User-initiated stop (stop_subagent) or deadline abort
2329
- // (timeout/hard cap) — not a failure.
2401
+ // (timeout/deadline) — not a failure.
2330
2402
  updateProgressStatus(state, ctx);
2331
2403
  } else {
2332
2404
  state.completedSubagents++;
@@ -2361,20 +2433,28 @@ export default function (pi: ExtensionAPI) {
2361
2433
  }
2362
2434
  }, 2000);
2363
2435
 
2364
- // Hard cap: stop polling AND abort the session after the deadline.
2365
- // W2 (issue #28): previously this only stopped the poller — the pi
2366
- // session kept running forever (orphaned). Now it pre-sets
2367
- // 'stopped' (so the .then keeps it, per W1) and aborts the
2368
- // session. The cap honors a shorter per-agent timeout.
2369
- const hardCapMs = Math.min(spawn.timeout ?? 30 * 60 * 1000, 30 * 60 * 1000);
2370
- const hardCapHandle = setTimeout(() => {
2436
+ // Default deadline: stop polling AND abort the session after the
2437
+ // deadline. W2 (issue #28): previously this only stopped the poller —
2438
+ // the pi session kept running forever (orphaned). Now it pre-sets
2439
+ // 'stopped' (so the .then keeps it, per W1) and aborts the session.
2440
+ // Issue #240: an explicit per-agent timeout is HONORED verbatim; this
2441
+ // deadline DEFAULTS to 30m only when no timeout is given. It is a
2442
+ // default, not a ceiling. Issue #244: exactly ONE deadline timer per
2443
+ // run — an explicit timeout is owned by the session-manager W3 timer,
2444
+ // so this index timer arms ONLY on the no-timeout path, mirroring
2445
+ // spawnBackgroundSession's `params.timeout && params.timeout > 0` guard
2446
+ // (with no timeout, it owns the 30m default, #28).
2447
+ const hasExplicitDeadline = spawn.timeout !== undefined && spawn.timeout > 0;
2448
+ const hardCapMs = spawn.timeout ?? DEFAULT_BACKGROUND_DEADLINE_MS;
2449
+ const hardCapHandle: ReturnType<typeof setTimeout> | undefined =
2450
+ hasExplicitDeadline ? undefined : setTimeout(() => {
2371
2451
  if (!completed && !agent.completedAt) {
2372
2452
  try {
2373
2453
  completed = true;
2374
2454
  clearInterval(pollInterval);
2375
2455
  // Pre-set stopped BEFORE aborting so the .then in
2376
2456
  // spawnBackgroundSession keeps the stopped state.
2377
- updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
2457
+ updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms deadline)`);
2378
2458
  agent._sessionRef?.abort().catch(() => {
2379
2459
  // Abort may reject if the session is mid-dispose;
2380
2460
  // the status flip above is already recorded.
@@ -2394,7 +2474,7 @@ export default function (pi: ExtensionAPI) {
2394
2474
  // W3/poller stopped path (no completedSubagents increment).
2395
2475
  updateProgressStatus(state, ctx);
2396
2476
  } catch (err) {
2397
- // Defensive: never let the hard-cap timer throw uncaught — that would
2477
+ // Defensive: never let the deadline timer throw uncaught — that would
2398
2478
  // skip finalizeLiveSubagent, the counter decrement, and the notification.
2399
2479
  // Do all fallible work first (output capture), then mutate counters,
2400
2480
  // then notify — a throw mid-path can't double-fire mutations.
@@ -2405,7 +2485,7 @@ export default function (pi: ExtensionAPI) {
2405
2485
  // get_subagent_result doesn't report 'running' and the W3 timer
2406
2486
  // guard (!agent.completedAt) can't re-fire later.
2407
2487
  try {
2408
- updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
2488
+ updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms deadline)`);
2409
2489
  } catch { /* ignore */ }
2410
2490
  try {
2411
2491
  setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
@@ -2447,7 +2527,11 @@ export default function (pi: ExtensionAPI) {
2447
2527
  // Resolve the preset and its model BEFORE the background branch so the
2448
2528
  // preset's model (and system prompt) are honored in background mode.
2449
2529
  const bgResolved = resolveSubagentParams({ ...params, task: singleTask }, state, ctx, log);
2450
- const { resolvedPreset: bgResolvedPreset, autoRoutedPreset: bgAutoRoutedPreset } = bgResolved;
2530
+ const {
2531
+ resolvedPreset: bgResolvedPreset,
2532
+ autoRoutedPreset: bgAutoRoutedPreset,
2533
+ autoRouteKeyword: bgAutoRouteKeyword,
2534
+ } = bgResolved;
2451
2535
  const bgModelResult = resolveSubagentModel(ctx, bgResolvedPreset, params.model);
2452
2536
  const bgModel = bgModelResult.ok
2453
2537
  ? `${bgModelResult.model.provider}/${bgModelResult.model.id}`
@@ -2457,16 +2541,8 @@ export default function (pi: ExtensionAPI) {
2457
2541
  // W5 (issue #28): approvalMode 'always' cannot work in background —
2458
2542
  // there is no interactive dialog to approve the diff. Reject loudly
2459
2543
  // instead of silently running unattended with write access.
2460
- if (bgResolved.resolvedApprovalMode === 'always') {
2461
- return {
2462
- content: [{ type: "text" as const, text:
2463
- `Cannot spawn background agent with approvalMode 'always': background agents run unattended ` +
2464
- `and cannot present the approval dialog. Use approvalMode 'auto' (default) or 'writes'.`
2465
- }],
2466
- details: undefined,
2467
- isError: true,
2468
- };
2469
- }
2544
+ const approvalError = rejectApprovalAlwaysInBackground(bgResolved.resolvedApprovalMode);
2545
+ if (approvalError) return approvalError;
2470
2546
  // W5: 'writes' in background silently auto-approves — warn once so the
2471
2547
  // caller knows the diff will NOT be gated on approval.
2472
2548
  if (bgResolved.resolvedApprovalMode === 'writes') {
@@ -2478,79 +2554,42 @@ export default function (pi: ExtensionAPI) {
2478
2554
  // W6 (issue #28): session cost limit must gate background spawns too —
2479
2555
  // the R5 check after the background branch never runs for them.
2480
2556
  // Same estimate/limit logic as single mode.
2481
- const bgPerTaskEstimate = state.config.perTaskCostEstimate > 0
2482
- ? state.config.perTaskCostEstimate
2483
- : 0.05;
2484
- if (state.checkCostLimit(bgPerTaskEstimate, ctx)) {
2485
- const bgLimit = state.config.sessionCostLimit;
2486
- const bgCurrentTotal = state.getSessionTotalCost(ctx);
2487
- log.warn("Background delegation rejected: session cost limit reached", {
2488
- currentTotal: bgCurrentTotal,
2489
- estimatedCost: bgPerTaskEstimate,
2490
- limit: bgLimit,
2491
- });
2492
- return {
2493
- content: [
2494
- {
2495
- type: "text" as const,
2496
- text:
2497
- `Cannot delegate: session cost limit reached ` +
2498
- `($${bgCurrentTotal.toFixed(4)} spent of $${bgLimit.toFixed(2)} limit). ` +
2499
- `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
2500
- },
2501
- ],
2502
- details: undefined,
2503
- isError: true,
2504
- };
2505
- }
2557
+ const costError = gateSessionCost({
2558
+ state,
2559
+ ctx,
2560
+ log,
2561
+ units: 1,
2562
+ label: "Background delegation",
2563
+ });
2564
+ if (costError) return costError;
2506
2565
 
2507
2566
  // F1: Validate cwd + outputFile the same way foreground single mode does —
2508
2567
  // an unvalidated outputFile would reach the prompt and could steer the
2509
2568
  // background agent's write tool outside the project root.
2510
- const bgCwdResult = validateCwd(bgResolved.effectiveCwd, ctx.cwd);
2511
- if (!bgCwdResult.ok) {
2512
- return {
2513
- content: [{ type: "text" as const, text: `Invalid cwd: ${bgCwdResult.error}` }],
2514
- details: undefined,
2515
- isError: true,
2516
- };
2517
- }
2518
- let bgResolvedOutputFile: string | undefined;
2519
- if (bgResolved.outputFile) {
2520
- const ofResult = validateOutputFile(bgResolved.outputFile, bgCwdResult.value);
2521
- if (!ofResult.ok) {
2522
- return {
2523
- content: [
2524
- { type: "text" as const, text: `Invalid outputFile: ${ofResult.error}` },
2525
- ],
2526
- details: undefined,
2527
- isError: true,
2528
- };
2529
- }
2530
- bgResolvedOutputFile = ofResult.value;
2531
- }
2532
-
2533
- // C: H1 validation for background mode — reject outputFile-vs-write
2534
- // conflicts before spawning (loud failure, same as single mode).
2535
- const bgValidation = validatePreTask({
2536
- task: singleTask,
2537
- toolOptions: bgResolved.toolOptions,
2538
- thinkingLevel: bgResolved.thinkingLevel,
2539
- gitMode: bgResolved.resolvedGitMode,
2540
- outputFile: bgResolvedOutputFile,
2569
+ const targets = validateDelegationTargets({
2570
+ ctx,
2571
+ effectiveCwd: bgResolved.effectiveCwd,
2572
+ outputFile: bgResolved.outputFile,
2541
2573
  });
2542
- if (bgValidation.warnings.length > 0) {
2543
- log.warn("Background pre-task validation warnings", { warnings: bgValidation.warnings });
2544
- }
2545
- if (!bgValidation.valid) {
2546
- const errText = bgValidation.errors.join("; ");
2547
- log.warn("Background pre-task validation failed", { errors: bgValidation.errors });
2548
- return {
2549
- content: [{ type: "text" as const, text: errText }],
2550
- details: undefined,
2551
- isError: true,
2552
- };
2553
- }
2574
+ if (!targets.ok) return targets.error;
2575
+ const bgResolvedOutputFile = targets.outputFile;
2576
+
2577
+ // C: H1 validation for background mode — reject capability blocks and
2578
+ // outputFile-vs-write conflicts before spawning (same as single mode;
2579
+ // `force` honors the same override foreground does).
2580
+ const bgPreTask = runPreTaskValidation({
2581
+ log,
2582
+ label: "Background",
2583
+ preTask: {
2584
+ task: singleTask,
2585
+ toolOptions: bgResolved.toolOptions,
2586
+ thinkingLevel: bgResolved.thinkingLevel,
2587
+ gitMode: bgResolved.resolvedGitMode,
2588
+ outputFile: bgResolvedOutputFile,
2589
+ force: params.force,
2590
+ },
2591
+ });
2592
+ if (bgPreTask.error) return bgPreTask.error;
2554
2593
 
2555
2594
  // Build the full prompt the same way foreground single mode does:
2556
2595
  // base prompt (optionally inherited) + custom prompt + preset guidance.
@@ -2571,7 +2610,7 @@ export default function (pi: ExtensionAPI) {
2571
2610
  thinkingLevel: bgResolved.thinkingLevel,
2572
2611
  priority: params.priority,
2573
2612
  systemPrompt: bgPrompt,
2574
- cwd: bgCwdResult.value,
2613
+ cwd: targets.cwd,
2575
2614
  toolOptions: bgResolved.toolOptions,
2576
2615
  timeout: bgResolved.timeout,
2577
2616
  gitMode: bgResolved.resolvedGitMode,
@@ -2579,11 +2618,11 @@ export default function (pi: ExtensionAPI) {
2579
2618
  sanitizeCwd: bgResolved.effectiveCwd,
2580
2619
  });
2581
2620
 
2582
- // B2: Surface auto-route decisions in background spawn result too.
2583
- // Derive the restriction from the RESOLVED toolOptions (which reflects
2584
- // per-call overrides) rather than the preset's declared values.
2621
+ // B2: Surface auto-route decisions in background spawn result too —
2622
+ // preset name + matched-keyword evidence + restriction, derived from
2623
+ // the RESOLVED toolOptions (reflects per-call overrides).
2585
2624
  const bgAutoRouteNote = bgAutoRoutedPreset
2586
- ? `\n\n[auto-routed to preset '${bgAutoRoutedPreset.name}' — ${bgResolved.toolOptions ? formatToolRestriction(bgResolved.toolOptions) : formatPresetRestriction(bgAutoRoutedPreset)}]`
2625
+ ? `\n\n[auto-routed to preset '${bgAutoRoutedPreset.name}'${bgAutoRouteKeyword ? ` (matched keyword '${bgAutoRouteKeyword}')` : ""} — ${bgResolved.toolOptions ? formatToolRestriction(bgResolved.toolOptions) : formatPresetRestriction(bgAutoRoutedPreset)}]`
2587
2626
  : "";
2588
2627
 
2589
2628
  return {
@@ -2594,7 +2633,10 @@ export default function (pi: ExtensionAPI) {
2594
2633
  `Task: ${agent.task}\n` +
2595
2634
  `Status: ${agent.status}\n\n` +
2596
2635
  `You'll be woken with a completion message when it finishes; use get_subagent_result({ agent_id: "${agent.id}" }) for retrieval and stall checks.` +
2597
- bgAutoRouteNote,
2636
+ bgAutoRouteNote +
2637
+ // C: warnings ride the immediate SPAWN result so the conductor
2638
+ // can stop/re-dispatch in the same turn.
2639
+ formatValidationWarnings(bgPreTask.warnings),
2598
2640
  }],
2599
2641
  details: undefined,
2600
2642
  };
@@ -2648,6 +2690,11 @@ export default function (pi: ExtensionAPI) {
2648
2690
  retry: () => showRetryMenu(ctx, state),
2649
2691
  sla: () => showSLAConfig(ctx, state, applyConfig),
2650
2692
  "sla-stats": () => showSLAStats(ctx, state),
2693
+ // E5: the compliance report menu (file access / secrets exposure /
2694
+ // full summary) shipped in tui.ts but had no handler key, so
2695
+ // `/brl-subagent compliance` fell through to the config menu and the
2696
+ // feature was unreachable (broken wiring, not dead code).
2697
+ compliance: () => showComplianceMenu(ctx, state),
2651
2698
  };
2652
2699
 
2653
2700
  if (trimmed && trimmed in handlers) {
@@ -2707,13 +2754,13 @@ export default function (pi: ExtensionAPI) {
2707
2754
  "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.",
2708
2755
  "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.",
2709
2756
  "Use outputFile to have the subagent write full findings to disk and return only a structured summary — saves context tokens for large investigations.",
2710
- "Set timeout (in ms) to limit how long a subagent can run. Useful for tasks that might hang or get stuck.",
2757
+ "Set timeout (in ms) to limit how long a subagent can run. Useful for tasks that might hang or get stuck. Background runs default to a 30-minute deadline when no timeout is given; an explicit timeout is honored as given (30 minutes is a default, not a ceiling).",
2711
2758
  "Set cwd to override the subagent's working directory. Defaults to the current project directory.",
2712
2759
  "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.",
2713
2760
  `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.`,
2714
2761
  buildTemplateGuideline(templateSummary),
2715
- "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.",
2716
- "Set retryOnTimeout: true to automatically retry a subagent that times out. Only retries once — the second timeout is treated as a final failure.",
2762
+ "To retry a failed subagent, pass its run ID as retryRunId. The retry restores the recorded params - including background, gitMode, approvalMode, force - with explicit values on this call winning. Multi-step runs degrade to a single task. See the retryRunId description for the full field list.",
2763
+ "Set retryOnTimeout: true to automatically retry a FOREGROUND subagent that times out. Only retries once — the second timeout is treated as a final failure. Background runs are not auto-retried; re-dispatch with retryRunId.",
2717
2764
  "Set background: true to run the subagent in the background without blocking. The tool returns immediately with an agent ID. With tasks, background fans out: every task starts as its own background agent, the call returns one ID per task in task order, and the conductor is woken once per agent as each finishes; chain and graph cannot be combined with background (rejected). 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.",
2718
2765
  "",
2719
2766
  "## Conductor Guardrails",
@@ -2723,309 +2770,19 @@ export default function (pi: ExtensionAPI) {
2723
2770
  "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.",
2724
2771
  "3. **Git mode**: Use gitMode='branch' for tasks that create commits or PRs. Use gitMode='none' for read-only tasks.",
2725
2772
  "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.",
2726
- "5. **Timeout**: Set timeout based on task complexity. Simple: 30s. Medium: 60s. Complex: 120s+. xhigh thinking: at least 120s.",
2773
+ "5. **Timeout**: Set timeout based on task complexity. Simple: 30s. Medium: 60s. Complex: 120s+. xhigh thinking: at least 120s. Background runs default to a 30-minute deadline when no timeout is set; an explicit timeout is honored.",
2727
2774
  "",
2728
- "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.",
2775
+ "These guardrails prevent common misconfigurations. The extension also validates configuration before spawning (H1): tool warnings are surfaced in the returned result, but two mismatches are HARD errors that reject the delegation — outputFile with the write tool excluded, and a capability-critical task/toolset mismatch (a run/execute/test/compile/benchmark task without bash, or an exploration task with none of find/ls/grep/bash; pass force: true to override the capability class). Getting it right the first time is faster and more efficient.",
2729
2776
  "",
2730
2777
  "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.",
2731
2778
  "The autoRoutePreset() function can automatically select the best preset based on task keywords. Consider using it for preset selection.",
2732
2779
  "Prefer a preset when the delegation matches a standard shape — review/audit/test/docs; override per-call deliberately.",
2733
2780
  ],
2734
- parameters: Type.Object({
2735
- task: Type.Optional(Type.String({
2736
- description: "Detailed description of the task for the subagent to complete (required for single mode, optional for chain/tasks/graph)",
2737
- })),
2738
- systemPrompt: Type.Optional(
2739
- Type.String({
2740
- description:
2741
- "Custom system prompt or additional instructions for the subagent. " +
2742
- "When inheritSystemPrompt is true (default), this is appended after the inherited prompt. " +
2743
- "When inheritSystemPrompt is false, this replaces the inherited prompt entirely.",
2744
- }),
2745
- ),
2746
- inheritSystemPrompt: Type.Optional(
2747
- Type.Boolean({
2748
- description:
2749
- "Whether to inherit the main agent's system prompt. " +
2750
- "Default: true. Set to false to use only your custom systemPrompt, " +
2751
- "or to avoid passing a large inherited prompt to the subagent.",
2752
- }),
2753
- ),
2754
- thinkingLevel: Type.Optional(
2755
- Type.String({
2756
- description:
2757
- "Requested thinking level for this subagent call. " +
2758
- "One of: off, minimal, low, medium, high, xhigh. " +
2759
- "Capped at the user's configured maximum. If omitted, the user's configured level is used.",
2760
- }),
2761
- ),
2762
- outputFile: Type.Optional(
2763
- Type.String({
2764
- description:
2765
- "Project-relative path where the subagent should write its full findings. " +
2766
- "When provided, the subagent is instructed to write complete output to this file " +
2767
- "and return only a structured summary.",
2768
- }),
2769
- ),
2770
- label: Type.Optional(
2771
- Type.String({
2772
- description:
2773
- "Human-readable label for this subagent (e.g., 'security-audit', 'docs-review'). " +
2774
- "Appears in the status bar and tool call display. " +
2775
- "Omit to use the default anonymous counter.",
2776
- }),
2777
- ),
2778
- model: Type.Optional(Type.String({ description: "Model override (provider/model-id). Defaults to the global subagent model." })),
2779
- timeout: Type.Optional(
2780
- Type.Number({
2781
- description:
2782
- "Maximum time in milliseconds the subagent is allowed to run. " +
2783
- "If exceeded, the subagent is killed and an error is returned.",
2784
- }),
2785
- ),
2786
- cwd: Type.Optional(
2787
- Type.String({
2788
- description:
2789
- "Working directory for the subagent. Must be an existing directory. " +
2790
- "Defaults to the conductor's current working directory.",
2791
- }),
2792
- ),
2793
- tools: Type.Optional(
2794
- Type.Array(Type.String(), {
2795
- description:
2796
- "Explicit allowlist of tool names for the subagent. Maps to pi's --tools flag.",
2797
- }),
2798
- ),
2799
- excludeTools: Type.Optional(
2800
- Type.Array(Type.String(), {
2801
- description:
2802
- "Tool names to disable for the subagent. Maps to pi's --exclude-tools flag.",
2803
- }),
2804
- ),
2805
- noBuiltinTools: Type.Optional(
2806
- Type.Boolean({
2807
- description:
2808
- "Disable all built-in tools for the subagent. Maps to pi's --no-builtin-tools flag.",
2809
- }),
2810
- ),
2811
- preset: Type.Optional(
2812
- Type.String({
2813
- description:
2814
- "Name of a saved delegation preset (created via /brl-subagent preset). " +
2815
- "Preset values are used as defaults; explicit parameters on this call override them.",
2816
- }),
2817
- ),
2818
- template: Type.Optional(
2819
- Type.String({
2820
- description:
2821
- "Name of a saved task template. Use with params to fill template slots. " +
2822
- "Templates are file-backed: 9 builtin templates ship with the extension " +
2823
- "(browse via /brl-subagent templates); override or add via .md files in " +
2824
- "~/.pi/agent/brl-subagent/templates/ or .pi/brl-subagent/templates/ " +
2825
- "(project-local wins over user-global over builtin).",
2826
- }),
2827
- ),
2828
- params: Type.Optional(
2829
- Type.Record(Type.String(), Type.String(), {
2830
- description:
2831
- "Parameter values for template ${param} slots. " +
2832
- "Keys are param names, values are the substitution text.",
2833
- }),
2834
- ),
2835
- retryRunId: Type.Optional(
2836
- Type.String({
2837
- description:
2838
- "ID of a previously failed subagent run to retry. " +
2839
- "The retried run uses the same task and parameters as the original. " +
2840
- "Only works with runs that ended in failure (exitCode != 0, timeout, error, or abort). " +
2841
- "Explicit parameters on this call override the original's.",
2842
- }),
2843
- ),
2844
- gitMode: Type.Optional(
2845
- Type.String({
2846
- description:
2847
- "Git integration mode for this subagent call. " +
2848
- "'branch' creates a work branch, captures the diff, and switches back. " +
2849
- "'none' (default) does nothing. Falls back to the configured default.",
2850
- }),
2851
- ),
2852
- retryOnTimeout: Type.Optional(
2853
- Type.Boolean({
2854
- description:
2855
- "If true and the subagent times out, automatically retry with the same parameters. " +
2856
- "Only retries once — the second timeout is treated as a final failure.",
2857
- }),
2858
- ),
2859
- approvalMode: Type.Optional(
2860
- Type.String({
2861
- description:
2862
- "Change approval mode: auto (never ask), writes (ask when files changed), " +
2863
- "always (ask every time). Default is user config (/brl-subagent approval).",
2864
- }),
2865
- ),
2866
- background: Type.Optional(
2867
- Type.Boolean({
2868
- description:
2869
- "Run the subagent in the background without blocking the conductor. " +
2870
- "When true, the tool returns immediately with an agent ID. " +
2871
- "The conductor is woken with a completion message; use get_subagent_result for post-wake retrieval and stall checks. " +
2872
- "Supports a single task or the tasks array (a fan-out that starts one background agent per task; with tasks the call returns one ID per task and the conductor is woken once per agent as each finishes); combining background with chain or graph is rejected. " +
2873
- "Default: false (blocking mode).",
2874
- }),
2875
- ),
2876
- priority: Type.Optional(
2877
- Type.Union([
2878
- Type.Literal("critical"),
2879
- Type.Literal("high"),
2880
- Type.Literal("normal"),
2881
- Type.Literal("low"),
2882
- ], {
2883
- description: "Concurrency priority for this delegation: critical, high, normal, or low. Higher-priority delegations queue ahead. Defaults to normal.",
2884
- })
2885
- ),
2886
- // Issue #114: NO per-step `priority` on chain[] — chain steps never
2887
- // compete for concurrency slots (the chain holds ONE slot for its whole
2888
- // duration); array order IS the priority.
2889
- chain: Type.Optional(Type.Array(Type.Object({
2890
- task: Type.String({ description: "Task description. Use {previous} to reference the previous step output." }),
2891
- label: Type.Optional(Type.String({})),
2892
- model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
2893
- thinkingLevel: Type.Optional(Type.String({})),
2894
- cwd: Type.Optional(Type.String({})),
2895
- timeout: Type.Optional(Type.Number({})),
2896
- outputFile: Type.Optional(Type.String({})),
2897
- tools: Type.Optional(Type.Array(Type.String({}))),
2898
- excludeTools: Type.Optional(Type.Array(Type.String({}))),
2899
- noBuiltinTools: Type.Optional(Type.Boolean({})),
2900
- systemPrompt: Type.Optional(Type.String({})),
2901
- inheritSystemPrompt: Type.Optional(Type.Boolean({})),
2902
- }), {
2903
- 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."
2904
- })),
2905
- tasks: Type.Optional(Type.Array(Type.Object({
2906
- task: Type.String({ description: "Task description for this parallel subtask" }),
2907
- label: Type.Optional(Type.String({})),
2908
- model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
2909
- thinkingLevel: Type.Optional(Type.String({})),
2910
- priority: Type.Optional(
2911
- Type.Union([
2912
- Type.Literal("critical"),
2913
- Type.Literal("high"),
2914
- Type.Literal("normal"),
2915
- Type.Literal("low"),
2916
- ], { description: "Concurrency priority for this subtask (overrides the call-level default)." })
2917
- ),
2918
- cwd: Type.Optional(Type.String({})),
2919
- timeout: Type.Optional(Type.Number({})),
2920
- outputFile: Type.Optional(Type.String({})),
2921
- tools: Type.Optional(Type.Array(Type.String({}))),
2922
- excludeTools: Type.Optional(Type.Array(Type.String({}))),
2923
- noBuiltinTools: Type.Optional(Type.Boolean({})),
2924
- systemPrompt: Type.Optional(Type.String({})),
2925
- inheritSystemPrompt: Type.Optional(Type.Boolean({})),
2926
- }), {
2927
- description: "Parallel tasks to execute concurrently. All tasks run regardless of individual failures. Max " + MAX_PARALLEL_TASKS + " tasks."
2928
- })),
2929
- graph: Type.Optional(Type.Array(Type.Object({
2930
- id: Type.String({ description: "Unique identifier for this task node" }),
2931
- task: Type.String({ description: "Task description. Use {otherId} to reference output from another task." }),
2932
- label: Type.Optional(Type.String({})),
2933
- model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
2934
- dependsOn: Type.Optional(Type.Array(Type.String({}), { description: "IDs of tasks that must complete before this one starts" })),
2935
- thinkingLevel: Type.Optional(Type.String({})),
2936
- priority: Type.Optional(
2937
- Type.Union([
2938
- Type.Literal("critical"),
2939
- Type.Literal("high"),
2940
- Type.Literal("normal"),
2941
- Type.Literal("low"),
2942
- ], { description: "Concurrency priority for this subtask (overrides the call-level default)." })
2943
- ),
2944
- cwd: Type.Optional(Type.String({})),
2945
- timeout: Type.Optional(Type.Number({})),
2946
- outputFile: Type.Optional(Type.String({})),
2947
- tools: Type.Optional(Type.Array(Type.String({}))),
2948
- excludeTools: Type.Optional(Type.Array(Type.String({}))),
2949
- noBuiltinTools: Type.Optional(Type.Boolean({})),
2950
- systemPrompt: Type.Optional(Type.String({})),
2951
- inheritSystemPrompt: Type.Optional(Type.Boolean({})),
2952
- }), {
2953
- description: "Declare tasks with dependencies. The scheduler parallelizes independent tasks and sequences dependent ones. Max " + MAX_GRAPH_TASKS + " tasks."
2954
- })),
2955
- }),
2781
+ parameters: delegateTaskParamsSchema,
2956
2782
 
2957
2783
  async execute(
2958
2784
  _toolCallId: string,
2959
- params: {
2960
- // Schema registers task as Type.Optional — required for single mode only,
2961
- // omitted by chain/tasks/graph calls. Aligned with Static<TParams>.
2962
- task?: string;
2963
- label?: string;
2964
- model?: string;
2965
- preset?: string;
2966
- systemPrompt?: string;
2967
- inheritSystemPrompt?: boolean;
2968
- thinkingLevel?: string;
2969
- outputFile?: string;
2970
- timeout?: number;
2971
- cwd?: string;
2972
- tools?: string[];
2973
- excludeTools?: string[];
2974
- noBuiltinTools?: boolean;
2975
- template?: string;
2976
- params?: Record<string, string>;
2977
- retryRunId?: string;
2978
- retryOnTimeout?: boolean;
2979
- background?: boolean;
2980
- gitMode?: string;
2981
- priority?: string;
2982
- chain?: Array<{
2983
- task: string;
2984
- label?: string;
2985
- model?: string;
2986
- thinkingLevel?: string;
2987
- cwd?: string;
2988
- timeout?: number;
2989
- outputFile?: string;
2990
- tools?: string[];
2991
- excludeTools?: string[];
2992
- noBuiltinTools?: boolean;
2993
- systemPrompt?: string;
2994
- inheritSystemPrompt?: boolean;
2995
- }>;
2996
- tasks?: Array<{
2997
- task: string;
2998
- label?: string;
2999
- model?: string;
3000
- thinkingLevel?: string;
3001
- priority?: string;
3002
- cwd?: string;
3003
- timeout?: number;
3004
- outputFile?: string;
3005
- tools?: string[];
3006
- excludeTools?: string[];
3007
- noBuiltinTools?: boolean;
3008
- systemPrompt?: string;
3009
- inheritSystemPrompt?: boolean;
3010
- }>;
3011
- graph?: Array<{
3012
- id: string;
3013
- task: string;
3014
- label?: string;
3015
- model?: string;
3016
- dependsOn?: string[];
3017
- thinkingLevel?: string;
3018
- priority?: string;
3019
- cwd?: string;
3020
- timeout?: number;
3021
- outputFile?: string;
3022
- tools?: string[];
3023
- excludeTools?: string[];
3024
- noBuiltinTools?: boolean;
3025
- systemPrompt?: string;
3026
- inheritSystemPrompt?: boolean;
3027
- }>;
3028
- },
2785
+ params: DelegateTaskParams,
3029
2786
  signal: AbortSignal | undefined,
3030
2787
  onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
3031
2788
  ctx: ExtensionContext,
@@ -3133,7 +2890,7 @@ export default function (pi: ExtensionAPI) {
3133
2890
  if (params.retryRunId) {
3134
2891
  const runEntry = state.findSpawnRunById(ctx, params.retryRunId);
3135
2892
  if (runEntry) {
3136
- params = resolveRetryParams(params, runEntry);
2893
+ params = resolveRetryParams(params, runEntry) as DelegateTaskParams;
3137
2894
  } else {
3138
2895
  // Issue #98: a silent no-op here made retries of background runs
3139
2896
  // (which previously never wrote run entries) start as FRESH runs
@@ -3257,31 +3014,14 @@ export default function (pi: ExtensionAPI) {
3257
3014
 
3258
3015
  // R5: Check session cost limit before spawning
3259
3016
  // Use a default per-task estimate of $0.05 if no perTaskCostEstimate is set
3260
- const perTaskEstimate = state.config.perTaskCostEstimate > 0
3261
- ? state.config.perTaskCostEstimate
3262
- : 0.05;
3263
- const currentTotal = state.getSessionTotalCost(ctx);
3264
- if (state.checkCostLimit(perTaskEstimate, ctx)) {
3265
- const limit = state.config.sessionCostLimit;
3266
- log.warn("Subagent delegation rejected: session cost limit reached", {
3267
- currentTotal,
3268
- estimatedCost: perTaskEstimate,
3269
- limit,
3270
- });
3271
- return {
3272
- content: [
3273
- {
3274
- type: "text" as const,
3275
- text:
3276
- `Cannot delegate: session cost limit reached ` +
3277
- `($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
3278
- `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
3279
- },
3280
- ],
3281
- details: undefined,
3282
- isError: true,
3283
- };
3284
- }
3017
+ const costError = gateSessionCost({
3018
+ state,
3019
+ ctx,
3020
+ log,
3021
+ units: 1,
3022
+ label: "Subagent delegation",
3023
+ });
3024
+ if (costError) return costError;
3285
3025
 
3286
3026
  // Reject delegation if recursion depth exceeds configured max.
3287
3027
  // This prevents subagents from spawning infinite sub-subagents while
@@ -3321,6 +3061,7 @@ export default function (pi: ExtensionAPI) {
3321
3061
  resolvedApprovalMode,
3322
3062
  resolvedPreset,
3323
3063
  autoRoutedPreset,
3064
+ autoRouteKeyword,
3324
3065
  } = resolveSubagentParams({ ...params, task: singleTask }, state, ctx, log);
3325
3066
 
3326
3067
  // F1: Validate CWD
@@ -3362,12 +3103,14 @@ export default function (pi: ExtensionAPI) {
3362
3103
  }
3363
3104
 
3364
3105
  // H1: Pre-task validation — deterministic check that tools/thinking match task
3106
+ // (`force` degrades capability errors to warnings — B2's override).
3365
3107
  const validation = validatePreTask({
3366
3108
  task,
3367
3109
  toolOptions,
3368
3110
  thinkingLevel,
3369
3111
  gitMode: resolvedGitMode,
3370
3112
  outputFile: resolvedOutputFile,
3113
+ force: params.force,
3371
3114
  });
3372
3115
  if (validation.warnings.length > 0) {
3373
3116
  log.warn("Pre-task validation warnings", { warnings: validation.warnings });
@@ -3384,6 +3127,9 @@ export default function (pi: ExtensionAPI) {
3384
3127
  isError: true,
3385
3128
  };
3386
3129
  }
3130
+ // C: surface warnings in the returned result (not just the log) —
3131
+ // shared renderer so foreground/background wording stays identical.
3132
+ const warningsNote = formatValidationWarnings(validation.warnings);
3387
3133
 
3388
3134
  // Resolve model (per-call top-level model override beats preset)
3389
3135
  const modelResult = resolveSubagentModel(ctx, resolvedPreset, params.model);
@@ -3771,6 +3517,9 @@ export default function (pi: ExtensionAPI) {
3771
3517
  if (suggestions.length > 0) {
3772
3518
  finalMsg += "\n\nSuggestions:\n" + suggestions.map((s) => `- ${s}`).join("\n");
3773
3519
  }
3520
+ // C: warnings ride the returned result too — a failed run is
3521
+ // exactly when the conductor most needs the mismatch signal.
3522
+ finalMsg += warningsNote;
3774
3523
 
3775
3524
  completeTranscript(runId, 'failed');
3776
3525
 
@@ -3797,16 +3546,17 @@ export default function (pi: ExtensionAPI) {
3797
3546
  state.recordSuccess();
3798
3547
 
3799
3548
  // B2: Surface auto-route decisions in the tool result so the conductor
3800
- // sees which preset was applied and what it restricts. Derive the
3801
- // restriction from the RESOLVED toolOptions (reflects per-call
3802
- // overrides) rather than the preset's declared values.
3549
+ // sees WHICH preset was auto-selected, the KEYWORD evidence for it,
3550
+ // and what it restricts. Derive the restriction from the RESOLVED
3551
+ // toolOptions (reflects per-call overrides) rather than the preset's
3552
+ // declared values.
3803
3553
  const autoRouteNote = autoRoutedPreset
3804
- ? `\n\n[auto-routed to preset '${autoRoutedPreset.name}' — ${toolOptions ? formatToolRestriction(toolOptions) : formatPresetRestriction(autoRoutedPreset)}]`
3554
+ ? `\n\n[auto-routed to preset '${autoRoutedPreset.name}'${autoRouteKeyword ? ` (matched keyword '${autoRouteKeyword}')` : ""} — ${toolOptions ? formatToolRestriction(toolOptions) : formatPresetRestriction(autoRoutedPreset)}]`
3805
3555
  : "";
3806
3556
 
3807
3557
  return {
3808
3558
  content: [
3809
- { type: "text" as const, text: (finalOutput || "(no output)") + autoRouteNote },
3559
+ { type: "text" as const, text: (finalOutput || "(no output)") + autoRouteNote + warningsNote },
3810
3560
  ],
3811
3561
  details: result,
3812
3562
  };
@@ -3886,11 +3636,6 @@ export default function (pi: ExtensionAPI) {
3886
3636
  agent_id: Type.String({
3887
3637
  description: "The agent ID returned by delegate_task when background=true",
3888
3638
  }),
3889
- wait: Type.Optional(
3890
- Type.Boolean({
3891
- description: "If true, wait for the agent to complete before returning. Default: false.",
3892
- }),
3893
- ),
3894
3639
  verbose: Type.Optional(
3895
3640
  Type.Boolean({
3896
3641
  description: "If true, include the full conversation log. Default: false.",
@@ -3979,7 +3724,7 @@ export default function (pi: ExtensionAPI) {
3979
3724
  label: "Steer Subagent",
3980
3725
  description: [
3981
3726
  "Send a steering message to a running background agent.",
3982
- "The message interrupts after the current tool execution.",
3727
+ "The message is delivered after the agent's current tool call finishes, before its next model call.",
3983
3728
  "Use this to redirect an agent's work without restarting it.",
3984
3729
  ].join(" "),
3985
3730
  parameters: Type.Object({
@@ -3994,7 +3739,7 @@ export default function (pi: ExtensionAPI) {
3994
3739
  const { steerAgent } = await import('./session-manager');
3995
3740
 
3996
3741
  try {
3997
- const agent = steerAgent(params.agent_id, params.message);
3742
+ const agent = await steerAgent(params.agent_id, params.message);
3998
3743
 
3999
3744
  if (!agent) {
4000
3745
  return {