@nexrall/code-core 1.4.32 → 1.4.34

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.
@@ -40,8 +40,15 @@ exports.stopReasonNotice = stopReasonNotice;
40
40
  exports.resolveMaxIterations = resolveMaxIterations;
41
41
  exports.lockPathsFor = lockPathsFor;
42
42
  exports.resolveMaxConcurrentSubtasks = resolveMaxConcurrentSubtasks;
43
+ exports.resolveMaxSubagentsPerSession = resolveMaxSubagentsPerSession;
43
44
  exports.createLimiter = createLimiter;
44
45
  exports._resetSubTaskLimiter = _resetSubTaskLimiter;
46
+ exports.resetSessionSubAgentBudget = resetSessionSubAgentBudget;
47
+ exports._sessionSubAgentCount = _sessionSubAgentCount;
48
+ exports.resolveMaxSubagentDepth = resolveMaxSubagentDepth;
49
+ exports.noSpawnReason = noSpawnReason;
50
+ exports.intersectAllowlists = intersectAllowlists;
51
+ exports.canSpawnSubAgents = canSpawnSubAgents;
45
52
  exports.resolveSubtaskTimeoutMs = resolveSubtaskTimeoutMs;
46
53
  exports.extractSubTaskText = extractSubTaskText;
47
54
  exports.capSubTaskText = capSubTaskText;
@@ -80,6 +87,7 @@ const testIntegrity_1 = require("./testIntegrity");
80
87
  const flaky_1 = require("./flaky");
81
88
  const claimEvidence_1 = require("./claimEvidence");
82
89
  const memory_1 = require("./memory");
90
+ const safeSlice_1 = require("../util/safeSlice");
83
91
  const fs = __importStar(require("fs"));
84
92
  const path = __importStar(require("path"));
85
93
  const child_process_1 = require("child_process");
@@ -477,7 +485,11 @@ const DEFAULT_MAX_CONCURRENT_SUBTASKS = 4;
477
485
  * degrade to "a lot" rather than fork-bomb the machine.
478
486
  */
