@maxiaochao/pi-toolkit 0.10.3 → 0.10.5

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,10 @@
1
+ ## 2026-10-08 - v0.10.5
2
+
3
+ - bundle herdr-subagents v0.4.2: add Alt+P and /parent controls to return from child Agents
4
+ ## 2026-10-08 - v0.10.4
5
+
6
+ - bundle herdr-subagents v0.4.1: targeted cancellation, live token polling, and idempotent tab cleanup
7
+
1
8
  ## 2026-10-08 - v0.10.3
2
9
 
3
10
  - 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.5",
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,15 @@
1
+ ## 2026-10-08 - v0.4.2
2
+
3
+ - add Alt+P and /parent controls to return from child Agents
4
+ - Add `/parent` and `Alt+P` inside child Agents to return focus to the exact parent Agent pane, with a visible child footer hint.
5
+
6
+ ## 2026-10-08 - v0.4.1
7
+
8
+ - targeted cancellation, live token polling, and idempotent tab cleanup
9
+ - Add `cancel` control for one active or queued run while leaving unrelated runs untouched.
10
+ - Refresh live Fleet token totals every second as well as on file events.
11
+ - Treat already-missing tabs as successful cleanup and emit one warning per real cleanup failure.
12
+
1
13
  ## 2026-10-08 - v0.4.0
2
14
 
3
15
  - add the live compact Fleet footer with authoritative metrics and refreshed media
@@ -72,9 +72,11 @@ 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
76
 
77
- 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.
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.
78
+
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. Inside every child Agent, press `Alt+P` or run `/parent` to return to the exact parent Agent pane; the shortcut is also shown in the child footer.
78
80
 
79
81
  ## History
80
82
 
@@ -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
 
@@ -16,12 +16,12 @@
16
16
  - Force write-capable batches to serial execution while allowing bounded read-only parallelism.
17
17
  - Preserve compact reports, full results, Pi sessions, blocked questions, and historical follow-ups.
18
18
  - Recover queued/running work after interruption without retrying post-prompt side effects, including truncated reports and recovery errors.
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.
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, child `/parent` and `Alt+P` return controls, 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 169 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);
@@ -4,6 +4,21 @@ import { StringEnum } from "@earendil-works/pi-ai";
4
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
  import { Type } from "typebox";
6
6
  import { resultPathForAttempt } from "../src/result-path.ts";
7
+ import { PARENT_PANE_ENV } from "../src/live-agent.ts";
8
+
9
+ const RETURN_SHORTCUT = "alt+p";
10
+
11
+ async function returnToParent(pi: ExtensionAPI, ctx: { ui: { notify(message: string, type?: "info" | "warning" | "error"): void } }): Promise<void> {
12
+ const parentPaneId = process.env[PARENT_PANE_ENV]?.trim();
13
+ if (!parentPaneId) {
14
+ ctx.ui.notify("Parent Agent pane is unavailable for this Subagent session.", "error");
15
+ return;
16
+ }
17
+ const result = await pi.exec("herdr", ["agent", "focus", parentPaneId]);
18
+ if (result.code !== 0) {
19
+ ctx.ui.notify(`Could not return to the parent Agent: ${result.stderr.trim() || result.stdout.trim() || `Herdr exited ${result.code}`}`, "error");
20
+ }
21
+ }
7
22
 
