@kolisachint/hoocode-agent 0.4.110 → 0.4.111

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 (79) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/core/format-duration.d.ts +7 -0
  3. package/dist/core/format-duration.d.ts.map +1 -0
  4. package/dist/core/format-duration.js +16 -0
  5. package/dist/core/format-duration.js.map +1 -0
  6. package/dist/core/keybindings.d.ts +10 -0
  7. package/dist/core/keybindings.d.ts.map +1 -1
  8. package/dist/core/keybindings.js +10 -0
  9. package/dist/core/keybindings.js.map +1 -1
  10. package/dist/core/lifeguard.d.ts +15 -0
  11. package/dist/core/lifeguard.d.ts.map +1 -1
  12. package/dist/core/lifeguard.js +48 -18
  13. package/dist/core/lifeguard.js.map +1 -1
  14. package/dist/core/subagent-inbox.d.ts +1 -1
  15. package/dist/core/subagent-inbox.d.ts.map +1 -1
  16. package/dist/core/subagent-inbox.js +2 -0
  17. package/dist/core/subagent-inbox.js.map +1 -1
  18. package/dist/core/subagent-pool-instance.d.ts.map +1 -1
  19. package/dist/core/subagent-pool-instance.js +18 -11
  20. package/dist/core/subagent-pool-instance.js.map +1 -1
  21. package/dist/core/subagent-pool.d.ts +25 -3
  22. package/dist/core/subagent-pool.d.ts.map +1 -1
  23. package/dist/core/subagent-pool.js +120 -25
  24. package/dist/core/subagent-pool.js.map +1 -1
  25. package/dist/core/subagent-result.d.ts.map +1 -1
  26. package/dist/core/subagent-result.js +6 -4
  27. package/dist/core/subagent-result.js.map +1 -1
  28. package/dist/core/task-store.d.ts +45 -3
  29. package/dist/core/task-store.d.ts.map +1 -1
  30. package/dist/core/task-store.js +51 -5
  31. package/dist/core/task-store.js.map +1 -1
  32. package/dist/core/team-view.d.ts.map +1 -1
  33. package/dist/core/team-view.js +2 -0
  34. package/dist/core/team-view.js.map +1 -1
  35. package/dist/core/token-budget.d.ts +13 -1
  36. package/dist/core/token-budget.d.ts.map +1 -1
  37. package/dist/core/token-budget.js +18 -1
  38. package/dist/core/token-budget.js.map +1 -1
  39. package/dist/core/tools/subagent.d.ts +1 -1
  40. package/dist/core/tools/subagent.d.ts.map +1 -1
  41. package/dist/core/tools/subagent.js +184 -53
  42. package/dist/core/tools/subagent.js.map +1 -1
  43. package/dist/core/tools/todo.d.ts.map +1 -1
  44. package/dist/core/tools/todo.js +39 -7
  45. package/dist/core/tools/todo.js.map +1 -1
  46. package/dist/modes/interactive/components/keybinding-hints.d.ts +10 -0
  47. package/dist/modes/interactive/components/keybinding-hints.d.ts.map +1 -1
  48. package/dist/modes/interactive/components/keybinding-hints.js +27 -1
  49. package/dist/modes/interactive/components/keybinding-hints.js.map +1 -1
  50. package/dist/modes/interactive/components/task-panel.d.ts +22 -1
  51. package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
  52. package/dist/modes/interactive/components/task-panel.js +302 -80
  53. package/dist/modes/interactive/components/task-panel.js.map +1 -1
  54. package/dist/modes/interactive/components/team-attach-panel.d.ts.map +1 -1
  55. package/dist/modes/interactive/components/team-attach-panel.js +15 -5
  56. package/dist/modes/interactive/components/team-attach-panel.js.map +1 -1
  57. package/dist/modes/interactive/theme/dark.json +8 -1
  58. package/dist/modes/interactive/theme/light.json +8 -1
  59. package/dist/modes/interactive/theme/theme-schema.json +28 -1
  60. package/dist/modes/interactive/theme/theme.d.ts +12 -1
  61. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  62. package/dist/modes/interactive/theme/theme.js +52 -0
  63. package/dist/modes/interactive/theme/theme.js.map +1 -1
  64. package/dist/modes/interactive/voice/voice-panel.d.ts.map +1 -1
  65. package/dist/modes/interactive/voice/voice-panel.js +5 -2
  66. package/dist/modes/interactive/voice/voice-panel.js.map +1 -1
  67. package/dist/modes/rpc/jsonl.d.ts +16 -1
  68. package/dist/modes/rpc/jsonl.d.ts.map +1 -1
  69. package/dist/modes/rpc/jsonl.js +53 -8
  70. package/dist/modes/rpc/jsonl.js.map +1 -1
  71. package/dist/utils/atomic-file.d.ts +13 -0
  72. package/dist/utils/atomic-file.d.ts.map +1 -0
  73. package/dist/utils/atomic-file.js +26 -0
  74. package/dist/utils/atomic-file.js.map +1 -0
  75. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  76. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  77. package/examples/extensions/sandbox/package.json +1 -1
  78. package/examples/extensions/with-deps/package.json +1 -1
  79. package/package.json +4 -4
