@maxiaochao/pi-toolkit 0.10.2 → 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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 2026-10-08 - v0.10.3
2
+
3
+ - bundle herdr-subagents v0.4.0: add the live compact Fleet footer with authoritative metrics and refreshed media
4
+ - 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.
5
+ - 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.
6
+
1
7
  ## 2026-10-08 - v0.10.2
2
8
 
3
9
  - bundle herdr-subagents v0.3.0: split execution and control tools and unify task runs
@@ -183,6 +189,7 @@
183
189
 
184
190
  ## Unreleased
185
191
 
192
+ - 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
193
  - 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
194
  - add the independent `@maxiaochao/pi-codex-edit` package with model-aware Codex-style `apply_patch`
188
195
  - 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.3",
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,11 @@
1
+ ## 2026-10-08 - v0.4.0
2
+
3
+ - add the live compact Fleet footer with authoritative metrics and refreshed media
4
+ - 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.
5
+ - 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.
6
+ - Preserve exact-pane keyboard focus while extending blocked-response and historical-resume activity projections with durable timing and session metadata.
7
+ - 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.
8
+
1
9
  ## 2026-10-08 - v0.3.0
2
10
 
3
11
  - 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,9 @@ 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
+ While a batch is active, a compact Fleet monitor stays below the editor. Its divider/title row summarizes the run, and each task row shows a hollow or filled status circle, the real persona and task name, status, live active duration, and cumulative child Pi token usage in one compact shared metrics column after the longest row rather than at the terminal edge. Running work uses Pi's warning/yellow semantic color and completed work uses success/green. Token totals use the child session's persisted assistant, tool-result, standalone usage, compaction, and branch-summary usage; they are never estimated. Queued tasks show neither fabricated time nor tokens. The monitor follows Pi theme semantics, truncates by visible terminal columns, and updates session usage from file events while one lightweight repaint timer advances running durations.
76
+
77
+ With an empty editor, press `↓` to enter the list, `↑`/`↓` to choose a task, `Enter` to focus its exact pane, and `Esc` to return. `/herdr-subagents focus <task-number>` remains available as a fallback.
77
78
 
78
79
  ## History
79
80
 
@@ -83,7 +84,7 @@ Continue a saved conversation in a new Herdr tab with `herdr_subagents_control({
83
84
 
84
85
  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
86
 
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.
87
+ The widget shows one row per queued, running, blocked, failed, or completed task. Answering a blocked task or resuming saved history refreshes authoritative persona, timing, session, status, and pane projections as work advances. Active duration accumulates only while the task is running, excluding time spent queued or waiting for an answer. Completed rows remain selectable until the whole batch settles. `/herdr-subagents active` shows the same compact state. Native labels stay short: `SA · <batch>` for the tab and `<order> · <task>` for each pane. When all tasks settle, the tab closes immediately if unfocused or after the user leaves it; a deferred close failure is persisted and shown as a warning.
87
88
 
88
89
  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
90
 
@@ -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 Fleet divider/row formatting, warning/yellow running indicators, success/green completed indicators, persona and task projections, cumulative active duration across blocking/resume, exact Pi usage aggregation from assistant messages, tool results, standalone usage, compaction, and branch summaries, malformed/truncated JSONL tolerance, queued metric omission, and visible-column bounds at narrow widths.
21
+ - Verify live token updates are file-event driven, elapsed repainting exists only while work runs, and widget disposal closes watchers and timers.
20
22
  - Verify blocked-response live Fleet updates, authoritative stale-field replacement, parent-shutdown cancellation, cleanup notifications, atomic state replacement, non-retried uncertain startup operations, and stalled warnings.
21
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 159 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, and persona cases must pass. Release validation also requires extension loading and a real Herdr read-only batch smoke with distinct task panes and final tab cleanup. The latest smoke completed two concurrent tasks, retained its focused run tab through settlement, and closed it automatically after focus moved away.
@@ -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,7 +158,7 @@ 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>();
162
163
  const inFlight = new Set<Promise<unknown>>();
163
164
  let sessionController = new AbortController();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-herdr-subagents",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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
  };
@@ -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
  }