@nexrall/code-core 1.4.66 → 1.4.67

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 (75) hide show
  1. package/dist/agent/agentTypes.d.ts +6 -2
  2. package/dist/agent/agentTypes.d.ts.map +1 -1
  3. package/dist/agent/agentTypes.js +13 -4
  4. package/dist/agent/askOnce.d.ts +12 -0
  5. package/dist/agent/askOnce.d.ts.map +1 -0
  6. package/dist/agent/askOnce.js +41 -0
  7. package/dist/agent/compaction.d.ts +244 -0
  8. package/dist/agent/compaction.d.ts.map +1 -0
  9. package/dist/agent/compaction.js +976 -0
  10. package/dist/agent/fileLocks.d.ts +34 -0
  11. package/dist/agent/fileLocks.d.ts.map +1 -0
  12. package/dist/agent/fileLocks.js +114 -0
  13. package/dist/agent/hooks.d.ts +324 -0
  14. package/dist/agent/hooks.d.ts.map +1 -0
  15. package/dist/agent/hooks.js +1228 -0
  16. package/dist/agent/iterationPolicy.d.ts +121 -0
  17. package/dist/agent/iterationPolicy.d.ts.map +1 -0
  18. package/dist/agent/iterationPolicy.js +297 -0
  19. package/dist/agent/lifecycleHost.d.ts +55 -0
  20. package/dist/agent/lifecycleHost.d.ts.map +1 -0
  21. package/dist/agent/lifecycleHost.js +294 -0
  22. package/dist/agent/loop.d.ts +11 -491
  23. package/dist/agent/loop.d.ts.map +1 -1
  24. package/dist/agent/loop.js +417 -3031
  25. package/dist/agent/planMode.d.ts.map +1 -1
  26. package/dist/agent/planMode.js +1 -0
  27. package/dist/agent/sharedTasks.d.ts +7 -0
  28. package/dist/agent/sharedTasks.d.ts.map +1 -1
  29. package/dist/agent/sharedTasks.js +16 -0
  30. package/dist/agent/subAgentBudget.d.ts +65 -0
  31. package/dist/agent/subAgentBudget.d.ts.map +1 -0
  32. package/dist/agent/subAgentBudget.js +269 -0
  33. package/dist/agent/subTask.d.ts +6 -0
  34. package/dist/agent/subTask.d.ts.map +1 -0
  35. package/dist/agent/subTask.js +713 -0
  36. package/dist/agent/subTaskSupport.d.ts +156 -0
  37. package/dist/agent/subTaskSupport.d.ts.map +1 -0
  38. package/dist/agent/subTaskSupport.js +409 -0
  39. package/dist/agent/toolDescriptions.d.ts +3 -0
  40. package/dist/agent/toolDescriptions.d.ts.map +1 -0
  41. package/dist/agent/toolDescriptions.js +116 -0
  42. package/dist/api/client.d.ts.map +1 -1
  43. package/dist/api/client.js +17 -0
  44. package/dist/index.d.ts +3 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +3 -0
  47. package/dist/mcp/client.d.ts +104 -0
  48. package/dist/mcp/client.d.ts.map +1 -1
  49. package/dist/mcp/client.js +136 -2
  50. package/dist/mcp/httpClient.d.ts +17 -1
  51. package/dist/mcp/httpClient.d.ts.map +1 -1
  52. package/dist/mcp/httpClient.js +120 -19
  53. package/dist/mcp/manager.d.ts +77 -2
  54. package/dist/mcp/manager.d.ts.map +1 -1
  55. package/dist/mcp/manager.js +275 -9
  56. package/dist/mcp/sseClient.d.ts +7 -1
  57. package/dist/mcp/sseClient.d.ts.map +1 -1
  58. package/dist/mcp/sseClient.js +45 -1
  59. package/dist/mcp/stats.d.ts +41 -0
  60. package/dist/mcp/stats.d.ts.map +1 -0
  61. package/dist/mcp/stats.js +108 -0
  62. package/dist/permissions/destructive.d.ts +2 -0
  63. package/dist/permissions/destructive.d.ts.map +1 -1
  64. package/dist/permissions/destructive.js +6 -2
  65. package/dist/permissions/destructiveTokens.d.ts +5 -0
  66. package/dist/permissions/destructiveTokens.d.ts.map +1 -1
  67. package/dist/permissions/destructiveTokens.js +9 -3
  68. package/dist/permissions/modePolicy.d.ts.map +1 -1
  69. package/dist/permissions/modePolicy.js +5 -2
  70. package/dist/permissions/rules.d.ts +4 -1
  71. package/dist/permissions/rules.d.ts.map +1 -1
  72. package/dist/permissions/rules.js +29 -0
  73. package/dist/types.d.ts +45 -1
  74. package/dist/types.d.ts.map +1 -1
  75. package/package.json +1 -1
