@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 +7 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/packages/herdr-subagents/CHANGELOG.md +8 -0
- package/packages/herdr-subagents/README.md +7 -6
- package/packages/herdr-subagents/TESTING.md +5 -3
- package/packages/herdr-subagents/docs/demo.webm +0 -0
- package/packages/herdr-subagents/docs/screenshot.png +0 -0
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +3 -2
- package/packages/herdr-subagents/package.json +1 -1
- package/packages/herdr-subagents/src/batch-runner.ts +5 -5
- package/packages/herdr-subagents/src/blocking.ts +4 -4
- package/packages/herdr-subagents/src/fleet-controller.ts +164 -16
- package/packages/herdr-subagents/src/fleet.ts +119 -8
- package/packages/herdr-subagents/src/history.ts +11 -5
- package/packages/herdr-subagents/src/records.ts +53 -1
- package/packages/herdr-subagents/src/tool.ts +1 -0
- package/packages/herdr-subagents/tests/run-tests.mjs +143 -4
- package/packages/todo/CHANGELOG.md +4 -0
- package/packages/todo/README.md +19 -9
- package/packages/todo/TESTING.md +9 -7
- package/packages/todo/docs/demo.gif +0 -0
- package/packages/todo/docs/screenshot.png +0 -0
- package/packages/todo/extensions/todo.ts +120 -90
- package/packages/todo/package.json +2 -2
- package/packages/todo/src/todo-state.ts +48 -49
- package/packages/todo/tests/todo-extension.mjs +134 -0
- package/packages/todo/tests/todo-state.mjs +80 -70
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) |
|
|
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.
|
|
4
|
-
"description": "Personal pi coding agent toolkit:
|
|
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
|
-

