@maxiaochao/pi-toolkit 0.10.1 → 0.10.3

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 (33) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/global/AGENTS.md +33 -50
  4. package/package.json +2 -2
  5. package/packages/herdr-subagents/CHANGELOG.md +14 -0
  6. package/packages/herdr-subagents/README.md +20 -18
  7. package/packages/herdr-subagents/TESTING.md +6 -4
  8. package/packages/herdr-subagents/docs/demo.webm +0 -0
  9. package/packages/herdr-subagents/docs/screenshot.png +0 -0
  10. package/packages/herdr-subagents/extensions/herdr-subagents.ts +91 -51
  11. package/packages/herdr-subagents/package.json +1 -1
  12. package/packages/herdr-subagents/src/batch-runner.ts +5 -5
  13. package/packages/herdr-subagents/src/blocking.ts +4 -4
  14. package/packages/herdr-subagents/src/fleet-controller.ts +164 -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 +16 -13
  18. package/packages/herdr-subagents/src/queued-runs.ts +3 -3
  19. package/packages/herdr-subagents/src/records.ts +53 -1
  20. package/packages/herdr-subagents/src/tool.ts +40 -6
  21. package/packages/herdr-subagents/src/validation.ts +16 -29
  22. package/packages/herdr-subagents/tests/extension-contract.ts +27 -9
  23. package/packages/herdr-subagents/tests/run-tests.mjs +153 -18
  24. package/packages/todo/CHANGELOG.md +4 -0
  25. package/packages/todo/README.md +19 -9
  26. package/packages/todo/TESTING.md +9 -7
  27. package/packages/todo/docs/demo.gif +0 -0
  28. package/packages/todo/docs/screenshot.png +0 -0
  29. package/packages/todo/extensions/todo.ts +120 -90
  30. package/packages/todo/package.json +2 -2
  31. package/packages/todo/src/todo-state.ts +48 -49
  32. package/packages/todo/tests/todo-extension.mjs +134 -0
  33. package/packages/todo/tests/todo-state.mjs +80 -70
@@ -9,7 +9,7 @@ import { cleanupRunTab } from "./tab-cleanup.ts";
9
9
  import type { OwnerIdentity } from "./ownership.ts";
10
10
  import { ownerRecord } from "./ownership.ts";
11
11
  import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
12
- import { applyReport, applyTaskStatus, isActiveStatus, persistFailureReport, readReport, readRunRecord, readTaskRecord, readTurnRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type TaskRecord, type TaskStatus, type TurnRecord } from "./records.ts";
12
+ import { applyReport, applyTaskStatus, isActiveStatus, persistFailureReport, projectTaskActivity, readReport, readRunRecord, readTaskRecord, readTurnRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type TaskActivity, type TaskRecord, type TaskStatus, type TurnRecord } from "./records.ts";
13
13
  import { isAbortError } from "./errors.ts";
14
14
  import { continuationSystemPrompt } from "./protocol.ts";
15
15
  import { RunPaneAllocator } from "./run-pane-allocator.ts";
@@ -28,7 +28,7 @@ export interface ResumeBlockedResult {
28
28
  requestedConcurrency: number;
29
29
  effectiveConcurrency: number;
30
30
  tasks?: Array<{ index: number; name: string; status: TaskStatus; summary: string; documents: PersistedDocument[]; error?: string; question?: string; paneId: string; recordDirectory: string; sessionFile: string }>;
31
- activity?: Array<{ index: number; name: string; status: TaskStatus; paneId?: string }>;
31
+ activity?: TaskActivity[];
32
32
  tabId?: string;
33
33
  }
34
34
 
@@ -45,7 +45,7 @@ async function emitProgress(options: RespondToBlockedTaskOptions, runDirectory:
45
45
  recordDirectory: runDirectory,
46
46
  requestedConcurrency: Number(run.requestedConcurrency ?? 1),
47
47
  effectiveConcurrency: Number(run.effectiveConcurrency ?? 1),
48
- activity: tasks.map((entry, index) => ({ index, name: String(entry.name ?? entry.agent ?? `Task ${index + 1}`), status: entry.status, ...(entry.paneId ? { paneId: entry.paneId } : {}) })),
48
+ activity: tasks.map((entry, index) => projectTaskActivity(entry, index)),
49
49
  ...(run.tabId ? { tabId: run.tabId } : {}),
50
50
  });
51
51
  }
@@ -73,7 +73,7 @@ async function projectSettledRun(runDirectory: string, runFile: string, run: Run
73
73
  await writeJsonAtomic(runFile, run);
74
74
  if (runStatus !== "blocked" && run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, onError: onCleanupError, failureStatus: "failed" });