@@ -12,9 +12,11 @@
12
12
  */
13
13
  import { Text } from "@kolisachint/hoocode-tui";
14
14
  import { Type } from "typebox";
15
+ import { agentColorFor } from "../../modes/interactive/theme/theme.js";
15
16
  import { TASK_TOOL_NAME } from "../agent-frontmatter.js";
16
17
  import { loadAgentRegistry } from "../agent-registry.js";
17
18
  import { defineTool } from "../extensions/types.js";
19
+ import { formatDurationSecs } from "../format-duration.js";
18
20
  import { getProviderExhaustion } from "../provider-health.js";
19
21
  import { SessionManager } from "../session-manager.js";
20
22
  import { delegateAllowList, isDelegateAllowed } from "../subagent-depth.js";
@@ -53,6 +55,7 @@ Guidelines:
53
55
  - 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
56
  - 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
57
  - 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.
58
+ - 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
59
  - 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
60
  }
58
61
  const taskParams = Type.Object({
@@ -116,8 +119,29 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
116
119
  ].join("\n"),
117
120
  promptSnippet: "delegate a self-contained task to a specialized subagent (choose via subagent_type)",
118
121
  parameters: taskParams,
119
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
122
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
120
123
  const pool = getSubagentPool(ctx.cwd);
124
+ // User-initiated cancel (Esc/abort): kill the dispatched run's whole
125
+ // process tree and let the dispatch settle with status "cancelled" —
126
+ // distinct from "failed" everywhere it surfaces (panel, inbox, result).
127
+ // Cold dispatches only: a warm worker has no cancel surface (it is
128
+ // reused), so warm runs still complete server-side and are discarded.
129
+ const cancelOnAbort = async (poolRunId, run) => {
130
+ if (!signal)
131
+ return run();
132
+ const onAbort = () => {
133
+ pool.cancel?.(poolRunId);
134
+ };
135
+ if (signal.aborted)
136
+ onAbort();
137
+ signal.addEventListener("abort", onAbort, { once: true });
138
+ try {
139
+ return await run();
140
+ }
141
+ finally {
142
+ signal.removeEventListener("abort", onAbort);
143
+ }
144
+ };
121
145
  // Pre-flight: if the inherited provider recently exhausted its quota (the
122
146
  // parent's own turn failed with a usage/rate-limit error that did not
123
147
  // recover), skip the spawn. Subagents run on the same provider, so this
@@ -126,12 +150,16 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
126
150
  const provider = ctx.model?.provider;
127
151
  const exhaustion = provider ? getProviderExhaustion(provider) : undefined;
128
152
  if (exhaustion) {
153
+ const skippedRunId = newDispatchTaskId();
129
154
  const skipped = taskStore.create(params.description?.trim() || summarize(params.prompt), {
130
155
  source: "subagent",
131
156
  subagentMode: params.subagent_type,
132
- agent: params.subagent_type,
157
+ agent: skippedRunId,
158
+ linkedTaskId: linkedTodoId(),
133
159
  });
160
+ registerSubagentDispatch(skippedRunId, subagentInbox.nextLabel(params.subagent_type));
134
161
  taskStore.update(skipped.id, { status: "failed", note: `${provider} exhausted` });
162
+ taskStore.patchAgent(skippedRunId, { state: "failed" });
135
163
  return {
136
164
  content: [
137
165
  {
@@ -156,23 +184,27 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
156
184
  const resumeId = params.resume_task_id?.trim();
157
185
  if (resumeId) {
158
186
  const summary = params.description?.trim() || summarize(params.prompt);
187
+ const runId = newDispatchTaskId();
188
+ const label = subagentInbox.nextLabel(params.subagent_type);
159
189
  const task = taskStore.create(summary, {
160
190
  source: "subagent",
161
191
  subagentMode: params.subagent_type,
162
- agent: params.subagent_type,
192
+ agent: runId,
193
+ linkedTaskId: linkedTodoId(),
163
194
  });
164
- registerSubagentDispatch(params.subagent_type);
195
+ registerSubagentDispatch(runId, label);
165
196
  taskStore.update(task.id, { status: "in_progress" });
166
197
  try {
167
- const dispatchResult = await pool.resume(resumeId, params.prompt, {
198
+ const dispatchResult = await cancelOnAbort(runId, () => pool.resume(resumeId, params.prompt, {
168
199
  model: ctx.model?.id,
169
200
  provider: ctx.model?.provider,
170
- });
201
+ taskId: runId,
202
+ }));
171
203
  // The session lives under the original task id; keep it as the resume handle.
172
- return finalizeDispatchResult(dispatchResult, params.subagent_type, task.id, resumeId);
204
+ return finalizeDispatchResult(dispatchResult, params.subagent_type, runId, task.id, resumeId);
173
205
  }
174
206
  catch (error) {
175
- taskStore.update(task.id, { status: "failed" });
207
+ markDispatchFailed(task.id, runId);
176
208
  throw error;
177
209
  }
178
210
  }
@@ -188,12 +220,23 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
188
220
  throw new Error(`Unknown subagent_type: "${params.subagent_type}". Available agents: ${available || "(none)"}.`);
189
221
  }
190
222
  const summary = params.description?.trim() || summarize(params.prompt);
223
+ // One roster row per dispatch, keyed by the (pre-allocated) pool task id.
224
+ // Keying by agent TYPE made concurrent same-type runs share a single row:
225
+ // their activity, state, and stats collided last-writer-wins. The inbox
226
+ // already keys by task id; this aligns the panel to the same model. The
227
+ // pool's task_progress events carry the task id, so patching by this id
228
+ // routes live activity to the right row.
229
+ const poolTaskId = newDispatchTaskId();
230
+ const label = subagentInbox.nextLabel(params.subagent_type);
191
231
  const task = taskStore.create(summary, {
192
232
  source: "subagent",
193
233
  subagentMode: params.subagent_type,
194
- agent: params.subagent_type,
234
+ agent: poolTaskId,
235
+ // Tie this run to the plan item it executes (the single in_progress
236
+ // TodoWrite task, when unambiguous) so the panel can nest it there.
237
+ linkedTaskId: linkedTodoId(),
195
238
  });
196
- registerSubagentDispatch(params.subagent_type);
239
+ registerSubagentDispatch(poolTaskId, label);
197
240
  taskStore.update(task.id, { status: "in_progress" });
198
241
  // Fork agents inherit the parent's conversation via a forked session.
199
242
  const forkSessionFile = def.fork
@@ -210,11 +253,9 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
210
253
  // recompute it here to choose the notify-and-pull return shape.
211
254
  const isBackground = params.background ?? backgroundAgents.has(params.subagent_type);
212
255
  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);
256
+ // Notify-and-pull: register the dispatch in the inbox under the
257
+ // pre-allocated id, await it, retain the body in the inbox, and return a
258
+ // compact notification (not the body). The model pulls it with TaskOutput.
218
259
  subagentInbox.observe(pool);
219
260
  subagentInbox.start(poolTaskId, label, params.subagent_type);
220
261
  // Warm path (opt-in): run on a reused RPC worker, retain the body in the
@@ -229,10 +270,10 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
229
270
  cwd: ctx.cwd,
230
271
  model: dispatchModel,
231
272
  provider: ctx.model?.provider,
232
- }, (activity) => taskStore.patchAgent(params.subagent_type, { activity }));
273
+ }, (activity) => taskStore.patchAgent(poolTaskId, { activity }));
233
274
  const dispatchResult = warmResultToTaskResult(warmResult, params.subagent_type, task.id);
