@maxiaochao/pi-toolkit 0.10.3 → 0.10.4

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 2026-10-08 - v0.10.4
2
+
3
+ - bundle herdr-subagents v0.4.1: targeted cancellation, live token polling, and idempotent tab cleanup
4
+
1
5
  ## 2026-10-08 - v0.10.3
2
6
 
3
7
  - bundle herdr-subagents v0.4.0: add the live compact Fleet footer with authoritative metrics and refreshed media
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.10.3",
3
+ "version": "0.10.4",
4
4
  "description": "Personal pi coding agent toolkit: execution plans, /btw, /cache_export and apply_patch extensions, browser automation, and bundled theme/skills.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,3 +1,10 @@
1
+ ## 2026-10-08 - v0.4.1
2
+
3
+ - targeted cancellation, live token polling, and idempotent tab cleanup
4
+ - Add `cancel` control for one active or queued run while leaving unrelated runs untouched.
5
+ - Refresh live Fleet token totals every second as well as on file events.
6
+ - Treat already-missing tabs as successful cleanup and emit one warning per real cleanup failure.
7
+
1
8
  ## 2026-10-08 - v0.4.0
2
9
 
3
10
  - add the live compact Fleet footer with authoritative metrics and refreshed media
@@ -72,7 +72,9 @@ A child missing required information reports `needs-input` with an exact questio
72
72
  herdr_subagents_control({ action: "respond", runId: "<run-id>", answer: "Use the main branch." })