75
75
  const summary = runStatus === "completed" ? "All tasks completed after the answer." : runStatus === "blocked" ? `${finalTasks[blockedIndex]?.name ?? "Task"} needs input: ${blockedReport?.question ?? "Input required."}` : finalReports.map((entry, index) => `${finalTasks[index].name}: ${entry.summary}`).join("\n");
76
- const activity = finalTasks.map((entry, index) => ({ index, name: String(entry.name ?? entry.agent ?? `Task ${index + 1}`), status: entry.status, ...(entry.paneId ? { paneId: String(entry.paneId) } : {}) }));
76
+ const activity = finalTasks.map((entry, index) => projectTaskActivity(entry, index));
77
77
  const tasks = finalTasks.map((entry, index) => ({ index, name: activity[index].name, status: entry.status, summary: String(finalReports[index].summary ?? "Task has no report."), documents: finalReports[index].documents ?? [], ...(entry.error ? { error: String(entry.error) } : {}), ...(entry.question ? { question: String(entry.question) } : {}), paneId: String(entry.paneId ?? ""), recordDirectory: taskDirectories[index], sessionFile: String(entry.sessionFile ?? "") }));
78
78
  return { ok: runStatus === "completed", runId: run.id, label: String(run.label ?? "batch"), status: runStatus, summary, ...(blockedReport?.question ? { question: blockedReport.question } : {}), documents: finalReports.flatMap((entry) => entry.documents ?? []), recordDirectory: runDirectory, requestedConcurrency: Number(run.requestedConcurrency ?? run.effectiveConcurrency ?? 1), effectiveConcurrency: Number(run.effectiveConcurrency ?? 1), tasks, activity, ...(run.tabId ? { tabId: String(run.tabId) } : {}) };
79
79
  }
