@hncode/hncode 0.5.6 → 0.6.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 (56) hide show
  1. package/package.json +37 -38
  2. package/src/agent.js +67 -2
  3. package/src/image-row.js +111 -0
  4. package/src/latex.js +1557 -0
  5. package/src/llm.js +19 -4
  6. package/src/mermaid/ansi.js +55 -0
  7. package/src/mermaid/canvas.js +449 -0
  8. package/src/mermaid/class-style.js +57 -0
  9. package/src/mermaid/css-colors.js +153 -0
  10. package/src/mermaid/diagrams/class.js +289 -0
  11. package/src/mermaid/diagrams/er.js +201 -0
  12. package/src/mermaid/diagrams/flowchart.js +488 -0
  13. package/src/mermaid/diagrams/gitgraph.js +205 -0
  14. package/src/mermaid/diagrams/mindmap.js +101 -0
  15. package/src/mermaid/diagrams/pie.js +104 -0
  16. package/src/mermaid/diagrams/sequence.js +282 -0
  17. package/src/mermaid/diagrams/state.js +292 -0
  18. package/src/mermaid/diagrams/timeline.js +99 -0
  19. package/src/mermaid/graph.js +138 -0
  20. package/src/mermaid/index.js +59 -0
  21. package/src/mermaid/labels.js +339 -0
  22. package/src/mermaid/layout-seq.js +225 -0
  23. package/src/mermaid/layout.js +1454 -0
  24. package/src/mermaid/registry.js +54 -0
  25. package/src/mermaid/source-box.js +102 -0
  26. package/src/mermaid/statements.js +235 -0
  27. package/src/mermaid/types.js +1 -0
  28. package/src/mermaid/width.js +29 -0
  29. package/src/screen-diff.js +512 -0
  30. package/src/screen-paint.js +128 -0
  31. package/src/screen.js +277 -0
  32. package/src/subagent-types.js +136 -147
  33. package/src/term-caps.js +10 -4
  34. package/src/tex/font.js +292 -0
  35. package/src/tex/fonts/LICENSE.txt +11 -0
  36. package/src/tex/fonts/STIX2Math.ttf +0 -0
  37. package/src/tex/index.js +293 -0
  38. package/src/tex/kitty.js +186 -0
  39. package/src/tex/layout.js +446 -0
  40. package/src/tex/parse.js +331 -0
  41. package/src/tex/raster.js +216 -0
  42. package/src/tex/render.js +167 -0
  43. package/src/tex/sixel.js +326 -0
  44. package/src/tex/svg.js +237 -0
  45. package/src/tool-result.js +2 -2
  46. package/src/tools/agent-swarm.js +3 -15
  47. package/src/tools/agent.js +454 -464
  48. package/src/tools/ask-user-question.js +195 -206
  49. package/src/tools/bash.js +39 -3
  50. package/src/tools/edit.js +10 -112
  51. package/src/tools/fetch-url.js +130 -120
  52. package/src/tools/memory.js +80 -88
  53. package/src/tools/read.js +27 -23
  54. package/src/tools/todo.js +8 -13
  55. package/src/tools/validate-args.js +78 -0
  56. package/src/tui.js +1346 -502