@@ -0,0 +1,713 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runSubTask = runSubTask;
4
+ const worktree_1 = require("./worktree");
5
+ const types_1 = require("../types");
6
+ const agentTypes_1 = require("./agentTypes");
7
+ const skills_1 = require("./skills");
8
+ const rules_1 = require("../permissions/rules");
9
+ const agentRegistry_1 = require("./agentRegistry");
10
+ const memory_1 = require("./memory");
11
+ const modelCatalogue_1 = require("./modelCatalogue");
12
+ const hooks_1 = require("./hooks");
13
+ const compaction_1 = require("./compaction");
14
+ const iterationPolicy_1 = require("./iterationPolicy");
15
+ const subTaskSupport_1 = require("./subTaskSupport");
16
+ let _subTaskCounter = 0; // unique per-process id → per-sub-agent todo scope
17
+ async function runSubTask(input, options, agentTypes,
18
+ // Set to true at the moment an agent loop actually STARTS, so the caller can refund the
19
+ // session slot it claimed for a spawn that turned out never to run.
20
+ //
21
+ // An out-param rather than a discriminated return type on purpose: every one of this
22
+ // function's ~8 early returns is a non-start, and several are far from the top. Enumerating
23
+ // them in the caller would be a list to forget to update; flipping one flag at the single
24
+ // point of no return cannot go stale, and a path added later is a non-start by default.
25
+ started,
26
+ // Seam: the recursive entry point (loop.ts's runAgentLoop), injected so this module never imports loop.ts.
27
+ runChild) {
28
+ const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
29
+ if (!prompt)
30
+ return { error: 'task tool requires a non-empty prompt' };
31
+ // The scope of the frame DOING the spawning — i.e. the owner of any resumable id this call
32
+ // produces, and the identity checked when resuming one. 'root' is the main agent.
33
+ const agentScope = options._agentScope ?? 'root';
34
+ const depth = options._depth ?? 0;
35
+ const depthLimit = (0, subTaskSupport_1.resolveMaxSubagentDepth)(options.workDir ? (0, rules_1.loadSettings)(options.workDir).raw : {});
36
+ if (depth >= depthLimit) {
37
+ return { error: (0, subTaskSupport_1.noSpawnReason)('depth', depthLimit) };
38
+ }
39
+ // NOTE: the session budget is claimed at the DISPATCH SITE, not here — runSubTask runs
40
+ // inside the concurrency limiter, so claiming here would make a doomed spawn wait behind
41
+ // running siblings before being told no. See the `name === 'task'` branch in runAgentLoop.
42
+ // Resolve an optional custom agent type (subagent_type).
43
+ //
44
+ // `agentTypes` is a snapshot taken once at the top of runAgentLoop, before the
45
+ // model said anything. That made "write .nexrall/agents/x.md, then use it"
46
+ // impossible within a single turn: the file existed on disk, but this lookup
47
+ // consulted a list captured before it was written, and the model was told the
48
+ // agent did not exist — which reads as "creating it failed".
49
+ //
50
+ // So on a MISS ONLY, re-read from disk before giving up. The hit path (every
51
+ // normal call) still costs zero syscalls, and the miss path costs ~4 mostly-
52
+ // ENOENT stats — against a sub-agent that is about to run for seconds to
53
+ // minutes. Note runAgentLoop re-reads for the sub-agent anyway, so the old
54
+ // behaviour was already inconsistent: fresh for the child, stale for the lookup.
55
+ // ── Resolve a resume target ─────────────────────────────────────────────────
56
+ //
57
+ // `resume_agent_id` continues a previous sub-agent. The stored transcript
58
+ // carries the agent's NAME, and that name — not the one the model passed — is
59
+ // what gets authorised below.
60
+ //
61
+ // This matters because an id would otherwise be a permanent capability: deny
62
+ // `task(explorer)` today and a model holding yesterday's explorer id could
63
+ // still resume it, with the deny rule looking like it was applied. Re-deriving
64
+ // the name from storage also stops a mismatched `subagent_type` from being
65
+ // used to launder a denied agent under an allowed name.
66
+ const resumeId = typeof input.resume_agent_id === 'string' ? input.resume_agent_id.trim() : '';
67
+ const resumed = resumeId ? (0, agentRegistry_1.getAgent)(resumeId) : undefined;
68
+ // OWNERSHIP, in addition to the deny-rule re-authorisation below.
69
+ //
70
+ // Re-deriving the agent NAME from storage stops an id laundering a denied agent, but it
71
+ // says nothing about WHO may use the id. The registry is one flat process-global Map, so a
72
+ // nested sub-agent could name an id it was never given and read another agent's entire
73
+ // unredacted transcript, or overwrite it. Harmless while sub-agents were leaves (only the
74
+ // main agent ever held an id); live once they can spawn.
75
+ //
76
+ // Reported as "expired" rather than "not yours": a distinct message would confirm the id
77
+ // exists, turning the error into an oracle for enumerating other frames' agents.
78
+ if (resumed && !(0, agentRegistry_1.canResume)(resumed, agentScope, options.sessionId)) {
79
+ return {
80
+ error: `No resumable sub-agent with id "${resumeId}" is available to this run. Start a fresh ` +
81
+ 'sub-task with a self-contained prompt instead.',
82
+ };
83
+ }
84
+ if (resumeId && !resumed) {
85
+ return {
86
+ error: `No resumable sub-agent with id "${resumeId}". Ids live only for the current session and the ` +
87
+ 'oldest are dropped when too many accumulate, so this one has expired or never existed. ' +
88
+ 'Start a fresh sub-task with a self-contained prompt instead.',
89
+ };
90
+ }
91
+ const requestedType = resumed
92
+ ? (resumed.agentName ?? '')
93
+ : (typeof input.subagent_type === 'string' ? input.subagent_type : '');
94
+ // ── Enforce `deny: ["task(<name>)"]` ─────────────────────────────────────────
95
+ //
96
+ // This is the load-bearing check; filtering the catalogue in runAgentLoop only
97
+ // stops the agent being SUGGESTED. It must run BEFORE resolution, because the
98
+ // reload-on-miss path below deliberately re-reads from disk UNFILTERED — a
99
+ // denied agent is absent from the snapshot, would therefore "miss", and would
100
+ // then be found by that reload and run. Denying by omission is not denying.
101
+ //
102
+ // Phrased as a policy refusal, not "unknown type": the model must not respond
103
+ // by trying to create the agent file it thinks is missing.
104
+ // Evaluated UNCONDITIONALLY, with 'general-purpose' standing in for an unnamed dispatch.
105
+ //
106
+ // This used to be `if (requestedType)`, which meant an unnamed spawn skipped the rule
107
+ // entirely: `deny: ["task(general-purpose)"]` matched the named form and returned null for
108
+ // the unnamed one. Omitting the field was therefore a bypass for the single most
109
+ // privileged variant — an unnamed sub-task has no allowlist of its own, so before the
110
+ // parent-intersection it received FULL access, exactly what such a rule is written to stop.
111
+ //
112
+ // The substitution is also the honest model rather than a patch: an unnamed sub-task IS
113
+ // general-purpose behaviourally (that is what naming it accomplished in the first place),
114
+ // so a rule about that agent should govern both spellings. A bare `deny: ["task"]` already
115
+ // caught both and is unaffected.
116
+ const denyKey = requestedType || 'general-purpose';
117
+ const decision = (0, rules_1.evaluatePermission)((0, rules_1.loadSettings)(options.workDir).permissions, 'task', { subagent_type: denyKey }, options.workDir);
118
+ if (decision === 'deny') {
119
+ return {
120
+ error: `The sub-agent "${denyKey}" is disabled by a permission rule in this project ` +
121
+ `(permissions.deny in settings.json). This is a deliberate policy choice, not a missing file — ` +
122
+ 'do not create it and do not retry. Do the work yourself, or use a different sub-agent.',
123
+ };
124
+ }
125
+ // An unnamed dispatch IS general-purpose (the tool schema says so): same role prompt,
126
+ // same "your final message is your report" instructions, same deny rule (denyKey above).
127
+ let agent = (0, agentTypes_1.findAgentType)(agentTypes, requestedType || 'general-purpose');
128
+ let knownTypes = agentTypes;
129
+ if (requestedType && !agent) {
130
+ // Same `extra` list the top-of-run snapshot used (see runAgentLoop) — otherwise a
131
+ // programmatically-registered agent (registerAgentType()) would resolve on the FIRST
132
+ // call in a turn (present in the snapshot) but "miss" on this reload path, since it was
133
+ // never written to disk for loadAgentTypes to rediscover.
134
+ knownTypes = (0, agentTypes_1.loadAgentTypesWithWarnings)(options.workDir, options._extraAgentTypes ?? []).types;
135
+ agent = (0, agentTypes_1.findAgentType)(knownTypes, requestedType);
136
+ }
137
+ if (requestedType && !agent) {
138
+ const known = knownTypes.map((a) => a.name).join(', ') || '(none defined)';
139
+ return {
140
+ error: `Unknown subagent_type "${requestedType}". Available types: ${known}.\n` +
141
+ 'If you just created .nexrall/agents/' + requestedType + '.md, make sure the write finished in an ' +
142
+ 'EARLIER tool call than this one — a file written in the same batch may not be on disk yet.',
143
+ };
144
+ }
145
+ // A custom agent's persona is delivered through the project-instructions
146
+ // channel (authoritative in the system prompt), layered above the project's
147
+ // own nexrall.md so it keeps project conventions.
148
+ // ── Per-agent memory (`memory:` frontmatter) ────────────────────────────────
149
+ //
150
+ // When an agent declares a memory scope, its own notes from previous runs are
151
+ // injected ahead of its role prompt, and it gains ONE extra tool to append to them.
152
+ //
153
+ // Claude Code implements the equivalent by auto-enabling Read/Write/Edit "regardless
154
+ // of what the tools allowlist says". We deliberately do NOT copy that: silently
155
+ // widening a declared allowlist is fail-OPEN, and this repo already fixed the
156
+ // mirror-image bug (an unparseable agent file used to receive FULL access). So the
157
+ // grant here is a single purpose-built tool that can only ever touch this agent's
158
+ // own notes file — declaring `memory:` cannot hand anything write access to the repo.
159
+ const memoryScope = agent?.memory;
160
+ const agentMemoryNotes = agent && memoryScope
161
+ ? (0, memory_1.agentMemoryPreamble)(agent.name, memoryScope, options.workDir)
162
+ : '';
163
+ // `lightPrompt` agents skip the project's nexrall.md — see AgentType.lightPrompt for
164
+ // why. The role prompt and any private notes still apply; only the (potentially very
165
+ // large) project instruction file is dropped.
166
+ //
167
+ // Composed from the PROJECT's instructions (`_projectNexrallMd`), never from the
168
+ // parent's own composite prompt: a grandchild used to inherit its parent's role
169
+ // ("# Sub-agent role: general-purpose … make the changes") on top of its own
170
+ // ("explorer … you NEVER modify files"), plus the parent's private memory notes.
171
+ const projectMd = options._projectNexrallMd ?? options.nexrallMd ?? '';
172
+ const inheritedMd = agent?.lightPrompt ? '' : projectMd;
173
+ // `skills:` — preload those skills' instructions (raw body: no !`cmd` expansion runs
174
+ // just because an agent was spawned). A missing name is stated, not silently dropped.
175
+ let preloadedSkills = '';
176
+ if (agent?.skills?.length) {
177
+ const known = (0, skills_1.loadSkillsWithWarnings)(options.workDir, options._extraSkills ?? []).skills;
178
+ preloadedSkills = agent.skills.map((n) => {
179
+ const sk = (0, skills_1.findSkill)(known, n);
180
+ return sk ? `# Preloaded skill: ${sk.name}\n${sk.body.trim()}` : `# Preloaded skill: ${n}\n(not found in this project — ignore)`;
181
+ }).join('\n\n');
182
+ }
183
+ // Shared-before-specific order: the project's nexrall.md (usually the largest part, and
184
+ // identical for every non-lightPrompt sibling) goes first; per-agent parts (preamble,
185
+ // role, skills, private notes) follow — last, where they also carry the most weight
186
+ // with the model. With the role first, two agents diverged at the first line of this
187
+ // block. HONEST SCOPE: this only buys cache reuse where everything BEFORE the block
188
+ // already matches — same tool list and same prompt flags (so e.g. two custom agents
189
+ // with equal `tools:`, on prefix-cached providers). Types with different allowlists
190
+ // diverge earlier, at the tools array, whatever the order here. It costs nothing, and
191
+ // tool enforcement never depended on prompt order (see the allowlist below).
192
+ const subNexrallMd = [
193
+ inheritedMd,
194
+ subTaskSupport_1.SUBAGENT_PREAMBLE,
195
+ agent ? `# Sub-agent role: ${agent.name}\n${agent.prompt}` : '',
196
+ preloadedSkills,
197
+ agentMemoryNotes,
198
+ ].filter(Boolean).join('\n\n---\n\n');
199
+ // Optional tool allowlist — deny anything outside it for this sub-agent.
200
+ //
201
+ // A refusal here is reported through `deniedReason` rather than the generic
202
+ // "Permission denied by user", which was actively misleading: the user denied
203
+ // nothing, and a model told that will re-ask for approval instead of noticing
204
+ // that the agent's own allowlist (often a typo'd tool name) is what stopped it.
205
+ // ── The child's allowlist is INTERSECTED with the parent's ──────────────────
206
+ //
207
+ // A child's own definition can only ever NARROW what its parent had, never widen it.
208
+ // Without this, nesting is a privilege-escalation ladder: a read-only agent (planner, or any
209
+ // user-defined reviewer) has no write_file, but `general-purpose` declares no `tools:` at all (= full access),
210
+ // so a read-only agent could delegate to an unrestricted one and edit the repo through
211
+ // it. The user's "this agent cannot write" would silently mean "cannot write directly".
212
+ //
213
+ // This was unreachable while sub-agents were leaves — nobody but the (unrestricted) main
214
+ // agent could spawn. Turning nesting on is what makes it live, so the intersection ships
215
+ // in the same change rather than as a follow-up.
216
+ //
217
+ // `null` still means "no allowlist", but only when BOTH sides say so: an unrestricted
218
+ // parent spawning general-purpose stays unrestricted (today's behaviour at depth 1),
219
+ // while a restricted parent yields a restricted child no matter what the child declares.
220
+ // Sticky for the same reason the allowlist intersects: inherited OR own, never shed.
221
+ const testFilesOnly = !!agent?.testFilesOnly || !!options._testFilesOnly;
222
+ const allowed = (0, subTaskSupport_1.intersectAllowlists)(agent?.tools ? new Set(agent.tools) : null, options._allowedTools);
223
+ // The ONE capability `memory:` grants. Added to the allowlist rather than bypassing
224
+ // it, so the allowlist stays the single source of truth for what this agent can do.
225
+ if (allowed && memoryScope)
226
+ allowed.add(iterationPolicy_1.AGENT_MEMORY_TOOL);
227
+ // `disallowedTools` (Claude Code) — inherited like the allowlist: a child can only add
228
+ // to its ancestors' refusals, never shed them.
229
+ const disallowed = new Set([...(options._disallowedTools ?? []), ...(agent?.disallowedTools ?? [])]);
230
+ if (allowed)
231
+ for (const t of disallowed)
232
+ allowed.delete(t);
233
+ // `mcpServers` — narrows, inherited like the tool allowlist.
234
+ let mcpAllow = options._mcpServerAllowlist;
235
+ if (agent?.mcpServers) {
236
+ const own = new Set(agent.mcpServers);
237
+ mcpAllow = mcpAllow ? new Set([...mcpAllow].filter((x) => own.has(x))) : own;
238
+ }
239
+ // Time spent waiting on a human (permission prompt) is not a stall.
240
+ let waitingOnUser = 0;
241
+ const gatedPermission = async (req) => {
242
+ // Guard against the tool being reachable without a declared scope — e.g. an agent
243
+ // that lists it in `tools:` by hand, or a general-purpose sub-task with no
244
+ // allowlist at all (`allowed === null` permits everything).
245
+ if (req.tool === iterationPolicy_1.AGENT_MEMORY_TOOL && !(agent && memoryScope)) {
246
+ throw new subTaskSupport_1.ToolNotAllowedError(`\`${iterationPolicy_1.AGENT_MEMORY_TOOL}\` is only available to a sub-agent whose definition declares a ` +
247
+ '`memory:` scope (project, user or local). Report anything worth remembering in your final ' +
248
+ 'message instead — the main agent decides what to persist.');
249
+ }
250
+ // `task` is answered by the same predicate that decides whether the tool was sent in
251
+ // the first place, so the gate cannot disagree with the prompt.
252
+ //
253
+ // This was briefly an UNCONDITIONAL refusal, which was correct only while the depth
254
+ // ceiling was hard-coded to 1 (every gated run was a leaf by definition). With nesting
255
+ // configurable that shortcut becomes a real bug: a depth-1 agent under
256
+ // `maxSubagentDepth: 3` would be handed the tool by the backend and then refused here.
257
+ // Fail-closed, so it would have looked like a mysterious dead end rather than a crash.
258
+ //
259
+ // Two distinct reasons, two distinct messages — a depth ceiling is raisable, an agent
260
+ // definition withholding `task` is not, and a refusal that lies about which one applies
261
+ // makes the model either give up early or hunt for an escape hatch.
262
+ if (req.tool === 'task' && !(0, subTaskSupport_1.canSpawnSubAgents)(depth + 1, allowed ?? undefined, depthLimit)) {
263
+ // `depth + 1`, not `depth`: this closure gates the CHILD's tool calls, and the child
264
+ // runs one level below the `depth` in scope here (which belongs to its parent). Using
265
+ // `depth` would evaluate the parent's right to spawn — permitting one level too many.
266
+ throw new subTaskSupport_1.ToolNotAllowedError(allowed && !allowed.has('task')
267
+ ? (0, subTaskSupport_1.noSpawnReason)('denied')
268
+ : (0, subTaskSupport_1.noSpawnReason)('depth', depthLimit));
269
+ }
270
+ if (allowed && !allowed.has(req.tool)) {
271
+ throw new subTaskSupport_1.ToolNotAllowedError(
272
+ // `agent?.name`, NOT `agent!.name`. The non-null assertion held only while `allowed`
273
+ // was derived solely from `agent?.tools` (non-null allowlist ⇒ named agent). The
274
+ // parent-intersection broke that invariant: a RESTRICTED parent dispatching `task`
275
+ // with no subagent_type yields a non-null inherited allowlist with `agent`
276
+ // undefined, and this line then threw a TypeError instead of ToolNotAllowedError —
277
+ // which the dispatch site does not recognise, so it laundered the refusal into the
278
+ // generic "Permission denied by user" this very message exists to avoid.
279
+ `The "${agent?.name ?? 'general-purpose'}" sub-agent is not allowed to use \`${req.tool}\` — it is not in that agent's ` +
280
+ 'tool allowlist. This is a restriction of the agent definition, NOT a user decision: do not ask ' +
281
+ 'for approval, use one of the tools you do have, or report back that the task needs a different agent.');
282
+ }
283
+ // Path-scoped write restriction — see allowsTestOnlyWrite for the reasoning and its
284
+ // known limit. Applies when THIS agent declares it OR any ancestor did: like the tool
285
+ // allowlist above, a restriction can only ever be narrowed by nesting, never shed.
286
+ // Without the inherited half, a test-only agent could delegate to an unrestricted agent and
287
+ // have production source written on its behalf.
288
+ if (mcpAllow && req.tool.includes('__') && !mcpAllow.has(req.tool.split('__')[0])) {
289
+ throw new subTaskSupport_1.ToolNotAllowedError(`The "${agent?.name ?? 'general-purpose'}" sub-agent may only use tools from these MCP servers: ` +
290
+ `${[...mcpAllow].join(', ') || '(none)'}. \`${req.tool}\` is from another server — use a different tool or report back.`);
291
+ }
292
+ if (disallowed.has(req.tool)) {
293
+ throw new subTaskSupport_1.ToolNotAllowedError(`The "${agent?.name ?? 'general-purpose'}" sub-agent may not use \`${req.tool}\` (disallowedTools in its ` +
294
+ 'definition). This is a restriction of the agent definition, NOT a user decision — use another tool or report back.');
295
+ }
296
+ if (req.tool === 'memory_write') {
297
+ throw new subTaskSupport_1.ToolNotAllowedError('Sub-agents cannot write the shared memory store. Put anything worth remembering in your final report — ' +
298
+ 'the main agent decides what to persist.');
299
+ }
300
+ if (testFilesOnly && !(0, compaction_1.allowsTestOnlyWrite)(req.tool, req.input)) {
301
+ throw new subTaskSupport_1.ToolNotAllowedError(`The "${agent?.name ?? 'general-purpose'}" sub-agent may only write to TEST files, so ` +
302
+ `\`${req.tool}\` was refused for this path. Do not try to work around it: if production ` +
303
+ 'code must change, say so in your report instead.');
304
+ }
305
+ // An agent writing its OWN notes (declared `memory:`) is answered here, not by the
306
+ // ancestors: their gates refuse agent_memory_write unless THEY declared a scope, so a
307
+ // memory agent spawned by general-purpose could never write. The tool can only touch
308
+ // this agent's own notes file, so there is nothing for anyone above to approve.
309
+ if (req.tool === iterationPolicy_1.AGENT_MEMORY_TOOL && agent && memoryScope)
310
+ return true;
311
+ waitingOnUser++;
312
+ try {
313
+ // Say WHICH sub-agent is asking (the innermost one wins as the request bubbles up).
314
+ const description = typeof input.description === 'string' ? input.description : undefined;
315
+ return await options.requestPermission({
316
+ ...req,
317
+ agent: req.agent ?? { name: agent?.name ?? 'general-purpose', ...(description ? { description } : {}) },
318
+ });
319
+ }
320
+ finally {
321
+ waitingOnUser--;
322
+ bumpProgress();
323
+ }
324
+ };
325
+ // ── Resume: continue a previous sub-agent instead of starting cold ──────────
326
+ //
327
+ // The new prompt is appended as another user turn to the stored transcript, so
328
+ // the agent keeps every file it read and every conclusion it reached. Without
329
+ // this, "now also check the auth path" means re-describing the entire job and
330
+ // re-reading everything — the most common and most expensive kind of waste in
331
+ // a delegated workflow.
332
+ const subMessages = resumed
333
+ ? [...resumed.messages, { role: 'user', content: [{ type: 'text', text: prompt }] }]
334
+ : [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
335
+ // A dedicated abort signal for this sub-agent, distinct from the parent's own
336
+ // options.abortSignal (user hit Ctrl+C). Set to true either when the parent
337
+ // aborts OR when the stall timeout below fires, whichever happens first —
338
+ // runAgentLoop already checks abortSignal.aborted at every iteration boundary,
339
+ // so this is enough to make it stop promptly without a forceful kill.
340
+ const subAbort = { aborted: false };
341
+ const subtaskTimeoutMs = (0, subTaskSupport_1.resolveSubtaskTimeoutMs)((0, rules_1.loadSettings)(options.workDir).raw);
342
+ // ── Stall watchdog, NOT a total-runtime deadline ─────────────────────────────
343
+ //
344
+ // This used to be a single setTimeout armed once and never refreshed. Its own
345
+ // comment called it a "stalled-agent cutoff", but it measured TOTAL LIFETIME: a
346
+ // sub-agent working hard and calling a tool every few seconds was killed at ten
347
+ // minutes exactly like one that had hung. That is not a hypothetical — auditing a
348
+ // handful of 600-2800 line files legitimately exceeds it, and when it fired the
349
+ // parent got back a fragment ("I'll start by reading the files…") after paying for
350
+ // 23 tool calls, then typically re-ran the whole thing.
351
+ //
352
+ // The main loop already draws this distinction correctly (client.ts's heartbeat vs
353
+ // progress watchdogs, where only real events bump lastProgressAt). Sub-agents now do
354
+ // too: the clock resets on every completed tool round, so the cap means "no progress
355
+ // for N minutes" — which is what catches a genuine hang — while useful work can run
356
+ // as long as it keeps being useful.
357
+ //
358
+ // `stalled` is tracked separately from `subAbort.aborted` because the parent's Ctrl+C
359
+ // also sets the latter, and the two must be reported differently.
360
+ let lastProgressAt = Date.now();
361
+ let stalled = false;
362
+ const bumpProgress = () => { lastProgressAt = Date.now(); };
363
+ const stallWatchdog = setInterval(() => {
364
+ if (waitingOnUser > 0) {
365
+ lastProgressAt = Date.now();
366
+ return;
367
+ }
368
+ if (Date.now() - lastProgressAt > subtaskTimeoutMs) {
369
+ stalled = true;
370
+ subAbort.aborted = true;
371
+ }
372
+ }, 1000);
373
+ // Mirror the parent's own abort into the sub-agent's signal so Ctrl+C during
374
+ // a running sub-task still stops it (previously this worked implicitly by
375
+ // sharing the same object via `...options` — now that we own a distinct
376
+ // object we must forward it explicitly).
377
+ const parentAbortPoll = setInterval(() => {
378
+ if (options.abortSignal?.aborted)
379
+ subAbort.aborted = true;
380
+ }, 250);
381
+ // ── Worktree isolation (`isolation: "worktree"` on the call or in the definition) ──
382
+ // A fresh worktree per spawn, like Claude Code: the child's writes, bash cwd and git
383
+ // commands are confined to it (worktreeEnforcement), and it is removed afterwards
384
+ // unless the child actually changed something.
385
+ let isoState;
386
+ if (input.isolation === 'worktree' || agent?.isolation === 'worktree') {
387
+ const created = (0, worktree_1.createWorktree)(options.workDir);
388
+ if (!created.ok || !created.state) {
389
+ clearInterval(stallWatchdog);
390
+ clearInterval(parentAbortPoll);
391
+ return {
392
+ error: `Could not create an isolated worktree for this sub-agent: ${created.error ?? 'unknown error'}. ` +
393
+ 'Run it without isolation, or fix the repository state first.',
394
+ };
395
+ }
396
+ isoState = created.state;
397
+ }
398
+ const childWorkDir = isoState?.worktreePath ?? options.workDir;
399
+ // Claude Code's Explore/Plan skip git status; so do lightPrompt agents here. An
400
+ // isolated child is told where it actually is.
401
+ let childEnv = options.env;
402
+ if (childEnv && agent?.lightPrompt) {
403
+ const { gitStatus: _s, gitDiff: _d, recentCommits: _c, ...rest } = childEnv;
404
+ childEnv = rest;
405
+ }
406
+ if (childEnv && isoState)
407
+ childEnv = { ...childEnv, cwd: isoState.worktreePath, gitBranch: isoState.branch ?? childEnv.gitBranch };
408
+ // Per-sub-agent accounting, reported in its tool result (Claude Code shows the same).
409
+ const subStartedAt = Date.now();
410
+ let subToolCalls = 0;
411
+ let subTokens = 0;
412
+ let subCost = 0;
413
+ let childStop = null;
414
+ let childNotice = null;
415
+ let childVerifications = [];
416
+ const finalize = (r) => {
417
+ let isoNote = '';
418
+ if (isoState) {
419
+ if ((0, worktree_1.worktreeHasWork)(isoState)) {
420
+ isoNote = `\n[isolated worktree: this sub-agent's changes are in ${isoState.worktreePath}` +
421
+ `${isoState.branch ? ` (branch ${isoState.branch})` : ''} — NOT in the main checkout. Review them, then merge or discard.]`;
422
+ }
423
+ else {
424
+ (0, worktree_1.removeWorktree)(isoState, { force: true });
425
+ }
426
+ isoState = undefined;
427
+ }
428
+ const stats = `[sub-agent stats: ${subToolCalls} tool call(s) · ${(0, subTaskSupport_1.formatTokenCount)(subTokens)} tokens · ` +
429
+ `$${subCost.toFixed(3)} · ${(0, subTaskSupport_1.formatElapsed)(Date.now() - subStartedAt)}]`;
430
+ const out = { ...r, ...(childVerifications.length ? { childVerifications } : {}) };
431
+ if (out.error !== undefined)
432
+ out.error = `${out.error}\n\n${stats}${isoNote}`;
433
+ else
434
+ out.output = `${out.output ?? ''}\n\n${stats}${isoNote}`;
435
+ return out;
436
+ };
437
+ // Claimed right before `try` (whose finally releases it). runSubTask has no `await`
438
+ // before this point, so two parallel calls cannot both pass the check.
439
+ if (resumed && !(0, agentRegistry_1.claimAgentForResume)(resumed.id)) {
440
+ clearInterval(stallWatchdog);
441
+ clearInterval(parentAbortPoll);
442
+ if (isoState)
443
+ (0, worktree_1.removeWorktree)(isoState, { force: true });
444
+ return {
445
+ error: `Sub-agent "${resumed.id}" is already being resumed by another task call that is still running. ` +
446
+ 'Wait for that result, then resume it again with your follow-up — two parallel resumes of one agent ' +
447
+ 'would overwrite each other\'s work.',
448
+ };
449
+ }
450
+ const childMeta = (m) => (m
451
+ ? { ...m, parentId: m.parentId ?? options._taskToolUseId, agentName: m.agentName ?? agent?.name ?? 'general-purpose' }
452
+ : undefined);
453
+ try {
454
+ // The point of no return: past here a real agent loop exists and the budget slot is spent.
455
+ if (started)
456
+ started.value = true;
457
+ // SubagentStart: observer (cannot veto — the PreToolUse hook on `task` already can).
458
+ // additionalContext is appended to the brief the child receives.
459
+ {
460
+ const subAgentName = agent?.name ?? (typeof input.subagent_type === 'string' && input.subagent_type ? input.subagent_type : 'general-purpose');
461
+ const startItems = (0, hooks_1.loadHooks)(options.workDir).SubagentStart;
462
+ if (startItems?.length) {
463
+ const o = await (0, hooks_1.runLifecycleHooks)(startItems, 'SubagentStart', options.workDir, { session_id: options.sessionId ?? '', agent_type: subAgentName, description: String(input.description ?? '') }, subAgentName, (0, hooks_1.hookRunOptsFor)(options)).catch(() => ({ block: false }));
464
+ if (o.context) {
465
+ const last = subMessages[subMessages.length - 1];
466
+ if (last && Array.isArray(last.content)) {
467
+ subMessages[subMessages.length - 1] = { ...last, content: [...last.content, { type: 'text', text: `\n\n<hook-context>\n${o.context}\n</hook-context>` }] };
468
+ }
469
+ }
470
+ }
471
+ }
472
+ const childRun = runChild(subMessages, {
473
+ ...options,
474
+ _depth: depth + 1,
475
+ _agentScope: `sub_${++_subTaskCounter}`, // isolated todo store per sub-agent
476
+ // ── Assigned UNCONDITIONALLY, never by conditional spread ────────────────
477
+ //
478
+ // These two were previously spread in only when set:
479
+ //
480
+ // ...(agent && memoryScope ? { _agentMemory: … } : {}),
481
+ //
482
+ // which does NOT clear the key — it leaves whatever `...options` already had.
483
+ // So a child WITHOUT its own `memory:` inherited its PARENT's binding and would
484
+ // have appended to another agent's private notes; likewise an agent with no
485
+ // `tools:` line inherited the parent's allowlist, making the prompt's capability
486
+ // claim disagree with its real one.
487
+ //
488
+ // This is now LIVE, not latent: nesting is enabled by default, so a grandchild really
489
+ // can be spawned by an agent that has a memory binding. The explicit `undefined` is
490
+ // what stops it inheriting that binding and appending to its grandparent's private
491
+ // notes — a silent cross-agent write rather than a visible error. Note the allowlist
492
+ // takes the opposite direction on purpose (inherited, because it RESTRICTS); identity
493
+ // must not be inherited, capability must.
494
+ _agentMemory: agent && memoryScope ? { agentName: agent.name, scope: memoryScope } : undefined,
495
+ // Assigned unconditionally for the same reason as _agentMemory above: a
496
+ // conditional spread would leave the PARENT's type name in place, so an
497
+ // untyped (general-purpose) child would be recorded in the audit trail
498
+ // under its parent's agent type — a wrong attribution, which is worse in a
499
+ // compliance record than an absent one.
500
+ _agentTypeName: agent?.name,
501
+ // The same set `gatedPermission` enforces above, so prompt and permission agree
502
+ // by construction instead of by two people remembering to update both.
503
+ _allowedTools: allowed ?? undefined,
504
+ // Propagated so a grandchild inherits it too — see _testFilesOnly. Assigned
505
+ // unconditionally (not by conditional spread) for the same reason as _agentMemory
506
+ // above: a conditional spread leaves the parent's value in place instead of clearing
507
+ // it, and here that direction is at least safe, whereas forgetting to propagate is not.
508
+ _testFilesOnly: testFilesOnly,
509
+ editorContext: null, // fresh isolated context for sub-agent
510
+ model: (0, modelCatalogue_1.resolveSubAgentModel)(agent?.model, options.model),
511
+ workDir: childWorkDir,
512
+ env: childEnv,
513
+ effort: agent?.effort ?? options.effort,
514
+ // A bounded run that REPORTS when it hits the limit (see childStop below), instead
515
+ // of inheriting the main agent's 500-step budget plus auto-continue to 2000.
516
+ maxIterations: agent?.maxTurns ?? subTaskSupport_1.DEFAULT_SUBAGENT_MAX_TURNS,
517
+ autoContinue: false,
518
+ // ── Session-level channels a child must NEVER consume ────────────────────
519
+ // Inherited through `...options`, a child drained the user's queued follow-ups
520
+ // and inbound peer messages into ITS history (the main agent never saw them),
521
+ // and its onProgress saved the child's transcript AS the session (CLI/desktop).
522
+ takePendingInput: undefined,
523
+ onInjectedInput: undefined,
524
+ drainPeerMessages: undefined,
525
+ onPeerMessage: undefined,
526
+ onProgress: undefined,
527
+ backgroundAgents: undefined,
528
+ _projectNexrallMd: projectMd,
529
+ _disallowedTools: disallowed.size ? disallowed : undefined,
530
+ _mcpServerAllowlist: mcpAllow,
531
+ _agentHooks: agent?.hooks,
532
+ _onStopReason: (reason, notice) => { childStop = reason; childNotice = notice; },
533
+ _onVerifications: (records) => { childVerifications = records; },
534
+ onUsage: (u, partial, cost, _sub) => {
535
+ if (!partial) {
536
+ subTokens += (u.input_tokens ?? 0) + (u.output_tokens ?? 0)
537
+ + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
538
+ subCost += cost ?? 0;
539
+ }
540
+ options.onUsage(u, partial, cost, true);
541
+ },
542
+ // Plan mode is inherited, never relaxed. If the main agent could spawn a
543
+ // sub-agent that writes, the lock would be one `task` call from useless.
544
+ // `permissionMode: plan` in a definition can only ADD the lock.
545
+ planMode: options.planMode || agent?.permissionMode === 'plan',
546
+ // Same reasoning as planMode directly above: a sub-agent that could reach
547
+ // outside its parent's worktree would defeat the isolation in one `task`
548
+ // call. Already inherited via `...options` above — restated explicitly so
549
+ // it reads the same way as planMode and is never accidentally dropped by
550
+ // a future refactor of this spread.
551
+ worktree: isoState ?? options.worktree,
552
+ // A sub-agent using message_peer_session/list_peer_sessions should
553
+ // present as the SAME peer identity as its parent — there is one
554
+ // registered peer per SESSION, not per sub-agent, so a sub-agent is
555
+ // not a separate discoverable entity of its own.
556
+ selfPeer: options.selfPeer,
557
+ nexrallMd: subNexrallMd,
558
+ abortSignal: subAbort,
559
+ requestPermission: gatedPermission,
560
+ onText: () => { }, // sub-agent text is returned as the tool result, not streamed live
561
+ // A sub-agent renders nothing live (text and thinking are not streamed — see below),
562
+ // so a restart of ITS stream has nothing on screen to roll back: a real no-op
563
+ // handler is exactly right, and it opts the child into post-render restarts.
564
+ onStreamRestart: () => { },
565
+ // Forward tool events with isSubTask=true so the UI can render a badge
566
+ // instead of prepending "[sub-task]" to the tool name (which caused double-prefix
567
+ // when the name was already labelled, and mixed display concerns into the data layer).
568
+ // Every tool event is PROGRESS: it proves the sub-agent is still doing work, which
569
+ // is what the stall watchdog above measures. Bumping on both use and result means a
570
+ // single very slow tool (a long test run) resets the clock when it starts AND when
571
+ // it finishes, so it cannot be mistaken for a hang.
572
+ // `parentId` = the `task` call that spawned THIS child. Set only if not already set,
573
+ // so a grandchild's events keep pointing at their own (nearest) task row.
574
+ onToolUse: (n, i, _s, m) => { bumpProgress(); subToolCalls++; options.onToolUse(n, i, true, childMeta(m)); },
575
+ onToolResult: (n, r, _s, m) => { bumpProgress(); options.onToolResult(n, r, true, childMeta(m)); },
576
+ // A long command streaming output is working, not hung.
577
+ onToolStreamChunk: (n, c, _s, m) => { bumpProgress(); options.onToolStreamChunk?.(n, c, true, childMeta(m)); },
578
+ // Thinking is progress (a model reasoning for minutes is working, not stalled), but
579
+ // it is NOT forwarded to the UI: parallel siblings' deltas interleaved into one live
580
+ // thinking block, and Claude Code does not show sub-agent reasoning either. The
581
+ // sub-agent's tool rows (grouped under its task row) are its visible progress.
582
+ onThinking: () => { bumpProgress(); },
583
+ onThinkingDelta: () => { bumpProgress(); },
584
+ onThinkingProgress: () => { bumpProgress(); },
585
+ });
586
+ const raced = await (0, subTaskSupport_1.raceHardStop)(childRun, subAbort);
587
+ if (raced === subTaskSupport_1.HARD_STOPPED) {
588
+ // The child was told to stop (stall watchdog, parent Stop, background stop) but a
589
+ // tool it is running ignored cancellation — an MCP call, an editor-side tool. The
590
+ // parent must not hang on it forever; the orphaned call is left to finish alone.
591
+ return finalize({
592
+ error: `Sub-task did not stop within ${Math.round((0, subTaskSupport_1.hardStopGraceMs)() / 1000)}s of being stopped: a tool it was ` +
593
+ 'running ignored cancellation. Its partial work could not be collected. Do not re-run it as-is — ' +
594
+ 'narrow the task, or avoid the tool that hung.',
595
+ });
596
+ }
597
+ const result = raced;
598
+ // ── Stall timeout: SALVAGE, don't discard ────────────────────────────────
599
+ //
600
+ // The sub-agent hit its wall-clock cap (rather than finishing, or the parent
601
+ // aborting). This used to return ONLY an error string — throwing away
602
+ // everything the sub-agent had produced in up to ten minutes of work. The
603
+ // tokens were billed in full either way, and the parent model, told merely
604
+ // that "it stalled", would routinely re-run the identical work from scratch.
605
+ //
606
+ // The timeout still has to be reported unmistakably (the parent must not
607
+ // mistake a truncated run for a complete answer), but it is reported ALONGSIDE
608
+ // whatever was actually accomplished, not instead of it. `preferLast: false`
609
+ // because a killed sub-agent rarely has a closing summary — its useful output
610
+ // is spread across the assistant turns it did manage to produce.
611
+ if (stalled && !options.abortSignal?.aborted) {
612
+ const mins = Math.round(subtaskTimeoutMs / 60000);
613
+ const partial = (0, subTaskSupport_1.capSubTaskText)((0, subTaskSupport_1.extractSubTaskText)(result, false));
614
+ const progress = (0, subTaskSupport_1.summariseSubTaskProgress)(result);
615
+ // Keep the transcript so the parent can CONTINUE this run instead of redoing it.
616
+ //
617
+ // Previously only a cleanly-finished sub-agent was remembered, on the reasoning
618
+ // that a transcript ending mid-thought is unsafe to build on. The reasoning is
619
+ // sound; the conclusion was too strong. Refusing to store it meant a stalled
620
+ // sub-agent's entire body of work — dozens of tool calls, already billed — was
621
+ // unreachable, so the parent's only option was the very thing we tell it not to
622
+ // do: run the whole task again. Resuming is now POSSIBLE but never implied to be
623
+ // safe: the text below states plainly that the work is unverified, and resumption
624
+ // re-authorises against current permissions exactly as it does for a clean run.
625
+ const partialId = (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result, agentScope, options.sessionId);
626
+ const sections = [
627
+ `Sub-task STOPPED after ${mins} minutes with NO PROGRESS (it was not making tool calls or ` +
628
+ 'producing output) — treat everything below as PARTIAL, unverified work, not a finished answer.',
629
+ progress,
630
+ partial ? `Partial output before it was stopped:\n\n${partial}` : '',
631
+ `Do NOT re-run the same sub-task from scratch. Either build on what is above, or continue THIS ` +
632
+ `run with resume_agent_id="${partialId}" (it still has everything it read), or split the ` +
633
+ 'remaining work into smaller, more focused sub-tasks.',
634
+ ].filter(Boolean);
635
+ // Returned as `error` (not `output`) on purpose: the loop's ledger counts an
636
+ // errored call as a non-effect, which is right — nothing here is verified —
637
+ // and the STALL_LIMIT runaway guard must still see repeated timeouts as
638
+ // failures so a permanently stuck sub-task can't loop forever.
639
+ return finalize({ error: sections.join('\n\n') });
640
+ }
641
+ // ── Stopped abnormally (turn limit, repeated failures, truncation, no balance) ──
642
+ // runAgentLoop RETURNS normally for these, so this used to fall through to the
643
+ // success path: the parent was handed a fragment as if it were the finished report,
644
+ // while the explanation went to the user's chat as if the main agent had stopped.
645
+ // Assigned inside callbacks, so TS narrows them to `null` here without the casts.
646
+ const stopReasonOfChild = childStop;
647
+ const noticeOfChild = childNotice;
648
+ if (stopReasonOfChild && subTaskSupport_1.ABNORMAL_SUBAGENT_STOPS.has(stopReasonOfChild) && !subAbort.aborted) {
649
+ const partial = (0, subTaskSupport_1.capSubTaskText)((0, subTaskSupport_1.extractSubTaskText)(result, false));
650
+ const progress = (0, subTaskSupport_1.summariseSubTaskProgress)(result);
651
+ const partialId = resumed
652
+ ? ((0, agentRegistry_1.updateAgent)(resumed.id, result, agentScope, options.sessionId), resumed.id)
653
+ : (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result, agentScope, options.sessionId);
654
+ const why = stopReasonOfChild === 'budget'
655
+ ? `it reached its turn limit (${agent?.maxTurns ?? subTaskSupport_1.DEFAULT_SUBAGENT_MAX_TURNS} model round-trips)`
656
+ : `it stopped early (${stopReasonOfChild})`;
657
+ const sections = [
658
+ `Sub-task did NOT finish: ${why}. Treat everything below as PARTIAL, unverified work.`,
659
+ noticeOfChild ? noticeOfChild.trim() : '',
660
+ progress,
661
+ partial ? `Partial output:\n\n${partial}` : '',
662
+ `To continue it with everything it already read, call task with resume_agent_id="${partialId}".`,
663
+ ].filter(Boolean);
664
+ return finalize({ error: sections.join('\n\n') });
665
+ }
666
+ // Normal completion: the final assistant message is the sub-agent's answer.
667
+ const text = (0, subTaskSupport_1.capSubTaskText)((0, subTaskSupport_1.extractSubTaskText)(result, true));
668
+ // Store the transcript so a follow-up can continue this agent rather than
669
+ // re-running it from scratch, and tell the parent the id.
670
+ //
671
+ // (The stalled and stopped-early paths above register their transcripts too, marked
672
+ // as partial work in the text they return; only a thrown failure is not resumable.)
673
+ const agentId = resumed
674
+ ? ((0, agentRegistry_1.updateAgent)(resumed.id, result, agentScope, options.sessionId), resumed.id)
675
+ : (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result, agentScope, options.sessionId);
676
+ const body = text || '(sub-task completed with no text output)';
677
+ return finalize({
678
+ output: `${body}\n\n[resumable: this sub-agent is "${agentId}". To ask IT a follow-up — keeping ` +
679
+ 'everything it already read and concluded — call task again with resume_agent_id="' + agentId +
680
+ '" instead of writing a new prompt from scratch.]',
681
+ });
682
+ }
683
+ catch (err) {
684
+ // Same salvage rule as the timeout path above, for the other way a sub-agent
685
+ // dies: runAgentLoop throws AgentTurnError when its stream fails, and that
686
+ // error CARRIES the history completed up to the failure precisely so callers
687
+ // don't lose it (see types.ts). Discarding it here — as this catch used to —
688
+ // reproduced the exact waste that class was written to prevent, one level down.
689
+ const salvaged = (0, types_1.salvageHistory)(err);
690
+ if (salvaged) {
691
+ const partial = (0, subTaskSupport_1.capSubTaskText)((0, subTaskSupport_1.extractSubTaskText)(salvaged, false));
692
+ const progress = (0, subTaskSupport_1.summariseSubTaskProgress)(salvaged);
693
+ const sections = [
694
+ `Sub-task FAILED before completing: ${err.message}`,
695
+ progress,
696
+ partial ? `Partial output before the failure:\n\n${partial}` : '',
697
+ 'Treat the above as PARTIAL, unverified work. Build on it rather than re-running the whole sub-task.',
698
+ ].filter(Boolean);
699
+ return finalize({ error: sections.join('\n\n') });
700
+ }
701
+ return finalize({ error: `Sub-task failed: ${err.message}` });
702
+ }
703
+ finally {
704
+ clearInterval(stallWatchdog);
705
+ clearInterval(parentAbortPoll);
706
+ // Every return above already ran finalize(); this only catches an unexpected path.
707
+ if (isoState && !(0, worktree_1.worktreeHasWork)(isoState))
708
+ (0, worktree_1.removeWorktree)(isoState, { force: true });
709
+ if (resumed)
710
+ (0, agentRegistry_1.releaseAgentForResume)(resumed.id);
711
+ }
712
+ }
713
+ //# sourceMappingURL=subTask.js.map