@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.
- package/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/packages/herdr-subagents/CHANGELOG.md +15 -0
- package/packages/herdr-subagents/README.md +9 -6
- package/packages/herdr-subagents/TESTING.md +6 -4
- package/packages/herdr-subagents/docs/demo.webm +0 -0
- package/packages/herdr-subagents/docs/screenshot.png +0 -0
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +40 -6
- package/packages/herdr-subagents/package.json +1 -1
- package/packages/herdr-subagents/src/batch-runner.ts +7 -7
- package/packages/herdr-subagents/src/blocking.ts +4 -4
- package/packages/herdr-subagents/src/dispatcher.ts +8 -0
- package/packages/herdr-subagents/src/fleet-controller.ts +167 -16
- package/packages/herdr-subagents/src/fleet.ts +119 -8
- package/packages/herdr-subagents/src/history.ts +11 -5
- package/packages/herdr-subagents/src/parameters.ts +2 -2
- package/packages/herdr-subagents/src/reconcile.ts +2 -1
- package/packages/herdr-subagents/src/records.ts +53 -1
- package/packages/herdr-subagents/src/tab-cleanup.ts +18 -2
- package/packages/herdr-subagents/src/tool.ts +30 -3
- package/packages/herdr-subagents/src/validation.ts +2 -1
- package/packages/herdr-subagents/tests/extension-contract.ts +16 -2
- package/packages/herdr-subagents/tests/run-tests.mjs +169 -8
- package/packages/todo/CHANGELOG.md +4 -0
- package/packages/todo/README.md +19 -9
- package/packages/todo/TESTING.md +9 -7
- package/packages/todo/docs/demo.gif +0 -0
- package/packages/todo/docs/screenshot.png +0 -0
- package/packages/todo/extensions/todo.ts +120 -90
- package/packages/todo/package.json +2 -2
- package/packages/todo/src/todo-state.ts +48 -49
- package/packages/todo/tests/todo-extension.mjs +134 -0
- 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.
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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<
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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) {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|