73
73
  ```
74
74
 
75
- While a batch is active, a compact Fleet monitor stays below the editor. Its divider/title row summarizes the run, and each task row shows a hollow or filled status circle, the real persona and task name, status, live active duration, and cumulative child Pi token usage in one compact shared metrics column after the longest row rather than at the terminal edge. Running work uses Pi's warning/yellow semantic color and completed work uses success/green. Token totals use the child session's persisted assistant, tool-result, standalone usage, compaction, and branch-summary usage; they are never estimated. Queued tasks show neither fabricated time nor tokens. The monitor follows Pi theme semantics, truncates by visible terminal columns, and updates session usage from file events while one lightweight repaint timer advances running durations.
75
+ Cancel one active or queued run with `herdr_subagents_control({ action: "cancel", runId: "<run-id>" })`. Cancellation aborts its live child prompts, archives active or queued tasks as cancelled, closes its run tab, and leaves unrelated runs alone.
76
+
77
+ While a batch is active, a compact Fleet monitor stays below the editor. Its divider/title row summarizes the run, and each task row shows a hollow or filled status circle, the real persona and task name, status, live active duration, and cumulative child Pi token usage in one compact shared metrics column after the longest row rather than at the terminal edge. Running work uses Pi's warning/yellow semantic color and completed work uses success/green. Token totals use the child session's persisted assistant, tool-result, standalone usage, compaction, and branch-summary usage; they are never estimated. Queued tasks show neither fabricated time nor tokens. The monitor follows Pi theme semantics, truncates by visible terminal columns, and refreshes session usage from both file events and the running one-second timer.
76
78
 
77
79
  With an empty editor, press `↓` to enter the list, `↑`/`↓` to choose a task, `Enter` to focus its exact pane, and `Esc` to return. `/herdr-subagents focus <task-number>` remains available as a fallback.
78
80
 
@@ -84,7 +86,7 @@ Continue a saved conversation in a new Herdr tab with `herdr_subagents_control({
84
86
 
85
87
  Read-only tasks start FIFO up to the configured concurrency limit. A pane is created only when its task starts, belongs exclusively to that task, and is never reused. Completed panes remain inspectable while another task in the batch is unsettled. Results remain in input order, and one failed task does not cancel its siblings. Any batch containing a write-capable persona is forced to concurrency one.
86
88
 
87
- The widget shows one row per queued, running, blocked, failed, or completed task. Answering a blocked task or resuming saved history refreshes authoritative persona, timing, session, status, and pane projections as work advances. Active duration accumulates only while the task is running, excluding time spent queued or waiting for an answer. Completed rows remain selectable until the whole batch settles. `/herdr-subagents active` shows the same compact state. Native labels stay short: `SA · <batch>` for the tab and `<order> · <task>` for each pane. When all tasks settle, the tab closes immediately if unfocused or after the user leaves it; a deferred close failure is persisted and shown as a warning.
89
+ The widget shows one row per queued, running, blocked, failed, or completed task. Answering a blocked task or resuming saved history refreshes authoritative persona, timing, session, status, and pane projections as work advances. Active duration accumulates only while the task is running, excluding time spent queued or waiting for an answer. Completed rows remain selectable until the whole batch settles. `/herdr-subagents active` shows the same compact state. Native labels stay short: `SA · <batch>` for the tab and `<order> · <task>` for each pane. When all tasks settle, the tab closes immediately if unfocused or after the user leaves it; a deferred close failure is persisted and shown as a warning. An already-missing tab is treated as successfully cleaned, so retries are idempotent.
88
90
 
89
91
  Foreground calls block until all children report completion, failure, or a request for input. Every child is started with Herdr's Agent lifecycle as a normal Pi TUI and prompted with wait semantics; no print-mode command or completion marker is injected into the pane.
90
92
 
@@ -18,10 +18,10 @@
18
18
  - Recover queued/running work after interruption without retrying post-prompt side effects, including truncated reports and recovery errors.
19
19
  - Verify the split public contracts (`herdr_subagents` with required `tasks`, plus `herdr_subagents_control` with required `action`) and injectable extension wiring, including conditional registration only inside Herdr, Fleet rendering, editor focus ownership, terminal-input passthrough, exact-pane navigation, and stable display while another background run queues.
20
20
  - Verify Fleet divider/row formatting, warning/yellow running indicators, success/green completed indicators, persona and task projections, cumulative active duration across blocking/resume, exact Pi usage aggregation from assistant messages, tool results, standalone usage, compaction, and branch summaries, malformed/truncated JSONL tolerance, queued metric omission, and visible-column bounds at narrow widths.
21
- - Verify live token updates are file-event driven, elapsed repainting exists only while work runs, and widget disposal closes watchers and timers.
22
- - Verify blocked-response live Fleet updates, authoritative stale-field replacement, parent-shutdown cancellation, cleanup notifications, atomic state replacement, non-retried uncertain startup operations, and stalled warnings.
21
+ - Verify live token updates are file-event driven and polled on each running one-second tick, elapsed repainting exists only while work runs, and widget disposal closes watchers and timers.
22
+ - Verify selected active/queued run cancellation, blocked-response live Fleet updates, authoritative stale-field replacement, parent-shutdown cancellation, idempotent missing-tab cleanup, cleanup notifications, atomic state replacement, non-retried uncertain startup operations, and stalled warnings.
23
23
  - Verify resumed batches retain newly allocated pane IDs and historical follow-ups exclude simultaneous session writers.
24
24
 
25
25
  ## Latest baseline
26
26
 
27
- All 159 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, and persona cases must pass. Release validation also requires extension loading and a real Herdr read-only batch smoke with distinct task panes and final tab cleanup. The latest smoke completed two concurrent tasks, retained its focused run tab through settlement, and closed it automatically after focus moved away.
27
+ All 168 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, and persona cases must pass. Release validation also requires extension loading and a real Herdr read-only batch smoke with distinct task panes and final tab cleanup. The latest smoke completed two concurrent tasks, retained its focused run tab through settlement, and closed it automatically after focus moved away.
@@ -160,6 +160,7 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
160
160
  const dispatcher = new RunDispatcher();
161
161
  const fleet = new FleetController(herdr, isEditorComponent, dependencies.createFleetWidget, dependencies.fleetWidgetRuntime);
162
162
  const runControllers = new Map<string, AbortController>();
163
+ const runPromises = new Map<string, Promise<unknown>>();
163
164
  const inFlight = new Set<Promise<unknown>>();
164
165
  let sessionController = new AbortController();
165
166
  let ownerSessionId: string | undefined;
@@ -228,8 +229,9 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
228
229
  }
229
230
  return result;
230
231
  });
232
+ runPromises.set(runId, promise);
231
233
  inFlight.add(promise);