8
23
  const ReportParams = Type.Object({
9
24
  status: StringEnum(["completed", "needs-input", "failed"] as const, { description: "Current outcome for this task." }),
@@ -24,6 +39,17 @@ function writeAtomic(path: string, content: string): void {
24
39
  }
25
40
 
26
41
  export default function (pi: ExtensionAPI) {
42
+ pi.registerCommand("parent", {
43
+ description: "Return focus to the parent Agent pane",
44
+ async handler(_args, ctx) { await returnToParent(pi, ctx); },
45
+ });
46
+ pi.registerShortcut(RETURN_SHORTCUT, {
47
+ description: "Return to parent Agent",
48
+ async handler(ctx) { await returnToParent(pi, ctx); },
49
+ });
50
+ pi.on("session_start", async (_event, ctx) => {
51
+ if (process.env[PARENT_PANE_ENV]?.trim()) ctx.ui.setStatus("herdr-subagents-parent", `${RETURN_SHORTCUT} parent`);
52
+ });
27
53
  pi.registerTool({
28
54
  name: "subagent_report",
29
55
  label: "Subagent Report",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-herdr-subagents",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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
  }
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import { appendFile, mkdir, readFile, rm, rmdir } from "node:fs/promises";
3
3
  import { isAbsolute, join } from "node:path";
4
4
  import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
5
- import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
5
+ import { childEnvironment, liveAgentName, promptLiveAgent } from "./live-agent.ts";
6
6
  import { writeJsonAtomic, writeTextAtomic } from "./state.ts";
7
7
  import { cleanupRunTab } from "./tab-cleanup.ts";
8
8
  import type { OwnerIdentity } from "./ownership.ts";
@@ -144,7 +144,7 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
144
144
  let report: PersistedReport;
145
145
  let status: "blocked" | "completed" | "failed";
146
146
  try {
147
- const env = { PI_HERDR_SUBAGENTS_CHILD: "1", PI_HERDR_SUBAGENTS_TASK_DIR: historical.recordDirectory };
147
+ const env = childEnvironment(historical.recordDirectory);
148
148
  tab = await herdr.createTab({ workspaceId: process.env.HERDR_WORKSPACE_ID ?? "", cwd, label: `SA · ${run.label}`, env, focus: false, signal: options.signal });
149
149
  const paneLabel = `${String(historical.order).padStart(2, "0")} · ${historical.name}`;
150
150
  await herdr.renamePane(tab.paneId, paneLabel, options.signal);
@@ -17,6 +17,17 @@ export interface LiveTaskRecord {
17
17
  skills?: string[];
18
18
  }
19
19
 
20
+ export const PARENT_PANE_ENV = "PI_HERDR_SUBAGENTS_PARENT_PANE_ID";
21
+
22
+ export function childEnvironment(taskDirectory: string, environment: NodeJS.ProcessEnv = process.env): Record<string, string> {
23
+ const parentPaneId = environment.HERDR_PANE_ID?.trim();
24
+ return {
25
+ PI_HERDR_SUBAGENTS_CHILD: "1",
26
+ PI_HERDR_SUBAGENTS_TASK_DIR: taskDirectory,
27
+ ...(parentPaneId ? { [PARENT_PANE_ENV]: parentPaneId } : {}),
28
+ };
29
+ }
30
+
20
31
  export function childPiArgs(task: LiveTaskRecord, systemPromptFile: string): string[] {
21
32
  const args = [
22
33
  "--approve",
@@ -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();
@@ -3,6 +3,7 @@ import { writeJsonAtomic } from "./state.ts";
3
3
  import type { RunRecord } from "./records.ts";
4
4
  import { applyRunStatus } from "./run-status.ts";
5
5
  import { cleanupRunTab } from "./tab-cleanup.ts";
6
+ import { childEnvironment } from "./live-agent.ts";
6
7
 
7
8
  interface RunPaneAllocatorOptions {
8
9
  herdr: HerdrAutomation;
@@ -31,7 +32,7 @@ export class RunPaneAllocator {
31
32
  let paneId = "";
32
33
  this.allocation = this.allocation.then(async () => {
33
34
  const { herdr, run, runFile, signal } = this.options;
34
- const env = { PI_HERDR_SUBAGENTS_CHILD: "1", PI_HERDR_SUBAGENTS_TASK_DIR: taskDirectory };
35
+ const env = childEnvironment(taskDirectory);
35
36
  let anchorExists = Boolean(run.tabId && this.lastPaneId);
36
37
  if (anchorExists && this.options.validateExistingPane) {
37
38
  const survivingPaneIds: string[] = [];
@@ -3,7 +3,7 @@ import { mkdir, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
5
5
  import { ensureRuntimeIgnored } from "./history.ts";
6
- import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
6
+ import { childEnvironment, liveAgentName, promptLiveAgent } from "./live-agent.ts";
7
7
  import { writeJsonAtomic } from "./state.ts";
8
8
  import { cleanupRunTab } from "./tab-cleanup.ts";
9
9
  import type { OwnerIdentity } from "./ownership.ts";
@@ -13,9 +13,6 @@ import { reportProtocolPrompt } from "./protocol.ts";
13
13
  import { applyRunStatus } from "./run-status.ts";
14
14
  import { isAbortError } from "./errors.ts";
15
15
 
16
- const CHILD_ENV = "PI_HERDR_SUBAGENTS_CHILD";
17
- const TASK_DIRECTORY_ENV = "PI_HERDR_SUBAGENTS_TASK_DIR";
18
-
19
16
  export type HerdrSubagentsDocument = PersistedDocument;
20
17
  export type HerdrSubagentsReport = PersistedReport;
21
18
 
@@ -168,10 +165,7 @@ export async function runHerdrSubagents(prompt: string, options: HerdrSubagentsO
168
165
  workspaceId: process.env.HERDR_WORKSPACE_ID,
169
166
  cwd,
170
167
  label: `SA · ${label}`,
171
- env: {
172
- [CHILD_ENV]: "1",
173
- [TASK_DIRECTORY_ENV]: taskDirectory,
174
- },
168
+ env: childEnvironment(taskDirectory),
175
169
  focus: false,
176
170
  signal: options.signal,
177
171
  });
@@ -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);
@@ -23,6 +23,7 @@ import { applyTaskStatus, isSettledRunStatus, projectTaskActivity, readTaskRecor
23
23
  import { RunPaneAllocator } from "../src/run-pane-allocator.ts";
24
24
  import { aggregateTaskStatus } from "../src/run-status.ts";
25
25
  import { isAbortError } from "../src/errors.ts";
26
+ import registerSubagentReport from "../extensions/subagent-report.ts";
26
27
 
27
28
  let passed = 0;
28
29
  let failed = 0;
@@ -140,17 +141,20 @@ function latestRunDirectory() {
140
141
  try {
141
142
  check("run validation accepts one or more tasks", validateRunParams({ tasks: [{ name: "one", prompt: "do it" }], concurrency: 2, background: true }) === undefined);
142
143
  check("control validation accepts supported calls", [
143
- { action: "list" }, { action: "history" }, { action: "cleanup", runId: "run" },
144
+ { action: "list" }, { action: "history" }, { action: "cancel", runId: "run" }, { action: "cleanup", runId: "run" },
144
145
  { action: "respond", runId: "run", answer: "yes", task: 1 },
145
146
  { action: "resume", runId: "run", task: 1, prompt: "continue" },
146
147
  ].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));
148
+ check("all supported actions have shared validation and rendering metadata", ["list", "history", "cancel", "cleanup", "respond", "resume"].every((action) => action in HERDR_ACTIONS));
148
149
  check("split validation rejects missing tasks and invalid control fields", [
149
150
  validateRunParams({ tasks: [] }),
150
151
  validateControlParams({ action: "list", prompt: "not allowed" }),
151
152
  validateControlParams({ action: "respond", runId: "run" }),
152
153
  validateControlParams({ action: "resume", runId: "run", task: 1 }),
153
154
  ].every((message) => typeof message === "string" && message.length > 0));
155
+ let cancelledRunId;
156
+ 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 }; } });
157
+ check("cancel control targets the selected active or queued run", !cancelControl.isError && cancelledRunId === "active-run" && cancelControl.details.cancelled === true);
154
158
  const fleet = new FleetSelection();
155
159
  const fleetBatch = { label: "reviews", activity: [
156
160
  { index: 0, name: "one", status: "completed", paneId: "p1" },
@@ -239,6 +243,12 @@ try {
239
243
  runtimeTimeouts.clear();
240
244
  await new Promise((resolve) => setTimeout(resolve, 0));
241
245
  check("Fleet widget refreshes token usage from file events", liveWidget.render(80)[1].includes("84 tok"));
246
+ liveTokens = 126;
247
+ for (const callback of [...runtimeIntervals.values()]) callback();
248
+ for (const callback of [...runtimeTimeouts.values()]) callback();
249
+ runtimeTimeouts.clear();
250
+ await new Promise((resolve) => setTimeout(resolve, 0));
251
+ check("Fleet widget refreshes token usage on every running one-second tick", liveWidget.render(80)[1].includes("126 tok"));
242
252
  liveWidget.update({ ...liveWidgetBatch, status: "blocked", activity: [{ ...liveWidgetBatch.activity[0], status: "blocked", updatedAt: "2026-10-08T07:00:05.000Z", activeStartedAt: undefined, elapsedMs: 5_000 }] });
243
253
  check("Fleet widget stops elapsed repainting when work blocks", runtimeIntervals.size === 0 && clearedIntervals === 1);
244
254
  liveWidget.dispose();
@@ -304,7 +314,25 @@ try {
304
314
  const outside = await runHerdrSubagents("hello", { cwd: root, herdr: unavailableHerdr });
305
315
  check("Herdr is required", !outside.ok && outside.errorMessage?.includes("inside Herdr") && unavailableHerdr.calls.length === 0);
306
316
 
307
- setEnv({ HERDR_ENV: "1", HERDR_WORKSPACE_ID: "w1", PI_HERDR_SUBAGENTS_PI_BINARY: "/definitely/missing/pi" });
317
+ setEnv({ HERDR_ENV: "1", HERDR_WORKSPACE_ID: "w1", HERDR_PANE_ID: "w1:p-parent", PI_HERDR_SUBAGENTS_PI_BINARY: "/definitely/missing/pi" });
318
+ const childCommands = new Map();
319
+ const childShortcuts = new Map();
320
+ const childEvents = new Map();
321
+ const childExecCalls = [];
322
+ const childStatuses = [];
323
+ registerSubagentReport({
324
+ registerTool() {},
325
+ registerCommand(name, options) { childCommands.set(name, options); },
326
+ registerShortcut(key, options) { childShortcuts.set(key, options); },
327
+ on(name, handler) { childEvents.set(name, handler); },
328
+ async exec(command, args) { childExecCalls.push([command, args]); return { stdout: "", stderr: "", code: 0, killed: false }; },
329
+ });
330
+ process.env.PI_HERDR_SUBAGENTS_PARENT_PANE_ID = "w1:p-parent";
331
+ const childContext = { ui: { notify() {}, setStatus(key, value) { childStatuses.push([key, value]); } } };
332
+ await childEvents.get("session_start")?.({}, childContext);
333
+ await childCommands.get("parent")?.handler("", childContext);
334
+ await childShortcuts.get("alt+p")?.handler(childContext);
335
+ check("child command and shortcut return to the exact parent Agent pane", childExecCalls.length === 2 && childExecCalls.every(([command, args]) => command === "herdr" && args.join(",") === "agent,focus,w1:p-parent") && childStatuses.some(([, value]) => value.includes("alt+p")));
308
336
  const herdr = fakeHerdr();
309
337
  const success = await runHerdrSubagents("Review authentication", {
310
338
  cwd: root,
@@ -321,7 +349,7 @@ try {
321
349
  const startRequest = herdr.calls.find(([name]) => name === "startAgent")[1];
322
350
  check("named unfocused tab in parent workspace", createRequest.workspaceId === "w1" && createRequest.cwd === root && createRequest.label === "SA · auth-review" && createRequest.focus === false);
323
351
  check("ordinary execution never steals pane focus", !herdr.calls.some(([name]) => name === "focusPane"));
324
- check("child environment points at durable task", createRequest.env.PI_HERDR_SUBAGENTS_CHILD === "1" && createRequest.env.PI_HERDR_SUBAGENTS_TASK_DIR.includes(join(".pi", "herdr-subagents", "runs")));
352
+ check("child environment points at durable task and parent pane", createRequest.env.PI_HERDR_SUBAGENTS_CHILD === "1" && createRequest.env.PI_HERDR_SUBAGENTS_TASK_DIR.includes(join(".pi", "herdr-subagents", "runs")) && createRequest.env.PI_HERDR_SUBAGENTS_PARENT_PANE_ID === "w1:p-parent");
325
353
  check("persistent isolated child Pi", startRequest.kind === "pi" && startRequest.paneId === "w1:p2" && startRequest.args.includes("--session") && startRequest.args.includes("--no-extensions") && startRequest.args.includes("--extension") && startRequest.args.includes("--no-skills") && startRequest.args.includes("--model") && startRequest.args.includes("fake/model") && startRequest.args.includes("--tools") && startRequest.args.includes("read,grep,subagent_report"));
326
354
  const promptRequest = herdr.calls.find(([name]) => name === "promptAgent")[1];
327
355
  check("prompt submitted through Herdr", promptRequest.prompt === "Review authentication" && promptRequest.target === startRequest.name);
@@ -430,14 +458,20 @@ try {
430
458
  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
459
  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
460
  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 });
461
+ const batchCleanupUpdates = [];
462
+ 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
463
  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"));
464
+ 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
465
  const cleanupProbeHerdr = fakeHerdr();
437
466
  cleanupProbeHerdr.isTabFocused = async () => { throw new Error("tab lookup failed"); };
438
467
  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
468
  const cleanupProbeRecord = JSON.parse(readFileSync(join(cleanupProbe.details.recordDirectory, "run.json"), "utf8"));
440
469
  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");
470
+ const missingTabHerdr = fakeHerdr();
471
+ missingTabHerdr.isTabFocused = async () => { throw new Error('{"error":{"code":"tab_not_found","message":"tab w1:t2 not found"}}'); };
472
+ 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 });
473
+ const missingTabRecord = JSON.parse(readFileSync(join(missingTabCleanup.details.recordDirectory, "run.json"), "utf8"));
474
+ check("already-missing tab is successful idempotent cleanup", !missingTabCleanup.isError && missingTabRecord.status === "completed" && !missingTabRecord.cleanupPendingTabIds && !missingTabRecord.cleanupError);
441
475
  const activeUpdate = batchUpdates.find((update) => update.activity?.some((task) => task.status === "running"));
442
476
  const counts = activityCounts(activeUpdate);
443
477
  const focusHerdr = fakeHerdr();
@@ -551,6 +585,13 @@ try {
551
585
  check("dispatcher starts next queued run automatically", starts.join(",") === "run-one,run-two" && dispatcher.snapshot().activeRunId === "run-two");
552
586
  releaseSecond();
553
587
  check("dispatcher settles queued run", await second === "two");
588
+ const cancellingDispatcher = new RunDispatcher();
589
+ cancellingDispatcher.pause();
590
+ const removedQueued = cancellingDispatcher.submit("remove-me", async () => "unexpected").catch((error) => error.message);
591
+ const keptQueued = cancellingDispatcher.submit("keep-me", async () => "kept");
592
+ 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");
593
+ cancellingDispatcher.resume();
594
+ check("dispatcher still executes uncancelled queued siblings", await keptQueued === "kept");
554
595
  const retainedDispatcher = new RunDispatcher();
555
596
  const retainedStarts = [];
556
597
  const retainedFirst = retainedDispatcher.submit("blocked-run", async () => { retainedStarts.push("blocked-run"); retainedDispatcher.retain("blocked-run"); return "blocked"; });