479
487
  function resolveMaxConcurrentSubtasks(settingsRaw = {}) {
480
- const clamp = (n) => Math.min(Math.floor(n), 16);
488
+ // `Math.max(1, )` matters: a fractional value like 0.5 passes the `> 0` guard, then floors
489
+ // to 0, and createLimiter(0) queues every task with nothing left to ever release them — a
490
+ // silent permanent hang with no timeout and no error. Harmless when only depth 0 used the
491
+ // limiter; now that every level does, it would wedge the whole tree.
492
+ const clamp = (n) => Math.max(1, Math.min(Math.floor(n), 16));
481
493
  const fromEnv = Number(process.env.NEXRALL_MAX_CONCURRENT_SUBTASKS);
482
494
  if (Number.isFinite(fromEnv) && fromEnv > 0)
483
495
  return clamp(fromEnv);
@@ -486,6 +498,40 @@ function resolveMaxConcurrentSubtasks(settingsRaw = {}) {
486
498
  return clamp(fromSettings);
487
499
  return DEFAULT_MAX_CONCURRENT_SUBTASKS;
488
500
  }
501
+ // ── Session TOTAL, distinct from the per-moment concurrency gate ─────────────
502
+ //
503
+ // maxConcurrentSubtasks bounds how many run AT ONCE; it does not bound how many run
504
+ // IN TOTAL. With a queue rather than a rejection, "4 at a time" and "unbounded" are the
505
+ // same thing given enough turns — the limiter just meters the spend, it never stops it.
506
+ // A model in a retry loop could spawn sub-agents indefinitely and the only signal would
507
+ // be the bill.
508
+ //
509
+ // This gap did not matter much while sub-agents were leaves: only the main agent could
510
+ // spawn, so the count grew linearly with its own turns. With nesting it grows like a
511
+ // tree, which is exactly why Anthropic added a per-session subagent ceiling alongside
512
+ // their concurrency cap rather than relying on concurrency alone.
513
+ //
514
+ // 100 is chosen to be invisible in real work (a heavy orchestration session uses a few
515
+ // dozen) and decisive in a runaway. Unlike the concurrency gate this REJECTS rather than
516
+ // queues: a queue that never drains is a hang, and the point here is to stop.
517
+ const DEFAULT_MAX_SUBAGENTS_PER_SESSION = 100;
518
+ /** Resolve the session-total sub-agent ceiling: env → settings.json → default. */
519
+ function resolveMaxSubagentsPerSession(settingsRaw = {}) {
520
+ // Clamped, unlike the first draft of this function. The argument for leaving it unbounded
521
+ // was that it only moves a counter — but `.nexrall/settings.json` is REPO-CONTROLLED and
522
+ // merged last, so a cloned repo could set 999999 and neutralise the one ceiling that makes
523
+ // a default depth > 1 defensible, turning `depth 5 x 16 wide` into an unbounded spend on a
524
+ // machine whose owner only opened a project. Depth and concurrency were already clamped for
525
+ // exactly this reason; this was the gap between them.
526
+ const floor = (n) => Math.max(1, Math.min(Math.floor(n), 10000));
527
+ const fromEnv = Number(process.env.NEXRALL_MAX_SUBAGENTS_PER_SESSION);
528
+ if (Number.isFinite(fromEnv) && fromEnv > 0)
529
+ return floor(fromEnv);
530
+ const fromSettings = Number(settingsRaw.maxSubagentsPerSession);
531
+ if (Number.isFinite(fromSettings) && fromSettings > 0)
532
+ return floor(fromSettings);
533
+ return DEFAULT_MAX_SUBAGENTS_PER_SESSION;
534
+ }
489
535
  /**
490
536
  * Minimal concurrency gate. Hand-rolled rather than pulling in `p-limit` because
491
537
  * the CLI ships as a single esbuild bundle with no node_modules, and this is a
@@ -519,21 +565,134 @@ function createLimiter(max) {
519
565
  // Once created it is reused for the process lifetime — rebuilding it per turn would
520
566
  // reset `active` and let the ceiling be exceeded, which is worse than not honouring a
521
567
  // mid-session settings change.
522
- let _subTaskLimitInstance = null;
568
+ // ONE LIMITER PER DEPTH, which is what makes nesting safe to gate at all.
569
+ //
570
+ // The old design gated only `depth === 0` and left nested spawns ungated — deliberately,
571
+ // because a single shared limiter deadlocks the moment a slot-holder re-enters it: a
572
+ // parent holding one of N slots waits for a child that can only start when a slot frees,
573
+ // and if all N are held by such parents the run wedges forever. While sub-agents were
574
+ // leaves that could not happen, so "gate the top, leave the rest" cost nothing.
575
+ //
576
+ // With nesting it costs everything: nested fan-out becomes completely unbounded, which is
577
+ // worse than the deadlock it was avoiding.
578
+ //
579
+ // Keying the limiter by depth fixes both at once. A depth-D run only ever waits on the
580
+ // depth-(D+1) limiter, never its own, so the wait-for graph is strictly ordered by depth —
581
+ // a DAG, and a DAG cannot deadlock. Every level is independently bounded, so worst-case
582
+ // concurrency is bounded per level rather than unbounded below level 1.
583
+ const _subTaskLimiters = new Map();
584
+ /** The tapered ceiling actually applied at each depth, so the queue notice can report it. */
585
+ const _subTaskLimitMaxByDepth = new Map();
523
586
  let _subTaskLimitMax = 0;
524
- /** Sub-tasks dispatched but not yet finished — used only to detect queueing. */
525
- let _inFlightSubTasks = 0;
526
- function subTaskLimiter(workDir) {
527
- if (!_subTaskLimitInstance) {
587
+ /** In-flight count per depth — used only to detect queueing, so the notice is accurate. */
588
+ const _inFlightByDepth = new Map();
589
+ function subTaskLimiter(depth, workDir) {
590
+ if (!_subTaskLimitMax) {
528
591
  _subTaskLimitMax = resolveMaxConcurrentSubtasks(workDir ? (0, rules_1.loadSettings)(workDir).raw : {});
529
- _subTaskLimitInstance = createLimiter(_subTaskLimitMax);
530
592
  }
531
- return { run: _subTaskLimitInstance, max: _subTaskLimitMax };
593
+ let run = _subTaskLimiters.get(depth);
594
+ let max = _subTaskLimitMaxByDepth.get(depth) ?? 0;
595
+ if (!run) {
596
+ // TAPERED per level, not `max` at every level.
597
+ //
598
+ // Giving each depth the full `max` multiplies total concurrency by the depth limit: 4
599
+ // becomes 12 by default and 16×5 = 80 at the configured maxima. The value is justified by
600
+ // shared resources — CPU, file handles, ONE API rate limit — none of which care which
601
+ // level a loop is running at, so honouring `4` per level silently abandons the limit the
602
+ // user set. Halving per level bounds the total at ~2× `max` (4+2+1 = 7) while keeping the
603
+ // per-depth structure that makes the wait-for graph acyclic.
604
+ //
605
+ // Never below 1: a level with 0 slots is a permanent hang, not a restriction.
606
+ max = Math.max(1, Math.ceil(_subTaskLimitMax / 2 ** Math.max(0, depth - 1)));
607
+ run = createLimiter(max);
608
+ _subTaskLimiters.set(depth, run);
609
+ _subTaskLimitMaxByDepth.set(depth, max);
610
+ }
611
+ return { run, max };
532
612
  }
533
613
  /** Test-only: forget the memoised limiter so a new limit can take effect. */
534
614
  function _resetSubTaskLimiter() {
535
- _subTaskLimitInstance = null;
615
+ _subTaskLimiters.clear();
616
+ _subTaskLimitMaxByDepth.clear();
617
+ _inFlightByDepth.clear();
536
618
  _subTaskLimitMax = 0;
619
+ _subAgentsThisSession = 0;
620
+ _sessionCapMax = 0;
621
+ _sessionCapNotified = false;
622
+ }
623
+ // Counts sub-agents STARTED, never decremented — that is what makes it a budget rather
624
+ // than a concurrency gate.
625
+ //
626
+ // NOT process-wide, unlike the limiter, and the distinction is load-bearing. A concurrency
627
+ // gate is safe to share across a process because it self-drains; a monotonic counter is
628
+ // not. In the CLI one process is one session, but VS Code calls runAgentLoop from a
629
+ // long-lived extension host, so process-scoped state would accumulate across every
630
+ // conversation in the window until delegation died permanently — and the remedy string
631
+ // ("start a new session") would be a lie, since only reloading the window would help.
632
+ let _subAgentsThisSession = 0;
633
+ let _sessionCapMax = 0;
634
+ let _sessionCapNotified = false;
635
+ /**
636
+ * Start a fresh sub-agent budget. Call when a NEW conversation begins.
637
+ *
638
+ * Exported for clients that reuse one process across conversations (the VS Code extension
639
+ * host). A client that never calls it gets process-lifetime semantics, which is correct
640
+ * for a one-shot CLI invocation.
641
+ */
642
+ function resetSessionSubAgentBudget() {
643
+ _subAgentsThisSession = 0;
644
+ _sessionCapMax = 0;
645
+ _sessionCapNotified = false;
646
+ }
647
+ /**
648
+ * Claim one slot against the session total. Returns an error string when exhausted.
649
+ *
650
+ * `notify` surfaces exhaustion to the HUMAN exactly once. Without it only the model is
651
+ * told, and a model instructed to "do the remaining work directly" complies silently — so
652
+ * the user never learns delegation was capped, which is the one thing a runaway guard has
653
+ * to make visible.
654
+ */
655
+ function claimSessionSubAgentSlot(workDir, notify) {
656
+ if (!_sessionCapMax) {
657
+ _sessionCapMax = resolveMaxSubagentsPerSession(workDir ? (0, rules_1.loadSettings)(workDir).raw : {});
658
+ }
659
+ if (_subAgentsThisSession >= _sessionCapMax) {
660
+ if (!_sessionCapNotified) {
661
+ _sessionCapNotified = true;
662
+ notify?.(`\u26a0\ufe0f Sub-agent budget reached (${_sessionCapMax} this session) \u2014 further delegation is ` +
663
+ 'blocked and the agent will continue without it. Raise "maxSubagentsPerSession" in ' +
664
+ '.nexrall/settings.json if this was legitimate work.');
665
+ }
666
+ return (`Sub-agent budget for this session is exhausted (${_sessionCapMax} started). This is a ` +
667
+ 'runaway-delegation guard, not a per-task limit: do the remaining work directly, and say ' +
668
+ 'in your final message that you hit the delegation cap.');
669
+ }
670
+ _subAgentsThisSession++;
671
+ return null;
672
+ }
673
+ /**
674
+ * Give back a slot claimed for a spawn that never started an agent.
675
+ *
676
+ * The claim happens at the dispatch site, BEFORE the concurrency limiter, so that a spawn
677
+ * which is already over budget is refused immediately instead of queueing behind running
678
+ * siblings. That ordering is what makes the guard useful in a runaway — but it also means
679
+ * the claim precedes runSubTask's own validation, which rejects on several paths without
680
+ * ever starting a loop (empty prompt, a `deny` rule, an unusable resume id).
681
+ *
682
+ * Without a refund those rejections spend budget: a model retrying against a deny rule would
683
+ * silently burn the whole allowance on spawns that never ran, then lose delegation for the
684
+ * session with a notice blaming a runaway that never happened. The counter's contract is
685
+ * "sub-agents STARTED", and this is what keeps that true while preserving fast refusal.
686
+ *
687
+ * Floored at 0 so a double refund can never manufacture budget.
688
+ */
689
+ function refundSessionSubAgentSlot() {
690
+ if (_subAgentsThisSession > 0)
691
+ _subAgentsThisSession--;
692
+ }
693
+ /** Test-only: observe the session counter without exporting the mutable binding. */
694
+ function _sessionSubAgentCount() {
695
+ return _subAgentsThisSession;
537
696
  }
538
697
  // ─── Human-readable tool descriptions ────────────────────────────────────────
539
698
  function humanDescription(name, input) {
@@ -620,15 +779,131 @@ function humanDescription(name, input) {
620
779
  }
621
780
  }
622
781
  // ─── Sub-task runner ──────────────────────────────────────────────────────────
623
- // Deepest nesting level allowed to spawn a sub-agent. The main agent runs at
624
- // depth 0 and the sub-agents it spawns at depth 1.
782
+ // Nesting depth. The main agent runs at depth 0, the sub-agents it spawns at depth 1.
783
+ //
784
+ // A run at `depth` may spawn when `depth < limit`, so a limit of N means N generations
785
+ // below the main agent. This used to be a hard-coded 1 with the historical note "This is
786
+ // 1, not 2" — that note was about making the CONSTANT agree with a system prompt that
787
+ // promised "sub-agents cannot spawn further sub-agents". Both sides of that agreement have
788
+ // now moved: nesting is a supported, configured capability, and the prompt is generated
789
+ // from the same predicate that enforces it (canSpawnSubAgents), so the two cannot drift
790
+ // again regardless of the value.
625
791
  //
626
- // This is 1, not 2. With 2 the guard below (`depth >= MAX_TASK_DEPTH`) let a
627
- // depth-1 sub-agent spawn depth-2 grandchildren contradicting both the constant's
628
- // own comment and the system prompt's promise that "sub-agents cannot spawn further
629
- // sub-agents", and quietly making the worst-case fan-out quadratic. The value and
630
- // the documented behaviour now agree.
631
- const MAX_TASK_DEPTH = 1;
792
+ // What made the old value load-bearing was that fan-out had no TOTAL bound only a
793
+ // per-moment concurrency gate. Depth × fan-out is multiplicative, so raising depth without
794
+ // a session ceiling converts a bounded queue into an unbounded tree. That ceiling
795
+ // (resolveMaxSubagentsPerSession) is what makes a depth > 1 safe to default to.
796
+ const DEFAULT_MAX_TASK_DEPTH = 3;
797
+ /**
798
+ * Hard ceiling on `maxSubagentDepth`, independent of what anyone configures.
799
+ *
800
+ * 5 matches the deepest tier Anthropic shipped for Claude Code. It exists because depth
801
+ * is MULTIPLICATIVE with fan-out: at 4-wide, depth 5 is 4^5 = 1024 possible loops. The
802
+ * per-depth limiter and the session ceiling below are what actually bound that, but a
803
+ * typo'd `maxSubagentDepth: 50` should degrade to "deep" rather than to a fork bomb —
804
+ * same reasoning as the clamp on maxConcurrentSubtasks.
805
+ */
806
+ const HARD_MAX_TASK_DEPTH = 5;
807
+ /**
808
+ * Deepest nesting level allowed to spawn: env → settings.json → default 3.
809
+ *
810
+ * Was a hard-coded 1, i.e. "sub-agents are leaves". That was the right default while the
811
+ * three capability decisions disagreed with each other (see canSpawnSubAgents), but it is
812
+ * no longer where the ecosystem is: Claude Code lifted the no-nesting rule and, after a
813
+ * brief period with it disabled entirely, settled on a configurable default of 3.
814
+ *
815
+ * 3, not 5, deliberately. Depth is a budget you SPEND, not headroom you fill: every level
816
+ * is a context window that receives only a dispatch prompt on the way down and returns
817
+ * only a summary on the way up, so the deeper frames pay full freight to carry less
818
+ * information. 3 covers orchestrator → worker → helper, which is where the observed value
819
+ * is; beyond that latency and token cost tend to exceed the benefit.
820
+ *
821
+ * Depth 1 remains available (`maxSubagentDepth: 1`) for anyone who wants leaves-only.
822
+ */
823
+ function resolveMaxSubagentDepth(settingsRaw = {}) {
824
+ const clamp = (n) => Math.max(1, Math.min(Math.floor(n), HARD_MAX_TASK_DEPTH));
825
+ const fromEnv = Number(process.env.NEXRALL_MAX_SUBAGENT_DEPTH);
826
+ if (Number.isFinite(fromEnv) && fromEnv > 0)
827
+ return clamp(fromEnv);
828
+ const fromSettings = Number(settingsRaw.maxSubagentDepth);
829
+ if (Number.isFinite(fromSettings) && fromSettings > 0)
830
+ return clamp(fromSettings);
831
+ return DEFAULT_MAX_TASK_DEPTH;
832
+ }
833
+ /**
834
+ * The ONE explanation for "this run may not spawn a sub-agent", shared by every place
835
+ * that can refuse it, so the same impossibility never gets two different stories.
836
+ *
837
+ * Parameterised on the REASON because the reasons are no longer interchangeable. It used
838
+ * to be a flat constant reading "Sub-agents cannot spawn further sub-agents — this is a
839
+ * structural limit"; with nesting configurable that sentence is now false for most runs,
840
+ * and telling a depth-1 agent its limit is structural when the user could raise it by one
841
+ * line of settings is the same class of misdirection as the "needs a different agent" text
842
+ * this replaced. A refusal has to be accurate about whether it can be lifted, or the model
843
+ * either gives up when it shouldn't or hunts for an escape that doesn't exist.
844
+ */
845
+ function noSpawnReason(kind, limit) {
846
+ if (kind === 'denied') {
847
+ return ('This sub-agent\'s definition does not grant `task`, so it may not delegate. That is a ' +
848
+ 'deliberate restriction on this agent type — do not ask for approval and do not look for ' +
849
+ 'a way around it. Do the work with the tools you have, or report back what is missing.');
850
+ }
851
+ return (`Maximum sub-agent nesting depth (${limit ?? DEFAULT_MAX_TASK_DEPTH}) reached, so this run ` +
852
+ 'is a leaf and cannot delegate further. Depth is a budget, not a bug: finish this work ' +
853
+ 'yourself, or report back so a shallower frame can decide. (The ceiling is ' +
854
+ '"maxSubagentDepth" in .nexrall/settings.json, but raising it mid-task will not help you — ' +
855
+ 'it applies from the next session.)');
856
+ }
857
+ /**
858
+ * Whether a run at `depth` may spawn sub-agents.
859
+ *
860
+ * The single source of truth for THREE things that must agree: the `<available_subagents>`
861
+ * catalogue in the system prompt, whether the backend is asked to send the `task` tool
862
+ * schema at all, and which prompt block teaches delegation. They used to be decided
863
+ * independently, and the result was a sub-agent that got the tool plus instructions to use
864
+ * it but no catalogue — then a permission-gate refusal telling it not to ask for approval.
865
+ *
866
+ * `depth < limit` because the children this run would create land at `depth + 1`; the
867
+ * guard inside runSubTask mirrors it as `depth >= limit`.
868
+ *
869
+ * `limit` is injected rather than read from module state so this stays pure and testable.
870
+ * Callers pass the resolved per-workspace value; it defaults to the built-in for the
871
+ * handful of call sites that have no settings in hand.
872
+ */
873
+ /**
874
+ * A child sub-agent's effective tool allowlist: its own, narrowed by its parent's.
875
+ *
876
+ * Exported and pure because it is a SECURITY boundary and was previously verified only by
877
+ * grepping the source for the intersection expression — which matched happily while the
878
+ * code threw a TypeError on one of its own four cases. A boundary needs behavioural tests.
879
+ *
880
+ * `null` means "no allowlist" (unrestricted), and it is returned only when BOTH sides say
881
+ * so. The four cases:
882
+ * own + parent → intersection (a child can narrow, never widen)
883
+ * own only → own (the main agent, which has no allowlist, spawning a specialist)
884
+ * parent only → a COPY of parent (an unnamed/general-purpose child inherits the
885
+ * restriction instead of resetting to full access — this is the
886
+ * escalation path, since `general-purpose` declares no tools at all)
887
+ * neither → null
888
+ *
889
+ * The parent-only case must COPY: the caller adds AGENT_MEMORY_TOOL to the returned set,
890
+ * which would otherwise mutate the parent's live allowlist.
891
+ */
892
+ function intersectAllowlists(own, parent) {
893
+ if (own && parent)
894
+ return new Set([...own].filter((t) => parent.has(t)));
895
+ if (own)
896
+ return own;
897
+ return parent ? new Set(parent) : null;
898
+ }
899
+ function canSpawnSubAgents(depth, allowedTools, limit = DEFAULT_MAX_TASK_DEPTH) {
900
+ if (depth >= limit)
901
+ return false;
902
+ // An allowlist that omits `task` is the other reason a run cannot delegate. No
903
+ // allowlist at all (the main agent, or a general-purpose sub-task) means no
904
+ // restriction from this clause — the depth check above still applies.
905
+ return allowedTools ? allowedTools.has('task') : true;
906
+ }
632
907
  let _subTaskCounter = 0; // unique per-process id → per-sub-agent todo scope
633
908
  // A sub-agent that stalls (hung tool, model provider stuck, infinite tool-call
634
909
  // loop bypassing the iteration budget somehow) used to have NO ceiling of its
@@ -680,34 +955,11 @@ class ToolNotAllowedError extends Error {
680
955
  }
681
956
  }
682
957
  exports.ToolNotAllowedError = ToolNotAllowedError;
683
- /**
684
- * Slice `s` to at most `max` UTF-16 units without splitting a surrogate pair.
685
- *
686
- * A bare `slice()` can cut between the high and low half of a non-BMP character
687
- * (emoji, many CJK extension glyphs), producing a lone surrogate — invalid UTF-16
688
- * that the Anthropic API rejects outright with "no low surrogate in string". That
689
- * exact failure has already been shipped and fixed once in this codebase; every
690
- * new cap on model-facing text has to be surrogate-aware from the start.
691
- */
692
- function sliceSafeEnd(s, max) {
693
- if (s.length <= max)
694
- return s;
695
- let end = max;
696
- const code = s.charCodeAt(end - 1);
697
- if (code >= 0xd800 && code <= 0xdbff)
698
- end--; // trailing high surrogate — drop it
699
- return s.slice(0, end);
700
- }
701
- /** Mirror of sliceSafeEnd for a tail slice: never START on a low surrogate. */
702
- function sliceSafeStart(s, from) {
703
- if (from <= 0)
704
- return s;
705
- let start = from;
706
- const code = s.charCodeAt(start);
707
- if (code >= 0xdc00 && code <= 0xdfff)
708
- start++; // leading low surrogate — drop it
709
- return s.slice(start);
710
- }
958
+ // sliceSafeEnd/sliceSafeStart moved to ../util/safeSlice so every module that
959
+ // truncates model-facing/wire-facing text (loop.ts and tools/executor.ts) shares
960
+ // ONE surrogate-safe implementation instead of drifting copies. See that file's
961
+ // header for why raw `.slice()` on these strings caused a 400
962
+ // "no low surrogate in string" from the Anthropic API.
711
963
  /**
712
964
  * Reduce a sub-agent's message history to the text its parent should receive.
713
965
  *
@@ -739,8 +991,8 @@ function extractSubTaskText(messages, preferLast = true) {
739
991
  function capSubTaskText(text, max = SUBTASK_MAX) {
740
992
  if (text.length <= max)
741
993
  return text;
742
- const head = sliceSafeEnd(text, Math.floor(max * 0.6));
743
- const tail = sliceSafeStart(text, text.length - Math.floor(max * 0.4));
994
+ const head = (0, safeSlice_1.sliceSafeEnd)(text, Math.floor(max * 0.6));
995
+ const tail = (0, safeSlice_1.sliceSafeStart)(text, text.length - Math.floor(max * 0.4));
744
996
  return `${head}\n\n[… sub-task output truncated (${text.length} chars) — kept the beginning and end …]\n\n${tail}`;
745
997
  }
746
998
  /**
@@ -827,7 +1079,7 @@ function lastToolResults(messages, count, maxChars) {
827
1079
  continue;
828
1080
  const name = nameById.get(String(b.tool_use_id ?? '')) ?? 'tool';
829
1081
  const body = text.length > maxChars
830
- ? `${sliceSafeEnd(text, maxChars)}\n… [truncated]`
1082
+ ? `${(0, safeSlice_1.sliceSafeEnd)(text, maxChars)}\n… [truncated]`
831
1083
  : text;
832
1084
  out.push(`• ${name}:\n${body}`);
833
1085
  }
@@ -848,14 +1100,29 @@ function toolResultText(block) {
848
1100
  }
849
1101
  return '';
850
1102
  }
851
- async function runSubTask(input, options, agentTypes) {
1103
+ async function runSubTask(input, options, agentTypes,
1104
+ // Set to true at the moment an agent loop actually STARTS, so the caller can refund the
1105
+ // session slot it claimed for a spawn that turned out never to run.
1106
+ //
1107
+ // An out-param rather than a discriminated return type on purpose: every one of this
1108
+ // function's ~8 early returns is a non-start, and several are far from the top. Enumerating
1109
+ // them in the caller would be a list to forget to update; flipping one flag at the single
1110
+ // point of no return cannot go stale, and a path added later is a non-start by default.
1111
+ started) {
852
1112
  const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
853
1113
  if (!prompt)
854
1114
  return { error: 'task tool requires a non-empty prompt' };
1115
+ // The scope of the frame DOING the spawning — i.e. the owner of any resumable id this call
1116
+ // produces, and the identity checked when resuming one. 'root' is the main agent.
1117
+ const agentScope = options._agentScope ?? 'root';
855
1118
  const depth = options._depth ?? 0;
856
- if (depth >= MAX_TASK_DEPTH) {
857
- return { error: 'Sub-agents cannot spawn further sub-agents. Do this work directly, or report back so the main agent can delegate it.' };
1119
+ const depthLimit = resolveMaxSubagentDepth(options.workDir ? (0, rules_1.loadSettings)(options.workDir).raw : {});
1120
+ if (depth >= depthLimit) {
1121
+ return { error: noSpawnReason('depth', depthLimit) };
858
1122
  }
1123
+ // NOTE: the session budget is claimed at the DISPATCH SITE, not here — runSubTask runs
1124
+ // inside the concurrency limiter, so claiming here would make a doomed spawn wait behind
1125
+ // running siblings before being told no. See the `name === 'task'` branch in runAgentLoop.
859
1126
  // Resolve an optional custom agent type (subagent_type).
860
1127
  //
861
1128
  // `agentTypes` is a snapshot taken once at the top of runAgentLoop, before the
@@ -882,6 +1149,22 @@ async function runSubTask(input, options, agentTypes) {
882
1149
  // used to launder a denied agent under an allowed name.
883
1150
  const resumeId = typeof input.resume_agent_id === 'string' ? input.resume_agent_id.trim() : '';
884
1151
  const resumed = resumeId ? (0, agentRegistry_1.getAgent)(resumeId) : undefined;
1152
+ // OWNERSHIP, in addition to the deny-rule re-authorisation below.
1153
+ //
1154
+ // Re-deriving the agent NAME from storage stops an id laundering a denied agent, but it
1155
+ // says nothing about WHO may use the id. The registry is one flat process-global Map, so a
1156
+ // nested sub-agent could name an id it was never given and read another agent's entire
1157
+ // unredacted transcript, or overwrite it. Harmless while sub-agents were leaves (only the
1158
+ // main agent ever held an id); live once they can spawn.
1159
+ //
1160
+ // Reported as "expired" rather than "not yours": a distinct message would confirm the id
1161
+ // exists, turning the error into an oracle for enumerating other frames' agents.
1162
+ if (resumed && !(0, agentRegistry_1.canResume)(resumed, agentScope)) {
1163
+ return {
1164
+ error: `No resumable sub-agent with id "${resumeId}" is available to this run. Start a fresh ` +
1165
+ 'sub-task with a self-contained prompt instead.',
1166
+ };
1167
+ }
885
1168
  if (resumeId && !resumed) {
886
1169
  return {
887
1170
  error: `No resumable sub-agent with id "${resumeId}". Ids live only for the current session and the ` +
@@ -902,15 +1185,26 @@ async function runSubTask(input, options, agentTypes) {
902
1185
  //
903
1186
  // Phrased as a policy refusal, not "unknown type": the model must not respond
904
1187
  // by trying to create the agent file it thinks is missing.
905
- if (requestedType) {
906
- const decision = (0, rules_1.evaluatePermission)((0, rules_1.loadSettings)(options.workDir).permissions, 'task', { subagent_type: requestedType }, options.workDir);
907
- if (decision === 'deny') {
908
- return {
909
- error: `The sub-agent "${requestedType}" is disabled by a permission rule in this project ` +
910
- `(permissions.deny in settings.json). This is a deliberate policy choice, not a missing file ` +
911
- 'do not create it and do not retry. Do the work yourself, or use a different sub-agent.',
912
- };
913
- }
1188
+ // Evaluated UNCONDITIONALLY, with 'general-purpose' standing in for an unnamed dispatch.
1189
+ //
1190
+ // This used to be `if (requestedType)`, which meant an unnamed spawn skipped the rule
1191
+ // entirely: `deny: ["task(general-purpose)"]` matched the named form and returned null for
1192
+ // the unnamed one. Omitting the field was therefore a bypass for the single most
1193
+ // privileged variant an unnamed sub-task has no allowlist of its own, so before the
1194
+ // parent-intersection it received FULL access, exactly what such a rule is written to stop.
1195
+ //
1196
+ // The substitution is also the honest model rather than a patch: an unnamed sub-task IS
1197
+ // general-purpose behaviourally (that is what naming it accomplished in the first place),
1198
+ // so a rule about that agent should govern both spellings. A bare `deny: ["task"]` already
1199
+ // caught both and is unaffected.
1200
+ const denyKey = requestedType || 'general-purpose';
1201
+ const decision = (0, rules_1.evaluatePermission)((0, rules_1.loadSettings)(options.workDir).permissions, 'task', { subagent_type: denyKey }, options.workDir);
1202
+ if (decision === 'deny') {
1203
+ return {
1204
+ error: `The sub-agent "${denyKey}" is disabled by a permission rule in this project ` +
1205
+ `(permissions.deny in settings.json). This is a deliberate policy choice, not a missing file — ` +
1206
+ 'do not create it and do not retry. Do the work yourself, or use a different sub-agent.',
1207
+ };
914
1208
  }
915
1209
  let agent = (0, agentTypes_1.findAgentType)(agentTypes, requestedType);
916
1210
  let knownTypes = agentTypes;
@@ -959,7 +1253,24 @@ async function runSubTask(input, options, agentTypes) {
959
1253
  // "Permission denied by user", which was actively misleading: the user denied
960
1254
  // nothing, and a model told that will re-ask for approval instead of noticing
961
1255
  // that the agent's own allowlist (often a typo'd tool name) is what stopped it.
962
- const allowed = agent?.tools ? new Set(agent.tools) : null;
1256
+ // ── The child's allowlist is INTERSECTED with the parent's ──────────────────
1257
+ //
1258
+ // A child's own definition can only ever NARROW what its parent had, never widen it.
1259
+ // Without this, nesting is a privilege-escalation ladder: `reviewer` is read-only and
1260
+ // has no write_file, but `general-purpose` declares no `tools:` at all (= full access),
1261
+ // so a read-only agent could delegate to an unrestricted one and edit the repo through
1262
+ // it. The user's "this agent cannot write" would silently mean "cannot write directly".
1263
+ //
1264
+ // This was unreachable while sub-agents were leaves — nobody but the (unrestricted) main
1265
+ // agent could spawn. Turning nesting on is what makes it live, so the intersection ships
1266
+ // in the same change rather than as a follow-up.
1267
+ //
1268
+ // `null` still means "no allowlist", but only when BOTH sides say so: an unrestricted
1269
+ // parent spawning general-purpose stays unrestricted (today's behaviour at depth 1),
1270
+ // while a restricted parent yields a restricted child no matter what the child declares.
1271
+ // Sticky for the same reason the allowlist intersects: inherited OR own, never shed.
1272
+ const testFilesOnly = !!agent?.testFilesOnly || !!options._testFilesOnly;
1273
+ const allowed = intersectAllowlists(agent?.tools ? new Set(agent.tools) : null, options._allowedTools);
963
1274
  // The ONE capability `memory:` grants. Added to the allowlist rather than bypassing
964
1275
  // it, so the allowlist stays the single source of truth for what this agent can do.
965
1276
  if (allowed && memoryScope)
@@ -973,16 +1284,48 @@ async function runSubTask(input, options, agentTypes) {
973
1284
  '`memory:` scope (project, user or local). Report anything worth remembering in your final ' +
974
1285
  'message instead — the main agent decides what to persist.');
975
1286
  }
1287
+ // `task` is answered by the same predicate that decides whether the tool was sent in
1288
+ // the first place, so the gate cannot disagree with the prompt.
1289
+ //
1290
+ // This was briefly an UNCONDITIONAL refusal, which was correct only while the depth
1291
+ // ceiling was hard-coded to 1 (every gated run was a leaf by definition). With nesting
1292
+ // configurable that shortcut becomes a real bug: a depth-1 agent under
1293
+ // `maxSubagentDepth: 3` would be handed the tool by the backend and then refused here.
1294
+ // Fail-closed, so it would have looked like a mysterious dead end rather than a crash.
1295
+ //
1296
+ // Two distinct reasons, two distinct messages — a depth ceiling is raisable, an agent
1297
+ // definition withholding `task` is not, and a refusal that lies about which one applies
1298
+ // makes the model either give up early or hunt for an escape hatch.
1299
+ if (req.tool === 'task' && !canSpawnSubAgents(depth + 1, allowed ?? undefined, depthLimit)) {
1300
+ // `depth + 1`, not `depth`: this closure gates the CHILD's tool calls, and the child
1301
+ // runs one level below the `depth` in scope here (which belongs to its parent). Using
1302
+ // `depth` would evaluate the parent's right to spawn — permitting one level too many.
1303
+ throw new ToolNotAllowedError(allowed && !allowed.has('task')
1304
+ ? noSpawnReason('denied')
1305
+ : noSpawnReason('depth', depthLimit));
1306
+ }
976
1307
  if (allowed && !allowed.has(req.tool)) {
977
- throw new ToolNotAllowedError(`The "${agent.name}" sub-agent is not allowed to use \`${req.tool}\` — it is not in that agent's ` +
1308
+ throw new ToolNotAllowedError(
1309
+ // `agent?.name`, NOT `agent!.name`. The non-null assertion held only while `allowed`
1310
+ // was derived solely from `agent?.tools` (non-null allowlist ⇒ named agent). The
1311
+ // parent-intersection broke that invariant: a RESTRICTED parent dispatching `task`
1312
+ // with no subagent_type yields a non-null inherited allowlist with `agent`
1313
+ // undefined, and this line then threw a TypeError instead of ToolNotAllowedError —
1314
+ // which the dispatch site does not recognise, so it laundered the refusal into the
1315
+ // generic "Permission denied by user" this very message exists to avoid.
1316
+ `The "${agent?.name ?? 'general-purpose'}" sub-agent is not allowed to use \`${req.tool}\` — it is not in that agent's ` +
978
1317
  'tool allowlist. This is a restriction of the agent definition, NOT a user decision: do not ask ' +
979
1318
  'for approval, use one of the tools you do have, or report back that the task needs a different agent.');
980
1319
  }
981
- // Path-scoped write restriction (agent.testFilesOnly) — see
982
- // allowsTestOnlyWrite for the reasoning and its known limit.
983
- if (agent?.testFilesOnly && !allowsTestOnlyWrite(req.tool, req.input)) {
984
- throw new ToolNotAllowedError(`The "${agent.name}" sub-agent may only write to TEST files, so \`${req.tool}\` was refused for this ` +
985
- 'path. Do not try to work around it: if production code must change, say so in your report instead.');
1320
+ // Path-scoped write restriction — see allowsTestOnlyWrite for the reasoning and its
1321
+ // known limit. Applies when THIS agent declares it OR any ancestor did: like the tool
1322
+ // allowlist above, a restriction can only ever be narrowed by nesting, never shed.
1323
+ // Without the inherited half, `test-writer` could delegate to an unrestricted agent and
1324
+ // have production source written on its behalf.
1325
+ if (testFilesOnly && !allowsTestOnlyWrite(req.tool, req.input)) {
1326
+ throw new ToolNotAllowedError(`The "${agent?.name ?? 'general-purpose'}" sub-agent may only write to TEST files, so ` +
1327
+ `\`${req.tool}\` was refused for this path. Do not try to work around it: if production ` +
1328
+ 'code must change, say so in your report instead.');
986
1329
  }
987
1330
  return options.requestPermission(req);
988
1331
  };
@@ -1039,6 +1382,9 @@ async function runSubTask(input, options, agentTypes) {
1039
1382
  subAbort.aborted = true;
1040
1383
  }, 250);
1041
1384
  try {
1385
+ // The point of no return: past here a real agent loop exists and the budget slot is spent.
1386
+ if (started)
1387
+ started.value = true;
1042
1388
  const result = await runAgentLoop(subMessages, {
1043
1389
  ...options,
1044
1390
  _depth: depth + 1,
@@ -1055,15 +1401,21 @@ async function runSubTask(input, options, agentTypes) {
1055
1401
  // `tools:` line inherited the parent's allowlist, making the prompt's capability
1056
1402
  // claim disagree with its real one.
1057
1403
  //
1058
- // MAX_TASK_DEPTH === 1 means no nested spawn can reach this today, so it is
1059
- // latent rather than live but the limiter comment below explicitly contemplates
1060
- // raising that depth, and this is exactly the kind of leak that would come back
1061
- // as a security bug rather than a visible error. Explicit undefined makes the
1062
- // child's identity independent of the parent's by construction.
1404
+ // This is now LIVE, not latent: nesting is enabled by default, so a grandchild really
1405
+ // can be spawned by an agent that has a memory binding. The explicit `undefined` is
1406
+ // what stops it inheriting that binding and appending to its grandparent's private
1407
+ // notes a silent cross-agent write rather than a visible error. Note the allowlist
1408
+ // takes the opposite direction on purpose (inherited, because it RESTRICTS); identity
1409
+ // must not be inherited, capability must.
1063
1410
  _agentMemory: agent && memoryScope ? { agentName: agent.name, scope: memoryScope } : undefined,
1064
1411
  // The same set `gatedPermission` enforces above, so prompt and permission agree
1065
1412
  // by construction instead of by two people remembering to update both.
1066
1413
  _allowedTools: allowed ?? undefined,
1414
+ // Propagated so a grandchild inherits it too — see _testFilesOnly. Assigned
1415
+ // unconditionally (not by conditional spread) for the same reason as _agentMemory
1416
+ // above: a conditional spread leaves the parent's value in place instead of clearing
1417
+ // it, and here that direction is at least safe, whereas forgetting to propagate is not.
1418
+ _testFilesOnly: testFilesOnly,
1067
1419
  editorContext: null, // fresh isolated context for sub-agent
1068
1420
  model: agent?.model ?? options.model,
1069
1421
  // Plan mode is inherited, never relaxed. If the main agent could spawn a
@@ -1134,7 +1486,7 @@ async function runSubTask(input, options, agentTypes) {
1134
1486
  // do: run the whole task again. Resuming is now POSSIBLE but never implied to be
1135
1487
  // safe: the text below states plainly that the work is unverified, and resumption
1136
1488
  // re-authorises against current permissions exactly as it does for a clean run.
1137
- const partialId = (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result);
1489
+ const partialId = (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result, agentScope);
1138
1490
  const sections = [
1139
1491
  `Sub-task STOPPED after ${mins} minutes with NO PROGRESS (it was not making tool calls or ` +
1140
1492
  'producing output) — treat everything below as PARTIAL, unverified work, not a finished answer.',
@@ -1161,8 +1513,8 @@ async function runSubTask(input, options, agentTypes) {
1161
1513
  // it cannot determine. Those paths already salvage their partial output as
1162
1514
  // TEXT, which is the safe way to carry that information forward.
1163
1515
  const agentId = resumed
1164
- ? ((0, agentRegistry_1.updateAgent)(resumed.id, result), resumed.id)
1165
- : (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result);
1516
+ ? ((0, agentRegistry_1.updateAgent)(resumed.id, result, agentScope), resumed.id)
1517
+ : (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result, agentScope);
1166
1518
  const body = text || '(sub-task completed with no text output)';
1167
1519
  return {
1168
1520
  output: `${body}\n\n[resumable: this sub-agent is "${agentId}". To ask IT a follow-up — keeping ` +
@@ -1420,13 +1772,13 @@ function transcriptOf(messages) {
1420
1772
  for (const m of messages) {
1421
1773
  for (const b of m.content) {
1422
1774
  if (b.type === 'text' && b.text) {
1423
- parts.push(`${m.role.toUpperCase()}: ${b.text.slice(0, 2000)}`);
1775
+ parts.push(`${m.role.toUpperCase()}: ${(0, safeSlice_1.sliceSafeEnd)(b.text, 2000)}`);
1424
1776
  }
1425
1777
  else if (b.type === 'tool_use') {
1426
- parts.push(`${m.role.toUpperCase()} [tool: ${b.name}]: ${JSON.stringify(b.input ?? {}).slice(0, 400)}`);
1778
+ parts.push(`${m.role.toUpperCase()} [tool: ${b.name}]: ${(0, safeSlice_1.sliceSafeEnd)(JSON.stringify(b.input ?? {}), 400)}`);
1427
1779
  }
1428
1780
  else if (b.type === 'tool_result') {
1429
- parts.push(`TOOL RESULT: ${String(b.content ?? '').slice(0, 600)}`);
1781
+ parts.push(`TOOL RESULT: ${(0, safeSlice_1.sliceSafeEnd)(String(b.content ?? ''), 600)}`);
1430
1782
  }
1431
1783
  }
1432
1784
  }
@@ -1437,8 +1789,8 @@ function transcriptOf(messages) {
1437
1789
  // continuation). Slice on line boundaries so we don't cut a line in half.
1438
1790
  const headBudget = Math.floor(MAX_TRANSCRIPT_CHARS * 0.4);
1439
1791
  const tailBudget = MAX_TRANSCRIPT_CHARS - headBudget;
1440
- const head = full.slice(0, headBudget);
1441
- const tail = full.slice(full.length - tailBudget);
1792
+ const head = (0, safeSlice_1.sliceSafeEnd)(full, headBudget);
1793
+ const tail = (0, safeSlice_1.sliceSafeStart)(full, full.length - tailBudget);
1442
1794
  const dropped = full.length - head.length - tail.length;
1443
1795
  return `${head}\n\n[… ${dropped} chars of mid-session transcript elided to fit the summariser's context window …]\n\n${tail}`;
1444
1796
  }
@@ -1892,8 +2244,8 @@ async function runAgentLoop(initialMessages, options) {
1892
2244
  // switch a sub-agent off, and an agent that may not run must not be
1893
2245
  // advertised (see below).
1894
2246
  const settings = (0, rules_1.loadSettings)(options.workDir);
1895
- // Discover custom sub-agent types. Only the top-level agent is told the
1896
- // catalogue (sub-agents can't spawn further), but every level resolves types.
2247
+ // Discover custom sub-agent types. Every level resolves types; only a level that may
2248
+ // actually delegate is TOLD the catalogue (see maySpawn below).
1897
2249
  //
1898
2250
  // Denied agents are filtered OUT of the catalogue rather than left in it to be
1899
2251
  // refused on dispatch. Listing an agent you have forbidden trains the model to
@@ -1901,7 +2253,15 @@ async function runAgentLoop(initialMessages, options) {
1901
2253
  // error path would name it again. Enforcement still happens at dispatch
1902
2254
  // (runSubTask) — this is the cosmetic half; that is the load-bearing half.
1903
2255
  const agentTypes = (0, agentTypes_1.loadAgentTypes)(options.workDir).filter((t) => (0, rules_1.evaluatePermission)(settings.permissions, 'task', { subagent_type: t.name }, options.workDir) !== 'deny');
1904
- const agentsCatalogue = depth === 0 ? (0, agentTypes_1.summariseAgents)(agentTypes) : '';
2256
+ // Can THIS run delegate at all? Previously only the catalogue was gated (`depth === 0`)
2257
+ // while the tool schema and its instructions went to every run — see canSpawnSubAgents.
2258
+ //
2259
+ // The depth limit is resolved from the SAME settings object the rest of the run uses, so
2260
+ // a project that sets maxSubagentDepth gets a prompt matching its own configuration
2261
+ // rather than the built-in default.
2262
+ const depthLimit = resolveMaxSubagentDepth(settings.raw);
2263
+ const maySpawn = canSpawnSubAgents(depth, options._allowedTools, depthLimit);
2264
+ const agentsCatalogue = maySpawn ? (0, agentTypes_1.summariseAgents)(agentTypes) : '';
1905
2265
  // Skills catalogue — unlike agentsCatalogue, available at every depth: a skill is
1906
2266
  // just a reusable prompt template (via use_skill), not another spawn point, so
1907
2267
  // sub-agents benefit from the same playbooks without the recursion concerns that
@@ -2224,6 +2584,9 @@ async function runAgentLoop(initialMessages, options) {
2224
2584
  ? options._allowedTools.has('memory_write')
2225
2585
  : true, // no allowlist = main agent = may write
2226
2586
  hasOwnAgentStore: !!options._agentMemory,
2587
+ // Withholds the `task` schema and its instructions when this run cannot
2588
+ // delegate — see canSpawnSubAgents.
2589
+ canSpawnSubAgents: maySpawn,
2227
2590
  agents: agentsCatalogue || undefined,
2228
2591
  skills: skillsCatalogue || undefined,
2229
2592
  // Only allow a post-render restart when the caller actually implements the
@@ -2515,37 +2878,57 @@ async function runAgentLoop(initialMessages, options) {
2515
2878
  // sub-task queued behind three long-running siblings could then be
2516
2879
  // "stopped for stalling" having never executed a single step.
2517
2880
  //
2518
- // Only TOP-LEVEL fan-out is gated (`depth === 0`) a deliberate
2519
- // deadlock guard, kept even though MAX_TASK_DEPTH currently makes a
2520
- // nested spawn impossible anyway. A nested spawn would request a slot
2521
- // while its parent still holds one; if every slot were held by a parent
2522
- // waiting on a child that can never be scheduled, the run would wedge
2523
- // permanently. Keeping the limiter acyclic (a holder never re-enters it)
2524
- // means raising MAX_TASK_DEPTH later can't silently reintroduce that.
2525
- if (depth === 0) {
2526
- const { run: limitRun, max: limitMax } = subTaskLimiter(options.workDir);
2881
+ // EVERY level is gated, each against its own depth's limiter.
2882
+ //
2883
+ // This used to be `depth === 0` only, because one shared limiter deadlocks if a
2884
+ // slot-holder re-enters it and with sub-agents as leaves, skipping the nested
2885
+ // case was free. Now that nesting is real, skipping it would leave fan-out below
2886
+ // level 1 completely unbounded. Per-depth limiters give bounded concurrency at
2887
+ // every level while keeping the wait-for graph a DAG (a depth-D holder only ever
2888
+ // waits on depth-D+1), so there is no cycle to deadlock on. See _subTaskLimiters.
2889
+ // Session budget FIRST, outside the limiter. runSubTask runs inside limitRun, so
2890
+ // claiming in there meant a spawn that was already over budget queued behind up to
2891
+ // `max` running siblings before hearing no — in a runaway, i.e. the exact case this
2892
+ // guard exists for, refusals trickled out at sibling-completion rate.
2893
+ const overBudget = claimSessionSubAgentSlot(options.workDir, options.onNotice ?? options.onText);
2894
+ if (overBudget) {
2895
+ result = { error: overBudget };
2896
+ }
2897
+ else {
2898
+ const childDepth = depth + 1;
2899
+ const { run: limitRun, max: limitMax } = subTaskLimiter(childDepth, options.workDir);
2527
2900
  // Tell the user when a sub-task is WAITING rather than working.
2528
2901
  //
2529
2902
  // With a burst of 8 and a ceiling of 4, the last four sat silently in the
2530
2903
  // queue. From the outside they looked started, so a long wait read as a
2531
2904
  // hang — and the fix for a hang (Ctrl+C and retry) is exactly wrong here,
2532
- // since the work was about to run. `_inFlightSubTasks` is only ever
2533
- // touched from this single dispatch point, so the count is exact.
2534
- if (_inFlightSubTasks >= limitMax) {
2535
- (options.onNotice ?? options.onText)(`\u23f3 Queued: ${limitMax} sub-agents are already running, so this one starts when a ` +
2536
- `slot frees up (raise "maxConcurrentSubtasks" in .nexrall/settings.json to widen it).`);
2905
+ // since the work was about to run. Counted per depth, so a level-2 queue is
2906
+ // not reported using a level-1 count.
2907
+ const inFlight = _inFlightByDepth.get(childDepth) ?? 0;
2908
+ if (inFlight >= limitMax) {
2909
+ (options.onNotice ?? options.onText)(`\u23f3 Queued: ${limitMax} sub-agent(s) already running at this level, so this one ` +
2910
+ `starts when a slot frees up (raise "maxConcurrentSubtasks" in .nexrall/settings.json ` +
2911
+ 'to widen it).');
2537
2912
  }
2538
- _inFlightSubTasks++;
2913
+ _inFlightByDepth.set(childDepth, inFlight + 1);
2914
+ // runSubTask flips this at its point of no return, so a spawn rejected by its own
2915
+ // validation (empty prompt, a deny rule, an unusable resume id) gives the slot
2916
+ // back. Without it, a model retrying against a deny rule would burn the entire
2917
+ // allowance on spawns that never ran and then lose delegation for the session.
2918
+ const started = { value: false };
2539
2919
  try {
2540
- result = await limitRun(() => runSubTask(input, options, agentTypes));
2920
+ result = await limitRun(() => runSubTask(input, options, agentTypes, started));
2541
2921
  }
2542
2922
  finally {
2543
- _inFlightSubTasks--;
2923
+ if (!started.value)
2924
+ refundSessionSubAgentSlot();
2925
+ const n = (_inFlightByDepth.get(childDepth) ?? 1) - 1;
2926
+ if (n > 0)
2927
+ _inFlightByDepth.set(childDepth, n);
2928
+ else
2929
+ _inFlightByDepth.delete(childDepth); // don't retain a key per depth forever
2544
2930
  }
2545
2931
  }
2546
- else {
2547
- result = await runSubTask(input, options, agentTypes);
2548
- }
2549
2932
  }
2550
2933
  else {
2551
2934
  const pre = runToolHooks(hooks.PreToolUse, 'PreToolUse', name, input, options.workDir);