brl-subagent 2.3.8 → 2.3.9

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
@@ -65,6 +65,7 @@ import {
65
65
  mergeWorkBranch,
66
66
  } from "./git";
67
67
  import { preflightCheck } from "./preflight";
68
+ import { gateSessionCost, rejectApprovalAlwaysInBackground, runPreTaskValidation, validateDelegationTargets, formatValidationWarnings } from "./prelude";
68
69
  import { loadBuiltinPresets, loadCustomPresets, getAllPresets, writePresetFile, formatPresetRestriction, formatToolRestriction } from "./presets";
69
70
  import { modelIsAvailable } from "./model-availability";
70
71
  import { validatePreTask, diagnoseFailure } from "./validate";
@@ -399,6 +400,14 @@ export default function (pi: ExtensionAPI) {
399
400
  };
400
401
  }
401
402
 
403
+ // Per-unit display name for validation prefixes: the label when present,
404
+ // else the task truncated to 60 chars. Shared by the foreground parallel
405
+ // pre-pass and the background fan-out pre-pass (issue #220) so a unit
406
+ // renders identically in either shape.
407
+ const displayTaskName = (merged: ReturnType<typeof mergeSubTaskParams>): string =>
408
+ merged.label ??
409
+ (merged.task.length > 60 ? `${merged.task.slice(0, 57)}...` : merged.task);
410
+
402
411
  // -------------------------------------------------------------------
403
412
  // P1: runChainMode
404
413
  // -------------------------------------------------------------------
@@ -416,32 +425,14 @@ export default function (pi: ExtensionAPI) {
416
425
  const chainModeStartedAt = new Date().toISOString();
417
426
 
418
427
  // 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
- }
428
+ const costError = gateSessionCost({
429
+ state,
430
+ ctx,
431
+ log,
432
+ units: chainSteps.length,
433
+ label: "Chain delegation",
434
+ });
435
+ if (costError) return costError;
445
436
 
446
437
  // Reject delegation if recursion depth exceeds configured max
447
438
  const currentDepth = getCurrentDepth();
