neuro-cli 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +510 -0
  3. package/dist/advisor/advisor.d.ts +50 -0
  4. package/dist/advisor/advisor.js +178 -0
  5. package/dist/agents/base.d.ts +62 -0
  6. package/dist/agents/base.js +215 -0
  7. package/dist/agents/orchestrator.d.ts +46 -0
  8. package/dist/agents/orchestrator.js +192 -0
  9. package/dist/agents/team.d.ts +51 -0
  10. package/dist/agents/team.js +210 -0
  11. package/dist/api/models.d.ts +23 -0
  12. package/dist/api/models.js +514 -0
  13. package/dist/api/ollama.d.ts +153 -0
  14. package/dist/api/ollama.js +751 -0
  15. package/dist/api/openrouter.d.ts +55 -0
  16. package/dist/api/openrouter.js +223 -0
  17. package/dist/commands/commands.d.ts +43 -0
  18. package/dist/commands/commands.js +308 -0
  19. package/dist/config/config.d.ts +8 -0
  20. package/dist/config/config.js +311 -0
  21. package/dist/context/compaction.d.ts +54 -0
  22. package/dist/context/compaction.js +251 -0
  23. package/dist/context/custom-agents.d.ts +107 -0
  24. package/dist/context/custom-agents.js +397 -0
  25. package/dist/context/custom-tools.d.ts +120 -0
  26. package/dist/context/custom-tools.js +564 -0
  27. package/dist/context/git-checkpoint.d.ts +52 -0
  28. package/dist/context/git-checkpoint.js +240 -0
  29. package/dist/context/neuro-md.d.ts +48 -0
  30. package/dist/context/neuro-md.js +202 -0
  31. package/dist/context/neuroignore.d.ts +102 -0
  32. package/dist/context/neuroignore.js +441 -0
  33. package/dist/context/repo-map.d.ts +38 -0
  34. package/dist/context/repo-map.js +220 -0
  35. package/dist/context/skill-standard.d.ts +262 -0
  36. package/dist/context/skill-standard.js +1156 -0
  37. package/dist/context/skill-system.d.ts +75 -0
  38. package/dist/context/skill-system.js +578 -0
  39. package/dist/context/tree-sitter.d.ts +99 -0
  40. package/dist/context/tree-sitter.js +1956 -0
  41. package/dist/core/acp.d.ts +325 -0
  42. package/dist/core/acp.js +1498 -0
  43. package/dist/core/api-server.d.ts +143 -0
  44. package/dist/core/api-server.js +550 -0
  45. package/dist/core/approval.d.ts +81 -0
  46. package/dist/core/approval.js +432 -0
  47. package/dist/core/auto-compact.d.ts +127 -0
  48. package/dist/core/auto-compact.js +436 -0
  49. package/dist/core/auto-mode.d.ts +232 -0
  50. package/dist/core/auto-mode.js +831 -0
  51. package/dist/core/background-session.d.ts +166 -0
  52. package/dist/core/background-session.js +696 -0
  53. package/dist/core/cicd.d.ts +257 -0
  54. package/dist/core/cicd.js +1443 -0
  55. package/dist/core/cloud-sync.d.ts +156 -0
  56. package/dist/core/cloud-sync.js +582 -0
  57. package/dist/core/code-review.d.ts +132 -0
  58. package/dist/core/code-review.js +1191 -0
  59. package/dist/core/completion.d.ts +49 -0
  60. package/dist/core/completion.js +384 -0
  61. package/dist/core/context.d.ts +38 -0
  62. package/dist/core/context.js +144 -0
  63. package/dist/core/diff-preview.d.ts +35 -0
  64. package/dist/core/diff-preview.js +173 -0
  65. package/dist/core/doom-loop.d.ts +51 -0
  66. package/dist/core/doom-loop.js +179 -0
  67. package/dist/core/engine.d.ts +183 -0
  68. package/dist/core/engine.js +942 -0
  69. package/dist/core/extended-thinking.d.ts +103 -0
  70. package/dist/core/extended-thinking.js +269 -0
  71. package/dist/core/fallback.d.ts +54 -0
  72. package/dist/core/fallback.js +104 -0
  73. package/dist/core/git-worktree.d.ts +139 -0
  74. package/dist/core/git-worktree.js +614 -0
  75. package/dist/core/headless.d.ts +30 -0
  76. package/dist/core/headless.js +101 -0
  77. package/dist/core/i18n.d.ts +83 -0
  78. package/dist/core/i18n.js +739 -0
  79. package/dist/core/linting.d.ts +129 -0
  80. package/dist/core/linting.js +699 -0
  81. package/dist/core/model-router.d.ts +109 -0
  82. package/dist/core/model-router.js +581 -0
  83. package/dist/core/multi-model.d.ts +243 -0
  84. package/dist/core/multi-model.js +1099 -0
  85. package/dist/core/multi-session.d.ts +144 -0
  86. package/dist/core/multi-session.js +442 -0
  87. package/dist/core/multimodal.d.ts +125 -0
  88. package/dist/core/multimodal.js +286 -0
  89. package/dist/core/observability.d.ts +93 -0
  90. package/dist/core/observability.js +737 -0
  91. package/dist/core/os-sandbox.d.ts +122 -0
  92. package/dist/core/os-sandbox.js +1193 -0
  93. package/dist/core/outcome-grading.d.ts +228 -0
  94. package/dist/core/outcome-grading.js +1123 -0
  95. package/dist/core/output-styles.d.ts +57 -0
  96. package/dist/core/output-styles.js +382 -0
  97. package/dist/core/parallel-agents.d.ts +183 -0
  98. package/dist/core/parallel-agents.js +563 -0
  99. package/dist/core/plugin-bundle.d.ts +236 -0
  100. package/dist/core/plugin-bundle.js +887 -0
  101. package/dist/core/plugin-sdk.d.ts +139 -0
  102. package/dist/core/plugin-sdk.js +273 -0
  103. package/dist/core/prompt-cache.d.ts +163 -0
  104. package/dist/core/prompt-cache.js +599 -0
  105. package/dist/core/sandbox.d.ts +127 -0
  106. package/dist/core/sandbox.js +369 -0
  107. package/dist/core/scheduled-tasks.d.ts +151 -0
  108. package/dist/core/scheduled-tasks.js +502 -0
  109. package/dist/core/security-scanner.d.ts +160 -0
  110. package/dist/core/security-scanner.js +1494 -0
  111. package/dist/core/session.d.ts +83 -0
  112. package/dist/core/session.js +269 -0
  113. package/dist/core/shell-completion.d.ts +51 -0
  114. package/dist/core/shell-completion.js +674 -0
  115. package/dist/core/smart-monitor.d.ts +146 -0
  116. package/dist/core/smart-monitor.js +1199 -0
  117. package/dist/core/spec-driven.d.ts +233 -0
  118. package/dist/core/spec-driven.js +1485 -0
  119. package/dist/core/spending-warnings.d.ts +123 -0
  120. package/dist/core/spending-warnings.js +456 -0
  121. package/dist/core/sub-agent.d.ts +298 -0
  122. package/dist/core/sub-agent.js +1023 -0
  123. package/dist/core/telemetry.d.ts +157 -0
  124. package/dist/core/telemetry.js +412 -0
  125. package/dist/core/terminal-ux.d.ts +134 -0
  126. package/dist/core/terminal-ux.js +649 -0
  127. package/dist/core/testing.d.ts +146 -0
  128. package/dist/core/testing.js +1200 -0
  129. package/dist/core/types.d.ts +439 -0
  130. package/dist/core/types.js +6 -0
  131. package/dist/core/undo-redo.d.ts +112 -0
  132. package/dist/core/undo-redo.js +290 -0
  133. package/dist/core/updater.d.ts +159 -0
  134. package/dist/core/updater.js +608 -0
  135. package/dist/core/vim-mode.d.ts +151 -0
  136. package/dist/core/vim-mode.js +771 -0
  137. package/dist/core/voice.d.ts +137 -0
  138. package/dist/core/voice.js +538 -0
  139. package/dist/core/web-dashboard.d.ts +109 -0
  140. package/dist/core/web-dashboard.js +484 -0
  141. package/dist/hooks/hooks.d.ts +74 -0
  142. package/dist/hooks/hooks.js +160 -0
  143. package/dist/hooks/llm-evaluator.d.ts +165 -0
  144. package/dist/hooks/llm-evaluator.js +560 -0
  145. package/dist/index.d.ts +3 -0
  146. package/dist/index.js +1186 -0
  147. package/dist/lsp/lsp-manager.d.ts +63 -0
  148. package/dist/lsp/lsp-manager.js +351 -0
  149. package/dist/mcp/client.d.ts +133 -0
  150. package/dist/mcp/client.js +684 -0
  151. package/dist/mcp/mcp-apps.d.ts +70 -0
  152. package/dist/mcp/mcp-apps.js +1007 -0
  153. package/dist/tools/bash.d.ts +5 -0
  154. package/dist/tools/bash.js +195 -0
  155. package/dist/tools/browser.d.ts +92 -0
  156. package/dist/tools/browser.js +1570 -0
  157. package/dist/tools/extended.d.ts +6 -0
  158. package/dist/tools/extended.js +191 -0
  159. package/dist/tools/file.d.ts +10 -0
  160. package/dist/tools/file.js +382 -0
  161. package/dist/tools/github.d.ts +389 -0
  162. package/dist/tools/github.js +759 -0
  163. package/dist/tools/index.d.ts +9 -0
  164. package/dist/tools/index.js +40 -0
  165. package/dist/tools/memory.d.ts +6 -0
  166. package/dist/tools/memory.js +197 -0
  167. package/dist/tools/registry.d.ts +29 -0
  168. package/dist/tools/registry.js +64 -0
  169. package/dist/tools/web.d.ts +6 -0
  170. package/dist/tools/web.js +150 -0
  171. package/dist/ui/renderer.d.ts +97 -0
  172. package/dist/ui/renderer.js +279 -0
  173. package/dist/ui/theme.d.ts +27 -0
  174. package/dist/ui/theme.js +106 -0
  175. package/package.json +83 -0