232
- void promise.finally(() => { inFlight.delete(promise); runControllers.delete(runId); }).catch(() => undefined);
234
+ void promise.finally(() => { inFlight.delete(promise); runControllers.delete(runId); runPromises.delete(runId); }).catch(() => undefined);
233
235
  if (params.background) {
234
236
  void promise.then((result) => {
235
237
  if (generation !== sessionGeneration) return;
@@ -237,6 +239,10 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
237
239
  if (!fleet.active && !dispatcher.snapshot().activeRunId && dispatcher.snapshot().queuedRunIds.length === 0) fleet.render();
238
240
  }).catch((error) => {
239
241
  if (generation !== sessionGeneration) return;
242
+ if (controller.signal.aborted) {
243
+ pi.sendMessage({ customType: "herdr-subagents-run", content: `Subagent run ${runId} cancelled.`, display: true, details: { runId, status: "cancelled" } }, { triggerTurn: true, deliverAs: "followUp" });
244
+ return;
245
+ }
240
246
  pi.sendMessage({ customType: "herdr-subagents-run", content: `Subagent run ${runId} failed: ${error instanceof Error ? error.message : String(error)}`, display: true, details: { runId, status: "failed" } }, { triggerTurn: true, deliverAs: "followUp" });
241
247
  });
242
248
  return { content: [{ type: "text" as const, text: `Subagent run queued: ${runId}` }], details: { runId, status: "queued", background: true } };
@@ -253,12 +259,12 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
253
259
  pi.registerTool({
254
260
  name: "herdr_subagents_control",
255
261
  label: "Herdr Subagents Control",
256
- description: "Inspect Herdr personas and run history, answer a blocked task, resume a saved task, or remove a selected archived run. Use herdr_subagents to start new work.",
262
+ description: "Inspect Herdr personas and run history, cancel active or queued work, answer a blocked task, resume a saved task, or remove a selected archived run. Use herdr_subagents to start new work.",
257
263
  promptSnippet: "Inspect and control Herdr subagent runs",
258
264
  parameters: HerdrSubagentsControlParams,
259
265
 
260
266
  async execute(_toolCallId, params: HerdrSubagentsControlToolParams, signal, onUpdate, ctx) {
261
- const executionId = params.runId ?? randomUUID();
267
+ const executionId = randomUUID();
262
268
  const controller = new AbortController();
263
269
  runControllers.set(executionId, controller);
264
270
  const runSignal = AbortSignal.any([controller.signal, sessionController.signal, ...(signal ? [signal] : [])]);
@@ -269,7 +275,34 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
269
275
  const updateDetails = update.details as { warning?: string };
270
276
  if (updateDetails.warning || updateText?.includes("cleanup failed")) ctx.ui.notify(updateDetails.warning ?? updateText ?? "Subagent warning", "warning");
271
277
  onUpdate?.(update);
272
- }, ctx, { agentsDirectory: packageAgentsDir, owner: { sessionId: ownerSessionId, processId: process.pid } });
278
+ }, ctx, {
279
+ agentsDirectory: packageAgentsDir,
280
+ herdr,
281
+ owner: { sessionId: ownerSessionId, processId: process.pid },
282
+ cancelRun: async (runId) => {
283
+ const reason = new Error("Subagent run cancelled by user.");
284
+ const targetController = runControllers.get(runId);
285
+ targetController?.abort(reason);
286
+ if (dispatcher.cancelQueuedRun(runId, reason)) {
287
+ await cancelQueuedRuns(ctx.cwd, [runId], { reason: reason.message, owner: { sessionId: ownerSessionId, processId: process.pid } });
288
+ fleet.clearRun(runId);
289
+ return { cancelled: true, queued: true };
290
+ }
291
+ const activePromise = runPromises.get(runId);
292
+ if (activePromise) {
293
+ await activePromise.catch(() => undefined);
294
+ fleet.clearRun(runId);
295
+ dispatcher.release(runId);
296
+ return { cancelled: true };
297
+ }
298
+ const cancellation = await cancelActiveSubagentRuns(ctx.cwd, herdr, { reason: reason.message, owner: { sessionId: ownerSessionId, processId: process.pid }, runId });
299
+ if (cancellation.cancelledRuns) {
300
+ fleet.clearRun(runId);
301
+ dispatcher.release(runId);
302
+ }
303
+ return { cancelled: cancellation.cancelledRuns > 0, cleanupErrors: cancellation.cleanupErrors };
304
+ },
305
+ });
273
306
  inFlight.add(promise);
274
307
  const result = await promise.finally(() => { inFlight.delete(promise); runControllers.delete(executionId); });
275
308
  fleet.sync(result.details);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-herdr-subagents",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Run inspectable interactive Pi subagent batches through Herdr.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -190,7 +190,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
190
190
  const failed = settledResults.filter((result) => result.status === "failed").length;
191
191
  const cancelled = options.signal?.aborted || activity.some((entry) => entry.status === "cancelled");
192
192
  const status: BatchResult["status"] = cancelled ? "cancelled" : blocked || activity.some((entry) => entry.status === "queued") ? "blocked" : failed === 0 ? "completed" : failed === tasks.length ? "failed" : "partial";
193
- const summary = status === "cancelled" ? "Subagent batch cancelled because the parent Pi session closed." : blocked ? `${blocked.name} needs input: ${blocked.question}` : failed ? `${tasks.length - failed}/${tasks.length} tasks completed; ${failed} failed.` : `${tasks.length}/${tasks.length} tasks completed.`;
193
+ const summary = status === "cancelled" ? "Subagent batch cancelled." : blocked ? `${blocked.name} needs input: ${blocked.question}` : failed ? `${tasks.length - failed}/${tasks.length} tasks completed; ${failed} failed.` : `${tasks.length}/${tasks.length} tasks completed.`;
194
194
  applyRunStatus(runRecord, status, { question: blocked?.question });
195
195
  await writeJsonAtomic(runFile, runRecord);
