@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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 2026-10-08 - v0.10.4
2
+
3
+ - bundle herdr-subagents v0.4.1: targeted cancellation, live token polling, and idempotent tab cleanup
4
+
5
+ ## 2026-10-08 - v0.10.3
6
+
7
+ - bundle herdr-subagents v0.4.0: add the live compact Fleet footer with authoritative metrics and refreshed media
8
+ - Add the responsive below-editor Herdr Fleet monitor with real personas, task states, cumulative active durations, and authoritative child Pi token totals, including event-driven live updates and safe partial-JSONL handling.
9
+ - Color running Fleet rows yellow and completed rows green, compact the shared metrics column instead of pushing it to the terminal edge, and update the published media with focus restoration after capture.
10
+
1
11
  ## 2026-10-08 - v0.10.2
2
12
 
3
13
  - bundle herdr-subagents v0.3.0: split execution and control tools and unify task runs
@@ -183,6 +193,7 @@
183
193
 
184
194
  ## Unreleased
185
195
 
196
+ - redesign bundled Todo as a branch-local execution plan with lifecycle-only model actions, expected-current guards, serialized calls, preserved branch history, and a live current-plan widget
186
197
  - let `/cache_export` switch between all history and individual compaction-defined Context segments, defaulting to the latest segment; cache-hit cumulative lines reset at compaction or model boundaries
187
198
  - add the independent `@maxiaochao/pi-codex-edit` package with model-aware Codex-style `apply_patch`
188
199
  - include the benchmark summary and interactive HTML architecture explainer
package/README.md CHANGED
@@ -22,7 +22,7 @@ The root package embeds the child-package source snapshots listed below. Install
22
22
 
23
23
  | Package | Capability | Install | Update |
24
24
  |---|---|---|---|
25
- | [pi-todo](packages/todo/README.md) | Session plans, automatic task advancement, `/todos` | `pi install npm:@maxiaochao/pi-todo` | `pi update npm:@maxiaochao/pi-todo` |
25
+ | [pi-todo](packages/todo/README.md) | Branch-local execution plans, guarded advancement, `/todos` history | `pi install npm:@maxiaochao/pi-todo` | `pi update npm:@maxiaochao/pi-todo` |
26
26
  | [pi-cache-export](packages/cache-export/README.md) | Tau-aligned interactive cache dashboard for Pi sessions | `pi install npm:@maxiaochao/pi-cache-export` | `pi update npm:@maxiaochao/pi-cache-export` |
27
27
  | [pi-codex-edit](packages/codex-edit/README.md) | Codex-harness `apply_patch` routing for selected GPT models | `pi install npm:@maxiaochao/pi-codex-edit` | `pi update npm:@maxiaochao/pi-codex-edit` |
28
28
  | [pi-herdr-subagents](packages/herdr-subagents/README.md) | Interactive Herdr Pi Agents with bounded concurrency, direct navigation, and recovery | `pi install npm:@maxiaochao/pi-herdr-subagents` | `pi update npm:@maxiaochao/pi-herdr-subagents` |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.10.2",
4
- "description": "Personal pi coding agent toolkit: /todos, /btw, /cache_export and apply_patch extensions, browser automation, and bundled theme/skills.",
3
+ "version": "0.10.4",
4
+ "description": "Personal pi coding agent toolkit: execution plans, /btw, /cache_export and apply_patch extensions, browser automation, and bundled theme/skills.",
5
5
  "type": "module",