234
275
  subagentInbox.finish(poolTaskId, dispatchResult);
235
- return finalizeDispatchResult(dispatchResult, params.subagent_type, task.id, poolTaskId, {
276
+ return finalizeDispatchResult(dispatchResult, params.subagent_type, poolTaskId, task.id, poolTaskId, {
236
277
  taskId: poolTaskId,
237
278
  label,
238
279
  });
@@ -245,23 +286,23 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
245
286
  }
246
287
  }
247
288
  try {
248
- const dispatchResult = await pool.dispatch(params.prompt, {
289
+ const dispatchResult = await cancelOnAbort(poolTaskId, () => pool.dispatch(params.prompt, {
249
290
  forceAgent: params.subagent_type,
250
291
  context: "",
251
292
  model: dispatchModel,
252
293
  provider: ctx.model?.provider,
253
294
  sessionFile: forkSessionFile,
254
295
  taskId: poolTaskId,
255
- });
296
+ }));
256
297
  subagentInbox.finish(poolTaskId, dispatchResult);
257
- return finalizeDispatchResult(dispatchResult, params.subagent_type, task.id, poolTaskId, {
298
+ return finalizeDispatchResult(dispatchResult, params.subagent_type, poolTaskId, task.id, poolTaskId, {
258
299
  taskId: poolTaskId,
259
300
  label,
260
301
  });
261
302
  }
262
303
  catch (error) {
263
304
  const reason = error instanceof Error ? error.message : String(error);
264
- taskStore.update(task.id, { status: "failed" });
305
+ markDispatchFailed(task.id, poolTaskId);
265
306
  subagentInbox.fail(poolTaskId, reason);
266
307
  // A background dispatch reports failure as a compact notification, not a
267
308
  // thrown tool error — the call was already answered by a placeholder.
@@ -295,15 +336,16 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
295
336
  },
296
337
  // Mirror the cold pool's live progress on the task panel roster so a
297
338
  // warm dispatch reads as busy (⋯ grep), not stuck.
298
- (activity) => taskStore.patchAgent(params.subagent_type, { activity }));
339
+ (activity) => taskStore.patchAgent(poolTaskId, { activity }));
299
340
  const dispatchResult = warmResultToTaskResult(warmResult, params.subagent_type, task.id);
