@kolisachint/hoocode-agent 0.4.13 → 0.4.15

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 (68) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/cli/args.d.ts +2 -0
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +8 -0
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/config.d.ts +8 -0
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +12 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/core/agent-frontmatter.d.ts +107 -0
  11. package/dist/core/agent-frontmatter.d.ts.map +1 -0
  12. package/dist/core/agent-frontmatter.js +189 -0
  13. package/dist/core/agent-frontmatter.js.map +1 -0
  14. package/dist/core/agent-registry.d.ts +52 -0
  15. package/dist/core/agent-registry.d.ts.map +1 -0
  16. package/dist/core/agent-registry.js +131 -0
  17. package/dist/core/agent-registry.js.map +1 -0
  18. package/dist/core/dispatch-evaluator.d.ts +17 -0
  19. package/dist/core/dispatch-evaluator.d.ts.map +1 -1
  20. package/dist/core/dispatch-evaluator.js +44 -10
  21. package/dist/core/dispatch-evaluator.js.map +1 -1
  22. package/dist/core/lifeguard.d.ts.map +1 -1
  23. package/dist/core/lifeguard.js +5 -5
  24. package/dist/core/lifeguard.js.map +1 -1
  25. package/dist/core/output-verifier.d.ts.map +1 -1
  26. package/dist/core/output-verifier.js +2 -2
  27. package/dist/core/output-verifier.js.map +1 -1
  28. package/dist/core/subagent-pool.d.ts +54 -3
  29. package/dist/core/subagent-pool.d.ts.map +1 -1
  30. package/dist/core/subagent-pool.js +152 -62
  31. package/dist/core/subagent-pool.js.map +1 -1
  32. package/dist/core/subagent-result.d.ts +11 -2
  33. package/dist/core/subagent-result.d.ts.map +1 -1
  34. package/dist/core/subagent-result.js +17 -4
  35. package/dist/core/subagent-result.js.map +1 -1
  36. package/dist/core/task-store.d.ts +12 -7
  37. package/dist/core/task-store.d.ts.map +1 -1
  38. package/dist/core/task-store.js +23 -15
  39. package/dist/core/task-store.js.map +1 -1
  40. package/dist/core/token-budget.d.ts.map +1 -1
  41. package/dist/core/token-budget.js +17 -14
  42. package/dist/core/token-budget.js.map +1 -1
  43. package/dist/core/tools/subagent.d.ts +32 -15
  44. package/dist/core/tools/subagent.d.ts.map +1 -1
  45. package/dist/core/tools/subagent.js +236 -112
  46. package/dist/core/tools/subagent.js.map +1 -1
  47. package/dist/main.d.ts.map +1 -1
  48. package/dist/main.js +13 -5
  49. package/dist/main.js.map +1 -1
  50. package/dist/modes/interactive/components/task-panel.d.ts +1 -1
  51. package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
  52. package/dist/modes/interactive/components/task-panel.js +31 -12
  53. package/dist/modes/interactive/components/task-panel.js.map +1 -1
  54. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  55. package/dist/modes/interactive/components/tool-execution.js +4 -2
  56. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  57. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  58. package/dist/modes/interactive/interactive-mode.js +12 -7
  59. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  60. package/dist/modes/print-mode.d.ts +2 -0
  61. package/dist/modes/print-mode.d.ts.map +1 -1
  62. package/dist/modes/print-mode.js +29 -2
  63. package/dist/modes/print-mode.js.map +1 -1
  64. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  65. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  66. package/examples/extensions/sandbox/package.json +1 -1
  67. package/examples/extensions/with-deps/package.json +1 -1
  68. package/package.json +4 -4
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { CONFIG_DIR_NAME } from "../config.js";
3
+ import { getDispatchTaskDir } from "../config.js";
4
4
  const VALID_STATUSES = ["complete", "partial", "failed"];
5
5
  /**
6
6
  * Verifies that a subagent's result.json exists, matches the expected schema,
@@ -18,7 +18,7 @@ export class OutputVerifier {
18
18
  */
