@maxiaochao/pi-toolkit 0.10.2 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -1
  3. package/package.json +2 -2
  4. package/packages/herdr-subagents/CHANGELOG.md +15 -0
  5. package/packages/herdr-subagents/README.md +9 -6
  6. package/packages/herdr-subagents/TESTING.md +6 -4
  7. package/packages/herdr-subagents/docs/demo.webm +0 -0
  8. package/packages/herdr-subagents/docs/screenshot.png +0 -0
  9. package/packages/herdr-subagents/extensions/herdr-subagents.ts +40 -6
  10. package/packages/herdr-subagents/package.json +1 -1
  11. package/packages/herdr-subagents/src/batch-runner.ts +7 -7
  12. package/packages/herdr-subagents/src/blocking.ts +4 -4
  13. package/packages/herdr-subagents/src/dispatcher.ts +8 -0
  14. package/packages/herdr-subagents/src/fleet-controller.ts +167 -16
  15. package/packages/herdr-subagents/src/fleet.ts +119 -8
  16. package/packages/herdr-subagents/src/history.ts +11 -5
  17. package/packages/herdr-subagents/src/parameters.ts +2 -2
  18. package/packages/herdr-subagents/src/reconcile.ts +2 -1
  19. package/packages/herdr-subagents/src/records.ts +53 -1
  20. package/packages/herdr-subagents/src/tab-cleanup.ts +18 -2
  21. package/packages/herdr-subagents/src/tool.ts +30 -3
  22. package/packages/herdr-subagents/src/validation.ts +2 -1
  23. package/packages/herdr-subagents/tests/extension-contract.ts +16 -2
  24. package/packages/herdr-subagents/tests/run-tests.mjs +169 -8
  25. package/packages/todo/CHANGELOG.md +4 -0
  26. package/packages/todo/README.md +19 -9
  27. package/packages/todo/TESTING.md +9 -7
  28. package/packages/todo/docs/demo.gif +0 -0
  29. package/packages/todo/docs/screenshot.png +0 -0
  30. package/packages/todo/extensions/todo.ts +120 -90
  31. package/packages/todo/package.json +2 -2
  32. package/packages/todo/src/todo-state.ts +48 -49
  33. package/packages/todo/tests/todo-extension.mjs +134 -0
  34. package/packages/todo/tests/todo-state.mjs +80 -70
@@ -1,4 +1,8 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1
4
  import type { BatchResult } from "./batch-runner.ts";
5
+ import type { TaskActivity } from "./records.ts";
2
6
 
3
7
  type FleetKey = "up" | "down" | "enter" | "escape";
4
8
  type KeyMatcher = (data: string, key: FleetKey) => boolean;