300
- return finalizeDispatchResult(dispatchResult, params.subagent_type, task.id, undefined);
341
+ return finalizeDispatchResult(dispatchResult, params.subagent_type, poolTaskId, task.id, undefined);
301
342
  }
302
343
  catch (error) {
303
344
  // A genuine infra failure (worker crash/timeout) retries cold; any other
304
345
  // error is a real dispatch failure and propagates.
305
346
  if (!(error instanceof WarmWorkerError)) {
306
347
  taskStore.update(task.id, { status: "failed" });
348
+ taskStore.patchAgent(poolTaskId, { state: "failed", activity: "" });
307
349
  throw error;
308
350
  }
309
351
  console.error(`[WARM] ${params.subagent_type} fell back to cold spawn: ${error.message}`);
@@ -312,23 +354,27 @@ export function createTaskToolDefinition(cwd = process.cwd()) {
312
354
  }
313
355
  // Foreground: block the turn and return the subagent's full answer inline.
314
356
  try {
315
- const dispatchResult = await pool.dispatch(params.prompt, {
357
+ const dispatchResult = await cancelOnAbort(poolTaskId, () => pool.dispatch(params.prompt, {
316
358
  forceAgent: params.subagent_type,
317
359
  context: "",
318
360
  model: dispatchModel,
319
361
  provider: ctx.model?.provider,
320
362
  sessionFile: forkSessionFile,
321
- });
322
- return finalizeDispatchResult(dispatchResult, params.subagent_type, task.id, dispatchResult.task_id);
363
+ taskId: poolTaskId,
364
+ }));
365
+ return finalizeDispatchResult(dispatchResult, params.subagent_type, poolTaskId, task.id, dispatchResult.task_id);
323
366
  }
324
367
  catch (error) {
325
- taskStore.update(task.id, { status: "failed" });
368
+ markDispatchFailed(task.id, poolTaskId);
326
369
  throw error;
327
370
  }
328
371
  },