19
19
  verify(task_id, cwd) {
20
20
  const base = cwd ?? this.defaultCwd;
21
- const path = join(base, CONFIG_DIR_NAME, "agents", task_id, "result.json");
21
+ const path = join(getDispatchTaskDir(base, task_id), "result.json");
22
22
  if (!existsSync(path)) {
23
23
  return {
24
24
  valid: false,
@@ -1 +1 @@
1
- {"version":3,"file":"output-verifier.js","sourceRoot":"","sources":["../../src/core/output-verifier.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { CONFIG_DIR_NAME } from \"../config.js\";\n\nexport interface VerificationResult {\n\tvalid: boolean;\n\treason?: string;\n}\n\nconst VALID_STATUSES = [\"complete\", \"partial\", \"failed\"] as const;\n\ntype Status = (typeof VALID_STATUSES)[number];\n\n/**\n * Verifies that a subagent's result.json exists, matches the expected schema,\n * and meets quality thresholds (non-empty summary, confidence >= 0.5).\n */\nexport class OutputVerifier {\n\tconstructor(private readonly defaultCwd: string = process.cwd()) {}\n\n\t/**\n\t * Verify the output for a given task.\n\t * @param task_id The task identifier.\n\t * @param cwd Optional working directory override (defaults to constructor value).\n\t */\n\tverify(task_id: string, cwd?: string): VerificationResult {\n\t\tconst base = cwd ?? this.defaultCwd;\n\t\tconst path = join(base, CONFIG_DIR_NAME, \"agents\", task_id, \"result.json\");\n\n\t\tif (!existsSync(path)) {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `result.json not found for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\tlet raw: string;\n\t\ttry {\n\t\t\traw = readFileSync(path, \"utf-8\");\n\t\t} catch {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Cannot read result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\tlet parsed: unknown;\n\t\ttry {\n\t\t\tparsed = JSON.parse(raw);\n\t\t} catch {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Invalid JSON in result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\tif (!parsed || typeof parsed !== \"object\") {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `result.json is not an object for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\tconst result = parsed as Record<string, unknown>;\n\n\t\t// summary\n\t\tif (typeof result.summary !== \"string\") {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Missing or invalid 'summary' in result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\t\tif (result.summary.trim().length === 0) {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Empty 'summary' in result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\t// files_changed\n\t\tif (!Array.isArray(result.files_changed)) {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Missing or invalid 'files_changed' in result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\t\tif (!result.files_changed.every((f) => typeof f === \"string\")) {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Non-string entries in 'files_changed' for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\t// confidence\n\t\tif (typeof result.confidence !== \"number\") {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Missing or invalid 'confidence' in result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\t\tif (result.confidence < 0.5) {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Confidence ${result.confidence} below threshold (0.5) for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\t// status\n\t\tif (typeof result.status !== \"string\") {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Missing or invalid 'status' in result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\t\tif (!VALID_STATUSES.includes(result.status as Status)) {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Invalid status '${result.status}' in result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\treturn { valid: true };\n\t}\n}\n"]}
1
+ {"version":3,"file":"output-verifier.js","sourceRoot":"","sources":["../../src/core/output-verifier.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { getDispatchTaskDir } from \"../config.js\";\n\nexport interface VerificationResult {\n\tvalid: boolean;\n\treason?: string;\n}\n\nconst VALID_STATUSES = [\"complete\", \"partial\", \"failed\"] as const;\n\ntype Status = (typeof VALID_STATUSES)[number];\n\n/**\n * Verifies that a subagent's result.json exists, matches the expected schema,\n * and meets quality thresholds (non-empty summary, confidence >= 0.5).\n */\nexport class OutputVerifier {\n\tconstructor(private readonly defaultCwd: string = process.cwd()) {}\n\n\t/**\n\t * Verify the output for a given task.\n\t * @param task_id The task identifier.\n\t * @param cwd Optional working directory override (defaults to constructor value).\n\t */\n\tverify(task_id: string, cwd?: string): VerificationResult {\n\t\tconst base = cwd ?? this.defaultCwd;\n\t\tconst path = join(getDispatchTaskDir(base, task_id), \"result.json\");\n\n\t\tif (!existsSync(path)) {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `result.json not found for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\tlet raw: string;\n\t\ttry {\n\t\t\traw = readFileSync(path, \"utf-8\");\n\t\t} catch {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Cannot read result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\tlet parsed: unknown;\n\t\ttry {\n\t\t\tparsed = JSON.parse(raw);\n\t\t} catch {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Invalid JSON in result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\tif (!parsed || typeof parsed !== \"object\") {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `result.json is not an object for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\tconst result = parsed as Record<string, unknown>;\n\n\t\t// summary\n\t\tif (typeof result.summary !== \"string\") {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Missing or invalid 'summary' in result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\t\tif (result.summary.trim().length === 0) {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Empty 'summary' in result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\t// files_changed\n\t\tif (!Array.isArray(result.files_changed)) {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Missing or invalid 'files_changed' in result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\t\tif (!result.files_changed.every((f) => typeof f === \"string\")) {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Non-string entries in 'files_changed' for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\t// confidence\n\t\tif (typeof result.confidence !== \"number\") {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Missing or invalid 'confidence' in result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\t\tif (result.confidence < 0.5) {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Confidence ${result.confidence} below threshold (0.5) for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\t// status\n\t\tif (typeof result.status !== \"string\") {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Missing or invalid 'status' in result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\t\tif (!VALID_STATUSES.includes(result.status as Status)) {\n\t\t\treturn {\n\t\t\t\tvalid: false,\n\t\t\t\treason: `Invalid status '${result.status}' in result.json for task ${task_id}`,\n\t\t\t};\n\t\t}\n\n\t\treturn { valid: true };\n\t}\n}\n"]}
@@ -10,6 +10,12 @@ export interface SubagentPoolTask {
10
10
  cwd?: string;
11
11
  model?: string;
12
12
  provider?: string;
13
+ /**
14
+ * Explicit session file for the child to persist/continue. When omitted the
15
+ * child uses its own dispatch dir (`<dispatch>/<task_id>/session.jsonl`).
16
+ * Resume reuses the original task's session file to continue the transcript.
17
+ */
18
+ sessionFile?: string;
13
19
  }
14
20
  export interface SubagentSlot {
15
21
  pid: number;
@@ -46,14 +52,17 @@ export interface TaskResult {
46
52
  duration?: number;
47
53
  }
48
54
  export interface DispatchOptions {
49
- /** Skip evaluation and force this agent type (user/explicit override). */
50
- forceAgent?: SubagentMode;
55
+ /** Skip evaluation and force this agent type (user/explicit override).
56
+ * Accepts any registry-defined agent name, not just the built-in modes. */
57
+ forceAgent?: SubagentMode | string;
51
58
  /** Context distilled from the calling agent, passed to the subagent. */
52
59
  context?: string;
53
60
  /** Model id for the subagent (defaults to the child's configured default). */
54
61
  model?: string;
55
62
  /** Provider for the subagent. */
56
63
  provider?: string;
64
+ /** Explicit session file to persist/continue (used by resume). */
65
+ sessionFile?: string;
57
66
  }
58
67
  export interface SubagentPoolOptions {
59
68
  /** Path to the hoocode executable (or the runtime, e.g. node, when prefixArgs is set). */
@@ -69,6 +78,12 @@ export interface SubagentPoolOptions {
69
78
  /** Default token budget per task. Defaults to 0. */
70
79
  defaultTokenBudget?: number;
71
80
  }
81
+ /**
82
+ * Default hard cap on assistant turns for a spawned subagent when its definition
83
+ * does not set `maxTurns`. The token budget is advisory (it warns but never
84
+ * kills), so this turn cap is the guaranteed hard stop for every subagent.
85
+ */
86
+ export declare const DEFAULT_SUBAGENT_MAX_TURNS = 50;
72
87
  /**
73
88
  * Pool for running hoocode subagents as child processes with bounded concurrency,
74
89
  * FIFO queuing with priority support, and automatic slot refill.
@@ -78,7 +93,8 @@ export interface SubagentPoolOptions {
78
93
  * - "task_failed" – task failed (spawn error, bad exit code, verification failure)
79
94
  * - "task_stalled" – heartbeat missed for 60s, process was SIGKILLed
80
95
  * - "task_timeout" – hard timeout exceeded, process was SIGKILLed
81
- * - "budget_warning" – token usage crossed 80% threshold
96
+ * - "budget_warning" – token usage crossed 80% threshold (advisory)
97
+ * - "budget_exceeded" – token usage crossed 100% threshold (advisory; never kills)
82
98
  */
83
99
  export declare class SubagentPool extends EventEmitter {
84
100
  private readonly maxConcurrency;
@@ -95,11 +111,15 @@ export declare class SubagentPool extends EventEmitter {
95
111
  private verifier;
96
112
  private lifeguard;
97
113
  private disposed;
114
+ /** Lazily-loaded agent registry (frontmatter definitions) for this pool's cwd. */
115
+ private registry?;
98
116
  /** Tracks why a task was killed (stalled / timeout) before exit handler fires. */
99
117
  private killReasons;
100
118
  /** Persistent terminal status map, survives wait_for consumption. */
101
119
  private taskStatus;
102
120
  constructor(options: SubagentPoolOptions);
121
+ /** Lazily load the agent registry for this pool's cwd. */
122
+ private getRegistry;
103
123
  /** Priority value: higher numbers run first. */
104
124
  private priorityOf;
105
125
  /** Queue a task. It will run when a slot is free. */
@@ -122,6 +142,37 @@ export declare class SubagentPool extends EventEmitter {
122
142
  * `output.json`, and return the result.
123
143
  */
124
144
  dispatch(task: string, options?: DispatchOptions): Promise<TaskResult>;
145
+ /**
146
+ * Fire-and-forget dispatch for background agents. Spawns the subagent and
147
+ * returns its handle immediately; the caller polls get_status()/collect().
148
+ */
149
+ dispatchDetached(task: string, options?: DispatchOptions): {
150
+ handled_inline: boolean;
151
+ task_id?: string;
152
+ agent_type?: string;
153
+ reason?: string;
154
+ };
155
+ /**
156
+ * Evaluate, log, and spawn a task without waiting. Shared by dispatch()
157
+ * (blocking) and dispatchDetached() (background).
158
+ */
159
+ private beginDispatch;
160
+ /**
161
+ * Non-destructively read a completed task's result (for background polling).
162
+ * Returns undefined while the task is still running/queued, or if its result
163
+ * was already consumed via wait_for().
164
+ */
165
+ collect(task_id: string): SubagentResult | undefined;
166
+ /** Absolute path of the persisted session file for a task. */
167
+ getSessionFile(task_id: string, cwd?: string): string;
168
+ /**
169
+ * Resume a previously dispatched subagent, continuing its persisted session
170
+ * with a follow-up prompt. Recovers the original agent type from its dispatch
171
+ * log. Rejects if no resumable session exists for the task.
172
+ */
173
+ resume(task_id: string, prompt: string, options?: Omit<DispatchOptions, "forceAgent" | "sessionFile">): Promise<TaskResult>;
174
+ /** Recover the agent type a task was dispatched with, from its dispatch log. */
175
+ private readDispatchAgentType;
125
176
  /**
126
177
  * Dispatch a batch of subtasks concurrently.
127
178
  *
@@ -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, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { CONFIG_DIR_NAME } from \"../config.js\";\nimport { waitForChildProcess } from \"../utils/child-process.js\";\nimport { DispatchEvaluator } from \"./dispatch-evaluator.js\";\nimport { SubagentLifeguard } from \"./lifeguard.js\";\nimport { OutputVerifier } from \"./output-verifier.js\";\nimport { getSubagentSystemPrompt, MODE_TOOLS, type SubagentMode } from \"./subagent.js\";\nimport { TokenBudget } from \"./token-budget.js\";\n\nexport interface SubagentPoolTask {\n\ttask_id: string;\n\tagent_type: SubagentMode | string;\n\ttask: string;\n\tcontext?: string;\n\ttoken_budget?: number;\n\tcwd?: string;\n\tmodel?: string;\n\tprovider?: string;\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}\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\tforceAgent?: SubagentMode;\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}\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}\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 for 60s, process was SIGKILLed\n * - \"task_timeout\" – hard timeout exceeded, process was SIGKILLed\n * - \"budget_warning\" – token usage crossed 80% threshold\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\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/** 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.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/** 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\n\t\tconst { forceAgent, context, model, provider } = 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: SubagentMode = forceAgent ?? (analysis.agent_type as SubagentMode) ?? \"explore\";\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\tconst logLine = `[DISPATCH] agent=${agent_type} reason=${reason} complexity=${complexity} task_id=${task_id}`;\n\t\tconsole.error(logLine);\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\tcwd: this.cwd,\n\t\t};\n\n\t\tconst startTime = Date.now();\n\t\tthis.spawn(poolTask);\n\t\tconst result = await this.wait_for(task_id);\n\t\tconst duration = Date.now() - startTime;\n\n\t\treturn {\n\t\t\thandled_inline: false,\n\t\t\ttask_id,\n\t\t\tagent_type,\n\t\t\treason,\n\t\t\tresult,\n\t\t\tduration,\n\t\t};\n\t}\n\n\t/**\n\t * Dispatch a batch of subtasks concurrently.\n\t *\n\t * Spawns up to `maxConcurrency` at once; overflow is queued with FIFO.\n\t * Returns aggregated results in the same order as the input.\n\t */\n\tasync dispatchBatch(\n\t\ttasks: Array<{ agent_type: SubagentMode; prompt: string }>,\n\t\tshared: Omit<DispatchOptions, \"forceAgent\"> = {},\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\n\t\tconst promises = tasks.map(async ({ agent_type, prompt }) => {\n\t\t\treturn this.dispatch(prompt, { ...shared, forceAgent: agent_type });\n\t\t});\n\n\t\treturn Promise.all(promises);\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(this.cwd, CONFIG_DIR_NAME, \"agents\", 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(this.cwd, CONFIG_DIR_NAME, \"agents\", 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/** 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\tconst args: string[] = [...this.prefixArgs, \"--mode\", \"json\", \"--no-session\", \"--task-id\", task.task_id];\n\n\t\tif (task.agent_type) {\n\t\t\ttry {\n\t\t\t\tconst mode = task.agent_type as SubagentMode;\n\t\t\t\tconst systemPrompt = getSubagentSystemPrompt(mode);\n\t\t\t\targs.push(\"--system-prompt\", systemPrompt);\n\t\t\t\t// Enforce the per-mode tool allowlist so read-only modes cannot edit/write.\n\t\t\t\tconst tools = MODE_TOOLS[mode];\n\t\t\t\tif (tools) {\n\t\t\t\t\targs.push(\"--tools\", tools.join(\",\"));\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Unknown mode, skip custom system prompt\n\t\t\t}\n\t\t}\n\n\t\tif (task.model) {\n\t\t\targs.push(\"--model\", task.model);\n\t\t}\n\t\tif (task.provider) {\n\t\t\targs.push(\"--provider\", task.provider);\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\tbudget.on(\"budget_exceeded\", () => {\n\t\t\t\tconst slot = this.slots.get(task.task_id);\n\t\t\t\tif (slot && !slot.process.killed) {\n\t\t\t\t\tslot.process.kill(\"SIGTERM\");\n\t\t\t\t}\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// 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// If killed by lifeguard, override exit handling\n\t\t\t\tif (killReason === \"stalled\" || killReason === \"timeout\") {\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: code === 0 && !budgetExceeded,\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\tbudget_exceeded: budgetExceeded,\n\t\t\t\t\tstatus: code === 0 && !budgetExceeded ? \"complete\" : \"failed\",\n\t\t\t\t};\n\n\t\t\t\tif (budgetExceeded) {\n\t\t\t\t\t// Force-return whatever exists in result.json, mark partial\n\t\t\t\t\tconst resultData = this.tryReadResultJson(task.task_id, task.cwd ?? this.cwd);\n\t\t\t\t\tresult.status = resultData ? \"partial\" : \"failed\";\n\t\t\t\t\tresult.result_data = resultData;\n\t\t\t\t\tif (resultData) {\n\t\t\t\t\t\tresult.ok = true; // partial is considered success with data\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Hard-stopped before any result.json was written. Surface a clear,\n\t\t\t\t\t\t// actionable reason instead of letting callers report \"unknown error\".\n\t\t\t\t\t\tresult.error = `Token budget exceeded (used ${tokens_used} of ${budget.getLimit()}) before the subagent produced a result. Narrow the task scope or raise the budget for ${task.agent_type} subagents.`;\n\t\t\t\t\t}\n\t\t\t\t\tthis.writeOutputJson(task.task_id, result);\n\t\t\t\t\tthis.emit(resultData ? \"task_done\" : \"task_failed\", {\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\t...(resultData ? { status: \"partial\" } : { error: result.error }),\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\tif (result.ok) {\n\t\t\t\t\tconst verification = this.verifier.verify(task.task_id, task.cwd ?? this.cwd);\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\t\t\t\t}\n\n\t\t\t\tthis.writeOutputJson(task.task_id, result);\n\n\t\t\t\tif (result.ok) {\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} else {\n\t\t\t\t\tthis.emit(\"task_failed\", {\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\terror: result.error ?? `Exited with code ${code}`,\n\t\t\t\t\t});\n\t\t\t\t}\n\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};\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\tprivate tryReadResultJson(task_id: string, cwd: string): Record<string, unknown> | undefined {\n\t\tconst path = join(cwd, CONFIG_DIR_NAME, \"agents\", 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, 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 { getSubagentSystemPrompt, MODE_TOOLS, type SubagentMode } from \"./subagent.js\";\nimport { TokenBudget } from \"./token-budget.js\";\n\nexport interface SubagentPoolTask {\n\ttask_id: string;\n\tagent_type: SubagentMode | 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}\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}\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?: SubagentMode | 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}\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 for 60s, process was 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\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.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/** 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: SubagentMode | string = forceAgent ?? (analysis.agent_type as SubagentMode) ?? \"explore\";\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) ?? \"explore\";\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\t/**\n\t * Dispatch a batch of subtasks concurrently.\n\t *\n\t * Spawns up to `maxConcurrency` at once; overflow is queued with FIFO.\n\t * Returns aggregated results in the same order as the input.\n\t */\n\tasync dispatchBatch(\n\t\ttasks: Array<{ agent_type: SubagentMode; prompt: string }>,\n\t\tshared: Omit<DispatchOptions, \"forceAgent\"> = {},\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\n\t\tconst promises = tasks.map(async ({ agent_type, prompt }) => {\n\t\t\treturn this.dispatch(prompt, { ...shared, forceAgent: agent_type });\n\t\t});\n\n\t\treturn Promise.all(promises);\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/** 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 mode = task.agent_type as SubagentMode;\n\t\t\tlet systemPrompt = def?.prompt;\n\t\t\tif (!systemPrompt) {\n\t\t\t\ttry {\n\t\t\t\t\tsystemPrompt = getSubagentSystemPrompt(mode);\n\t\t\t\t} catch {\n\t\t\t\t\tsystemPrompt = undefined;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (systemPrompt) {\n\t\t\t\targs.push(\"--system-prompt\", systemPrompt);\n\t\t\t}\n\t\t\t// Tool allowlist: prefer the definition's normalized allowlist. When the\n\t\t\t// definition omits tools (inherit-all) but the agent maps to a built-in\n\t\t\t// mode, fall back to MODE_TOOLS to preserve the read-only sandbox.\n\t\t\tconst tools = def?.tools ?? MODE_TOOLS[mode];\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.\n\t\tconst explicitModel = 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\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// 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// If killed by lifeguard, override exit handling\n\t\t\t\tif (killReason === \"stalled\" || killReason === \"timeout\") {\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: code === 0,\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: code === 0 ? \"complete\" : \"failed\",\n\t\t\t\t};\n\n\t\t\t\tif (result.ok) {\n\t\t\t\t\tconst verification = this.verifier.verify(task.task_id, task.cwd ?? this.cwd);\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\t\t\t\t}\n\n\t\t\t\tthis.writeOutputJson(task.task_id, result);\n\n\t\t\t\tif (result.ok) {\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} else {\n\t\t\t\t\tthis.emit(\"task_failed\", {\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\terror: result.error ?? `Exited with code ${code}`,\n\t\t\t\t\t});\n\t\t\t\t}\n\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};\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\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"]}
@@ -2,13 +2,21 @@ import { spawn } from "node:child_process";
2
2
  import { EventEmitter } from "node:events";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
- import { CONFIG_DIR_NAME } from "../config.js";
5
+ import { getDispatchTaskDir } from "../config.js";
6
6
  import { waitForChildProcess } from "../utils/child-process.js";
7
+ import { MODEL_INHERIT } from "./agent-frontmatter.js";
8
+ import { loadAgentRegistry } from "./agent-registry.js";
7
9
  import { DispatchEvaluator } from "./dispatch-evaluator.js";
8
10
  import { SubagentLifeguard } from "./lifeguard.js";
9
11
  import { OutputVerifier } from "./output-verifier.js";
10
12
  import { getSubagentSystemPrompt, MODE_TOOLS } from "./subagent.js";
11
13
  import { TokenBudget } from "./token-budget.js";
14
+ /**
15
+ * Default hard cap on assistant turns for a spawned subagent when its definition
16
+ * does not set `maxTurns`. The token budget is advisory (it warns but never
17
+ * kills), so this turn cap is the guaranteed hard stop for every subagent.
18
+ */
19
+ export const DEFAULT_SUBAGENT_MAX_TURNS = 50;
12
20
  /**
13
21
  * Pool for running hoocode subagents as child processes with bounded concurrency,
14
22
  * FIFO queuing with priority support, and automatic slot refill.
@@ -18,7 +26,8 @@ import { TokenBudget } from "./token-budget.js";
18
26
  * - "task_failed" – task failed (spawn error, bad exit code, verification failure)
19
27
  * - "task_stalled" – heartbeat missed for 60s, process was SIGKILLed
20
28
  * - "task_timeout" – hard timeout exceeded, process was SIGKILLed
21
- * - "budget_warning" – token usage crossed 80% threshold
29
+ * - "budget_warning" – token usage crossed 80% threshold (advisory)
30
+ * - "budget_exceeded" – token usage crossed 100% threshold (advisory; never kills)
22
31
  */
23
32
  export class SubagentPool extends EventEmitter {
24
33
  maxConcurrency;
@@ -35,6 +44,8 @@ export class SubagentPool extends EventEmitter {
35
44
  verifier = new OutputVerifier();
36
45
  lifeguard;
37
46
  disposed = false;
47
+ /** Lazily-loaded agent registry (frontmatter definitions) for this pool's cwd. */
48
+ registry;
38
49
  /** Tracks why a task was killed (stalled / timeout) before exit handler fires. */
39
50
  killReasons = new Map();
40
51
  /** Persistent terminal status map, survives wait_for consumption. */
@@ -58,6 +69,13 @@ export class SubagentPool extends EventEmitter {
58
69
  this.emit("task_timeout", data);
59
70
  });
60
71
  }
72
+ /** Lazily load the agent registry for this pool's cwd. */
73
+ getRegistry() {
74
+ if (!this.registry) {
75
+ this.registry = loadAgentRegistry({ cwd: this.cwd });
76
+ }
77
+ return this.registry;
78
+ }
61
79
  /** Priority value: higher numbers run first. */
62
80
  priorityOf(agent_type) {
63
81
  switch (agent_type) {
@@ -146,7 +164,40 @@ export class SubagentPool extends EventEmitter {
146
164
  if (this.disposed) {
147
165
  return Promise.reject(new Error("SubagentPool has been disposed"));
148
166
  }
149
- const { forceAgent, context, model, provider } = options;
167
+ const begin = this.beginDispatch(task, options);
168
+ if (begin.handled_inline) {
169
+ return { handled_inline: true, reason: begin.reason };
170
+ }
171
+ const result = await this.wait_for(begin.task_id);
172
+ return {
173
+ handled_inline: false,
174
+ task_id: begin.task_id,
175
+ agent_type: begin.agent_type,
176
+ reason: begin.reason,
177
+ result,
178
+ duration: Date.now() - begin.startTime,
179
+ };
180
+ }
181
+ /**
182
+ * Fire-and-forget dispatch for background agents. Spawns the subagent and
183
+ * returns its handle immediately; the caller polls get_status()/collect().
184
+ */
185
+ dispatchDetached(task, options = {}) {
186
+ if (this.disposed) {
187
+ throw new Error("SubagentPool has been disposed");
188
+ }
189
+ const begin = this.beginDispatch(task, options);
190
+ if (begin.handled_inline) {
191
+ return { handled_inline: true, reason: begin.reason };
192
+ }
193
+ return { handled_inline: false, task_id: begin.task_id, agent_type: begin.agent_type, reason: begin.reason };
194
+ }
195
+ /**
196
+ * Evaluate, log, and spawn a task without waiting. Shared by dispatch()
197
+ * (blocking) and dispatchDetached() (background).
198
+ */
199
+ beginDispatch(task, options) {
200
+ const { forceAgent, context, model, provider, sessionFile } = options;
150
201
  const evaluator = new DispatchEvaluator();
151
202
  const analysis = evaluator.evaluate(task);
152
203
  if (!forceAgent && !analysis.should_delegate) {
@@ -158,8 +209,7 @@ export class SubagentPool extends EventEmitter {
158
209
  const complexity = analysis.estimated_complexity;
159
210
  // Pre-dispatch logging. Use stderr: stdout is reserved for the JSON event
160
211
  // stream / TUI render and must not be polluted.
161
- const logLine = `[DISPATCH] agent=${agent_type} reason=${reason} complexity=${complexity} task_id=${task_id}`;
162
- console.error(logLine);
212
+ console.error(`[DISPATCH] agent=${agent_type} reason=${reason} complexity=${complexity} task_id=${task_id}`);
163
213
  this.writeDispatchLog(task_id, agent_type, reason, complexity, task);
164
214
  const poolTask = {
165
215
  task_id,
@@ -168,20 +218,53 @@ export class SubagentPool extends EventEmitter {
168
218
  context,
169
219
  model,
170
220
  provider,
221
+ sessionFile,
171
222
  cwd: this.cwd,
172
223
  };
173
224
  const startTime = Date.now();
174
225
  this.spawn(poolTask);
175
- const result = await this.wait_for(task_id);
176
- const duration = Date.now() - startTime;
177
- return {
178
- handled_inline: false,
179
- task_id,
180
- agent_type,
181
- reason,
182
- result,
183
- duration,
184
- };
226
+ return { handled_inline: false, task_id, agent_type, reason, startTime };
227
+ }
228
+ /**
229
+ * Non-destructively read a completed task's result (for background polling).
230
+ * Returns undefined while the task is still running/queued, or if its result
231
+ * was already consumed via wait_for().
232
+ */
233
+ collect(task_id) {
234
+ return this.completed.get(task_id);
235
+ }
236
+ /** Absolute path of the persisted session file for a task. */
237
+ getSessionFile(task_id, cwd = this.cwd) {
238
+ return join(getDispatchTaskDir(cwd, task_id), "session.jsonl");
239
+ }
240
+ /**
241
+ * Resume a previously dispatched subagent, continuing its persisted session
242
+ * with a follow-up prompt. Recovers the original agent type from its dispatch
243
+ * log. Rejects if no resumable session exists for the task.
244
+ */
245
+ async resume(task_id, prompt, options = {}) {
246
+ if (this.disposed) {
247
+ return Promise.reject(new Error("SubagentPool has been disposed"));
248
+ }
249
+ const sessionFile = this.getSessionFile(task_id);
250
+ if (!existsSync(sessionFile)) {
251
+ return Promise.reject(new Error(`No resumable session for task "${task_id}" (expected ${sessionFile}).`));
252
+ }
253
+ const agent_type = this.readDispatchAgentType(task_id) ?? "explore";
254
+ return this.dispatch(prompt, { ...options, forceAgent: agent_type, sessionFile });
255
+ }
256
+ /** Recover the agent type a task was dispatched with, from its dispatch log. */
257
+ readDispatchAgentType(task_id) {
258
+ const path = join(getDispatchTaskDir(this.cwd, task_id), "dispatch-log.json");
259
+ if (!existsSync(path))
260
+ return undefined;
261
+ try {
262
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
263
+ return typeof parsed.agent_type === "string" ? parsed.agent_type : undefined;
264
+ }
265
+ catch {
266
+ return undefined;
267
+ }
185
268
  }
186
269
  /**
187
270
  * Dispatch a batch of subtasks concurrently.
@@ -207,7 +290,7 @@ export class SubagentPool extends EventEmitter {
207
290
  complexity,
208
291
  task,
209
292
  };
210
- const path = join(this.cwd, CONFIG_DIR_NAME, "agents", task_id, "dispatch-log.json");
293
+ const path = join(getDispatchTaskDir(this.cwd, task_id), "dispatch-log.json");
211
294
  try {
212
295
  mkdirSync(dirname(path), { recursive: true });
213
296
  writeFileSync(path, JSON.stringify(log, null, 2));
@@ -228,7 +311,7 @@ export class SubagentPool extends EventEmitter {
228
311
  budget_exceeded: result.budget_exceeded,
229
312
  result_data: result.result_data,
230
313
  };
231
- const path = join(this.cwd, CONFIG_DIR_NAME, "agents", task_id, "output.json");
314
+ const path = join(getDispatchTaskDir(this.cwd, task_id), "output.json");
232
315
  try {
233
316
  mkdirSync(dirname(path), { recursive: true });
234
317
  writeFileSync(path, JSON.stringify(output, null, 2));
@@ -272,28 +355,58 @@ export class SubagentPool extends EventEmitter {
272
355
  }
273
356
  /** Build CLI arguments for a task. */
274
357
  buildArgs(task) {
275
- const args = [...this.prefixArgs, "--mode", "json", "--no-session", "--task-id", task.task_id];
358
+ // Persist the child's session so a finished/interrupted subagent can be
359
+ // resumed later (see resume()). SessionManager.open() creates the file on
360
+ // first run and continues it on subsequent runs.
361
+ const sessionFile = task.sessionFile ?? this.getSessionFile(task.task_id, task.cwd ?? this.cwd);
362
+ const args = [
363
+ ...this.prefixArgs,
364
+ "--mode",
365
+ "json",
366
+ "--session",
367
+ sessionFile,
368
+ "--task-id",
369
+ task.task_id,
370
+ ];
371
+ // Prefer the data-driven agent definition from the registry; fall back to the
372
+ // built-in mode prompt/allowlist for legacy modes not present in the registry.
373
+ const def = task.agent_type ? this.getRegistry().get(task.agent_type) : undefined;
276
374
  if (task.agent_type) {
277
- try {
278
- const mode = task.agent_type;
279
- const systemPrompt = getSubagentSystemPrompt(mode);
280
- args.push("--system-prompt", systemPrompt);
281
- // Enforce the per-mode tool allowlist so read-only modes cannot edit/write.
282
- const tools = MODE_TOOLS[mode];
283
- if (tools) {
284
- args.push("--tools", tools.join(","));
375
+ const mode = task.agent_type;
376
+ let systemPrompt = def?.prompt;
377
+ if (!systemPrompt) {
378
+ try {
379
+ systemPrompt = getSubagentSystemPrompt(mode);
285
380
  }
381
+ catch {
382
+ systemPrompt = undefined;
383
+ }
384
+ }
385
+ if (systemPrompt) {
386
+ args.push("--system-prompt", systemPrompt);
286
387
  }
287
- catch {
288
- // Unknown mode, skip custom system prompt
388
+ // Tool allowlist: prefer the definition's normalized allowlist. When the
389
+ // definition omits tools (inherit-all) but the agent maps to a built-in
390
+ // mode, fall back to MODE_TOOLS to preserve the read-only sandbox.
391
+ const tools = def?.tools ?? MODE_TOOLS[mode];
392
+ if (tools && tools.length > 0) {
393
+ args.push("--tools", tools.join(","));
289
394
  }
290
395
  }
291
- if (task.model) {
292
- args.push("--model", task.model);
396
+ // Model precedence: a definition's explicit model wins (unless it is the
397
+ // `inherit` sentinel), otherwise use the caller-provided model.
398
+ const explicitModel = def?.model && def.model !== MODEL_INHERIT ? def.model : undefined;
399
+ const modelToUse = explicitModel ?? task.model;
400
+ if (modelToUse) {
401
+ args.push("--model", modelToUse);
293
402
  }
294
403
  if (task.provider) {
295
404
  args.push("--provider", task.provider);
296
405
  }
406
+ // Always give subagents a hard turn cap. With the token budget now advisory
407
+ // (warn-only), this is the guaranteed hard stop for a runaway subagent.
408
+ const maxTurns = def?.maxTurns && def.maxTurns > 0 ? def.maxTurns : DEFAULT_SUBAGENT_MAX_TURNS;
409
+ args.push("--max-turns", String(maxTurns));
297
410
  const prompt = task.context?.trim()
298
411
  ? `Context from the calling agent:\n\n${task.context.trim()}\n\nTask: ${task.task.trim()}`
299
412
  : `Task: ${task.task.trim()}`;
@@ -313,11 +426,11 @@ export class SubagentPool extends EventEmitter {
313
426
  budget.on("budget_warning", (data) => {
314
427
  this.emit("budget_warning", data);
315
428
  });
316
- budget.on("budget_exceeded", () => {
317
- const slot = this.slots.get(task.task_id);
318
- if (slot && !slot.process.killed) {
319
- slot.process.kill("SIGTERM");
320
- }
429
+ // The token budget is advisory: surface telemetry but never kill. The
430
+ // guaranteed hard stop is the per-subagent turn cap (--max-turns); see
431
+ // DEFAULT_SUBAGENT_MAX_TURNS.
432
+ budget.on("budget_exceeded", (data) => {
433
+ this.emit("budget_exceeded", data);
321
434
  });
322
435
  this.budgets.set(task.task_id, budget);
323
436
  }
@@ -420,37 +533,14 @@ export class SubagentPool extends EventEmitter {
420
533
  }
421
534
  const result = {
422
535
  task_id: task.task_id,
423
- ok: code === 0 && !budgetExceeded,
536
+ ok: code === 0,
424
537
  stdout,
425
538
  stderr,
426
539
  exit_code: code,
540
+ // Advisory telemetry only: exceeding the budget never fails the task.
427
541
  budget_exceeded: budgetExceeded,
428
- status: code === 0 && !budgetExceeded ? "complete" : "failed",
542
+ status: code === 0 ? "complete" : "failed",
429
543
  };
430
- if (budgetExceeded) {
431
- // Force-return whatever exists in result.json, mark partial
432
- const resultData = this.tryReadResultJson(task.task_id, task.cwd ?? this.cwd);
433
- result.status = resultData ? "partial" : "failed";
434
- result.result_data = resultData;
435
- if (resultData) {
436
- result.ok = true; // partial is considered success with data
437
- }
438
- else {
439
- // Hard-stopped before any result.json was written. Surface a clear,
440
- // actionable reason instead of letting callers report "unknown error".
441
- result.error = `Token budget exceeded (used ${tokens_used} of ${budget.getLimit()}) before the subagent produced a result. Narrow the task scope or raise the budget for ${task.agent_type} subagents.`;
442
- }
443
- this.writeOutputJson(task.task_id, result);
444
- this.emit(resultData ? "task_done" : "task_failed", {
445
- task_id: task.task_id,
446
- agent_type: task.agent_type,
447
- duration,
448
- tokens_used,
449
- ...(resultData ? { status: "partial" } : { error: result.error }),
450
- });
451
- this.resolveWaiter(task.task_id, result);
452
- return;
453
- }
454
544
  if (result.ok) {
455
545
  const verification = this.verifier.verify(task.task_id, task.cwd ?? this.cwd);
456
546
  if (!verification.valid) {
@@ -529,7 +619,7 @@ export class SubagentPool extends EventEmitter {
529
619
  });
530
620
  }
531
621
  tryReadResultJson(task_id, cwd) {
532
- const path = join(cwd, CONFIG_DIR_NAME, "agents", task_id, "result.json");
622
+ const path = join(getDispatchTaskDir(cwd, task_id), "result.json");
533
623
  if (!existsSync(path))
534
624
  return undefined;
535
625
  try {