@@ -7,6 +11,8 @@ const legacyKeys: Record<FleetKey, string> = { up: "\u001b[A", down: "\u001b[B",
7
11
  const legacyMatchesKey: KeyMatcher = (data, key) => data === legacyKeys[key];
8
12
 
9
13
  export interface FleetInputResult { consume?: boolean; focusTask?: number; changed?: boolean; }
14
+ export interface FleetTaskMetrics { tokens?: number; }
15
+ export type FleetMetrics = ReadonlyMap<number, FleetTaskMetrics>;
10
16
 
11
17
  export function fleetEditorHasFocus(focused: unknown, isEditor: (value: unknown) => boolean): boolean {
12
18
  return isEditor(focused);
@@ -25,6 +31,7 @@ export class FleetSelection {
25
31
 
26
32
  handle(data: string, editorText: string, taskCount: number): FleetInputResult {
27
33
  if (taskCount === 0) { this.reset(); return {}; }
34
+ this.selected = this.selectedIndex(taskCount);
28
35
  if (!this.selecting) {
29
36
  if (!this.matchesKey(data, "down") || editorText.length !== 0) return {};
30
37
  this.selecting = true;
@@ -36,21 +43,125 @@ export class FleetSelection {
36
43
  this.selected = (this.selected + delta + taskCount) % taskCount;
37
44
  return { consume: true, changed: true };
38
45
  }
39
- if (this.matchesKey(data, "enter")) return { consume: true, focusTask: this.selected + 1 };
46
+ if (this.matchesKey(data, "enter")) return { consume: true, focusTask: this.selectedIndex(taskCount) + 1 };
40
47
  if (this.matchesKey(data, "escape")) { this.selecting = false; return { consume: true, changed: true }; }
41
48
  this.selecting = false;
42
49
  return { changed: true };
43
50
  }
44
51
  }
45
52
 
46
- export function fleetLines(batch: Pick<BatchResult, "label" | "activity">, selection: FleetSelection): string[] {
53
+ function finiteTokenPart(value: unknown): number {
54
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
55
+ }
56
+
57
+ function usageTokens(value: unknown): number {
58
+ if (!value || typeof value !== "object" || Array.isArray(value)) return 0;
59
+ const usage = value as Record<string, unknown>;
60
+ return finiteTokenPart(usage.input) + finiteTokenPart(usage.output) + finiteTokenPart(usage.cacheRead) + finiteTokenPart(usage.cacheWrite);
61
+ }
62
+
63
+ /** Match Pi's cumulative footer semantics without trusting an incomplete JSONL tail. */
64
+ export function aggregateSessionTokens(jsonl: string): number {
65
+ let total = 0;
66
+ for (const line of jsonl.split(/\r?\n/)) {
67
+ if (!line.trim()) continue;
68
+ let entry: Record<string, unknown>;
69
+ try {
70
+ const parsed: unknown = JSON.parse(line);
71
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
72
+ entry = parsed as Record<string, unknown>;
73
+ } catch { continue; }
74
+ if (entry.type === "usage") total += usageTokens(entry.usage);
75
+ else if (entry.type === "compaction" || entry.type === "branch_summary") total += usageTokens(entry.usage);
76
+ else if (entry.type === "message") {
77
+ const message = entry.message;
78
+ if (!message || typeof message !== "object" || Array.isArray(message)) continue;
79
+ const record = message as Record<string, unknown>;
80
+ if (record.role === "assistant" || record.role === "toolResult") total += usageTokens(record.usage);
81
+ }
82
+ }
83
+ return total;
84
+ }
85
+
86
+ export async function readSessionTokens(path: string): Promise<number | undefined> {
87
+ try { return aggregateSessionTokens(await readFile(path, "utf8")); }
88
+ catch { return undefined; }
89
+ }
90
+
91
+ export function formatFleetDuration(task: TaskActivity, now = Date.now()): string | undefined {
92
+ if (!task.startedAt || task.status === "queued" || task.status === "starting") return undefined;
93
+ let elapsed = typeof task.elapsedMs === "number" && Number.isFinite(task.elapsedMs) ? Math.max(0, task.elapsedMs) : undefined;
94
+ if (task.status === "running" && task.activeStartedAt) {
95
+ const activeStarted = Date.parse(task.activeStartedAt);
96
+ if (Number.isFinite(activeStarted) && now >= activeStarted) elapsed = (elapsed ?? 0) + (now - activeStarted);
97
+ }
98
+ if (elapsed === undefined) {
99
+ const started = Date.parse(task.startedAt);
100
+ const terminal = task.completedAt ?? (task.status === "blocked" ? task.updatedAt : undefined);
101
+ const ended = terminal ? Date.parse(terminal) : now;
102
+ if (!Number.isFinite(started) || !Number.isFinite(ended) || ended < started) return undefined;
103
+ elapsed = ended - started;
104
+ }
105
+ const seconds = Math.floor(elapsed / 1000);
106
+ if (seconds < 60) return `${seconds}s`;
107
+ const minutes = Math.floor(seconds / 60);
108
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
109
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
110
+ }
111
+
112
+ export function formatFleetTokens(tokens: number): string {
113
+ if (tokens < 1_000) return `${tokens} tok`;
114
+ if (tokens < 10_000) return `${(tokens / 1_000).toFixed(1)}k tok`;
115
+ if (tokens < 1_000_000) return `${Math.round(tokens / 1_000)}k tok`;
116
+ return `${(tokens / 1_000_000).toFixed(1)}m tok`;
117
+ }
118
+
119
+ function statusSummary(activity: TaskActivity[]): string {
47
120
  const counts = new Map<string, number>();
48
- for (const task of batch.activity) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
49
- const summary = ["running", "queued", "blocked", "failed", "completed", "cancelled"]
121
+ for (const task of activity) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
122
+ return ["running", "queued", "blocked", "failed", "completed", "cancelled"]
50
123
  .filter((status) => counts.has(status)).map((status) => `${status} ${counts.get(status)}`).join(" · ");
124
+ }
125
+
126
+ function fitColumns(left: string, right: string, width: number): string {
127
+ if (width <= 0) return "";
128
+ if (!right) return truncateToWidth(left, width);
129
+ const rightWidth = visibleWidth(right);
130
+ if (rightWidth + 2 >= width) return truncateToWidth(left, width);
131
+ const fittedLeft = truncateToWidth(left, width - rightWidth - 1);
132
+ const gap = " ".repeat(Math.max(1, width - visibleWidth(fittedLeft) - rightWidth));
133
+ return truncateToWidth(fittedLeft + gap + right, width);
134
+ }
135
+
136
+ type FleetTheme = Pick<Theme, "fg" | "bold">;
137
+ const plainTheme: FleetTheme = { fg: (_color, text) => text, bold: (text) => text } as FleetTheme;
138
+
139
+ export function fleetLines(
140
+ batch: Pick<BatchResult, "label" | "activity">,
141
+ selection: FleetSelection,
142
+ options: { width?: number; now?: number; metrics?: FleetMetrics; theme?: FleetTheme } = {},
143
+ ): string[] {
144
+ const width = Math.max(1, options.width ?? 120);
145
+ const theme = options.theme ?? plainTheme;
51
146
  const selected = selection.selectedIndex(batch.activity.length);
52
- return [
53
- `Subagents · ${batch.label} · ${summary}`,
54
- ...batch.activity.map((task, index) => `${selection.isSelecting() && index === selected ? "›" : " "} ${String(task.index + 1).padStart(2, "0")} · ${task.name} ${task.status}`),
55
- ];
147
+ const title = `─ ${theme.bold("Fleet")} · ${batch.label} · ${statusSummary(batch.activity)} `;
148
+ const titleWidth = visibleWidth(title);
149
+ const lines = [truncateToWidth(title + "─".repeat(Math.max(0, width - titleWidth)), width)];
150
+ const rows = batch.activity.map((task, index) => {
151
+ const chosen = selection.isSelecting() && index === selected;
152
+ const circle = task.status === "queued" || task.status === "starting" ? "○" : "●";
153
+ const color = task.status === "completed" ? "success" : task.status === "running" ? "warning" : task.status === "failed" ? "error" : task.status === "blocked" ? "warning" : task.status === "cancelled" ? "dim" : "accent";
154
+ const marker = chosen ? theme.fg("accent", "›") : " ";
155
+ const persona = theme.fg("muted", task.agent || "worker");
156
+ const left = `${marker} ${theme.fg(color, circle)} ${persona} ${task.name} ${theme.fg("dim", task.status)}`;
157
+ const duration = formatFleetDuration(task, options.now);
158
+ const tokens = task.status === "queued" || task.status === "starting" ? undefined : options.metrics?.get(task.index)?.tokens;
159
+ const right = [duration, tokens === undefined ? undefined : formatFleetTokens(tokens)].filter(Boolean).join(" ");
160
+ return { left, right: theme.fg("dim", right) };
161
+ });
162
+ const maxLeft = Math.max(0, ...rows.map(({ left }) => visibleWidth(left)));
163
+ const maxRight = Math.max(0, ...rows.map(({ right }) => visibleWidth(right)));
164
+ const contentWidth = Math.min(width, maxLeft + (maxRight ? 2 + maxRight : 0));
165
+ for (const row of rows) lines.push(fitColumns(row.left, row.right, contentWidth));
166
+ return lines;
56
167
  }
@@ -8,7 +8,7 @@ import { cleanupRunTab } from "./tab-cleanup.ts";
8
8
  import type { OwnerIdentity } from "./ownership.ts";
9
9
  import { ownerRecord } from "./ownership.ts";
10
10
  import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
11
- import { applyReport, applyTaskStatus, isActiveStatus, readReport, readRunRecord, readTaskRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type RunStatus, type TaskRecord, type TaskStatus } from "./records.ts";
11
+ import { applyReport, applyTaskStatus, isActiveStatus, projectTaskActivity, readReport, readRunRecord, readTaskRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type RunStatus, type TaskActivity, type TaskRecord, type TaskStatus } from "./records.ts";
12
12
  import { continuationSystemPrompt } from "./protocol.ts";
13
13
  import { isAbortError } from "./errors.ts";
14
14
  import { findRunDirectory } from "./run-locator.ts";
@@ -96,9 +96,10 @@ export async function cleanSubagentRun(cwd: string, runId: string): Promise<bool
96
96
  return true;
97
97
  }
98
98
 
99
- export interface ResumeHistoricalTaskOptions { herdr?: HerdrAutomation; signal?: AbortSignal; owner?: OwnerIdentity; onCleanupError?: (message: string) => void; stalledWarningSeconds?: number; onStalled?: (message: string) => void; }
99
+ export interface ResumeHistoricalTaskOptions { herdr?: HerdrAutomation; signal?: AbortSignal; owner?: OwnerIdentity; onCleanupError?: (message: string) => void; stalledWarningSeconds?: number; onStalled?: (message: string) => void; onUpdate?: (result: ResumeHistoricalTaskResult) => void; }
100
+ export interface ResumeHistoricalTaskResult { runId: string; label: string; status: TaskStatus; summary: string; documents: PersistedDocument[]; tabId: string; paneId: string; sessionFile: string; activity: TaskActivity[]; }
100
101
 
101
- export async function resumeHistoricalTask(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions = {}): Promise<{ runId: string; status: TaskStatus; summary: string; documents: PersistedDocument[]; tabId: string; paneId: string; sessionFile: string }> {
102
+ export async function resumeHistoricalTask(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions = {}): Promise<ResumeHistoricalTaskResult> {
102
103
  const runDirectory = await findRunDirectory(join(cwd, ".pi", "herdr-subagents", "runs"), runId);
103
104
  if (!runDirectory) throw new Error(`Unknown Subagent run '${runId}'.`);
104
105
  const lockDirectory = join(runDirectory, ".resume.lock");
@@ -111,7 +112,7 @@ export async function resumeHistoricalTask(cwd: string, runId: string, taskNumbe
111
112
  finally { await rmdir(lockDirectory); }
112
113
  }
113
114
 
114
- async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions): Promise<{ runId: string; status: TaskStatus; summary: string; documents: PersistedDocument[]; tabId: string; paneId: string; sessionFile: string }> {
115
+ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions): Promise<ResumeHistoricalTaskResult> {
115
116
  const herdr = options.herdr ?? new CliHerdrAutomation();
116
117
  const { signal, onCleanupError } = options;
117
118
  const run = (await listSubagentHistory(cwd)).find((candidate) => candidate.id === runId);
@@ -158,6 +159,8 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
158
159
  writeJsonAtomic(turnFile, { attempt, prompt, status: "running", sessionFile: historical.sessionFile, tabId: tab.tabId, paneId: tab.paneId, agentName, startedAt: new Date().toISOString() }),
159
160
  writeJsonAtomic(activeRunFile, activeRunRecord),
160
161
  ]);
162
+ const runningActivity = await Promise.all(run.tasks.map(async (entry, index) => projectTaskActivity(await readTaskRecord(join(entry.recordDirectory, "task.json")), index)));
163
+ options.onUpdate?.({ runId, label: run.label, status: "running", summary: `${historical.name} is continuing.`, documents: [], tabId: tab.tabId, paneId: tab.paneId, sessionFile: historical.sessionFile, activity: runningActivity });
161
164
  report = (await promptLiveAgent({ herdr, task: { ...task, id: historical.id, sessionFile: historical.sessionFile }, paneId: tab.paneId, prompt, systemPromptFile: followupPrompt, agentName, signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => options.onStalled?.(`${historical.name} appears stalled; it is still running.`) })).report;
162
165
  status = applyReport(task, report);
163
166
  } catch (error) {
@@ -182,7 +185,8 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
182
185
  if (status !== "blocked") {
183
186
  await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError, failureStatus: "failed" });
184
187
  }
185
- return { runId, status, summary: report.summary, documents: report.documents ?? [], tabId: tab.tabId, paneId: tab.paneId, sessionFile: historical.sessionFile };
188
+ const activity = await Promise.all(run.tasks.map(async (entry, index) => projectTaskActivity(await readTaskRecord(join(entry.recordDirectory, "task.json")), index)));
189
+ return { runId, label: run.label, status, summary: report.summary, documents: report.documents ?? [], tabId: tab.tabId, paneId: tab.paneId, sessionFile: historical.sessionFile, activity };
186
190
  } catch (error) {
187
191
  const message = error instanceof Error ? error.message : String(error);
188
192
  const completedAt = new Date().toISOString();
@@ -204,6 +208,8 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
204
208
  writeJsonAtomic(turnFile, { attempt, prompt, status, summary: report.summary, documents: report.documents, error: message, sessionFile: historical.sessionFile, ...(tab ? { tabId: tab.tabId, paneId: tab.paneId } : {}), completedAt }),
205
209
  writeJsonAtomic(join(run.recordDirectory, "run.json"), runRecord),
206
210
  ]);
