@kolisachint/hoocode-agent 0.4.110 → 0.4.112

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 (108) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/dist/cli/args.d.ts +1 -0
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +8 -3
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/core/extensions/loader.d.ts +10 -1
  7. package/dist/core/extensions/loader.d.ts.map +1 -1
  8. package/dist/core/extensions/loader.js +17 -2
  9. package/dist/core/extensions/loader.js.map +1 -1
  10. package/dist/core/extensions/plugins/marketplace.d.ts +36 -5
  11. package/dist/core/extensions/plugins/marketplace.d.ts.map +1 -1
  12. package/dist/core/extensions/plugins/marketplace.js +63 -17
  13. package/dist/core/extensions/plugins/marketplace.js.map +1 -1
  14. package/dist/core/format-duration.d.ts +7 -0
  15. package/dist/core/format-duration.d.ts.map +1 -0
  16. package/dist/core/format-duration.js +16 -0
  17. package/dist/core/format-duration.js.map +1 -0
  18. package/dist/core/keybindings.d.ts +10 -0
  19. package/dist/core/keybindings.d.ts.map +1 -1
  20. package/dist/core/keybindings.js +10 -0
  21. package/dist/core/keybindings.js.map +1 -1
  22. package/dist/core/lifeguard.d.ts +15 -0
  23. package/dist/core/lifeguard.d.ts.map +1 -1
  24. package/dist/core/lifeguard.js +48 -18
  25. package/dist/core/lifeguard.js.map +1 -1
  26. package/dist/core/scheduler.d.ts +8 -2
  27. package/dist/core/scheduler.d.ts.map +1 -1
  28. package/dist/core/scheduler.js +9 -3
  29. package/dist/core/scheduler.js.map +1 -1
  30. package/dist/core/settings-defaults.d.ts +1 -1
  31. package/dist/core/settings-defaults.d.ts.map +1 -1
  32. package/dist/core/settings-defaults.js +2 -2
  33. package/dist/core/settings-defaults.js.map +1 -1
  34. package/dist/core/settings-types.d.ts.map +1 -1
  35. package/dist/core/settings-types.js.map +1 -1
  36. package/dist/core/subagent-inbox.d.ts +1 -1
  37. package/dist/core/subagent-inbox.d.ts.map +1 -1
  38. package/dist/core/subagent-inbox.js +2 -0
  39. package/dist/core/subagent-inbox.js.map +1 -1
  40. package/dist/core/subagent-pool-instance.d.ts.map +1 -1
  41. package/dist/core/subagent-pool-instance.js +18 -11
  42. package/dist/core/subagent-pool-instance.js.map +1 -1
  43. package/dist/core/subagent-pool.d.ts +25 -3
  44. package/dist/core/subagent-pool.d.ts.map +1 -1
  45. package/dist/core/subagent-pool.js +120 -25
  46. package/dist/core/subagent-pool.js.map +1 -1
  47. package/dist/core/subagent-result.d.ts.map +1 -1
  48. package/dist/core/subagent-result.js +6 -4
  49. package/dist/core/subagent-result.js.map +1 -1
  50. package/dist/core/task-store.d.ts +45 -3
  51. package/dist/core/task-store.d.ts.map +1 -1
  52. package/dist/core/task-store.js +51 -5
  53. package/dist/core/task-store.js.map +1 -1
  54. package/dist/core/team-view.d.ts.map +1 -1
  55. package/dist/core/team-view.js +2 -0
  56. package/dist/core/team-view.js.map +1 -1
  57. package/dist/core/token-budget.d.ts +13 -1
  58. package/dist/core/token-budget.d.ts.map +1 -1
  59. package/dist/core/token-budget.js +18 -1
  60. package/dist/core/token-budget.js.map +1 -1
  61. package/dist/core/tools/subagent.d.ts +16 -4
  62. package/dist/core/tools/subagent.d.ts.map +1 -1
  63. package/dist/core/tools/subagent.js +211 -67
  64. package/dist/core/tools/subagent.js.map +1 -1
  65. package/dist/core/tools/todo.d.ts.map +1 -1
  66. package/dist/core/tools/todo.js +39 -7
  67. package/dist/core/tools/todo.js.map +1 -1
  68. package/dist/extensions/core/loop.d.ts.map +1 -1
  69. package/dist/extensions/core/loop.js +4 -1
  70. package/dist/extensions/core/loop.js.map +1 -1
  71. package/dist/extensions/core/marketplace.d.ts +5 -3
  72. package/dist/extensions/core/marketplace.d.ts.map +1 -1
  73. package/dist/extensions/core/marketplace.js +37 -15
  74. package/dist/extensions/core/marketplace.js.map +1 -1
  75. package/dist/modes/interactive/components/keybinding-hints.d.ts +10 -0
  76. package/dist/modes/interactive/components/keybinding-hints.d.ts.map +1 -1
  77. package/dist/modes/interactive/components/keybinding-hints.js +27 -1
  78. package/dist/modes/interactive/components/keybinding-hints.js.map +1 -1
  79. package/dist/modes/interactive/components/task-panel.d.ts +22 -1
  80. package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
  81. package/dist/modes/interactive/components/task-panel.js +302 -80
  82. package/dist/modes/interactive/components/task-panel.js.map +1 -1
  83. package/dist/modes/interactive/components/team-attach-panel.d.ts.map +1 -1
  84. package/dist/modes/interactive/components/team-attach-panel.js +15 -5
  85. package/dist/modes/interactive/components/team-attach-panel.js.map +1 -1
  86. package/dist/modes/interactive/theme/dark.json +8 -1
  87. package/dist/modes/interactive/theme/light.json +8 -1
  88. package/dist/modes/interactive/theme/theme-schema.json +28 -1
  89. package/dist/modes/interactive/theme/theme.d.ts +12 -1
  90. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  91. package/dist/modes/interactive/theme/theme.js +52 -0
  92. package/dist/modes/interactive/theme/theme.js.map +1 -1
  93. package/dist/modes/interactive/voice/voice-panel.d.ts.map +1 -1
  94. package/dist/modes/interactive/voice/voice-panel.js +5 -2
  95. package/dist/modes/interactive/voice/voice-panel.js.map +1 -1
  96. package/dist/modes/rpc/jsonl.d.ts +16 -1
  97. package/dist/modes/rpc/jsonl.d.ts.map +1 -1
  98. package/dist/modes/rpc/jsonl.js +53 -8
  99. package/dist/modes/rpc/jsonl.js.map +1 -1
  100. package/dist/utils/atomic-file.d.ts +13 -0
  101. package/dist/utils/atomic-file.d.ts.map +1 -0
  102. package/dist/utils/atomic-file.js +26 -0
  103. package/dist/utils/atomic-file.js.map +1 -0
  104. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  105. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  106. package/examples/extensions/sandbox/package.json +1 -1
  107. package/examples/extensions/with-deps/package.json +1 -1
  108. package/package.json +4 -4