329
372
  renderCall(args, theme) {
330
373
  const type = args.subagent_type ?? "agent";
331
- const text = theme.fg("toolTitle", theme.bold("Agent ")) + theme.fg("accent", `[${type}]`);
374
+ // The [type] tag carries the agent's identity color — the same hue this
375
+ // agent has in the task panel and TaskOutput — so a transcript full of
376
+ // concurrent dispatches is scannable by color.
377
+ const text = theme.fg("toolTitle", theme.bold("Agent ")) + theme.fg(agentColorFor(type), `[${type}]`);
332
378
  return new Text(text, 0, 0);
333
379
  },
334
380
  });
@@ -350,13 +396,47 @@ export function resolveForkSessionFile(def, parentSessionPath, cwd) {
350
396
  return undefined;
351
397
  }
352
398
  }
399
+ /** Pre-allocated pool task id for a dispatch (matches the pool's own format). */
400
+ function newDispatchTaskId() {
401
+ return `dispatch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
402
+ }
353
403
  /**
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.
404
+ * The TodoWrite plan item this dispatch is (most plausibly) working on: the
405
+ * single in_progress main-agent task. TodoWrite discipline keeps exactly one
406
+ * item in_progress, so when that holds the link is unambiguous; with zero or
407
+ * several in_progress items no link is recorded rather than guessing. Used to
408
+ * nest the run under its plan item in the task panel's flat lens.
357
409
  */
358
- function registerSubagentDispatch(type) {
359
- taskStore.upsertAgent({ id: type, name: type, role: "subagent", kind: "subagent", state: "running" });
410
+ function linkedTodoId() {
411
+ const inProgress = taskStore
412
+ .list()
413
+ .filter((t) => t.source === undefined &&
414
+ t.agent === undefined &&
415
+ t.parentTaskId === undefined &&
416
+ t.status === "in_progress");
417
+ return inProgress.length === 1 ? inProgress[0]?.id : undefined;
418
+ }
419
+ /**
420
+ * Register this dispatch in the task store's roster, keyed by its run id (the
421
+ * pool task id) so concurrent runs of the same agent type each get their own
422
+ * row, state, activity, and stats. The friendly label ("explore#1") names the
423
+ * row the same way the inbox names background tasks.
424
+ */
425
+ function registerSubagentDispatch(runId, label) {
426
+ taskStore.upsertAgent({ id: runId, name: label, role: "subagent", kind: "subagent", state: "running" });
427
+ }
428
+ /**
429
+ * Catch-path bookkeeping: mark the dispatch failed unless it already settled.
430
+ * finalizeDispatchResult records a terminal status (failed/cancelled) *before*
431
+ * throwing, so a blanket "failed" here would clobber a cancellation with a
432
+ * failure the moment the thrown error passed through.
433
+ */
434
+ function markDispatchFailed(taskStoreId, runId) {
435
+ const current = taskStore.list().find((t) => t.id === taskStoreId);
436
+ if (current && current.status !== "pending" && current.status !== "in_progress")
437
+ return;
438
+ taskStore.update(taskStoreId, { status: "failed" });
439
+ taskStore.patchAgent(runId, { state: "failed", activity: "" });
360
440
  }
361
441
  /** Names of agents configured to run in the background (non-blocking). */
362
442
  function collectBackgroundAgentNames(cwd) {
@@ -426,26 +506,29 @@ function warmResultToTaskResult(warm, agentType, taskStoreId) {
426
506
  * inbox, so it returns a compact, self-contained notification (success or
427
507
  * failure) and never throws — the call was already answered by a placeholder.
428
508
  */
429
- function finalizeDispatchResult(dispatchResult, subagentType, taskStoreId, resumeHandle, background) {
509
+ function finalizeDispatchResult(dispatchResult, subagentType, runAgentId, taskStoreId, resumeHandle, background) {
430
510
  const result = dispatchResult.result;
431
511
  const resultData = result?.result_data;
432
512
  const usage = resultData?.usage;
433
513
  // Merge the child's own task subtree under the dispatching task so nested
434
514
  // delegation (depth >= 2) is visible in the subagents lens's task tree.
435
515
  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.
516
+ // Roll this run's usage into its own roster row (rows are per-dispatch).
438
517
  if (usage) {
439
- taskStore.addAgentStats(subagentType, { input: usage.input, output: usage.output, cost: usage.cost });
518
+ taskStore.addAgentStats(runAgentId, { input: usage.input, output: usage.output, cost: usage.cost });
440
519
  }
441
520
  if (!result || !result.ok) {
521
+ // A user-initiated cancel is not a failure: it gets its own terminal
522
+ // status so the panel shows ⊘ cancelled (dim) instead of ✗ failed (red).
523
+ const cancelled = result?.status === "cancelled";
442
524
  const failNote = result?.usedInheritedModelFallback ? "inherited-model retry failed" : undefined;
443
- taskStore.update(taskStoreId, { status: "failed", usage, note: failNote });
444
- taskStore.patchAgent(subagentType, { state: "failed" });
525
+ taskStore.update(taskStoreId, { status: cancelled ? "cancelled" : "failed", usage, note: failNote });
526
+ taskStore.patchAgent(runAgentId, { state: cancelled ? "cancelled" : "failed", activity: "" });
445
527
  const reason = result?.error ?? (result?.status ? `subagent ${result.status}` : "unknown error");
446
528
  if (background) {
529
+ const verdict = cancelled ? "cancelled ⊘" : "failed ✗";
447
530
  return {
448
- content: [{ type: "text", text: `${background.label} failed — ${reason}` }],
531
+ content: [{ type: "text", text: `${background.label} ${verdict} — ${reason}` }],
449
532
  details: {
450
533
  subagent_type: subagentType,
451
534
  ok: false,
@@ -456,20 +539,20 @@ function finalizeDispatchResult(dispatchResult, subagentType, taskStoreId, resum
456
539
  },
457
540
  };
458
541
  }
542
+ if (cancelled) {
543
+ throw new Error(`Subagent (${subagentType}) cancelled by user.`);
544
+ }
459
545
  const stderr = result?.stderr?.trim();
460
546
  throw new Error(`Subagent (${subagentType}) failed: ${reason}${stderr ? `\nstderr: ${stderr.slice(-500)}` : ""}`);
461
547
  }
462
548
  // Leave the task in the store with its final status; it stays visible in the
463
549
  // 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.
550
+ // fell back to the inherited model rather than emitting a chat message — and
551
+ // clear any stale note otherwise (note is clearable via an explicit undefined).
465
552
  const fallbackNote = dispatchResult.result?.usedInheritedModelFallback ? "ran on inherited model" : undefined;
466
553
  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" });
554
+ // Rows are per-run, so this run settles to done regardless of siblings.
555
+ taskStore.patchAgent(runAgentId, { state: "done", activity: "" });
473
556
  let answer = resultData?.summary || "(subagent returned no output)";
474
557
  // Partial results are resumable; surface the handle so the parent can continue.
475
558
  if (result.status === "partial" && resumeHandle) {
@@ -504,7 +587,7 @@ const taskOutputParams = Type.Object({
504
587
  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
588
  })),
506
589
  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.",
590
+ description: "List all background subagents with their status (running/done/failed/cancelled) and current activity. No result bodies are returned.",
508
591
  })),
509
592
  wait: Type.Optional(Type.Boolean({
510
593
  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 +595,13 @@ const taskOutputParams = Type.Object({
512
595
  timeout_ms: Type.Optional(Type.Number({ description: "Maximum time to block in wait mode, in milliseconds (default 120000)." })),
513
596
  });
514
597
  const TASK_OUTPUT_DEFAULT_TIMEOUT_MS = 120_000;
515
- /** Whole seconds a record has run (so far, or until it settled). */
598
+ /**
599
+ * Elapsed time a record has run (so far, or until it settled), in the same
600
+ * format the task panel uses so the two surfaces always agree.
601
+ */
516
602
  function recordElapsed(rec) {
517
603
  const end = rec.endedAt ?? Date.now();
518
- return `${Math.max(0, Math.round((end - rec.startedAt) / 1000))}s`;
604
+ return formatDurationSecs((end - rec.startedAt) / 1000);
519
605
  }
520
606
  /** A compact roster of every known background subagent — status + activity, no bodies. */
521
607
  function formatTaskRoster() {
@@ -536,6 +622,8 @@ function formatTaskRoster() {
536
622
  return `- ${r.label} done (uncollected) ${when} — ${r.summaryLine ?? ""}`;
537
623
  case "collected":
538
624
  return `- ${r.label} collected ${when} — ${r.summaryLine ?? ""}`;
625
+ case "cancelled":
626
+ return `- ${r.label} cancelled ⊘ — ${r.error ?? "cancelled by user"}`;
539
627
  default:
540
628
  return `- ${r.label} ${r.lifecycle} ✗ — ${r.error ?? "unknown error"}`;
541
629
  }
@@ -629,19 +717,62 @@ export function createTaskOutputToolDefinition() {
629
717
  details: { task_id: handle, status: "collected", ok: true },
630
718
  };
631
719
  }
632
- // failed / stalled / timeout
720
+ // failed / stalled / timeout / cancelled
721
+ const glyph = rec.lifecycle === "cancelled" ? "⊘" : "✗";
633
722
  return {
634
723
  content: [
635
- { type: "text", text: `${rec.label} ${rec.lifecycle} ✗ — ${rec.error ?? "unknown error"}.` },
724
+ {
725
+ type: "text",
726
+ text: `${rec.label} ${rec.lifecycle} ${glyph} — ${rec.error ?? "unknown error"}.`,
727
+ },
636
728
  ],
637
729
  details: { task_id: handle, status: rec.lifecycle, ok: false },
638
730
  };
639
731
  },
640
732
  renderCall(args, theme) {
641
733
  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);
734
+ // A friendly label ("explore#1") gets its agent's identity color so the
735
+ // pull visually pairs with the dispatch that produced it; raw ids and
736
+ // "list" stay dim.
737
+ const hashIdx = target.indexOf("#");
738
+ const styledTarget = hashIdx > 0 ? theme.fg(agentColorFor(target.slice(0, hashIdx)), target) : theme.fg("dim", target);
739
+ const text = theme.fg("toolTitle", theme.bold("TaskOutput ")) +
740
+ styledTarget +
741
+ (args.wait ? theme.fg("dim", " (wait)") : "");
643
742
  return new Text(text, 0, 0);
644
743
  },
744
+ renderResult(result, _options, theme) {
745
+ // Display-only colorization of the roster/status lines (the text sent to
746
+ // the model stays plain). Lines that aren't roster rows — collected
747
+ // bodies, status sentences — render exactly like the default fallback.
748
+ const text = result.content
749
+ .map((c) => (c.type === "text" ? c.text : ""))
750
+ .filter(Boolean)
751
+ .join("\n");
752
+ if (!text)
753
+ return new Text("", 0, 0);
754
+ const rosterLine = /^- (\S+)\s{2}(running|done \(uncollected\)|collected|failed|stalled|timeout|cancelled)(.*)$/;
755
+ const styled = text
756
+ .split("\n")
757
+ .map((line) => {
758
+ const match = rosterLine.exec(line);
759
+ if (!match)
760
+ return theme.fg("toolOutput", line);
761
+ const [, label, status, rest] = match;
762
+ const hashIdx = label.indexOf("#");
763
+ const labelColor = hashIdx > 0 ? agentColorFor(label.slice(0, hashIdx)) : "accent";
764
+ const statusColor = status === "running"
765
+ ? "warning"
766
+ : status.startsWith("done")
767
+ ? "success"
768
+ : status === "collected" || status === "cancelled"
769
+ ? "muted"
770
+ : "error";
771
+ return `- ${theme.fg(labelColor, label)} ${theme.fg(statusColor, status)}${theme.fg("dim", rest)}`;
772
+ })
773
+ .join("\n");
774
+ return new Text(styled, 0, 0);
775
+ },
645
776
  });
646
777
  }
647
778
  //# sourceMappingURL=subagent.js.map