211
+ const activity = await Promise.all(run.tasks.map(async (entry, index) => projectTaskActivity(await readTaskRecord(join(entry.recordDirectory, "task.json")), index)));
212
+ options.onUpdate?.({ runId, label: run.label, status, summary: report.summary, documents: report.documents, tabId: tab?.tabId ?? "", paneId: tab?.paneId ?? "", sessionFile: historical.sessionFile, activity });
207
213
  if (tab) await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError, failureStatus: "failed" });
208
214
  throw error;
209
215
  }
@@ -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();
@@ -64,6 +64,8 @@ export interface TaskRecord {
64
64
  systemPrompt?: string;
65
65
  sessionFile?: string;
66
66
  startedAt?: string;
67
+ activeStartedAt?: string;
68
+ elapsedMs?: number;
67
69
  updatedAt?: string;
68
70
  completedAt?: string;
69
71
  tabId?: string;
@@ -76,6 +78,38 @@ export interface TaskRecord {
76
78
  error?: string;
77
79
  }
78
80
 
81
+ export interface TaskActivity {
82
+ index: number;
83
+ name: string;
84
+ status: TaskStatus;
85
+ agent: string;
86
+ prompt?: string;
87
+ paneId?: string;
88
+ sessionFile?: string;
89
+ startedAt?: string;
90
+ activeStartedAt?: string;
91
+ elapsedMs?: number;
92
+ completedAt?: string;
93
+ updatedAt?: string;
94
+ }
95
+
96
+ export function projectTaskActivity(task: TaskRecord, index = task.order - 1): TaskActivity {
97
+ return {
98
+ index,
99
+ name: String(task.name ?? task.agent ?? `Task ${index + 1}`),
100
+ status: task.status,
101
+ agent: String(task.agent ?? "worker"),
102
+ ...(task.prompt ? { prompt: task.prompt } : {}),
103
+ ...(task.paneId ? { paneId: task.paneId } : {}),
104
+ ...(task.sessionFile ? { sessionFile: task.sessionFile } : {}),
105
+ ...(task.startedAt ? { startedAt: task.startedAt } : {}),
106
+ ...(task.activeStartedAt ? { activeStartedAt: task.activeStartedAt } : {}),
107
+ ...(typeof task.elapsedMs === "number" ? { elapsedMs: task.elapsedMs } : {}),
108
+ ...(task.completedAt ? { completedAt: task.completedAt } : {}),
109
+ ...(task.updatedAt ? { updatedAt: task.updatedAt } : {}),
110
+ };
111
+ }
112
+
79
113
  export interface TurnRecord {
80
114
  attempt: number;
81
115
  prompt?: string;
@@ -184,7 +218,25 @@ export function applyLifecycleStatus<T extends { status: string; question?: stri
184
218
  }
185
219
 
186
220
  export function applyTaskStatus<T extends TaskRecord | TurnRecord>(record: T, status: TaskStatus, options: { question?: string; error?: string; at?: string } = {}): T {
187
- return applyLifecycleStatus(record, status, options);
221
+ const at = options.at ?? new Date().toISOString();
222
+ if ("order" in record) {
223
+ if (record.status === "running" && status !== "running") {
224
+ const activeStarted = Date.parse(record.activeStartedAt ?? record.startedAt ?? "");
225
+ const ended = Date.parse(at);
226
+ if (Number.isFinite(activeStarted) && Number.isFinite(ended) && ended >= activeStarted) record.elapsedMs = Math.max(0, Number(record.elapsedMs ?? 0)) + (ended - activeStarted);
227
+ delete record.activeStartedAt;
228
+ } else if (status === "running" && record.status !== "running") {
229
+ if (!(typeof record.elapsedMs === "number" && Number.isFinite(record.elapsedMs))) {
230
+ const frozenAt = record.status === "blocked" ? record.updatedAt : isSettledRunStatus(record.status) ? record.completedAt : undefined;
231
+ const started = Date.parse(record.startedAt ?? "");
232
+ const ended = Date.parse(frozenAt ?? "");
233
+ if (Number.isFinite(started) && Number.isFinite(ended) && ended >= started) record.elapsedMs = ended - started;
234
+ }
235
+ record.startedAt ??= at;
236
+ record.activeStartedAt = at;
237
+ }
238
+ }
239
+ return applyLifecycleStatus(record, status, { ...options, at });
188
240
  }
189
241
 
190
242
  export async function settleAttemptFailure<T extends TaskRecord | TurnRecord>(
@@ -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,10 +128,34 @@ 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(), {
131
157
  herdr: dependencies.herdr, signal, owner: dependencies.owner, stalledWarningSeconds: discovery.settings.stalledWarningSeconds,
158
+ onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: partial }),
132
159
  onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "resume", runId: params.runId, stalled: true } }),
133
160
  onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "resume", runId: params.runId, cleanupError: text } }),
134
161
  });
@@ -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);