6
6
  "workspaces": [
7
7
  "packages/*"
@@ -1,3 +1,18 @@
1
+ ## 2026-10-08 - v0.4.1
2
+
3
+ - targeted cancellation, live token polling, and idempotent tab cleanup
4
+ - Add `cancel` control for one active or queued run while leaving unrelated runs untouched.
5
+ - Refresh live Fleet token totals every second as well as on file events.
6
+ - Treat already-missing tabs as successful cleanup and emit one warning per real cleanup failure.
7
+
8
+ ## 2026-10-08 - v0.4.0
9
+
10
+ - add the live compact Fleet footer with authoritative metrics and refreshed media
11
+ - Replace the below-editor task list with a compact responsive Fleet monitor showing authoritative persona, task status, cumulative active time, and true child Pi usage from assistant, tool-result, standalone usage, compaction, and branch-summary entries.
12
+ - Update Fleet metrics from child-session file events, repaint elapsed time only while work runs, safely ignore malformed or partial JSONL tails, and dispose every watcher and timer with the widget.
13
+ - Preserve exact-pane keyboard focus while extending blocked-response and historical-resume activity projections with durable timing and session metadata.
14
+ - Use warning/yellow indicators for running Fleet rows and success/green indicators for completed rows, compact the shared metrics column instead of pushing it to the terminal edge, and publish media that demonstrates the live parent footer while restoring focus after capture.
15
+
1
16
  ## 2026-10-08 - v0.3.0
2
17
 
3
18
  - split execution and control tools and unify task runs
@@ -2,16 +2,15 @@
2
2
 
3
3
  A Pi extension that runs focused foreground or background work as normal interactive Pi Agents in visible Herdr panes. Full answers and sessions stay in project-local records while the parent receives only concise summaries and document paths.
4
4
 
5
- ![A parent Pi conversation running three visible Herdr Subagents panes](docs/screenshot.png)
5
+ ![A parent Pi conversation showing the live Herdr Subagents Fleet footer](docs/screenshot.png)
6
6
 
7
7
  ## Demo
8
8
 
9
- The recording shows a real Pi conversation invoking a three-task read-only batch, Herdr opening one interactive Agent pane per task, and the compact results returning to the parent.
9
+ The recording shows a real Pi conversation invoking a three-task read-only batch, the active Fleet footer in the parent, Herdr opening one interactive Agent pane per task, and the compact results returning to the parent.
10
10
 
11
11
  https://github.com/user-attachments/assets/8ddf3ae6-a11a-492b-9cae-d9496ba65b67
12
12
 
13
- The video starts a three-agent batch, focuses each live child pane in turn, then returns
14
- to the parent summary. The reproducible source recording is retained as [`docs/demo.webm`](docs/demo.webm).
13
+ The video starts a three-agent batch, focuses each live child pane in turn, captures the parent Fleet footer with yellow running indicators and true usage, then returns to the parent summary. Completed Fleet rows use green indicators. The reproducible source recording is retained as [`docs/demo.webm`](docs/demo.webm).
15
14
 
16
15
  ## Requirements and install
17
16
 
@@ -73,7 +72,11 @@ A child missing required information reports `needs-input` with an exact questio
73
72
  herdr_subagents_control({ action: "respond", runId: "<run-id>", answer: "Use the main branch." })
74
73
  ```
75
74
 
76
- While a batch is active, its task rows stay below the editor. With an empty editor, press `↓` to enter the list, `↑`/`↓` to choose a task, `Enter` to focus its exact pane, and `Esc` to return. `/herdr-subagents focus <task-number>` remains available as a fallback.
75
+ Cancel one active or queued run with `herdr_subagents_control({ action: "cancel", runId: "<run-id>" })`. Cancellation aborts its live child prompts, archives active or queued tasks as cancelled, closes its run tab, and leaves unrelated runs alone.
76
+
77
+ While a batch is active, a compact Fleet monitor stays below the editor. Its divider/title row summarizes the run, and each task row shows a hollow or filled status circle, the real persona and task name, status, live active duration, and cumulative child Pi token usage in one compact shared metrics column after the longest row rather than at the terminal edge. Running work uses Pi's warning/yellow semantic color and completed work uses success/green. Token totals use the child session's persisted assistant, tool-result, standalone usage, compaction, and branch-summary usage; they are never estimated. Queued tasks show neither fabricated time nor tokens. The monitor follows Pi theme semantics, truncates by visible terminal columns, and refreshes session usage from both file events and the running one-second timer.
78
+
79
+ With an empty editor, press `↓` to enter the list, `↑`/`↓` to choose a task, `Enter` to focus its exact pane, and `Esc` to return. `/herdr-subagents focus <task-number>` remains available as a fallback.
77
80
 
78
81
  ## History
79
82
 
@@ -83,7 +86,7 @@ Continue a saved conversation in a new Herdr tab with `herdr_subagents_control({
83
86
 
84
87
  Read-only tasks start FIFO up to the configured concurrency limit. A pane is created only when its task starts, belongs exclusively to that task, and is never reused. Completed panes remain inspectable while another task in the batch is unsettled. Results remain in input order, and one failed task does not cancel its siblings. Any batch containing a write-capable persona is forced to concurrency one.
85
88
 
86
- The widget shows one row per queued, running, blocked, failed, or completed task. Answering a blocked task refreshes rows and pane IDs as that task resumes and queued siblings start. Completed rows remain selectable until the whole batch settles. `/herdr-subagents active` shows the same compact state. Native labels stay short: `SA · <batch>` for the tab and `<order> · <task>` for each pane. When all tasks settle, the tab closes immediately if unfocused or after the user leaves it; a deferred close failure is persisted and shown as a warning.
89
+ The widget shows one row per queued, running, blocked, failed, or completed task. Answering a blocked task or resuming saved history refreshes authoritative persona, timing, session, status, and pane projections as work advances. Active duration accumulates only while the task is running, excluding time spent queued or waiting for an answer. Completed rows remain selectable until the whole batch settles. `/herdr-subagents active` shows the same compact state. Native labels stay short: `SA · <batch>` for the tab and `<order> · <task>` for each pane. When all tasks settle, the tab closes immediately if unfocused or after the user leaves it; a deferred close failure is persisted and shown as a warning. An already-missing tab is treated as successfully cleaned, so retries are idempotent.
87
90
 
88
91
  Foreground calls block until all children report completion, failure, or a request for input. Every child is started with Herdr's Agent lifecycle as a normal Pi TUI and prompted with wait semantics; no print-mode command or completion marker is injected into the pane.
89
92
 
@@ -4,9 +4,9 @@
4
4
 
5
5
  1. `npm --prefix packages/herdr-subagents test` runs deterministic scheduler, recovery, and persona tests.
6
6
  2. `pi -ne -ns --no-session -e ./packages/herdr-subagents/extensions/herdr-subagents.ts` verifies extension loading.
7
- 3. From a visible X11 Herdr client with the GStreamer X11 and VP8 plugins installed, run `HERDR_MEDIA_WINDOW_ID=<window-id> node scripts/capture-package-media.mjs`. The Herdr capture must show a parent Pi conversation, a three-task run tab, each of the three real interactive Agent panes receiving focus in turn, and the final compact parent response.
7
+ 3. From a dedicated visible X11 Herdr client/window with the GStreamer X11 and VP8 plugins installed, run `HERDR_MEDIA_WINDOW_ID=<window-id> node scripts/capture-package-media.mjs`. Capture intentionally cycles through the live child panes, so avoid using that client/window for other work while recording; the script restores the previously focused pane afterward when it still exists. The Herdr capture must show a parent Pi conversation, a three-task run tab, each of the three real interactive Agent panes receiving focus in turn, the active parent Fleet footer with yellow running indicators and real metrics, and the final compact parent response. The published screenshot must be the parent Fleet view, while the full WebM preserves the parent → three focused children → Fleet → parent sequence.
8
8
  4. `npm pack ./packages/herdr-subagents --dry-run` verifies package contents.
9
- 5. Verify the README renders the GitHub attachment video directly and live Herdr UI media `docs/screenshot.png` and `docs/demo.webm` are present in the tarball; `docs/demo.gif` must be absent. Inspect the WebM metadata and play it through once to confirm the parent → three focused children → parent sequence.
9
+ 5. Verify the README renders the GitHub attachment video directly and live Herdr UI media `docs/screenshot.png` and `docs/demo.webm` are present in the tarball; `docs/demo.gif` must be absent. Inspect the WebM metadata and play it through once to confirm the parent → three focused children → Fleet → parent sequence.
10
10
 
11
11
  ## Cases
12
12
 
@@ -17,9 +17,11 @@
17
17
  - Preserve compact reports, full results, Pi sessions, blocked questions, and historical follow-ups.
18
18
  - Recover queued/running work after interruption without retrying post-prompt side effects, including truncated reports and recovery errors.
19
19
  - Verify the split public contracts (`herdr_subagents` with required `tasks`, plus `herdr_subagents_control` with required `action`) and injectable extension wiring, including conditional registration only inside Herdr, Fleet rendering, editor focus ownership, terminal-input passthrough, exact-pane navigation, and stable display while another background run queues.
20
- - Verify blocked-response live Fleet updates, authoritative stale-field replacement, parent-shutdown cancellation, cleanup notifications, atomic state replacement, non-retried uncertain startup operations, and stalled warnings.
20
+ - Verify Fleet divider/row formatting, warning/yellow running indicators, success/green completed indicators, persona and task projections, cumulative active duration across blocking/resume, exact Pi usage aggregation from assistant messages, tool results, standalone usage, compaction, and branch summaries, malformed/truncated JSONL tolerance, queued metric omission, and visible-column bounds at narrow widths.
21
+ - Verify live token updates are file-event driven and polled on each running one-second tick, elapsed repainting exists only while work runs, and widget disposal closes watchers and timers.
22
+ - Verify selected active/queued run cancellation, blocked-response live Fleet updates, authoritative stale-field replacement, parent-shutdown cancellation, idempotent missing-tab cleanup, cleanup notifications, atomic state replacement, non-retried uncertain startup operations, and stalled warnings.
21
23
  - Verify resumed batches retain newly allocated pane IDs and historical follow-ups exclude simultaneous session writers.
22
24
 
23
25
  ## Latest baseline
24
26
 
25
- All 137 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, and persona cases must pass. Release validation also requires extension loading and a real Herdr read-only batch smoke with distinct task panes and final tab cleanup. The latest smoke completed two concurrent tasks, retained its focused run tab through settlement, and closed it automatically after focus moved away.
27
+ All 168 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, and persona cases must pass. Release validation also requires extension loading and a real Herdr read-only batch smoke with distinct task panes and final tab cleanup. The latest smoke completed two concurrent tasks, retained its focused run tab through settlement, and closed it automatically after focus moved away.
@@ -12,7 +12,7 @@ import { cancelActiveSubagentRuns, reconcileSubagentRuns } from "../src/reconcil
12
12
  import { executeHerdrSubagents, executeHerdrSubagentsControl, type HerdrSubagentsBatchDetails, type HerdrSubagentsControlToolParams, type HerdrSubagentsDetails, type HerdrSubagentsRunParams } from "../src/tool.ts";
13
13
  import { HerdrSubagentsControlParams, HerdrSubagentsParams } from "../src/parameters.ts";
14
14
  import { HERDR_ACTIONS } from "../src/validation.ts";
15
- import { FleetController } from "../src/fleet-controller.ts";
15
+ import { FleetController, type FleetWidgetRuntime } from "../src/fleet-controller.ts";
16
16
  import { isSettledRunStatus } from "../src/records.ts";
17
17
 
18
18
  const packageAgentsDir = fileURLToPath(new URL("../agents/", import.meta.url));
@@ -121,6 +121,7 @@ export interface HerdrSubagentsExtensionDependencies {
121
121
  executeControl?: typeof executeHerdrSubagentsControl;
122
122
  isEditor?: (value: unknown) => boolean;
123
123
  createFleetWidget?: (lines: string[]) => Container;
124
+ fleetWidgetRuntime?: FleetWidgetRuntime;
124
125
  }
125
126
 
126
127
  async function recoverParentSession(cwd: string, herdr: HerdrAutomation, owner: { sessionId?: string; processId: number }, notify: (message: string) => void): Promise<void> {
@@ -157,8 +158,9 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
157
158
  const executeControl = dependencies.executeControl ?? executeHerdrSubagentsControl;
158
159
  const isEditorComponent = dependencies.isEditor ?? ((value: unknown) => value instanceof Editor);
159
160
  const dispatcher = new RunDispatcher();
160
- const fleet = new FleetController(herdr, isEditorComponent, dependencies.createFleetWidget);
161
+ const fleet = new FleetController(herdr, isEditorComponent, dependencies.createFleetWidget, dependencies.fleetWidgetRuntime);
161
162
  const runControllers = new Map<string, AbortController>();
163
+ const runPromises = new Map<string, Promise<unknown>>();
162
164
  const inFlight = new Set<Promise<unknown>>();
163
165
  let sessionController = new AbortController();
164
166
  let ownerSessionId: string | undefined;
@@ -227,8 +229,9 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
227
229
  }
228
230
  return result;
229
231
  });
232
+ runPromises.set(runId, promise);
230
233
  inFlight.add(promise);
231
- void promise.finally(() => { inFlight.delete(promise); runControllers.delete(runId); }).catch(() => undefined);
234
+ void promise.finally(() => { inFlight.delete(promise); runControllers.delete(runId); runPromises.delete(runId); }).catch(() => undefined);
232
235
  if (params.background) {
233
236
  void promise.then((result) => {
234
237
  if (generation !== sessionGeneration) return;
@@ -236,6 +239,10 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
236
239
  if (!fleet.active && !dispatcher.snapshot().activeRunId && dispatcher.snapshot().queuedRunIds.length === 0) fleet.render();
237
240
  }).catch((error) => {
238
241
  if (generation !== sessionGeneration) return;
242
+ if (controller.signal.aborted) {
243
+ pi.sendMessage({ customType: "herdr-subagents-run", content: `Subagent run ${runId} cancelled.`, display: true, details: { runId, status: "cancelled" } }, { triggerTurn: true, deliverAs: "followUp" });
244
+ return;
245
+ }
239
246
  pi.sendMessage({ customType: "herdr-subagents-run", content: `Subagent run ${runId} failed: ${error instanceof Error ? error.message : String(error)}`, display: true, details: { runId, status: "failed" } }, { triggerTurn: true, deliverAs: "followUp" });
240
247
  });
241
248
  return { content: [{ type: "text" as const, text: `Subagent run queued: ${runId}` }], details: { runId, status: "queued", background: true } };
@@ -252,12 +259,12 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
252
259
  pi.registerTool({
253
260
  name: "herdr_subagents_control",
254
261
  label: "Herdr Subagents Control",
255
- description: "Inspect Herdr personas and run history, answer a blocked task, resume a saved task, or remove a selected archived run. Use herdr_subagents to start new work.",
262
+ description: "Inspect Herdr personas and run history, cancel active or queued work, answer a blocked task, resume a saved task, or remove a selected archived run. Use herdr_subagents to start new work.",
256
263
  promptSnippet: "Inspect and control Herdr subagent runs",
257
264
  parameters: HerdrSubagentsControlParams,
258
265
 
259
266
  async execute(_toolCallId, params: HerdrSubagentsControlToolParams, signal, onUpdate, ctx) {
260
- const executionId = params.runId ?? randomUUID();
267
+ const executionId = randomUUID();
261
268
  const controller = new AbortController();
262
269
  runControllers.set(executionId, controller);
263
270
  const runSignal = AbortSignal.any([controller.signal, sessionController.signal, ...(signal ? [signal] : [])]);
@@ -268,7 +275,34 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
268
275
  const updateDetails = update.details as { warning?: string };
269
276
  if (updateDetails.warning || updateText?.includes("cleanup failed")) ctx.ui.notify(updateDetails.warning ?? updateText ?? "Subagent warning", "warning");
270
277
  onUpdate?.(update);
271
- }, ctx, { agentsDirectory: packageAgentsDir, owner: { sessionId: ownerSessionId, processId: process.pid } });
278
+ }, ctx, {
279
+ agentsDirectory: packageAgentsDir,
280
+ herdr,
281
+ owner: { sessionId: ownerSessionId, processId: process.pid },
282
+ cancelRun: async (runId) => {
283
+ const reason = new Error("Subagent run cancelled by user.");
284
+ const targetController = runControllers.get(runId);
285
+ targetController?.abort(reason);
286
+ if (dispatcher.cancelQueuedRun(runId, reason)) {
287
+ await cancelQueuedRuns(ctx.cwd, [runId], { reason: reason.message, owner: { sessionId: ownerSessionId, processId: process.pid } });
288
+ fleet.clearRun(runId);
289
+ return { cancelled: true, queued: true };
290
+ }
291
+ const activePromise = runPromises.get(runId);
292
+ if (activePromise) {
293
+ await activePromise.catch(() => undefined);
294
+ fleet.clearRun(runId);
295
+ dispatcher.release(runId);
296
+ return { cancelled: true };
297
+ }
298
+ const cancellation = await cancelActiveSubagentRuns(ctx.cwd, herdr, { reason: reason.message, owner: { sessionId: ownerSessionId, processId: process.pid }, runId });
299
+ if (cancellation.cancelledRuns) {
300
+ fleet.clearRun(runId);
301
+ dispatcher.release(runId);
302
+ }
303
+ return { cancelled: cancellation.cancelledRuns > 0, cleanupErrors: cancellation.cleanupErrors };
304
+ },
305
+ });
272
306
  inFlight.add(promise);
273
307
  const result = await promise.finally(() => { inFlight.delete(promise); runControllers.delete(executionId); });
274
308
  fleet.sync(result.details);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-herdr-subagents",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Run inspectable interactive Pi subagent batches through Herdr.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -10,7 +10,7 @@ import { writeJsonAtomic } from "./state.ts";
10
10
  import type { OwnerIdentity } from "./ownership.ts";
11
11
  import { ownerRecord } from "./ownership.ts";
12
12
  import { RunPaneAllocator } from "./run-pane-allocator.ts";
13
- import { applyReport, applyTaskStatus, settleAttemptFailure, type PersistedReport, type RunRecord, type TaskRecord, type TaskStatus } from "./records.ts";
13
+ import { applyReport, applyTaskStatus, projectTaskActivity, settleAttemptFailure, type PersistedReport, type RunRecord, type TaskActivity, type TaskRecord, type TaskStatus } from "./records.ts";
14
14
  import { reportProtocolPrompt } from "./protocol.ts";
15
15
  import { cleanupRunTab } from "./tab-cleanup.ts";
16
16
  import { applyRunStatus } from "./run-status.ts";
@@ -63,7 +63,7 @@ export interface BatchResult {
63
63
  requestedConcurrency: number;
64
64
  effectiveConcurrency: number;
65
65
  tasks: BatchTaskResult[];
66
- activity: Array<{ index: number; name: string; status: BatchTaskStatus; paneId?: string }>;
66
+ activity: TaskActivity[];
67
67
  summary: string;
68
68
  documents: HerdrSubagentsDocument[];
69
69
  recordDirectory: string;
@@ -114,7 +114,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
114
114
  await writeJsonAtomic(runFile, runRecord);
115
115
  const herdr = options.herdr ?? new CliHerdrAutomation();
116
116
  const results = new Array<BatchTaskResult>(tasks.length);
117
- const activity: BatchResult["activity"] = tasks.map((task, index) => ({ index, name: task.name, status: "queued" }));
117
+ const activity: BatchResult["activity"] = contexts.map((context, index) => projectTaskActivity(context.taskRecord, index));
118
118
  let tabId: string | undefined;
119
119
  const paneAllocator = new RunPaneAllocator({ herdr, run: runRecord, runFile, signal: options.signal, onCleanupError: options.onCleanupError });
120
120
  const emit = (status: BatchResult["status"], summary: string) => options.onUpdate?.({
@@ -146,7 +146,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
146
146
  applyTaskStatus(context.taskRecord, "running");
147
147
  Object.assign(context.taskRecord, { tabId, paneId, paneLabel, agentName, startedAt: timestamp() });
148
148
  await writeJsonAtomic(context.taskFile, context.taskRecord);
149
- activity[index] = { index, name: task.name, status: "running", paneId };
149
+ activity[index] = projectTaskActivity(context.taskRecord, index);
150
150
  emit("running", `${settled}/${tasks.length} tasks settled.`);
151
151
  const prompted = await promptLiveAgent({ herdr, task: { ...task, id: context.taskId, sessionFile: context.sessionFile }, paneId, prompt: task.prompt, systemPromptFile: context.systemPromptFile, agentName, signal: options.signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => emit("running", `${task.name} appears stalled; it is still running.`) });
152
152
  agentName = prompted.agentName;
@@ -164,7 +164,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
164
164
  Object.assign(context.taskRecord, { paneId, agentName });
165
165
  await writeJsonAtomic(context.taskFile, context.taskRecord);
166
166
  results[index] = { index, name: task.name, status, summary: report.summary, documents: report.documents, ...(report.error ? { error: report.error } : {}), ...(report.question ? { question: report.question } : {}), paneId, recordDirectory: context.taskDirectory, sessionFile: context.sessionFile };
167
- activity[index] = { index, name: task.name, status, ...(paneId ? { paneId } : {}) };
167
+ activity[index] = projectTaskActivity(context.taskRecord, index);
168
168
  settled += 1;
169
169
  emit("running", `${settled}/${tasks.length} tasks settled.`);
170
170
  };
@@ -190,7 +190,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
190
190
  const failed = settledResults.filter((result) => result.status === "failed").length;
191
191
  const cancelled = options.signal?.aborted || activity.some((entry) => entry.status === "cancelled");
192
192
  const status: BatchResult["status"] = cancelled ? "cancelled" : blocked || activity.some((entry) => entry.status === "queued") ? "blocked" : failed === 0 ? "completed" : failed === tasks.length ? "failed" : "partial";
193
- const summary = status === "cancelled" ? "Subagent batch cancelled because the parent Pi session closed." : blocked ? `${blocked.name} needs input: ${blocked.question}` : failed ? `${tasks.length - failed}/${tasks.length} tasks completed; ${failed} failed.` : `${tasks.length}/${tasks.length} tasks completed.`;
193
+ const summary = status === "cancelled" ? "Subagent batch cancelled." : blocked ? `${blocked.name} needs input: ${blocked.question}` : failed ? `${tasks.length - failed}/${tasks.length} tasks completed; ${failed} failed.` : `${tasks.length}/${tasks.length} tasks completed.`;
194
194
  applyRunStatus(runRecord, status, { question: blocked?.question });
195
195
  await writeJsonAtomic(runFile, runRecord);
196
196
  return { ok: status === "completed", status, runId, label: options.label, requestedConcurrency, effectiveConcurrency, tasks: settledResults, activity, summary, documents: settledResults.flatMap((result) => result.documents), recordDirectory: runDirectory, tabId };
@@ -205,7 +205,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
205
205
  await cleanupRunTab({
206
206
  herdr, tabId, runFile, runRecord, signal: options.signal, failureStatus: "failed",
207
207
  errorPrefix: "Task results settled, but Herdr tab cleanup failed",
208
- onError: (message) => { emit("failed", message); options.onCleanupError?.(message); },
208
+ onError: (message) => options.onCleanupError?.(message),
209
209
  });
210
210
  } catch (error) {
211
211
  const message = error instanceof Error ? error.message : String(error);
@@ -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
  }
@@ -51,6 +51,14 @@ export class RunDispatcher {
51
51
  this.changed();
52
52
  return cancelled.map((run) => run.id);
53
53
  }
54
+ cancelQueuedRun(id: string, reason: Error): boolean {
55
+ const index = this.queue.findIndex((run) => run.id === id);
56
+ if (index < 0) return false;
57
+ const [cancelled] = this.queue.splice(index, 1);
58
+ cancelled.reject(reason);
59
+ this.changed();
60
+ return true;
61
+ }
54
62
 
55
63
  private changed(): void { this.onChange?.(this.snapshot()); }
56
64
 
@@ -1,40 +1,178 @@
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(() => {
107
+ this.tui.requestRender();
108
+ this.scheduleMetricsRefresh(0);
109
+ }, 1_000);
110
+ else if (!hasRunning && this.elapsedTimer) { this.runtime.clearInterval(this.elapsedTimer); this.elapsedTimer = undefined; }
111
+ this.scheduleMetricsRefresh(0);
112
+ }
113
+
114
+ private scheduleMetricsRefresh(delay = 25): void {
115
+ if (this.disposed) return;
116
+ if (this.refreshTimer) this.runtime.clearTimeout(this.refreshTimer);
117
+ this.refreshTimer = this.runtime.setTimeout(() => { this.refreshTimer = undefined; void this.refreshMetrics(); }, delay);
118
+ }
119
+
120
+ private async refreshMetrics(): Promise<void> {
121
+ const generation = ++this.generation;
122
+ const next = await Promise.all(this.batch.activity.map(async (task) => ({ index: task.index, tokens: task.sessionFile ? await this.runtime.readTokens(task.sessionFile) : undefined })));
123
+ if (this.disposed || generation !== this.generation) return;
124
+ this.metrics.clear();
125
+ for (const metric of next) if (metric.tokens !== undefined) this.metrics.set(metric.index, { tokens: metric.tokens });
126
+ this.tui.requestRender();
127
+ }
128
+ }
129
+
9
130
  export class FleetController {
131
+ private readonly herdr: HerdrAutomation;
132
+ private readonly isEditor: (value: unknown) => boolean;
133
+ private readonly createWidget?: (lines: string[]) => Container;
134
+ private readonly widgetRuntime: FleetWidgetRuntime;
10
135
  private activeBatch?: HerdrSubagentsBatchDetails;
11
136
  private readonly selection = new FleetSelection(matchesKey);
12
137
  private unsubscribeInput?: () => void;
13
138
  private ui?: ExtensionUIContext;
14
- private tui?: { focusedComponent?: unknown };
139
+ private tui?: TUI;
140
+ private widget?: FleetWidget;
15
141
 
16
- constructor(
17
- private readonly herdr: HerdrAutomation,
18
- private readonly isEditor: (value: unknown) => boolean,
19
- private readonly createWidget?: (lines: string[]) => Container,
20
- ) {}
142
+ constructor(herdr: HerdrAutomation, isEditor: (value: unknown) => boolean, createWidget?: (lines: string[]) => Container, widgetRuntime: FleetWidgetRuntime = defaultFleetWidgetRuntime) {
143
+ this.herdr = herdr;
144
+ this.isEditor = isEditor;
145
+ this.createWidget = createWidget;
146
+ this.widgetRuntime = widgetRuntime;
147
+ }
21
148
 
22
149
  get active(): HerdrSubagentsBatchDetails | undefined { return this.activeBatch; }
23
150
 
24
151
  bind(ui: ExtensionUIContext, tuiMode: boolean): void {
25
152
  this.unsubscribeInput?.();
153
+ if (this.ui && this.ui !== ui) {
154
+ this.ui.setWidget("herdr-subagents", undefined, { placement: "belowEditor" });
155
+ this.widget = undefined;
156
+ this.tui = undefined;
157
+ }
26
158
  this.ui = ui;
27
159
  this.unsubscribeInput = tuiMode ? ui.onTerminalInput((data) => this.handleInput(data)) : undefined;
160
+ if (this.activeBatch) this.render();
28
161
  }
29
162
 
30
163
  sync(details: unknown): void {
164
+ if (!this.ui) return;
31
165
  const batch = details as Partial<HerdrSubagentsBatchDetails>;
32
166
  if (!batch.activity || !batch.label || !batch.status) return;
167
+ if (this.activeBatch && batch.runId !== this.activeBatch.runId) return;
168
+ if (this.activeBatch?.runId !== batch.runId) this.selection.reset();
33
169
  this.activeBatch = isActiveStatus(batch.status) ? batch as HerdrSubagentsBatchDetails : undefined;
34
170
  this.render();
35
171
  }
36
172
 
37
173
  setActive(batch: HerdrSubagentsBatchDetails | undefined): void {
174
+ if (!this.ui) return;
175
+ if (this.activeBatch?.runId !== batch?.runId) this.selection.reset();
38
176
  this.activeBatch = batch;
39
177
  this.render();
40
178
  }
@@ -53,18 +191,31 @@ export class FleetController {
53
191
  this.activeBatch = undefined;
54
192
  this.selection.reset();
55
193
  this.render();
194
+ this.ui = undefined;
195
+ this.widget = undefined;
196
+ this.tui = undefined;
56
197
  }
57
198
 
58
199
  render(): void {
59
200
  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" });
201
+ if (!this.activeBatch) {
202
+ this.widget = undefined;
203
+ this.ui.setWidget("herdr-subagents", undefined, { placement: "belowEditor" });
204
+ return;
205
+ }
206
+ if (this.createWidget) {
207
+ this.ui.setWidget("herdr-subagents", (tui) => {
208
+ this.tui = tui;
209
+ return this.createWidget?.(fleetLines(this.activeBatch as HerdrSubagentsBatchDetails, this.selection)) ?? new Container();
210
+ }, { placement: "belowEditor" });
211
+ return;
212
+ }
213
+ if (this.widget) { this.widget.update(this.activeBatch); return; }
214
+ this.ui.setWidget("herdr-subagents", (tui, theme) => {
215
+ this.tui = tui;
216
+ this.widget = new FleetWidget(tui, theme, this.activeBatch as HerdrSubagentsBatchDetails, this.selection, this.widgetRuntime);
217
+ return this.widget;
218
+ }, { placement: "belowEditor" });
68
219
  }
69
220
 
70
221
  private handleInput(data: string): { consume: true } | undefined {