@@ -521,7 +512,8 @@ export default function (pi: ExtensionAPI) {
521
512
  // #32). Chain top-level tasks are empty (modeCount forbids task+chain), so
522
513
  // keyword warnings skip on empty text and only the hard outputFile-vs-write
523
514
  // conflict applies (issue #34). The mode-level outputFile on globalParams is
524
- // the one validated; per-step outputFiles are future work (issue #3).
515
+ // the one validated here; every step's own cwd/outputFile/tools are
516
+ // validated by the per-step pre-pass below (issue #222).
525
517
  const validation = validatePreTask({
526
518
  task: globalParams.task,
527
519
  toolOptions: globalParams.toolOptions,
@@ -542,6 +534,47 @@ export default function (pi: ExtensionAPI) {
542
534
  };
543
535
  }
544
536
 
537
+ // Per-step pre-pass (issue #222): resolve + validate EVERY step before
538
+ // any spawn, so a single bad step rejects the chain with nothing
539
+ // started — the same all-before-any-spawn semantics the background
540
+ // fan-out and the parallel `tasks` shape use. Chain steps are fully
541
+ // known up front and a step's cwd/outputFile never depend on a previous
542
+ // step's output (only its TASK text consumes {previous}), so the pass
543
+ // can run up front — and must, because discovering step 5's bad cwd
544
+ // after four steps already ran would waste their whole budget.
545
+ const stepCwds: string[] = [];
546
+ const stepWarnings: string[] = [];
547
+ for (let i = 0; i < chainSteps.length; i++) {
548
+ const merged = mergeSubTaskParams(globalParams, chainSteps[i]);
549
+ const stepPrefix = `Step ${i + 1} ("${displayTaskName(merged)}")`;
550
+
551
+ const stepTargets = validateDelegationTargets({
552
+ ctx,
553
+ effectiveCwd: merged.effectiveCwd,
554
+ outputFile: merged.outputFile,
555
+ prefix: stepPrefix,
556
+ });
557
+ if (!stepTargets.ok) return stepTargets.error;
558
+ stepCwds.push(stepTargets.cwd);
559
+
560
+ const stepValidation = runPreTaskValidation({
561
+ log,
562
+ label: "Chain",
563
+ prefix: stepPrefix,
564
+ logContext: { step: i + 1 },
565
+ preTask: {
566
+ task: merged.task,
567
+ toolOptions: merged.toolOptions,
568
+ thinkingLevel: merged.thinkingLevel,
569
+ gitMode: globalParams.resolvedGitMode,
570
+ outputFile: merged.outputFile,
571
+ force: params.force as boolean | undefined,
572
+ },
573
+ });
574
+ if (stepValidation.error) return stepValidation.error;
575
+ for (const w of stepValidation.warnings) stepWarnings.push(`${stepPrefix}: ${w}`);
576
+ }
577
+
545
578
  // Resolve model once (per-call top-level model override beats preset)
546
579
  const modelResult = resolveSubagentModel(ctx, globalParams.resolvedPreset, params.model as string | undefined);
547
580
  if (!modelResult.ok) return modelResult.error;
@@ -706,7 +739,10 @@ export default function (pi: ExtensionAPI) {
706
739
  let result: SubagentResult;
707
740
  try {
708
741
  result = await runSubagent(
709
- resolvedCwd,
742
+ // Issue #222: this step's OWN validated cwd (the pre-pass
743
+ // resolved it against ctx.cwd) — the mode-level
744
+ // resolvedCwd is the fallback for steps that declare none.
745
+ stepCwds[i],
710
746
  subagentPrompt,
711
747
  stepModel,
712
748
  merged.thinkingLevel,
@@ -724,7 +760,7 @@ export default function (pi: ExtensionAPI) {
724
760
  // run entry as failed — crashOutput comes from the live entry's
725
761
  // last streamed output (the crash result itself has no
726
762
  // messages), and errorCategory is classified onto originalParams.
727
- finalizeUnitRunCrash(state, pi, run, err, "Chain step", resolvedCwd, log, {
763
+ finalizeUnitRunCrash(state, pi, run, err, "Chain step", stepCwds[i], log, {
728
764
  step: i + 1,
729
765
  });
730
766
  }
@@ -869,9 +905,15 @@ export default function (pi: ExtensionAPI) {
869
905
  };
870
906
  state.persistRun(pi, chainAggregateRun);
871
907
 
908
+ // Per-step validation warnings ride the result, prefixed with their
909
+ // step (issue #222), after the JSON payload — empty renders nothing so
910
+ // warning-free results stay byte-identical.
872
911
  return {
873
912
  content: [
874
- { type: "text" as const, text: JSON.stringify(chainDetails, null, 2) },
913
+ {
914
+ type: "text" as const,
915
+ text: JSON.stringify(chainDetails, null, 2) + formatValidationWarnings(stepWarnings),
916
+ },
875
917
  ],
876
918
  details: chainDetails,
877
919
  };
@@ -903,32 +945,14 @@ export default function (pi: ExtensionAPI) {
903
945
  const taskList = params.tasks as SubTaskParams[];
904
946
 
905
947
  // 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
- }
948
+ const costError = gateSessionCost({
949
+ state,
950
+ ctx,
951
+ log,
952
+ units: taskList.length,
953
+ label: "Parallel delegation",
954
+ });
955
+ if (costError) return costError;
932
956
 
933
957
  // Reject delegation if recursion depth exceeds configured max
934
958
  const currentDepth = getCurrentDepth();
@@ -975,17 +999,9 @@ export default function (pi: ExtensionAPI) {
975
999
  );
976
1000
 
977
1001
  // 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;
1002
+ const targets = validateDelegationTargets({ ctx, effectiveCwd: globalParams.effectiveCwd });
1003
+ if (!targets.ok) return targets.error;
1004
+ const resolvedCwd = targets.cwd;
989
1005
 
990
1006
  // Pre-flight checks — fail fast before consuming resources
991
1007
  const pfResult = preflightCheck(resolvedCwd);
@@ -1008,26 +1024,64 @@ export default function (pi: ExtensionAPI) {
1008
1024
  // #32). Parallel top-level tasks are empty (modeCount forbids task+tasks),
1009
1025
  // so keyword warnings skip on empty text and only the hard
1010
1026
  // 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,
1027
+ // outputFile on globalParams is the one validated here; every task's own
1028
+ // cwd/outputFile/tools are validated by the per-task pre-pass below
1029
+ // (issue #222).
1030
+ const preTask = runPreTaskValidation({
1031
+ log,
1032
+ label: "Parallel",
1033
+ preTask: {
1034
+ task: globalParams.task,
1035
+ toolOptions: globalParams.toolOptions,
1036
+ thinkingLevel: globalParams.thinkingLevel,
1037
+ gitMode: globalParams.resolvedGitMode,
1038
+ outputFile: globalParams.outputFile,
1039
+ },
1019
1040
  });
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
- };
1041
+ if (preTask.error) return preTask.error;
1042
+
1043
+ // Per-task pre-pass (issue #220, extended by #222): validate EVERY task
1044
+ // before any spawn, so a single bad task rejects the batch with nothing
1045
+ // started (`force` degrades capability errors to warnings) — the same
1046
+ // semantics runBackgroundFanOut applies. Each task's OWN cwd is resolved
1047
+ // and validated here (with its outputFile, resolved against that cwd) and
1048
+ // the resolved value is what that task spawns in — issue #222 closed the
1049
+ // gap where a per-task `cwd` was merged but the spawn still used the
1050
+ // mode-level cwd. Per-task warnings are prefixed with their unit and
1051
+ // collected here to ride the final result. The mode-entry validation
1052
+ // above stays for the mode-level outputFile (issue #34).
1053
+ const taskWarnings: string[] = [];
1054
+ // Parallel index → the task's own validated cwd (issue #222).
1055
+ const taskCwds: string[] = [];
1056
+ for (let i = 0; i < taskList.length; i++) {
1057
+ const merged = mergeSubTaskParams(globalParams, taskList[i]);
1058
+ const taskPrefix = `Task ${i + 1} ("${displayTaskName(merged)}")`;
1059
+
1060
+ const taskTargets = validateDelegationTargets({
1061
+ ctx,
1062
+ effectiveCwd: merged.effectiveCwd,
1063
+ outputFile: merged.outputFile,
1064
+ prefix: taskPrefix,
1065
+ });
1066
+ if (!taskTargets.ok) return taskTargets.error;
1067
+ taskCwds.push(taskTargets.cwd);
1068
+
1069
+ const unitValidation = runPreTaskValidation({
1070
+ log,
1071
+ label: "Parallel",
1072
+ prefix: taskPrefix,
1073
+ logContext: { task: i + 1 },
1074
+ preTask: {
1075
+ task: merged.task,
1076
+ toolOptions: merged.toolOptions,
1077
+ thinkingLevel: merged.thinkingLevel,
1078
+ gitMode: globalParams.resolvedGitMode,
1079
+ outputFile: merged.outputFile,
1080
+ force: params.force as boolean | undefined,
1081
+ },
1082
+ });
1083
+ if (unitValidation.error) return unitValidation.error;
1084
+ for (const w of unitValidation.warnings) taskWarnings.push(`${taskPrefix}: ${w}`);
1031
1085
  }
1032
1086
 
1033
1087
  // Resolve model once (per-call top-level model override beats preset)
@@ -1078,6 +1132,7 @@ export default function (pi: ExtensionAPI) {
1078
1132
  const runTask = async (
1079
1133
  index: number,
1080
1134
  merged: ReturnType<typeof mergeSubTaskParams>,
1135
+ unitCwd: string,
1081
1136
  ): Promise<boolean> => {
1082
1137
 
1083
1138
  // C3: Resolve this step's model override (step.model > global resolved model)
@@ -1154,7 +1209,8 @@ export default function (pi: ExtensionAPI) {
1154
1209
  // the live ghost (grace logic treats a 'running' record as live).
1155
1210
  try {
1156
1211
  const result = await runSubagent(
1157
- resolvedCwd,
1212
+ // Issue #222: this task's OWN validated cwd.
1213
+ unitCwd,
1158
1214
  subagentPrompt,
1159
1215
  stepModel,
1160
1216
  merged.thinkingLevel,
@@ -1197,7 +1253,7 @@ export default function (pi: ExtensionAPI) {
1197
1253
  // the live drill-in loops forever. finalizeLiveSubagent is
1198
1254
  // idempotent; the rethrow keeps Promise.allSettled semantics (the
1199
1255
  // caller's finally still releases the concurrency slot).
1200
- finalizeUnitRunCrash(state, pi, run, err, "Parallel subtask", resolvedCwd, log, {
1256
+ finalizeUnitRunCrash(state, pi, run, err, "Parallel subtask", unitCwd, log, {
1201
1257
  index,
1202
1258
  });
1203
1259
  }
@@ -1275,7 +1331,7 @@ export default function (pi: ExtensionAPI) {
1275
1331
  }
1276
1332
  let taskSuccess = false;
1277
1333
  try {
1278
- taskSuccess = await runTask(index, merged);
1334
+ taskSuccess = await runTask(index, merged, taskCwds[index]);
1279
1335
  } finally {
1280
1336
  // Issue #137: release with the unit's real success — the status
1281
1337
  // bar's "N failed" count reflects the true outcome.
@@ -1332,9 +1388,15 @@ export default function (pi: ExtensionAPI) {
1332
1388
  totalCost,
1333
1389
  });
1334
1390
 
1391
+ // C: per-task validation warnings ride the result, prefixed with their
1392
+ // task (issue #220), after the JSON payload — empty renders nothing so
1393
+ // warning-free results stay byte-identical.
1335
1394
  return {
1336
1395
  content: [
1337
- { type: "text" as const, text: JSON.stringify(parallelDetails, null, 2) },
1396
+ {
1397
+ type: "text" as const,
1398
+ text: JSON.stringify(parallelDetails, null, 2) + formatValidationWarnings(taskWarnings),
1399
+ },
1338
1400
  ],
1339
1401
  details: parallelDetails,
1340
1402
  };
@@ -1372,32 +1434,14 @@ export default function (pi: ExtensionAPI) {
1372
1434
  }
1373
1435
 
1374
1436
  // 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
- }
1437
+ const costError = gateSessionCost({
1438
+ state,
1439
+ ctx,
1440
+ log,
1441
+ units: graphTasks.length,
1442
+ label: "Graph delegation",
1443
+ });
1444
+ if (costError) return costError;
1401
1445
 
1402
1446
  // Reject delegation if recursion depth exceeds configured max
1403
1447
  const currentDepth = getCurrentDepth();
@@ -1478,8 +1522,9 @@ export default function (pi: ExtensionAPI) {
1478
1522
  // #32). Graph top-level tasks are empty (modeCount forbids task+graph),
1479
1523
  // so keyword warnings skip on empty text and only the hard
1480
1524
  // 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).
1525
+ // outputFile on globalParams is the one validated here; every node's own
1526
+ // cwd/outputFile/tools are validated by the per-node pre-pass below
1527
+ // (issue #222).
1483
1528
  const validation = validatePreTask({
1484
1529
  task: globalParams.task,
1485
1530
  toolOptions: globalParams.toolOptions,
@@ -1555,6 +1600,73 @@ export default function (pi: ExtensionAPI) {
1555
1600
  }
1556
1601
  const waves = sortResult.waves;
1557
1602
 
1603
+ // Per-node pre-pass (issue #222): resolve + validate EVERY node before
1604
+ // any spawn, so a single bad node rejects the whole graph with nothing
1605
+ // started — the same all-before-any-spawn semantics the background
1606
+ // fan-out, the parallel `tasks` shape and the chain use. Each node's OWN
1607
+ // cwd is resolved and validated here (with its outputFile, resolved
1608
+ // against that cwd) and the resolved value is what that node spawns in.
1609
+ // The pre-pass runs on the DECLARED task text, before {id} substitution
1610
+ // (the substituted text is only known mid-dispatch, once a dependency's
1611
+ // output exists) — the declared text is validated with the placeholder
1612
+ // TOKENS stripped (a dependency whose id happens to be a blocking
1613
+ // keyword must not make `{<id>}` itself trip the keyword rules), while
1614
+ // the SUBSTITUTED content stays unvalidated by design. The host's own
1615
+ // wording is still validated, and refusing to validate up front would
1616
+ // mean a bad node in the LAST wave is discovered after every earlier
1617
+ // wave already spent its budget.
1618
+ const nodeCwds = new Map<string, string>();
1619
+ const nodeWarnings: string[] = [];
1620
+ for (const graphTask of graphTasks) {
1621
+ const merged = mergeSubTaskParams(globalParams, {
1622
+ task: graphTask.task,
1623
+ label: graphTask.label,
1624
+ model: graphTask.model,
1625
+ thinkingLevel: graphTask.thinkingLevel,
1626
+ priority: graphTask.priority,
1627
+ cwd: graphTask.cwd,
1628
+ timeout: graphTask.timeout,
1629
+ outputFile: graphTask.outputFile,
1630
+ tools: graphTask.tools,
1631
+ excludeTools: graphTask.excludeTools,
1632
+ noBuiltinTools: graphTask.noBuiltinTools,
1633
+ systemPrompt: graphTask.systemPrompt,
1634
+ inheritSystemPrompt: graphTask.inheritSystemPrompt,
1635
+ });
1636
+ const nodePrefix = `Node "${graphTask.id}"`;
1637
+
1638
+ const nodeTargets = validateDelegationTargets({
1639
+ ctx,
1640
+ effectiveCwd: merged.effectiveCwd,
1641
+ outputFile: merged.outputFile,
1642
+ prefix: nodePrefix,
1643
+ });
1644
+ if (!nodeTargets.ok) return nodeTargets.error;
1645
+ nodeCwds.set(graphTask.id, nodeTargets.cwd);
1646
+
1647
+ const nodeValidation = runPreTaskValidation({
1648
+ log,
1649
+ label: "Graph",
1650
+ prefix: nodePrefix,
1651
+ logContext: { id: graphTask.id },
1652
+ preTask: {
1653
+ // Placeholder TOKENS stripped (issue #222 review): the rules
1654
+ // read the declared string, so a `{<id>}` whose dependency id
1655
+ // is a blocking keyword would block on a token, not on text
1656
+ // the host wrote. The declared wording around it is unchanged
1657
+ // and still validated.
1658
+ task: merged.task.replace(GRAPH_OUTPUT_PLACEHOLDER_RE, " "),
1659
+ toolOptions: merged.toolOptions,
1660
+ thinkingLevel: merged.thinkingLevel,
1661
+ gitMode: globalParams.resolvedGitMode,
1662
+ outputFile: merged.outputFile,
1663
+ force: params.force as boolean | undefined,
1664
+ },
1665
+ });
1666
+ if (nodeValidation.error) return nodeValidation.error;
1667
+ for (const w of nodeValidation.warnings) nodeWarnings.push(`${nodePrefix}: ${w}`);
1668
+ }
1669
+
1558
1670
  // Build base prompt once
1559
1671
  const basePrompt = ctx.getSystemPrompt();
1560
1672
 
@@ -1626,6 +1738,10 @@ export default function (pi: ExtensionAPI) {
1626
1738
  };
1627
1739
 
1628
1740
  const merged = mergeSubTaskParams(globalParams, subTaskParams);
1741
+ // Issue #222: the node's OWN validated cwd (pre-pass);
1742
+ // resolvedCwd (the mode-level cwd) is the guaranteed
1743
+ // fallback, so a node that declares none still resolves.
1744
+ const nodeCwd = nodeCwds.get(graphTask.id) ?? resolvedCwd;
1629
1745
 
1630
1746
  // C3: Resolve this step's model override (step.model > global resolved model)
1631
1747
  const stepModel = resolveStepModel(ctx, merged.model, subagentModel);
@@ -1709,7 +1825,7 @@ export default function (pi: ExtensionAPI) {
1709
1825
  const stepOnUpdate = makeLiveOnUpdate(state, runId, onUpdate);
1710
1826
 
1711
1827
  const result = await runSubagent(
1712
- resolvedCwd,
1828
+ nodeCwd,
1713
1829
  subagentPrompt,
1714
1830
  stepModel,
1715
1831
  merged.thinkingLevel,
@@ -1760,7 +1876,7 @@ export default function (pi: ExtensionAPI) {
1760
1876
  // finalizeLiveSubagent is idempotent; the rethrow keeps
1761
1877
  // Promise.allSettled semantics (a failing node must NOT cancel
1762
1878
  // sibling nodes in the wave).
1763
- finalizeUnitRunCrash(state, pi, run, err, "Graph node", resolvedCwd, log, {
1879
+ finalizeUnitRunCrash(state, pi, run, err, "Graph node", nodeCwd, log, {
1764
1880
  id: graphTask.id,
1765
1881
  });
1766
1882
  } finally {
@@ -1857,9 +1973,15 @@ export default function (pi: ExtensionAPI) {
1857
1973
  };
1858
1974
  state.persistRun(pi, graphAggregateRun);
1859
1975
 
1976
+ // Per-node validation warnings ride the result, prefixed with their
1977
+ // node id (issue #222), after the JSON payload — empty renders
1978
+ // nothing so warning-free results stay byte-identical.
1860
1979
  return {
1861
1980
  content: [
1862
- { type: "text" as const, text: JSON.stringify(graphDetails, null, 2) },
1981
+ {
1982
+ type: "text" as const,
1983
+ text: JSON.stringify(graphDetails, null, 2) + formatValidationWarnings(nodeWarnings),
1984
+ },
1863
1985
  ],
1864
1986
  details: graphDetails,
1865
1987
  };
@@ -1897,32 +2019,14 @@ export default function (pi: ExtensionAPI) {
1897
2019
 
1898
2020
  // Session cost limit gates the WHOLE batch before any spawn — same
1899
2021
  // 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
- }
2022
+ const costError = gateSessionCost({
2023
+ state,
2024
+ ctx,
2025
+ log,
2026
+ units: taskList.length,
2027
+ label: "Background fan-out",
2028
+ });
2029
+ if (costError) return costError;
1926
2030
 
1927
2031
  // Resolve global params once — same call shape as runParallelMode.
1928
2032
  const globalParams = resolveSubagentParams(
@@ -1958,16 +2062,8 @@ export default function (pi: ExtensionAPI) {
1958
2062
  // Same rejection as the single-background path: approvalMode 'always'
1959
2063
  // cannot work in background — there is no interactive dialog to approve
1960
2064
  // 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
- }
2065
+ const approvalError = rejectApprovalAlwaysInBackground(globalParams.resolvedApprovalMode);
2066
+ if (approvalError) return approvalError;
1971
2067
  // W5 parity with the single-background path: 'writes' silently
1972
2068
  // auto-approves in background — warn once so the caller knows none of
1973
2069
  // the N agents' diffs will be gated on approval.
@@ -1981,9 +2077,9 @@ export default function (pi: ExtensionAPI) {
1981
2077
  // git lock is awaited inside the first spawn and held until that agent
1982
2078
  // settles, so same-repository branch-mode spawns would serialize and
1983
2079
  // 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.
2080
+ // per-repository-checked) because the lock is keyed by the resolved
2081
+ // repository root: units in different directories of one repo share the
2082
+ // working tree and contend for the same lock.
1987
2083
  if (globalParams.resolvedGitMode === 'branch') {
1988
2084
  return {
1989
2085
  content: [{ type: "text" as const, text:
@@ -2006,11 +2102,10 @@ export default function (pi: ExtensionAPI) {
2006
2102
  cwd: string;
2007
2103
  outputFile: string | undefined;
2008
2104
  };
2009
- const displayTaskName = (merged: ReturnType<typeof mergeSubTaskParams>): string =>
2010
- merged.label ??
2011
- (merged.task.length > 60 ? `${merged.task.slice(0, 57)}...` : merged.task);
2012
2105
 
2013
2106
  const fanOutTasks: FanOutTask[] = [];
2107
+ // C: per-task warnings collect here to ride the immediate spawn result.
2108
+ const taskWarnings: string[] = [];
2014
2109
  for (let i = 0; i < taskList.length; i++) {
2015
2110
  const merged = mergeSubTaskParams(globalParams, taskList[i]);
2016
2111
  const name = displayTaskName(merged);
@@ -2018,48 +2113,32 @@ export default function (pi: ExtensionAPI) {
2018
2113
  // Per-task model override wins; the global model is the fallback.
2019
2114
  const stepModel = resolveStepModel(ctx, merged.model, globalModel);
2020
2115
 
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,
2116
+ const targets = validateDelegationTargets({
2117
+ ctx,
2118
+ effectiveCwd: merged.effectiveCwd,
2047
2119
  outputFile: merged.outputFile,
2120
+ prefix: taskPrefix,
2048
2121
  });
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
- }
2122
+ if (!targets.ok) return targets.error;
2123
+
2124
+ const preTask = runPreTaskValidation({
2125
+ log,
2126
+ label: "Background fan-out",
2127
+ prefix: taskPrefix,
2128
+ logContext: { task: i + 1 },
2129
+ preTask: {
2130
+ task: merged.task,
2131
+ toolOptions: merged.toolOptions,
2132
+ thinkingLevel: merged.thinkingLevel,
2133
+ gitMode: globalParams.resolvedGitMode,
2134
+ outputFile: merged.outputFile,
2135
+ force: params.force as boolean | undefined,
2136
+ },
2137
+ });
2138
+ if (preTask.error) return preTask.error;
2139
+ for (const w of preTask.warnings) taskWarnings.push(`${taskPrefix}: ${w}`);
2061
2140
 
2062
- fanOutTasks.push({ merged, model: stepModel, cwd: cwdResult.value, outputFile: resolvedOutputFile });
2141
+ fanOutTasks.push({ merged, model: stepModel, cwd: targets.cwd, outputFile: targets.outputFile });
2063
2142
  }
2064
2143
 
2065
2144
  // Spawn pass — sequential, in task order, through the shared tail.
@@ -2112,7 +2191,11 @@ export default function (pi: ExtensionAPI) {
2112
2191
  timeout: merged.timeout,
2113
2192
  gitMode: globalParams.resolvedGitMode,
2114
2193
  // Retry parity with createUnitRun: snapshot this unit's resolved
2115
- // values so a retry restores them.
2194
+ // values so a retry restores them. The execution-shape fields
2195
+ // (background/gitMode/approvalMode/force) are deliberately NOT
2196
+ // passed here — a fan-out unit is spec'd to retry as a single
2197
+ // run, so it must not inherit this spawn's background-ness.
2198
+ // Same omission in unit-run.ts's createUnitRun.
2116
2199
  originalParams: snapshotOriginalParams({
2117
2200
  systemPrompt: merged.customSP,
2118
2201
  inheritSystemPrompt: merged.inheritSP,
@@ -2149,7 +2232,9 @@ export default function (pi: ExtensionAPI) {
2149
2232
  started.push({ id: agent.id, label: merged.label });
2150
2233
  }
2151
2234
 
2152
- // Text-only result: ids in task order + the wake hint.
2235
+ // Text-only result: ids in task order + the wake hint. C: per-task
2236
+ // validation warnings ride the immediate SPAWN result (prefixed with
2237
+ // their task) so the conductor can stop/re-dispatch in the same turn.
2153
2238
  const lines = started
2154
2239
  .map((s, i) => `${i + 1}. ${s.label ? `"${s.label}" ` : ""}— ${s.id}`)
2155
2240
  .join("\n");
@@ -2157,7 +2242,8 @@ export default function (pi: ExtensionAPI) {
2157
2242
  content: [{ type: "text" as const, text:
2158
2243
  `Background agents started: ${started.length}\n\n` +
2159
2244
  `${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.`
2245
+ `You'll be woken with a completion message as each finishes; use get_subagent_result({ agent_id }) for retrieval and stall checks.` +
2246
+ formatValidationWarnings(taskWarnings)
2161
2247
  }],
2162
2248
  details: undefined,
2163
2249
  };
@@ -2447,7 +2533,11 @@ export default function (pi: ExtensionAPI) {
2447
2533
  // Resolve the preset and its model BEFORE the background branch so the
2448
2534
  // preset's model (and system prompt) are honored in background mode.
2449
2535
  const bgResolved = resolveSubagentParams({ ...params, task: singleTask }, state, ctx, log);
2450
- const { resolvedPreset: bgResolvedPreset, autoRoutedPreset: bgAutoRoutedPreset } = bgResolved;
2536
+ const {
2537
+ resolvedPreset: bgResolvedPreset,
2538
+ autoRoutedPreset: bgAutoRoutedPreset,
2539
+ autoRouteKeyword: bgAutoRouteKeyword,
2540
+ } = bgResolved;
2451
2541
  const bgModelResult = resolveSubagentModel(ctx, bgResolvedPreset, params.model);
2452
2542
  const bgModel = bgModelResult.ok
2453
2543
  ? `${bgModelResult.model.provider}/${bgModelResult.model.id}`
@@ -2457,16 +2547,8 @@ export default function (pi: ExtensionAPI) {
2457
2547
  // W5 (issue #28): approvalMode 'always' cannot work in background —
2458
2548
  // there is no interactive dialog to approve the diff. Reject loudly
2459
2549
  // 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
- }
2550
+ const approvalError = rejectApprovalAlwaysInBackground(bgResolved.resolvedApprovalMode);
2551
+ if (approvalError) return approvalError;
2470
2552
  // W5: 'writes' in background silently auto-approves — warn once so the
2471
2553
  // caller knows the diff will NOT be gated on approval.
2472
2554
  if (bgResolved.resolvedApprovalMode === 'writes') {
@@ -2478,79 +2560,42 @@ export default function (pi: ExtensionAPI) {
2478
2560
  // W6 (issue #28): session cost limit must gate background spawns too —
2479
2561
  // the R5 check after the background branch never runs for them.
2480
2562
  // 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
- }
2563
+ const costError = gateSessionCost({
2564
+ state,
2565
+ ctx,
2566
+ log,
2567
+ units: 1,
2568
+ label: "Background delegation",
2569
+ });
2570
+ if (costError) return costError;
2506
2571
 
2507
2572
  // F1: Validate cwd + outputFile the same way foreground single mode does —
2508
2573
  // an unvalidated outputFile would reach the prompt and could steer the
2509
2574
  // 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,
2575
+ const targets = validateDelegationTargets({
2576
+ ctx,
2577
+ effectiveCwd: bgResolved.effectiveCwd,
2578
+ outputFile: bgResolved.outputFile,
2541
2579
  });
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
- }
2580
+ if (!targets.ok) return targets.error;
2581
+ const bgResolvedOutputFile = targets.outputFile;
2582
+
2583
+ // C: H1 validation for background mode — reject capability blocks and
2584
+ // outputFile-vs-write conflicts before spawning (same as single mode;
2585
+ // `force` honors the same override foreground does).
2586
+ const bgPreTask = runPreTaskValidation({
2587
+ log,
2588
+ label: "Background",
2589
+ preTask: {
2590
+ task: singleTask,
2591
+ toolOptions: bgResolved.toolOptions,
2592
+ thinkingLevel: bgResolved.thinkingLevel,
2593
+ gitMode: bgResolved.resolvedGitMode,
2594
+ outputFile: bgResolvedOutputFile,
2595
+ force: params.force,
2596
+ },
2597
+ });
2598
+ if (bgPreTask.error) return bgPreTask.error;
2554
2599
 
2555
2600
  // Build the full prompt the same way foreground single mode does:
2556
2601
  // base prompt (optionally inherited) + custom prompt + preset guidance.
@@ -2571,7 +2616,7 @@ export default function (pi: ExtensionAPI) {
2571
2616
  thinkingLevel: bgResolved.thinkingLevel,
2572
2617
  priority: params.priority,
2573
2618
  systemPrompt: bgPrompt,
2574
- cwd: bgCwdResult.value,
2619
+ cwd: targets.cwd,
2575
2620
  toolOptions: bgResolved.toolOptions,
2576
2621
  timeout: bgResolved.timeout,
2577
2622
  gitMode: bgResolved.resolvedGitMode,
@@ -2579,11 +2624,11 @@ export default function (pi: ExtensionAPI) {
2579
2624
  sanitizeCwd: bgResolved.effectiveCwd,
2580
2625
  });
2581
2626
 
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.
2627
+ // B2: Surface auto-route decisions in background spawn result too —
2628
+ // preset name + matched-keyword evidence + restriction, derived from
2629
+ // the RESOLVED toolOptions (reflects per-call overrides).
2585
2630
  const bgAutoRouteNote = bgAutoRoutedPreset
2586
- ? `\n\n[auto-routed to preset '${bgAutoRoutedPreset.name}' — ${bgResolved.toolOptions ? formatToolRestriction(bgResolved.toolOptions) : formatPresetRestriction(bgAutoRoutedPreset)}]`
2631
+ ? `\n\n[auto-routed to preset '${bgAutoRoutedPreset.name}'${bgAutoRouteKeyword ? ` (matched keyword '${bgAutoRouteKeyword}')` : ""} — ${bgResolved.toolOptions ? formatToolRestriction(bgResolved.toolOptions) : formatPresetRestriction(bgAutoRoutedPreset)}]`
2587
2632
  : "";
2588
2633
 
2589
2634
  return {
@@ -2594,7 +2639,10 @@ export default function (pi: ExtensionAPI) {
2594
2639
  `Task: ${agent.task}\n` +
2595
2640
  `Status: ${agent.status}\n\n` +
2596
2641
  `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,
2642
+ bgAutoRouteNote +
2643
+ // C: warnings ride the immediate SPAWN result so the conductor
2644
+ // can stop/re-dispatch in the same turn.
2645
+ formatValidationWarnings(bgPreTask.warnings),
2598
2646
  }],
2599
2647
  details: undefined,
2600
2648
  };
@@ -2707,12 +2755,12 @@ export default function (pi: ExtensionAPI) {
2707
2755
  "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
2756
  "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
2757
  "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.",
2758
+ "Set timeout (in ms) to limit how long a subagent can run. Useful for tasks that might hang or get stuck. Background runs are additionally hard-capped at 30 minutes, so a larger value is silently shortened.",
2711
2759
  "Set cwd to override the subagent's working directory. Defaults to the current project directory.",
2712
2760
  "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
2761
  `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
2762
  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.",
2763
+ "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.",
2716
2764
  "Set retryOnTimeout: true to automatically retry a subagent that times out. Only retries once — the second timeout is treated as a final failure.",
2717
2765
  "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
2766
  "",
@@ -2723,9 +2771,9 @@ export default function (pi: ExtensionAPI) {
2723
2771
  "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
2772
  "3. **Git mode**: Use gitMode='branch' for tasks that create commits or PRs. Use gitMode='none' for read-only tasks.",
2725
2773
  "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.",
2774
+ "5. **Timeout**: Set timeout based on task complexity. Simple: 30s. Medium: 60s. Complex: 120s+. xhigh thinking: at least 120s. Background runs are hard-capped at 30 minutes regardless of the value set.",
2727
2775
  "",
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.",
2776
+ "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
2777
  "",
2730
2778
  "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
2779
  "The autoRoutePreset() function can automatically select the best preset based on task keywords. Consider using it for preset selection.",
@@ -2780,7 +2828,8 @@ export default function (pi: ExtensionAPI) {
2780
2828
  Type.Number({
2781
2829
  description:
2782
2830
  "Maximum time in milliseconds the subagent is allowed to run. " +
2783
- "If exceeded, the subagent is killed and an error is returned.",
2831
+ "If exceeded, the subagent is killed and an error is returned. " +
2832
+ "Background runs are additionally hard-capped at 30 minutes, so a larger value is silently shortened there.",
2784
2833
  }),
2785
2834
  ),
2786
2835
  cwd: Type.Optional(
@@ -2836,9 +2885,21 @@ export default function (pi: ExtensionAPI) {
2836
2885
  Type.String({
2837
2886
  description:
2838
2887
  "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.",
2888
+ "The retry rebuilds the parameter object from a fixed field set, falling back to the " +
2889
+ "original run's recorded values, with explicit values on this call winning: task, label, " +
2890
+ "model, preset, systemPrompt, inheritSystemPrompt, thinkingLevel, priority, outputFile, " +
2891
+ "timeout, cwd, tools, excludeTools, noBuiltinTools, background, gitMode, approvalMode, force. " +
2892
+ "The execution-shape fields ARE restored: a retried background run stays background (with its " +
2893
+ "completion wake), and a retried branch-mode run keeps its work branch, its approvalMode " +
2894
+ "gating and its force override. Qualify that by the effective gitMode - when neither the record " +
2895
+ "nor this call carries one, the configured default applies, which may still be 'branch'. " +
2896
+ "Not restored: chain/tasks/graph and params, so a retried multi-step run silently degrades to a " +
2897
+ "single task - re-issue it fresh. retryOnTimeout is explicit-only: never restored, honoured when passed. " +
2898
+ "template is asymmetric: the original's template is NOT restored, but a template passed on the " +
2899
+ "retry call DOES take effect (it is resolved before the retry merge). " +
2900
+ "Fan-out units (parallel/chain/graph origin) record no background, so retrying one is a single " +
2901
+ "foreground run - pass background: true for a background retry. " +
2902
+ "Only works with runs that ended in failure (exitCode != 0, timeout, error, or abort).",
2842
2903
  }),
2843
2904
  ),
2844
2905
  gitMode: Type.Optional(
@@ -2863,6 +2924,15 @@ export default function (pi: ExtensionAPI) {
2863
2924
  "always (ask every time). Default is user config (/brl-subagent approval).",
2864
2925
  }),
2865
2926
  ),
2927
+ force: Type.Optional(
2928
+ Type.Boolean({
2929
+ description:
2930
+ "Override for capability pre-flight errors (default false). A dispatch is blocked when the task clearly needs a capability the resolved toolset lacks — " +
2931
+ "a run/execute/test/compile/benchmark task with no bash, or an exploration task (search/grep/find/list/locate/glob) with none of find/ls/grep/bash. " +
2932
+ "Set force: true to dispatch anyway: the mismatch is then delivered as a warning in the result instead of rejecting the call. " +
2933
+ "Warnings are surfaced either way. force never suppresses outputFile-without-write conflicts.",
2934
+ }),
2935
+ ),
2866
2936
  background: Type.Optional(
2867
2937
  Type.Boolean({
2868
2938
  description:
@@ -2928,7 +2998,7 @@ export default function (pi: ExtensionAPI) {
2928
2998
  })),
2929
2999
  graph: Type.Optional(Type.Array(Type.Object({
2930
3000
  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." }),
3001
+ task: Type.String({ description: "Task description. Use {<nodeId>} to reference output from another task (the referenced node's own id, which must be a word-character id - letters, digits, underscore; ids like 'step-1' or 'node.a' are not matched and the braces are left as literal text)." }),
2932
3002
  label: Type.Optional(Type.String({})),
2933
3003
  model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
2934
3004
  dependsOn: Type.Optional(Type.Array(Type.String({}), { description: "IDs of tasks that must complete before this one starts" })),
@@ -2977,6 +3047,7 @@ export default function (pi: ExtensionAPI) {
2977
3047
  retryRunId?: string;
2978
3048
  retryOnTimeout?: boolean;
2979
3049
  background?: boolean;
3050
+ force?: boolean;
2980
3051
  gitMode?: string;
2981
3052
  priority?: string;
2982
3053
  chain?: Array<{
@@ -3257,31 +3328,14 @@ export default function (pi: ExtensionAPI) {
3257
3328
 
3258
3329
  // R5: Check session cost limit before spawning
3259
3330
  // 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
- }
3331
+ const costError = gateSessionCost({
3332
+ state,
3333
+ ctx,
3334
+ log,
3335
+ units: 1,
3336
+ label: "Subagent delegation",
3337
+ });
3338
+ if (costError) return costError;
3285
3339
 
3286
3340
  // Reject delegation if recursion depth exceeds configured max.
3287
3341
  // This prevents subagents from spawning infinite sub-subagents while
@@ -3321,6 +3375,7 @@ export default function (pi: ExtensionAPI) {
3321
3375
  resolvedApprovalMode,
3322
3376
  resolvedPreset,
3323
3377
  autoRoutedPreset,
3378
+ autoRouteKeyword,
3324
3379
  } = resolveSubagentParams({ ...params, task: singleTask }, state, ctx, log);
3325
3380
 
3326
3381
  // F1: Validate CWD
@@ -3362,12 +3417,14 @@ export default function (pi: ExtensionAPI) {
3362
3417
  }
3363
3418
 
3364
3419
  // H1: Pre-task validation — deterministic check that tools/thinking match task
3420
+ // (`force` degrades capability errors to warnings — B2's override).
3365
3421
  const validation = validatePreTask({
3366
3422
  task,
3367
3423
  toolOptions,
3368
3424
  thinkingLevel,
3369
3425
  gitMode: resolvedGitMode,
3370
3426
  outputFile: resolvedOutputFile,
3427
+ force: params.force,
3371
3428
  });
3372
3429
  if (validation.warnings.length > 0) {
3373
3430
  log.warn("Pre-task validation warnings", { warnings: validation.warnings });
@@ -3384,6 +3441,9 @@ export default function (pi: ExtensionAPI) {
3384
3441
  isError: true,
3385
3442
  };
3386
3443
  }
3444
+ // C: surface warnings in the returned result (not just the log) —
3445
+ // shared renderer so foreground/background wording stays identical.
3446
+ const warningsNote = formatValidationWarnings(validation.warnings);
3387
3447
 
3388
3448
  // Resolve model (per-call top-level model override beats preset)
3389
3449
  const modelResult = resolveSubagentModel(ctx, resolvedPreset, params.model);
@@ -3771,6 +3831,9 @@ export default function (pi: ExtensionAPI) {
3771
3831
  if (suggestions.length > 0) {
3772
3832
  finalMsg += "\n\nSuggestions:\n" + suggestions.map((s) => `- ${s}`).join("\n");
3773
3833
  }
3834
+ // C: warnings ride the returned result too — a failed run is
3835
+ // exactly when the conductor most needs the mismatch signal.
3836
+ finalMsg += warningsNote;
3774
3837
 
3775
3838
  completeTranscript(runId, 'failed');
3776
3839
 
@@ -3797,16 +3860,17 @@ export default function (pi: ExtensionAPI) {
3797
3860
  state.recordSuccess();
3798
3861
 
3799
3862
  // 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.
3863
+ // sees WHICH preset was auto-selected, the KEYWORD evidence for it,
3864
+ // and what it restricts. Derive the restriction from the RESOLVED
3865
+ // toolOptions (reflects per-call overrides) rather than the preset's
3866
+ // declared values.
3803
3867
  const autoRouteNote = autoRoutedPreset
3804
- ? `\n\n[auto-routed to preset '${autoRoutedPreset.name}' — ${toolOptions ? formatToolRestriction(toolOptions) : formatPresetRestriction(autoRoutedPreset)}]`
3868
+ ? `\n\n[auto-routed to preset '${autoRoutedPreset.name}'${autoRouteKeyword ? ` (matched keyword '${autoRouteKeyword}')` : ""} — ${toolOptions ? formatToolRestriction(toolOptions) : formatPresetRestriction(autoRoutedPreset)}]`
3805
3869
  : "";
3806
3870
 
3807
3871
  return {
3808
3872
  content: [
3809
- { type: "text" as const, text: (finalOutput || "(no output)") + autoRouteNote },
3873
+ { type: "text" as const, text: (finalOutput || "(no output)") + autoRouteNote + warningsNote },
3810
3874
  ],
3811
3875
  details: result,
3812
3876
  };
@@ -3886,11 +3950,6 @@ export default function (pi: ExtensionAPI) {
3886
3950
  agent_id: Type.String({
3887
3951
  description: "The agent ID returned by delegate_task when background=true",
3888
3952
  }),
3889
- wait: Type.Optional(
3890
- Type.Boolean({
3891
- description: "If true, wait for the agent to complete before returning. Default: false.",
3892
- }),
3893
- ),
3894
3953
  verbose: Type.Optional(
3895
3954
  Type.Boolean({
3896
3955
  description: "If true, include the full conversation log. Default: false.",