@@ -7,14 +7,19 @@
7
7
  * fresh, isolated child process (SubagentPool) and only its final answer is
8
8
  * returned to the parent.
9
9
  *
10
- * It is an optional, opt-in tool (enabled via --enable-subagents or the
11
- * `enableSubagent` setting); see buildSessionOptions in main.ts.
10
+ * Enabled by default (the `enableSubagent` setting defaults to true); disable
11
+ * with `enableSubagent: false`. The `--enable-subagents` flag still force-enables
12
+ * it. Nesting is bounded by the tree-wide depth cap (maxSubagentDepth, default 2:
13
+ * a spawned subagent may itself delegate one more level, and depth-2 grandchildren
14
+ * cannot). See buildSessionOptions in main.ts.
12
15
  */
13
16
  import { Text } from "@kolisachint/hoocode-tui";
14
17
  import { Type } from "typebox";
18
+ import { agentColorFor } from "../../modes/interactive/theme/theme.js";
15
19
  import { TASK_TOOL_NAME } from "../agent-frontmatter.js";
16
20
  import { loadAgentRegistry } from "../agent-registry.js";
17
21
  import { defineTool } from "../extensions/types.js";
22
+ import { formatDurationSecs } from "../format-duration.js";
18
23
  import { getProviderExhaustion } from "../provider-health.js";
19
24
  import { SessionManager } from "../session-manager.js";
20
25
  import { delegateAllowList, isDelegateAllowed } from "../subagent-depth.js";
@@ -33,7 +38,21 @@ export { summarizeAgentDescription } from "../agent-registry.js";
33
38
  * `<available_agents>` block the system prompt emits whenever the Task tool is
34
39
  * active (see agent-session `_rebuildSystemPrompt`); this appendix references
35
40
  * that list rather than re-rendering the roster and paying for it twice. */