@@ -1,465 +1,455 @@
1
- // Agent tool — launch a subagent to handle a task, mirroring kimi-code's Agent.
2
- //
3
- // A subagent is a full Agent instance (same toolbelt, same protocol) with its OWN
4
- // message list. The point is context isolation: the parent gets a conclusion back
5
- // instead of a pile of file dumps.
6
- //
7
- // Supports, like kimi:
8
- // prompt / description the task
9
- // subagent_type profile name (we keep a small catalog)
10
- // resume continue an earlier subagent by agent id
11
- // run_in_background start a background task and return its id
12
- // fork start from a snapshot of THIS conversation
13
- // model run on another configured model
14
- //
15
- // The result is returned as a plain string. A subagent's output is only visible
16
- // to the CALLER, never rendered to the user directly — the parent is expected to
17
- // summarise it.
18
-
19
- import { createTask, appendTaskOutput, settleTask } from '../agent-task.js';
20
- import { registerRun, endRun } from '../subagent-control.js';
21
- import { SUBAGENT_TYPES, DEFAULT_SUBAGENT_TYPE, availableTypes, toolsForSubagent } from '../subagent-types.js';
22
- import { runShellHooks } from '../hooks.js';
23
-
24
- // Live subagent registry, keyed by agent id. A resumed agent keeps its own
25
- // message list here, which is what makes `resume` work.
26
- //
27
- // Bounded: a 128-item swarm made 128 entries holding FULL transcripts, kept for
28
- // the process lifetime. The oldest entry is evicted past the cap — resuming a
29
- // very old subagent then reports it as unknown instead of silently holding every
30
- // transcript forever.
31
- const SUBAGENTS_MAX = 64;
32
- const subagents = new Map();
33
- function rememberSubagent(id, entry) {
34
- subagents.delete(id); // re-insertion moves the entry to the newest slot
35
- subagents.set(id, entry);
36
- while (subagents.size > SUBAGENTS_MAX) {
37
- subagents.delete(subagents.keys().next().value);
38
- }
39
- }
40
- let agentSeq = 0;
41
-
42
-
43
- export function newAgentId() {
44
- agentSeq = (agentSeq + 1) % 1e6;
45
- return `agent-${Date.now().toString(36)}${String(agentSeq).padStart(3, '0')}`;
46
- }
47
-
48
- export function getSubagent(id) {
49
- return subagents.get(id) || null;
50
- }
51
-
52
- export function listSubagents() {
53
- return [...subagents.values()];
54
- }
55
-
56
- // Build the system prompt for a subagent: its profile prompt plus the shared
57
- // rules, so a subagent behaves like the main agent within its remit.
58
- function systemPromptFor(type, cfg, systemPrompt) {
59
- const profile = SUBAGENT_TYPES[type] || SUBAGENT_TYPES[DEFAULT_SUBAGENT_TYPE];
60
- const base = (cfg.systemPrompt && String(cfg.systemPrompt).trim()) || systemPrompt;
61
- return `${profile.prompt}\n\n${base}`;
62
- }
63
-
64
- // Run a subagent to completion. `history` seeds its messages (fork). Returns
65
- // { text, usage } or { error }.
66
- // `onProgress(text)` receives the subagent's live output (its reasoning and answer
67
- // stream, and each tool it runs) so the TUI can show it under the `Using Agent`
68
- // row the way Bash's output is shown. `agentId` labels the lines when several
69
- // subagents run at once (AgentSwarm), so the reader can tell them apart.
70
- async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProgress, onStep, agentId, onAgent }) {
71
- // The parent-signal listener (registered below, removed when the run settles).
72
- let onSubagentAbort = null;
73
- const { Agent, SYSTEM_PROMPT } = await import('../agent.js');
74
- const subCfg = {
75
- ...cfg,
76
- systemPrompt: systemPromptFor(type, cfg, SYSTEM_PROMPT),
77
- // The profile narrows the toolbelt (explore / plan / researcher are read-only)
78
- // and the delegation tools are removed unconditionally — a subagent must not
79
- // start another agent. `noDelegation` is the hard backstop in agent.js for the
80
- // case where the model emits a call for a tool it no longer has.
81
- toolFilter: toolsForSubagent(type, cfg.toolFilter),
82
- noDelegation: true,
83
- };
84
- // fork: the snapshot comes first, then the new instruction.
85
- const messages = Array.isArray(history) && history.length
86
- ? [...history, { role: 'user', content: prompt }]
87
- : [{ role: 'user', content: prompt }];
88
-
89
- let text = '';
90
- // A failure used to leave `text` empty, so a caller could not tell "produced nothing" from
91
- // "died". The Agent's only signal on error is an event, so capture it here.
92
- let failure = null;
93
- const agent = new Agent({
94
- cfg: subCfg,
95
- messages,
96
- onEvent: (e) => {
97
- if (e.type === 'data') text += e.text;
98
- // Mirror what the subagent is actually doing into the parent's transcript:
99
- // its reasoning and answer stream as `data`/`think`, and every tool it runs
100
- // arrives as tool_start/tool_result. Without this the `Using Agent …` row sat
101
- // silent for the whole run — the subagent looked hung.
102
- if (onProgress) {
103
- const tag = agentId ? `[${agentId}] ` : '';
104
- if (e.type === 'data' || e.type === 'think') {
105
- if (e.text) onProgress(tag + e.text);
106
- } else if (e.type === 'tool_start') {
107
- onProgress(`\n${tag}\u25b8 ${e.name}\n`);
108
- // One more round of work done. A SUBAGENT has no fixed step budget (agent.js sets
109
- // `stepLimit = Infinity` unless the caller bounds it), so "steps remaining" is not
110
- // a real quantity and a percentage of it would be invented. Tool calls completed is
111
- // the honest measure: it only ever grows, it is what the run is actually doing, and
112
- // the swarm turns it into a bar against the pace its other agents have set.
113
- if (typeof onStep === 'function') onStep();
114
- } else if (e.type === 'tool_result') {
115
- const body = String(e.content == null ? '' : e.content);
116
- const first = body.split('\n').slice(0, 3).join('\n');
117
- onProgress(`${tag}${first}${body.split('\n').length > 3 ? '\n\u2026' : ''}\n`);
118
- } else if (e.type === 'error') {
119
- onProgress(`\n${tag}[error] ${(e.error && e.error.message) || String(e.error)}\n`);
120
- failure = (e.error && e.error.message) || String(e.error);
121
- }
122
- }
123
- if (onEvent) onEvent(e);
124
- },
125
- // A subagent never prompts: it inherits the parent's permission mode by
126
- // running under the same ctx (kimi does the same — approvals belong to the
127
- // parent turn, not the child).
128
- onApproval: async () => true,
129
- });
130
- if (signal) {
131
- if (signal.aborted) throw new Error('Interrupted by user');
132
- onSubagentAbort = () => agent.interrupt();
133
- signal.addEventListener('abort', onSubagentAbort, { once: true });
134
- }
135
- // Hand the LIVE agent back to the caller so a control tool can steer or interrupt it
136
- // while it runs. `subagents` only learns about this run when it finishes, so without
137
- // this the only way to reach a running subagent would be to interrupt the whole turn.
138
- if (typeof onAgent === 'function') onAgent(agent);
139
- // The abort listener is removed when the run settles. `{once:true}` only makes it
140
- // fire once — it stays REGISTERED until then, so every completed subagent left
141
- // one closure (holding the whole subagent) pinned on the parent's signal.
142
- try {
143
- await agent.run();
144
- } finally {
145
- if (onSubagentAbort) signal.removeEventListener('abort', onSubagentAbort);
146
- }
147
- // SHELL HOOK: SubagentStop. The subagent finished (or was interrupted); the
148
- // parent-side hook can log/archive it. Never blocks.
149
- // `hookConfig` may be the raw `{event:[…]}` map or a `{hooks:{…}}` wrapper
150
- // (agent.js normalises it to the wrapper) — accept either.
151
- const hc = subCfg.hookConfig;
152
- const hookCfg = hc ? (hc.hooks ? hc : { hooks: hc }) : null;
153
- if (hookCfg) {
154
- await runShellHooks(hookCfg, 'SubagentStop',
155
- { agentId: agentId || '', subagentType: type, prompt }, cfg.workspace);
156
- }
157
- // The parent must receive ONLY what the subagent concluded — never its tool
158
- // chatter, and never its reasoning. Two things could leak in:
159
- // * the aggregate `data` stream, which also contains intermediate narration;
160
- // * an inline `<think>…</think>` block, when a model puts reasoning in the
161
- // content stream instead of the separate reasoning channel.
162
- // So: take the LAST assistant message (the handoff), and strip any think block
163
- // from it. Fall back to the filtered stream text when there is no message.
164
- // Strip whichever spelling the model used. `\b` alone is wrong here: `k` and `i`
165
- // are both word characters, so `<think\b` does NOT match `<thinking>` — a model
166
- // using the long form had its whole reasoning block leak into the handoff. Enumerate
167
- // the variants instead, matching llm.js's tag table.
168
- const stripThink = (s) => String(s)
169
- .replace(/<(?:think|thinking)\b[^>]*>[\s\S]*?<\/(?:think|thinking)>/gi, '')
170
- .replace(/<think(?:ing)?\b[^>]*>[\s\S]*$/i, '') // an unclosed block: drop to the end
171
- .trim();
172
- const lastAssistant = [...agent.messages].reverse()
173
- .find((m) => m.role === 'assistant' && typeof m.content === 'string' && stripThink(m.content));
174
- const result = (lastAssistant && stripThink(lastAssistant.content)) || stripThink(text) || '(no output)';
175
- // The failure travels WITH the text. A run that died and a run that simply produced nothing
176
- // both used to arrive as an empty/`(no output)` string, so the caller could not tell them apart
177
- // and reported a dead agent as a successful empty answer.
178
- return { text: result, messages: agent.messages, failed: failure };
179
- }
180
-
181
- export const spec = {
182
- name: 'Agent',
183
- description: `Launch a subagent to handle a task. The subagent runs with its own context, so delegating keeps the bulk of intermediate file contents out of your own context — you get a conclusion back instead of a pile of dumps.
184
-
185
- Writing the prompt:
186
- - The subagent starts with zero context — it has not seen this conversation. Brief it like a colleague who just walked into the room: state the goal, list what you already know, hand over the specifics.
187
- - Lookups (read this file, run that test): put the exact path or command in the prompt.
188
- - Investigations (figure out X, find why Y): give the question, not prescribed steps.
189
- - Do not delegate understanding. If the task hinges on a file path or line number, find it yourself first and write it into the prompt.
190
-
191
- Usage notes:
192
- - When the task continues earlier work a subagent already did, prefer resuming that agent (pass its \`resume\` id) over spawning a fresh instance.
193
- - A subagent's result is only visible to you, not to the user. Summarize the relevant parts yourself in your own reply.
194
-
195
- When NOT to use Agent:
196
- - Skip delegation for trivial work you can already do directly — reading a file whose path you know, or any task that takes a step or two.
197
- - Do NOT spawn a subagent unless the user, or the applicable AGENTS.md / project instructions, explicitly asks for sub-agents, delegation, or parallel agent work. Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do NOT count as permission to spawn. Doing the work yourself is the default.
198
- - Do not delegate the step you are about to be blocked on: if your very next action depends on the result, do it yourself and keep the critical path moving.
199
- - Do not delegate work you are already doing — redelegating an unresumed task duplicates effort instead of saving context.
200
-
201
- Available agent types:
202
- ${availableTypes()}`,
203
- parameters: {
204
- type: 'object',
205
- properties: {
206
- prompt: { type: 'string', description: 'Full task prompt for the subagent.' },
207
- description: { type: 'string', description: 'Short task description (3-5 words) for UI display.' },
208
- subagent_type: { type: 'string', description: `One of the available agent types. Defaults to "${DEFAULT_SUBAGENT_TYPE}" when omitted.` },
209
- resume: { type: 'string', description: 'Optional agent id to resume instead of creating a new instance. Do not also pass subagent_type.' },
210
- run_in_background: { type: 'boolean', default: false, description: 'If true, return immediately with a task id instead of waiting. The result arrives in the conversation later.' },
211
- fork: { type: 'boolean', default: false, description: "Start the subagent from a snapshot of this agent's completed conversation history instead of zero context." },
212
- model: { type: 'string', description: 'A configured model key to run the subagent on, or "primary" for the current model. Ignored when resuming.' },
213
- },
214
- required: ['prompt', 'description'],
215
- },
216
-
217
- async execute(args, ctx) {
218
- const prompt = String(args.prompt || '').trim();
219
- const description = String(args.description || '').trim();
220
- if (!prompt) return 'Error: `prompt` is required.';
221
- if (!description) return 'Error: `description` is required (short label for the task list).';
222
-
223
- const resumeId = args.resume ? String(args.resume).trim() : '';
224
- const wantFork = args.fork === true;
225
- if (resumeId && args.subagent_type) {
226
- return 'Error: cannot set subagent_type when resuming an existing agent. Resume by agent id only.';
227
- }
228
- if (resumeId && wantFork) {
229
- return 'Error: `fork` cannot be combined with `resume`.';
230
- }
231
-
232
- // ---- resume an existing subagent -------------------------------------
233
- let existing = null;
234
- if (resumeId) {
235
- existing = subagents.get(resumeId);
236
- if (!existing) return `Error: no such subagent: ${resumeId}`;
237
- }
238
-
239
- const type = existing
240
- ? existing.type
241
- : (SUBAGENT_TYPES[args.subagent_type] ? args.subagent_type : DEFAULT_SUBAGENT_TYPE);
242
-
243
- // ---- model override ---------------------------------------------------
244
- // Precedence: the call's own `model` > the user's configured subagent model
245
- // (/subagent-model) > the session's model. 'primary' always means "this
246
- // session's model", so an explicit primary beats a configured default.
247
- let subCfg = ctx;
248
- const wantPrimary = args.model === 'primary';
249
- const explicit = args.model && !wantPrimary && ctx.raw && ctx.raw.models && ctx.raw.models[args.model]
250
- ? args.model : '';
251
- const configured = ctx.subagentModel && ctx.raw && ctx.raw.models && ctx.raw.models[ctx.subagentModel]
252
- ? ctx.subagentModel : '';
253
- // A NAME THAT DOES NOT EXIST IS AN ERROR, not a fallback. Resolving it to "" ran the
254
- // subagent on the session's model while the caller believed it had chosen — a typo, or a
255
- // model configured in another session, produced a silently different agent.
256
- if (args.model && !wantPrimary && !explicit) {
257
- const known = Object.keys((ctx.raw && ctx.raw.models) || {});
258
- const list = known.length ? known.join(', ') : 'none';
259
- return `Error: unknown model "${args.model}". Configured: ${list}. Use "primary" for the session model.`;
260
- }
261
-
262
- // The user's `/subagent-model` setting can go stale when a model is renamed or removed, and
263
- // it used to fail the same silent way. Say so, through onOutput so it reaches the transcript.
264
- if (ctx.subagentModel && !configured) {
265
- const list = Object.keys((ctx.raw && ctx.raw.models) || {}).join(', ') || 'none';
266
- try {
267
- if (typeof ctx.onOutput === 'function') {
268
- ctx.onOutput(`[subagent model] "${ctx.subagentModel}" is no longer configured, so this subagent runs on the session model. Re-pick it with /swarm-sub-agent. Available: ${list}\n`);
269
- }
270
- } catch { /* a view that cannot be written to is not a reason to fail the call */ }
271
- }
272
-
273
- const chosen = wantPrimary ? '' : (explicit || configured);
274
- if (chosen) {
275
- const { resolveModelArg } = await import('../config.js');
276
- subCfg = resolveModelArg(ctx, chosen);
277
- }
278
-
279
- // ---- fork: snapshot this conversation --------------------------------
280
- let history = null;
281
- if (wantFork) {
282
- history = (ctx._parentMessages || []).filter(
283
- (m) => m.role !== 'system' && m.role !== 'tool' && typeof m.content === 'string' && m.content.trim(),
284
- );
285
- }
286
-
287
- // ---- background -------------------------------------------------------
288
- if (args.run_in_background === true) {
289
- const controller = new AbortController();
290
- const task = createTask(ctx, 'agent', {
291
- description,
292
- agentId: null,
293
- subagentType: type,
294
- model: args.model || '',
295
- detached: true,
296
- });
297
- task._abort = () => controller.abort();
298
- const agentId = existing ? existing.id : newAgentId();
299
- task.agentId = agentId;
300
-
301
- // Register the run so a control tool can reach it while it is in flight. The handle
302
- // is created BEFORE the run so both `run_in_background` and the detached-foreground
303
- // path behave the same, and cleared in `finally` so a settled run can never be
304
- // listed as controllable.
305
- let runId = '';
306
- const early = { agent: null };
307
-
308
- // Fire and forget: the task record carries the result when it lands.
309
- void (async () => {
310
- try {
311
- const run = await runSubagent({
312
- cfg: subCfg, type, prompt, history,
313
- signal: controller.signal,
314
- onAgent: (agent) => { early.agent = agent; },
315
- // No live streaming into the task output: that should read as the
316
- // subagent's CONCLUSION. `run.text` below is the cleaned final message.
317
- });
318
- const entry = existing || { id: agentId, type, messages: [] };
319
- entry.messages = run.messages;
320
- entry.type = type;
321
- rememberSubagent(agentId, entry);
322
- appendTaskOutput(task, `\n\n${run.text}`);
323
- settleTask(task, 'completed');
324
- if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
325
- } catch (e) {
326
- const stopped = controller.signal.aborted;
327
- settleTask(task, stopped ? 'killed' : 'failed', { stopReason: stopped ? 'Stopped by user' : e.message });
328
- appendTaskOutput(task, `\n[error: ${e.message}]`);
329
- if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
330
- } finally {
331
- if (runId) endRun(runId);
332
- }
333
- })();
334
- runId = registerRun({
335
- agentId, taskId: task.taskId, type, description,
336
- steer: (text) => { if (early.agent) early.agent.steer(text); },
337
- interrupt: () => controller.abort(),
338
- });
339
-
340
- return `agent_id: ${agentId}\ntask_id: ${task.taskId}\nstatus: running\nnext_step: Continue your work; the result arrives in a later message. Use TaskOutput {task_id: "${task.taskId}"} for a non-blocking check or TaskStop to cancel.`;
341
- }
342
-
343
- // ---- foreground (the default; Ctrl+B detaches it) ---------------------
344
- // Registered like Bash's foreground hook so the TUI's ctrl+b can move a long
345
- // subagent into the background WITHOUT losing its work: the run keeps going,
346
- // the task record gets the result when it lands, and this call resolves at
347
- // once with the task id so the parent turn can continue.
348
- const bgController = new AbortController();
349
- const onParentAbort = () => bgController.abort();
350
- if (ctx.signal) {
351
- if (ctx.signal.aborted) return 'Error: the turn was interrupted before the subagent started.';
352
- ctx.signal.addEventListener('abort', onParentAbort, { once: true });
353
- }
354
- const agentId = existing ? existing.id : newAgentId();
355
- const entry = existing || { id: agentId, type, messages: [] };
356
- rememberSubagent(agentId, entry);
357
-
358
- let detachedId = null;
359
- const resetForeground = () => { if (ctx && ctx._foreground && ctx._foreground.detach) ctx._foreground = null; };
360
- // Start the run BEFORE the detach hook is installed, so the hook's closure can
361
- // reference the in-flight promise (it waits on it to record the result).
362
- // `onProgress` feeds the parent's live-output sink (ctx.onOutput), which the
363
- // agent tags with THIS tool call's id — the same path Bash uses, so the TUI
364
- // renders the subagent's stream under the `Using Agent` row and Ctrl+O folds it.
365
- // The live Agent is captured as soon as `runSubagent` builds it, which is what makes
366
- // this run controllable from another tool call (or the TUI) without waiting for it to
367
- // finish. `live` is a box because the handle arrives asynchronously.
368
- const live = { agent: null };
369
- const runPromise = runSubagent({
370
- cfg: subCfg, type, prompt, history, signal: bgController.signal,
371
- onAgent: (agent) => { live.agent = agent; },
372
- // `_progressTag` is set by AgentSwarm to the item index, so a swarm's
373
- // concurrently-running subagents are distinguishable in the live stream.
374
- // A plain Agent call has no tag and prints its stream unlabelled.
375
- //
376
- // `_subTaskId` is also set by AgentSwarm: it names the task THIS subagent owns, so its
377
- // stream lands on its own row in /tasks instead of being folded into the swarm's single
378
- // buffer. Without it a 7-item swarm had one task and no way to see what any of them was
379
- // doing.
380
- onProgress: (text) => {
381
- if (ctx._subTaskId) {
382
- const own = ctx.tasks && ctx.tasks[ctx._subTaskId];
383
- if (own) appendTaskOutput(own, text);
384
- }
385
- if (typeof ctx.onOutput !== 'function') return;
386
- const tag = ctx._progressTag ? `[${ctx._progressTag}] ` : '';
387
- ctx.onOutput(tag ? text.split('\n').map((l) => (l ? tag + l : l)).join('\n') : text);
388
- },
389
- // Each completed tool call is one unit of progress. Only AgentSwarm supplies the sink;
390
- // a lone Agent call has no grid to fill and nothing to report to.
391
- onStep: () => { if (typeof ctx._onSubProgress === 'function') ctx._onSubProgress(); },
392
- });
393
- // Registered here so a foreground subagent is reachable by AgentMessage / AgentInterrupt
394
- // too — that is precisely the case where a user wants to redirect one: it is the run
395
- // they are watching. Cleared when the run settles, however it settles.
396
- let runId = registerRun({
397
- agentId, type, description,
398
- steer: (text) => { if (live.agent) live.agent.steer(text); },
399
- interrupt: () => bgController.abort(),
400
- });
401
- void runPromise.catch(() => {}).finally(() => { if (runId) { endRun(runId); runId = ''; } });
402
- if (ctx) {
403
- ctx._foreground = {
404
- kind: 'Agent',
405
- description,
406
- detach: () => {
407
- if (detachedId) return detachedId;
408
- // Hand the in-flight work to a background task. The controller is NOT
409
- // aborted, so the subagent keeps running.
410
- const task = createTask(ctx, 'agent', {
411
- description, agentId, subagentType: type, model: args.model || '', detached: true,
412
- });
413
- task._abort = () => bgController.abort();
414
- detachedId = task.taskId;
415
- resetForeground();
416
- void (async () => {
417
- try {
418
- const r = await runPromise;
419
- entry.messages = r.messages; entry.type = type;
420
- rememberSubagent(agentId, entry);
421
- appendTaskOutput(task, r.text);
422
- settleTask(task, 'completed');
423
- if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
424
- } catch (e) {
425
- const stopped = bgController.signal.aborted;
426
- settleTask(task, stopped ? 'killed' : 'failed', { stopReason: stopped ? 'Stopped by user' : e.message });
427
- appendTaskOutput(task, `\n[error: ${e.message}]`);
428
- if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
429
- } finally {
430
- // The detached run kept the parent-abort link alive on purpose (an
431
- // aborted turn kills it); once it settles the link goes too.
432
- if (ctx.signal) ctx.signal.removeEventListener('abort', onParentAbort);
433
- }
434
- })();
435
- return task.taskId;
436
- },
437
- };
438
- }
439
- let run;
440
- try {
441
- run = await runPromise;
442
- } catch (e) {
443
- resetForeground();
444
- if (ctx.signal) ctx.signal.removeEventListener('abort', onParentAbort);
445
- // Detached: the background task owns the error now, not this call.
446
- if (detachedId) return `Moved to background as task #${detachedId}. It keeps running; inspect with TaskList / TaskOutput {task_id: "${detachedId}"}.`;
447
- return `Error: subagent failed: ${e.message}`;
448
- }
449
- if (ctx.signal) ctx.signal.removeEventListener('abort', onParentAbort);
450
- resetForeground();
451
- if (detachedId) {
452
- return `Moved to background as task #${detachedId}. It keeps running; inspect with TaskList / TaskOutput {task_id: "${detachedId}"}.`;
453
- }
454
- entry.messages = run.messages;
455
- entry.type = type;
456
- rememberSubagent(agentId, entry);
457
-
458
- // A FAILED subagent returns an error, not an empty string. `run.text` is '' when the Agent
459
- // died, which the swarm printed as "(no output)" — so four agents that had all failed on one
460
- // error looked like four agents that had found nothing to say.
461
- if (run.failed) return `Error: subagent "${agentId}" failed: ${run.failed}`;
462
-
463
- return `agent_id: ${agentId}\n\n${run.text}`;
464
- },
1
+ // Agent tool — launch a subagent to handle a task, mirroring kimi-code's Agent.
2
+ //
3
+ // A subagent is a full Agent instance (same toolbelt, same protocol) with its OWN
4
+ // message list. The point is context isolation: the parent gets a conclusion back
5
+ // instead of a pile of file dumps.
6
+ //
7
+ // Supports, like kimi:
8
+ // prompt / description the task
9
+ // subagent_type profile name (we keep a small catalog)
10
+ // resume continue an earlier subagent by agent id
11
+ // run_in_background start a background task and return its id
12
+ // fork start from a snapshot of THIS conversation
13
+ // model run on another configured model
14
+ //
15
+ // The result is returned as a plain string. A subagent's output is only visible
16
+ // to the CALLER, never rendered to the user directly — the parent is expected to
17
+ // summarise it.
18
+
19
+ import { createTask, appendTaskOutput, settleTask } from '../agent-task.js';
20
+ import { registerRun, endRun } from '../subagent-control.js';
21
+ import { SUBAGENT_TYPES, DEFAULT_SUBAGENT_TYPE, availableTypes, toolsForSubagent } from '../subagent-types.js';
22
+ import { runShellHooks } from '../hooks.js';
23
+
24
+ // Live subagent registry, keyed by agent id. A resumed agent keeps its own
25
+ // message list here, which is what makes `resume` work.
26
+ //
27
+ // Bounded: a 128-item swarm made 128 entries holding FULL transcripts, kept for
28
+ // the process lifetime. The oldest entry is evicted past the cap — resuming a
29
+ // very old subagent then reports it as unknown instead of silently holding every
30
+ // transcript forever.
31
+ const SUBAGENTS_MAX = 64;
32
+ const subagents = new Map();
33
+ function rememberSubagent(id, entry) {
34
+ subagents.delete(id); // re-insertion moves the entry to the newest slot
35
+ subagents.set(id, entry);
36
+ while (subagents.size > SUBAGENTS_MAX) {
37
+ subagents.delete(subagents.keys().next().value);
38
+ }
39
+ }
40
+ let agentSeq = 0;
41
+
42
+
43
+ export function newAgentId() {
44
+ agentSeq = (agentSeq + 1) % 1e6;
45
+ return `agent-${Date.now().toString(36)}${String(agentSeq).padStart(3, '0')}`;
46
+ }
47
+
48
+ export function getSubagent(id) {
49
+ return subagents.get(id) || null;
50
+ }
51
+
52
+ export function listSubagents() {
53
+ return [...subagents.values()];
54
+ }
55
+
56
+ // Build the system prompt for a subagent: its profile prompt plus the shared
57
+ // rules, so a subagent behaves like the main agent within its remit.
58
+ function systemPromptFor(type, cfg, systemPrompt) {
59
+ const profile = SUBAGENT_TYPES[type] || SUBAGENT_TYPES[DEFAULT_SUBAGENT_TYPE];
60
+ const base = (cfg.systemPrompt && String(cfg.systemPrompt).trim()) || systemPrompt;
61
+ return `${profile.prompt}\n\n${base}`;
62
+ }
63
+
64
+ // Run a subagent to completion. `history` seeds its messages (fork). Returns
65
+ // { text, usage } or { error }.
66
+ // `onProgress(text)` receives the subagent's live output (its reasoning and answer
67
+ // stream, and each tool it runs) so the TUI can show it under the `Using Agent`
68
+ // row the way Bash's output is shown. `agentId` labels the lines when several
69
+ // subagents run at once (AgentSwarm), so the reader can tell them apart.
70
+ async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProgress, onStep, agentId, onAgent }) {
71
+ // The parent-signal listener (registered below, removed when the run settles).
72
+ let onSubagentAbort = null;
73
+ const { Agent, SYSTEM_PROMPT } = await import('../agent.js');
74
+ const subCfg = {
75
+ ...cfg,
76
+ systemPrompt: systemPromptFor(type, cfg, SYSTEM_PROMPT),
77
+ // The profile narrows the toolbelt (explore / plan / researcher are read-only)
78
+ // and the delegation tools are removed unconditionally — a subagent must not
79
+ // start another agent. `noDelegation` is the hard backstop in agent.js for the
80
+ // case where the model emits a call for a tool it no longer has.
81
+ toolFilter: toolsForSubagent(type, cfg.toolFilter),
82
+ noDelegation: true,
83
+ };
84
+ // fork: the snapshot comes first, then the new instruction.
85
+ const messages = Array.isArray(history) && history.length
86
+ ? [...history, { role: 'user', content: prompt }]
87
+ : [{ role: 'user', content: prompt }];
88
+
89
+ let text = '';
90
+ // A failure used to leave `text` empty, so a caller could not tell "produced nothing" from
91
+ // "died". The Agent's only signal on error is an event, so capture it here.
92
+ let failure = null;
93
+ const agent = new Agent({
94
+ cfg: subCfg,
95
+ messages,
96
+ onEvent: (e) => {
97
+ if (e.type === 'data') text += e.text;
98
+ // Mirror what the subagent is actually doing into the parent's transcript:
99
+ // its reasoning and answer stream as `data`/`think`, and every tool it runs
100
+ // arrives as tool_start/tool_result. Without this the `Using Agent …` row sat
101
+ // silent for the whole run — the subagent looked hung.
102
+ if (onProgress) {
103
+ const tag = agentId ? `[${agentId}] ` : '';
104
+ if (e.type === 'data' || e.type === 'think') {
105
+ if (e.text) onProgress(tag + e.text);
106
+ } else if (e.type === 'tool_start') {
107
+ onProgress(`\n${tag}\u25b8 ${e.name}\n`);
108
+ // One more round of work done. A SUBAGENT has no fixed step budget (agent.js sets
109
+ // `stepLimit = Infinity` unless the caller bounds it), so "steps remaining" is not
110
+ // a real quantity and a percentage of it would be invented. Tool calls completed is
111
+ // the honest measure: it only ever grows, it is what the run is actually doing, and
112
+ // the swarm turns it into a bar against the pace its other agents have set.
113
+ if (typeof onStep === 'function') onStep();
114
+ } else if (e.type === 'tool_result') {
115
+ const body = String(e.content == null ? '' : e.content);
116
+ const first = body.split('\n').slice(0, 3).join('\n');
117
+ onProgress(`${tag}${first}${body.split('\n').length > 3 ? '\n\u2026' : ''}\n`);
118
+ } else if (e.type === 'error') {
119
+ onProgress(`\n${tag}[error] ${(e.error && e.error.message) || String(e.error)}\n`);
120
+ failure = (e.error && e.error.message) || String(e.error);
121
+ }
122
+ }
123
+ if (onEvent) onEvent(e);
124
+ },
125
+ // A subagent never prompts: it inherits the parent's permission mode by
126
+ // running under the same ctx (kimi does the same — approvals belong to the
127
+ // parent turn, not the child).
128
+ onApproval: async () => true,
129
+ });
130
+ if (signal) {
131
+ if (signal.aborted) throw new Error('Interrupted by user');
132
+ onSubagentAbort = () => agent.interrupt();
133
+ signal.addEventListener('abort', onSubagentAbort, { once: true });
134
+ }
135
+ // Hand the LIVE agent back to the caller so a control tool can steer or interrupt it
136
+ // while it runs. `subagents` only learns about this run when it finishes, so without
137
+ // this the only way to reach a running subagent would be to interrupt the whole turn.
138
+ if (typeof onAgent === 'function') onAgent(agent);
139
+ // The abort listener is removed when the run settles. `{once:true}` only makes it
140
+ // fire once — it stays REGISTERED until then, so every completed subagent left
141
+ // one closure (holding the whole subagent) pinned on the parent's signal.
142
+ try {
143
+ await agent.run();
144
+ } finally {
145
+ if (onSubagentAbort) signal.removeEventListener('abort', onSubagentAbort);
146
+ }
147
+ // SHELL HOOK: SubagentStop. The subagent finished (or was interrupted); the
148
+ // parent-side hook can log/archive it. Never blocks.
149
+ // `hookConfig` may be the raw `{event:[…]}` map or a `{hooks:{…}}` wrapper
150
+ // (agent.js normalises it to the wrapper) — accept either.
151
+ const hc = subCfg.hookConfig;
152
+ const hookCfg = hc ? (hc.hooks ? hc : { hooks: hc }) : null;
153
+ if (hookCfg) {
154
+ await runShellHooks(hookCfg, 'SubagentStop',
155
+ { agentId: agentId || '', subagentType: type, prompt }, cfg.workspace);
156
+ }
157
+ // The parent must receive ONLY what the subagent concluded — never its tool
158
+ // chatter, and never its reasoning. Two things could leak in:
159
+ // * the aggregate `data` stream, which also contains intermediate narration;
160
+ // * an inline `<think>…</think>` block, when a model puts reasoning in the
161
+ // content stream instead of the separate reasoning channel.
162
+ // So: take the LAST assistant message (the handoff), and strip any think block
163
+ // from it. Fall back to the filtered stream text when there is no message.
164
+ // Strip whichever spelling the model used. `\b` alone is wrong here: `k` and `i`
165
+ // are both word characters, so `<think\b` does NOT match `<thinking>` — a model
166
+ // using the long form had its whole reasoning block leak into the handoff. Enumerate
167
+ // the variants instead, matching llm.js's tag table.
168
+ const stripThink = (s) => String(s)
169
+ .replace(/<(?:think|thinking)\b[^>]*>[\s\S]*?<\/(?:think|thinking)>/gi, '')
170
+ .replace(/<think(?:ing)?\b[^>]*>[\s\S]*$/i, '') // an unclosed block: drop to the end
171
+ .trim();
172
+ const lastAssistant = [...agent.messages].reverse()
173
+ .find((m) => m.role === 'assistant' && typeof m.content === 'string' && stripThink(m.content));
174
+ const result = (lastAssistant && stripThink(lastAssistant.content)) || stripThink(text) || '(no output)';
175
+ // The failure travels WITH the text. A run that died and a run that simply produced nothing
176
+ // both used to arrive as an empty/`(no output)` string, so the caller could not tell them apart
177
+ // and reported a dead agent as a successful empty answer.
178
+ return { text: result, messages: agent.messages, failed: failure };
179
+ }
180
+
181
+ export const spec = {
182
+ name: 'Agent',
183
+ description: `Launch a subagent. It runs with its own context, so you get a conclusion back instead of the file contents it read on the way.
184
+
185
+ Spawn one only when the user (or AGENTS.md) explicitly asked for delegation, sub-agents, or parallel agent work. A request for depth, thoroughness, research or a detailed analysis is NOT permission — do the work yourself by default.
186
+
187
+ Write the prompt for a colleague who has not seen this conversation: state the goal, hand over the exact path or command for a lookup, and give the QUESTION rather than prescribed steps for an investigation. Find the path or line number yourself first; do not delegate understanding.
188
+
189
+ Continuing earlier subagent work: pass its \`resume\` id instead of starting fresh. Its result is visible only to you — summarize the relevant parts in your own reply.
190
+
191
+ Types:
192
+ ${availableTypes()}`,
193
+ parameters: {
194
+ type: 'object',
195
+ properties: {
196
+ prompt: { type: 'string', description: 'Full task prompt for the subagent.' },
197
+ description: { type: 'string', description: 'Short task description (3-5 words) for UI display.' },
198
+ subagent_type: { type: 'string', description: `One of the available agent types. Defaults to "${DEFAULT_SUBAGENT_TYPE}" when omitted.` },
199
+ resume: { type: 'string', description: 'Optional agent id to resume instead of creating a new instance. Do not also pass subagent_type.' },
200
+ run_in_background: { type: 'boolean', default: false, description: 'If true, return immediately with a task id instead of waiting. The result arrives in the conversation later.' },
201
+ fork: { type: 'boolean', default: false, description: "Start the subagent from a snapshot of this agent's completed conversation history instead of zero context." },
202
+ model: { type: 'string', description: 'A configured model key to run the subagent on, or "primary" for the current model. Ignored when resuming.' },
203
+ },
204
+ required: ['prompt', 'description'],
205
+ },
206
+
207
+ async execute(args, ctx) {
208
+ const prompt = String(args.prompt || '').trim();
209
+ const description = String(args.description || '').trim();
210
+ if (!prompt) return 'Error: `prompt` is required.';
211
+ if (!description) return 'Error: `description` is required (short label for the task list).';
212
+
213
+ const resumeId = args.resume ? String(args.resume).trim() : '';
214
+ const wantFork = args.fork === true;
215
+ if (resumeId && args.subagent_type) {
216
+ return 'Error: cannot set subagent_type when resuming an existing agent. Resume by agent id only.';
217
+ }
218
+ if (resumeId && wantFork) {
219
+ return 'Error: `fork` cannot be combined with `resume`.';
220
+ }
221
+
222
+ // ---- resume an existing subagent -------------------------------------
223
+ let existing = null;
224
+ if (resumeId) {
225
+ existing = subagents.get(resumeId);
226
+ if (!existing) return `Error: no such subagent: ${resumeId}`;
227
+ }
228
+
229
+ const type = existing
230
+ ? existing.type
231
+ : (SUBAGENT_TYPES[args.subagent_type] ? args.subagent_type : DEFAULT_SUBAGENT_TYPE);
232
+
233
+ // ---- model override ---------------------------------------------------
234
+ // Precedence: the call's own `model` > the user's configured subagent model
235
+ // (/subagent-model) > the session's model. 'primary' always means "this
236
+ // session's model", so an explicit primary beats a configured default.
237
+ let subCfg = ctx;
238
+ const wantPrimary = args.model === 'primary';
239
+ const explicit = args.model && !wantPrimary && ctx.raw && ctx.raw.models && ctx.raw.models[args.model]
240
+ ? args.model : '';
241
+ const configured = ctx.subagentModel && ctx.raw && ctx.raw.models && ctx.raw.models[ctx.subagentModel]
242
+ ? ctx.subagentModel : '';
243
+ // A NAME THAT DOES NOT EXIST IS AN ERROR, not a fallback. Resolving it to "" ran the
244
+ // subagent on the session's model while the caller believed it had chosen — a typo, or a
245
+ // model configured in another session, produced a silently different agent.
246
+ if (args.model && !wantPrimary && !explicit) {
247
+ const known = Object.keys((ctx.raw && ctx.raw.models) || {});
248
+ const list = known.length ? known.join(', ') : 'none';
249
+ return `Error: unknown model "${args.model}". Configured: ${list}. Use "primary" for the session model.`;
250
+ }
251
+
252
+ // The user's `/subagent-model` setting can go stale when a model is renamed or removed, and
253
+ // it used to fail the same silent way. Say so, through onOutput so it reaches the transcript.
254
+ if (ctx.subagentModel && !configured) {
255
+ const list = Object.keys((ctx.raw && ctx.raw.models) || {}).join(', ') || 'none';
256
+ try {
257
+ if (typeof ctx.onOutput === 'function') {
258
+ ctx.onOutput(`[subagent model] "${ctx.subagentModel}" is no longer configured, so this subagent runs on the session model. Re-pick it with /swarm-sub-agent. Available: ${list}\n`);
259
+ }
260
+ } catch { /* a view that cannot be written to is not a reason to fail the call */ }
261
+ }
262
+
263
+ const chosen = wantPrimary ? '' : (explicit || configured);
264
+ if (chosen) {
265
+ const { resolveModelArg } = await import('../config.js');
266
+ subCfg = resolveModelArg(ctx, chosen);
267
+ }
268
+
269
+ // ---- fork: snapshot this conversation --------------------------------
270
+ let history = null;
271
+ if (wantFork) {
272
+ history = (ctx._parentMessages || []).filter(
273
+ (m) => m.role !== 'system' && m.role !== 'tool' && typeof m.content === 'string' && m.content.trim(),
274
+ );
275
+ }
276
+
277
+ // ---- background -------------------------------------------------------
278
+ if (args.run_in_background === true) {
279
+ const controller = new AbortController();
280
+ const task = createTask(ctx, 'agent', {
281
+ description,
282
+ agentId: null,
283
+ subagentType: type,
284
+ model: args.model || '',
285
+ detached: true,
286
+ });
287
+ task._abort = () => controller.abort();
288
+ const agentId = existing ? existing.id : newAgentId();
289
+ task.agentId = agentId;
290
+
291
+ // Register the run so a control tool can reach it while it is in flight. The handle
292
+ // is created BEFORE the run so both `run_in_background` and the detached-foreground
293
+ // path behave the same, and cleared in `finally` so a settled run can never be
294
+ // listed as controllable.
295
+ let runId = '';
296
+ const early = { agent: null };
297
+
298
+ // Fire and forget: the task record carries the result when it lands.
299
+ void (async () => {
300
+ try {
301
+ const run = await runSubagent({
302
+ cfg: subCfg, type, prompt, history,
303
+ signal: controller.signal,
304
+ onAgent: (agent) => { early.agent = agent; },
305
+ // No live streaming into the task output: that should read as the
306
+ // subagent's CONCLUSION. `run.text` below is the cleaned final message.
307
+ });
308
+ const entry = existing || { id: agentId, type, messages: [] };
309
+ entry.messages = run.messages;
310
+ entry.type = type;
311
+ rememberSubagent(agentId, entry);
312
+ appendTaskOutput(task, `\n\n${run.text}`);
313
+ settleTask(task, 'completed');
314
+ if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
315
+ } catch (e) {
316
+ const stopped = controller.signal.aborted;
317
+ settleTask(task, stopped ? 'killed' : 'failed', { stopReason: stopped ? 'Stopped by user' : e.message });
318
+ appendTaskOutput(task, `\n[error: ${e.message}]`);
319
+ if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
320
+ } finally {
321
+ if (runId) endRun(runId);
322
+ }
323
+ })();
324
+ runId = registerRun({
325
+ agentId, taskId: task.taskId, type, description,
326
+ steer: (text) => { if (early.agent) early.agent.steer(text); },
327
+ interrupt: () => controller.abort(),
328
+ });
329
+
330
+ return `agent_id: ${agentId}\ntask_id: ${task.taskId}\nstatus: running\nnext_step: Continue your work; the result arrives in a later message. Use TaskOutput {task_id: "${task.taskId}"} for a non-blocking check or TaskStop to cancel.`;
331
+ }
332
+
333
+ // ---- foreground (the default; Ctrl+B detaches it) ---------------------
334
+ // Registered like Bash's foreground hook so the TUI's ctrl+b can move a long
335
+ // subagent into the background WITHOUT losing its work: the run keeps going,
336
+ // the task record gets the result when it lands, and this call resolves at
337
+ // once with the task id so the parent turn can continue.
338
+ const bgController = new AbortController();
339
+ const onParentAbort = () => bgController.abort();
340
+ if (ctx.signal) {
341
+ if (ctx.signal.aborted) return 'Error: the turn was interrupted before the subagent started.';
342
+ ctx.signal.addEventListener('abort', onParentAbort, { once: true });
343
+ }
344
+ const agentId = existing ? existing.id : newAgentId();
345
+ const entry = existing || { id: agentId, type, messages: [] };
346
+ rememberSubagent(agentId, entry);
347
+
348
+ let detachedId = null;
349
+ const resetForeground = () => { if (ctx && ctx._foreground && ctx._foreground.detach) ctx._foreground = null; };
350
+ // Start the run BEFORE the detach hook is installed, so the hook's closure can
351
+ // reference the in-flight promise (it waits on it to record the result).
352
+ // `onProgress` feeds the parent's live-output sink (ctx.onOutput), which the
353
+ // agent tags with THIS tool call's id — the same path Bash uses, so the TUI
354
+ // renders the subagent's stream under the `Using Agent` row and Ctrl+O folds it.
355
+ // The live Agent is captured as soon as `runSubagent` builds it, which is what makes
356
+ // this run controllable from another tool call (or the TUI) without waiting for it to
357
+ // finish. `live` is a box because the handle arrives asynchronously.
358
+ const live = { agent: null };
359
+ const runPromise = runSubagent({
360
+ cfg: subCfg, type, prompt, history, signal: bgController.signal,
361
+ onAgent: (agent) => { live.agent = agent; },
362
+ // `_progressTag` is set by AgentSwarm to the item index, so a swarm's
363
+ // concurrently-running subagents are distinguishable in the live stream.
364
+ // A plain Agent call has no tag and prints its stream unlabelled.
365
+ //
366
+ // `_subTaskId` is also set by AgentSwarm: it names the task THIS subagent owns, so its
367
+ // stream lands on its own row in /tasks instead of being folded into the swarm's single
368
+ // buffer. Without it a 7-item swarm had one task and no way to see what any of them was
369
+ // doing.
370
+ onProgress: (text) => {
371
+ if (ctx._subTaskId) {
372
+ const own = ctx.tasks && ctx.tasks[ctx._subTaskId];
373
+ if (own) appendTaskOutput(own, text);
374
+ }
375
+ if (typeof ctx.onOutput !== 'function') return;
376
+ const tag = ctx._progressTag ? `[${ctx._progressTag}] ` : '';
377
+ ctx.onOutput(tag ? text.split('\n').map((l) => (l ? tag + l : l)).join('\n') : text);
378
+ },
379
+ // Each completed tool call is one unit of progress. Only AgentSwarm supplies the sink;
380
+ // a lone Agent call has no grid to fill and nothing to report to.
381
+ onStep: () => { if (typeof ctx._onSubProgress === 'function') ctx._onSubProgress(); },
382
+ });
383
+ // Registered here so a foreground subagent is reachable by AgentMessage / AgentInterrupt
384
+ // too — that is precisely the case where a user wants to redirect one: it is the run
385
+ // they are watching. Cleared when the run settles, however it settles.
386
+ let runId = registerRun({
387
+ agentId, type, description,
388
+ steer: (text) => { if (live.agent) live.agent.steer(text); },
389
+ interrupt: () => bgController.abort(),
390
+ });
391
+ void runPromise.catch(() => {}).finally(() => { if (runId) { endRun(runId); runId = ''; } });
392
+ if (ctx) {
393
+ ctx._foreground = {
394
+ kind: 'Agent',
395
+ description,
396
+ detach: () => {
397
+ if (detachedId) return detachedId;
398
+ // Hand the in-flight work to a background task. The controller is NOT
399
+ // aborted, so the subagent keeps running.
400
+ const task = createTask(ctx, 'agent', {
401
+ description, agentId, subagentType: type, model: args.model || '', detached: true,
402
+ });
403
+ task._abort = () => bgController.abort();
404
+ detachedId = task.taskId;
405
+ resetForeground();
406
+ void (async () => {
407
+ try {
408
+ const r = await runPromise;
409
+ entry.messages = r.messages; entry.type = type;
410
+ rememberSubagent(agentId, entry);
411
+ appendTaskOutput(task, r.text);
412
+ settleTask(task, 'completed');
413
+ if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
414
+ } catch (e) {
415
+ const stopped = bgController.signal.aborted;
416
+ settleTask(task, stopped ? 'killed' : 'failed', { stopReason: stopped ? 'Stopped by user' : e.message });
417
+ appendTaskOutput(task, `\n[error: ${e.message}]`);
418
+ if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
419
+ } finally {
420
+ // The detached run kept the parent-abort link alive on purpose (an
421
+ // aborted turn kills it); once it settles the link goes too.
422
+ if (ctx.signal) ctx.signal.removeEventListener('abort', onParentAbort);
423
+ }
424
+ })();
425
+ return task.taskId;
426
+ },
427
+ };
428
+ }
429
+ let run;
430
+ try {
431
+ run = await runPromise;
432
+ } catch (e) {
433
+ resetForeground();
434
+ if (ctx.signal) ctx.signal.removeEventListener('abort', onParentAbort);
435
+ // Detached: the background task owns the error now, not this call.
436
+ if (detachedId) return `Moved to background as task #${detachedId}. It keeps running; inspect with TaskList / TaskOutput {task_id: "${detachedId}"}.`;
437
+ return `Error: subagent failed: ${e.message}`;
438
+ }
439
+ if (ctx.signal) ctx.signal.removeEventListener('abort', onParentAbort);
440
+ resetForeground();
441
+ if (detachedId) {
442
+ return `Moved to background as task #${detachedId}. It keeps running; inspect with TaskList / TaskOutput {task_id: "${detachedId}"}.`;
443
+ }
444
+ entry.messages = run.messages;
445
+ entry.type = type;
446
+ rememberSubagent(agentId, entry);
447
+
448
+ // A FAILED subagent returns an error, not an empty string. `run.text` is '' when the Agent
449
+ // died, which the swarm printed as "(no output)" — so four agents that had all failed on one
450
+ // error looked like four agents that had found nothing to say.
451
+ if (run.failed) return `Error: subagent "${agentId}" failed: ${run.failed}`;
452
+
453
+ return `agent_id: ${agentId}\n\n${run.text}`;
454
+ },
465
455
  };