196
196
  return { ok: status === "completed", status, runId, label: options.label, requestedConcurrency, effectiveConcurrency, tasks: settledResults, activity, summary, documents: settledResults.flatMap((result) => result.documents), recordDirectory: runDirectory, tabId };
@@ -205,7 +205,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
205
205
  await cleanupRunTab({
206
206
  herdr, tabId, runFile, runRecord, signal: options.signal, failureStatus: "failed",
207
207
  errorPrefix: "Task results settled, but Herdr tab cleanup failed",
208
- onError: (message) => { emit("failed", message); options.onCleanupError?.(message); },
208
+ onError: (message) => options.onCleanupError?.(message),
209
209
  });
210
210
  } catch (error) {
211
211
  const message = error instanceof Error ? error.message : String(error);
@@ -51,6 +51,14 @@ export class RunDispatcher {
51
51
  this.changed();
52
52
  return cancelled.map((run) => run.id);
53
53
  }
54
+ cancelQueuedRun(id: string, reason: Error): boolean {
55
+ const index = this.queue.findIndex((run) => run.id === id);
56
+ if (index < 0) return false;
57
+ const [cancelled] = this.queue.splice(index, 1);
58
+ cancelled.reject(reason);
59
+ this.changed();
60
+ return true;
61
+ }
54
62
 
55
63
  private changed(): void { this.onChange?.(this.snapshot()); }
56
64
 
@@ -103,7 +103,10 @@ export class FleetWidget implements Component {
103
103
  } catch { /* The next durable activity update can retry the watch. */ }
104
104
  }
105
105
  const hasRunning = this.batch.activity.some((task) => task.status === "running");
106
- if (hasRunning && !this.elapsedTimer) this.elapsedTimer = this.runtime.setInterval(() => this.tui.requestRender(), 1_000);
106
+ if (hasRunning && !this.elapsedTimer) this.elapsedTimer = this.runtime.setInterval(() => {
107
+ this.tui.requestRender();
108
+ this.scheduleMetricsRefresh(0);
109
+ }, 1_000);
107
110
  else if (!hasRunning && this.elapsedTimer) { this.runtime.clearInterval(this.elapsedTimer); this.elapsedTimer = undefined; }
108
111
  this.scheduleMetricsRefresh(0);
109
112
  }
@@ -16,8 +16,8 @@ export const HerdrSubagentsParams = Type.Object({
16
16
  }, { additionalProperties: false });
17
17
 