36
- export function buildTaskMainPrompt() {
41
+ /**
42
+ * Build the main-session subagent instructions appended to the system prompt.
43
+ *
44
+ * The detailed background/barrier guidance (the three heaviest bullets) is only
45
+ * emitted when the project actually has background-capable agents; otherwise a
46
+ * single concise line covers the per-call `background: true` escape hatch. This
47
+ * keeps the always-on cost down for the common case where nothing runs in the
48
+ * background. `cwd` is used only to detect those agents.
49
+ */
50
+ export function buildTaskMainPrompt(cwd = process.cwd()) {
51
+ const hasBackgroundAgents = collectBackgroundAgentNames(cwd).size > 0;
52
+ const backgroundGuidance = hasBackgroundAgents
53
+ ? `- Background agents run non-blocking (or force per call with \`background: true\`): you get a short "explore#1 finished" notification and pull the full result with \`TaskOutput\` (e.g. \`TaskOutput("explore#1")\`); \`TaskOutput(list: true)\` shows what's running.
54
+ - After dispatching, don't idle — keep doing independent work (read/edit unrelated files, draft, dispatch more); barrier with \`TaskOutput(wait: true)\` (a named task, or all outstanding when no id) only when you genuinely can't proceed.`
55
+ : `- You can force any task to run in the background with \`background: true\` (non-blocking): keep working, then pull its result with \`TaskOutput\` (e.g. \`TaskOutput("explore#1")\`) or block on it with \`TaskOutput(wait: true)\`.`;
37
56
  return `You have access to the **Task** tool. Use it to delegate self-contained tasks to specialized subagents that run in their own isolated context and return only their final answer. Pick an agent by name from the <available_agents> list in this prompt and pass it as \`subagent_type\`.
38
57
 
39
58
  When to delegate:
@@ -50,9 +69,8 @@ Guidelines:
50
69
  - Do NOT delegate tasks that require tight back-and-forth with your current reasoning, or edits to files you are actively reasoning about.
51
70
  - The subagent returns ONLY its final answer. Its intermediate reasoning, tool calls, and output are hidden from you.
52
71
  - Delegate proactively when work is self-contained or parallelizable: multi-step investigation, read-only exploration (use \`explore\`), research before changes (use \`plan\`), drafting a standalone file/section, or running a long command/test suite. Dispatch independent subtasks in the same turn. Handle only trivial single-step edits or tightly interactive back-and-forth inline.
53
- - Some agents run in the background (non-blocking); force it per call with \`background: true\` (or \`background: false\` to wait inline). A background Task does not block your turn and does not return its answer inline: you get a short notification ("explore#1 finished") and the full result is held for you to pull with \`TaskOutput\`. Keep working in the meantime.
54
- - After dispatching background work, DO NOT stop and wait — that wastes the parallelism and looks stuck. Immediately continue with the next useful thing: read or edit an independent file, draft the parts of your answer that don't depend on the pending result, or dispatch more independent subtasks. Only barrier (with \`TaskOutput(wait: true)\`) when you genuinely cannot proceed without the result. Prefer background dispatch for any self-contained or parallelizable work so your turn never blocks on a subagent.
55
- - Use **TaskOutput** to manage background subagents: \`TaskOutput(list: true)\` shows every running/finished subagent and what each is doing; \`TaskOutput("explore#1")\` reads a finished subagent's full result (or reports its status if still running); \`TaskOutput(wait: true)\` blocks until a named task — or, with no task_id, ALL outstanding subagents — finish. Dispatch a batch in one turn, then barrier on them with \`TaskOutput(wait: true)\` only once you've exhausted the work you can do without them.
72
+ ${backgroundGuidance}
73
+ - When working through a TodoWrite plan, mark the plan item in_progress BEFORE dispatching subagents for it: each dispatch is attributed to the current in_progress item in the user's task panel, so dispatching first (or with several items in_progress) leaves the run unattributed.
56
74
  - To continue a previous subagent (for example one that returned partial results), call Task again with \`resume_task_id\` set to its task_id; it resumes with its full prior transcript and \`prompt\` is your follow-up.`;
57
75
  }
58
76
  const taskParams = Type.Object({
@@ -106,18 +124,37 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
106
124
  return override;
107
125
  return backgroundAgents.has(String(toolCall.arguments?.subagent_type ?? ""));
108
126
  },
109
- // Kept lean: the available agents are listed in the system prompt, and the
110
- // `complexity`/`background` semantics live in their parameter descriptions
111
- // repeating them here would re-spend those tokens on every turn.
112
- description: [
113
- "Delegate a focused task to a specialized subagent that runs in a fresh, isolated context (it cannot see this conversation). Choose one of the available agents (listed in the system prompt) via `subagent_type` and pass everything it needs via `prompt`; the subagent returns only its final answer.",
114
- "WHEN TO USE: (1) self-contained work where you only need the final result; (2) parallel investigation/edits without losing your reasoning chain; (3) a discrete unit (explore one module, run one test file, review one PR, fix one isolated bug, write docs); (4) a long command or test suite you want to run without blocking your reasoning.",
115
- "Do NOT use for tasks needing tight back-and-forth with your current reasoning, or edits to files you are actively reasoning about. Delegate proactively for self-contained or parallelizable work; handle only trivial single-step or tightly interactive work inline.",
116
- ].join("\n"),
127
+ // Kept to mechanics only: the when-to-use / when-not-to guidance lives once in
128
+ // the system-prompt block (buildTaskMainPrompt), the available agents are
129
+ // listed there too, and the `complexity`/`background`/`resume_task_id`
130
+ // semantics live in their parameter descriptions. Repeating any of that here
131
+ // would re-spend those tokens on every turn.
132
+ description: "Delegate a focused task to a specialized subagent that runs in a fresh, isolated context (it cannot see this conversation). Choose one of the available agents (listed in the system prompt) via `subagent_type` and pass everything it needs via `prompt`; the subagent returns only its final answer.",
117
133
  promptSnippet: "delegate a self-contained task to a specialized subagent (choose via subagent_type)",
118
134
  parameters: taskParams,
119
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
135
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
120
136
  const pool = getSubagentPool(ctx.cwd);
137
+ // User-initiated cancel (Esc/abort): kill the dispatched run's whole
138
+ // process tree and let the dispatch settle with status "cancelled" —
139
+ // distinct from "failed" everywhere it surfaces (panel, inbox, result).
140
+ // Cold dispatches only: a warm worker has no cancel surface (it is
141
+ // reused), so warm runs still complete server-side and are discarded.
142
+ const cancelOnAbort = async (poolRunId, run) => {
143
+ if (!signal)
144
+ return run();
145
+ const onAbort = () => {
146
+ pool.cancel?.(poolRunId);
147
+ };
148
+ if (signal.aborted)
149
+ onAbort();
150
+ signal.addEventListener("abort", onAbort, { once: true });
151
+ try {
152
+ return await run();
153
+ }
154
+ finally {
155
+ signal.removeEventListener("abort", onAbort);
156
+ }
157
+ };
121
158
  // Pre-flight: if the inherited provider recently exhausted its quota (the
122
159
  // parent's own turn failed with a usage/rate-limit error that did not
123
160
  // recover), skip the spawn. Subagents run on the same provider, so this
@@ -126,12 +163,16 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
126
163
  const provider = ctx.model?.provider;
127
164
  const exhaustion = provider ? getProviderExhaustion(provider) : undefined;
128
165
  if (exhaustion) {
166
+ const skippedRunId = newDispatchTaskId();
129
167
  const skipped = taskStore.create(params.description?.trim() || summarize(params.prompt), {
130
168
  source: "subagent",
131
169
  subagentMode: params.subagent_type,
132
- agent: params.subagent_type,
170
+ agent: skippedRunId,
171
+ linkedTaskId: linkedTodoId(),
133
172
  });
173
+ registerSubagentDispatch(skippedRunId, subagentInbox.nextLabel(params.subagent_type));
134
174
  taskStore.update(skipped.id, { status: "failed", note: `${provider} exhausted` });
175
+ taskStore.patchAgent(skippedRunId, { state: "failed" });
135
176
  return {
136
177
  content: [
137
178
  {
@@ -156,23 +197,27 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
156
197
  const resumeId = params.resume_task_id?.trim();
157
198
  if (resumeId) {
158
199
  const summary = params.description?.trim() || summarize(params.prompt);
200
+ const runId = newDispatchTaskId();
201
+ const label = subagentInbox.nextLabel(params.subagent_type);
159
202
  const task = taskStore.create(summary, {
160
203
  source: "subagent",
161
204
  subagentMode: params.subagent_type,
162
- agent: params.subagent_type,
205
+ agent: runId,
206
+ linkedTaskId: linkedTodoId(),
163
207
  });
164
- registerSubagentDispatch(params.subagent_type);
208
+ registerSubagentDispatch(runId, label);
165
209
  taskStore.update(task.id, { status: "in_progress" });
166
210
  try {
167
- const dispatchResult = await pool.resume(resumeId, params.prompt, {
211
+ const dispatchResult = await cancelOnAbort(runId, () => pool.resume(resumeId, params.prompt, {
168
212
  model: ctx.model?.id,
169
213
  provider: ctx.model?.provider,
170
- });
214
+ taskId: runId,
215
+ }));
171
216
  // The session lives under the original task id; keep it as the resume handle.
172
- return finalizeDispatchResult(dispatchResult, params.subagent_type, task.id, resumeId);
217
+ return finalizeDispatchResult(dispatchResult, params.subagent_type, runId, task.id, resumeId);
173
218
  }
174
219
  catch (error) {
175
- taskStore.update(task.id, { status: "failed" });
220
+ markDispatchFailed(task.id, runId);
176
221
  throw error;
177
222
  }
178
223
  }
@@ -188,12 +233,23 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
188
233
  throw new Error(`Unknown subagent_type: "${params.subagent_type}". Available agents: ${available || "(none)"}.`);
189
234
  }
190
235
  const summary = params.description?.trim() || summarize(params.prompt);
236
+ // One roster row per dispatch, keyed by the (pre-allocated) pool task id.
237
+ // Keying by agent TYPE made concurrent same-type runs share a single row:
238
+ // their activity, state, and stats collided last-writer-wins. The inbox
239
+ // already keys by task id; this aligns the panel to the same model. The
240
+ // pool's task_progress events carry the task id, so patching by this id
241
+ // routes live activity to the right row.
242
+ const poolTaskId = newDispatchTaskId();
243
+ const label = subagentInbox.nextLabel(params.subagent_type);
191
244
  const task = taskStore.create(summary, {
192
245
  source: "subagent",
193
246
  subagentMode: params.subagent_type,
194
- agent: params.subagent_type,
247
+ agent: poolTaskId,
248
+ // Tie this run to the plan item it executes (the single in_progress
249
+ // TodoWrite task, when unambiguous) so the panel can nest it there.
250
+ linkedTaskId: linkedTodoId(),
195
251
  });
196
- registerSubagentDispatch(params.subagent_type);
252
+ registerSubagentDispatch(poolTaskId, label);
197
253
  taskStore.update(task.id, { status: "in_progress" });
198
254
  // Fork agents inherit the parent's conversation via a forked session.
199
255
  const forkSessionFile = def.fork
@@ -210,11 +266,9 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
210
266
  // recompute it here to choose the notify-and-pull return shape.
211
267
  const isBackground = params.background ?? backgroundAgents.has(params.subagent_type);
212
268
  if (isBackground) {
213
- // Notify-and-pull: register the dispatch in the inbox under a pre-allocated
214
- // id, await it, retain the body in the inbox, and return a compact
215
- // notification (not the body). The model pulls the body with TaskOutput.
216
- const poolTaskId = `dispatch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
217
- const label = subagentInbox.nextLabel(params.subagent_type);
269
+ // Notify-and-pull: register the dispatch in the inbox under the
270
+ // pre-allocated id, await it, retain the body in the inbox, and return a
271
+ // compact notification (not the body). The model pulls it with TaskOutput.
218
272
  subagentInbox.observe(pool);
219
273
  subagentInbox.start(poolTaskId, label, params.subagent_type);
220
274
  // Warm path (opt-in): run on a reused RPC worker, retain the body in the
@@ -229,10 +283,10 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
229
283
  cwd: ctx.cwd,
230
284
  model: dispatchModel,
231
285
  provider: ctx.model?.provider,
232
- }, (activity) => taskStore.patchAgent(params.subagent_type, { activity }));
286
+ }, (activity) => taskStore.patchAgent(poolTaskId, { activity }));
233
287
  const dispatchResult = warmResultToTaskResult(warmResult, params.subagent_type, task.id);
234
288
  subagentInbox.finish(poolTaskId, dispatchResult);
235
- return finalizeDispatchResult(dispatchResult, params.subagent_type, task.id, poolTaskId, {
289
+ return finalizeDispatchResult(dispatchResult, params.subagent_type, poolTaskId, task.id, poolTaskId, {
236
290
  taskId: poolTaskId,
237
291
  label,
238
292
  });
@@ -245,23 +299,23 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
245
299
  }
246
300
  }
247
301
  try {
248
- const dispatchResult = await pool.dispatch(params.prompt, {
302
+ const dispatchResult = await cancelOnAbort(poolTaskId, () => pool.dispatch(params.prompt, {
249
303
  forceAgent: params.subagent_type,
250
304
  context: "",
251
305
  model: dispatchModel,
252
306
  provider: ctx.model?.provider,
253
307
  sessionFile: forkSessionFile,
254
308
  taskId: poolTaskId,
255
- });
309
+ }));
256
310
  subagentInbox.finish(poolTaskId, dispatchResult);
257
- return finalizeDispatchResult(dispatchResult, params.subagent_type, task.id, poolTaskId, {
311
+ return finalizeDispatchResult(dispatchResult, params.subagent_type, poolTaskId, task.id, poolTaskId, {
258
312
  taskId: poolTaskId,
259
313
  label,
260
314
  });
261
315
  }
262
316
  catch (error) {
263
317
  const reason = error instanceof Error ? error.message : String(error);
264
- taskStore.update(task.id, { status: "failed" });
318
+ markDispatchFailed(task.id, poolTaskId);
265
319
  subagentInbox.fail(poolTaskId, reason);
266
320
  // A background dispatch reports failure as a compact notification, not a
267
321
  // thrown tool error — the call was already answered by a placeholder.
@@ -295,15 +349,16 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
295
349
  },
296
350
  // Mirror the cold pool's live progress on the task panel roster so a
297
351
  // warm dispatch reads as busy (⋯ grep), not stuck.
298
- (activity) => taskStore.patchAgent(params.subagent_type, { activity }));
352
+ (activity) => taskStore.patchAgent(poolTaskId, { activity }));
299
353
  const dispatchResult = warmResultToTaskResult(warmResult, params.subagent_type, task.id);
300
- return finalizeDispatchResult(dispatchResult, params.subagent_type, task.id, undefined);
354
+ return finalizeDispatchResult(dispatchResult, params.subagent_type, poolTaskId, task.id, undefined);
301
355
  }
302
356
  catch (error) {
303
357
  // A genuine infra failure (worker crash/timeout) retries cold; any other
304
358
  // error is a real dispatch failure and propagates.
305
359
  if (!(error instanceof WarmWorkerError)) {
306
360
  taskStore.update(task.id, { status: "failed" });
361
+ taskStore.patchAgent(poolTaskId, { state: "failed", activity: "" });
307
362
  throw error;
308
363
  }
309
364
  console.error(`[WARM] ${params.subagent_type} fell back to cold spawn: ${error.message}`);
@@ -312,23 +367,27 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
312
367
  }
313
368
  // Foreground: block the turn and return the subagent's full answer inline.
314
369
  try {
315
- const dispatchResult = await pool.dispatch(params.prompt, {
370
+ const dispatchResult = await cancelOnAbort(poolTaskId, () => pool.dispatch(params.prompt, {
316
371
  forceAgent: params.subagent_type,
317
372
  context: "",
318
373
  model: dispatchModel,
319
374
  provider: ctx.model?.provider,
320
375
  sessionFile: forkSessionFile,
321
- });
322
- return finalizeDispatchResult(dispatchResult, params.subagent_type, task.id, dispatchResult.task_id);
376
+ taskId: poolTaskId,
377
+ }));
378
+ return finalizeDispatchResult(dispatchResult, params.subagent_type, poolTaskId, task.id, dispatchResult.task_id);
323
379
  }
324
380
  catch (error) {
325
- taskStore.update(task.id, { status: "failed" });
381
+ markDispatchFailed(task.id, poolTaskId);
326
382
  throw error;
327
383
  }
328
384
  },
329
385
  renderCall(args, theme) {
330
386
  const type = args.subagent_type ?? "agent";
331
- const text = theme.fg("toolTitle", theme.bold("Agent ")) + theme.fg("accent", `[${type}]`);
387
+ // The [type] tag carries the agent's identity color — the same hue this
388
+ // agent has in the task panel and TaskOutput — so a transcript full of
389
+ // concurrent dispatches is scannable by color.
390
+ const text = theme.fg("toolTitle", theme.bold("Agent ")) + theme.fg(agentColorFor(type), `[${type}]`);
332
391
  return new Text(text, 0, 0);
333
392
  },
334
393
  });
@@ -350,13 +409,47 @@ export function resolveForkSessionFile(def, parentSessionPath, cwd) {
350
409
  return undefined;
351
410
  }
352
411
  }
412
+ /** Pre-allocated pool task id for a dispatch (matches the pool's own format). */
413
+ function newDispatchTaskId() {
414
+ return `dispatch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
415
+ }
416
+ /**
417
+ * The TodoWrite plan item this dispatch is (most plausibly) working on: the
418
+ * single in_progress main-agent task. TodoWrite discipline keeps exactly one
419
+ * item in_progress, so when that holds the link is unambiguous; with zero or
420
+ * several in_progress items no link is recorded rather than guessing. Used to
421
+ * nest the run under its plan item in the task panel's flat lens.
422
+ */
423
+ function linkedTodoId() {
424
+ const inProgress = taskStore
425
+ .list()
426
+ .filter((t) => t.source === undefined &&
427
+ t.agent === undefined &&
428
+ t.parentTaskId === undefined &&
429
+ t.status === "in_progress");
430
+ return inProgress.length === 1 ? inProgress[0]?.id : undefined;
431
+ }
353
432
  /**
354
- * Register the dispatched agent in the task store's roster so the task pane's
355
- * grouped views (subagents/teams) can draw a group header for it. Upsert keeps
356
- * accumulated stats across re-dispatches of the same agent type.
433
+ * Register this dispatch in the task store's roster, keyed by its run id (the
434
+ * pool task id) so concurrent runs of the same agent type each get their own
435
+ * row, state, activity, and stats. The friendly label ("explore#1") names the
436
+ * row the same way the inbox names background tasks.
357
437
  */
358
- function registerSubagentDispatch(type) {
359
- taskStore.upsertAgent({ id: type, name: type, role: "subagent", kind: "subagent", state: "running" });
438
+ function registerSubagentDispatch(runId, label) {
439
+ taskStore.upsertAgent({ id: runId, name: label, role: "subagent", kind: "subagent", state: "running" });
440
+ }
441
+ /**
442
+ * Catch-path bookkeeping: mark the dispatch failed unless it already settled.
443
+ * finalizeDispatchResult records a terminal status (failed/cancelled) *before*
444
+ * throwing, so a blanket "failed" here would clobber a cancellation with a
445
+ * failure the moment the thrown error passed through.
446
+ */
447
+ function markDispatchFailed(taskStoreId, runId) {
448
+ const current = taskStore.list().find((t) => t.id === taskStoreId);
449
+ if (current && current.status !== "pending" && current.status !== "in_progress")
450
+ return;
451
+ taskStore.update(taskStoreId, { status: "failed" });
452
+ taskStore.patchAgent(runId, { state: "failed", activity: "" });
360
453
  }
361
454
  /** Names of agents configured to run in the background (non-blocking). */
362
455
  function collectBackgroundAgentNames(cwd) {
@@ -426,26 +519,29 @@ function warmResultToTaskResult(warm, agentType, taskStoreId) {
426
519
  * inbox, so it returns a compact, self-contained notification (success or
427
520
  * failure) and never throws — the call was already answered by a placeholder.
428
521
  */
429
- function finalizeDispatchResult(dispatchResult, subagentType, taskStoreId, resumeHandle, background) {
522
+ function finalizeDispatchResult(dispatchResult, subagentType, runAgentId, taskStoreId, resumeHandle, background) {
430
523
  const result = dispatchResult.result;
431
524
  const resultData = result?.result_data;
432
525
  const usage = resultData?.usage;
433
526
  // Merge the child's own task subtree under the dispatching task so nested
434
527
  // delegation (depth >= 2) is visible in the subagents lens's task tree.
435
528
  mergeChildTaskTree(resultData?.task_tree, taskStoreId);
436
- // Roll the agent's per-run usage into its roster stats so the grouped views'
437
- // header carries the agent's own token/cost totals.
529
+ // Roll this run's usage into its own roster row (rows are per-dispatch).
438
530
  if (usage) {
439
- taskStore.addAgentStats(subagentType, { input: usage.input, output: usage.output, cost: usage.cost });
531
+ taskStore.addAgentStats(runAgentId, { input: usage.input, output: usage.output, cost: usage.cost });
440
532
  }
441
533
  if (!result || !result.ok) {
534
+ // A user-initiated cancel is not a failure: it gets its own terminal
535
+ // status so the panel shows ⊘ cancelled (dim) instead of ✗ failed (red).
536
+ const cancelled = result?.status === "cancelled";
442
537
  const failNote = result?.usedInheritedModelFallback ? "inherited-model retry failed" : undefined;
443
- taskStore.update(taskStoreId, { status: "failed", usage, note: failNote });
444
- taskStore.patchAgent(subagentType, { state: "failed" });
538
+ taskStore.update(taskStoreId, { status: cancelled ? "cancelled" : "failed", usage, note: failNote });
539
+ taskStore.patchAgent(runAgentId, { state: cancelled ? "cancelled" : "failed", activity: "" });
445
540
  const reason = result?.error ?? (result?.status ? `subagent ${result.status}` : "unknown error");
446
541
  if (background) {
542
+ const verdict = cancelled ? "cancelled ⊘" : "failed ✗";
447
543
  return {
448
- content: [{ type: "text", text: `${background.label} failed — ${reason}` }],
544
+ content: [{ type: "text", text: `${background.label} ${verdict} — ${reason}` }],
449
545
  details: {
450
546
  subagent_type: subagentType,
451
547
  ok: false,
@@ -456,20 +552,20 @@ function finalizeDispatchResult(dispatchResult, subagentType, taskStoreId, resum
456
552
  },
457
553
  };
458
554
  }
555
+ if (cancelled) {
556
+ throw new Error(`Subagent (${subagentType}) cancelled by user.`);
557
+ }
459
558
  const stderr = result?.stderr?.trim();
460
559
  throw new Error(`Subagent (${subagentType}) failed: ${reason}${stderr ? `\nstderr: ${stderr.slice(-500)}` : ""}`);
461
560
  }
462
561
  // Leave the task in the store with its final status; it stays visible in the
463
562
  // task panel until the next user message arrives. Surface a ⚠ cue when the run
464
- // fell back to the inherited model rather than emitting a chat message.
563
+ // fell back to the inherited model rather than emitting a chat message — and
564
+ // clear any stale note otherwise (note is clearable via an explicit undefined).
465
565
  const fallbackNote = dispatchResult.result?.usedInheritedModelFallback ? "ran on inherited model" : undefined;
466
566
  taskStore.update(taskStoreId, { status: "done", usage, note: fallbackNote });
467
- // Parallel dispatches share one roster entry per agent type: stay `running`
468
- // while a sibling task is still live, settle to `done` otherwise.
469
- const siblingLive = taskStore
470
- .list()
471
- .some((t) => t.agent === subagentType && (t.status === "in_progress" || t.status === "pending"));
472
- taskStore.patchAgent(subagentType, { state: siblingLive ? "running" : "done" });
567
+ // Rows are per-run, so this run settles to done regardless of siblings.
568
+ taskStore.patchAgent(runAgentId, { state: "done", activity: "" });
473
569
  let answer = resultData?.summary || "(subagent returned no output)";
474
570
  // Partial results are resumable; surface the handle so the parent can continue.
475
571
  if (result.status === "partial" && resumeHandle) {
@@ -504,7 +600,7 @@ const taskOutputParams = Type.Object({
504
600
  description: 'Handle of a background subagent — its task_id or friendly label (e.g. "explore#1") from a Task notification. Omit (or set list:true) to see every background task.',
505
601
  })),
506
602
  list: Type.Optional(Type.Boolean({
507
- description: "List all background subagents with their status (running/done/failed) and current activity. No result bodies are returned.",
603
+ description: "List all background subagents with their status (running/done/failed/cancelled) and current activity. No result bodies are returned.",
508
604
  })),
509
605
  wait: Type.Optional(Type.Boolean({
510
606
  description: "Block until the named task finishes — or, with no task_id, until all outstanding subagents finish (a swarm barrier) — before returning. Bounded by timeout_ms.",
@@ -512,10 +608,13 @@ const taskOutputParams = Type.Object({
512
608
  timeout_ms: Type.Optional(Type.Number({ description: "Maximum time to block in wait mode, in milliseconds (default 120000)." })),
513
609
  });
514
610
  const TASK_OUTPUT_DEFAULT_TIMEOUT_MS = 120_000;
515
- /** Whole seconds a record has run (so far, or until it settled). */
611
+ /**
612
+ * Elapsed time a record has run (so far, or until it settled), in the same
613
+ * format the task panel uses so the two surfaces always agree.
614
+ */
516
615
  function recordElapsed(rec) {
517
616
  const end = rec.endedAt ?? Date.now();
518
- return `${Math.max(0, Math.round((end - rec.startedAt) / 1000))}s`;
617
+ return formatDurationSecs((end - rec.startedAt) / 1000);
519
618
  }
520
619
  /** A compact roster of every known background subagent — status + activity, no bodies. */
521
620
  function formatTaskRoster() {
@@ -536,6 +635,8 @@ function formatTaskRoster() {
536
635
  return `- ${r.label} done (uncollected) ${when} — ${r.summaryLine ?? ""}`;
537
636
  case "collected":
538
637
  return `- ${r.label} collected ${when} — ${r.summaryLine ?? ""}`;
638
+ case "cancelled":
639
+ return `- ${r.label} cancelled ⊘ — ${r.error ?? "cancelled by user"}`;
539
640
  default:
540
641
  return `- ${r.label} ${r.lifecycle} ✗ — ${r.error ?? "unknown error"}`;
541
642
  }
@@ -629,19 +730,62 @@ export function createTaskOutputToolDefinition() {
629
730
  details: { task_id: handle, status: "collected", ok: true },
630
731
  };
631
732
  }
632
- // failed / stalled / timeout
733
+ // failed / stalled / timeout / cancelled
734
+ const glyph = rec.lifecycle === "cancelled" ? "⊘" : "✗";
633
735
  return {
634
736
  content: [
635
- { type: "text", text: `${rec.label} ${rec.lifecycle} ✗ — ${rec.error ?? "unknown error"}.` },
737
+ {
738
+ type: "text",
739
+ text: `${rec.label} ${rec.lifecycle} ${glyph} — ${rec.error ?? "unknown error"}.`,
740
+ },
636
741
  ],
637
742
  details: { task_id: handle, status: rec.lifecycle, ok: false },
638
743
  };
639
744
  },
640
745
  renderCall(args, theme) {
641
746
  const target = args.list ? "list" : String(args.task_id ?? "");
642
- const text = theme.fg("toolTitle", theme.bold("TaskOutput ")) + theme.fg("dim", args.wait ? `${target} (wait)` : target);
747
+ // A friendly label ("explore#1") gets its agent's identity color so the
748
+ // pull visually pairs with the dispatch that produced it; raw ids and
749
+ // "list" stay dim.
750
+ const hashIdx = target.indexOf("#");
751
+ const styledTarget = hashIdx > 0 ? theme.fg(agentColorFor(target.slice(0, hashIdx)), target) : theme.fg("dim", target);
752
+ const text = theme.fg("toolTitle", theme.bold("TaskOutput ")) +
753
+ styledTarget +
754
+ (args.wait ? theme.fg("dim", " (wait)") : "");
643
755
  return new Text(text, 0, 0);
644
756
  },
757
+ renderResult(result, _options, theme) {
758
+ // Display-only colorization of the roster/status lines (the text sent to
759
+ // the model stays plain). Lines that aren't roster rows — collected
760
+ // bodies, status sentences — render exactly like the default fallback.
761
+ const text = result.content
762
+ .map((c) => (c.type === "text" ? c.text : ""))
763
+ .filter(Boolean)
764
+ .join("\n");
765
+ if (!text)
766
+ return new Text("", 0, 0);
767
+ const rosterLine = /^- (\S+)\s{2}(running|done \(uncollected\)|collected|failed|stalled|timeout|cancelled)(.*)$/;
768
+ const styled = text
769
+ .split("\n")
770
+ .map((line) => {
771
+ const match = rosterLine.exec(line);
772
+ if (!match)
773
+ return theme.fg("toolOutput", line);
774
+ const [, label, status, rest] = match;
775
+ const hashIdx = label.indexOf("#");
776
+ const labelColor = hashIdx > 0 ? agentColorFor(label.slice(0, hashIdx)) : "accent";
777
+ const statusColor = status === "running"
778
+ ? "warning"
779
+ : status.startsWith("done")
780
+ ? "success"
781
+ : status === "collected" || status === "cancelled"
782
+ ? "muted"
783
+ : "error";
784
+ return `- ${theme.fg(labelColor, label)} ${theme.fg(statusColor, status)}${theme.fg("dim", rest)}`;
785
+ })
786
+ .join("\n");
787
+ return new Text(styled, 0, 0);
788
+ },
645
789
  });
646
790
  }
647
791
  //# sourceMappingURL=subagent.js.map