@@ -0,0 +1,1023 @@
1
+ // ============================================================
2
+ // NeuroCLI - Sub-Agent Spawning with Scoped Tool Access (GAP-27)
3
+ // Enables parent agents to spawn child sub-agents with
4
+ // restricted tool access, file scope, resource limits,
5
+ // hierarchical delegation, and structured result handoff.
6
+ // Inspired by Claude Code's Task tool and Cursor sub-agents.
7
+ // Uses only Node.js built-in modules + existing project deps.
8
+ // ============================================================
9
+ import { EventEmitter } from 'events';
10
+ import { resolve, relative, isAbsolute } from 'path';
11
+ import { existsSync, readFileSync, statSync, readdirSync } from 'fs';
12
+ import { ToolRegistry } from '../tools/registry.js';
13
+ import { BaseAgent } from '../agents/base.js';
14
+ // ============================================================
15
+ // Constants
16
+ // ============================================================
17
+ /** Default maximum nesting depth for sub-agents */
18
+ const DEFAULT_MAX_DEPTH = 3;
19
+ /** Default resource limits if not specified */
20
+ const DEFAULT_RESOURCE_LIMITS = {
21
+ maxTokens: 100_000,
22
+ maxTurns: 20,
23
+ timeoutMs: 120_000, // 2 minutes
24
+ };
25
+ /** Names of tools that involve file I/O (used for file scope wrapping) */
26
+ const FILE_TOOL_NAMES = new Set([
27
+ 'read_file',
28
+ 'write_file',
29
+ 'edit_file',
30
+ 'list_directory',
31
+ 'search_files',
32
+ 'create_directory',
33
+ 'move_file',
34
+ 'delete_file',
35
+ 'file_stats',
36
+ ]);
37
+ // ============================================================
38
+ // ScopedToolRegistry
39
+ // ============================================================
40
+ /**
41
+ * A filtered view of a ToolRegistry that only exposes tools
42
+ * permitted by the sub-agent's allowlist/denylist, and wraps
43
+ * file tools with path validation for file scope enforcement.
44
+ */
45
+ class ScopedToolRegistry extends ToolRegistry {
46
+ allowedSet;
47
+ deniedSet;
48
+ fileScopeAllowed;
49
+ fileScopeDenied;
50
+ fileScopeEnabled;
51
+ workingDir;
52
+ constructor(parentRegistry, allowedTools, deniedTools, fileScope, workingDir) {
53
+ super();
54
+ this.allowedSet = new Set(allowedTools);
55
+ this.deniedSet = new Set(deniedTools);
56
+ this.workingDir = workingDir;
57
+ // Resolve file scope paths to absolute
58
+ this.fileScopeAllowed = new Set((fileScope?.allowedPaths ?? []).map((p) => isAbsolute(p) ? p : resolve(workingDir, p)));
59
+ this.fileScopeDenied = new Set((fileScope?.deniedPaths ?? []).map((p) => isAbsolute(p) ? p : resolve(workingDir, p)));
60
+ this.fileScopeEnabled = this.fileScopeAllowed.size > 0 || this.fileScopeDenied.size > 0;
61
+ // Copy permitted tools from parent, wrapping file tools if needed
62
+ for (const tool of parentRegistry.getAll()) {
63
+ if (!this.allowedSet.has(tool.name))
64
+ continue;
65
+ if (this.deniedSet.has(tool.name))
66
+ continue;
67
+ if (this.fileScopeEnabled && FILE_TOOL_NAMES.has(tool.name)) {
68
+ this.register(this.wrapFileTool(tool));
69
+ }
70
+ else {
71
+ this.register(tool);
72
+ }
73
+ }
74
+ }
75
+ /**
76
+ * Wrap a file tool executor with path validation.
77
+ * If a file path argument is outside the allowed scope or
78
+ * inside the denied scope, the tool returns an error instead
79
+ * of executing.
80
+ */
81
+ wrapFileTool(tool) {
82
+ const originalExecute = tool.execute.bind(tool);
83
+ const scopeAllowed = this.fileScopeAllowed;
84
+ const scopeDenied = this.fileScopeDenied;
85
+ const workingDir = this.workingDir;
86
+ const wrappedExecute = async (args, context) => {
87
+ // Find the file path argument — tools use various arg names
88
+ const filePath = this.extractFilePath(args);
89
+ if (filePath !== null) {
90
+ const absolutePath = isAbsolute(filePath) ? filePath : resolve(workingDir, filePath);
91
+ // Check denied paths first (denied takes priority)
92
+ for (const denied of scopeDenied) {
93
+ if (absolutePath.startsWith(denied) || absolutePath === denied) {
94
+ return `ERROR: Access denied — path "${filePath}" is outside the allowed file scope.`;
95
+ }
96
+ }
97
+ // If allowed paths are specified, the path must be under one of them
98
+ if (scopeAllowed.size > 0) {
99
+ let isAllowed = false;
100
+ for (const allowed of scopeAllowed) {
101
+ if (absolutePath.startsWith(allowed) || absolutePath === allowed) {
102
+ isAllowed = true;
103
+ break;
104
+ }
105
+ }
106
+ if (!isAllowed) {
107
+ return `ERROR: Access denied — path "${filePath}" is outside the allowed file scope.`;
108
+ }
109
+ }
110
+ }
111
+ return originalExecute(args, context);
112
+ };
113
+ return {
114
+ name: tool.name,
115
+ definition: tool.definition,
116
+ description: tool.description,
117
+ parameters: tool.parameters,
118
+ execute: wrappedExecute,
119
+ getApprovalRequest: tool.getApprovalRequest?.bind(tool),
120
+ risk: tool.risk,
121
+ };
122
+ }
123
+ /**
124
+ * Extract a file path from tool arguments.
125
+ * Different tools use different argument names for paths.
126
+ */
127
+ extractFilePath(args) {
128
+ const pathKeys = ['path', 'filePath', 'file_path', 'source', 'directory', 'dir'];
129
+ for (const key of pathKeys) {
130
+ if (typeof args[key] === 'string' && args[key].length > 0) {
131
+ return args[key];
132
+ }
133
+ }
134
+ // Check for nested destinations (e.g., move_file has source + destination)
135
+ if (typeof args['destination'] === 'string') {
136
+ return args['destination'];
137
+ }
138
+ return null;
139
+ }
140
+ }
141
+ // ============================================================
142
+ // FileSnapshot
143
+ // ============================================================
144
+ /**
145
+ * Simple file snapshot utility that records file content hashes
146
+ * before sub-agent execution and compares afterwards to detect
147
+ * what files were modified.
148
+ */
149
+ class FileSnapshot {
150
+ workingDir;
151
+ snapshot = new Map();
152
+ scopedPaths;
153
+ constructor(workingDir, fileScope) {
154
+ this.workingDir = workingDir;
155
+ if (fileScope && fileScope.allowedPaths.length > 0) {
156
+ this.scopedPaths = fileScope.allowedPaths.map((p) => isAbsolute(p) ? p : resolve(workingDir, p));
157
+ }
158
+ else {
159
+ this.scopedPaths = [workingDir];
160
+ }
161
+ }
162
+ /**
163
+ * Take a snapshot of all files in the scoped paths.
164
+ * Uses a fast hash (simple content length + first/last bytes) for
165
+ * comparison purposes. Does not follow symlinks or descend into
166
+ * node_modules / .git directories.
167
+ */
168
+ take() {
169
+ this.snapshot = new Map();
170
+ for (const scopedPath of this.scopedPaths) {
171
+ this.walkDir(scopedPath);
172
+ }
173
+ return new Map(this.snapshot);
174
+ }
175
+ /**
176
+ * Compare the current file state against the snapshot and
177
+ * return a list of detected changes.
178
+ */
179
+ diff() {
180
+ const changes = [];
181
+ const currentSnapshot = new Map();
182
+ // Re-scan the same directories
183
+ for (const scopedPath of this.scopedPaths) {
184
+ this.walkDirInto(scopedPath, currentSnapshot);
185
+ }
186
+ // Find modified and deleted files (present in original snapshot)
187
+ for (const [filePath, originalHash] of this.snapshot) {
188
+ const currentHash = currentSnapshot.get(filePath);
189
+ if (currentHash === undefined) {
190
+ changes.push({ path: filePath, type: 'delete' });
191
+ }
192
+ else if (currentHash !== originalHash) {
193
+ changes.push({ path: filePath, type: 'modify' });
194
+ }
195
+ }
196
+ // Find newly created files (present in current but not original)
197
+ for (const [filePath] of currentSnapshot) {
198
+ if (!this.snapshot.has(filePath)) {
199
+ changes.push({ path: filePath, type: 'create' });
200
+ }
201
+ }
202
+ return changes;
203
+ }
204
+ /**
205
+ * Recursively walk a directory and record file hashes.
206
+ */
207
+ walkDir(dirPath) {
208
+ this.walkDirInto(dirPath, this.snapshot);
209
+ }
210
+ walkDirInto(dirPath, target) {
211
+ if (!existsSync(dirPath))
212
+ return;
213
+ let entries;
214
+ try {
215
+ entries = readdirSync(dirPath, { withFileTypes: true });
216
+ }
217
+ catch {
218
+ return; // Permission denied or similar
219
+ }
220
+ for (const entry of entries) {
221
+ // Skip common non-essential directories
222
+ if (entry.isDirectory()) {
223
+ if (entry.name === 'node_modules' ||
224
+ entry.name === '.git' ||
225
+ entry.name === '.next' ||
226
+ entry.name === 'dist' ||
227
+ entry.name === '.turbo') {
228
+ continue;
229
+ }
230
+ this.walkDirInto(resolve(dirPath, entry.name), target);
231
+ }
232
+ else if (entry.isFile()) {
233
+ const fullPath = resolve(dirPath, entry.name);
234
+ try {
235
+ const hash = this.quickHash(fullPath);
236
+ target.set(fullPath, hash);
237
+ }
238
+ catch {
239
+ // File may be unreadable; skip
240
+ }
241
+ }
242
+ }
243
+ }
244
+ /**
245
+ * Quick content hash: uses file size + first 64 bytes + last 64 bytes.
246
+ * Fast and sufficient for detecting modifications without reading
247
+ * entire large files.
248
+ */
249
+ quickHash(filePath) {
250
+ const stat = statSync(filePath);
251
+ const size = stat.size;
252
+ if (size === 0)
253
+ return `0:empty`;
254
+ const fd = [];
255
+ const buf = Buffer.alloc(Math.min(size, 128));
256
+ const fileHandle = readFileSync(filePath);
257
+ // Use first 64 bytes and last 64 bytes
258
+ const firstChunk = fileHandle.subarray(0, Math.min(64, size));
259
+ const lastChunk = size > 64 ? fileHandle.subarray(Math.max(0, size - 64)) : Buffer.alloc(0);
260
+ return `${size}:${firstChunk.toString('hex')}:${lastChunk.toString('hex')}`;
261
+ }
262
+ }
263
+ // ============================================================
264
+ // SubAgentManager
265
+ // ============================================================
266
+ /**
267
+ * Manages the lifecycle of sub-agents spawned by a parent agent.
268
+ *
269
+ * A parent agent creates a SubAgentManager with its own OpenRouterClient
270
+ * and ToolRegistry. When it wants to delegate work, it calls spawn()
271
+ * or spawnAsync() with a configuration that defines the child's
272
+ * permissions, tools, and resource limits.
273
+ *
274
+ * The SubAgentManager:
275
+ * - Creates a temporary BaseAgent with a scoped tool registry
276
+ * - Enforces resource limits (tokens, turns, timeout)
277
+ * - Tracks file modifications via before/after snapshots
278
+ * - Supports hierarchical delegation (sub-agents can spawn their own children)
279
+ * - Emits events for status changes
280
+ *
281
+ * Usage:
282
+ * ```typescript
283
+ * const manager = new SubAgentManager(client, registry, '/project', 'session-1');
284
+ *
285
+ * // Sequential: block until done
286
+ * const result = await manager.spawn({
287
+ * name: 'code-searcher',
288
+ * systemPrompt: 'You are a code search specialist.',
289
+ * allowedTools: ['search_files', 'read_file'],
290
+ * resourceLimits: { maxTokens: 50_000, maxTurns: 10, timeoutMs: 60_000 },
291
+ * maxDepth: 2,
292
+ * mode: 'sequential',
293
+ * }, 'Find all uses of the deprecated API');
294
+ *
295
+ * // Async: fire and forget, get a task ID
296
+ * const taskId = await manager.spawnAsync({ ... }, 'Refactor the utils');
297
+ * // Later...
298
+ * const status = manager.getStatus(taskId);
299
+ * const result = await manager.waitFor(taskId);
300
+ * ```
301
+ */
302
+ export class SubAgentManager extends EventEmitter {
303
+ client;
304
+ parentRegistry;
305
+ workingDirectory;
306
+ sessionId;
307
+ defaultModel;
308
+ currentDepth;
309
+ maxGlobalDepth;
310
+ /** Active and completed sub-agent records */
311
+ records = new Map();
312
+ /** Monotonic counter for generating unique task IDs */
313
+ taskCounter = 0;
314
+ /**
315
+ * @param client The OpenRouterClient shared with the parent agent
316
+ * @param registry The parent's ToolRegistry (tools will be filtered for children)
317
+ * @param workingDir The working directory for sub-agents
318
+ * @param sessionId The session ID to inherit
319
+ * @param defaultModel The default model to use (inherited from parent)
320
+ * @param currentDepth Current nesting depth (0 for top-level manager)
321
+ * @param maxGlobalDepth Maximum allowed nesting depth across the hierarchy
322
+ */
323
+ constructor(client, registry, workingDir, sessionId, defaultModel = 'qwen/qwen3-coder:free', currentDepth = 0, maxGlobalDepth = DEFAULT_MAX_DEPTH) {
324
+ super();
325
+ this.client = client;
326
+ this.parentRegistry = registry;
327
+ this.workingDirectory = workingDir;
328
+ this.sessionId = sessionId;
329
+ this.defaultModel = defaultModel;
330
+ this.currentDepth = currentDepth;
331
+ this.maxGlobalDepth = maxGlobalDepth;
332
+ }
333
+ // ----------------------------------------------------------
334
+ // Public API — Spawn
335
+ // ----------------------------------------------------------
336
+ /**
337
+ * Spawn a sub-agent in sequential mode and wait for its result.
338
+ * The parent agent blocks until the child completes (or fails/times out).
339
+ *
340
+ * @param config Configuration for the sub-agent
341
+ * @param prompt The task prompt to send to the sub-agent
342
+ * @returns Structured result from the sub-agent
343
+ * @throws Error if the sub-agent fails or is cancelled
344
+ */
345
+ async spawn(config, prompt) {
346
+ const effectiveConfig = this.applyDefaults(config);
347
+ this.validateConfig(effectiveConfig);
348
+ const taskId = this.generateTaskId(effectiveConfig.name);
349
+ const record = this.createRecord(taskId, effectiveConfig, prompt);
350
+ this.emit('subagent:spawning', taskId, effectiveConfig.name);
351
+ try {
352
+ const result = await this.executeSubAgent(record, effectiveConfig, prompt);
353
+ return result;
354
+ }
355
+ catch (err) {
356
+ const error = err instanceof Error ? err : new Error(String(err));
357
+ const failResult = {
358
+ success: false,
359
+ output: '',
360
+ filesModified: [],
361
+ tokensUsed: 0,
362
+ turnsCompleted: 0,
363
+ error: error.message,
364
+ duration: Date.now() - record.startedAt,
365
+ };
366
+ record.status = 'failed';
367
+ record.result = failResult;
368
+ record.completedAt = Date.now();
369
+ record.completionReject?.(error);
370
+ this.emit('subagent:failed', taskId, error.message);
371
+ return failResult;
372
+ }
373
+ finally {
374
+ this.records.delete(taskId);
375
+ }
376
+ }
377
+ /**
378
+ * Spawn a sub-agent in async (fire-and-forget) mode.
379
+ * Returns a task ID immediately; the sub-agent runs in the background.
380
+ * Use getStatus() to check progress and waitFor() to collect results.
381
+ *
382
+ * @param config Configuration for the sub-agent
383
+ * @param prompt The task prompt to send to the sub-agent
384
+ * @returns Unique task ID for tracking this sub-agent
385
+ */
386
+ async spawnAsync(config, prompt) {
387
+ const effectiveConfig = this.applyDefaults(config);
388
+ this.validateConfig(effectiveConfig);
389
+ const taskId = this.generateTaskId(effectiveConfig.name);
390
+ const record = this.createRecord(taskId, effectiveConfig, prompt);
391
+ this.emit('subagent:spawning', taskId, effectiveConfig.name);
392
+ // Execute in the background (non-blocking)
393
+ this.executeSubAgent(record, effectiveConfig, prompt)
394
+ .then((result) => {
395
+ record.status = 'completed';
396
+ record.result = result;
397
+ record.completedAt = Date.now();
398
+ record.completionResolve?.(result);
399
+ this.emit('subagent:completed', taskId, result);
400
+ })
401
+ .catch((err) => {
402
+ if (record.status === 'cancelled')
403
+ return; // Already handled
404
+ const error = err instanceof Error ? err : new Error(String(err));
405
+ record.status = 'failed';
406
+ record.result = {
407
+ success: false,
408
+ output: '',
409
+ filesModified: [],
410
+ tokensUsed: 0,
411
+ turnsCompleted: 0,
412
+ error: error.message,
413
+ duration: Date.now() - record.startedAt,
414
+ };
415
+ record.completedAt = Date.now();
416
+ record.completionReject?.(error);
417
+ this.emit('subagent:failed', taskId, error.message);
418
+ });
419
+ return taskId;
420
+ }
421
+ // ----------------------------------------------------------
422
+ // Public API — Status & Control
423
+ // ----------------------------------------------------------
424
+ /**
425
+ * Get the current status of an async sub-agent task.
426
+ *
427
+ * @param taskId The task ID returned by spawnAsync()
428
+ * @returns Current status, or undefined if the task doesn't exist
429
+ */
430
+ getStatus(taskId) {
431
+ const record = this.records.get(taskId);
432
+ if (!record)
433
+ return undefined;
434
+ return record.status;
435
+ }
436
+ /**
437
+ * Get detailed info about a sub-agent.
438
+ *
439
+ * @param taskId The task ID
440
+ * @returns Sub-agent info, or undefined if not found
441
+ */
442
+ getInfo(taskId) {
443
+ const record = this.records.get(taskId);
444
+ if (!record)
445
+ return undefined;
446
+ return {
447
+ taskId: record.taskId,
448
+ name: record.config.name,
449
+ status: record.status,
450
+ depth: record.depth,
451
+ turnsCompleted: record.result?.turnsCompleted ?? 0,
452
+ tokensUsed: record.result?.tokensUsed ?? 0,
453
+ elapsedMs: Date.now() - record.startedAt,
454
+ parentTaskId: record.parentTaskId,
455
+ startedAt: record.startedAt,
456
+ };
457
+ }
458
+ /**
459
+ * Cancel a running sub-agent.
460
+ * The sub-agent's abort controller is triggered, causing it to
461
+ * stop at the next iteration boundary.
462
+ *
463
+ * @param taskId The task ID to cancel
464
+ * @returns True if the agent was successfully cancelled
465
+ */
466
+ cancel(taskId) {
467
+ const record = this.records.get(taskId);
468
+ if (!record)
469
+ return false;
470
+ if (record.status === 'completed' ||
471
+ record.status === 'failed' ||
472
+ record.status === 'cancelled') {
473
+ return false; // Already terminal
474
+ }
475
+ record.abortController.abort();
476
+ record.status = 'cancelled';
477
+ record.completedAt = Date.now();
478
+ if (!record.result) {
479
+ record.result = {
480
+ success: false,
481
+ output: '',
482
+ filesModified: [],
483
+ tokensUsed: 0,
484
+ turnsCompleted: 0,
485
+ error: 'Cancelled by parent',
486
+ duration: Date.now() - record.startedAt,
487
+ };
488
+ }
489
+ record.completionReject?.(new Error(`Sub-agent "${taskId}" was cancelled`));
490
+ this.emit('subagent:cancelled', taskId);
491
+ return true;
492
+ }
493
+ /**
494
+ * List all active sub-agents (pending, running, or timed_out).
495
+ *
496
+ * @returns Array of sub-agent info objects
497
+ */
498
+ list() {
499
+ const infos = [];
500
+ for (const record of this.records.values()) {
501
+ if (record.status === 'pending' ||
502
+ record.status === 'running' ||
503
+ record.status === 'timed_out') {
504
+ infos.push({
505
+ taskId: record.taskId,
506
+ name: record.config.name,
507
+ status: record.status,
508
+ depth: record.depth,
509
+ turnsCompleted: record.result?.turnsCompleted ?? 0,
510
+ tokensUsed: record.result?.tokensUsed ?? 0,
511
+ elapsedMs: Date.now() - record.startedAt,
512
+ parentTaskId: record.parentTaskId,
513
+ startedAt: record.startedAt,
514
+ });
515
+ }
516
+ }
517
+ return infos.sort((a, b) => a.startedAt - b.startedAt);
518
+ }
519
+ /**
520
+ * Wait for a specific async sub-agent to complete and return its result.
521
+ *
522
+ * @param taskId The task ID to wait for
523
+ * @returns The sub-agent's result
524
+ * @throws Error if the task doesn't exist or was cancelled
525
+ */
526
+ async waitFor(taskId) {
527
+ const record = this.records.get(taskId);
528
+ if (!record) {
529
+ throw new Error(`Sub-agent task "${taskId}" not found`);
530
+ }
531
+ // If already completed, return immediately
532
+ if (record.status === 'completed' && record.result) {
533
+ return record.result;
534
+ }
535
+ // If already failed/cancelled, throw
536
+ if ((record.status === 'failed' || record.status === 'cancelled') &&
537
+ record.result) {
538
+ if (record.result.error) {
539
+ throw new Error(record.result.error);
540
+ }
541
+ return record.result;
542
+ }
543
+ return record.completionPromise;
544
+ }
545
+ /**
546
+ * Wait for all currently running async sub-agents to complete.
547
+ * Returns results for all tasks that were active at the time of the call.
548
+ *
549
+ * @returns Array of results from all awaited sub-agents
550
+ */
551
+ async waitForAll() {
552
+ const activeIds = [];
553
+ for (const [id, record] of this.records) {
554
+ if (record.status === 'pending' ||
555
+ record.status === 'running') {
556
+ activeIds.push(id);
557
+ }
558
+ }
559
+ const results = await Promise.allSettled(activeIds.map((id) => this.waitFor(id)));
560
+ return results.map((r, i) => {
561
+ if (r.status === 'fulfilled')
562
+ return r.value;
563
+ // Rejected — return a failure result
564
+ return {
565
+ success: false,
566
+ output: '',
567
+ filesModified: [],
568
+ tokensUsed: 0,
569
+ turnsCompleted: 0,
570
+ error: r.reason?.message ?? String(r.reason),
571
+ duration: 0,
572
+ };
573
+ });
574
+ }
575
+ /**
576
+ * Get the result of a completed sub-agent.
577
+ *
578
+ * @param taskId The task ID
579
+ * @returns The result, or undefined if not completed
580
+ */
581
+ getResult(taskId) {
582
+ const record = this.records.get(taskId);
583
+ if (!record || record.status !== 'completed')
584
+ return undefined;
585
+ return record.result;
586
+ }
587
+ /**
588
+ * Get the current nesting depth of this manager.
589
+ * Useful for checking whether further sub-agent spawning is allowed.
590
+ */
591
+ get depth() {
592
+ return this.currentDepth;
593
+ }
594
+ /**
595
+ * Check whether this manager can spawn additional sub-agents
596
+ * (i.e., we haven't hit the max depth).
597
+ */
598
+ get canSpawn() {
599
+ return this.currentDepth < this.maxGlobalDepth;
600
+ }
601
+ // ----------------------------------------------------------
602
+ // Private — Record Management
603
+ // ----------------------------------------------------------
604
+ /**
605
+ * Generate a unique task ID.
606
+ */
607
+ generateTaskId(name) {
608
+ this.taskCounter++;
609
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 20);
610
+ return `sub-${slug}-${this.taskCounter}-${Date.now().toString(36)}`;
611
+ }
612
+ /**
613
+ * Apply default values to a SubAgentConfig.
614
+ */
615
+ applyDefaults(config) {
616
+ return {
617
+ name: config.name,
618
+ systemPrompt: config.systemPrompt,
619
+ allowedTools: config.allowedTools,
620
+ deniedTools: config.deniedTools ?? [],
621
+ fileScope: config.fileScope ?? {
622
+ allowedPaths: [this.workingDirectory],
623
+ deniedPaths: [],
624
+ },
625
+ resourceLimits: {
626
+ maxTokens: config.resourceLimits?.maxTokens ?? DEFAULT_RESOURCE_LIMITS.maxTokens,
627
+ maxTurns: config.resourceLimits?.maxTurns ?? DEFAULT_RESOURCE_LIMITS.maxTurns,
628
+ timeoutMs: config.resourceLimits?.timeoutMs ?? DEFAULT_RESOURCE_LIMITS.timeoutMs,
629
+ },
630
+ model: config.model ?? this.defaultModel,
631
+ maxDepth: config.maxDepth ?? DEFAULT_MAX_DEPTH,
632
+ mode: config.mode ?? 'sequential',
633
+ };
634
+ }
635
+ /**
636
+ * Validate a SubAgentConfig, throwing if it's invalid.
637
+ */
638
+ validateConfig(config) {
639
+ if (!config.name || config.name.trim().length === 0) {
640
+ throw new Error('Sub-agent config must have a non-empty name');
641
+ }
642
+ if (!config.systemPrompt || config.systemPrompt.trim().length === 0) {
643
+ throw new Error('Sub-agent config must have a non-empty systemPrompt');
644
+ }
645
+ if (!config.allowedTools || config.allowedTools.length === 0) {
646
+ throw new Error('Sub-agent config must specify at least one allowed tool');
647
+ }
648
+ if (config.resourceLimits.maxTokens <= 0) {
649
+ throw new Error('Sub-agent resourceLimits.maxTokens must be > 0');
650
+ }
651
+ if (config.resourceLimits.maxTurns <= 0) {
652
+ throw new Error('Sub-agent resourceLimits.maxTurns must be > 0');
653
+ }
654
+ if (config.resourceLimits.timeoutMs <= 0) {
655
+ throw new Error('Sub-agent resourceLimits.timeoutMs must be > 0');
656
+ }
657
+ // Check nesting depth
658
+ const nextDepth = this.currentDepth + 1;
659
+ if (nextDepth > this.maxGlobalDepth) {
660
+ throw new Error(`Cannot spawn sub-agent: nesting depth ${nextDepth} exceeds maximum ${this.maxGlobalDepth}`);
661
+ }
662
+ if (config.maxDepth > this.maxGlobalDepth) {
663
+ throw new Error(`Sub-agent maxDepth ${config.maxDepth} exceeds global maximum ${this.maxGlobalDepth}`);
664
+ }
665
+ // Validate that allowed tools exist in the parent registry
666
+ for (const toolName of config.allowedTools) {
667
+ if (!this.parentRegistry.has(toolName)) {
668
+ throw new Error(`Allowed tool "${toolName}" does not exist in the parent tool registry`);
669
+ }
670
+ }
671
+ }
672
+ /**
673
+ * Create a SubAgentRecord for tracking the sub-agent lifecycle.
674
+ */
675
+ createRecord(taskId, config, prompt) {
676
+ let completionResolve = null;
677
+ let completionReject = null;
678
+ const completionPromise = new Promise((resolve, reject) => {
679
+ completionResolve = resolve;
680
+ completionReject = reject;
681
+ });
682
+ const record = {
683
+ taskId,
684
+ config,
685
+ prompt,
686
+ status: 'pending',
687
+ depth: this.currentDepth + 1,
688
+ workingDirectory: this.workingDirectory,
689
+ sessionId: this.sessionId,
690
+ abortController: new AbortController(),
691
+ startedAt: Date.now(),
692
+ fileSnapshot: new Map(),
693
+ completionPromise,
694
+ completionResolve,
695
+ completionReject,
696
+ };
697
+ this.records.set(taskId, record);
698
+ return record;
699
+ }
700
+ // ----------------------------------------------------------
701
+ // Private — Execution Engine
702
+ // ----------------------------------------------------------
703
+ /**
704
+ * Execute a sub-agent: create a scoped BaseAgent, run it with
705
+ * resource limit enforcement, and collect results.
706
+ */
707
+ async executeSubAgent(record, config, prompt) {
708
+ record.status = 'running';
709
+ this.emit('subagent:started', record.taskId, config.name);
710
+ const startTime = Date.now();
711
+ const abortSignal = record.abortController.signal;
712
+ // Take a file snapshot before execution
713
+ const snapshot = new FileSnapshot(this.workingDirectory, config.fileScope);
714
+ record.fileSnapshot = snapshot.take();
715
+ // Build the scoped tool registry
716
+ const scopedRegistry = new ScopedToolRegistry(this.parentRegistry, config.allowedTools, config.deniedTools ?? [], config.fileScope, this.workingDirectory);
717
+ // Build the AgentConfig for the sub-agent's BaseAgent
718
+ const agentConfig = {
719
+ name: config.name,
720
+ description: `Sub-agent: ${config.name}`,
721
+ systemPrompt: this.buildSubAgentSystemPrompt(config),
722
+ model: config.model,
723
+ maxTokens: config.resourceLimits.maxTokens,
724
+ maxIterations: config.resourceLimits.maxTurns,
725
+ tools: config.allowedTools.filter((t) => !(config.deniedTools ?? []).includes(t)),
726
+ autoApprove: true, // Sub-agents auto-approve; the parent already gated access
727
+ temperature: 0.5,
728
+ };
729
+ // Create the temporary BaseAgent
730
+ const agent = new BaseAgent(agentConfig, this.client, scopedRegistry, this.workingDirectory, `${this.sessionId}-sub-${record.taskId}`);
731
+ // Set up timeout
732
+ let timeoutHandle = null;
733
+ let timedOut = false;
734
+ if (config.resourceLimits.timeoutMs > 0) {
735
+ timeoutHandle = setTimeout(() => {
736
+ timedOut = true;
737
+ record.abortController.abort();
738
+ }, config.resourceLimits.timeoutMs);
739
+ }
740
+ // Resource-tracking state
741
+ let totalTokensUsed = 0;
742
+ let turnsCompleted = 0;
743
+ // Callbacks that enforce resource limits
744
+ const resourceCallbacks = {
745
+ onIteration: (iteration, _maxIterations) => {
746
+ turnsCompleted = iteration;
747
+ // Check for abort (timeout or explicit cancel)
748
+ if (abortSignal.aborted) {
749
+ throw new Error(timedOut
750
+ ? `Sub-agent "${config.name}" timed out after ${config.resourceLimits.timeoutMs}ms`
751
+ : `Sub-agent "${config.name}" was cancelled`);
752
+ }
753
+ // Check token budget
754
+ if (totalTokensUsed >= config.resourceLimits.maxTokens) {
755
+ throw new Error(`Sub-agent "${config.name}" exceeded token budget of ${config.resourceLimits.maxTokens}`);
756
+ }
757
+ },
758
+ onToolCall: (_toolName, _args) => {
759
+ // Check for abort before each tool call
760
+ if (abortSignal.aborted) {
761
+ throw new Error(timedOut
762
+ ? `Sub-agent "${config.name}" timed out`
763
+ : `Sub-agent "${config.name}" was cancelled`);
764
+ }
765
+ },
766
+ };
767
+ try {
768
+ // Run the sub-agent
769
+ const runResult = await agent.run(prompt, resourceCallbacks);
770
+ // Clear timeout
771
+ if (timeoutHandle !== null)
772
+ clearTimeout(timeoutHandle);
773
+ // Account for token usage
774
+ totalTokensUsed = runResult.usage.inputTokens + runResult.usage.outputTokens;
775
+ // Detect file changes
776
+ const fileChanges = snapshot.diff();
777
+ const filesModified = fileChanges.map((fc) => relative(this.workingDirectory, fc.path) || fc.path);
778
+ const result = {
779
+ success: true,
780
+ output: runResult.content || '',
781
+ filesModified,
782
+ tokensUsed: totalTokensUsed,
783
+ turnsCompleted: runResult.iterations,
784
+ duration: Date.now() - startTime,
785
+ };
786
+ record.status = 'completed';
787
+ record.result = result;
788
+ record.completedAt = Date.now();
789
+ record.completionResolve?.(result);
790
+ this.emit('subagent:completed', record.taskId, result);
791
+ return result;
792
+ }
793
+ catch (err) {
794
+ if (timeoutHandle !== null)
795
+ clearTimeout(timeoutHandle);
796
+ // If timed out, set the appropriate status
797
+ if (timedOut) {
798
+ record.status = 'timed_out';
799
+ }
800
+ const error = err instanceof Error ? err : new Error(String(err));
801
+ // Detect file changes even on failure (partial modifications are important)
802
+ const fileChanges = snapshot.diff();
803
+ const filesModified = fileChanges.map((fc) => relative(this.workingDirectory, fc.path) || fc.path);
804
+ const result = {
805
+ success: false,
806
+ output: '',
807
+ filesModified,
808
+ tokensUsed: totalTokensUsed,
809
+ turnsCompleted,
810
+ error: error.message,
811
+ duration: Date.now() - startTime,
812
+ };
813
+ record.result = result;
814
+ record.completedAt = Date.now();
815
+ if (record.status === 'running') {
816
+ record.status = 'failed';
817
+ }
818
+ this.emit('subagent:failed', record.taskId, error.message);
819
+ // For sequential mode, re-throw so the caller knows it failed
820
+ if (config.mode === 'sequential') {
821
+ throw error;
822
+ }
823
+ // For async mode, the result is stored and accessible via getStatus/getResult
824
+ return result;
825
+ }
826
+ }
827
+ /**
828
+ * Build the system prompt for a sub-agent, combining the user-provided
829
+ * system prompt with context about the sub-agent's restrictions.
830
+ */
831
+ buildSubAgentSystemPrompt(config) {
832
+ let prompt = config.systemPrompt;
833
+ prompt += `\n\n## Sub-Agent Context`;
834
+ prompt += `\n- You are a sub-agent named "${config.name}"`;
835
+ prompt += `\n- Available tools: ${config.allowedTools.filter((t) => !(config.deniedTools ?? []).includes(t)).join(', ')}`;
836
+ if (config.fileScope) {
837
+ const allowedPaths = config.fileScope.allowedPaths.join(', ') || '(none)';
838
+ const deniedPaths = config.fileScope.deniedPaths.join(', ') || '(none)';
839
+ prompt += `\n- Allowed file paths: ${allowedPaths}`;
840
+ prompt += `\n- Denied file paths: ${deniedPaths}`;
841
+ prompt += `\n- IMPORTANT: You can only access files within the allowed paths. Attempts to access other paths will be denied.`;
842
+ }
843
+ prompt += `\n- Token budget: ${config.resourceLimits.maxTokens.toLocaleString()} tokens`;
844
+ prompt += `\n- Turn limit: ${config.resourceLimits.maxTurns} turns`;
845
+ prompt += `\n- Timeout: ${Math.round(config.resourceLimits.timeoutMs / 1000)} seconds`;
846
+ if (config.maxDepth > 0 && this.currentDepth + 1 < this.maxGlobalDepth) {
847
+ prompt += `\n- You may spawn sub-agents up to depth ${config.maxDepth}`;
848
+ }
849
+ else {
850
+ prompt += `\n- You cannot spawn further sub-agents (depth limit reached)`;
851
+ }
852
+ prompt += `\n\n## Sub-Agent Guidelines
853
+ - Work efficiently within your resource limits
854
+ - Focus on the specific task you were given
855
+ - Do not attempt to access files or tools outside your scope
856
+ - When you complete your task, provide a clear summary of what you did
857
+ - If you encounter scope violations, report them rather than retrying`;
858
+ return prompt;
859
+ }
860
+ // ----------------------------------------------------------
861
+ // Private — Utilities
862
+ // ----------------------------------------------------------
863
+ /**
864
+ * Clean up completed/failed/cancelled sub-agent records.
865
+ * Returns the number of records cleaned up.
866
+ */
867
+ cleanup() {
868
+ let cleaned = 0;
869
+ for (const [id, record] of this.records) {
870
+ if (record.status === 'completed' ||
871
+ record.status === 'failed' ||
872
+ record.status === 'cancelled' ||
873
+ record.status === 'timed_out') {
874
+ this.records.delete(id);
875
+ cleaned++;
876
+ }
877
+ }
878
+ return cleaned;
879
+ }
880
+ }
881
+ // ============================================================
882
+ // SubAgentTool — A tool that allows agents to spawn sub-agents
883
+ // ============================================================
884
+ /**
885
+ * A ToolExecutor that can be registered in the parent agent's tool
886
+ * registry to give the agent the ability to spawn sub-agents via
887
+ * the LLM tool-calling interface.
888
+ *
889
+ * This tool appears as "spawn_sub_agent" in the agent's tool list
890
+ * and accepts the sub-agent configuration + prompt as arguments.
891
+ *
892
+ * Usage:
893
+ * ```typescript
894
+ * const manager = new SubAgentManager(client, registry, cwd, sessionId);
895
+ * const subAgentTool = new SubAgentTool(manager);
896
+ * registry.register(subAgentTool);
897
+ * ```
898
+ */
899
+ export class SubAgentTool {
900
+ name = 'spawn_sub_agent';
901
+ description = 'Spawn a sub-agent to perform a specific task. The sub-agent runs with restricted tools and resources. Use this to delegate focused work to a specialist agent.';
902
+ risk = 'medium';
903
+ definition = {
904
+ name: 'spawn_sub_agent',
905
+ description: 'Spawn a sub-agent to perform a specific task. The sub-agent runs with restricted tools and resources. Use this to delegate focused work to a specialist agent.',
906
+ parameters: {
907
+ type: 'object',
908
+ properties: {
909
+ name: {
910
+ type: 'string',
911
+ description: 'A descriptive name for the sub-agent (e.g., "code-searcher", "test-runner")',
912
+ },
913
+ prompt: {
914
+ type: 'string',
915
+ description: 'The task to assign to the sub-agent',
916
+ },
917
+ allowedTools: {
918
+ type: 'string',
919
+ description: 'Comma-separated list of tool names the sub-agent can use (e.g., "read_file,search_files,list_directory")',
920
+ },
921
+ maxTokens: {
922
+ type: 'number',
923
+ description: 'Maximum tokens the sub-agent may consume (default: 50000)',
924
+ },
925
+ maxTurns: {
926
+ type: 'number',
927
+ description: 'Maximum number of turns the sub-agent may take (default: 10)',
928
+ },
929
+ timeoutSeconds: {
930
+ type: 'number',
931
+ description: 'Timeout in seconds for the sub-agent (default: 60)',
932
+ },
933
+ },
934
+ required: ['name', 'prompt', 'allowedTools'],
935
+ },
936
+ };
937
+ manager;
938
+ constructor(manager) {
939
+ this.manager = manager;
940
+ }
941
+ async execute(args, _context) {
942
+ const name = args.name;
943
+ const prompt = args.prompt;
944
+ const allowedToolsStr = args.allowedTools;
945
+ const maxTokens = args.maxTokens ?? 50_000;
946
+ const maxTurns = args.maxTurns ?? 10;
947
+ const timeoutSeconds = args.timeoutSeconds ?? 60;
948
+ if (!name || !prompt || !allowedToolsStr) {
949
+ return 'ERROR: Missing required parameters: name, prompt, allowedTools';
950
+ }
951
+ const allowedTools = allowedToolsStr
952
+ .split(',')
953
+ .map((t) => t.trim())
954
+ .filter((t) => t.length > 0);
955
+ if (allowedTools.length === 0) {
956
+ return 'ERROR: At least one tool must be specified in allowedTools';
957
+ }
958
+ // Check if the manager can still spawn
959
+ if (!this.manager.canSpawn) {
960
+ return 'ERROR: Maximum sub-agent nesting depth reached. Cannot spawn further sub-agents.';
961
+ }
962
+ const config = {
963
+ name,
964
+ systemPrompt: `You are a specialized sub-agent named "${name}". Focus on completing the specific task you are assigned efficiently.`,
965
+ allowedTools,
966
+ resourceLimits: {
967
+ maxTokens,
968
+ maxTurns,
969
+ timeoutMs: timeoutSeconds * 1000,
970
+ },
971
+ maxDepth: DEFAULT_MAX_DEPTH,
972
+ mode: 'sequential',
973
+ };
974
+ try {
975
+ const result = await this.manager.spawn(config, prompt);
976
+ if (result.success) {
977
+ let output = `Sub-agent "${name}" completed successfully.\n`;
978
+ output += `Duration: ${Math.round(result.duration / 1000)}s | Tokens: ${result.tokensUsed.toLocaleString()} | Turns: ${result.turnsCompleted}\n`;
979
+ if (result.filesModified.length > 0) {
980
+ output += `Files modified: ${result.filesModified.join(', ')}\n`;
981
+ }
982
+ output += `\nResult:\n${result.output}`;
983
+ return output;
984
+ }
985
+ else {
986
+ return `Sub-agent "${name}" failed: ${result.error}\nTokens used: ${result.tokensUsed.toLocaleString()} | Turns: ${result.turnsCompleted}`;
987
+ }
988
+ }
989
+ catch (err) {
990
+ const error = err instanceof Error ? err.message : String(err);
991
+ return `ERROR: Sub-agent "${name}" encountered an error: ${error}`;
992
+ }
993
+ }
994
+ getApprovalRequest(args) {
995
+ // Sub-agent spawning is medium risk — it could modify files
996
+ // but is already scoped by the config
997
+ return {
998
+ toolName: this.name,
999
+ args,
1000
+ risk: 'medium',
1001
+ description: `Spawn sub-agent "${args.name}" with tools: ${args.allowedTools}`,
1002
+ };
1003
+ }
1004
+ }
1005
+ // ============================================================
1006
+ // Helper — Create a SubAgentManager from a NeuroEngine context
1007
+ // ============================================================
1008
+ /**
1009
+ * Convenience factory for creating a SubAgentManager from the
1010
+ * standard NeuroEngine components.
1011
+ *
1012
+ * @param client The OpenRouter client from the engine
1013
+ * @param registry The tool registry from the engine
1014
+ * @param workingDir The current working directory
1015
+ * @param sessionId The current session ID
1016
+ * @param defaultModel The model to use for sub-agents
1017
+ * @param parentDepth The current nesting depth (0 for top-level)
1018
+ * @returns A configured SubAgentManager
1019
+ */
1020
+ export function createSubAgentManager(client, registry, workingDir, sessionId, defaultModel = 'qwen/qwen3-coder:free', parentDepth = 0) {
1021
+ return new SubAgentManager(client, registry, workingDir, sessionId, defaultModel, parentDepth, DEFAULT_MAX_DEPTH);
1022
+ }
1023
+ //# sourceMappingURL=sub-agent.js.map