|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
Binary file
|
|
Binary file
|
|
@@ -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();
|
|
@@ -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:
|
|
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"] =
|
|
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] =
|
|
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] =
|
|
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?:
|
|
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) => (
|
|
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) => (
|
|
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 {
|
|
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?:
|
|
136
|
+
private tui?: TUI;
|
|
137
|
+
private widget?: FleetWidget;
|
|
15
138
|
|
|
16
|
-
constructor(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
61
|
-
this.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
46
|
+
if (this.matchesKey(data, "enter")) return { consume: true, focusTask: this.selectedIndex(taskCount) + 1 };
|
|
40
47
|
if (this.matchesKey(data, "escape")) { this.selecting = false; return { consume: true, changed: true }; }
|
|
41
48
|
this.selecting = false;
|
|
42
49
|
return { changed: true };
|
|
43
50
|
}
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
|
|
53
|
+
function finiteTokenPart(value: unknown): number {
|
|
54
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function usageTokens(value: unknown): number {
|
|
58
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return 0;
|
|
59
|
+
const usage = value as Record<string, unknown>;
|
|
60
|
+
return finiteTokenPart(usage.input) + finiteTokenPart(usage.output) + finiteTokenPart(usage.cacheRead) + finiteTokenPart(usage.cacheWrite);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Match Pi's cumulative footer semantics without trusting an incomplete JSONL tail. */
|
|
64
|
+
export function aggregateSessionTokens(jsonl: string): number {
|
|
65
|
+
let total = 0;
|
|
66
|
+
for (const line of jsonl.split(/\r?\n/)) {
|
|
67
|
+
if (!line.trim()) continue;
|
|
68
|
+
let entry: Record<string, unknown>;
|
|
69
|
+
try {
|
|
70
|
+
const parsed: unknown = JSON.parse(line);
|
|
71
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
72
|
+
entry = parsed as Record<string, unknown>;
|
|
73
|
+
} catch { continue; }
|
|
74
|
+
if (entry.type === "usage") total += usageTokens(entry.usage);
|
|
75
|
+
else if (entry.type === "compaction" || entry.type === "branch_summary") total += usageTokens(entry.usage);
|
|
76
|
+
else if (entry.type === "message") {
|
|
77
|
+
const message = entry.message;
|
|
78
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) continue;
|
|
79
|
+
const record = message as Record<string, unknown>;
|
|
80
|
+
if (record.role === "assistant" || record.role === "toolResult") total += usageTokens(record.usage);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return total;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function readSessionTokens(path: string): Promise<number | undefined> {
|
|
87
|
+
try { return aggregateSessionTokens(await readFile(path, "utf8")); }
|
|
88
|
+
catch { return undefined; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function formatFleetDuration(task: TaskActivity, now = Date.now()): string | undefined {
|
|
92
|
+
if (!task.startedAt || task.status === "queued" || task.status === "starting") return undefined;
|
|
93
|
+
let elapsed = typeof task.elapsedMs === "number" && Number.isFinite(task.elapsedMs) ? Math.max(0, task.elapsedMs) : undefined;
|
|
94
|
+
if (task.status === "running" && task.activeStartedAt) {
|
|
95
|
+
const activeStarted = Date.parse(task.activeStartedAt);
|
|
96
|
+
if (Number.isFinite(activeStarted) && now >= activeStarted) elapsed = (elapsed ?? 0) + (now - activeStarted);
|
|
97
|
+
}
|
|
98
|
+
if (elapsed === undefined) {
|
|
99
|
+
const started = Date.parse(task.startedAt);
|
|
100
|
+
const terminal = task.completedAt ?? (task.status === "blocked" ? task.updatedAt : undefined);
|
|
101
|
+
const ended = terminal ? Date.parse(terminal) : now;
|
|
102
|
+
if (!Number.isFinite(started) || !Number.isFinite(ended) || ended < started) return undefined;
|
|
103
|
+
elapsed = ended - started;
|
|
104
|
+
}
|
|
105
|
+
const seconds = Math.floor(elapsed / 1000);
|
|
106
|
+
if (seconds < 60) return `${seconds}s`;
|
|
107
|
+
const minutes = Math.floor(seconds / 60);
|
|
108
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
|
109
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function formatFleetTokens(tokens: number): string {
|
|
113
|
+
if (tokens < 1_000) return `${tokens} tok`;
|
|
114
|
+
if (tokens < 10_000) return `${(tokens / 1_000).toFixed(1)}k tok`;
|
|
115
|
+
if (tokens < 1_000_000) return `${Math.round(tokens / 1_000)}k tok`;
|
|
116
|
+
return `${(tokens / 1_000_000).toFixed(1)}m tok`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function statusSummary(activity: TaskActivity[]): string {
|
|
47
120
|
const counts = new Map<string, number>();
|
|
48
|
-
for (const task of
|
|
49
|
-
|
|
121
|
+
for (const task of activity) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
|
|
122
|
+
return ["running", "queued", "blocked", "failed", "completed", "cancelled"]
|
|
50
123
|
.filter((status) => counts.has(status)).map((status) => `${status} ${counts.get(status)}`).join(" · ");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function fitColumns(left: string, right: string, width: number): string {
|
|
127
|
+
if (width <= 0) return "";
|
|
128
|
+
if (!right) return truncateToWidth(left, width);
|
|
129
|
+
const rightWidth = visibleWidth(right);
|
|
130
|
+
if (rightWidth + 2 >= width) return truncateToWidth(left, width);
|
|
131
|
+
const fittedLeft = truncateToWidth(left, width - rightWidth - 1);
|
|
132
|
+
const gap = " ".repeat(Math.max(1, width - visibleWidth(fittedLeft) - rightWidth));
|
|
133
|
+
return truncateToWidth(fittedLeft + gap + right, width);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type FleetTheme = Pick<Theme, "fg" | "bold">;
|
|
137
|
+
const plainTheme: FleetTheme = { fg: (_color, text) => text, bold: (text) => text } as FleetTheme;
|
|
138
|
+
|
|
139
|
+
export function fleetLines(
|
|
140
|
+
batch: Pick<BatchResult, "label" | "activity">,
|
|
141
|
+
selection: FleetSelection,
|
|
142
|
+
options: { width?: number; now?: number; metrics?: FleetMetrics; theme?: FleetTheme } = {},
|
|
143
|
+
): string[] {
|
|
144
|
+
const width = Math.max(1, options.width ?? 120);
|
|
145
|
+
const theme = options.theme ?? plainTheme;
|
|
51
146
|
const selected = selection.selectedIndex(batch.activity.length);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
147
|
+
const title = `─ ${theme.bold("Fleet")} · ${batch.label} · ${statusSummary(batch.activity)} `;
|
|
148
|
+
const titleWidth = visibleWidth(title);
|
|
149
|
+
const lines = [truncateToWidth(title + "─".repeat(Math.max(0, width - titleWidth)), width)];
|
|
150
|
+
const rows = batch.activity.map((task, index) => {
|
|
151
|
+
const chosen = selection.isSelecting() && index === selected;
|
|
152
|
+
const circle = task.status === "queued" || task.status === "starting" ? "○" : "●";
|
|
153
|
+
const color = task.status === "completed" ? "success" : task.status === "running" ? "warning" : task.status === "failed" ? "error" : task.status === "blocked" ? "warning" : task.status === "cancelled" ? "dim" : "accent";
|
|
154
|
+
const marker = chosen ? theme.fg("accent", "›") : " ";
|
|
155
|
+
const persona = theme.fg("muted", task.agent || "worker");
|
|
156
|
+
const left = `${marker} ${theme.fg(color, circle)} ${persona} ${task.name} ${theme.fg("dim", task.status)}`;
|
|
157
|
+
const duration = formatFleetDuration(task, options.now);
|
|
158
|
+
const tokens = task.status === "queued" || task.status === "starting" ? undefined : options.metrics?.get(task.index)?.tokens;
|
|
159
|
+
const right = [duration, tokens === undefined ? undefined : formatFleetTokens(tokens)].filter(Boolean).join(" ");
|
|
160
|
+
return { left, right: theme.fg("dim", right) };
|
|
161
|
+
});
|
|
162
|
+
const maxLeft = Math.max(0, ...rows.map(({ left }) => visibleWidth(left)));
|
|
163
|
+
const maxRight = Math.max(0, ...rows.map(({ right }) => visibleWidth(right)));
|
|
164
|
+
const contentWidth = Math.min(width, maxLeft + (maxRight ? 2 + maxRight : 0));
|
|
165
|
+
for (const row of rows) lines.push(fitColumns(row.left, row.right, contentWidth));
|
|
166
|
+
return lines;
|
|
56
167
|
}
|