@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
|
@@ -8,7 +8,7 @@ import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
|
8
8
|
import type { OwnerIdentity } from "./ownership.ts";
|
|
9
9
|
import { ownerRecord } from "./ownership.ts";
|
|
10
10
|
import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
|
|
11
|
-
import { applyReport, applyTaskStatus, isActiveStatus, readReport, readRunRecord, readTaskRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type RunStatus, type TaskRecord, type TaskStatus } from "./records.ts";
|
|
11
|
+
import { applyReport, applyTaskStatus, isActiveStatus, projectTaskActivity, readReport, readRunRecord, readTaskRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type RunStatus, type TaskActivity, type TaskRecord, type TaskStatus } from "./records.ts";
|
|
12
12
|
import { continuationSystemPrompt } from "./protocol.ts";
|
|
13
13
|
import { isAbortError } from "./errors.ts";
|
|
14
14
|
import { findRunDirectory } from "./run-locator.ts";
|
|
@@ -96,9 +96,10 @@ export async function cleanSubagentRun(cwd: string, runId: string): Promise<bool
|
|
|
96
96
|
return true;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
export interface ResumeHistoricalTaskOptions { herdr?: HerdrAutomation; signal?: AbortSignal; owner?: OwnerIdentity; onCleanupError?: (message: string) => void; stalledWarningSeconds?: number; onStalled?: (message: string) => void; }
|
|
99
|
+
export interface ResumeHistoricalTaskOptions { herdr?: HerdrAutomation; signal?: AbortSignal; owner?: OwnerIdentity; onCleanupError?: (message: string) => void; stalledWarningSeconds?: number; onStalled?: (message: string) => void; onUpdate?: (result: ResumeHistoricalTaskResult) => void; }
|
|
100
|
+
export interface ResumeHistoricalTaskResult { runId: string; label: string; status: TaskStatus; summary: string; documents: PersistedDocument[]; tabId: string; paneId: string; sessionFile: string; activity: TaskActivity[]; }
|
|
100
101
|
|
|
101
|
-
export async function resumeHistoricalTask(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions = {}): Promise<
|
|
102
|
+
export async function resumeHistoricalTask(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions = {}): Promise<ResumeHistoricalTaskResult> {
|
|
102
103
|
const runDirectory = await findRunDirectory(join(cwd, ".pi", "herdr-subagents", "runs"), runId);
|
|
103
104
|
if (!runDirectory) throw new Error(`Unknown Subagent run '${runId}'.`);
|
|
104
105
|
const lockDirectory = join(runDirectory, ".resume.lock");
|
|
@@ -111,7 +112,7 @@ export async function resumeHistoricalTask(cwd: string, runId: string, taskNumbe
|
|
|
111
112
|
finally { await rmdir(lockDirectory); }
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions): Promise<
|
|
115
|
+
async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions): Promise<ResumeHistoricalTaskResult> {
|
|
115
116
|
const herdr = options.herdr ?? new CliHerdrAutomation();
|
|
116
117
|
const { signal, onCleanupError } = options;
|
|
117
118
|
const run = (await listSubagentHistory(cwd)).find((candidate) => candidate.id === runId);
|
|
@@ -158,6 +159,8 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
|
|
|
158
159
|
writeJsonAtomic(turnFile, { attempt, prompt, status: "running", sessionFile: historical.sessionFile, tabId: tab.tabId, paneId: tab.paneId, agentName, startedAt: new Date().toISOString() }),
|
|
159
160
|
writeJsonAtomic(activeRunFile, activeRunRecord),
|
|
160
161
|
]);
|
|
162
|
+
const runningActivity = await Promise.all(run.tasks.map(async (entry, index) => projectTaskActivity(await readTaskRecord(join(entry.recordDirectory, "task.json")), index)));
|
|
163
|
+
options.onUpdate?.({ runId, label: run.label, status: "running", summary: `${historical.name} is continuing.`, documents: [], tabId: tab.tabId, paneId: tab.paneId, sessionFile: historical.sessionFile, activity: runningActivity });
|
|
161
164
|
report = (await promptLiveAgent({ herdr, task: { ...task, id: historical.id, sessionFile: historical.sessionFile }, paneId: tab.paneId, prompt, systemPromptFile: followupPrompt, agentName, signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => options.onStalled?.(`${historical.name} appears stalled; it is still running.`) })).report;
|
|
162
165
|
status = applyReport(task, report);
|
|
163
166
|
} catch (error) {
|
|
@@ -182,7 +185,8 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
|
|
|
182
185
|
if (status !== "blocked") {
|
|
183
186
|
await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError, failureStatus: "failed" });
|
|
184
187
|
}
|
|
185
|
-
|
|
188
|
+
const activity = await Promise.all(run.tasks.map(async (entry, index) => projectTaskActivity(await readTaskRecord(join(entry.recordDirectory, "task.json")), index)));
|
|
189
|
+
return { runId, label: run.label, status, summary: report.summary, documents: report.documents ?? [], tabId: tab.tabId, paneId: tab.paneId, sessionFile: historical.sessionFile, activity };
|
|
186
190
|
} catch (error) {
|
|
187
191
|
const message = error instanceof Error ? error.message : String(error);
|
|
188
192
|
const completedAt = new Date().toISOString();
|
|
@@ -204,6 +208,8 @@ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber
|
|
|
204
208
|
writeJsonAtomic(turnFile, { attempt, prompt, status, summary: report.summary, documents: report.documents, error: message, sessionFile: historical.sessionFile, ...(tab ? { tabId: tab.tabId, paneId: tab.paneId } : {}), completedAt }),
|
|
205
209
|
writeJsonAtomic(join(run.recordDirectory, "run.json"), runRecord),
|
|
206
210
|
]);
|
|
211
|
+
const activity = await Promise.all(run.tasks.map(async (entry, index) => projectTaskActivity(await readTaskRecord(join(entry.recordDirectory, "task.json")), index)));
|
|
212
|
+
options.onUpdate?.({ runId, label: run.label, status, summary: report.summary, documents: report.documents, tabId: tab?.tabId ?? "", paneId: tab?.paneId ?? "", sessionFile: historical.sessionFile, activity });
|
|
207
213
|
if (tab) await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError, failureStatus: "failed" });
|
|
208
214
|
throw error;
|
|
209
215
|
}
|
|
@@ -64,6 +64,8 @@ export interface TaskRecord {
|
|
|
64
64
|
systemPrompt?: string;
|
|
65
65
|
sessionFile?: string;
|
|
66
66
|
startedAt?: string;
|
|
67
|
+
activeStartedAt?: string;
|
|
68
|
+
elapsedMs?: number;
|
|
67
69
|
updatedAt?: string;
|
|
68
70
|
completedAt?: string;
|
|
69
71
|
tabId?: string;
|
|
@@ -76,6 +78,38 @@ export interface TaskRecord {
|
|
|
76
78
|
error?: string;
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
export interface TaskActivity {
|
|
82
|
+
index: number;
|
|
83
|
+
name: string;
|
|
84
|
+
status: TaskStatus;
|
|
85
|
+
agent: string;
|
|
86
|
+
prompt?: string;
|
|
87
|
+
paneId?: string;
|
|
88
|
+
sessionFile?: string;
|
|
89
|
+
startedAt?: string;
|
|
90
|
+
activeStartedAt?: string;
|
|
91
|
+
elapsedMs?: number;
|
|
92
|
+
completedAt?: string;
|
|
93
|
+
updatedAt?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function projectTaskActivity(task: TaskRecord, index = task.order - 1): TaskActivity {
|
|
97
|
+
return {
|
|
98
|
+
index,
|
|
99
|
+
name: String(task.name ?? task.agent ?? `Task ${index + 1}`),
|
|
100
|
+
status: task.status,
|
|
101
|
+
agent: String(task.agent ?? "worker"),
|
|
102
|
+
...(task.prompt ? { prompt: task.prompt } : {}),
|
|
103
|
+
...(task.paneId ? { paneId: task.paneId } : {}),
|
|
104
|
+
...(task.sessionFile ? { sessionFile: task.sessionFile } : {}),
|
|
105
|
+
...(task.startedAt ? { startedAt: task.startedAt } : {}),
|
|
106
|
+
...(task.activeStartedAt ? { activeStartedAt: task.activeStartedAt } : {}),
|
|
107
|
+
...(typeof task.elapsedMs === "number" ? { elapsedMs: task.elapsedMs } : {}),
|
|
108
|
+
...(task.completedAt ? { completedAt: task.completedAt } : {}),
|
|
109
|
+
...(task.updatedAt ? { updatedAt: task.updatedAt } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
79
113
|
export interface TurnRecord {
|
|
80
114
|
attempt: number;
|
|
81
115
|
prompt?: string;
|
|
@@ -184,7 +218,25 @@ export function applyLifecycleStatus<T extends { status: string; question?: stri
|
|
|
184
218
|
}
|
|
185
219
|
|
|
186
220
|
export function applyTaskStatus<T extends TaskRecord | TurnRecord>(record: T, status: TaskStatus, options: { question?: string; error?: string; at?: string } = {}): T {
|
|
187
|
-
|
|
221
|
+
const at = options.at ?? new Date().toISOString();
|
|
222
|
+
if ("order" in record) {
|
|
223
|
+
if (record.status === "running" && status !== "running") {
|
|
224
|
+
const activeStarted = Date.parse(record.activeStartedAt ?? record.startedAt ?? "");
|
|
225
|
+
const ended = Date.parse(at);
|
|
226
|
+
if (Number.isFinite(activeStarted) && Number.isFinite(ended) && ended >= activeStarted) record.elapsedMs = Math.max(0, Number(record.elapsedMs ?? 0)) + (ended - activeStarted);
|
|
227
|
+
delete record.activeStartedAt;
|
|
228
|
+
} else if (status === "running" && record.status !== "running") {
|
|
229
|
+
if (!(typeof record.elapsedMs === "number" && Number.isFinite(record.elapsedMs))) {
|
|
230
|
+
const frozenAt = record.status === "blocked" ? record.updatedAt : isSettledRunStatus(record.status) ? record.completedAt : undefined;
|
|
231
|
+
const started = Date.parse(record.startedAt ?? "");
|
|
232
|
+
const ended = Date.parse(frozenAt ?? "");
|
|
233
|
+
if (Number.isFinite(started) && Number.isFinite(ended) && ended >= started) record.elapsedMs = ended - started;
|
|
234
|
+
}
|
|
235
|
+
record.startedAt ??= at;
|
|
236
|
+
record.activeStartedAt = at;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return applyLifecycleStatus(record, status, { ...options, at });
|
|
188
240
|
}
|
|
189
241
|
|
|
190
242
|
export async function settleAttemptFailure<T extends TaskRecord | TurnRecord>(
|
|
@@ -129,6 +129,7 @@ const actionHandlers = {
|
|
|
129
129
|
try {
|
|
130
130
|
const result = await resumeHistoricalTask(ctx.cwd, params.runId as string, params.task as number, (params.prompt as string).trim(), {
|
|
131
131
|
herdr: dependencies.herdr, signal, owner: dependencies.owner, stalledWarningSeconds: discovery.settings.stalledWarningSeconds,
|
|
132
|
+
onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: partial }),
|
|
132
133
|
onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "resume", runId: params.runId, stalled: true } }),
|
|
133
134
|
onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "resume", runId: params.runId, cleanupError: text } }),
|
|
134
135
|
});
|
|
@@ -2,6 +2,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSyn
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { getEventListeners } from "node:events";
|
|
5
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
5
6
|
import { discoverPackageAgents, parseAgentMarkdown } from "../src/personas.ts";
|
|
6
7
|
import { loadSubagentConfiguration } from "../src/config.ts";
|
|
7
8
|
import { runHerdrSubagents } from "../src/runner.ts";
|
|
@@ -14,10 +15,11 @@ import { cancelQueuedRuns, loadQueuedRuns, persistQueuedRun } from "../src/queue
|
|
|
14
15
|
import { cancelActiveSubagentRuns, reconcileSubagentRuns } from "../src/reconcile.ts";
|
|
15
16
|
import { continueQueuedRun } from "../src/continue-run.ts";
|
|
16
17
|
import { CliHerdrAutomation } from "../src/herdr.ts";
|
|
17
|
-
import { fleetEditorHasFocus, FleetSelection, fleetLines } from "../src/fleet.ts";
|
|
18
|
+
import { aggregateSessionTokens, fleetEditorHasFocus, FleetSelection, fleetLines, formatFleetDuration, formatFleetTokens } from "../src/fleet.ts";
|
|
19
|
+
import { FleetController, FleetWidget } from "../src/fleet-controller.ts";
|
|
18
20
|
import { HERDR_ACTIONS, validateControlParams, validateRunParams } from "../src/validation.ts";
|
|
19
21
|
import { resultPathForAttempt } from "../src/result-path.ts";
|
|
20
|
-
import { isSettledRunStatus, readTaskRecord } from "../src/records.ts";
|
|
22
|
+
import { applyTaskStatus, isSettledRunStatus, projectTaskActivity, readTaskRecord } from "../src/records.ts";
|
|
21
23
|
import { RunPaneAllocator } from "../src/run-pane-allocator.ts";
|
|
22
24
|
import { aggregateTaskStatus } from "../src/run-status.ts";
|
|
23
25
|
import { isAbortError } from "../src/errors.ts";
|
|
@@ -160,7 +162,137 @@ try {
|
|
|
160
162
|
check("Fleet Escape exits selection", fleet.handle("\u001b[B", "", 2).consume && fleet.handle("\u001b", "", 2).consume && !fleet.isSelecting());
|
|
161
163
|
const enhancedFleet = new FleetSelection((data, key) => data === `enhanced-${key}`);
|
|
162
164
|
check("Fleet delegates enhanced keyboard decoding to Pi key matching", enhancedFleet.handle("enhanced-down", "", 2).consume && enhancedFleet.handle("enhanced-up", "", 2).consume && enhancedFleet.handle("enhanced-enter", "", 2).focusTask === 2);
|
|
165
|
+
const shrinkingFleet = new FleetSelection();
|
|
166
|
+
shrinkingFleet.handle("\u001b[B", "", 3);
|
|
167
|
+
shrinkingFleet.handle("\u001b[A", "", 3);
|
|
168
|
+
check("Fleet Enter focuses the row displayed after activity shrinks", shrinkingFleet.selectedIndex(2) === 1 && shrinkingFleet.handle("\r", "", 2).focusTask === 2);
|
|
163
169
|
check("Fleet ignores unknown focus, dialogs, and overlays", fleetEditorHasFocus(undefined, () => false) === false && fleetEditorHasFocus({ kind: "dialog" }, () => false) === false && fleetEditorHasFocus({ kind: "editor" }, () => true) === true);
|
|
170
|
+
const usage = (input, output, cacheRead, cacheWrite) => ({ input, output, cacheRead, cacheWrite, totalTokens: input + output + cacheRead + cacheWrite, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
|
|
171
|
+
const usageJsonl = [
|
|
172
|
+
{ type: "message", message: { role: "assistant", usage: usage(1, 2, 3, 4) } },
|
|
173
|
+
{ type: "usage", usage: usage(5, 6, 7, 8) },
|
|
174
|
+
{ type: "message", message: { role: "toolResult", usage: usage(9, 10, 11, 12) } },
|
|
175
|
+
{ type: "compaction", usage: usage(1, 1, 1, 1) },
|
|
176
|
+
{ type: "branch_summary", usage: usage(2, 2, 2, 2) },
|
|
177
|
+
{ type: "message", message: { role: "user", usage: usage(99, 99, 99, 99) } },
|
|
178
|
+
].map(JSON.stringify).join("\n");
|
|
179
|
+
check("Fleet aggregates every authoritative Pi usage-bearing entry", aggregateSessionTokens(usageJsonl) === 90);
|
|
180
|
+
check("Fleet ignores malformed and truncated JSONL while preserving valid usage", aggregateSessionTokens(`${usageJsonl.split("\n")[0]}\nnot-json\n{\"type\":\"message\",\"message\":`) === 10);
|
|
181
|
+
const elapsedStart = "2026-10-08T07:00:00.000Z";
|
|
182
|
+
check("Fleet elapsed formatting is live and blocked duration freezes", formatFleetDuration({ index: 0, name: "live", agent: "explorer", status: "running", startedAt: elapsedStart }, Date.parse(elapsedStart) + 65_000) === "1m 5s" && formatFleetDuration({ index: 0, name: "blocked", agent: "explorer", status: "blocked", startedAt: elapsedStart, updatedAt: "2026-10-08T07:00:05.000Z" }, Date.parse(elapsedStart) + 99_000) === "5s");
|
|
183
|
+
const timedTask = { id: "timed", runId: "run", order: 1, name: "timed", agent: "worker", status: "queued" };
|
|
184
|
+
applyTaskStatus(timedTask, "running", { at: elapsedStart });
|
|
185
|
+
applyTaskStatus(timedTask, "blocked", { at: "2026-10-08T07:00:05.000Z" });
|
|
186
|
+
applyTaskStatus(timedTask, "running", { at: "2026-10-08T07:01:00.000Z" });
|
|
187
|
+
check("Fleet duration accumulates active work without counting blocked wait", formatFleetDuration(projectTaskActivity(timedTask), Date.parse("2026-10-08T07:01:07.000Z")) === "12s");
|
|
188
|
+
const legacyTimedTask = { id: "legacy", runId: "run", order: 1, name: "legacy", agent: "worker", status: "blocked", startedAt: elapsedStart, updatedAt: "2026-10-08T07:00:05.000Z" };
|
|
189
|
+
applyTaskStatus(legacyTimedTask, "running", { at: "2026-10-08T07:01:00.000Z" });
|
|
190
|
+
check("Fleet timing migrates legacy blocked records without losing prior work", formatFleetDuration(projectTaskActivity(legacyTimedTask), Date.parse("2026-10-08T07:01:07.000Z")) === "12s");
|
|
191
|
+
const recoveredTimedTask = { id: "recovered", runId: "run", order: 1, name: "recovered", agent: "worker", status: "running", startedAt: elapsedStart };
|
|
192
|
+
applyTaskStatus(recoveredTimedTask, "cancelled", { at: "2026-10-08T07:00:09.000Z" });
|
|
193
|
+
check("Fleet timing settles legacy recovered-running records", formatFleetDuration(projectTaskActivity(recoveredTimedTask), Date.parse("2026-10-08T08:00:00.000Z")) === "9s");
|
|
194
|
+
check("Fleet formats true token totals without estimates", formatFleetTokens(999) === "999 tok" && formatFleetTokens(1_250) === "1.3k tok" && formatFleetTokens(12_400) === "12k tok");
|
|
195
|
+
const metricFleet = new FleetSelection();
|
|
196
|
+
const metricBatch = { label: "audit", activity: [
|
|
197
|
+
{ index: 0, name: "inspect auth", agent: "explorer", status: "running", startedAt: elapsedStart, sessionFile: "/tmp/live.jsonl" },
|
|
198
|
+
{ index: 1, name: "review patch", agent: "reviewer", status: "queued", sessionFile: "/tmp/queued.jsonl" },
|
|
199
|
+
] };
|
|
200
|
+
const metricLines = fleetLines(metricBatch, metricFleet, { width: 80, now: Date.parse(elapsedStart) + 65_000, metrics: new Map([[0, { tokens: 1_250 }], [1, { tokens: 999 }]]) });
|
|
201
|
+
check("Fleet rows show persona task status elapsed and right-aligned true usage", metricLines[1].includes("explorer") && metricLines[1].includes("inspect auth") && metricLines[1].includes("running") && metricLines[1].endsWith("1m 5s 1.3k tok"));
|
|
202
|
+
const compactLines = fleetLines({ label: "compact", activity: [
|
|
203
|
+
{ index: 0, name: "API", agent: "explorer", status: "running", startedAt: elapsedStart },
|
|
204
|
+
{ index: 1, name: "Recovery", agent: "explorer", status: "completed", startedAt: elapsedStart, completedAt: "2026-10-08T07:00:09.000Z" },
|
|
205
|
+
] }, new FleetSelection(), { width: 160, now: Date.parse(elapsedStart) + 21_000, metrics: new Map([[0, { tokens: 4_800 }], [1, { tokens: 900 }]]) });
|
|
206
|
+
check("wide Fleet rows align metrics without terminal-width whitespace", visibleWidth(compactLines[1]) < 80 && compactLines[1].indexOf("tok") === compactLines[2].indexOf("tok"));
|
|
207
|
+
const semanticColors = [];
|
|
208
|
+
fleetLines({ label: "colors", activity: [
|
|
209
|
+
{ index: 0, name: "doing", agent: "worker", status: "running" },
|
|
210
|
+
{ index: 1, name: "done", agent: "worker", status: "completed" },
|
|
211
|
+
] }, new FleetSelection(), { theme: { fg: (color, text) => { semanticColors.push([color, text]); return text; }, bold: (text) => text } });
|
|
212
|
+
check("Fleet uses warning yellow for running work and success green for completed work", semanticColors.some(([color, text]) => color === "warning" && text === "●") && semanticColors.some(([color, text]) => color === "success" && text === "●"));
|
|
213
|
+
check("queued Fleet rows fabricate neither duration nor usage", metricLines[2].includes("reviewer") && metricLines[2].includes("queued") && !metricLines[2].includes("tok") && !metricLines[2].match(/\d+[smh]/));
|
|
214
|
+
check("Fleet rendering fits narrow terminal widths", [1, 8, 16, 24].every((width) => fleetLines(metricBatch, metricFleet, { width, metrics: new Map([[0, { tokens: 1_250 }]]) }).every((line) => visibleWidth(line) <= width)));
|
|
215
|
+
let liveTokens = 42;
|
|
216
|
+
let nextRuntimeId = 1;
|
|
217
|
+
const runtimeIntervals = new Map();
|
|
218
|
+
const runtimeTimeouts = new Map();
|
|
219
|
+
const runtimeWatchers = [];
|
|
220
|
+
let clearedIntervals = 0;
|
|
221
|
+
const fleetRuntime = {
|
|
222
|
+
watchSession(path, onChange, onError) { const entry = { path, onChange, onError, closed: false }; runtimeWatchers.push(entry); return { close() { entry.closed = true; } }; },
|
|
223
|
+
setInterval(callback) { const id = nextRuntimeId++; runtimeIntervals.set(id, callback); return id; },
|
|
224
|
+
clearInterval(id) { if (runtimeIntervals.delete(id)) clearedIntervals += 1; },
|
|
225
|
+
setTimeout(callback) { const id = nextRuntimeId++; runtimeTimeouts.set(id, callback); return id; },
|
|
226
|
+
clearTimeout(id) { runtimeTimeouts.delete(id); },
|
|
227
|
+
async readTokens() { return liveTokens; },
|
|
228
|
+
};
|
|
229
|
+
let requestedFleetRenders = 0;
|
|
230
|
+
const liveWidgetBatch = { runId: "live", label: "live", status: "running", activity: [{ index: 0, name: "inspect", agent: "explorer", status: "running", startedAt: elapsedStart, activeStartedAt: elapsedStart, sessionFile: "/tmp/fleet-live/session.jsonl", paneId: "p1" }], prompts: [], tasks: [], documents: [], summary: "", ok: false, requestedConcurrency: 1, effectiveConcurrency: 1, recordDirectory: "/tmp/fleet-live" };
|
|
231
|
+
const liveWidget = new FleetWidget({ requestRender() { requestedFleetRenders += 1; } }, { fg: (_color, text) => text, bold: (text) => text }, liveWidgetBatch, new FleetSelection(), fleetRuntime);
|
|
232
|
+
for (const callback of [...runtimeTimeouts.values()]) callback();
|
|
233
|
+
runtimeTimeouts.clear();
|
|
234
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
235
|
+
check("Fleet widget reads usage on demand and uses one running repaint timer", liveWidget.render(80)[1].includes("42 tok") && runtimeWatchers.length === 1 && runtimeIntervals.size === 1 && requestedFleetRenders > 0);
|
|
236
|
+
liveTokens = 84;
|
|
237
|
+
runtimeWatchers[0].onChange();
|
|
238
|
+
for (const callback of [...runtimeTimeouts.values()]) callback();
|
|
239
|
+
runtimeTimeouts.clear();
|
|
240
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
241
|
+
check("Fleet widget refreshes token usage from file events", liveWidget.render(80)[1].includes("84 tok"));
|
|
242
|
+
liveWidget.update({ ...liveWidgetBatch, status: "blocked", activity: [{ ...liveWidgetBatch.activity[0], status: "blocked", updatedAt: "2026-10-08T07:00:05.000Z", activeStartedAt: undefined, elapsedMs: 5_000 }] });
|
|
243
|
+
check("Fleet widget stops elapsed repainting when work blocks", runtimeIntervals.size === 0 && clearedIntervals === 1);
|
|
244
|
+
liveWidget.dispose();
|
|
245
|
+
const watcherClosed = runtimeWatchers.every((watcher) => watcher.closed);
|
|
246
|
+
liveWidget.update(liveWidgetBatch);
|
|
247
|
+
check("Fleet widget disposal closes watchers and prevents timer restart", watcherClosed && runtimeIntervals.size === 0 && runtimeTimeouts.size === 0);
|
|
248
|
+
const makeFleetUi = () => {
|
|
249
|
+
let component;
|
|
250
|
+
let factoryInstalls = 0;
|
|
251
|
+
return {
|
|
252
|
+
ui: {
|
|
253
|
+
setWidget(_key, content) { component?.dispose?.(); component = undefined; if (typeof content === "function") { factoryInstalls += 1; component = content({ requestRender() {}, focusedComponent: undefined }, { fg: (_color, text) => text, bold: (text) => text }); } },
|
|
254
|
+
onTerminalInput() { return () => {}; },
|
|
255
|
+
getEditorText() { return ""; },
|
|
256
|
+
},
|
|
257
|
+
get component() { return component; },
|
|
258
|
+
get factoryInstalls() { return factoryInstalls; },
|
|
259
|
+
};
|
|
260
|
+
};
|
|
261
|
+
const firstFleetUi = makeFleetUi();
|
|
262
|
+
const secondFleetUi = makeFleetUi();
|
|
263
|
+
const fleetController = new FleetController({}, () => false, undefined, fleetRuntime);
|
|
264
|
+
fleetController.bind(firstFleetUi.ui, true);
|
|
265
|
+
fleetController.setActive(liveWidgetBatch);
|
|
266
|
+
const firstUiWidget = firstFleetUi.component;
|
|
267
|
+
fleetController.bind(secondFleetUi.ui, true);
|
|
268
|
+
fleetController.sync(liveWidgetBatch);
|
|
269
|
+
check("Fleet UI rebinding replaces the disposed widget in the new context", firstUiWidget !== secondFleetUi.component && secondFleetUi.factoryInstalls === 1);
|
|
270
|
+
fleetController.sync({ ...liveWidgetBatch, runId: "foreign", label: "foreign" });
|
|
271
|
+
check("foreign run updates do not replace the active Fleet owner", fleetController.active?.runId === "live" && secondFleetUi.factoryInstalls === 1);
|
|
272
|
+
const installsBeforeShutdown = secondFleetUi.factoryInstalls;
|
|
273
|
+
fleetController.shutdown();
|
|
274
|
+
fleetController.sync(liveWidgetBatch);
|
|
275
|
+
check("late updates after shutdown cannot recreate Fleet resources", secondFleetUi.factoryInstalls === installsBeforeShutdown && runtimeIntervals.size === 0 && !fleetController.active);
|
|
276
|
+
let resolveStaleTokens;
|
|
277
|
+
const staleTimeouts = new Map();
|
|
278
|
+
const staleWatchers = [];
|
|
279
|
+
let staleId = 1;
|
|
280
|
+
const staleRuntime = {
|
|
281
|
+
watchSession(path, onChange) { const watcher = { path, onChange, closed: false }; staleWatchers.push(watcher); return { close() { watcher.closed = true; } }; },
|
|
282
|
+
setInterval() { return staleId++; },
|
|
283
|
+
clearInterval() {},
|
|
284
|
+
setTimeout(callback) { const id = staleId++; staleTimeouts.set(id, callback); return id; },
|
|
285
|
+
clearTimeout(id) { staleTimeouts.delete(id); },
|
|
286
|
+
readTokens() { return new Promise((resolve) => { resolveStaleTokens = resolve; }); },
|
|
287
|
+
};
|
|
288
|
+
const staleWidget = new FleetWidget({ requestRender() {} }, { fg: (_color, text) => text, bold: (text) => text }, liveWidgetBatch, new FleetSelection(), staleRuntime);
|
|
289
|
+
for (const callback of [...staleTimeouts.values()]) callback();
|
|
290
|
+
staleTimeouts.clear();
|
|
291
|
+
staleWidget.update({ ...liveWidgetBatch, runId: "replacement", activity: [{ ...liveWidgetBatch.activity[0], sessionFile: "/tmp/fleet-replacement/session.jsonl" }] });
|
|
292
|
+
resolveStaleTokens(999);
|
|
293
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
294
|
+
check("Fleet ignores stale token reads after session sources change", !staleWidget.render(80)[1].includes("999 tok"));
|
|
295
|
+
staleWidget.dispose();
|
|
164
296
|
let unknownStatusRejected = false;
|
|
165
297
|
try { aggregateTaskStatus(["unknown"]); } catch { unknownStatusRejected = true; }
|
|
166
298
|
check("task status aggregation rejects unknown states", unknownStatusRejected);
|
|
@@ -188,6 +320,7 @@ try {
|
|
|
188
320
|
const createRequest = herdr.calls.find(([name]) => name === "createTab")[1];
|
|
189
321
|
const startRequest = herdr.calls.find(([name]) => name === "startAgent")[1];
|
|
190
322
|
check("named unfocused tab in parent workspace", createRequest.workspaceId === "w1" && createRequest.cwd === root && createRequest.label === "SA · auth-review" && createRequest.focus === false);
|
|
323
|
+
check("ordinary execution never steals pane focus", !herdr.calls.some(([name]) => name === "focusPane"));
|
|
191
324
|
check("child environment points at durable task", createRequest.env.PI_HERDR_SUBAGENTS_CHILD === "1" && createRequest.env.PI_HERDR_SUBAGENTS_TASK_DIR.includes(join(".pi", "herdr-subagents", "runs")));
|
|
192
325
|
check("persistent isolated child Pi", startRequest.kind === "pi" && startRequest.paneId === "w1:p2" && startRequest.args.includes("--session") && startRequest.args.includes("--no-extensions") && startRequest.args.includes("--extension") && startRequest.args.includes("--no-skills") && startRequest.args.includes("--model") && startRequest.args.includes("fake/model") && startRequest.args.includes("--tools") && startRequest.args.includes("read,grep,subagent_report"));
|
|
193
326
|
const promptRequest = herdr.calls.find(([name]) => name === "promptAgent")[1];
|
|
@@ -309,6 +442,7 @@ try {
|
|
|
309
442
|
const counts = activityCounts(activeUpdate);
|
|
310
443
|
const focusHerdr = fakeHerdr();
|
|
311
444
|
const runningTask = activeUpdate.activity.find((task) => task.status === "running");
|
|
445
|
+
check("activity projections expose true persona task timing and session sources", runningTask.agent === "explorer" && runningTask.name === "one" && runningTask.prompt === "one slow" && typeof runningTask.startedAt === "string" && runningTask.sessionFile.endsWith("session.jsonl"));
|
|
312
446
|
const focused = await focusActiveTask(activeUpdate, runningTask.index + 1, focusHerdr);
|
|
313
447
|
check("active progress exposes widget counts and exact pane navigation", counts.running > 0 && counts.queued > 0 && focused && focusHerdr.calls.at(-1)[0] === "focusPane" && focusHerdr.calls.at(-1)[1].paneId === runningTask.paneId);
|
|
314
448
|
const settledFocused = await focusActiveTask({ activity: [{ index: 0, name: "settled", status: "completed", paneId: "w1:p-settled" }] }, 1, focusHerdr);
|
|
@@ -548,9 +682,11 @@ try {
|
|
|
548
682
|
const stableHistory = history.find((run) => run.id === backgroundId);
|
|
549
683
|
check("history lists compact project-local runs and tasks", Boolean(stableHistory?.tasks.length) && historyText(history).includes("stable") && !historyText(history).includes("FULL stable"));
|
|
550
684
|
const historyHerdr = fakeHerdr();
|
|
551
|
-
const
|
|
685
|
+
const historicalUpdates = [];
|
|
686
|
+
const historicalResume = await executeHerdrSubagents({ action: "resume", runId: backgroundId, task: 1, prompt: "follow up" }, undefined, (update) => historicalUpdates.push(update.details), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: historyHerdr });
|
|
552
687
|
const historyStartRequest = historyHerdr.calls.find(([name]) => name === "startAgent")[1];
|
|
553
688
|
check("historical resume preserves the saved Pi session and persona prompt", !historicalResume.isError && historyHerdr.calls[0][0] === "createTab" && historyStartRequest.args.includes(stableHistory.tasks[0].sessionFile) && historyStartRequest.args.includes(stableHistory.tasks[0].model) && historyStartRequest.args.some((value) => String(value).includes("subagent_report")) && existsSync(join(stableHistory.tasks[0].recordDirectory, "turns", "02.json")) && readFileSync(join(stableHistory.tasks[0].recordDirectory, "followup-system-prompt.md"), "utf8").includes("Investigate the assigned question without modifying files"));
|
|
689
|
+
check("historical resume streams authoritative Fleet activity", historicalUpdates.some((update) => update.status === "running" && update.activity?.[0]?.agent === "explorer" && update.activity[0].sessionFile === stableHistory.tasks[0].sessionFile));
|
|
554
690
|
const historicalTaskRecord = JSON.parse(readFileSync(join(stableHistory.tasks[0].recordDirectory, "task.json"), "utf8"));
|
|
555
691
|
const historicalTurnRecord = JSON.parse(readFileSync(join(stableHistory.tasks[0].recordDirectory, "turns", "02.json"), "utf8"));
|
|
556
692
|
check("historical task and turn retain their tab identity", historicalTaskRecord.tabId === historicalResume.details.tabId && historicalTurnRecord.tabId === historicalResume.details.tabId);
|
|
@@ -618,9 +754,11 @@ try {
|
|
|
618
754
|
check("historical pre-prompt failure replaces stale state and report", prePromptFailure.isError && prePromptFailedTask.status === "failed" && !("question" in prePromptFailedTask) && prePromptFailedTask.error.includes("historical startup failed") && prePromptFailedTask.completedAt !== "2000-01-01T00:00:00.000Z" && prePromptFailureReport.status === "failed" && prePromptFailureReport.error.includes("historical startup failed"));
|
|
619
755
|
check("historical resume releases the run lock after failure", !existsSync(join(stableHistory.recordDirectory, ".resume.lock")));
|
|
620
756
|
const postPromptFailureHerdr = fakeHerdr("post-fail");
|
|
621
|
-
const
|
|
757
|
+
const postPromptFailureUpdates = [];
|
|
758
|
+
const postPromptFailure = await executeHerdrSubagents({ action: "resume", runId: backgroundId, task: 1, prompt: "fails after submit" }, undefined, (update) => postPromptFailureUpdates.push(update.details), { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: postPromptFailureHerdr });
|
|
622
759
|
const postPromptFailureReport = JSON.parse(readFileSync(join(stableHistory.tasks[0].recordDirectory, "report.json"), "utf8"));
|
|
623
760
|
check("historical post-prompt failure persists its authoritative report", postPromptFailure.isError && postPromptFailureReport.status === "failed" && postPromptFailureReport.error.includes("connection lost after prompt submission"));
|
|
761
|
+
check("historical resume failure emits terminal Fleet activity", postPromptFailureUpdates.some((update) => update.status === "running") && postPromptFailureUpdates.at(-1).status === "failed" && postPromptFailureUpdates.at(-1).activity.every((task) => task.status !== "running"));
|
|
624
762
|
const shutdownBlockedHerdr = fakeHerdr("blocked");
|
|
625
763
|
const shutdownBlocked = await executeHerdrSubagents({ tasks: [{ name: "blocked-at-shutdown", prompt: "need input", agent: "explorer" }] }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: shutdownBlockedHerdr });
|
|
626
764
|
await cancelActiveSubagentRuns(root, shutdownBlockedHerdr);
|
|
@@ -674,6 +812,7 @@ try {
|
|
|
674
812
|
const authoritativeReport = JSON.parse(readFileSync(join(authoritativeTaskDirectory, "report.json"), "utf8"));
|
|
675
813
|
check("valid persisted report remains authoritative after Herdr prompt transport failure", !authoritativeAfterPromptFailure.isError && authoritativeTask.status === "completed" && authoritativeReport.status === "completed" && authoritativeAfterPromptFailure.details.tasks[0].summary === authoritativeReport.summary);
|
|
676
814
|
check("production batch path never passes print mode", batchStarts.every((request) => !request.args.includes("--print")));
|
|
815
|
+
check("production batch path never steals pane focus", !batchHerdr.calls.some(([name]) => name === "focusPane"));
|
|
677
816
|
|
|
678
817
|
const atomicFile = join(root, "atomic.json");
|
|
679
818
|
await Promise.all(Array.from({ length: 12 }, (_, index) => writeJsonAtomic(atomicFile, { index, payload: "x".repeat(1000) })));
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- replace generic Todo CRUD with serialized execution-plan lifecycle commands, guarded single-step advancement, preserved replan history, canonical current-plan results, branch reconstruction tests, and a live rerendering current-plan widget
|
|
6
|
+
|
|
3
7
|
## 2026-09-30 - v0.1.0
|
|
4
8
|
|
|
5
9
|
- 从 `@maxiaochao/pi-toolkit` 提取 todo 权威源码,支持主包捆绑和独立安装。
|
package/packages/todo/README.md
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
# @maxiaochao/pi-todo
|
|
2
2
|
|
|
3
|
-
Pi
|
|
3
|
+
Pi 的分支本地执行计划扩展,提供 `todo` 生命周期工具、只显示当前计划的 widget 和 `/todos` 完整历史视图。
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
7
|
## 为什么有这个包
|
|
8
8
|
|
|
9
|
-
这是 pi-toolkit
|
|
9
|
+
这是 pi-toolkit 自研的计划执行模块,用明确状态替代只存在于对话文本里的计划。Todo 是用户可见的后续工作顺序,不是隐藏推理、完成报告或通用任务管理器。它解决改向后历史丢失、并发调用误完成、多个步骤同时 doing、完成后不推进,以及 UI 序号与持久 ID 混用的问题。
|
|
10
10
|
|
|
11
11
|
## 演示
|
|
12
12
|
|
|
13
13
|

|
|
14
14
|
|
|
15
15
|
```text
|
|
16
|
-
todo({ action: "
|
|
17
|
-
todo({ action: "
|
|
16
|
+
todo({ action: "plan", items: ["Inspect callers", "Implement", "Verify"] })
|
|
17
|
+
todo({ action: "complete_current", expected_current_id: 1 })
|
|
18
18
|
/todos
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
第一项实际完成后,下一项自动进入 `doing`。工具结果和 widget 只展示 `CURRENT plan`;`/todos` 展示当前分支的全部完成、取消和未完成历史。
|
|
22
22
|
|
|
23
23
|
## 安装
|
|
24
24
|
|
|
@@ -47,10 +47,20 @@ pi update npm:@maxiaochao/pi-todo # 更新独立子包
|
|
|
47
47
|
|
|
48
48
|
## 行为
|
|
49
49
|
|
|
50
|
-
- `
|
|
51
|
-
- `
|
|
52
|
-
-
|
|
53
|
-
-
|
|
50
|
+
- `plan(items)` 创建或重新规划。非空计划取消并保留全部旧 `doing`/`todo` 历史,保留已关闭历史,使用新的单调递增 ID,并启动第一步。
|
|
51
|
+
- `plan([])` 取消全部未完成工作,不留下当前计划;ID 不重置。
|
|
52
|
+
- `add_step(text)` 只追加一步。`complete_current` 和 `cancel_current` 必须携带匹配唯一 `doing` 步骤的 `expected_current_id`,否则原子失败;每次调用只推进一步。
|
|
53
|
+
- 调用共享可变状态并串行执行。同轮多个 sibling completion 不是支持的顺序机制。
|
|
54
|
+
- `list` 与每个变更都向模型返回简洁、规范的 `CURRENT plan`,包含稳定内部 ID 和状态;只有 `/todos` 显示完整历史。
|
|
55
|
+
- widget 从实时状态读取当前计划,使用连续显示位置、隐藏内部 ID 和关闭历史,并在计划结束时消失。
|
|
56
|
+
|
|
57
|
+
## 使用准则
|
|
58
|
+
|
|
59
|
+
- 只为明确的多步工作、确实分阶段的工作或跨模型交接创建计划;不要为简单单步任务创建计划。
|
|
60
|
+
- 仅在工作实际完成后调用 `complete_current`。仍然需要但被阻塞的步骤保持当前状态。
|
|
61
|
+
- `cancel_current` 表示当前步骤不再需要且后续计划仍有效;方向改变时使用 `plan`。
|
|
62
|
+
- 保留用户给出的步骤意图和顺序,只在可执行性要求下拆分步骤。
|
|
63
|
+
- 协议不提供任意更新、删除、清空、撤销、批量完成或 marker 文本解析。
|
|
54
64
|
|
|
55
65
|
## 开发与测试
|
|
56
66
|
|
package/packages/todo/TESTING.md
CHANGED
|
@@ -2,19 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## 流程
|
|
4
4
|
|
|
5
|
-
1. `npm --prefix packages/todo test
|
|
5
|
+
1. `npm --prefix packages/todo test`:运行状态机确定性测试和扩展/widget 集成测试。
|
|
6
6
|
2. `npm --prefix packages/todo run load-test`:让 Pi 加载真实扩展入口。
|
|
7
7
|
3. `npm pack ./packages/todo --dry-run`:确认发布文件完整。
|
|
8
8
|
4. 确认由真实状态机输出生成的 `docs/screenshot.png` 和 `docs/demo.gif` 可打开且进入 tarball。
|
|
9
9
|
|
|
10
10
|
## 用例数据
|
|
11
11
|
|
|
12
|
-
-
|
|
13
|
-
- `
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
12
|
+
- 恢复旧格式记录、修复非法生命周期,并只从当前分支快照重建。
|
|
13
|
+
- 非空 `plan` 保留关闭历史、取消旧未完成步骤并使用单调新 ID;`plan([])` 留下空当前计划。
|
|
14
|
+
- `expected_current_id` 的 stale、重复和缺失调用原子失败;顺序完成与取消每次只推进一步。
|
|
15
|
+
- `add_step` 只追加,步骤文本不作为 marker 或唯一键解释。
|
|
16
|
+
- 并发发起的调用按提交顺序串行化,同轮重复 completion 不会跨越两个步骤。
|
|
17
|
+
- 已挂载 widget 从实时状态重渲染、主动请求 TUI render、隐藏内部 ID 和关闭历史,并在计划结束时消失。
|
|
18
|
+
- `/todos` 展示完整当前分支历史,而所有工具结果只返回规范 `CURRENT plan`。
|
|
17
19
|
|
|
18
20
|
## 最近验证结果
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
迁移基线:状态机和扩展集成测试全部通过,扩展可由 Pi 独立加载。发布前必须重新运行上述三步;跳过的检查不算通过。
|
|
Binary file
|
|
Binary file
|