18
18
  export const HerdrSubagentsControlParams = Type.Object({
19
- action: StringEnum(["list", "respond", "history", "resume", "cleanup"] as const, { description: "Control operation to perform." }),
20
- runId: Type.Optional(Type.String({ minLength: 1, description: "Durable run ID used by respond, resume, or cleanup." })),
19
+ action: StringEnum(["list", "respond", "history", "resume", "cancel", "cleanup"] as const, { description: "Control operation to perform." }),
20
+ runId: Type.Optional(Type.String({ minLength: 1, description: "Durable run ID used by respond, resume, cancel, or cleanup." })),
21
21
  answer: Type.Optional(Type.String({ minLength: 1, description: "Answer sent to a blocked task when action is respond." })),
22
22
  task: Type.Optional(Type.Integer({ minimum: 1, description: "One-based task number selected for respond or resume." })),
23
23
  prompt: Type.Optional(Type.String({ minLength: 1, description: "Follow-up prompt used by resume." })),
@@ -23,7 +23,7 @@ async function closeRunTab(run: RunRecord, runFile: string, herdr: HerdrAutomati
23
23
  }
24
24
  }
25
25
 
26
- export interface CancelActiveRunsOptions { reason?: string; owner?: OwnerIdentity; }
26
+ export interface CancelActiveRunsOptions { reason?: string; owner?: OwnerIdentity; runId?: string; }
27
27
 
28
28
  export async function cancelActiveSubagentRuns(cwd: string, herdr: HerdrAutomation, options: CancelActiveRunsOptions = {}): Promise<CancellationResult> {
29
29
  const reason = options.reason ?? "Parent Pi session closed.";
@@ -39,6 +39,7 @@ export async function cancelActiveSubagentRuns(cwd: string, herdr: HerdrAutomati
39
39
  result.cleanupErrors.push(`Could not inspect Subagent run ${runFile}: ${error instanceof Error ? error.message : String(error)}`);
40
40
  continue;
41
41
  }
42
+ if (options.runId && run.id !== options.runId) continue;
42
43
  if (!isActiveStatus(run.status)) continue;
43
44
  if (options.owner?.sessionId && run.ownerSessionId !== options.owner.sessionId) continue;
44
45
  const now = new Date().toISOString();
@@ -2,6 +2,11 @@ import type { HerdrAutomation } from "./herdr.ts";
2
2
  import { readRunRecord, type RunRecord } from "./records.ts";
3
3
  import { writeJsonAtomic } from "./state.ts";
4
4
 
5
+ function isMissingTabError(error: unknown): boolean {
6
+ const detail = error instanceof Error ? `${error.message}\n${(error as { stderr?: unknown }).stderr ?? ""}` : String(error);
7
+ return /tab[_ -]?not[_ -]?found|unknown tab/i.test(detail);
8
+ }
9
+
5
10
  export async function cleanupRunTab(options: {
6
11
  herdr: HerdrAutomation;
7
12
  tabId: string;
@@ -52,17 +57,28 @@ export async function cleanupRunTab(options: {
52
57
  .then(() => options.herdr.closeTab(options.tabId, options.signal))
53
58
  .then(() => finishClosed(true))
54
59
  .catch(async (error) => {
60
+ if (isMissingTabError(error)) { await finishClosed(true); return; }
55
61
  if (!options.signal?.aborted) { await reportFailure(error, true); return; }
56
62
  try {
57
63
  await options.herdr.closeTab(options.tabId);
58
64
  await finishClosed(true);
59
- } catch (closeError) { await reportFailure(closeError, true); }
65
+ } catch (closeError) {
66
+ if (isMissingTabError(closeError)) { await finishClosed(true); return; }
67
+ await reportFailure(closeError, true);
68
+ }
60
69
  });
61
70
  return { deferred: true };
62
71
  }
63
72
  await options.herdr.closeTab(options.tabId, options.signal);
64
73
  await finishClosed();
65
74
  }
66
- catch (error) { await reportFailure(error); throw error; }
75
+ catch (error) {
76
+ if (isMissingTabError(error)) {
77
+ await finishClosed();
78
+ return { deferred: false };
79
+ }
80
+ await reportFailure(error);
81
+ throw error;
82
+ }
67
83
  return { deferred: false };
68
84
  }
@@ -1,4 +1,4 @@
1
- import type { HerdrAutomation } from "./herdr.ts";
1
+ import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
2
2
  import { loadSubagentConfiguration, type ConfigurationPaths, type SubagentConfiguration } from "./config.ts";
3
3
  import { runHerdrSubagents, type HerdrSubagentsResult } from "./runner.ts";
4
4
  import { runHerdrSubagentsBatch, type BatchResult, type BatchTask } from "./batch-runner.ts";
@@ -6,6 +6,8 @@ import { respondToBlockedTask } from "./blocking.ts";
6
6
  import { cleanSubagentRun, historyText, listSubagentHistory, resumeHistoricalTask } from "./history.ts";
7
7
  import { HERDR_ACTIONS, validateControlParams, validateRunParams } from "./validation.ts";
8
8
  import type { OwnerIdentity } from "./ownership.ts";
9
+ import { cancelActiveSubagentRuns } from "./reconcile.ts";
10
+ import { cancelQueuedRuns, loadQueuedRuns } from "./queued-runs.ts";
9
11
 
10
12
  export interface HerdrSubagentsTaskParams { name: string; prompt: string; agent?: string; model?: string; }
11
13
 
@@ -17,7 +19,7 @@ export interface HerdrSubagentsRunParams {
17
19
  }
18
20
 
19
21
  export interface HerdrSubagentsControlToolParams {
20
- action: "list" | "respond" | "history" | "resume" | "cleanup";
22
+ action: "list" | "respond" | "history" | "resume" | "cancel" | "cleanup";
21
23
  runId?: string;
22
24
  answer?: string;
23
25
  task?: number;
@@ -26,7 +28,7 @@ export interface HerdrSubagentsControlToolParams {
26
28
 
27
29
  /** Internal superset used by shared execution helpers. */
28
30
  export interface HerdrSubagentsToolParams {
29
- action?: "list" | "respond" | "history" | "resume" | "cleanup";
31
+ action?: "list" | "respond" | "history" | "resume" | "cancel" | "cleanup";
30
32
  prompt?: string;
31
33
  agent?: string;
32
34
  label?: string;
@@ -51,6 +53,7 @@ export interface HerdrSubagentsToolDependencies {
51
53
  configurationPaths?: ConfigurationPaths;
52
54
  owner?: OwnerIdentity;
53
55
  runId?: string;
56
+ cancelRun?: (runId: string) => Promise<{ cancelled: boolean; queued?: boolean; cleanupErrors?: string[] }>;
54
57
  }
55
58
 
56
59
  export type HerdrSubagentsDetails = HerdrSubagentsResult & {
@@ -125,6 +128,29 @@ const actionHandlers = {
125
128
  return errorResponse(text, { action: "cleanup", runId: params.runId, cleaned: false });
126
129
  }
127
130
  },
131
+ async cancel({ params, ctx, dependencies }: ToolExecutionContext) {
132
+ const runId = (params.runId as string).trim();
133
+ try {
134
+ let result: { cancelled: boolean; queued?: boolean; cleanupErrors?: string[] };
135
+ if (dependencies.cancelRun) result = await dependencies.cancelRun(runId);
136
+ else {
137
+ const reason = "Subagent run cancelled by user.";
138
+ const queued = (await loadQueuedRuns(ctx.cwd)).some((run) => run.runId === runId);
139
+ if (queued) {
140
+ await cancelQueuedRuns(ctx.cwd, [runId], { reason, owner: dependencies.owner });
141
+ result = { cancelled: true, queued: true };
142
+ } else {
143
+ const cancellation = await cancelActiveSubagentRuns(ctx.cwd, dependencies.herdr ?? new CliHerdrAutomation(), { reason, owner: dependencies.owner, runId });
144
+ result = { cancelled: cancellation.cancelledRuns > 0, cleanupErrors: cancellation.cleanupErrors };
145
+ }
146
+ }
147
+ const suffix = result.cleanupErrors?.length ? ` Cleanup warnings: ${result.cleanupErrors.join("; ")}` : "";
148
+ const text = result.cancelled ? `Cancelled Subagent run ${runId}.${suffix}` : `Subagent run '${runId}' is not active or queued.`;
149
+ return { content: [{ type: "text", text }], details: { action: "cancel", runId, ...result }, ...(result.cancelled ? {} : { isError: true }) };
150
+ } catch (error) {
151
+ return errorResponse(error instanceof Error ? error.message : String(error), { action: "cancel", runId, cancelled: false });
152
+ }
153
+ },
128
154
  async resume({ params, signal, onUpdate, ctx, dependencies, discovery }: ToolExecutionContext) {
129
155
  try {
130
156
  const result = await resumeHistoricalTask(ctx.cwd, params.runId as string, params.task as number, (params.prompt as string).trim(), {
@@ -3,6 +3,7 @@ import type { HerdrSubagentsControlToolParams, HerdrSubagentsRunParams } from ".
3
3
  export const HERDR_ACTIONS = {
4
4
  list: { allowed: ["action"], includeRunId: false },
5
5
  history: { allowed: ["action"], includeRunId: false },
6
+ cancel: { allowed: ["action", "runId"], includeRunId: true },
6
7
  cleanup: { allowed: ["action", "runId"], includeRunId: true },
7
8
  respond: { allowed: ["action", "runId", "answer", "task"], includeRunId: true },
8
9
  resume: { allowed: ["action", "runId", "task", "prompt"], includeRunId: true },
@@ -18,7 +19,7 @@ export function validateControlParams(params: HerdrSubagentsControlToolParams):
18
19
  if (!allowed) return "Provide a valid control action.";
19
20
  const extra = Object.keys(params).find((key) => !allowed.includes(key as keyof HerdrSubagentsControlToolParams));
20
21
  if (extra) return `Field '${extra}' is not valid with action '${params.action}'.`;
21
- if (params.action === "cleanup" && !params.runId?.trim()) return "runId is required for action 'cleanup'.";
22
+ if ((params.action === "cancel" || params.action === "cleanup") && !params.runId?.trim()) return `runId is required for action '${params.action}'.`;
22
23
  if (params.action === "respond" && (!params.runId?.trim() || !params.answer?.trim())) return "runId and answer are required for action 'respond'.";
23
24
  if (params.action === "resume" && (!params.runId?.trim() || !params.task || !params.prompt?.trim())) return "runId, task, and prompt are required for action 'resume'.";
24
25
  return undefined;
@@ -103,9 +103,23 @@ async function verifyPublicUiWiring() {
103
103
  await background.tool.execute("second", { background: true, tasks: [{ name: "queued task", prompt: "two" }] }, undefined, undefined, { cwd, ui: background.ui });
104
104
  if (background.widgetCalls !== activeWidgetCalls || !background.renderedLines.some((line) => line.includes("active task"))) throw new Error("queued background run replaced the active Fleet widget");
105
105
  releaseFirst?.({ content: [{ type: "text", text: "done" }], details: { ...runningDetails, status: "completed" } });
106
- await new Promise((resolve) => setTimeout(resolve, 10));
106
+ await new Promise((resolve) => setTimeout(resolve, 10));
107
+
108
+ let cancelledSignal: AbortSignal | undefined;
109
+ const cancellable = harness(async (params, signal, _onUpdate, _ctx, dependencies) => {
110
+ if (params.action === "cancel") {
111
+ const result = await dependencies.cancelRun(params.runId);
112
+ return { content: [{ type: "text", text: "cancelled" }], details: { action: "cancel", runId: params.runId, ...result } };
113
+ }
114
+ cancelledSignal = signal;
115
+ return await new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true }));
116
+ });
117
+ await startHarness(cancellable, cwd);
118
+ const cancellableRun = await cancellable.tool?.execute("cancellable", { background: true, tasks: [{ name: "cancel me", prompt: "wait" }] }, undefined, undefined, { cwd, ui: cancellable.ui });
119
+ const cancelled = await cancellable.controlTool?.execute("cancel", { action: "cancel", runId: cancellableRun.details.runId }, undefined, undefined, { cwd, ui: cancellable.ui });
120
+ if (!cancelledSignal?.aborted || cancelled?.details?.cancelled !== true) throw new Error("cancel control did not abort the selected live background run");
107
121
 
108
- const rejectedCwd = join(cwd, "rejected-background");
122
+ const rejectedCwd = join(cwd, "rejected-background");
109
123
  mkdirSync(rejectedCwd, { recursive: true });
110
124
  const rejectedBackground = harness(async () => ({ content: [{ type: "text", text: "Unknown persona" }], details: { errorMessage: "Unknown persona" }, isError: true }));
111
125
  await startHarness(rejectedBackground, rejectedCwd);
@@ -140,17 +140,20 @@ function latestRunDirectory() {
140
140
  try {
141
141
  check("run validation accepts one or more tasks", validateRunParams({ tasks: [{ name: "one", prompt: "do it" }], concurrency: 2, background: true }) === undefined);
142
142
  check("control validation accepts supported calls", [
143
- { action: "list" }, { action: "history" }, { action: "cleanup", runId: "run" },
143
+ { action: "list" }, { action: "history" }, { action: "cancel", runId: "run" }, { action: "cleanup", runId: "run" },
144
144
  { action: "respond", runId: "run", answer: "yes", task: 1 },
145
145
  { action: "resume", runId: "run", task: 1, prompt: "continue" },
146
146
  ].every((params) => validateControlParams(params) === undefined));
147
- check("all supported actions have shared validation and rendering metadata", ["list", "history", "cleanup", "respond", "resume"].every((action) => action in HERDR_ACTIONS));
147
+ check("all supported actions have shared validation and rendering metadata", ["list", "history", "cancel", "cleanup", "respond", "resume"].every((action) => action in HERDR_ACTIONS));
148
148
  check("split validation rejects missing tasks and invalid control fields", [
149
149
  validateRunParams({ tasks: [] }),
150
150
  validateControlParams({ action: "list", prompt: "not allowed" }),
151
151
  validateControlParams({ action: "respond", runId: "run" }),
152
152
  validateControlParams({ action: "resume", runId: "run", task: 1 }),
153
153
  ].every((message) => typeof message === "string" && message.length > 0));
154
+ let cancelledRunId;
155
+ const cancelControl = await executeHerdrSubagents({ action: "cancel", runId: "active-run" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), cancelRun: async (runId) => { cancelledRunId = runId; return { cancelled: true }; } });
156
+ check("cancel control targets the selected active or queued run", !cancelControl.isError && cancelledRunId === "active-run" && cancelControl.details.cancelled === true);
154
157
  const fleet = new FleetSelection();
155
158
  const fleetBatch = { label: "reviews", activity: [
156
159
  { index: 0, name: "one", status: "completed", paneId: "p1" },
@@ -239,6 +242,12 @@ try {
239
242
  runtimeTimeouts.clear();
240
243
  await new Promise((resolve) => setTimeout(resolve, 0));
241
244
  check("Fleet widget refreshes token usage from file events", liveWidget.render(80)[1].includes("84 tok"));
245
+ liveTokens = 126;
246
+ for (const callback of [...runtimeIntervals.values()]) callback();
247
+ for (const callback of [...runtimeTimeouts.values()]) callback();
248
+ runtimeTimeouts.clear();
249
+ await new Promise((resolve) => setTimeout(resolve, 0));
250
+ check("Fleet widget refreshes token usage on every running one-second tick", liveWidget.render(80)[1].includes("126 tok"));
242
251
  liveWidget.update({ ...liveWidgetBatch, status: "blocked", activity: [{ ...liveWidgetBatch.activity[0], status: "blocked", updatedAt: "2026-10-08T07:00:05.000Z", activeStartedAt: undefined, elapsedMs: 5_000 }] });
243
252
  check("Fleet widget stops elapsed repainting when work blocks", runtimeIntervals.size === 0 && clearedIntervals === 1);
244
253
  liveWidget.dispose();
@@ -430,14 +439,20 @@ try {
430
439
  check("batch is FIFO with a dedicated pane per task", batchPrompts.join(",") === "one slow,two,three FAIL,four" && new Set(batchStarts.map((request) => request.paneId)).size === 4);
431
440
  check("failed sibling does not cancel batch", batch.isError && batchDetails.status === "partial" && batchDetails.tasks.map((task) => task.name).join(",") === "one,two,three,four" && batchDetails.tasks[2].status === "failed" && batchDetails.tasks[3].status === "completed");
432
441
  const batchCleanupHerdr = fakeHerdr("close-failed");
433
- const batchCleanup = await executeHerdrSubagents({ tasks: [{ name: "cleanup", prompt: "cleanup", agent: "explorer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: batchCleanupHerdr });
442
+ const batchCleanupUpdates = [];
443
+ const batchCleanup = await executeHerdrSubagents({ tasks: [{ name: "cleanup", prompt: "cleanup", agent: "explorer" }] }, undefined, (update) => batchCleanupUpdates.push(update), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: batchCleanupHerdr });
434
444
  const batchCleanupRecord = JSON.parse(readFileSync(join(batchCleanup.details.recordDirectory, "run.json"), "utf8"));
435
- check("batch cleanup failure is persisted and surfaced", batchCleanup.isError && batchCleanup.content[0].text.includes("cleanup failed") && batchCleanupRecord.status === "failed" && batchCleanupRecord.cleanupError.includes("tab still busy"));
445
+ check("batch cleanup failure is persisted and surfaced once", batchCleanup.isError && batchCleanup.content[0].text.includes("cleanup failed") && batchCleanupRecord.status === "failed" && batchCleanupRecord.cleanupError.includes("tab still busy") && batchCleanupUpdates.filter((update) => update.content[0]?.text.includes("cleanup failed")).length === 1);
436
446
  const cleanupProbeHerdr = fakeHerdr();
437
447
  cleanupProbeHerdr.isTabFocused = async () => { throw new Error("tab lookup failed"); };
438
448
  const cleanupProbe = await executeHerdrSubagents({ tasks: [{ name: "cleanup-probe", prompt: "cleanup probe", agent: "explorer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: cleanupProbeHerdr });
439
449
  const cleanupProbeRecord = JSON.parse(readFileSync(join(cleanupProbe.details.recordDirectory, "run.json"), "utf8"));
440
450
  check("cleanup focus-probe failure is persisted and surfaced", cleanupProbe.isError && cleanupProbe.content[0].text.includes("tab lookup failed") && cleanupProbeRecord.status === "failed" && cleanupProbeRecord.cleanupError === "tab lookup failed");
451
+ const missingTabHerdr = fakeHerdr();
452
+ missingTabHerdr.isTabFocused = async () => { throw new Error('{"error":{"code":"tab_not_found","message":"tab w1:t2 not found"}}'); };
453
+ const missingTabCleanup = await executeHerdrSubagents({ tasks: [{ name: "already-closed", prompt: "already closed", agent: "explorer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: missingTabHerdr });
454
+ const missingTabRecord = JSON.parse(readFileSync(join(missingTabCleanup.details.recordDirectory, "run.json"), "utf8"));
455
+ check("already-missing tab is successful idempotent cleanup", !missingTabCleanup.isError && missingTabRecord.status === "completed" && !missingTabRecord.cleanupPendingTabIds && !missingTabRecord.cleanupError);
441
456
  const activeUpdate = batchUpdates.find((update) => update.activity?.some((task) => task.status === "running"));
442
457
  const counts = activityCounts(activeUpdate);
443
458
  const focusHerdr = fakeHerdr();
@@ -551,6 +566,13 @@ try {
551
566
  check("dispatcher starts next queued run automatically", starts.join(",") === "run-one,run-two" && dispatcher.snapshot().activeRunId === "run-two");
552
567
  releaseSecond();
553
568
  check("dispatcher settles queued run", await second === "two");
569
+ const cancellingDispatcher = new RunDispatcher();
570
+ cancellingDispatcher.pause();
571
+ const removedQueued = cancellingDispatcher.submit("remove-me", async () => "unexpected").catch((error) => error.message);
572
+ const keptQueued = cancellingDispatcher.submit("keep-me", async () => "kept");
573
+ check("dispatcher cancels only the selected queued run", cancellingDispatcher.cancelQueuedRun("remove-me", new Error("cancelled by user")) && await removedQueued === "cancelled by user" && cancellingDispatcher.snapshot().queuedRunIds.join(",") === "keep-me");
574
+ cancellingDispatcher.resume();
575
+ check("dispatcher still executes uncancelled queued siblings", await keptQueued === "kept");
554
576
  const retainedDispatcher = new RunDispatcher();
555
577
  const retainedStarts = [];
556
578
  const retainedFirst = retainedDispatcher.submit("blocked-run", async () => { retainedStarts.push("blocked-run"); retainedDispatcher.retain("blocked-run"); return "blocked"; });