@kolisachint/hoocode-agent 0.4.61 → 0.4.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/cli/args.d.ts +5 -0
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +22 -0
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/core/agent-frontmatter.d.ts +22 -0
  7. package/dist/core/agent-frontmatter.d.ts.map +1 -1
  8. package/dist/core/agent-frontmatter.js +60 -0
  9. package/dist/core/agent-frontmatter.js.map +1 -1
  10. package/dist/core/agent-session.d.ts +3 -0
  11. package/dist/core/agent-session.d.ts.map +1 -1
  12. package/dist/core/agent-session.js +7 -1
  13. package/dist/core/agent-session.js.map +1 -1
  14. package/dist/core/dispatch-evaluator.d.ts +3 -2
  15. package/dist/core/dispatch-evaluator.d.ts.map +1 -1
  16. package/dist/core/dispatch-evaluator.js +9 -5
  17. package/dist/core/dispatch-evaluator.js.map +1 -1
  18. package/dist/core/sdk.d.ts +5 -0
  19. package/dist/core/sdk.d.ts.map +1 -1
  20. package/dist/core/sdk.js +1 -0
  21. package/dist/core/sdk.js.map +1 -1
  22. package/dist/core/settings-defaults.d.ts +2 -0
  23. package/dist/core/settings-defaults.d.ts.map +1 -1
  24. package/dist/core/settings-defaults.js +2 -0
  25. package/dist/core/settings-defaults.js.map +1 -1
  26. package/dist/core/settings-manager.d.ts +6 -0
  27. package/dist/core/settings-manager.d.ts.map +1 -1
  28. package/dist/core/settings-manager.js +10 -0
  29. package/dist/core/settings-manager.js.map +1 -1
  30. package/dist/core/subagent-depth.d.ts +67 -0
  31. package/dist/core/subagent-depth.d.ts.map +1 -0
  32. package/dist/core/subagent-depth.js +112 -0
  33. package/dist/core/subagent-depth.js.map +1 -0
  34. package/dist/core/subagent-pool-instance.d.ts.map +1 -1
  35. package/dist/core/subagent-pool-instance.js +11 -1
  36. package/dist/core/subagent-pool-instance.js.map +1 -1
  37. package/dist/core/subagent-pool.d.ts.map +1 -1
  38. package/dist/core/subagent-pool.js +47 -17
  39. package/dist/core/subagent-pool.js.map +1 -1
  40. package/dist/core/subagent-result.d.ts +31 -0
  41. package/dist/core/subagent-result.d.ts.map +1 -1
  42. package/dist/core/subagent-result.js +31 -0
  43. package/dist/core/subagent-result.js.map +1 -1
  44. package/dist/core/task-store.d.ts +11 -1
  45. package/dist/core/task-store.d.ts.map +1 -1
  46. package/dist/core/task-store.js +3 -0
  47. package/dist/core/task-store.js.map +1 -1
  48. package/dist/core/token-budget.d.ts.map +1 -1
  49. package/dist/core/token-budget.js +6 -9
  50. package/dist/core/token-budget.js.map +1 -1
  51. package/dist/core/tools/subagent.d.ts +8 -0
  52. package/dist/core/tools/subagent.d.ts.map +1 -1
  53. package/dist/core/tools/subagent.js +55 -2
  54. package/dist/core/tools/subagent.js.map +1 -1
  55. package/dist/init-templates.generated.d.ts.map +1 -1
  56. package/dist/init-templates.generated.js +3 -6
  57. package/dist/init-templates.generated.js.map +1 -1
  58. package/dist/main.d.ts.map +1 -1
  59. package/dist/main.js +31 -3
  60. package/dist/main.js.map +1 -1
  61. package/dist/modes/interactive/components/task-panel.d.ts +12 -7
  62. package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
  63. package/dist/modes/interactive/components/task-panel.js +90 -57
  64. package/dist/modes/interactive/components/task-panel.js.map +1 -1
  65. package/dist/modes/print-mode.d.ts.map +1 -1
  66. package/dist/modes/print-mode.js +8 -1
  67. package/dist/modes/print-mode.js.map +1 -1
  68. package/docs/routing.md +15 -10
  69. package/examples/agents/fork-reviewer.md +29 -0
  70. package/examples/agents/orchestrator.md +35 -0
  71. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  72. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  73. package/examples/extensions/sandbox/package.json +1 -1
  74. package/examples/extensions/with-deps/package.json +1 -1
  75. package/package.json +4 -4
  76. package/templates/agents/explore.md +4 -5
  77. package/templates/agents/general-purpose.md +8 -7
  78. package/templates/agents/plan.md +39 -0
  79. package/templates/agents/doc.md +0 -37
  80. package/templates/agents/edit.md +0 -39
  81. package/templates/agents/review.md +0 -38
  82. package/templates/agents/test.md +0 -37
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Subagent nesting depth + tree-wide safety bounds.
3
+ *
4
+ * Nesting is governed entirely by environment variables so every process in a
5
+ * delegation tree agrees without any runtime cross-process coordination (no
6
+ * shared lock files, no slots to leak on crash):
7
+ *
8
+ * - HOOCODE_SUBAGENT_DEPTH current process's depth (root unset/0, its
9
+ * children 1, grandchildren 2, ...).
10
+ * - HOOCODE_SUBAGENT_MAX_DEPTH the tree-wide cap, seeded once by the root from
11
+ * its `maxSubagentDepth` setting and inherited by
12
+ * every descendant.
13
+ *
14
+ * The default cap is 1 — a subagent may not spawn further subagents — which
15
+ * reproduces the original hard guard exactly. Raising it is an opt-in feature.
16
+ *
17
+ * Fan-out is bounded deterministically rather than by a global counter: pools at
18
+ * depth >= 1 run with NESTED_SUBAGENT_CONCURRENCY instead of the root's default,
19
+ * so the worst-case live process count is a fixed function of depth and the per
20
+ * level caps (e.g. 5 + 5*2 = 15 at depth 2), with nothing to reclaim on crash.
21
+ */
22
+ export declare const SUBAGENT_DEPTH_ENV = "HOOCODE_SUBAGENT_DEPTH";
23
+ export declare const SUBAGENT_MAX_DEPTH_ENV = "HOOCODE_SUBAGENT_MAX_DEPTH";
24
+ export declare const NESTED_CONCURRENCY_ENV = "HOOCODE_NESTED_SUBAGENT_CONCURRENCY";
25
+ /** Comma-separated allowlist of subagent types the current process may delegate to. */
26
+ export declare const DELEGATE_ALLOW_ENV = "HOOCODE_DELEGATE_ALLOW";
27
+ /** Default tree-wide cap: subagents cannot spawn subagents (original behavior). */
28
+ export declare const DEFAULT_MAX_SUBAGENT_DEPTH = 1;
29
+ /** Concurrency cap for pools running at depth >= 1, keeping nested fan-out bounded. */
30
+ export declare const NESTED_SUBAGENT_CONCURRENCY = 2;
31
+ /**
32
+ * Hard ceiling on the configurable nesting depth. The worst-case live process
33
+ * count grows geometrically with depth (each level's pool can run
34
+ * NESTED_SUBAGENT_CONCURRENCY children), so an unbounded cap would let a
35
+ * mis-configured setting exhaust the host. At this ceiling the worst case stays
36
+ * modest: 5 * (2^3 - 1) = 35 processes.
37
+ */
38
+ export declare const ABSOLUTE_MAX_SUBAGENT_DEPTH = 3;
39
+ /** Clamp a requested cap into the supported range [1, ABSOLUTE_MAX_SUBAGENT_DEPTH]. */
40
+ export declare function clampMaxSubagentDepth(n: number): number;
41
+ /** Depth of the current process (0 = root/main session). */
42
+ export declare function currentSubagentDepth(env?: NodeJS.ProcessEnv): number;
43
+ /**
44
+ * Tree-wide max depth. Reads the inherited env value when present (any spawned
45
+ * process), otherwise falls back to the provided setting (the root seeds env
46
+ * from this). Clamped to >= 1 so the cap can never disable delegation entirely.
47
+ */
48
+ export declare function resolveMaxSubagentDepth(settingValue?: number, env?: NodeJS.ProcessEnv): number;
49
+ /** True when a process at the current depth may still spawn subagents. */
50
+ export declare function canSpawnSubagent(settingValue?: number, env?: NodeJS.ProcessEnv): boolean;
51
+ /**
52
+ * Concurrency cap for pools running at depth >= 1. Reads the inherited env value
53
+ * (seeded by the root from the `nestedSubagentConcurrency` setting) when present,
54
+ * else the provided setting, else the default. Clamped to >= 1.
55
+ */
56
+ export declare function resolveNestedConcurrency(settingValue?: number, env?: NodeJS.ProcessEnv): number;
57
+ /** Concurrency for a pool created in the current process: reduced when nested. */
58
+ export declare function poolConcurrencyForDepth(env?: NodeJS.ProcessEnv): number | undefined;
59
+ /**
60
+ * Subagent types the current process is restricted to delegating to, or undefined
61
+ * when unrestricted (may delegate to any type). Set per spawned agent from its
62
+ * `delegate: <types>` frontmatter; the root is always unrestricted.
63
+ */
64
+ export declare function delegateAllowList(env?: NodeJS.ProcessEnv): string[] | undefined;
65
+ /** Whether the current process may delegate to the given subagent type. */
66
+ export declare function isDelegateAllowed(subagentType: string, env?: NodeJS.ProcessEnv): boolean;
67
+ //# sourceMappingURL=subagent-depth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subagent-depth.d.ts","sourceRoot":"","sources":["../../src/core/subagent-depth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,eAAO,MAAM,kBAAkB,2BAA2B,CAAC;AAC3D,eAAO,MAAM,sBAAsB,+BAA+B,CAAC;AACnE,eAAO,MAAM,sBAAsB,wCAAwC,CAAC;AAC5E,uFAAuF;AACvF,eAAO,MAAM,kBAAkB,2BAA2B,CAAC;AAE3D,mFAAmF;AACnF,eAAO,MAAM,0BAA0B,IAAI,CAAC;AAE5C,uFAAuF;AACvF,eAAO,MAAM,2BAA2B,IAAI,CAAC;AAE7C;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,IAAI,CAAC;AAE7C,uFAAuF;AACvF,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAGvD;AAED,4DAA4D;AAC5D,wBAAgB,oBAAoB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAGjF;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAU3G;AAED,0EAA0E;AAC1E,wBAAgB,gBAAgB,CAAC,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAErG;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAU5G;AAED,kFAAkF;AAClF,wBAAgB,uBAAuB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,GAAG,SAAS,CAEhG;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,EAAE,GAAG,SAAS,CAQ5F;AAED,2EAA2E;AAC3E,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAGrG","sourcesContent":["/**\n * Subagent nesting depth + tree-wide safety bounds.\n *\n * Nesting is governed entirely by environment variables so every process in a\n * delegation tree agrees without any runtime cross-process coordination (no\n * shared lock files, no slots to leak on crash):\n *\n * - HOOCODE_SUBAGENT_DEPTH current process's depth (root unset/0, its\n * children 1, grandchildren 2, ...).\n * - HOOCODE_SUBAGENT_MAX_DEPTH the tree-wide cap, seeded once by the root from\n * its `maxSubagentDepth` setting and inherited by\n * every descendant.\n *\n * The default cap is 1 — a subagent may not spawn further subagents — which\n * reproduces the original hard guard exactly. Raising it is an opt-in feature.\n *\n * Fan-out is bounded deterministically rather than by a global counter: pools at\n * depth >= 1 run with NESTED_SUBAGENT_CONCURRENCY instead of the root's default,\n * so the worst-case live process count is a fixed function of depth and the per\n * level caps (e.g. 5 + 5*2 = 15 at depth 2), with nothing to reclaim on crash.\n */\n\nexport const SUBAGENT_DEPTH_ENV = \"HOOCODE_SUBAGENT_DEPTH\";\nexport const SUBAGENT_MAX_DEPTH_ENV = \"HOOCODE_SUBAGENT_MAX_DEPTH\";\nexport const NESTED_CONCURRENCY_ENV = \"HOOCODE_NESTED_SUBAGENT_CONCURRENCY\";\n/** Comma-separated allowlist of subagent types the current process may delegate to. */\nexport const DELEGATE_ALLOW_ENV = \"HOOCODE_DELEGATE_ALLOW\";\n\n/** Default tree-wide cap: subagents cannot spawn subagents (original behavior). */\nexport const DEFAULT_MAX_SUBAGENT_DEPTH = 1;\n\n/** Concurrency cap for pools running at depth >= 1, keeping nested fan-out bounded. */\nexport const NESTED_SUBAGENT_CONCURRENCY = 2;\n\n/**\n * Hard ceiling on the configurable nesting depth. The worst-case live process\n * count grows geometrically with depth (each level's pool can run\n * NESTED_SUBAGENT_CONCURRENCY children), so an unbounded cap would let a\n * mis-configured setting exhaust the host. At this ceiling the worst case stays\n * modest: 5 * (2^3 - 1) = 35 processes.\n */\nexport const ABSOLUTE_MAX_SUBAGENT_DEPTH = 3;\n\n/** Clamp a requested cap into the supported range [1, ABSOLUTE_MAX_SUBAGENT_DEPTH]. */\nexport function clampMaxSubagentDepth(n: number): number {\n\tif (!Number.isFinite(n)) return DEFAULT_MAX_SUBAGENT_DEPTH;\n\treturn Math.min(Math.max(1, Math.floor(n)), ABSOLUTE_MAX_SUBAGENT_DEPTH);\n}\n\n/** Depth of the current process (0 = root/main session). */\nexport function currentSubagentDepth(env: NodeJS.ProcessEnv = process.env): number {\n\tconst n = Number.parseInt(env[SUBAGENT_DEPTH_ENV] ?? \"0\", 10);\n\treturn Number.isFinite(n) && n > 0 ? n : 0;\n}\n\n/**\n * Tree-wide max depth. Reads the inherited env value when present (any spawned\n * process), otherwise falls back to the provided setting (the root seeds env\n * from this). Clamped to >= 1 so the cap can never disable delegation entirely.\n */\nexport function resolveMaxSubagentDepth(settingValue?: number, env: NodeJS.ProcessEnv = process.env): number {\n\tconst raw = env[SUBAGENT_MAX_DEPTH_ENV];\n\tif (raw !== undefined) {\n\t\tconst n = Number.parseInt(raw, 10);\n\t\tif (Number.isFinite(n) && n >= 1) return clampMaxSubagentDepth(n);\n\t}\n\tif (settingValue !== undefined && Number.isFinite(settingValue) && settingValue >= 1) {\n\t\treturn clampMaxSubagentDepth(settingValue);\n\t}\n\treturn DEFAULT_MAX_SUBAGENT_DEPTH;\n}\n\n/** True when a process at the current depth may still spawn subagents. */\nexport function canSpawnSubagent(settingValue?: number, env: NodeJS.ProcessEnv = process.env): boolean {\n\treturn currentSubagentDepth(env) < resolveMaxSubagentDepth(settingValue, env);\n}\n\n/**\n * Concurrency cap for pools running at depth >= 1. Reads the inherited env value\n * (seeded by the root from the `nestedSubagentConcurrency` setting) when present,\n * else the provided setting, else the default. Clamped to >= 1.\n */\nexport function resolveNestedConcurrency(settingValue?: number, env: NodeJS.ProcessEnv = process.env): number {\n\tconst raw = env[NESTED_CONCURRENCY_ENV];\n\tif (raw !== undefined) {\n\t\tconst n = Number.parseInt(raw, 10);\n\t\tif (Number.isFinite(n) && n >= 1) return n;\n\t}\n\tif (settingValue !== undefined && Number.isFinite(settingValue) && settingValue >= 1) {\n\t\treturn Math.floor(settingValue);\n\t}\n\treturn NESTED_SUBAGENT_CONCURRENCY;\n}\n\n/** Concurrency for a pool created in the current process: reduced when nested. */\nexport function poolConcurrencyForDepth(env: NodeJS.ProcessEnv = process.env): number | undefined {\n\treturn currentSubagentDepth(env) >= 1 ? resolveNestedConcurrency(undefined, env) : undefined;\n}\n\n/**\n * Subagent types the current process is restricted to delegating to, or undefined\n * when unrestricted (may delegate to any type). Set per spawned agent from its\n * `delegate: <types>` frontmatter; the root is always unrestricted.\n */\nexport function delegateAllowList(env: NodeJS.ProcessEnv = process.env): string[] | undefined {\n\tconst raw = env[DELEGATE_ALLOW_ENV];\n\tif (raw === undefined) return undefined;\n\tconst list = raw\n\t\t.split(\",\")\n\t\t.map((s) => s.trim())\n\t\t.filter((s) => s.length > 0);\n\treturn list.length > 0 ? list : undefined;\n}\n\n/** Whether the current process may delegate to the given subagent type. */\nexport function isDelegateAllowed(subagentType: string, env: NodeJS.ProcessEnv = process.env): boolean {\n\tconst allow = delegateAllowList(env);\n\treturn !allow || allow.includes(subagentType);\n}\n"]}
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Subagent nesting depth + tree-wide safety bounds.
3
+ *
4
+ * Nesting is governed entirely by environment variables so every process in a
5
+ * delegation tree agrees without any runtime cross-process coordination (no
6
+ * shared lock files, no slots to leak on crash):
7
+ *
8
+ * - HOOCODE_SUBAGENT_DEPTH current process's depth (root unset/0, its
9
+ * children 1, grandchildren 2, ...).
10
+ * - HOOCODE_SUBAGENT_MAX_DEPTH the tree-wide cap, seeded once by the root from
11
+ * its `maxSubagentDepth` setting and inherited by
12
+ * every descendant.
13
+ *
14
+ * The default cap is 1 — a subagent may not spawn further subagents — which
15
+ * reproduces the original hard guard exactly. Raising it is an opt-in feature.
16
+ *
17
+ * Fan-out is bounded deterministically rather than by a global counter: pools at
18
+ * depth >= 1 run with NESTED_SUBAGENT_CONCURRENCY instead of the root's default,
19
+ * so the worst-case live process count is a fixed function of depth and the per
20
+ * level caps (e.g. 5 + 5*2 = 15 at depth 2), with nothing to reclaim on crash.
21
+ */
22
+ export const SUBAGENT_DEPTH_ENV = "HOOCODE_SUBAGENT_DEPTH";
23
+ export const SUBAGENT_MAX_DEPTH_ENV = "HOOCODE_SUBAGENT_MAX_DEPTH";
24
+ export const NESTED_CONCURRENCY_ENV = "HOOCODE_NESTED_SUBAGENT_CONCURRENCY";
25
+ /** Comma-separated allowlist of subagent types the current process may delegate to. */
26
+ export const DELEGATE_ALLOW_ENV = "HOOCODE_DELEGATE_ALLOW";
27
+ /** Default tree-wide cap: subagents cannot spawn subagents (original behavior). */
28
+ export const DEFAULT_MAX_SUBAGENT_DEPTH = 1;
29
+ /** Concurrency cap for pools running at depth >= 1, keeping nested fan-out bounded. */
30
+ export const NESTED_SUBAGENT_CONCURRENCY = 2;
31
+ /**
32
+ * Hard ceiling on the configurable nesting depth. The worst-case live process
33
+ * count grows geometrically with depth (each level's pool can run
34
+ * NESTED_SUBAGENT_CONCURRENCY children), so an unbounded cap would let a
35
+ * mis-configured setting exhaust the host. At this ceiling the worst case stays
36
+ * modest: 5 * (2^3 - 1) = 35 processes.
37
+ */
38
+ export const ABSOLUTE_MAX_SUBAGENT_DEPTH = 3;
39
+ /** Clamp a requested cap into the supported range [1, ABSOLUTE_MAX_SUBAGENT_DEPTH]. */
40
+ export function clampMaxSubagentDepth(n) {
41
+ if (!Number.isFinite(n))
42
+ return DEFAULT_MAX_SUBAGENT_DEPTH;
43
+ return Math.min(Math.max(1, Math.floor(n)), ABSOLUTE_MAX_SUBAGENT_DEPTH);
44
+ }
45
+ /** Depth of the current process (0 = root/main session). */
46
+ export function currentSubagentDepth(env = process.env) {
47
+ const n = Number.parseInt(env[SUBAGENT_DEPTH_ENV] ?? "0", 10);
48
+ return Number.isFinite(n) && n > 0 ? n : 0;
49
+ }
50
+ /**
51
+ * Tree-wide max depth. Reads the inherited env value when present (any spawned
52
+ * process), otherwise falls back to the provided setting (the root seeds env
53
+ * from this). Clamped to >= 1 so the cap can never disable delegation entirely.
54
+ */
55
+ export function resolveMaxSubagentDepth(settingValue, env = process.env) {
56
+ const raw = env[SUBAGENT_MAX_DEPTH_ENV];
57
+ if (raw !== undefined) {
58
+ const n = Number.parseInt(raw, 10);
59
+ if (Number.isFinite(n) && n >= 1)
60
+ return clampMaxSubagentDepth(n);
61
+ }
62
+ if (settingValue !== undefined && Number.isFinite(settingValue) && settingValue >= 1) {
63
+ return clampMaxSubagentDepth(settingValue);
64
+ }
65
+ return DEFAULT_MAX_SUBAGENT_DEPTH;
66
+ }
67
+ /** True when a process at the current depth may still spawn subagents. */
68
+ export function canSpawnSubagent(settingValue, env = process.env) {
69
+ return currentSubagentDepth(env) < resolveMaxSubagentDepth(settingValue, env);
70
+ }
71
+ /**
72
+ * Concurrency cap for pools running at depth >= 1. Reads the inherited env value
73
+ * (seeded by the root from the `nestedSubagentConcurrency` setting) when present,
74
+ * else the provided setting, else the default. Clamped to >= 1.
75
+ */
76
+ export function resolveNestedConcurrency(settingValue, env = process.env) {
77
+ const raw = env[NESTED_CONCURRENCY_ENV];
78
+ if (raw !== undefined) {
79
+ const n = Number.parseInt(raw, 10);
80
+ if (Number.isFinite(n) && n >= 1)
81
+ return n;
82
+ }
83
+ if (settingValue !== undefined && Number.isFinite(settingValue) && settingValue >= 1) {
84
+ return Math.floor(settingValue);
85
+ }
86
+ return NESTED_SUBAGENT_CONCURRENCY;
87
+ }
88
+ /** Concurrency for a pool created in the current process: reduced when nested. */
89
+ export function poolConcurrencyForDepth(env = process.env) {
90
+ return currentSubagentDepth(env) >= 1 ? resolveNestedConcurrency(undefined, env) : undefined;
91
+ }
92
+ /**
93
+ * Subagent types the current process is restricted to delegating to, or undefined
94
+ * when unrestricted (may delegate to any type). Set per spawned agent from its
95
+ * `delegate: <types>` frontmatter; the root is always unrestricted.
96
+ */
97
+ export function delegateAllowList(env = process.env) {
98
+ const raw = env[DELEGATE_ALLOW_ENV];
99
+ if (raw === undefined)
100
+ return undefined;
101
+ const list = raw
102
+ .split(",")
103
+ .map((s) => s.trim())
104
+ .filter((s) => s.length > 0);
105
+ return list.length > 0 ? list : undefined;
106
+ }
107
+ /** Whether the current process may delegate to the given subagent type. */
108
+ export function isDelegateAllowed(subagentType, env = process.env) {
109
+ const allow = delegateAllowList(env);
110
+ return !allow || allow.includes(subagentType);
111
+ }
112
+ //# sourceMappingURL=subagent-depth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subagent-depth.js","sourceRoot":"","sources":["../../src/core/subagent-depth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,CAAC,MAAM,kBAAkB,GAAG,wBAAwB,CAAC;AAC3D,MAAM,CAAC,MAAM,sBAAsB,GAAG,4BAA4B,CAAC;AACnE,MAAM,CAAC,MAAM,sBAAsB,GAAG,qCAAqC,CAAC;AAC5E,uFAAuF;AACvF,MAAM,CAAC,MAAM,kBAAkB,GAAG,wBAAwB,CAAC;AAE3D,mFAAmF;AACnF,MAAM,CAAC,MAAM,0BAA0B,GAAG,CAAC,CAAC;AAE5C,uFAAuF;AACvF,MAAM,CAAC,MAAM,2BAA2B,GAAG,CAAC,CAAC;AAE7C;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,CAAC,CAAC;AAE7C,uFAAuF;AACvF,MAAM,UAAU,qBAAqB,CAAC,CAAS,EAAU;IACxD,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;QAAE,OAAO,0BAA0B,CAAC;IAC3D,OAAO,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,2BAA2B,CAAC,CAAC;AAAA,CACzE;AAED,4DAA4D;AAC5D,MAAM,UAAU,oBAAoB,CAAC,GAAG,GAAsB,OAAO,CAAC,GAAG,EAAU;IAClF,MAAM,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,kBAAkB,CAAC,IAAI,GAAG,EAAE,EAAE,CAAC,CAAC;IAC9D,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAAA,CAC3C;AAED;;;;GAIG;AACH,MAAM,UAAU,uBAAuB,CAAC,YAAqB,EAAE,GAAG,GAAsB,OAAO,CAAC,GAAG,EAAU;IAC5G,MAAM,GAAG,GAAG,GAAG,CAAC,sBAAsB,CAAC,CAAC;IACxC,IAAI,GAAG,KAAK,SAAS,EAAE,CAAC;QACvB,MAAM,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QACnC,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,OAAO,qBAAqB,CAAC,CAAC,CAAC,CAAC;IACnE,CAAC;IACD,IAAI,YAAY,KAAK,SAAS,IAAI,MAAM,CAAC,QAAQ,CAAC,YAAY,CAAC,IAAI,YAAY,IAAI,CAAC,EAAE,CAAC;QACtF,OAAO,qBAAqB,CAAC,YAAY,CAAC,CAAC;IAC5C,CAAC;IACD,OAAO,0BAA0B,CAAC;AAAA,CAClC;AAED,0EAA0E;AAC1E,MAAM,UAAU,gBAAgB,CAAC,YAAqB,EAAE,GAAG,GAAsB,OAAO,CAAC,GAAG,EAAW;IACtG,OAAO,oBAAoB,CAAC,GAAG,CAAC,GAAG,uBAAuB,CAAC,YAAY,EAAE,GAAG,CAAC,CAAC;AAAA,CAC9E;AAED;;;;GAIG;AACH,MAAM,UAAU,wBAAwB,CAAC,YAAqB,EAAE,GAAG,GAAsB,OAAO,CAAC,GAAG,EAAU;IAC7G,MAAM,GAAG,GAAG,GAAG,CAAC,sBAAsB,CAAC,CAAC;IACxC,IAAI,GAAG,KAAK,SAAS,EAAE,CAAC;QACvB,MAAM,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QACnC,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,OAAO,CAAC,CAAC;IAC5C,CAAC;IACD,IAAI,YAAY,KAAK,SAAS,IAAI,MAAM,CAAC,QAAQ,CAAC,YAAY,CAAC,IAAI,YAAY,IAAI,CAAC,EAAE,CAAC;QACtF,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;IACjC,CAAC;IACD,OAAO,2BAA2B,CAAC;AAAA,CACnC;AAED,kFAAkF;AAClF,MAAM,UAAU,uBAAuB,CAAC,GAAG,GAAsB,OAAO,CAAC,GAAG,EAAsB;IACjG,OAAO,oBAAoB,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,wBAAwB,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AAAA,CAC7F;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAG,GAAsB,OAAO,CAAC,GAAG,EAAwB;IAC7F,MAAM,GAAG,GAAG,GAAG,CAAC,kBAAkB,CAAC,CAAC;IACpC,IAAI,GAAG,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IACxC,MAAM,IAAI,GAAG,GAAG;SACd,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;SACpB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAC9B,OAAO,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;AAAA,CAC1C;AAED,2EAA2E;AAC3E,MAAM,UAAU,iBAAiB,CAAC,YAAoB,EAAE,GAAG,GAAsB,OAAO,CAAC,GAAG,EAAW;IACtG,MAAM,KAAK,GAAG,iBAAiB,CAAC,GAAG,CAAC,CAAC;IACrC,OAAO,CAAC,KAAK,IAAI,KAAK,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC;AAAA,CAC9C","sourcesContent":["/**\n * Subagent nesting depth + tree-wide safety bounds.\n *\n * Nesting is governed entirely by environment variables so every process in a\n * delegation tree agrees without any runtime cross-process coordination (no\n * shared lock files, no slots to leak on crash):\n *\n * - HOOCODE_SUBAGENT_DEPTH current process's depth (root unset/0, its\n * children 1, grandchildren 2, ...).\n * - HOOCODE_SUBAGENT_MAX_DEPTH the tree-wide cap, seeded once by the root from\n * its `maxSubagentDepth` setting and inherited by\n * every descendant.\n *\n * The default cap is 1 — a subagent may not spawn further subagents — which\n * reproduces the original hard guard exactly. Raising it is an opt-in feature.\n *\n * Fan-out is bounded deterministically rather than by a global counter: pools at\n * depth >= 1 run with NESTED_SUBAGENT_CONCURRENCY instead of the root's default,\n * so the worst-case live process count is a fixed function of depth and the per\n * level caps (e.g. 5 + 5*2 = 15 at depth 2), with nothing to reclaim on crash.\n */\n\nexport const SUBAGENT_DEPTH_ENV = \"HOOCODE_SUBAGENT_DEPTH\";\nexport const SUBAGENT_MAX_DEPTH_ENV = \"HOOCODE_SUBAGENT_MAX_DEPTH\";\nexport const NESTED_CONCURRENCY_ENV = \"HOOCODE_NESTED_SUBAGENT_CONCURRENCY\";\n/** Comma-separated allowlist of subagent types the current process may delegate to. */\nexport const DELEGATE_ALLOW_ENV = \"HOOCODE_DELEGATE_ALLOW\";\n\n/** Default tree-wide cap: subagents cannot spawn subagents (original behavior). */\nexport const DEFAULT_MAX_SUBAGENT_DEPTH = 1;\n\n/** Concurrency cap for pools running at depth >= 1, keeping nested fan-out bounded. */\nexport const NESTED_SUBAGENT_CONCURRENCY = 2;\n\n/**\n * Hard ceiling on the configurable nesting depth. The worst-case live process\n * count grows geometrically with depth (each level's pool can run\n * NESTED_SUBAGENT_CONCURRENCY children), so an unbounded cap would let a\n * mis-configured setting exhaust the host. At this ceiling the worst case stays\n * modest: 5 * (2^3 - 1) = 35 processes.\n */\nexport const ABSOLUTE_MAX_SUBAGENT_DEPTH = 3;\n\n/** Clamp a requested cap into the supported range [1, ABSOLUTE_MAX_SUBAGENT_DEPTH]. */\nexport function clampMaxSubagentDepth(n: number): number {\n\tif (!Number.isFinite(n)) return DEFAULT_MAX_SUBAGENT_DEPTH;\n\treturn Math.min(Math.max(1, Math.floor(n)), ABSOLUTE_MAX_SUBAGENT_DEPTH);\n}\n\n/** Depth of the current process (0 = root/main session). */\nexport function currentSubagentDepth(env: NodeJS.ProcessEnv = process.env): number {\n\tconst n = Number.parseInt(env[SUBAGENT_DEPTH_ENV] ?? \"0\", 10);\n\treturn Number.isFinite(n) && n > 0 ? n : 0;\n}\n\n/**\n * Tree-wide max depth. Reads the inherited env value when present (any spawned\n * process), otherwise falls back to the provided setting (the root seeds env\n * from this). Clamped to >= 1 so the cap can never disable delegation entirely.\n */\nexport function resolveMaxSubagentDepth(settingValue?: number, env: NodeJS.ProcessEnv = process.env): number {\n\tconst raw = env[SUBAGENT_MAX_DEPTH_ENV];\n\tif (raw !== undefined) {\n\t\tconst n = Number.parseInt(raw, 10);\n\t\tif (Number.isFinite(n) && n >= 1) return clampMaxSubagentDepth(n);\n\t}\n\tif (settingValue !== undefined && Number.isFinite(settingValue) && settingValue >= 1) {\n\t\treturn clampMaxSubagentDepth(settingValue);\n\t}\n\treturn DEFAULT_MAX_SUBAGENT_DEPTH;\n}\n\n/** True when a process at the current depth may still spawn subagents. */\nexport function canSpawnSubagent(settingValue?: number, env: NodeJS.ProcessEnv = process.env): boolean {\n\treturn currentSubagentDepth(env) < resolveMaxSubagentDepth(settingValue, env);\n}\n\n/**\n * Concurrency cap for pools running at depth >= 1. Reads the inherited env value\n * (seeded by the root from the `nestedSubagentConcurrency` setting) when present,\n * else the provided setting, else the default. Clamped to >= 1.\n */\nexport function resolveNestedConcurrency(settingValue?: number, env: NodeJS.ProcessEnv = process.env): number {\n\tconst raw = env[NESTED_CONCURRENCY_ENV];\n\tif (raw !== undefined) {\n\t\tconst n = Number.parseInt(raw, 10);\n\t\tif (Number.isFinite(n) && n >= 1) return n;\n\t}\n\tif (settingValue !== undefined && Number.isFinite(settingValue) && settingValue >= 1) {\n\t\treturn Math.floor(settingValue);\n\t}\n\treturn NESTED_SUBAGENT_CONCURRENCY;\n}\n\n/** Concurrency for a pool created in the current process: reduced when nested. */\nexport function poolConcurrencyForDepth(env: NodeJS.ProcessEnv = process.env): number | undefined {\n\treturn currentSubagentDepth(env) >= 1 ? resolveNestedConcurrency(undefined, env) : undefined;\n}\n\n/**\n * Subagent types the current process is restricted to delegating to, or undefined\n * when unrestricted (may delegate to any type). Set per spawned agent from its\n * `delegate: <types>` frontmatter; the root is always unrestricted.\n */\nexport function delegateAllowList(env: NodeJS.ProcessEnv = process.env): string[] | undefined {\n\tconst raw = env[DELEGATE_ALLOW_ENV];\n\tif (raw === undefined) return undefined;\n\tconst list = raw\n\t\t.split(\",\")\n\t\t.map((s) => s.trim())\n\t\t.filter((s) => s.length > 0);\n\treturn list.length > 0 ? list : undefined;\n}\n\n/** Whether the current process may delegate to the given subagent type. */\nexport function isDelegateAllowed(subagentType: string, env: NodeJS.ProcessEnv = process.env): boolean {\n\tconst allow = delegateAllowList(env);\n\treturn !allow || allow.includes(subagentType);\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"subagent-pool-instance.d.ts","sourceRoot":"","sources":["../../src/core/subagent-pool-instance.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAQlD,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,CAYzD;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,IAAI,YAAY,GAAG,SAAS,CAE3D;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAG9D;AAED,mFAAmF;AACnF,wBAAgB,mBAAmB,IAAI,IAAI,CAI1C;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,YAAY,GAAG,SAAS,GAAG,IAAI,CAElF","sourcesContent":["/**\n * Process-wide SubagentPool singleton.\n *\n * The subagent tool and the `/subagent` command both delegate through one pool\n * so concurrency limits, lifeguard monitoring, and token budgets are shared\n * across every delegation in the session. Created lazily on first use and torn\n * down on process exit.\n */\n\nimport { getSubagentSpawnCommand } from \"../config.js\";\nimport { SubagentPool } from \"./subagent-pool.js\";\n\nlet pool: SubagentPool | undefined;\nlet override: SubagentPool | undefined;\nlet exitHandlerRegistered = false;\n/** Latest non-default skill paths to forward to subagents, kept in sync with the resource loader. */\nlet latestSkillPaths: string[] = [];\n\n/** Get the shared pool for a given working directory, creating it on first use. */\nexport function getSubagentPool(cwd: string): SubagentPool {\n\tif (override) return override;\n\tif (!pool) {\n\t\tconst { executable, prefixArgs } = getSubagentSpawnCommand();\n\t\tpool = new SubagentPool({ executable, prefixArgs, cwd, skillPaths: latestSkillPaths });\n\n\t\tif (!exitHandlerRegistered) {\n\t\t\texitHandlerRegistered = true;\n\t\t\tprocess.once(\"exit\", () => pool?.dispose());\n\t\t}\n\t}\n\treturn pool;\n}\n\n/**\n * Return the shared pool if one already exists, without creating it. Use this for\n * best-effort signaling (e.g. reporting external load) that must not spin up a pool\n * and its lifeguard just because the signal fired before any subagent was dispatched.\n */\nexport function peekSubagentPool(): SubagentPool | undefined {\n\treturn override ?? pool;\n}\n\n/**\n * Update the skill paths forwarded to every subagent.\n * Call this after the resource loader reloads or extends its skill set.\n * If the pool has already been created, updates it immediately.\n * If not, the paths will be passed in when the pool is first created.\n */\nexport function updateSubagentSkillPaths(paths: string[]): void {\n\tlatestSkillPaths = paths;\n\tpool?.updateSkillPaths(paths);\n}\n\n/** Dispose and clear the shared pool. Intended for test isolation and shutdown. */\nexport function disposeSubagentPool(): void {\n\tpool?.dispose();\n\tpool = undefined;\n\tlatestSkillPaths = [];\n}\n\n/**\n * Inject a pool instance for tests, bypassing real child-process spawning.\n * Pass `undefined` to clear the override.\n */\nexport function setSubagentPoolForTesting(testPool: SubagentPool | undefined): void {\n\toverride = testPool;\n}\n"]}
1
+ {"version":3,"file":"subagent-pool-instance.d.ts","sourceRoot":"","sources":["../../src/core/subagent-pool-instance.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAQlD,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,CAqBzD;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,IAAI,YAAY,GAAG,SAAS,CAE3D;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAG9D;AAED,mFAAmF;AACnF,wBAAgB,mBAAmB,IAAI,IAAI,CAI1C;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,YAAY,GAAG,SAAS,GAAG,IAAI,CAElF","sourcesContent":["/**\n * Process-wide SubagentPool singleton.\n *\n * The subagent tool and the `/subagent` command both delegate through one pool\n * so concurrency limits, lifeguard monitoring, and token budgets are shared\n * across every delegation in the session. Created lazily on first use and torn\n * down on process exit.\n */\n\nimport { getSubagentSpawnCommand } from \"../config.js\";\nimport { poolConcurrencyForDepth } from \"./subagent-depth.js\";\nimport { SubagentPool } from \"./subagent-pool.js\";\n\nlet pool: SubagentPool | undefined;\nlet override: SubagentPool | undefined;\nlet exitHandlerRegistered = false;\n/** Latest non-default skill paths to forward to subagents, kept in sync with the resource loader. */\nlet latestSkillPaths: string[] = [];\n\n/** Get the shared pool for a given working directory, creating it on first use. */\nexport function getSubagentPool(cwd: string): SubagentPool {\n\tif (override) return override;\n\tif (!pool) {\n\t\tconst { executable, prefixArgs } = getSubagentSpawnCommand();\n\t\t// Pools created inside a nested subagent (depth >= 1) run with a reduced\n\t\t// concurrency cap so deep delegation trees stay bounded; the root keeps the\n\t\t// SubagentPool default.\n\t\tpool = new SubagentPool({\n\t\t\texecutable,\n\t\t\tprefixArgs,\n\t\t\tcwd,\n\t\t\tskillPaths: latestSkillPaths,\n\t\t\tmaxConcurrency: poolConcurrencyForDepth(),\n\t\t});\n\n\t\tif (!exitHandlerRegistered) {\n\t\t\texitHandlerRegistered = true;\n\t\t\tprocess.once(\"exit\", () => pool?.dispose());\n\t\t}\n\t}\n\treturn pool;\n}\n\n/**\n * Return the shared pool if one already exists, without creating it. Use this for\n * best-effort signaling (e.g. reporting external load) that must not spin up a pool\n * and its lifeguard just because the signal fired before any subagent was dispatched.\n */\nexport function peekSubagentPool(): SubagentPool | undefined {\n\treturn override ?? pool;\n}\n\n/**\n * Update the skill paths forwarded to every subagent.\n * Call this after the resource loader reloads or extends its skill set.\n * If the pool has already been created, updates it immediately.\n * If not, the paths will be passed in when the pool is first created.\n */\nexport function updateSubagentSkillPaths(paths: string[]): void {\n\tlatestSkillPaths = paths;\n\tpool?.updateSkillPaths(paths);\n}\n\n/** Dispose and clear the shared pool. Intended for test isolation and shutdown. */\nexport function disposeSubagentPool(): void {\n\tpool?.dispose();\n\tpool = undefined;\n\tlatestSkillPaths = [];\n}\n\n/**\n * Inject a pool instance for tests, bypassing real child-process spawning.\n * Pass `undefined` to clear the override.\n */\nexport function setSubagentPoolForTesting(testPool: SubagentPool | undefined): void {\n\toverride = testPool;\n}\n"]}
@@ -7,6 +7,7 @@
7
7
  * down on process exit.
8
8
  */
9
9
  import { getSubagentSpawnCommand } from "../config.js";
10
+ import { poolConcurrencyForDepth } from "./subagent-depth.js";
10
11
  import { SubagentPool } from "./subagent-pool.js";
11
12
  let pool;
12
13
  let override;
@@ -19,7 +20,16 @@ export function getSubagentPool(cwd) {
19
20
  return override;
20
21
  if (!pool) {
21
22
  const { executable, prefixArgs } = getSubagentSpawnCommand();
22
- pool = new SubagentPool({ executable, prefixArgs, cwd, skillPaths: latestSkillPaths });
23
+ // Pools created inside a nested subagent (depth >= 1) run with a reduced
24
+ // concurrency cap so deep delegation trees stay bounded; the root keeps the
25
+ // SubagentPool default.
26
+ pool = new SubagentPool({
27
+ executable,
28
+ prefixArgs,
29
+ cwd,
30
+ skillPaths: latestSkillPaths,
31
+ maxConcurrency: poolConcurrencyForDepth(),
32
+ });
23
33
  if (!exitHandlerRegistered) {
24
34
  exitHandlerRegistered = true;
25
35
  process.once("exit", () => pool?.dispose());
@@ -1 +1 @@
1
- {"version":3,"file":"subagent-pool-instance.js","sourceRoot":"","sources":["../../src/core/subagent-pool-instance.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AACvD,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD,IAAI,IAA8B,CAAC;AACnC,IAAI,QAAkC,CAAC;AACvC,IAAI,qBAAqB,GAAG,KAAK,CAAC;AAClC,qGAAqG;AACrG,IAAI,gBAAgB,GAAa,EAAE,CAAC;AAEpC,mFAAmF;AACnF,MAAM,UAAU,eAAe,CAAC,GAAW,EAAgB;IAC1D,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAC9B,IAAI,CAAC,IAAI,EAAE,CAAC;QACX,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,GAAG,uBAAuB,EAAE,CAAC;QAC7D,IAAI,GAAG,IAAI,YAAY,CAAC,EAAE,UAAU,EAAE,UAAU,EAAE,GAAG,EAAE,UAAU,EAAE,gBAAgB,EAAE,CAAC,CAAC;QAEvF,IAAI,CAAC,qBAAqB,EAAE,CAAC;YAC5B,qBAAqB,GAAG,IAAI,CAAC;YAC7B,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;QAC7C,CAAC;IACF,CAAC;IACD,OAAO,IAAI,CAAC;AAAA,CACZ;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,GAA6B;IAC5D,OAAO,QAAQ,IAAI,IAAI,CAAC;AAAA,CACxB;AAED;;;;;GAKG;AACH,MAAM,UAAU,wBAAwB,CAAC,KAAe,EAAQ;IAC/D,gBAAgB,GAAG,KAAK,CAAC;IACzB,IAAI,EAAE,gBAAgB,CAAC,KAAK,CAAC,CAAC;AAAA,CAC9B;AAED,mFAAmF;AACnF,MAAM,UAAU,mBAAmB,GAAS;IAC3C,IAAI,EAAE,OAAO,EAAE,CAAC;IAChB,IAAI,GAAG,SAAS,CAAC;IACjB,gBAAgB,GAAG,EAAE,CAAC;AAAA,CACtB;AAED;;;GAGG;AACH,MAAM,UAAU,yBAAyB,CAAC,QAAkC,EAAQ;IACnF,QAAQ,GAAG,QAAQ,CAAC;AAAA,CACpB","sourcesContent":["/**\n * Process-wide SubagentPool singleton.\n *\n * The subagent tool and the `/subagent` command both delegate through one pool\n * so concurrency limits, lifeguard monitoring, and token budgets are shared\n * across every delegation in the session. Created lazily on first use and torn\n * down on process exit.\n */\n\nimport { getSubagentSpawnCommand } from \"../config.js\";\nimport { SubagentPool } from \"./subagent-pool.js\";\n\nlet pool: SubagentPool | undefined;\nlet override: SubagentPool | undefined;\nlet exitHandlerRegistered = false;\n/** Latest non-default skill paths to forward to subagents, kept in sync with the resource loader. */\nlet latestSkillPaths: string[] = [];\n\n/** Get the shared pool for a given working directory, creating it on first use. */\nexport function getSubagentPool(cwd: string): SubagentPool {\n\tif (override) return override;\n\tif (!pool) {\n\t\tconst { executable, prefixArgs } = getSubagentSpawnCommand();\n\t\tpool = new SubagentPool({ executable, prefixArgs, cwd, skillPaths: latestSkillPaths });\n\n\t\tif (!exitHandlerRegistered) {\n\t\t\texitHandlerRegistered = true;\n\t\t\tprocess.once(\"exit\", () => pool?.dispose());\n\t\t}\n\t}\n\treturn pool;\n}\n\n/**\n * Return the shared pool if one already exists, without creating it. Use this for\n * best-effort signaling (e.g. reporting external load) that must not spin up a pool\n * and its lifeguard just because the signal fired before any subagent was dispatched.\n */\nexport function peekSubagentPool(): SubagentPool | undefined {\n\treturn override ?? pool;\n}\n\n/**\n * Update the skill paths forwarded to every subagent.\n * Call this after the resource loader reloads or extends its skill set.\n * If the pool has already been created, updates it immediately.\n * If not, the paths will be passed in when the pool is first created.\n */\nexport function updateSubagentSkillPaths(paths: string[]): void {\n\tlatestSkillPaths = paths;\n\tpool?.updateSkillPaths(paths);\n}\n\n/** Dispose and clear the shared pool. Intended for test isolation and shutdown. */\nexport function disposeSubagentPool(): void {\n\tpool?.dispose();\n\tpool = undefined;\n\tlatestSkillPaths = [];\n}\n\n/**\n * Inject a pool instance for tests, bypassing real child-process spawning.\n * Pass `undefined` to clear the override.\n */\nexport function setSubagentPoolForTesting(testPool: SubagentPool | undefined): void {\n\toverride = testPool;\n}\n"]}
1
+ {"version":3,"file":"subagent-pool-instance.js","sourceRoot":"","sources":["../../src/core/subagent-pool-instance.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AACvD,OAAO,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD,IAAI,IAA8B,CAAC;AACnC,IAAI,QAAkC,CAAC;AACvC,IAAI,qBAAqB,GAAG,KAAK,CAAC;AAClC,qGAAqG;AACrG,IAAI,gBAAgB,GAAa,EAAE,CAAC;AAEpC,mFAAmF;AACnF,MAAM,UAAU,eAAe,CAAC,GAAW,EAAgB;IAC1D,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAC9B,IAAI,CAAC,IAAI,EAAE,CAAC;QACX,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,GAAG,uBAAuB,EAAE,CAAC;QAC7D,yEAAyE;QACzE,4EAA4E;QAC5E,wBAAwB;QACxB,IAAI,GAAG,IAAI,YAAY,CAAC;YACvB,UAAU;YACV,UAAU;YACV,GAAG;YACH,UAAU,EAAE,gBAAgB;YAC5B,cAAc,EAAE,uBAAuB,EAAE;SACzC,CAAC,CAAC;QAEH,IAAI,CAAC,qBAAqB,EAAE,CAAC;YAC5B,qBAAqB,GAAG,IAAI,CAAC;YAC7B,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;QAC7C,CAAC;IACF,CAAC;IACD,OAAO,IAAI,CAAC;AAAA,CACZ;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,GAA6B;IAC5D,OAAO,QAAQ,IAAI,IAAI,CAAC;AAAA,CACxB;AAED;;;;;GAKG;AACH,MAAM,UAAU,wBAAwB,CAAC,KAAe,EAAQ;IAC/D,gBAAgB,GAAG,KAAK,CAAC;IACzB,IAAI,EAAE,gBAAgB,CAAC,KAAK,CAAC,CAAC;AAAA,CAC9B;AAED,mFAAmF;AACnF,MAAM,UAAU,mBAAmB,GAAS;IAC3C,IAAI,EAAE,OAAO,EAAE,CAAC;IAChB,IAAI,GAAG,SAAS,CAAC;IACjB,gBAAgB,GAAG,EAAE,CAAC;AAAA,CACtB;AAED;;;GAGG;AACH,MAAM,UAAU,yBAAyB,CAAC,QAAkC,EAAQ;IACnF,QAAQ,GAAG,QAAQ,CAAC;AAAA,CACpB","sourcesContent":["/**\n * Process-wide SubagentPool singleton.\n *\n * The subagent tool and the `/subagent` command both delegate through one pool\n * so concurrency limits, lifeguard monitoring, and token budgets are shared\n * across every delegation in the session. Created lazily on first use and torn\n * down on process exit.\n */\n\nimport { getSubagentSpawnCommand } from \"../config.js\";\nimport { poolConcurrencyForDepth } from \"./subagent-depth.js\";\nimport { SubagentPool } from \"./subagent-pool.js\";\n\nlet pool: SubagentPool | undefined;\nlet override: SubagentPool | undefined;\nlet exitHandlerRegistered = false;\n/** Latest non-default skill paths to forward to subagents, kept in sync with the resource loader. */\nlet latestSkillPaths: string[] = [];\n\n/** Get the shared pool for a given working directory, creating it on first use. */\nexport function getSubagentPool(cwd: string): SubagentPool {\n\tif (override) return override;\n\tif (!pool) {\n\t\tconst { executable, prefixArgs } = getSubagentSpawnCommand();\n\t\t// Pools created inside a nested subagent (depth >= 1) run with a reduced\n\t\t// concurrency cap so deep delegation trees stay bounded; the root keeps the\n\t\t// SubagentPool default.\n\t\tpool = new SubagentPool({\n\t\t\texecutable,\n\t\t\tprefixArgs,\n\t\t\tcwd,\n\t\t\tskillPaths: latestSkillPaths,\n\t\t\tmaxConcurrency: poolConcurrencyForDepth(),\n\t\t});\n\n\t\tif (!exitHandlerRegistered) {\n\t\t\texitHandlerRegistered = true;\n\t\t\tprocess.once(\"exit\", () => pool?.dispose());\n\t\t}\n\t}\n\treturn pool;\n}\n\n/**\n * Return the shared pool if one already exists, without creating it. Use this for\n * best-effort signaling (e.g. reporting external load) that must not spin up a pool\n * and its lifeguard just because the signal fired before any subagent was dispatched.\n */\nexport function peekSubagentPool(): SubagentPool | undefined {\n\treturn override ?? pool;\n}\n\n/**\n * Update the skill paths forwarded to every subagent.\n * Call this after the resource loader reloads or extends its skill set.\n * If the pool has already been created, updates it immediately.\n * If not, the paths will be passed in when the pool is first created.\n */\nexport function updateSubagentSkillPaths(paths: string[]): void {\n\tlatestSkillPaths = paths;\n\tpool?.updateSkillPaths(paths);\n}\n\n/** Dispose and clear the shared pool. Intended for test isolation and shutdown. */\nexport function disposeSubagentPool(): void {\n\tpool?.dispose();\n\tpool = undefined;\n\tlatestSkillPaths = [];\n}\n\n/**\n * Inject a pool instance for tests, bypassing real child-process spawning.\n * Pass `undefined` to clear the override.\n */\nexport function setSubagentPoolForTesting(testPool: SubagentPool | undefined): void {\n\toverride = testPool;\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"subagent-pool.d.ts","sourceRoot":"","sources":["../../src/core/subagent-pool.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn } from \"node:child_process\";\nimport { EventEmitter } from \"node:events\";\nimport { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { getDispatchTaskDir } from \"../config.js\";\nimport { waitForChildProcess } from \"../utils/child-process.js\";\nimport { MODEL_INHERIT } from \"./agent-frontmatter.js\";\nimport { type AgentRegistry, loadAgentRegistry } from \"./agent-registry.js\";\nimport { DispatchEvaluator } from \"./dispatch-evaluator.js\";\nimport { SubagentLifeguard } from \"./lifeguard.js\";\nimport { OutputVerifier } from \"./output-verifier.js\";\nimport { TokenBudget } from \"./token-budget.js\";\n\nexport interface SubagentPoolTask {\n\ttask_id: string;\n\tagent_type: string;\n\ttask: string;\n\tcontext?: string;\n\ttoken_budget?: number;\n\tcwd?: string;\n\tmodel?: string;\n\tprovider?: string;\n\t/**\n\t * Explicit session file for the child to persist/continue. When omitted the\n\t * child uses its own dispatch dir (`<dispatch>/<task_id>/session.jsonl`).\n\t * Resume reuses the original task's session file to continue the transcript.\n\t */\n\tsessionFile?: string;\n\t/** Internal: retry using the caller's model when a built-in agent's preferred model fails. */\n\tuseInheritedModelFallback?: boolean;\n}\n\nexport interface SubagentSlot {\n\tpid: number;\n\tagent_type: string;\n\ttask_id: string;\n\tspawned_at: number;\n\ttoken_budget: number;\n\tprocess: ReturnType<typeof spawn>;\n}\n\nexport interface SubagentResult {\n\ttask_id: string;\n\tok: boolean;\n\tstdout: string;\n\tstderr: string;\n\texit_code: number | null;\n\terror?: string;\n\t/** True when the task exceeded its token budget and was hard-stopped. */\n\tbudget_exceeded?: boolean;\n\t/** Terminal status derived from how the task finished. */\n\tstatus?: \"complete\" | \"partial\" | \"failed\" | \"stalled\" | \"timeout\";\n\t/** Parsed result.json content when available (e.g. on partial completion). */\n\tresult_data?: Record<string, unknown>;\n\t/** True when this run used the inherited-model fallback (preferred model failed first). */\n\tusedInheritedModelFallback?: boolean;\n}\n\nexport interface TaskResult {\n\t/** True when the evaluator decided the task is simple enough for inline handling. */\n\thandled_inline: boolean;\n\t/** Present when the task was delegated. */\n\ttask_id?: string;\n\tagent_type?: string;\n\treason?: string;\n\t/** Subagent result when delegated. */\n\tresult?: SubagentResult;\n\t/** Duration in milliseconds when delegated. */\n\tduration?: number;\n}\n\nexport interface DispatchOptions {\n\t/** Skip evaluation and force this agent type (user/explicit override).\n\t * Accepts any registry-defined agent name, not just the built-in modes. */\n\tforceAgent?: string;\n\t/** Context distilled from the calling agent, passed to the subagent. */\n\tcontext?: string;\n\t/** Model id for the subagent (defaults to the child's configured default). */\n\tmodel?: string;\n\t/** Provider for the subagent. */\n\tprovider?: string;\n\t/** Explicit session file to persist/continue (used by resume). */\n\tsessionFile?: string;\n}\n\nexport interface SubagentPoolOptions {\n\t/** Path to the hoocode executable (or the runtime, e.g. node, when prefixArgs is set). */\n\texecutable: string;\n\t/** Args inserted before task args (e.g. the CLI entry script for node/tsx). */\n\tprefixArgs?: string[];\n\t/** Maximum concurrent child processes. Defaults to 5. */\n\tmaxConcurrency?: number;\n\t/** Working directory for spawned processes. Defaults to process.cwd(). */\n\tcwd?: string;\n\t/** Environment variables. Defaults to process.env. */\n\tenv?: NodeJS.ProcessEnv;\n\t/** Default token budget per task. Defaults to 0. */\n\tdefaultTokenBudget?: number;\n\t/**\n\t * Non-default skill paths to forward to every spawned subagent via --skill.\n\t * Subagents auto-discover skills from standard locations; only paths that\n\t * won't be found by default discovery need to be forwarded here.\n\t */\n\tskillPaths?: string[];\n}\n\n/**\n * Default hard cap on assistant turns for a spawned subagent when its definition\n * does not set `maxTurns`. The token budget is advisory (it warns but never\n * kills), so this turn cap is the guaranteed hard stop for every subagent.\n */\nexport const DEFAULT_SUBAGENT_MAX_TURNS = 50;\n\n/**\n * Pool for running hoocode subagents as child processes with bounded concurrency,\n * FIFO queuing with priority support, and automatic slot refill.\n *\n * Events:\n * - \"task_done\" – task completed successfully and output was verified\n * - \"task_failed\" – task failed (spawn error, bad exit code, verification failure)\n * - \"task_stalled\" – heartbeat missed past the load-scaled threshold (60s base,\n * widened under concurrency/event-loop lag), process SIGKILLed\n * - \"task_timeout\" – hard timeout exceeded, process was SIGKILLed\n * - \"budget_warning\" – token usage crossed 80% threshold (advisory)\n * - \"budget_exceeded\" – token usage crossed 100% threshold (advisory; never kills)\n */\nexport class SubagentPool extends EventEmitter {\n\tprivate readonly maxConcurrency: number;\n\tprivate readonly executable: string;\n\tprivate readonly prefixArgs: string[];\n\tprivate readonly cwd: string;\n\tprivate readonly env: NodeJS.ProcessEnv;\n\tprivate readonly defaultTokenBudget: number;\n\t/** Non-default skill paths forwarded to every spawned subagent via --skill. */\n\tprivate skillPaths: string[];\n\n\tprivate slots = new Map<string, SubagentSlot>();\n\tprivate queue: SubagentPoolTask[] = [];\n\tprivate completed = new Map<string, SubagentResult>();\n\tprivate waiters = new Map<string, { resolve: (result: SubagentResult) => void; reject: (err: Error) => void }>();\n\tprivate budgets = new Map<string, TokenBudget>();\n\tprivate verifier = new OutputVerifier();\n\tprivate lifeguard: SubagentLifeguard;\n\tprivate disposed = false;\n\t/** Lazily-loaded agent registry (frontmatter definitions) for this pool's cwd. */\n\tprivate registry?: AgentRegistry;\n\t/** Tracks why a task was killed (stalled / timeout) before exit handler fires. */\n\tprivate killReasons = new Map<string, \"stalled\" | \"timeout\">();\n\t/** Persistent terminal status map, survives wait_for consumption. */\n\tprivate taskStatus = new Map<string, \"done\" | \"failed\" | \"stalled\" | \"timeout\">();\n\n\tconstructor(options: SubagentPoolOptions) {\n\t\tsuper();\n\t\tthis.maxConcurrency = options.maxConcurrency ?? 5;\n\t\tthis.executable = options.executable;\n\t\tthis.prefixArgs = options.prefixArgs ?? [];\n\t\tthis.cwd = options.cwd ?? process.cwd();\n\t\tthis.env = options.env ?? process.env;\n\t\tthis.defaultTokenBudget = options.defaultTokenBudget ?? 0;\n\t\tthis.skillPaths = options.skillPaths ? [...options.skillPaths] : [];\n\t\tthis.verifier = new OutputVerifier(this.cwd);\n\t\tthis.lifeguard = new SubagentLifeguard(this.cwd);\n\t\tthis.lifeguard.on(\"stalled\", (data: { task_id: string; pid: number }) => {\n\t\t\tthis.killReasons.set(data.task_id, \"stalled\");\n\t\t\tthis.emit(\"task_stalled\", data);\n\t\t});\n\t\tthis.lifeguard.on(\"timeout\", (data: { task_id: string; pid: number }) => {\n\t\t\tthis.killReasons.set(data.task_id, \"timeout\");\n\t\t\tthis.emit(\"task_timeout\", data);\n\t\t});\n\t}\n\n\t/** Update the non-default skill paths forwarded to new subagents. */\n\tupdateSkillPaths(paths: string[]): void {\n\t\tthis.skillPaths = [...paths];\n\t}\n\n\t/**\n\t * Report external in-process load (e.g. the number of background MCP tools\n\t * currently executing in the parent) to the lifeguard. This widens its\n\t * heartbeat/timeout tolerance so monitored subagents aren't false-positive\n\t * reaped when the parent's event loop is busy with concurrent background work.\n\t */\n\tsetExternalLoad(count: number): void {\n\t\tthis.lifeguard.setExternalLoad(count);\n\t}\n\n\t/** Lazily load the agent registry for this pool's cwd. */\n\tprivate getRegistry(): AgentRegistry {\n\t\tif (!this.registry) {\n\t\t\tthis.registry = loadAgentRegistry({ cwd: this.cwd });\n\t\t}\n\t\treturn this.registry;\n\t}\n\n\t/** Priority value: higher numbers run first. */\n\tprivate priorityOf(agent_type: string): number {\n\t\tswitch (agent_type) {\n\t\t\tcase \"explore\":\n\t\t\tcase \"review\":\n\t\t\t\treturn 2;\n\t\t\tcase \"doc\":\n\t\t\t\treturn 0;\n\t\t\tdefault:\n\t\t\t\treturn 1;\n\t\t}\n\t}\n\n\t/** Queue a task. It will run when a slot is free. */\n\tspawn(task: SubagentPoolTask): void {\n\t\tif (this.disposed) {\n\t\t\tthrow new Error(\"SubagentPool has been disposed\");\n\t\t}\n\t\tif (\n\t\t\tthis.slots.has(task.task_id) ||\n\t\t\tthis.queue.some((t) => t.task_id === task.task_id) ||\n\t\t\tthis.completed.has(task.task_id)\n\t\t) {\n\t\t\tthrow new Error(`Duplicate task_id: ${task.task_id}`);\n\t\t}\n\n\t\tconst p = this.priorityOf(task.agent_type);\n\t\tconst idx = this.queue.findIndex((t) => this.priorityOf(t.agent_type) < p);\n\t\tif (idx === -1) {\n\t\t\tthis.queue.push(task);\n\t\t} else {\n\t\t\tthis.queue.splice(idx, 0, task);\n\t\t}\n\t\tthis.pull();\n\t}\n\n\t/** Current status of a task. */\n\tget_status(task_id: string): \"running\" | \"queued\" | \"done\" | \"failed\" | \"stalled\" | \"timeout\" {\n\t\tif (this.slots.has(task_id)) return \"running\";\n\t\tif (this.queue.some((t) => t.task_id === task_id)) return \"queued\";\n\t\tconst persisted = this.taskStatus.get(task_id);\n\t\tif (persisted) return persisted;\n\t\tconst result = this.completed.get(task_id);\n\t\tif (result) {\n\t\t\tif (result.status === \"stalled\") return \"stalled\";\n\t\t\tif (result.status === \"timeout\") return \"timeout\";\n\t\t\tif (result.ok) return \"done\";\n\t\t\treturn \"failed\";\n\t\t}\n\t\treturn \"failed\";\n\t}\n\n\t/** Wait for a task to complete and return its result. */\n\twait_for(task_id: string): Promise<SubagentResult> {\n\t\tif (this.disposed) {\n\t\t\treturn Promise.reject(new Error(\"SubagentPool has been disposed\"));\n\t\t}\n\n\t\tconst existing = this.completed.get(task_id);\n\t\tif (existing) {\n\t\t\tthis.completed.delete(task_id);\n\t\t\treturn Promise.resolve(existing);\n\t\t}\n\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tthis.waiters.set(task_id, { resolve, reject });\n\t\t});\n\t}\n\n\t/** Number of currently running subagents. */\n\trunning_count(): number {\n\t\treturn this.slots.size;\n\t}\n\n\t/** Number of tasks waiting in the queue. */\n\tqueued_count(): number {\n\t\treturn this.queue.length;\n\t}\n\n\t/**\n\t * Dispatch a task through the evaluator.\n\t *\n\t * - If `options.forceAgent` is provided, skip evaluation and spawn directly.\n\t * - Otherwise evaluate the task. If it should be handled inline, return\n\t * `{ handled_inline: true }` immediately.\n\t * - If delegating, spawn the subagent, wait for completion, write\n\t * `output.json`, and return the result.\n\t */\n\tasync dispatch(task: string, options: DispatchOptions = {}): Promise<TaskResult> {\n\t\tif (this.disposed) {\n\t\t\treturn Promise.reject(new Error(\"SubagentPool has been disposed\"));\n\t\t}\n\t\tconst begin = this.beginDispatch(task, options);\n\t\tif (begin.handled_inline) {\n\t\t\treturn { handled_inline: true, reason: begin.reason };\n\t\t}\n\t\tconst result = await this.wait_for(begin.task_id);\n\t\treturn {\n\t\t\thandled_inline: false,\n\t\t\ttask_id: begin.task_id,\n\t\t\tagent_type: begin.agent_type,\n\t\t\treason: begin.reason,\n\t\t\tresult,\n\t\t\tduration: Date.now() - begin.startTime,\n\t\t};\n\t}\n\n\t/**\n\t * Fire-and-forget dispatch for background agents. Spawns the subagent and\n\t * returns its handle immediately; the caller polls get_status()/collect().\n\t */\n\tdispatchDetached(\n\t\ttask: string,\n\t\toptions: DispatchOptions = {},\n\t): { handled_inline: boolean; task_id?: string; agent_type?: string; reason?: string } {\n\t\tif (this.disposed) {\n\t\t\tthrow new Error(\"SubagentPool has been disposed\");\n\t\t}\n\t\tconst begin = this.beginDispatch(task, options);\n\t\tif (begin.handled_inline) {\n\t\t\treturn { handled_inline: true, reason: begin.reason };\n\t\t}\n\t\treturn { handled_inline: false, task_id: begin.task_id, agent_type: begin.agent_type, reason: begin.reason };\n\t}\n\n\t/**\n\t * Evaluate, log, and spawn a task without waiting. Shared by dispatch()\n\t * (blocking) and dispatchDetached() (background).\n\t */\n\tprivate beginDispatch(\n\t\ttask: string,\n\t\toptions: DispatchOptions,\n\t):\n\t\t| { handled_inline: true; reason?: string }\n\t\t| { handled_inline: false; task_id: string; agent_type: string; reason?: string; startTime: number } {\n\t\tconst { forceAgent, context, model, provider, sessionFile } = options;\n\t\tconst evaluator = new DispatchEvaluator();\n\t\tconst analysis = evaluator.evaluate(task);\n\n\t\tif (!forceAgent && !analysis.should_delegate) {\n\t\t\treturn { handled_inline: true, reason: analysis.reason };\n\t\t}\n\n\t\tconst agent_type = forceAgent ?? \"general-purpose\";\n\t\tconst task_id = `dispatch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\n\t\tconst reason = forceAgent ? \"user_override\" : analysis.reason;\n\t\tconst complexity = analysis.estimated_complexity;\n\n\t\t// Pre-dispatch logging. Use stderr: stdout is reserved for the JSON event\n\t\t// stream / TUI render and must not be polluted.\n\t\tconsole.error(`[DISPATCH] agent=${agent_type} reason=${reason} complexity=${complexity} task_id=${task_id}`);\n\t\tthis.writeDispatchLog(task_id, agent_type, reason, complexity, task);\n\n\t\tconst poolTask: SubagentPoolTask = {\n\t\t\ttask_id,\n\t\t\tagent_type,\n\t\t\ttask,\n\t\t\tcontext,\n\t\t\tmodel,\n\t\t\tprovider,\n\t\t\tsessionFile,\n\t\t\tcwd: this.cwd,\n\t\t};\n\t\tconst startTime = Date.now();\n\t\tthis.spawn(poolTask);\n\t\treturn { handled_inline: false, task_id, agent_type, reason, startTime };\n\t}\n\n\t/**\n\t * Non-destructively read a completed task's result (for background polling).\n\t * Returns undefined while the task is still running/queued, or if its result\n\t * was already consumed via wait_for().\n\t */\n\tcollect(task_id: string): SubagentResult | undefined {\n\t\treturn this.completed.get(task_id);\n\t}\n\n\t/** Absolute path of the persisted session file for a task. */\n\tgetSessionFile(task_id: string, cwd: string = this.cwd): string {\n\t\treturn join(getDispatchTaskDir(cwd, task_id), \"session.jsonl\");\n\t}\n\n\t/**\n\t * Resume a previously dispatched subagent, continuing its persisted session\n\t * with a follow-up prompt. Recovers the original agent type from its dispatch\n\t * log. Rejects if no resumable session exists for the task.\n\t */\n\tasync resume(\n\t\ttask_id: string,\n\t\tprompt: string,\n\t\toptions: Omit<DispatchOptions, \"forceAgent\" | \"sessionFile\"> = {},\n\t): Promise<TaskResult> {\n\t\tif (this.disposed) {\n\t\t\treturn Promise.reject(new Error(\"SubagentPool has been disposed\"));\n\t\t}\n\t\tconst sessionFile = this.getSessionFile(task_id);\n\t\tif (!existsSync(sessionFile)) {\n\t\t\treturn Promise.reject(new Error(`No resumable session for task \"${task_id}\" (expected ${sessionFile}).`));\n\t\t}\n\t\tconst agent_type = this.readDispatchAgentType(task_id) ?? \"general-purpose\";\n\t\treturn this.dispatch(prompt, { ...options, forceAgent: agent_type, sessionFile });\n\t}\n\n\t/** Recover the agent type a task was dispatched with, from its dispatch log. */\n\tprivate readDispatchAgentType(task_id: string): string | undefined {\n\t\tconst path = join(getDispatchTaskDir(this.cwd, task_id), \"dispatch-log.json\");\n\t\tif (!existsSync(path)) return undefined;\n\t\ttry {\n\t\t\tconst parsed = JSON.parse(readFileSync(path, \"utf-8\")) as { agent_type?: string };\n\t\t\treturn typeof parsed.agent_type === \"string\" ? parsed.agent_type : undefined;\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tprivate writeDispatchLog(\n\t\ttask_id: string,\n\t\tagent_type: string,\n\t\treason: string,\n\t\tcomplexity: string,\n\t\ttask: string,\n\t): void {\n\t\tconst log = {\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\ttask_id,\n\t\t\tagent_type,\n\t\t\treason,\n\t\t\tcomplexity,\n\t\t\ttask,\n\t\t};\n\t\tconst path = join(getDispatchTaskDir(this.cwd, task_id), \"dispatch-log.json\");\n\t\ttry {\n\t\t\tmkdirSync(dirname(path), { recursive: true });\n\t\t\twriteFileSync(path, JSON.stringify(log, null, 2));\n\t\t} catch {\n\t\t\t// Best-effort persistence\n\t\t}\n\t}\n\n\tprivate writeOutputJson(task_id: string, result: SubagentResult): void {\n\t\tconst output = {\n\t\t\ttask_id: result.task_id,\n\t\t\tok: result.ok,\n\t\t\texit_code: result.exit_code,\n\t\t\tstatus: result.status,\n\t\t\tstdout: result.stdout,\n\t\t\tstderr: result.stderr,\n\t\t\terror: result.error,\n\t\t\tbudget_exceeded: result.budget_exceeded,\n\t\t\tresult_data: result.result_data,\n\t\t};\n\t\tconst path = join(getDispatchTaskDir(this.cwd, task_id), \"output.json\");\n\t\ttry {\n\t\t\tmkdirSync(dirname(path), { recursive: true });\n\t\t\twriteFileSync(path, JSON.stringify(output, null, 2));\n\t\t} catch {\n\t\t\t// Best-effort persistence\n\t\t}\n\t}\n\n\t/**\n\t * Remove a task's dispatch dir after a clean, verified success. Best-effort:\n\t * a cleanup failure must never fail an otherwise successful task.\n\t */\n\tprivate cleanupDispatchDir(task_id: string, cwd: string): void {\n\t\ttry {\n\t\t\trmSync(getDispatchTaskDir(cwd, task_id), { recursive: true, force: true });\n\t\t} catch {\n\t\t\t// Best-effort cleanup\n\t\t}\n\t}\n\n\t/** Kill all running processes, clear the queue, and reject pending waiters. */\n\tdispose(): void {\n\t\tif (this.disposed) return;\n\t\tthis.disposed = true;\n\n\t\tfor (const slot of this.slots.values()) {\n\t\t\tif (!slot.process.killed) {\n\t\t\t\tslot.process.kill(\"SIGTERM\");\n\t\t\t}\n\t\t}\n\t\tthis.slots.clear();\n\t\tthis.queue = [];\n\n\t\tfor (const [task_id, waiter] of this.waiters) {\n\t\t\twaiter.reject(new Error(\"SubagentPool disposed\"));\n\t\t\tthis.waiters.delete(task_id);\n\t\t}\n\t\tthis.completed.clear();\n\t\tfor (const budget of this.budgets.values()) {\n\t\t\tbudget.removeAllListeners();\n\t\t}\n\t\tthis.budgets.clear();\n\t\tthis.killReasons.clear();\n\t\tthis.taskStatus.clear();\n\t\tthis.lifeguard.dispose();\n\t\tthis.removeAllListeners();\n\t}\n\n\t/** Pull tasks from the queue while slots are available. */\n\tprivate pull(): void {\n\t\twhile (this.slots.size < this.maxConcurrency && this.queue.length > 0) {\n\t\t\tconst task = this.queue.shift()!;\n\t\t\tthis.startTask(task, false);\n\t\t}\n\t}\n\n\t/** Build CLI arguments for a task. */\n\tprivate buildArgs(task: SubagentPoolTask): string[] {\n\t\t// Persist the child's session so a finished/interrupted subagent can be\n\t\t// resumed later (see resume()). SessionManager.open() creates the file on\n\t\t// first run and continues it on subsequent runs.\n\t\tconst sessionFile = task.sessionFile ?? this.getSessionFile(task.task_id, task.cwd ?? this.cwd);\n\t\tconst args: string[] = [\n\t\t\t...this.prefixArgs,\n\t\t\t\"--mode\",\n\t\t\t\"json\",\n\t\t\t\"--session\",\n\t\t\tsessionFile,\n\t\t\t\"--task-id\",\n\t\t\ttask.task_id,\n\t\t];\n\n\t\t// Prefer the data-driven agent definition from the registry; fall back to the\n\t\t// built-in mode prompt/allowlist for legacy modes not present in the registry.\n\t\tconst def = task.agent_type ? this.getRegistry().get(task.agent_type) : undefined;\n\n\t\tif (task.agent_type) {\n\t\t\tconst systemPrompt = def?.prompt;\n\t\t\tif (systemPrompt) {\n\t\t\t\targs.push(\"--system-prompt\", systemPrompt);\n\t\t\t}\n\t\t\t// Tool allowlist comes from the agent definition's frontmatter `tools`\n\t\t\t// field (read-only built-ins declare their own sandbox). When omitted, no\n\t\t\t// --tools is passed and the subagent inherits all parent tools.\n\t\t\tconst tools = def?.tools;\n\t\t\tif (tools && tools.length > 0) {\n\t\t\t\targs.push(\"--tools\", tools.join(\",\"));\n\t\t\t}\n\t\t}\n\n\t\t// Model precedence: a definition's explicit model wins (unless it is the\n\t\t// `inherit` sentinel), otherwise use the caller-provided model. Built-in\n\t\t// agents can retry with the inherited model when their preferred model is\n\t\t// unavailable or quota-limited.\n\t\tconst explicitModel =\n\t\t\t!task.useInheritedModelFallback && def?.model && def.model !== MODEL_INHERIT ? def.model : undefined;\n\t\tconst modelToUse = explicitModel ?? task.model;\n\t\tif (modelToUse) {\n\t\t\targs.push(\"--model\", modelToUse);\n\t\t}\n\t\tif (task.provider) {\n\t\t\targs.push(\"--provider\", task.provider);\n\t\t}\n\n\t\t// Always give subagents a hard turn cap. With the token budget now advisory\n\t\t// (warn-only), this is the guaranteed hard stop for a runaway subagent.\n\t\tconst maxTurns = def?.maxTurns && def.maxTurns > 0 ? def.maxTurns : DEFAULT_SUBAGENT_MAX_TURNS;\n\t\targs.push(\"--max-turns\", String(maxTurns));\n\n\t\t// Forward non-default skill paths so the subagent has access to all parent skills.\n\t\t// Standard discovery locations (~/.hoocode/, .hoocode/, .claude/) are found automatically.\n\t\tfor (const skillPath of this.skillPaths) {\n\t\t\targs.push(\"--skill\", skillPath);\n\t\t}\n\n\t\tconst prompt = task.context?.trim()\n\t\t\t? `Context from the calling agent:\\n\\n${task.context.trim()}\\n\\nTask: ${task.task.trim()}`\n\t\t\t: `Task: ${task.task.trim()}`;\n\t\targs.push(prompt);\n\n\t\treturn args;\n\t}\n\n\t/** Start a task in a child process, with one retry on failure. */\n\tprivate startTask(task: SubagentPoolTask, isRetry: boolean): void {\n\t\t// Get or create a TokenBudget tracker. On retry, reuse the existing one\n\t\t// so cumulative usage persists across retries.\n\t\tlet budget = this.budgets.get(task.task_id);\n\t\tif (!budget) {\n\t\t\tbudget = new TokenBudget(task.task_id, task.agent_type, {\n\t\t\t\tlimit: task.token_budget,\n\t\t\t\tcwd: task.cwd ?? this.cwd,\n\t\t\t});\n\t\t\tbudget.on(\"budget_warning\", (data: { task_id: string; message: string; used: number; limit: number }) => {\n\t\t\t\tthis.emit(\"budget_warning\", data);\n\t\t\t});\n\t\t\t// The token budget is advisory: surface telemetry but never kill. The\n\t\t\t// guaranteed hard stop is the per-subagent turn cap (--max-turns); see\n\t\t\t// DEFAULT_SUBAGENT_MAX_TURNS.\n\t\t\tbudget.on(\"budget_exceeded\", (data: { task_id: string; used: number; limit: number }) => {\n\t\t\t\tthis.emit(\"budget_exceeded\", data);\n\t\t\t});\n\t\t\tthis.budgets.set(task.task_id, budget);\n\t\t}\n\n\t\tlet proc: ReturnType<typeof spawn>;\n\t\ttry {\n\t\t\tproc = spawn(this.executable, this.buildArgs(task), {\n\t\t\t\tcwd: task.cwd ?? this.cwd,\n\t\t\t\t// Mark the child as a subagent so its own DispatchEvaluator refuses to\n\t\t\t\t// spawn further subagents (depth guard).\n\t\t\t\tenv: { ...this.env, HOOCODE_SUBAGENT_DEPTH: \"1\" },\n\t\t\t\tshell: false,\n\t\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t\t});\n\t\t} catch {\n\t\t\tif (!isRetry) {\n\t\t\t\tthis.startTask(task, true);\n\t\t\t} else {\n\t\t\t\tthis.emit(\"task_failed\", {\n\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\terror: \"Spawn failed synchronously\",\n\t\t\t\t});\n\t\t\t\tthis.resolveWaiter(task.task_id, {\n\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\tok: false,\n\t\t\t\t\tstdout: \"\",\n\t\t\t\t\tstderr: \"\",\n\t\t\t\t\texit_code: null,\n\t\t\t\t\terror: \"Spawn failed synchronously\",\n\t\t\t\t\tstatus: \"failed\",\n\t\t\t\t});\n\t\t\t\tthis.pull();\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tconst slot: SubagentSlot = {\n\t\t\tpid: proc.pid ?? 0,\n\t\t\tagent_type: task.agent_type,\n\t\t\ttask_id: task.task_id,\n\t\t\tspawned_at: Date.now(),\n\t\t\ttoken_budget: task.token_budget ?? this.defaultTokenBudget,\n\t\t\tprocess: proc,\n\t\t};\n\n\t\tthis.slots.set(task.task_id, slot);\n\t\tthis.lifeguard.monitor(task.task_id, task.agent_type, proc);\n\n\t\tlet stdout = \"\";\n\t\tlet stderr = \"\";\n\n\t\tproc.stdout?.on(\"data\", (data: Buffer) => {\n\t\t\tconst chunk = data.toString();\n\t\t\tstdout += chunk;\n\t\t\tbudget.processStdout(chunk);\n\n\t\t\t// Any output proves the child is alive and working, so treat it as a\n\t\t\t// heartbeat. The dedicated {\"ping\":true} line below still matters for\n\t\t\t// quiet phases (e.g. a long single model turn that emits nothing), but\n\t\t\t// relying on it alone falsely reaps subagents that are busily streaming\n\t\t\t// events while the parent's event loop is starved by concurrent load.\n\t\t\tthis.lifeguard.recordHeartbeat(task.task_id);\n\n\t\t\t// Heartbeat detection: look for {\"ping\":true} JSON lines\n\t\t\tfor (const raw of chunk.split(\"\\n\")) {\n\t\t\t\tconst line = raw.trim();\n\t\t\t\tif (!line.startsWith(\"{\")) continue;\n\t\t\t\ttry {\n\t\t\t\t\tconst parsed = JSON.parse(line) as Record<string, unknown>;\n\t\t\t\t\tif (parsed.ping === true) {\n\t\t\t\t\t\tthis.lifeguard.recordHeartbeat(task.task_id);\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// Not a ping line, ignore\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t\tproc.stderr?.on(\"data\", (data: Buffer) => {\n\t\t\tstderr += data.toString();\n\t\t});\n\n\t\twaitForChildProcess(proc)\n\t\t\t.then((code) => {\n\t\t\t\tthis.slots.delete(task.task_id);\n\t\t\t\tbudget.flush();\n\n\t\t\t\tconst killReason = this.killReasons.get(task.task_id);\n\t\t\t\tthis.killReasons.delete(task.task_id);\n\n\t\t\t\tconst duration = Date.now() - slot.spawned_at;\n\t\t\t\tconst tokens_used = budget.getUsed();\n\t\t\t\tconst budgetExceeded = budget.isExceeded();\n\n\t\t\t\t// A subagent's success is defined by a valid, verified result.json, not by\n\t\t\t\t// its exit code. A child that finished its work and wrote a valid result can\n\t\t\t\t// still be SIGKILLed by the lifeguard before it exits on its own (lingering\n\t\t\t\t// open handles delay a natural exit past the heartbeat threshold), which forces\n\t\t\t\t// exit_code === null. Keying completion off the verified result, not code === 0,\n\t\t\t\t// honors that genuine success instead of discarding it as a false stall.\n\t\t\t\tconst verification = this.verifier.verify(task.task_id, task.cwd ?? this.cwd);\n\t\t\t\t// A well-formed result.json counts as clean completion unless its own\n\t\t\t\t// status field declares failure (e.g. \"failed\" from a provider quota\n\t\t\t\t// error). Without this check the pool would treat a child that wrote a\n\t\t\t\t// valid-but-failed result.json and exited non-zero as a success.\n\t\t\t\tlet cleanlyCompleted = code === 0 || verification.valid;\n\t\t\t\tif (cleanlyCompleted && verification.valid) {\n\t\t\t\t\tconst rd = this.tryReadResultJson(task.task_id, task.cwd ?? this.cwd);\n\t\t\t\t\tif (rd && (rd as Record<string, unknown>).status === \"failed\") {\n\t\t\t\t\t\tcleanlyCompleted = false;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// If killed by lifeguard before producing a valid result, honor the kill.\n\t\t\t\tif ((killReason === \"stalled\" || killReason === \"timeout\") && !verification.valid) {\n\t\t\t\t\tconst result: SubagentResult = {\n\t\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\t\tok: false,\n\t\t\t\t\t\tstdout,\n\t\t\t\t\t\tstderr,\n\t\t\t\t\t\texit_code: code,\n\t\t\t\t\t\tstatus: killReason,\n\t\t\t\t\t};\n\t\t\t\t\tthis.writeOutputJson(task.task_id, result);\n\t\t\t\t\tthis.emit(`task_${killReason}`, {\n\t\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\t\tagent_type: task.agent_type,\n\t\t\t\t\t\tduration,\n\t\t\t\t\t\ttokens_used,\n\t\t\t\t\t});\n\t\t\t\t\tthis.resolveWaiter(task.task_id, result);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst result: SubagentResult = {\n\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\tok: cleanlyCompleted,\n\t\t\t\t\tstdout,\n\t\t\t\t\tstderr,\n\t\t\t\t\texit_code: code,\n\t\t\t\t\t// Advisory telemetry only: exceeding the budget never fails the task.\n\t\t\t\t\tbudget_exceeded: budgetExceeded,\n\t\t\t\t\tstatus: cleanlyCompleted ? \"complete\" : \"failed\",\n\t\t\t\t\tusedInheritedModelFallback: task.useInheritedModelFallback === true,\n\t\t\t\t};\n\n\t\t\t\tif (result.ok) {\n\t\t\t\t\tif (!verification.valid) {\n\t\t\t\t\t\tresult.ok = false;\n\t\t\t\t\t\tresult.error = verification.reason;\n\t\t\t\t\t\tresult.status = \"failed\";\n\t\t\t\t\t\tthis.writeOutputJson(task.task_id, result);\n\t\t\t\t\t\tthis.emit(\"task_failed\", {\n\t\t\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\t\t\tagent_type: task.agent_type,\n\t\t\t\t\t\t\tduration,\n\t\t\t\t\t\t\ttokens_used,\n\t\t\t\t\t\t\terror: verification.reason,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tthis.resolveWaiter(task.task_id, result);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\t// Attach the verified result.json so callers can read the summary\n\t\t\t\t\t// without parsing the raw event stream.\n\t\t\t\t\tresult.result_data = this.tryReadResultJson(task.task_id, task.cwd ?? this.cwd);\n\n\t\t\t\t\t// Clean success: discard the per-task dispatch dir entirely\n\t\t\t\t\t// (session.jsonl, result.json, dispatch-log.json, budget.json). The\n\t\t\t\t\t// in-memory result already carries result_data, so callers lose\n\t\t\t\t\t// nothing. Trade-off: resume() only works for non-successful tasks.\n\t\t\t\t\tthis.cleanupDispatchDir(task.task_id, task.cwd ?? this.cwd);\n\n\t\t\t\t\tthis.emit(\"task_done\", {\n\t\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\t\tagent_type: task.agent_type,\n\t\t\t\t\t\tduration,\n\t\t\t\t\t\ttokens_used,\n\t\t\t\t\t\tstatus: \"complete\",\n\t\t\t\t\t});\n\t\t\t\t\tthis.resolveWaiter(task.task_id, result);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// Failure path: keep the dispatch dir for debugging and persist output.\n\t\t\t\t// Attach the child's result.json (if any) and derive a concrete failure\n\t\t\t\t// reason so callers see the real cause (e.g. a provider usage/quota\n\t\t\t\t// error) instead of a generic \"subagent failed\".\n\t\t\t\tresult.result_data = this.tryReadResultJson(task.task_id, task.cwd ?? this.cwd);\n\t\t\t\tif (!result.error) {\n\t\t\t\t\tresult.error = this.deriveFailureReason(result);\n\t\t\t\t}\n\t\t\t\tif (this.shouldRetryWithInheritedModel(task, result)) {\n\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t`[DISPATCH] agent=${task.agent_type} task_id=${task.task_id} preferred model failed; retrying with inherited model`,\n\t\t\t\t\t);\n\t\t\t\t\tthis.cleanupRetryArtifacts(task);\n\t\t\t\t\tthis.queue.unshift({ ...task, useInheritedModelFallback: true });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.writeOutputJson(task.task_id, result);\n\t\t\t\tthis.emit(\"task_failed\", {\n\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\tagent_type: task.agent_type,\n\t\t\t\t\tduration,\n\t\t\t\t\ttokens_used,\n\t\t\t\t\terror: result.error ?? `Exited with code ${code}`,\n\t\t\t\t});\n\t\t\t\tthis.resolveWaiter(task.task_id, result);\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\tthis.slots.delete(task.task_id);\n\t\t\t\tbudget.flush();\n\t\t\t\tconst duration = Date.now() - slot.spawned_at;\n\t\t\t\tconst tokens_used = budget.getUsed();\n\t\t\t\tif (!isRetry) {\n\t\t\t\t\tthis.startTask(task, true);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst error = err instanceof Error ? err.message : String(err);\n\t\t\t\tconst result: SubagentResult = {\n\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\tok: false,\n\t\t\t\t\tstdout,\n\t\t\t\t\tstderr,\n\t\t\t\t\texit_code: null,\n\t\t\t\t\terror,\n\t\t\t\t\tstatus: \"failed\",\n\t\t\t\t\tusedInheritedModelFallback: task.useInheritedModelFallback === true,\n\t\t\t\t};\n\t\t\t\tthis.writeOutputJson(task.task_id, result);\n\t\t\t\tthis.emit(\"task_failed\", {\n\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\tagent_type: task.agent_type,\n\t\t\t\t\tduration,\n\t\t\t\t\ttokens_used,\n\t\t\t\t\terror,\n\t\t\t\t});\n\t\t\t\tthis.resolveWaiter(task.task_id, result);\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tbudget.removeAllListeners();\n\t\t\t\tthis.budgets.delete(task.task_id);\n\t\t\t\tthis.pull();\n\t\t\t});\n\t}\n\n\t/** Whether a failed built-in subagent should be retried with `model: inherit`. */\n\tprivate shouldRetryWithInheritedModel(task: SubagentPoolTask, result: SubagentResult): boolean {\n\t\tif (task.useInheritedModelFallback) return false;\n\t\tif (task.sessionFile) return false;\n\t\t// Only the parent model is required: the provider may be unset when the\n\t\t// harness routes through a gateway. The retry inherits the parent model and\n\t\t// lets the child resolve the provider from its own default when none was threaded through.\n\t\tif (!task.model) return false;\n\n\t\tconst def = task.agent_type ? this.getRegistry().get(task.agent_type) : undefined;\n\t\t// Built-in agents always inherit; project agents may pin an explicit model in\n\t\t// frontmatter, so let them fall back too when that model is rejected.\n\t\tif (def?.source !== \"builtin\" && def?.source !== \"project\") return false;\n\t\tif (!def.model || def.model === MODEL_INHERIT) return false;\n\n\t\treturn this.isInheritedModelFallbackError(result);\n\t}\n\n\t/** Detect provider/model failures where inheriting the parent model can recover. */\n\tprivate isInheritedModelFallbackError(result: SubagentResult): boolean {\n\t\tconst text = [result.error, result.stderr, JSON.stringify(result.result_data ?? {})]\n\t\t\t.filter((part): part is string => typeof part === \"string\" && part.length > 0)\n\t\t\t.join(\"\\n\");\n\n\t\treturn /usage[_\\s-]?limit|subscription|quota|rate.?limit|too many requests|429|insufficient|out of credit|credit balance|billing|payment required|402|model[^\\n]*(not found|unavailable|not available|not supported|does not exist|invalid|unsupported)|no api key|no auth configured|authentication|unauthorized|forbidden|permission/i.test(\n\t\t\ttext,\n\t\t);\n\t}\n\n\t/** Remove failed attempt artifacts before rerunning the same task id. */\n\tprivate cleanupRetryArtifacts(task: SubagentPoolTask): void {\n\t\tconst cwd = task.cwd ?? this.cwd;\n\t\tconst taskDir = getDispatchTaskDir(cwd, task.task_id);\n\t\tconst sessionFile = task.sessionFile ?? this.getSessionFile(task.task_id, cwd);\n\t\ttry {\n\t\t\trmSync(sessionFile, { force: true });\n\t\t\trmSync(join(taskDir, \"result.json\"), { force: true });\n\t\t\trmSync(join(taskDir, \"output.json\"), { force: true });\n\t\t} catch {\n\t\t\t// Best-effort cleanup; retry can still proceed with existing artifacts.\n\t\t}\n\t}\n\n\t/**\n\t * Best-effort concrete failure reason for a non-zero-exit subagent. Prefers\n\t * the child's result.json summary (which carries the provider/model error\n\t * message on failure), then the tail of stderr, then the exit code.\n\t */\n\tprivate deriveFailureReason(result: SubagentResult): string {\n\t\tconst summary = (result.result_data as { summary?: string } | undefined)?.summary?.trim();\n\t\tif (summary) {\n\t\t\treturn summary;\n\t\t}\n\t\tconst stderrTail = result.stderr\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => line.trim())\n\t\t\t.filter((line) => line.length > 0)\n\t\t\t.slice(-5)\n\t\t\t.join(\"\\n\");\n\t\tif (stderrTail) {\n\t\t\treturn stderrTail;\n\t\t}\n\t\treturn `Exited with code ${result.exit_code}`;\n\t}\n\n\tprivate tryReadResultJson(task_id: string, cwd: string): Record<string, unknown> | undefined {\n\t\tconst path = join(getDispatchTaskDir(cwd, task_id), \"result.json\");\n\t\tif (!existsSync(path)) return undefined;\n\t\ttry {\n\t\t\tconst raw = readFileSync(path, \"utf-8\");\n\t\t\treturn JSON.parse(raw) as Record<string, unknown>;\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tprivate resolveWaiter(task_id: string, result: SubagentResult): void {\n\t\t// Persist terminal status for get_status() even after wait_for consumes the result\n\t\tif (result.status === \"stalled\") this.taskStatus.set(task_id, \"stalled\");\n\t\telse if (result.status === \"timeout\") this.taskStatus.set(task_id, \"timeout\");\n\t\telse if (result.ok) this.taskStatus.set(task_id, \"done\");\n\t\telse this.taskStatus.set(task_id, \"failed\");\n\n\t\tconst waiter = this.waiters.get(task_id);\n\t\tif (waiter) {\n\t\t\twaiter.resolve(result);\n\t\t\tthis.waiters.delete(task_id);\n\t\t\treturn;\n\t\t}\n\t\tthis.completed.set(task_id, result);\n\t}\n}\n"]}
1
+ {"version":3,"file":"subagent-pool.d.ts","sourceRoot":"","sources":["../../src/core/subagent-pool.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn } from \"node:child_process\";\nimport { EventEmitter } from \"node:events\";\nimport { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { getDispatchTaskDir } from \"../config.js\";\nimport { waitForChildProcess } from \"../utils/child-process.js\";\nimport { MODEL_INHERIT } from \"./agent-frontmatter.js\";\nimport { type AgentRegistry, loadAgentRegistry } from \"./agent-registry.js\";\nimport { DispatchEvaluator } from \"./dispatch-evaluator.js\";\nimport { SubagentLifeguard } from \"./lifeguard.js\";\nimport { OutputVerifier } from \"./output-verifier.js\";\nimport { currentSubagentDepth, resolveMaxSubagentDepth, SUBAGENT_DEPTH_ENV } from \"./subagent-depth.js\";\nimport { TokenBudget } from \"./token-budget.js\";\n\nexport interface SubagentPoolTask {\n\ttask_id: string;\n\tagent_type: string;\n\ttask: string;\n\tcontext?: string;\n\ttoken_budget?: number;\n\tcwd?: string;\n\tmodel?: string;\n\tprovider?: string;\n\t/**\n\t * Explicit session file for the child to persist/continue. When omitted the\n\t * child uses its own dispatch dir (`<dispatch>/<task_id>/session.jsonl`).\n\t * Resume reuses the original task's session file to continue the transcript.\n\t */\n\tsessionFile?: string;\n\t/** Internal: retry using the caller's model when a built-in agent's preferred model fails. */\n\tuseInheritedModelFallback?: boolean;\n}\n\nexport interface SubagentSlot {\n\tpid: number;\n\tagent_type: string;\n\ttask_id: string;\n\tspawned_at: number;\n\ttoken_budget: number;\n\tprocess: ReturnType<typeof spawn>;\n}\n\nexport interface SubagentResult {\n\ttask_id: string;\n\tok: boolean;\n\tstdout: string;\n\tstderr: string;\n\texit_code: number | null;\n\terror?: string;\n\t/** True when the task exceeded its token budget and was hard-stopped. */\n\tbudget_exceeded?: boolean;\n\t/** Terminal status derived from how the task finished. */\n\tstatus?: \"complete\" | \"partial\" | \"failed\" | \"stalled\" | \"timeout\";\n\t/** Parsed result.json content when available (e.g. on partial completion). */\n\tresult_data?: Record<string, unknown>;\n\t/** True when this run used the inherited-model fallback (preferred model failed first). */\n\tusedInheritedModelFallback?: boolean;\n}\n\nexport interface TaskResult {\n\t/** True when the evaluator decided the task is simple enough for inline handling. */\n\thandled_inline: boolean;\n\t/** Present when the task was delegated. */\n\ttask_id?: string;\n\tagent_type?: string;\n\treason?: string;\n\t/** Subagent result when delegated. */\n\tresult?: SubagentResult;\n\t/** Duration in milliseconds when delegated. */\n\tduration?: number;\n}\n\nexport interface DispatchOptions {\n\t/** Skip evaluation and force this agent type (user/explicit override).\n\t * Accepts any registry-defined agent name, not just the built-in modes. */\n\tforceAgent?: string;\n\t/** Context distilled from the calling agent, passed to the subagent. */\n\tcontext?: string;\n\t/** Model id for the subagent (defaults to the child's configured default). */\n\tmodel?: string;\n\t/** Provider for the subagent. */\n\tprovider?: string;\n\t/** Explicit session file to persist/continue (used by resume). */\n\tsessionFile?: string;\n}\n\nexport interface SubagentPoolOptions {\n\t/** Path to the hoocode executable (or the runtime, e.g. node, when prefixArgs is set). */\n\texecutable: string;\n\t/** Args inserted before task args (e.g. the CLI entry script for node/tsx). */\n\tprefixArgs?: string[];\n\t/** Maximum concurrent child processes. Defaults to 5. */\n\tmaxConcurrency?: number;\n\t/** Working directory for spawned processes. Defaults to process.cwd(). */\n\tcwd?: string;\n\t/** Environment variables. Defaults to process.env. */\n\tenv?: NodeJS.ProcessEnv;\n\t/** Default token budget per task. Defaults to 0. */\n\tdefaultTokenBudget?: number;\n\t/**\n\t * Non-default skill paths to forward to every spawned subagent via --skill.\n\t * Subagents auto-discover skills from standard locations; only paths that\n\t * won't be found by default discovery need to be forwarded here.\n\t */\n\tskillPaths?: string[];\n}\n\n/**\n * Default hard cap on assistant turns for a spawned subagent when its definition\n * does not set `maxTurns`. The token budget is advisory (it warns but never\n * kills), so this turn cap is the guaranteed hard stop for every subagent.\n */\nexport const DEFAULT_SUBAGENT_MAX_TURNS = 50;\n\n/**\n * Pool for running hoocode subagents as child processes with bounded concurrency,\n * FIFO queuing with priority support, and automatic slot refill.\n *\n * Events:\n * - \"task_done\" – task completed successfully and output was verified\n * - \"task_failed\" – task failed (spawn error, bad exit code, verification failure)\n * - \"task_stalled\" – heartbeat missed past the load-scaled threshold (60s base,\n * widened under concurrency/event-loop lag), process SIGKILLed\n * - \"task_timeout\" – hard timeout exceeded, process was SIGKILLed\n * - \"budget_warning\" – token usage crossed 80% threshold (advisory)\n * - \"budget_exceeded\" – token usage crossed 100% threshold (advisory; never kills)\n */\nexport class SubagentPool extends EventEmitter {\n\tprivate readonly maxConcurrency: number;\n\tprivate readonly executable: string;\n\tprivate readonly prefixArgs: string[];\n\tprivate readonly cwd: string;\n\tprivate readonly env: NodeJS.ProcessEnv;\n\tprivate readonly defaultTokenBudget: number;\n\t/** Non-default skill paths forwarded to every spawned subagent via --skill. */\n\tprivate skillPaths: string[];\n\n\tprivate slots = new Map<string, SubagentSlot>();\n\tprivate queue: SubagentPoolTask[] = [];\n\tprivate completed = new Map<string, SubagentResult>();\n\tprivate waiters = new Map<string, { resolve: (result: SubagentResult) => void; reject: (err: Error) => void }>();\n\tprivate budgets = new Map<string, TokenBudget>();\n\tprivate verifier = new OutputVerifier();\n\tprivate lifeguard: SubagentLifeguard;\n\tprivate disposed = false;\n\t/** Lazily-loaded agent registry (frontmatter definitions) for this pool's cwd. */\n\tprivate registry?: AgentRegistry;\n\t/** Tracks why a task was killed (stalled / timeout) before exit handler fires. */\n\tprivate killReasons = new Map<string, \"stalled\" | \"timeout\">();\n\t/** Persistent terminal status map, survives wait_for consumption. */\n\tprivate taskStatus = new Map<string, \"done\" | \"failed\" | \"stalled\" | \"timeout\">();\n\n\tconstructor(options: SubagentPoolOptions) {\n\t\tsuper();\n\t\tthis.maxConcurrency = options.maxConcurrency ?? 5;\n\t\tthis.executable = options.executable;\n\t\tthis.prefixArgs = options.prefixArgs ?? [];\n\t\tthis.cwd = options.cwd ?? process.cwd();\n\t\tthis.env = options.env ?? process.env;\n\t\tthis.defaultTokenBudget = options.defaultTokenBudget ?? 0;\n\t\tthis.skillPaths = options.skillPaths ? [...options.skillPaths] : [];\n\t\tthis.verifier = new OutputVerifier(this.cwd);\n\t\tthis.lifeguard = new SubagentLifeguard(this.cwd);\n\t\tthis.lifeguard.on(\"stalled\", (data: { task_id: string; pid: number }) => {\n\t\t\tthis.killReasons.set(data.task_id, \"stalled\");\n\t\t\tthis.emit(\"task_stalled\", data);\n\t\t});\n\t\tthis.lifeguard.on(\"timeout\", (data: { task_id: string; pid: number }) => {\n\t\t\tthis.killReasons.set(data.task_id, \"timeout\");\n\t\t\tthis.emit(\"task_timeout\", data);\n\t\t});\n\t}\n\n\t/** Update the non-default skill paths forwarded to new subagents. */\n\tupdateSkillPaths(paths: string[]): void {\n\t\tthis.skillPaths = [...paths];\n\t}\n\n\t/**\n\t * Report external in-process load (e.g. the number of background MCP tools\n\t * currently executing in the parent) to the lifeguard. This widens its\n\t * heartbeat/timeout tolerance so monitored subagents aren't false-positive\n\t * reaped when the parent's event loop is busy with concurrent background work.\n\t */\n\tsetExternalLoad(count: number): void {\n\t\tthis.lifeguard.setExternalLoad(count);\n\t}\n\n\t/** Lazily load the agent registry for this pool's cwd. */\n\tprivate getRegistry(): AgentRegistry {\n\t\tif (!this.registry) {\n\t\t\tthis.registry = loadAgentRegistry({ cwd: this.cwd });\n\t\t}\n\t\treturn this.registry;\n\t}\n\n\t/** Priority value: higher numbers run first. */\n\tprivate priorityOf(agent_type: string): number {\n\t\t// Read-only investigation (explore/plan) often unblocks downstream work, so\n\t\t// it runs ahead of other agents.\n\t\treturn agent_type === \"explore\" || agent_type === \"plan\" ? 2 : 1;\n\t}\n\n\t/** Queue a task. It will run when a slot is free. */\n\tspawn(task: SubagentPoolTask): void {\n\t\tif (this.disposed) {\n\t\t\tthrow new Error(\"SubagentPool has been disposed\");\n\t\t}\n\t\tif (\n\t\t\tthis.slots.has(task.task_id) ||\n\t\t\tthis.queue.some((t) => t.task_id === task.task_id) ||\n\t\t\tthis.completed.has(task.task_id)\n\t\t) {\n\t\t\tthrow new Error(`Duplicate task_id: ${task.task_id}`);\n\t\t}\n\n\t\tconst p = this.priorityOf(task.agent_type);\n\t\tconst idx = this.queue.findIndex((t) => this.priorityOf(t.agent_type) < p);\n\t\tif (idx === -1) {\n\t\t\tthis.queue.push(task);\n\t\t} else {\n\t\t\tthis.queue.splice(idx, 0, task);\n\t\t}\n\t\tthis.pull();\n\t}\n\n\t/** Current status of a task. */\n\tget_status(task_id: string): \"running\" | \"queued\" | \"done\" | \"failed\" | \"stalled\" | \"timeout\" {\n\t\tif (this.slots.has(task_id)) return \"running\";\n\t\tif (this.queue.some((t) => t.task_id === task_id)) return \"queued\";\n\t\tconst persisted = this.taskStatus.get(task_id);\n\t\tif (persisted) return persisted;\n\t\tconst result = this.completed.get(task_id);\n\t\tif (result) {\n\t\t\tif (result.status === \"stalled\") return \"stalled\";\n\t\t\tif (result.status === \"timeout\") return \"timeout\";\n\t\t\tif (result.ok) return \"done\";\n\t\t\treturn \"failed\";\n\t\t}\n\t\treturn \"failed\";\n\t}\n\n\t/** Wait for a task to complete and return its result. */\n\twait_for(task_id: string): Promise<SubagentResult> {\n\t\tif (this.disposed) {\n\t\t\treturn Promise.reject(new Error(\"SubagentPool has been disposed\"));\n\t\t}\n\n\t\tconst existing = this.completed.get(task_id);\n\t\tif (existing) {\n\t\t\tthis.completed.delete(task_id);\n\t\t\treturn Promise.resolve(existing);\n\t\t}\n\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tthis.waiters.set(task_id, { resolve, reject });\n\t\t});\n\t}\n\n\t/** Number of currently running subagents. */\n\trunning_count(): number {\n\t\treturn this.slots.size;\n\t}\n\n\t/** Number of tasks waiting in the queue. */\n\tqueued_count(): number {\n\t\treturn this.queue.length;\n\t}\n\n\t/**\n\t * Dispatch a task through the evaluator.\n\t *\n\t * - If `options.forceAgent` is provided, skip evaluation and spawn directly.\n\t * - Otherwise evaluate the task. If it should be handled inline, return\n\t * `{ handled_inline: true }` immediately.\n\t * - If delegating, spawn the subagent, wait for completion, write\n\t * `output.json`, and return the result.\n\t */\n\tasync dispatch(task: string, options: DispatchOptions = {}): Promise<TaskResult> {\n\t\tif (this.disposed) {\n\t\t\treturn Promise.reject(new Error(\"SubagentPool has been disposed\"));\n\t\t}\n\t\tconst begin = this.beginDispatch(task, options);\n\t\tif (begin.handled_inline) {\n\t\t\treturn { handled_inline: true, reason: begin.reason };\n\t\t}\n\t\tconst result = await this.wait_for(begin.task_id);\n\t\treturn {\n\t\t\thandled_inline: false,\n\t\t\ttask_id: begin.task_id,\n\t\t\tagent_type: begin.agent_type,\n\t\t\treason: begin.reason,\n\t\t\tresult,\n\t\t\tduration: Date.now() - begin.startTime,\n\t\t};\n\t}\n\n\t/**\n\t * Fire-and-forget dispatch for background agents. Spawns the subagent and\n\t * returns its handle immediately; the caller polls get_status()/collect().\n\t */\n\tdispatchDetached(\n\t\ttask: string,\n\t\toptions: DispatchOptions = {},\n\t): { handled_inline: boolean; task_id?: string; agent_type?: string; reason?: string } {\n\t\tif (this.disposed) {\n\t\t\tthrow new Error(\"SubagentPool has been disposed\");\n\t\t}\n\t\tconst begin = this.beginDispatch(task, options);\n\t\tif (begin.handled_inline) {\n\t\t\treturn { handled_inline: true, reason: begin.reason };\n\t\t}\n\t\treturn { handled_inline: false, task_id: begin.task_id, agent_type: begin.agent_type, reason: begin.reason };\n\t}\n\n\t/**\n\t * Evaluate, log, and spawn a task without waiting. Shared by dispatch()\n\t * (blocking) and dispatchDetached() (background).\n\t */\n\tprivate beginDispatch(\n\t\ttask: string,\n\t\toptions: DispatchOptions,\n\t):\n\t\t| { handled_inline: true; reason?: string }\n\t\t| { handled_inline: false; task_id: string; agent_type: string; reason?: string; startTime: number } {\n\t\tconst { forceAgent, context, model, provider, sessionFile } = options;\n\t\tconst evaluator = new DispatchEvaluator();\n\t\tconst analysis = evaluator.evaluate(task);\n\n\t\tif (!forceAgent && !analysis.should_delegate) {\n\t\t\treturn { handled_inline: true, reason: analysis.reason };\n\t\t}\n\n\t\tconst agent_type = forceAgent ?? \"general-purpose\";\n\t\tconst task_id = `dispatch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\n\t\tconst reason = forceAgent ? \"user_override\" : analysis.reason;\n\t\tconst complexity = analysis.estimated_complexity;\n\t\t// Depth of the child about to be spawned (this process's depth + 1). Surfaced\n\t\t// so a delegation tree's nesting is visible in logs without extra tooling.\n\t\tconst childDepth = currentSubagentDepth(this.env) + 1;\n\n\t\t// Pre-dispatch logging. Use stderr: stdout is reserved for the JSON event\n\t\t// stream / TUI render and must not be polluted.\n\t\tconsole.error(\n\t\t\t`[DISPATCH] agent=${agent_type} depth=${childDepth} reason=${reason} complexity=${complexity} task_id=${task_id}`,\n\t\t);\n\t\tthis.writeDispatchLog(task_id, agent_type, reason, complexity, task, childDepth);\n\n\t\tconst poolTask: SubagentPoolTask = {\n\t\t\ttask_id,\n\t\t\tagent_type,\n\t\t\ttask,\n\t\t\tcontext,\n\t\t\tmodel,\n\t\t\tprovider,\n\t\t\tsessionFile,\n\t\t\tcwd: this.cwd,\n\t\t};\n\t\tconst startTime = Date.now();\n\t\tthis.spawn(poolTask);\n\t\treturn { handled_inline: false, task_id, agent_type, reason, startTime };\n\t}\n\n\t/**\n\t * Non-destructively read a completed task's result (for background polling).\n\t * Returns undefined while the task is still running/queued, or if its result\n\t * was already consumed via wait_for().\n\t */\n\tcollect(task_id: string): SubagentResult | undefined {\n\t\treturn this.completed.get(task_id);\n\t}\n\n\t/** Absolute path of the persisted session file for a task. */\n\tgetSessionFile(task_id: string, cwd: string = this.cwd): string {\n\t\treturn join(getDispatchTaskDir(cwd, task_id), \"session.jsonl\");\n\t}\n\n\t/**\n\t * Resume a previously dispatched subagent, continuing its persisted session\n\t * with a follow-up prompt. Recovers the original agent type from its dispatch\n\t * log. Rejects if no resumable session exists for the task.\n\t */\n\tasync resume(\n\t\ttask_id: string,\n\t\tprompt: string,\n\t\toptions: Omit<DispatchOptions, \"forceAgent\" | \"sessionFile\"> = {},\n\t): Promise<TaskResult> {\n\t\tif (this.disposed) {\n\t\t\treturn Promise.reject(new Error(\"SubagentPool has been disposed\"));\n\t\t}\n\t\tconst sessionFile = this.getSessionFile(task_id);\n\t\tif (!existsSync(sessionFile)) {\n\t\t\treturn Promise.reject(new Error(`No resumable session for task \"${task_id}\" (expected ${sessionFile}).`));\n\t\t}\n\t\tconst agent_type = this.readDispatchAgentType(task_id) ?? \"general-purpose\";\n\t\treturn this.dispatch(prompt, { ...options, forceAgent: agent_type, sessionFile });\n\t}\n\n\t/** Recover the agent type a task was dispatched with, from its dispatch log. */\n\tprivate readDispatchAgentType(task_id: string): string | undefined {\n\t\tconst path = join(getDispatchTaskDir(this.cwd, task_id), \"dispatch-log.json\");\n\t\tif (!existsSync(path)) return undefined;\n\t\ttry {\n\t\t\tconst parsed = JSON.parse(readFileSync(path, \"utf-8\")) as { agent_type?: string };\n\t\t\treturn typeof parsed.agent_type === \"string\" ? parsed.agent_type : undefined;\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tprivate writeDispatchLog(\n\t\ttask_id: string,\n\t\tagent_type: string,\n\t\treason: string,\n\t\tcomplexity: string,\n\t\ttask: string,\n\t\tdepth: number,\n\t): void {\n\t\tconst log = {\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\ttask_id,\n\t\t\tagent_type,\n\t\t\tdepth,\n\t\t\treason,\n\t\t\tcomplexity,\n\t\t\ttask,\n\t\t};\n\t\tconst path = join(getDispatchTaskDir(this.cwd, task_id), \"dispatch-log.json\");\n\t\ttry {\n\t\t\tmkdirSync(dirname(path), { recursive: true });\n\t\t\twriteFileSync(path, JSON.stringify(log, null, 2));\n\t\t} catch {\n\t\t\t// Best-effort persistence\n\t\t}\n\t}\n\n\tprivate writeOutputJson(task_id: string, result: SubagentResult): void {\n\t\tconst output = {\n\t\t\ttask_id: result.task_id,\n\t\t\tok: result.ok,\n\t\t\texit_code: result.exit_code,\n\t\t\tstatus: result.status,\n\t\t\tstdout: result.stdout,\n\t\t\tstderr: result.stderr,\n\t\t\terror: result.error,\n\t\t\tbudget_exceeded: result.budget_exceeded,\n\t\t\tresult_data: result.result_data,\n\t\t};\n\t\tconst path = join(getDispatchTaskDir(this.cwd, task_id), \"output.json\");\n\t\ttry {\n\t\t\tmkdirSync(dirname(path), { recursive: true });\n\t\t\twriteFileSync(path, JSON.stringify(output, null, 2));\n\t\t} catch {\n\t\t\t// Best-effort persistence\n\t\t}\n\t}\n\n\t/**\n\t * Remove a task's dispatch dir after a clean, verified success. Best-effort:\n\t * a cleanup failure must never fail an otherwise successful task.\n\t */\n\tprivate cleanupDispatchDir(task_id: string, cwd: string): void {\n\t\ttry {\n\t\t\trmSync(getDispatchTaskDir(cwd, task_id), { recursive: true, force: true });\n\t\t} catch {\n\t\t\t// Best-effort cleanup\n\t\t}\n\t}\n\n\t/** Kill all running processes, clear the queue, and reject pending waiters. */\n\tdispose(): void {\n\t\tif (this.disposed) return;\n\t\tthis.disposed = true;\n\n\t\tfor (const slot of this.slots.values()) {\n\t\t\tif (!slot.process.killed) {\n\t\t\t\tslot.process.kill(\"SIGTERM\");\n\t\t\t}\n\t\t}\n\t\tthis.slots.clear();\n\t\tthis.queue = [];\n\n\t\tfor (const [task_id, waiter] of this.waiters) {\n\t\t\twaiter.reject(new Error(\"SubagentPool disposed\"));\n\t\t\tthis.waiters.delete(task_id);\n\t\t}\n\t\tthis.completed.clear();\n\t\tfor (const budget of this.budgets.values()) {\n\t\t\tbudget.removeAllListeners();\n\t\t}\n\t\tthis.budgets.clear();\n\t\tthis.killReasons.clear();\n\t\tthis.taskStatus.clear();\n\t\tthis.lifeguard.dispose();\n\t\tthis.removeAllListeners();\n\t}\n\n\t/** Pull tasks from the queue while slots are available. */\n\tprivate pull(): void {\n\t\twhile (this.slots.size < this.maxConcurrency && this.queue.length > 0) {\n\t\t\tconst task = this.queue.shift()!;\n\t\t\tthis.startTask(task, false);\n\t\t}\n\t}\n\n\t/** Build CLI arguments for a task. */\n\tprivate buildArgs(task: SubagentPoolTask): string[] {\n\t\t// Persist the child's session so a finished/interrupted subagent can be\n\t\t// resumed later (see resume()). SessionManager.open() creates the file on\n\t\t// first run and continues it on subsequent runs.\n\t\tconst sessionFile = task.sessionFile ?? this.getSessionFile(task.task_id, task.cwd ?? this.cwd);\n\t\tconst args: string[] = [\n\t\t\t...this.prefixArgs,\n\t\t\t\"--mode\",\n\t\t\t\"json\",\n\t\t\t\"--session\",\n\t\t\tsessionFile,\n\t\t\t\"--task-id\",\n\t\t\ttask.task_id,\n\t\t];\n\n\t\t// Prefer the data-driven agent definition from the registry; fall back to the\n\t\t// built-in mode prompt/allowlist for legacy modes not present in the registry.\n\t\tconst def = task.agent_type ? this.getRegistry().get(task.agent_type) : undefined;\n\n\t\tif (task.agent_type) {\n\t\t\tconst systemPrompt = def?.prompt;\n\t\t\tif (systemPrompt) {\n\t\t\t\targs.push(\"--system-prompt\", systemPrompt);\n\t\t\t}\n\n\t\t\t// A `delegate: true` agent may itself dispatch via the Task tool, but only\n\t\t\t// while the child it becomes can still nest (childDepth < cap) — so the\n\t\t\t// deepest permitted level cannot delegate further. Gating here keeps the\n\t\t\t// authorization explicit and bounded by the same cap as the depth guard.\n\t\t\tconst childDepth = currentSubagentDepth(this.env) + 1;\n\t\t\tconst canChildDelegate = def?.delegate === true && childDepth < resolveMaxSubagentDepth(undefined, this.env);\n\n\t\t\t// Tool allowlist comes from the agent definition's frontmatter `tools`\n\t\t\t// field (read-only built-ins declare their own sandbox). When omitted, no\n\t\t\t// --tools is passed and the subagent inherits all parent tools (so the Task\n\t\t\t// tool already survives). A delegating agent with an explicit allowlist must\n\t\t\t// have Task/TaskOutput added, or the child would filter them out.\n\t\t\tconst tools = def?.tools ? [...def.tools] : undefined;\n\t\t\tif (canChildDelegate && tools) {\n\t\t\t\tfor (const t of [\"Task\", \"TaskOutput\"]) {\n\t\t\t\t\tif (!tools.includes(t)) tools.push(t);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (tools && tools.length > 0) {\n\t\t\t\targs.push(\"--tools\", tools.join(\",\"));\n\t\t\t}\n\t\t\tif (def?.disallowedTools && def.disallowedTools.length > 0) {\n\t\t\t\targs.push(\"--disallowed-tools\", def.disallowedTools.join(\",\"));\n\t\t\t}\n\n\t\t\t// Propagate subagent enablement so the child registers the Task tool; without\n\t\t\t// this the flag-based enablement would not reach a spawned child.\n\t\t\tif (canChildDelegate) {\n\t\t\t\targs.push(\"--enable-subagents\");\n\t\t\t\t// Scoped delegation: restrict which agent types this child may spawn.\n\t\t\t\tif (def?.delegateTo && def.delegateTo.length > 0) {\n\t\t\t\t\targs.push(\"--delegate-allow\", def.delegateTo.join(\",\"));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Model precedence: a definition's explicit model wins (unless it is the\n\t\t// `inherit` sentinel), otherwise use the caller-provided model. Built-in\n\t\t// agents can retry with the inherited model when their preferred model is\n\t\t// unavailable or quota-limited.\n\t\tconst explicitModel =\n\t\t\t!task.useInheritedModelFallback && def?.model && def.model !== MODEL_INHERIT ? def.model : undefined;\n\t\tconst modelToUse = explicitModel ?? task.model;\n\t\tif (modelToUse) {\n\t\t\targs.push(\"--model\", modelToUse);\n\t\t}\n\t\tif (task.provider) {\n\t\t\targs.push(\"--provider\", task.provider);\n\t\t}\n\n\t\t// Always give subagents a hard turn cap. With the token budget now advisory\n\t\t// (warn-only), this is the guaranteed hard stop for a runaway subagent.\n\t\tconst maxTurns = def?.maxTurns && def.maxTurns > 0 ? def.maxTurns : DEFAULT_SUBAGENT_MAX_TURNS;\n\t\targs.push(\"--max-turns\", String(maxTurns));\n\n\t\t// Forward non-default skill paths so the subagent has access to all parent skills.\n\t\t// Standard discovery locations (~/.hoocode/, .hoocode/, .claude/) are found automatically.\n\t\tfor (const skillPath of this.skillPaths) {\n\t\t\targs.push(\"--skill\", skillPath);\n\t\t}\n\n\t\tconst prompt = task.context?.trim()\n\t\t\t? `Context from the calling agent:\\n\\n${task.context.trim()}\\n\\nTask: ${task.task.trim()}`\n\t\t\t: `Task: ${task.task.trim()}`;\n\t\targs.push(prompt);\n\n\t\treturn args;\n\t}\n\n\t/** Start a task in a child process, with one retry on failure. */\n\tprivate startTask(task: SubagentPoolTask, isRetry: boolean): void {\n\t\t// Get or create a TokenBudget tracker. On retry, reuse the existing one\n\t\t// so cumulative usage persists across retries.\n\t\tlet budget = this.budgets.get(task.task_id);\n\t\tif (!budget) {\n\t\t\tbudget = new TokenBudget(task.task_id, task.agent_type, {\n\t\t\t\tlimit: task.token_budget,\n\t\t\t\tcwd: task.cwd ?? this.cwd,\n\t\t\t});\n\t\t\tbudget.on(\"budget_warning\", (data: { task_id: string; message: string; used: number; limit: number }) => {\n\t\t\t\tthis.emit(\"budget_warning\", data);\n\t\t\t});\n\t\t\t// The token budget is advisory: surface telemetry but never kill. The\n\t\t\t// guaranteed hard stop is the per-subagent turn cap (--max-turns); see\n\t\t\t// DEFAULT_SUBAGENT_MAX_TURNS.\n\t\t\tbudget.on(\"budget_exceeded\", (data: { task_id: string; used: number; limit: number }) => {\n\t\t\t\tthis.emit(\"budget_exceeded\", data);\n\t\t\t});\n\t\t\tthis.budgets.set(task.task_id, budget);\n\t\t}\n\n\t\tlet proc: ReturnType<typeof spawn>;\n\t\ttry {\n\t\t\tproc = spawn(this.executable, this.buildArgs(task), {\n\t\t\t\tcwd: task.cwd ?? this.cwd,\n\t\t\t\t// Stamp the child's depth (parent depth + 1) so its own guard knows where\n\t\t\t\t// it sits in the tree. The tree-wide cap (HOOCODE_SUBAGENT_MAX_DEPTH) is\n\t\t\t\t// inherited via the spread; at the default cap of 1 the child lands at\n\t\t\t\t// depth 1 and cannot spawn further subagents.\n\t\t\t\tenv: {\n\t\t\t\t\t...this.env,\n\t\t\t\t\t[SUBAGENT_DEPTH_ENV]: String(currentSubagentDepth(this.env) + 1),\n\t\t\t\t},\n\t\t\t\tshell: false,\n\t\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t\t});\n\t\t} catch {\n\t\t\tif (!isRetry) {\n\t\t\t\tthis.startTask(task, true);\n\t\t\t} else {\n\t\t\t\tthis.emit(\"task_failed\", {\n\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\terror: \"Spawn failed synchronously\",\n\t\t\t\t});\n\t\t\t\tthis.resolveWaiter(task.task_id, {\n\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\tok: false,\n\t\t\t\t\tstdout: \"\",\n\t\t\t\t\tstderr: \"\",\n\t\t\t\t\texit_code: null,\n\t\t\t\t\terror: \"Spawn failed synchronously\",\n\t\t\t\t\tstatus: \"failed\",\n\t\t\t\t});\n\t\t\t\tthis.pull();\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tconst slot: SubagentSlot = {\n\t\t\tpid: proc.pid ?? 0,\n\t\t\tagent_type: task.agent_type,\n\t\t\ttask_id: task.task_id,\n\t\t\tspawned_at: Date.now(),\n\t\t\ttoken_budget: task.token_budget ?? this.defaultTokenBudget,\n\t\t\tprocess: proc,\n\t\t};\n\n\t\tthis.slots.set(task.task_id, slot);\n\t\tthis.lifeguard.monitor(task.task_id, task.agent_type, proc);\n\n\t\tlet stdout = \"\";\n\t\tlet stderr = \"\";\n\n\t\tproc.stdout?.on(\"data\", (data: Buffer) => {\n\t\t\tconst chunk = data.toString();\n\t\t\tstdout += chunk;\n\t\t\tbudget.processStdout(chunk);\n\n\t\t\t// Any output proves the child is alive and working, so treat it as a\n\t\t\t// heartbeat. The dedicated {\"ping\":true} line below still matters for\n\t\t\t// quiet phases (e.g. a long single model turn that emits nothing), but\n\t\t\t// relying on it alone falsely reaps subagents that are busily streaming\n\t\t\t// events while the parent's event loop is starved by concurrent load.\n\t\t\tthis.lifeguard.recordHeartbeat(task.task_id);\n\n\t\t\t// Heartbeat detection: look for {\"ping\":true} JSON lines\n\t\t\tfor (const raw of chunk.split(\"\\n\")) {\n\t\t\t\tconst line = raw.trim();\n\t\t\t\tif (!line.startsWith(\"{\")) continue;\n\t\t\t\ttry {\n\t\t\t\t\tconst parsed = JSON.parse(line) as Record<string, unknown>;\n\t\t\t\t\tif (parsed.ping === true) {\n\t\t\t\t\t\tthis.lifeguard.recordHeartbeat(task.task_id);\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// Not a ping line, ignore\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t\tproc.stderr?.on(\"data\", (data: Buffer) => {\n\t\t\tstderr += data.toString();\n\t\t});\n\n\t\twaitForChildProcess(proc)\n\t\t\t.then((code) => {\n\t\t\t\tthis.slots.delete(task.task_id);\n\t\t\t\tbudget.flush();\n\n\t\t\t\tconst killReason = this.killReasons.get(task.task_id);\n\t\t\t\tthis.killReasons.delete(task.task_id);\n\n\t\t\t\tconst duration = Date.now() - slot.spawned_at;\n\t\t\t\tconst tokens_used = budget.getUsed();\n\t\t\t\tconst budgetExceeded = budget.isExceeded();\n\n\t\t\t\t// A subagent's success is defined by a valid, verified result.json, not by\n\t\t\t\t// its exit code. A child that finished its work and wrote a valid result can\n\t\t\t\t// still be SIGKILLed by the lifeguard before it exits on its own (lingering\n\t\t\t\t// open handles delay a natural exit past the heartbeat threshold), which forces\n\t\t\t\t// exit_code === null. Keying completion off the verified result, not code === 0,\n\t\t\t\t// honors that genuine success instead of discarding it as a false stall.\n\t\t\t\tconst verification = this.verifier.verify(task.task_id, task.cwd ?? this.cwd);\n\t\t\t\t// A well-formed result.json counts as clean completion unless its own\n\t\t\t\t// status field declares failure (e.g. \"failed\" from a provider quota\n\t\t\t\t// error). Without this check the pool would treat a child that wrote a\n\t\t\t\t// valid-but-failed result.json and exited non-zero as a success.\n\t\t\t\tlet cleanlyCompleted = code === 0 || verification.valid;\n\t\t\t\tif (cleanlyCompleted && verification.valid) {\n\t\t\t\t\tconst rd = this.tryReadResultJson(task.task_id, task.cwd ?? this.cwd);\n\t\t\t\t\tif (rd && (rd as Record<string, unknown>).status === \"failed\") {\n\t\t\t\t\t\tcleanlyCompleted = false;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// If killed by lifeguard before producing a valid result, honor the kill.\n\t\t\t\tif ((killReason === \"stalled\" || killReason === \"timeout\") && !verification.valid) {\n\t\t\t\t\tconst result: SubagentResult = {\n\t\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\t\tok: false,\n\t\t\t\t\t\tstdout,\n\t\t\t\t\t\tstderr,\n\t\t\t\t\t\texit_code: code,\n\t\t\t\t\t\tstatus: killReason,\n\t\t\t\t\t};\n\t\t\t\t\tthis.writeOutputJson(task.task_id, result);\n\t\t\t\t\tthis.emit(`task_${killReason}`, {\n\t\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\t\tagent_type: task.agent_type,\n\t\t\t\t\t\tduration,\n\t\t\t\t\t\ttokens_used,\n\t\t\t\t\t});\n\t\t\t\t\tthis.resolveWaiter(task.task_id, result);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst result: SubagentResult = {\n\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\tok: cleanlyCompleted,\n\t\t\t\t\tstdout,\n\t\t\t\t\tstderr,\n\t\t\t\t\texit_code: code,\n\t\t\t\t\t// Advisory telemetry only: exceeding the budget never fails the task.\n\t\t\t\t\tbudget_exceeded: budgetExceeded,\n\t\t\t\t\tstatus: cleanlyCompleted ? \"complete\" : \"failed\",\n\t\t\t\t\tusedInheritedModelFallback: task.useInheritedModelFallback === true,\n\t\t\t\t};\n\n\t\t\t\tif (result.ok) {\n\t\t\t\t\tif (!verification.valid) {\n\t\t\t\t\t\tresult.ok = false;\n\t\t\t\t\t\tresult.error = verification.reason;\n\t\t\t\t\t\tresult.status = \"failed\";\n\t\t\t\t\t\tthis.writeOutputJson(task.task_id, result);\n\t\t\t\t\t\tthis.emit(\"task_failed\", {\n\t\t\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\t\t\tagent_type: task.agent_type,\n\t\t\t\t\t\t\tduration,\n\t\t\t\t\t\t\ttokens_used,\n\t\t\t\t\t\t\terror: verification.reason,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tthis.resolveWaiter(task.task_id, result);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\t// Attach the verified result.json so callers can read the summary\n\t\t\t\t\t// without parsing the raw event stream.\n\t\t\t\t\tresult.result_data = this.tryReadResultJson(task.task_id, task.cwd ?? this.cwd);\n\n\t\t\t\t\t// Clean success: discard the per-task dispatch dir entirely\n\t\t\t\t\t// (session.jsonl, result.json, dispatch-log.json, budget.json). The\n\t\t\t\t\t// in-memory result already carries result_data, so callers lose\n\t\t\t\t\t// nothing. Trade-off: resume() only works for non-successful tasks.\n\t\t\t\t\tthis.cleanupDispatchDir(task.task_id, task.cwd ?? this.cwd);\n\n\t\t\t\t\tthis.emit(\"task_done\", {\n\t\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\t\tagent_type: task.agent_type,\n\t\t\t\t\t\tduration,\n\t\t\t\t\t\ttokens_used,\n\t\t\t\t\t\tstatus: \"complete\",\n\t\t\t\t\t});\n\t\t\t\t\tthis.resolveWaiter(task.task_id, result);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// Failure path: keep the dispatch dir for debugging and persist output.\n\t\t\t\t// Attach the child's result.json (if any) and derive a concrete failure\n\t\t\t\t// reason so callers see the real cause (e.g. a provider usage/quota\n\t\t\t\t// error) instead of a generic \"subagent failed\".\n\t\t\t\tresult.result_data = this.tryReadResultJson(task.task_id, task.cwd ?? this.cwd);\n\t\t\t\tif (!result.error) {\n\t\t\t\t\tresult.error = this.deriveFailureReason(result);\n\t\t\t\t}\n\t\t\t\tif (this.shouldRetryWithInheritedModel(task, result)) {\n\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t`[DISPATCH] agent=${task.agent_type} task_id=${task.task_id} preferred model failed; retrying with inherited model`,\n\t\t\t\t\t);\n\t\t\t\t\tthis.cleanupRetryArtifacts(task);\n\t\t\t\t\tthis.queue.unshift({ ...task, useInheritedModelFallback: true });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.writeOutputJson(task.task_id, result);\n\t\t\t\tthis.emit(\"task_failed\", {\n\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\tagent_type: task.agent_type,\n\t\t\t\t\tduration,\n\t\t\t\t\ttokens_used,\n\t\t\t\t\terror: result.error ?? `Exited with code ${code}`,\n\t\t\t\t});\n\t\t\t\tthis.resolveWaiter(task.task_id, result);\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\tthis.slots.delete(task.task_id);\n\t\t\t\tbudget.flush();\n\t\t\t\tconst duration = Date.now() - slot.spawned_at;\n\t\t\t\tconst tokens_used = budget.getUsed();\n\t\t\t\tif (!isRetry) {\n\t\t\t\t\tthis.startTask(task, true);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst error = err instanceof Error ? err.message : String(err);\n\t\t\t\tconst result: SubagentResult = {\n\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\tok: false,\n\t\t\t\t\tstdout,\n\t\t\t\t\tstderr,\n\t\t\t\t\texit_code: null,\n\t\t\t\t\terror,\n\t\t\t\t\tstatus: \"failed\",\n\t\t\t\t\tusedInheritedModelFallback: task.useInheritedModelFallback === true,\n\t\t\t\t};\n\t\t\t\tthis.writeOutputJson(task.task_id, result);\n\t\t\t\tthis.emit(\"task_failed\", {\n\t\t\t\t\ttask_id: task.task_id,\n\t\t\t\t\tagent_type: task.agent_type,\n\t\t\t\t\tduration,\n\t\t\t\t\ttokens_used,\n\t\t\t\t\terror,\n\t\t\t\t});\n\t\t\t\tthis.resolveWaiter(task.task_id, result);\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tbudget.removeAllListeners();\n\t\t\t\tthis.budgets.delete(task.task_id);\n\t\t\t\tthis.pull();\n\t\t\t});\n\t}\n\n\t/** Whether a failed built-in subagent should be retried with `model: inherit`. */\n\tprivate shouldRetryWithInheritedModel(task: SubagentPoolTask, result: SubagentResult): boolean {\n\t\tif (task.useInheritedModelFallback) return false;\n\t\tif (task.sessionFile) return false;\n\t\t// Only the parent model is required: the provider may be unset when the\n\t\t// harness routes through a gateway. The retry inherits the parent model and\n\t\t// lets the child resolve the provider from its own default when none was threaded through.\n\t\tif (!task.model) return false;\n\n\t\tconst def = task.agent_type ? this.getRegistry().get(task.agent_type) : undefined;\n\t\t// Built-in agents always inherit; project agents may pin an explicit model in\n\t\t// frontmatter, so let them fall back too when that model is rejected.\n\t\tif (def?.source !== \"builtin\" && def?.source !== \"project\") return false;\n\t\tif (!def.model || def.model === MODEL_INHERIT) return false;\n\n\t\treturn this.isInheritedModelFallbackError(result);\n\t}\n\n\t/** Detect provider/model failures where inheriting the parent model can recover. */\n\tprivate isInheritedModelFallbackError(result: SubagentResult): boolean {\n\t\tconst text = [result.error, result.stderr, JSON.stringify(result.result_data ?? {})]\n\t\t\t.filter((part): part is string => typeof part === \"string\" && part.length > 0)\n\t\t\t.join(\"\\n\");\n\n\t\treturn /usage[_\\s-]?limit|subscription|quota|rate.?limit|too many requests|429|insufficient|out of credit|credit balance|billing|payment required|402|model[^\\n]*(not found|unavailable|not available|not supported|does not exist|invalid|unsupported)|no api key|no auth configured|authentication|unauthorized|forbidden|permission/i.test(\n\t\t\ttext,\n\t\t);\n\t}\n\n\t/** Remove failed attempt artifacts before rerunning the same task id. */\n\tprivate cleanupRetryArtifacts(task: SubagentPoolTask): void {\n\t\tconst cwd = task.cwd ?? this.cwd;\n\t\tconst taskDir = getDispatchTaskDir(cwd, task.task_id);\n\t\tconst sessionFile = task.sessionFile ?? this.getSessionFile(task.task_id, cwd);\n\t\ttry {\n\t\t\trmSync(sessionFile, { force: true });\n\t\t\trmSync(join(taskDir, \"result.json\"), { force: true });\n\t\t\trmSync(join(taskDir, \"output.json\"), { force: true });\n\t\t} catch {\n\t\t\t// Best-effort cleanup; retry can still proceed with existing artifacts.\n\t\t}\n\t}\n\n\t/**\n\t * Best-effort concrete failure reason for a non-zero-exit subagent. Prefers\n\t * the child's result.json summary (which carries the provider/model error\n\t * message on failure), then the tail of stderr, then the exit code.\n\t */\n\tprivate deriveFailureReason(result: SubagentResult): string {\n\t\tconst summary = (result.result_data as { summary?: string } | undefined)?.summary?.trim();\n\t\tif (summary) {\n\t\t\treturn summary;\n\t\t}\n\t\tconst stderrTail = result.stderr\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => line.trim())\n\t\t\t.filter((line) => line.length > 0)\n\t\t\t.slice(-5)\n\t\t\t.join(\"\\n\");\n\t\tif (stderrTail) {\n\t\t\treturn stderrTail;\n\t\t}\n\t\treturn `Exited with code ${result.exit_code}`;\n\t}\n\n\tprivate tryReadResultJson(task_id: string, cwd: string): Record<string, unknown> | undefined {\n\t\tconst path = join(getDispatchTaskDir(cwd, task_id), \"result.json\");\n\t\tif (!existsSync(path)) return undefined;\n\t\ttry {\n\t\t\tconst raw = readFileSync(path, \"utf-8\");\n\t\t\treturn JSON.parse(raw) as Record<string, unknown>;\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tprivate resolveWaiter(task_id: string, result: SubagentResult): void {\n\t\t// Persist terminal status for get_status() even after wait_for consumes the result\n\t\tif (result.status === \"stalled\") this.taskStatus.set(task_id, \"stalled\");\n\t\telse if (result.status === \"timeout\") this.taskStatus.set(task_id, \"timeout\");\n\t\telse if (result.ok) this.taskStatus.set(task_id, \"done\");\n\t\telse this.taskStatus.set(task_id, \"failed\");\n\n\t\tconst waiter = this.waiters.get(task_id);\n\t\tif (waiter) {\n\t\t\twaiter.resolve(result);\n\t\t\tthis.waiters.delete(task_id);\n\t\t\treturn;\n\t\t}\n\t\tthis.completed.set(task_id, result);\n\t}\n}\n"]}
@@ -9,6 +9,7 @@ import { loadAgentRegistry } from "./agent-registry.js";
9
9
  import { DispatchEvaluator } from "./dispatch-evaluator.js";
10
10
  import { SubagentLifeguard } from "./lifeguard.js";
11
11
  import { OutputVerifier } from "./output-verifier.js";
12
+ import { currentSubagentDepth, resolveMaxSubagentDepth, SUBAGENT_DEPTH_ENV } from "./subagent-depth.js";
12
13
  import { TokenBudget } from "./token-budget.js";
13
14
  /**
14
15
  * Default hard cap on assistant turns for a spawned subagent when its definition
@@ -94,15 +95,9 @@ export class SubagentPool extends EventEmitter {
94
95
  }
95
96
  /** Priority value: higher numbers run first. */
96
97
  priorityOf(agent_type) {
97
- switch (agent_type) {
98
- case "explore":
99
- case "review":
100
- return 2;
101
- case "doc":
102
- return 0;
103
- default:
104
- return 1;
105
- }
98
+ // Read-only investigation (explore/plan) often unblocks downstream work, so
99
+ // it runs ahead of other agents.
100
+ return agent_type === "explore" || agent_type === "plan" ? 2 : 1;
106
101
  }
107
102
  /** Queue a task. It will run when a slot is free. */
108
103
  spawn(task) {
@@ -223,10 +218,13 @@ export class SubagentPool extends EventEmitter {
223
218
  const task_id = `dispatch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
224
219
  const reason = forceAgent ? "user_override" : analysis.reason;
225
220
  const complexity = analysis.estimated_complexity;
221
+ // Depth of the child about to be spawned (this process's depth + 1). Surfaced
222
+ // so a delegation tree's nesting is visible in logs without extra tooling.
223
+ const childDepth = currentSubagentDepth(this.env) + 1;
226
224
  // Pre-dispatch logging. Use stderr: stdout is reserved for the JSON event
227
225
  // stream / TUI render and must not be polluted.
228
- console.error(`[DISPATCH] agent=${agent_type} reason=${reason} complexity=${complexity} task_id=${task_id}`);
229
- this.writeDispatchLog(task_id, agent_type, reason, complexity, task);
226
+ console.error(`[DISPATCH] agent=${agent_type} depth=${childDepth} reason=${reason} complexity=${complexity} task_id=${task_id}`);
227
+ this.writeDispatchLog(task_id, agent_type, reason, complexity, task, childDepth);
230
228
  const poolTask = {
231
229
  task_id,
232
230
  agent_type,
@@ -282,11 +280,12 @@ export class SubagentPool extends EventEmitter {
282
280
  return undefined;
283
281
  }
284
282
  }
285
- writeDispatchLog(task_id, agent_type, reason, complexity, task) {
283
+ writeDispatchLog(task_id, agent_type, reason, complexity, task, depth) {
286
284
  const log = {
287
285
  timestamp: new Date().toISOString(),
288
286
  task_id,
289
287
  agent_type,
288
+ depth,
290
289
  reason,
291
290
  complexity,
292
291
  task,
@@ -389,13 +388,39 @@ export class SubagentPool extends EventEmitter {
389
388
  if (systemPrompt) {
390
389
  args.push("--system-prompt", systemPrompt);
391
390
  }
391
+ // A `delegate: true` agent may itself dispatch via the Task tool, but only
392
+ // while the child it becomes can still nest (childDepth < cap) — so the
393
+ // deepest permitted level cannot delegate further. Gating here keeps the
394
+ // authorization explicit and bounded by the same cap as the depth guard.
395
+ const childDepth = currentSubagentDepth(this.env) + 1;
396
+ const canChildDelegate = def?.delegate === true && childDepth < resolveMaxSubagentDepth(undefined, this.env);
392
397
  // Tool allowlist comes from the agent definition's frontmatter `tools`
393
398
  // field (read-only built-ins declare their own sandbox). When omitted, no
394
- // --tools is passed and the subagent inherits all parent tools.
395
- const tools = def?.tools;
399
+ // --tools is passed and the subagent inherits all parent tools (so the Task
400
+ // tool already survives). A delegating agent with an explicit allowlist must
401
+ // have Task/TaskOutput added, or the child would filter them out.
402
+ const tools = def?.tools ? [...def.tools] : undefined;
403
+ if (canChildDelegate && tools) {
404
+ for (const t of ["Task", "TaskOutput"]) {
405
+ if (!tools.includes(t))
406
+ tools.push(t);
407
+ }
408
+ }
396
409
  if (tools && tools.length > 0) {
397
410
  args.push("--tools", tools.join(","));
398
411
  }
412
+ if (def?.disallowedTools && def.disallowedTools.length > 0) {
413
+ args.push("--disallowed-tools", def.disallowedTools.join(","));
414
+ }
415
+ // Propagate subagent enablement so the child registers the Task tool; without
416
+ // this the flag-based enablement would not reach a spawned child.
417
+ if (canChildDelegate) {
418
+ args.push("--enable-subagents");
419
+ // Scoped delegation: restrict which agent types this child may spawn.
420
+ if (def?.delegateTo && def.delegateTo.length > 0) {
421
+ args.push("--delegate-allow", def.delegateTo.join(","));
422
+ }
423
+ }
399
424
  }
400
425
  // Model precedence: a definition's explicit model wins (unless it is the
401
426
  // `inherit` sentinel), otherwise use the caller-provided model. Built-in
@@ -449,9 +474,14 @@ export class SubagentPool extends EventEmitter {
449
474
  try {
450
475
  proc = spawn(this.executable, this.buildArgs(task), {
451
476
  cwd: task.cwd ?? this.cwd,
452
- // Mark the child as a subagent so its own DispatchEvaluator refuses to
453
- // spawn further subagents (depth guard).
454
- env: { ...this.env, HOOCODE_SUBAGENT_DEPTH: "1" },
477
+ // Stamp the child's depth (parent depth + 1) so its own guard knows where
478
+ // it sits in the tree. The tree-wide cap (HOOCODE_SUBAGENT_MAX_DEPTH) is
479
+ // inherited via the spread; at the default cap of 1 the child lands at
480
+ // depth 1 and cannot spawn further subagents.
481
+ env: {
482
+ ...this.env,
483
+ [SUBAGENT_DEPTH_ENV]: String(currentSubagentDepth(this.env) + 1),
484
+ },
455
485
  shell: false,
456
486
  stdio: ["ignore", "pipe", "pipe"],
457
487
  });