@@ -1,40 +1,175 @@
1
+ import { watch } from "node:fs";
2
+ import { basename, dirname } from "node:path";
1
3
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
- import { Container, isKeyRelease, matchesKey, Text } from "@earendil-works/pi-tui";
4
+ import type { Theme } from "@earendil-works/pi-coding-agent";
5
+ import { Container, isKeyRelease, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
3
6
  import type { HerdrAutomation } from "./herdr.ts";
4
- import { fleetEditorHasFocus, FleetSelection, fleetLines } from "./fleet.ts";
7
+ import { fleetEditorHasFocus, FleetSelection, fleetLines, readSessionTokens, type FleetTaskMetrics } from "./fleet.ts";
5
8
  import { focusActiveTask } from "./monitor.ts";
6
9
  import { isActiveStatus } from "./records.ts";
7
10
  import type { HerdrSubagentsBatchDetails } from "./tool.ts";
8
11
 
12
+ interface Closable { close(): void; }
13
+ export interface FleetWidgetRuntime {
14
+ watchSession(path: string, onChange: () => void, onError: () => void): Closable;
15
+ setInterval(callback: () => void, ms: number): unknown;
16
+ clearInterval(handle: unknown): void;
17
+ setTimeout(callback: () => void, ms: number): unknown;
18
+ clearTimeout(handle: unknown): void;
19
+ readTokens(path: string): Promise<number | undefined>;
20
+ }
21
+
22
+ const defaultFleetWidgetRuntime: FleetWidgetRuntime = {
23
+ watchSession(path, onChange, onError) {
24
+ const watcher = watch(dirname(path), (_event, filename) => {
25
+ if (!filename || filename.toString() === basename(path)) onChange();
26
+ });
27
+ watcher.on("error", () => { watcher.close(); onError(); });
28
+ return watcher;
29
+ },
30
+ setInterval: (callback, ms) => setInterval(callback, ms),
31
+ clearInterval: (handle) => clearInterval(handle as ReturnType<typeof setInterval>),
32
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
33
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
34
+ readTokens: readSessionTokens,
35
+ };
36
+
37
+ export class FleetWidget implements Component {
38
+ private readonly tui: TUI;
39
+ private readonly theme: Theme;
40
+ private readonly selection: FleetSelection;
41
+ private readonly runtime: FleetWidgetRuntime;
42
+ private batch: HerdrSubagentsBatchDetails;
43
+ private readonly metrics = new Map<number, FleetTaskMetrics>();
44
+ private readonly watchers = new Map<string, Closable>();
45
+ private elapsedTimer?: unknown;
46
+ private refreshTimer?: unknown;
47
+ private generation = 0;
48
+ private disposed = false;
49
+ private sourceKey = "";
50
+
51
+ constructor(tui: TUI, theme: Theme, batch: HerdrSubagentsBatchDetails, selection: FleetSelection, runtime: FleetWidgetRuntime = defaultFleetWidgetRuntime) {
52
+ this.tui = tui;
53
+ this.theme = theme;
54
+ this.selection = selection;
55
+ this.runtime = runtime;
56
+ this.batch = batch;
57
+ this.configureLiveUpdates();
58
+ }
59
+
60
+ update(batch: HerdrSubagentsBatchDetails): void {
61
+ if (this.disposed) return;
62
+ this.batch = batch;
63
+ this.configureLiveUpdates();
64
+ this.tui.requestRender();
65
+ }
66
+
67
+ render(width: number): string[] {
68
+ return fleetLines(this.batch, this.selection, { width, metrics: this.metrics, theme: this.theme });
69
+ }
70
+
71
+ invalidate(): void {}
72
+
73
+ dispose(): void {
74
+ if (this.disposed) return;
75
+ this.disposed = true;
76
+ this.generation += 1;
77
+ for (const watcher of this.watchers.values()) watcher.close();
78
+ this.watchers.clear();
79
+ if (this.elapsedTimer) this.runtime.clearInterval(this.elapsedTimer);
80
+ if (this.refreshTimer) this.runtime.clearTimeout(this.refreshTimer);
81
+ this.elapsedTimer = undefined;
82
+ this.refreshTimer = undefined;
83
+ }
84
+
85
+ private configureLiveUpdates(): void {
86
+ const sessionFiles = new Set(this.batch.activity.map((task) => task.sessionFile).filter((path): path is string => Boolean(path)));
87
+ const nextSourceKey = `${this.batch.runId}\n${[...sessionFiles].sort().join("\n")}`;
88
+ if (nextSourceKey !== this.sourceKey) {
89
+ this.sourceKey = nextSourceKey;
90
+ this.generation += 1;
91
+ this.metrics.clear();
92
+ }
93
+ for (const [path, watcher] of this.watchers) {
94
+ if (sessionFiles.has(path)) continue;
95
+ watcher.close();
96
+ this.watchers.delete(path);
97
+ }
98
+ for (const path of sessionFiles) {
99
+ if (this.watchers.has(path)) continue;
100
+ try {
101
+ const watcher = this.runtime.watchSession(path, () => this.scheduleMetricsRefresh(), () => this.watchers.delete(path));
102
+ this.watchers.set(path, watcher);
103
+ } catch { /* The next durable activity update can retry the watch. */ }
104
+ }
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);
107
+ else if (!hasRunning && this.elapsedTimer) { this.runtime.clearInterval(this.elapsedTimer); this.elapsedTimer = undefined; }
108
+ this.scheduleMetricsRefresh(0);
109
+ }
110
+
111
+ private scheduleMetricsRefresh(delay = 25): void {
112
+ if (this.disposed) return;
113
+ if (this.refreshTimer) this.runtime.clearTimeout(this.refreshTimer);
114
+ this.refreshTimer = this.runtime.setTimeout(() => { this.refreshTimer = undefined; void this.refreshMetrics(); }, delay);
115
+ }
116
+
117
+ private async refreshMetrics(): Promise<void> {
118
+ const generation = ++this.generation;
119
+ const next = await Promise.all(this.batch.activity.map(async (task) => ({ index: task.index, tokens: task.sessionFile ? await this.runtime.readTokens(task.sessionFile) : undefined })));
120
+ if (this.disposed || generation !== this.generation) return;
121
+ this.metrics.clear();
122
+ for (const metric of next) if (metric.tokens !== undefined) this.metrics.set(metric.index, { tokens: metric.tokens });
123
+ this.tui.requestRender();
124
+ }
125
+ }
126
+
9
127
  export class FleetController {
128
+ private readonly herdr: HerdrAutomation;
129
+ private readonly isEditor: (value: unknown) => boolean;
130
+ private readonly createWidget?: (lines: string[]) => Container;
131
+ private readonly widgetRuntime: FleetWidgetRuntime;
10
132
  private activeBatch?: HerdrSubagentsBatchDetails;
11
133
  private readonly selection = new FleetSelection(matchesKey);
12
134
  private unsubscribeInput?: () => void;
13
135
  private ui?: ExtensionUIContext;
14
- private tui?: { focusedComponent?: unknown };
136
+ private tui?: TUI;
137
+ private widget?: FleetWidget;
15
138
 
16
- constructor(
17
- private readonly herdr: HerdrAutomation,
18
- private readonly isEditor: (value: unknown) => boolean,
19
- private readonly createWidget?: (lines: string[]) => Container,
20
- ) {}
139
+ constructor(herdr: HerdrAutomation, isEditor: (value: unknown) => boolean, createWidget?: (lines: string[]) => Container, widgetRuntime: FleetWidgetRuntime = defaultFleetWidgetRuntime) {
140
+ this.herdr = herdr;
141
+ this.isEditor = isEditor;
142
+ this.createWidget = createWidget;
143
+ this.widgetRuntime = widgetRuntime;
144
+ }
21
145
 
22
146
  get active(): HerdrSubagentsBatchDetails | undefined { return this.activeBatch; }
23
147
 
24
148
  bind(ui: ExtensionUIContext, tuiMode: boolean): void {
25
149
  this.unsubscribeInput?.();
150
+ if (this.ui && this.ui !== ui) {
151
+ this.ui.setWidget("herdr-subagents", undefined, { placement: "belowEditor" });
152
+ this.widget = undefined;
153
+ this.tui = undefined;
154
+ }
26
155
  this.ui = ui;
27
156
  this.unsubscribeInput = tuiMode ? ui.onTerminalInput((data) => this.handleInput(data)) : undefined;
157
+ if (this.activeBatch) this.render();
28
158
  }
29
159
 
30
160
  sync(details: unknown): void {
161
+ if (!this.ui) return;
31
162
  const batch = details as Partial<HerdrSubagentsBatchDetails>;
32
163
  if (!batch.activity || !batch.label || !batch.status) return;
164
+ if (this.activeBatch && batch.runId !== this.activeBatch.runId) return;
165
+ if (this.activeBatch?.runId !== batch.runId) this.selection.reset();
33
166
  this.activeBatch = isActiveStatus(batch.status) ? batch as HerdrSubagentsBatchDetails : undefined;
34
167
  this.render();
35
168
  }
36
169
 
37
170
  setActive(batch: HerdrSubagentsBatchDetails | undefined): void {
171
+ if (!this.ui) return;
172
+ if (this.activeBatch?.runId !== batch?.runId) this.selection.reset();
38
173
  this.activeBatch = batch;
39
174
  this.render();
40
175
  }
@@ -53,18 +188,31 @@ export class FleetController {
53
188
  this.activeBatch = undefined;
54
189
  this.selection.reset();
55
190
  this.render();
191
+ this.ui = undefined;
192
+ this.widget = undefined;
193
+ this.tui = undefined;
56
194
  }
57
195
 
58
196
  render(): void {
59
197
  if (!this.ui) return;
60
- this.ui.setWidget("herdr-subagents", this.activeBatch ? ((tui) => {
61
- this.tui = tui as { focusedComponent?: unknown };
62
- const lines = fleetLines(this.activeBatch as HerdrSubagentsBatchDetails, this.selection);
63
- if (this.createWidget) return this.createWidget(lines);
64
- const container = new Container();
65
- for (const line of lines) container.addChild(new Text(line, 1, 0));
66
- return container;
67
- }) : undefined, { placement: "belowEditor" });
198
+ if (!this.activeBatch) {
199
+ this.widget = undefined;
200
+ this.ui.setWidget("herdr-subagents", undefined, { placement: "belowEditor" });
201
+ return;
202
+ }
203
+ if (this.createWidget) {
204
+ this.ui.setWidget("herdr-subagents", (tui) => {
205
+ this.tui = tui;
206
+ return this.createWidget?.(fleetLines(this.activeBatch as HerdrSubagentsBatchDetails, this.selection)) ?? new Container();
207
+ }, { placement: "belowEditor" });
208
+ return;
209
+ }
210
+ if (this.widget) { this.widget.update(this.activeBatch); return; }
211
+ this.ui.setWidget("herdr-subagents", (tui, theme) => {
212
+ this.tui = tui;
213
+ this.widget = new FleetWidget(tui, theme, this.activeBatch as HerdrSubagentsBatchDetails, this.selection, this.widgetRuntime);
214
+ return this.widget;
215
+ }, { placement: "belowEditor" });
68
216
  }
69
217
 
70
218
  private handleInput(data: string): { consume: true } | undefined {
@@ -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
  }
@@ -1,21 +1,24 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import { Type } from "typebox";
3
3
 
4
+ const HerdrSubagentsTaskParams = Type.Object({
5
+ name: Type.String({ minLength: 1, maxLength: 48, description: "Short task name shown in the pane and task list." }),
6
+ prompt: Type.String({ minLength: 1, description: "Self-contained prompt for this task." }),
7
+ agent: Type.Optional(Type.String({ minLength: 1, description: "Persona for this task. Defaults to worker." })),
8
+ model: Type.Optional(Type.String({ minLength: 1, description: "Optional model override for this task." })),
9
+ }, { additionalProperties: false });
10
+
4
11
  export const HerdrSubagentsParams = Type.Object({
5
- action: Type.Optional(StringEnum(["list", "respond", "history", "resume", "cleanup"] as const, { description: "Operation mode. Omit it to execute one task or a batch." })),
12
+ tasks: Type.Array(HerdrSubagentsTaskParams, { minItems: 1, description: "Ordered tasks to execute. Use one item for a single task; independent read-only tasks may run concurrently." }),
13
+ label: Type.Optional(Type.String({ minLength: 1, maxLength: 48, description: "Short Herdr run-tab label." })),
14
+ concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneously active Agents. Write-capable tasks force concurrency one." })),
15
+ background: Type.Optional(Type.Boolean({ description: "Return a run ID immediately while the run continues in this parent session." })),
16
+ }, { additionalProperties: false });
17
+
18
+ export const HerdrSubagentsControlParams = Type.Object({
19
+ action: StringEnum(["list", "respond", "history", "resume", "cleanup"] as const, { description: "Control operation to perform." }),
6
20
  runId: Type.Optional(Type.String({ minLength: 1, description: "Durable run ID used by respond, resume, or cleanup." })),
7
21
  answer: Type.Optional(Type.String({ minLength: 1, description: "Answer sent to a blocked task when action is respond." })),
8
22
  task: Type.Optional(Type.Integer({ minimum: 1, description: "One-based task number selected for respond or resume." })),
9
- prompt: Type.Optional(Type.String({ minLength: 1, description: "Task prompt for single execution, or follow-up prompt for resume." })),
10
- agent: Type.Optional(Type.String({ minLength: 1, description: "Persona for single execution. Defaults to worker." })),
11
- label: Type.Optional(Type.String({ minLength: 1, maxLength: 48, description: "Short Herdr run-tab label." })),
12
- model: Type.Optional(Type.String({ minLength: 1, description: "Optional Pi model override, for example provider/model:high." })),
13
- tasks: Type.Optional(Type.Array(Type.Object({
14
- name: Type.String({ minLength: 1, maxLength: 48, description: "Short task name shown in the pane and task list." }),
15
- prompt: Type.String({ minLength: 1, description: "Self-contained prompt for this task." }),
16
- agent: Type.Optional(Type.String({ minLength: 1, description: "Persona for this task. Defaults to worker." })),
17
- model: Type.Optional(Type.String({ minLength: 1, description: "Optional model override for this task." })),
18
- }), { minItems: 1, description: "Ordered tasks for bounded-concurrency batch execution." })),
19
- concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneously active Agents in a batch." })),
20
- background: Type.Optional(Type.Boolean({ description: "Return a run ID immediately while the batch continues in this parent session." })),
23
+ prompt: Type.Optional(Type.String({ minLength: 1, description: "Follow-up prompt used by resume." })),
21
24
  }, { additionalProperties: false });
@@ -1,15 +1,15 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
2
  import { mkdir, readFile, rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import type { HerdrSubagentsToolParams } from "./tool.ts";
4
+ import type { HerdrSubagentsRunParams } from "./tool.ts";
5
5
  import { isProcessAlive, ownerRecord, type OwnerIdentity } from "./ownership.ts";
6
6
  import { writeJsonAtomic } from "./state.ts";
7
7
  import { findRunDirectory } from "./run-locator.ts";
8
8
 
9
- export interface QueuedRunRecord { runId: string; cwd: string; ownerSessionId?: string; ownerProcessId?: number; params: HerdrSubagentsToolParams; queuedAt: string; }
9
+ export interface QueuedRunRecord { runId: string; cwd: string; ownerSessionId?: string; ownerProcessId?: number; params: HerdrSubagentsRunParams; queuedAt: string; }
10
10
  function queueDirectory(cwd: string): string { return join(cwd, ".pi", "herdr-subagents", "queue"); }
11
11
 
12
- export async function persistQueuedRun(cwd: string, runId: string, params: HerdrSubagentsToolParams, owner?: OwnerIdentity): Promise<void> {
12
+ export async function persistQueuedRun(cwd: string, runId: string, params: HerdrSubagentsRunParams, owner?: OwnerIdentity): Promise<void> {
13
13
  const directory = queueDirectory(cwd);
14
14
  await mkdir(directory, { recursive: true, mode: 0o700 });
15
15
  await writeJsonAtomic(join(directory, `${runId}.json`), { runId, cwd, ...ownerRecord(owner), params: { ...params, background: true }, queuedAt: 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>(