@ferris1225/pi-subagents 4.1.2 → 4.1.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/README.md +561 -506
- package/agents/cleaner.md +2 -2
- package/agents/documenter.md +46 -44
- package/agents/explorer.md +15 -11
- package/agents/reviewer.md +5 -2
- package/agents/worker.md +5 -3
- package/package.json +55 -55
- package/src/agents.ts +42 -1
- package/src/completion.ts +160 -160
- package/src/dispatch.ts +634 -637
- package/src/fixloop.ts +15 -32
- package/src/models.ts +189 -189
- package/src/monitor.ts +97 -27
- package/src/recovery.ts +145 -145
- package/src/rpc-run.ts +6 -3
- package/src/runtime.ts +5 -0
- package/src/session-fork.ts +80 -80
- package/src/setup.ts +151 -136
- package/src/spawn.ts +8 -2
- package/src/thread-lifecycle.ts +43 -11
- package/src/tools.ts +44 -30
- package/src/widget.ts +65 -19
package/src/monitor.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
|
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
|
|
21
21
|
export type RunStatus = "queued" | "running" | "steering" | "interrupting" | "parked" | "done" | "failed";
|
|
22
|
+
export type ContinuationKind = "resume-retained" | "resume-appended" | "fork-retained" | "fork-appended" | "retarget";
|
|
22
23
|
|
|
23
24
|
export function isRunActiveStatus(status: RunStatus): boolean {
|
|
24
25
|
return status === "queued" || status === "running" || status === "steering" || status === "interrupting";
|
|
@@ -45,16 +46,25 @@ export interface RunView {
|
|
|
45
46
|
usage: UsageStats;
|
|
46
47
|
/** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
|
|
47
48
|
activity?: string;
|
|
48
|
-
/** Epoch ms when
|
|
49
|
+
/** Epoch ms when this logical run first started executing. */
|
|
49
50
|
startedAt?: number;
|
|
50
|
-
/** Epoch ms when the
|
|
51
|
+
/** Epoch ms when the current active segment started. */
|
|
52
|
+
activeSince?: number;
|
|
53
|
+
/** Cumulative active execution time from closed segments; parked time is excluded. */
|
|
54
|
+
elapsedMs: number;
|
|
55
|
+
/** Epoch ms when the latest active segment stopped. */
|
|
51
56
|
endedAt?: number;
|
|
57
|
+
/** Why this generation reused retained context, shown in the widget/status. */
|
|
58
|
+
continuationKind?: ContinuationKind;
|
|
52
59
|
/** When set, this is an internal managed-workflow step. */
|
|
53
60
|
groupId?: string;
|
|
54
61
|
/** Human-readable role within a chain, e.g. "fix round 1" or "re-review round 1". */
|
|
55
62
|
relationLabel?: string;
|
|
56
63
|
/** Stable owning run whose row represents the whole managed workflow. */
|
|
57
64
|
parentRunId?: number;
|
|
65
|
+
/** This stable top-level row currently owns a multi-stage managed workflow.
|
|
66
|
+
* Its elapsed time is workflow-wide; active child rows own stage telemetry. */
|
|
67
|
+
managedWorkflow?: boolean;
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
/** Optional metadata for documenter/reviewer/fix children of a stable parent run. */
|
|
@@ -64,6 +74,7 @@ export interface RunChainMeta {
|
|
|
64
74
|
parentRunId?: number;
|
|
65
75
|
isolation?: IsolationMode;
|
|
66
76
|
forkedFromRunId?: number;
|
|
77
|
+
continuationKind?: ContinuationKind;
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
// ---------------------------------------------------------------------------
|
|
@@ -276,10 +287,32 @@ export function formatDuration(ms: number): string {
|
|
|
276
287
|
return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
|
|
277
288
|
}
|
|
278
289
|
|
|
279
|
-
/**
|
|
290
|
+
/** Cumulative active time across generations; parked gaps never count. */
|
|
291
|
+
export function elapsedMilliseconds(run: RunView, now: number = Date.now()): number {
|
|
292
|
+
let elapsed = run.elapsedMs ?? 0;
|
|
293
|
+
if (run.activeSince !== undefined) elapsed += Math.max(0, now - run.activeSince);
|
|
294
|
+
// Keep formatting tolerant of older/synthetic RunView values that predate
|
|
295
|
+
// segmented timing and carry only startedAt/endedAt.
|
|
296
|
+
if (elapsed === 0 && run.startedAt !== undefined && run.activeSince === undefined) {
|
|
297
|
+
elapsed = Math.max(0, (run.endedAt ?? now) - run.startedAt);
|
|
298
|
+
}
|
|
299
|
+
return elapsed;
|
|
300
|
+
}
|
|
301
|
+
|
|
280
302
|
export function formatElapsed(run: RunView, now: number = Date.now()): string {
|
|
281
|
-
if (run.startedAt === undefined) return "";
|
|
282
|
-
return formatDuration((run
|
|
303
|
+
if (run.startedAt === undefined && run.elapsedMs <= 0) return "";
|
|
304
|
+
return formatDuration(elapsedMilliseconds(run, now));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function continuationLabel(kind: ContinuationKind | undefined, sourceRunId?: number): string | undefined {
|
|
308
|
+
switch (kind) {
|
|
309
|
+
case "resume-retained": return "resume: current objective";
|
|
310
|
+
case "resume-appended": return "resume: appended objective";
|
|
311
|
+
case "fork-retained": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: current objective`;
|
|
312
|
+
case "fork-appended": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: appended objective`;
|
|
313
|
+
case "retarget": return "retarget: replacement objective";
|
|
314
|
+
default: return undefined;
|
|
315
|
+
}
|
|
283
316
|
}
|
|
284
317
|
|
|
285
318
|
/** Max length of the argument target inside a formatted activity line. */
|
|
@@ -409,11 +442,13 @@ export class MonitorStore {
|
|
|
409
442
|
thinking,
|
|
410
443
|
status: "queued",
|
|
411
444
|
usage: emptyUsage(),
|
|
445
|
+
elapsedMs: 0,
|
|
412
446
|
...(meta?.groupId ? { groupId: meta.groupId } : {}),
|
|
413
447
|
...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
|
|
414
448
|
...(meta?.parentRunId !== undefined ? { parentRunId: meta.parentRunId } : {}),
|
|
415
449
|
...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined } : {}),
|
|
416
450
|
...(meta?.forkedFromRunId !== undefined ? { forkedFromRunId: meta.forkedFromRunId } : {}),
|
|
451
|
+
...(meta?.continuationKind ? { continuationKind: meta.continuationKind } : {}),
|
|
417
452
|
});
|
|
418
453
|
this.notify();
|
|
419
454
|
return id;
|
|
@@ -422,17 +457,33 @@ export class MonitorStore {
|
|
|
422
457
|
setStatus(id: number, status: RunStatus): void {
|
|
423
458
|
const run = this.find(id);
|
|
424
459
|
if (!run) return;
|
|
460
|
+
const previousStatus = run.status;
|
|
461
|
+
const wasExecuting = previousStatus === "running" || previousStatus === "steering" || previousStatus === "interrupting";
|
|
462
|
+
const isExecuting = status === "running" || status === "steering" || status === "interrupting";
|
|
463
|
+
const now = Date.now();
|
|
425
464
|
run.status = status;
|
|
426
|
-
if (
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
run.
|
|
465
|
+
if (isExecuting && !wasExecuting) {
|
|
466
|
+
run.startedAt ??= now;
|
|
467
|
+
run.activeSince = now;
|
|
468
|
+
run.endedAt = undefined;
|
|
469
|
+
} else if (!isExecuting && wasExecuting && run.activeSince !== undefined) {
|
|
470
|
+
run.elapsedMs += Math.max(0, now - run.activeSince);
|
|
471
|
+
run.activeSince = undefined;
|
|
433
472
|
}
|
|
473
|
+
if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
|
|
474
|
+
run.endedAt = now;
|
|
475
|
+
}
|
|
476
|
+
this.notify();
|
|
477
|
+
}
|
|
478
|
+
/** Switch a stable top-level row from one model run to workflow ownership.
|
|
479
|
+
* The original role remains for identity; child rows show stage telemetry. */
|
|
480
|
+
setManagedWorkflow(id: number, active: boolean): void {
|
|
481
|
+
const run = this.find(id);
|
|
482
|
+
if (!run) return;
|
|
483
|
+
run.managedWorkflow = active || undefined;
|
|
434
484
|
this.notify();
|
|
435
485
|
}
|
|
486
|
+
|
|
436
487
|
setUsage(id: number, usage: UsageStats, model?: string): void {
|
|
437
488
|
const run = this.find(id);
|
|
438
489
|
if (!run) return;
|
|
@@ -503,26 +554,38 @@ export class MonitorStore {
|
|
|
503
554
|
this.notify();
|
|
504
555
|
}
|
|
505
556
|
|
|
506
|
-
/**
|
|
507
|
-
|
|
508
|
-
setAgent(id: number, agent: string): void {
|
|
557
|
+
/** Update the objective shown for a queued retarget or resumed generation. */
|
|
558
|
+
setTask(id: number, task: string): void {
|
|
509
559
|
const run = this.find(id);
|
|
510
560
|
if (!run) return;
|
|
511
|
-
run.
|
|
561
|
+
run.task = task;
|
|
562
|
+
run.label = runLabel(task);
|
|
512
563
|
this.notify();
|
|
513
564
|
}
|
|
514
565
|
|
|
515
|
-
|
|
516
|
-
setTask(id: number, task: string): void {
|
|
566
|
+
setContinuationKind(id: number, kind: ContinuationKind): void {
|
|
517
567
|
const run = this.find(id);
|
|
518
568
|
if (!run) return;
|
|
519
|
-
run.
|
|
520
|
-
run.label = runLabel(task);
|
|
569
|
+
run.continuationKind = kind;
|
|
521
570
|
this.notify();
|
|
522
571
|
}
|
|
523
572
|
|
|
524
|
-
|
|
525
|
-
|
|
573
|
+
getElapsedMs(id: number, now: number = Date.now()): number | undefined {
|
|
574
|
+
const run = this.find(id);
|
|
575
|
+
return run ? elapsedMilliseconds(run, now) : undefined;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Reuse a stable logical run id for a resumed generation without discarding
|
|
579
|
+
* active time accumulated by earlier generations. */
|
|
580
|
+
restartRun(
|
|
581
|
+
id: number,
|
|
582
|
+
agent: string,
|
|
583
|
+
task: string,
|
|
584
|
+
model?: string,
|
|
585
|
+
thinking?: string,
|
|
586
|
+
isolation?: IsolationMode,
|
|
587
|
+
meta?: { elapsedMs?: number; continuationKind?: ContinuationKind },
|
|
588
|
+
): void {
|
|
526
589
|
const run = this.find(id);
|
|
527
590
|
if (!run) {
|
|
528
591
|
this.runs.push({
|
|
@@ -535,6 +598,8 @@ export class MonitorStore {
|
|
|
535
598
|
...(isolation ? { isolation, integrationStatus: isolation === "worktree" ? "pending" as const : undefined } : {}),
|
|
536
599
|
status: "queued",
|
|
537
600
|
usage: emptyUsage(),
|
|
601
|
+
elapsedMs: meta?.elapsedMs ?? 0,
|
|
602
|
+
continuationKind: meta?.continuationKind,
|
|
538
603
|
});
|
|
539
604
|
this.notify();
|
|
540
605
|
return;
|
|
@@ -549,8 +614,11 @@ export class MonitorStore {
|
|
|
549
614
|
run.status = "queued";
|
|
550
615
|
run.usage = emptyUsage();
|
|
551
616
|
run.activity = undefined;
|
|
552
|
-
run.
|
|
617
|
+
run.managedWorkflow = undefined;
|
|
618
|
+
run.activeSince = undefined;
|
|
553
619
|
run.endedAt = undefined;
|
|
620
|
+
run.elapsedMs = Math.max(run.elapsedMs, meta?.elapsedMs ?? 0);
|
|
621
|
+
run.continuationKind = meta?.continuationKind;
|
|
554
622
|
this.notify();
|
|
555
623
|
}
|
|
556
624
|
|
|
@@ -589,12 +657,14 @@ export class MonitorStore {
|
|
|
589
657
|
|
|
590
658
|
summarize(run: RunView): string {
|
|
591
659
|
const usage = formatUsageCompact(run.usage);
|
|
592
|
-
const parts = [run.agent];
|
|
660
|
+
const parts = [run.managedWorkflow ? `${run.agent} workflow` : run.agent];
|
|
661
|
+
const continuation = continuationLabel(run.continuationKind, run.forkedFromRunId);
|
|
662
|
+
if (continuation) parts.push(continuation);
|
|
593
663
|
if (run.relationLabel) parts.push(run.relationLabel);
|
|
594
|
-
if (run.model) parts.push(run.model);
|
|
595
|
-
if (run.thinking) parts.push(`thinking ${run.thinking}`);
|
|
664
|
+
if (!run.managedWorkflow && run.model) parts.push(run.model);
|
|
665
|
+
if (!run.managedWorkflow && run.thinking) parts.push(`thinking ${run.thinking}`);
|
|
596
666
|
if (run.isolation === "worktree") parts.push(`worktree ${run.integrationStatus ?? "active"}`);
|
|
597
|
-
if (usage) parts.push(usage);
|
|
667
|
+
if (!run.managedWorkflow && usage) parts.push(usage);
|
|
598
668
|
const elapsed = formatElapsed(run);
|
|
599
669
|
if (elapsed) parts.push(elapsed);
|
|
600
670
|
return parts.join(" · ");
|
package/src/recovery.ts
CHANGED
|
@@ -1,145 +1,145 @@
|
|
|
1
|
-
/** Durable handoff for worktree integration/cleanup failures across sessions. */
|
|
2
|
-
|
|
3
|
-
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { existsSync } from "node:fs";
|
|
5
|
-
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
6
|
-
import { dirname, join } from "node:path";
|
|
7
|
-
import { stripVTControlCharacters } from "node:util";
|
|
8
|
-
import type { WorktreeFinalization } from "./worktree.ts";
|
|
9
|
-
|
|
10
|
-
export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
|
|
11
|
-
const RECOVERY_MANIFEST_VERSION = 1;
|
|
12
|
-
|
|
13
|
-
export interface RecoveryRecord {
|
|
14
|
-
runId: number;
|
|
15
|
-
createdAt: number;
|
|
16
|
-
integrated: boolean;
|
|
17
|
-
worktreePath?: string;
|
|
18
|
-
patchPath?: string;
|
|
19
|
-
error?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface RecoveryManifest {
|
|
23
|
-
version: number;
|
|
24
|
-
records: RecoveryRecord[];
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function getRecoveryManifestPath(configPath: string): string {
|
|
28
|
-
return join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function normalizeRecord(value: unknown): RecoveryRecord | undefined {
|
|
32
|
-
if (!value || typeof value !== "object") return undefined;
|
|
33
|
-
const raw = value as Record<string, unknown>;
|
|
34
|
-
if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
|
|
35
|
-
if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
|
|
36
|
-
return {
|
|
37
|
-
runId: raw.runId,
|
|
38
|
-
createdAt: raw.createdAt,
|
|
39
|
-
integrated: raw.integrated === true,
|
|
40
|
-
...(typeof raw.worktreePath === "string" && raw.worktreePath ? { worktreePath: raw.worktreePath } : {}),
|
|
41
|
-
...(typeof raw.patchPath === "string" && raw.patchPath ? { patchPath: raw.patchPath } : {}),
|
|
42
|
-
...(typeof raw.error === "string" && raw.error ? { error: raw.error } : {}),
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
|
|
47
|
-
try {
|
|
48
|
-
const parsed = JSON.parse(await readFile(getRecoveryManifestPath(configPath), "utf8")) as {
|
|
49
|
-
records?: unknown;
|
|
50
|
-
};
|
|
51
|
-
if (!Array.isArray(parsed.records)) return [];
|
|
52
|
-
return parsed.records.flatMap((record) => {
|
|
53
|
-
const normalized = normalizeRecord(record);
|
|
54
|
-
return normalized ? [normalized] : [];
|
|
55
|
-
});
|
|
56
|
-
} catch {
|
|
57
|
-
return [];
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function recoveryKey(record: RecoveryRecord): string {
|
|
62
|
-
return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function writeManifest(path: string, records: readonly RecoveryRecord[]): Promise<void> {
|
|
66
|
-
if (records.length === 0) {
|
|
67
|
-
await rm(path, { force: true });
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
await mkdir(dirname(path), { recursive: true });
|
|
71
|
-
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
72
|
-
try {
|
|
73
|
-
const manifest: RecoveryManifest = {
|
|
74
|
-
version: RECOVERY_MANIFEST_VERSION,
|
|
75
|
-
records: [...records],
|
|
76
|
-
};
|
|
77
|
-
await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
78
|
-
await rename(temporaryPath, path);
|
|
79
|
-
} finally {
|
|
80
|
-
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Merge retained artifacts into the durable manifest. */
|
|
85
|
-
export async function persistRecoveryRecords(
|
|
86
|
-
configPath: string,
|
|
87
|
-
records: readonly RecoveryRecord[],
|
|
88
|
-
): Promise<void> {
|
|
89
|
-
if (records.length === 0) return;
|
|
90
|
-
const path = getRecoveryManifestPath(configPath);
|
|
91
|
-
await withFileMutationQueue(path, async () => {
|
|
92
|
-
const merged = new Map<string, RecoveryRecord>();
|
|
93
|
-
for (const record of await readRecoveryRecords(configPath)) merged.set(recoveryKey(record), record);
|
|
94
|
-
for (const record of records) merged.set(recoveryKey(record), record);
|
|
95
|
-
await writeManifest(path, [...merged.values()]);
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function recoveryRecordFromFinalization(
|
|
100
|
-
runId: number,
|
|
101
|
-
finalization: WorktreeFinalization,
|
|
102
|
-
now = Date.now(),
|
|
103
|
-
): RecoveryRecord {
|
|
104
|
-
return {
|
|
105
|
-
runId,
|
|
106
|
-
createdAt: now,
|
|
107
|
-
integrated: finalization.integrated,
|
|
108
|
-
...(finalization.worktreePath ? { worktreePath: finalization.worktreePath } : {}),
|
|
109
|
-
...(finalization.patchPath ? { patchPath: finalization.patchPath } : {}),
|
|
110
|
-
...(finalization.error ? { error: finalization.error } : {}),
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Show retained recovery paths on every later session start until the user
|
|
115
|
-
* removes the artifacts. Stale records are pruned automatically. */
|
|
116
|
-
export async function announceRecoveryRecords(
|
|
117
|
-
configPath: string,
|
|
118
|
-
ctx: {
|
|
119
|
-
hasUI?: boolean;
|
|
120
|
-
ui: { notify(message: string, kind: "info" | "warning" | "error"): void };
|
|
121
|
-
},
|
|
122
|
-
): Promise<void> {
|
|
123
|
-
if (ctx.hasUI === false) return;
|
|
124
|
-
const records = await readRecoveryRecords(configPath);
|
|
125
|
-
if (records.length === 0) return;
|
|
126
|
-
const live = records.filter((record) =>
|
|
127
|
-
(record.worktreePath ? existsSync(record.worktreePath) : false) ||
|
|
128
|
-
(record.patchPath ? existsSync(record.patchPath) : false),
|
|
129
|
-
);
|
|
130
|
-
if (live.length !== records.length) {
|
|
131
|
-
const path = getRecoveryManifestPath(configPath);
|
|
132
|
-
await withFileMutationQueue(path, () => writeManifest(path, live)).catch(() => undefined);
|
|
133
|
-
}
|
|
134
|
-
for (const record of live) {
|
|
135
|
-
const paths = [
|
|
136
|
-
record.worktreePath ? `worktree ${stripVTControlCharacters(record.worktreePath)}` : undefined,
|
|
137
|
-
record.patchPath ? `patch ${stripVTControlCharacters(record.patchPath)}` : undefined,
|
|
138
|
-
].filter(Boolean).join(" · ");
|
|
139
|
-
const reason = record.error ? ` · ${stripVTControlCharacters(record.error)}` : "";
|
|
140
|
-
ctx.ui.notify(
|
|
141
|
-
`pi-subagents recovery for run #${record.runId}: ${record.integrated ? "changes were applied but cleanup failed" : "integration failed"}${paths ? ` · retained ${paths}` : ""}${reason}`,
|
|
142
|
-
"error",
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
1
|
+
/** Durable handoff for worktree integration/cleanup failures across sessions. */
|
|
2
|
+
|
|
3
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { stripVTControlCharacters } from "node:util";
|
|
8
|
+
import type { WorktreeFinalization } from "./worktree.ts";
|
|
9
|
+
|
|
10
|
+
export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
|
|
11
|
+
const RECOVERY_MANIFEST_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
export interface RecoveryRecord {
|
|
14
|
+
runId: number;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
integrated: boolean;
|
|
17
|
+
worktreePath?: string;
|
|
18
|
+
patchPath?: string;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RecoveryManifest {
|
|
23
|
+
version: number;
|
|
24
|
+
records: RecoveryRecord[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getRecoveryManifestPath(configPath: string): string {
|
|
28
|
+
return join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeRecord(value: unknown): RecoveryRecord | undefined {
|
|
32
|
+
if (!value || typeof value !== "object") return undefined;
|
|
33
|
+
const raw = value as Record<string, unknown>;
|
|
34
|
+
if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
|
|
35
|
+
if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
|
|
36
|
+
return {
|
|
37
|
+
runId: raw.runId,
|
|
38
|
+
createdAt: raw.createdAt,
|
|
39
|
+
integrated: raw.integrated === true,
|
|
40
|
+
...(typeof raw.worktreePath === "string" && raw.worktreePath ? { worktreePath: raw.worktreePath } : {}),
|
|
41
|
+
...(typeof raw.patchPath === "string" && raw.patchPath ? { patchPath: raw.patchPath } : {}),
|
|
42
|
+
...(typeof raw.error === "string" && raw.error ? { error: raw.error } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(await readFile(getRecoveryManifestPath(configPath), "utf8")) as {
|
|
49
|
+
records?: unknown;
|
|
50
|
+
};
|
|
51
|
+
if (!Array.isArray(parsed.records)) return [];
|
|
52
|
+
return parsed.records.flatMap((record) => {
|
|
53
|
+
const normalized = normalizeRecord(record);
|
|
54
|
+
return normalized ? [normalized] : [];
|
|
55
|
+
});
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function recoveryKey(record: RecoveryRecord): string {
|
|
62
|
+
return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function writeManifest(path: string, records: readonly RecoveryRecord[]): Promise<void> {
|
|
66
|
+
if (records.length === 0) {
|
|
67
|
+
await rm(path, { force: true });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await mkdir(dirname(path), { recursive: true });
|
|
71
|
+
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
72
|
+
try {
|
|
73
|
+
const manifest: RecoveryManifest = {
|
|
74
|
+
version: RECOVERY_MANIFEST_VERSION,
|
|
75
|
+
records: [...records],
|
|
76
|
+
};
|
|
77
|
+
await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
78
|
+
await rename(temporaryPath, path);
|
|
79
|
+
} finally {
|
|
80
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Merge retained artifacts into the durable manifest. */
|
|
85
|
+
export async function persistRecoveryRecords(
|
|
86
|
+
configPath: string,
|
|
87
|
+
records: readonly RecoveryRecord[],
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
if (records.length === 0) return;
|
|
90
|
+
const path = getRecoveryManifestPath(configPath);
|
|
91
|
+
await withFileMutationQueue(path, async () => {
|
|
92
|
+
const merged = new Map<string, RecoveryRecord>();
|
|
93
|
+
for (const record of await readRecoveryRecords(configPath)) merged.set(recoveryKey(record), record);
|
|
94
|
+
for (const record of records) merged.set(recoveryKey(record), record);
|
|
95
|
+
await writeManifest(path, [...merged.values()]);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function recoveryRecordFromFinalization(
|
|
100
|
+
runId: number,
|
|
101
|
+
finalization: WorktreeFinalization,
|
|
102
|
+
now = Date.now(),
|
|
103
|
+
): RecoveryRecord {
|
|
104
|
+
return {
|
|
105
|
+
runId,
|
|
106
|
+
createdAt: now,
|
|
107
|
+
integrated: finalization.integrated,
|
|
108
|
+
...(finalization.worktreePath ? { worktreePath: finalization.worktreePath } : {}),
|
|
109
|
+
...(finalization.patchPath ? { patchPath: finalization.patchPath } : {}),
|
|
110
|
+
...(finalization.error ? { error: finalization.error } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Show retained recovery paths on every later session start until the user
|
|
115
|
+
* removes the artifacts. Stale records are pruned automatically. */
|
|
116
|
+
export async function announceRecoveryRecords(
|
|
117
|
+
configPath: string,
|
|
118
|
+
ctx: {
|
|
119
|
+
hasUI?: boolean;
|
|
120
|
+
ui: { notify(message: string, kind: "info" | "warning" | "error"): void };
|
|
121
|
+
},
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
if (ctx.hasUI === false) return;
|
|
124
|
+
const records = await readRecoveryRecords(configPath);
|
|
125
|
+
if (records.length === 0) return;
|
|
126
|
+
const live = records.filter((record) =>
|
|
127
|
+
(record.worktreePath ? existsSync(record.worktreePath) : false) ||
|
|
128
|
+
(record.patchPath ? existsSync(record.patchPath) : false),
|
|
129
|
+
);
|
|
130
|
+
if (live.length !== records.length) {
|
|
131
|
+
const path = getRecoveryManifestPath(configPath);
|
|
132
|
+
await withFileMutationQueue(path, () => writeManifest(path, live)).catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
for (const record of live) {
|
|
135
|
+
const paths = [
|
|
136
|
+
record.worktreePath ? `worktree ${stripVTControlCharacters(record.worktreePath)}` : undefined,
|
|
137
|
+
record.patchPath ? `patch ${stripVTControlCharacters(record.patchPath)}` : undefined,
|
|
138
|
+
].filter(Boolean).join(" · ");
|
|
139
|
+
const reason = record.error ? ` · ${stripVTControlCharacters(record.error)}` : "";
|
|
140
|
+
ctx.ui.notify(
|
|
141
|
+
`pi-subagents recovery for run #${record.runId}: ${record.integrated ? "changes were applied but cleanup failed" : "integration failed"}${paths ? ` · retained ${paths}` : ""}${reason}`,
|
|
142
|
+
"error",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/rpc-run.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
|
|
|
14
14
|
import { basename, join } from "node:path";
|
|
15
15
|
import { StringDecoder } from "node:string_decoder";
|
|
16
16
|
import type { Message } from "@earendil-works/pi-ai";
|
|
17
|
-
import type
|
|
17
|
+
import { SUBAGENT_TOOL_NAMES, type AgentConfig } from "./agents.ts";
|
|
18
18
|
import type { ThinkingLevel } from "./config.ts";
|
|
19
19
|
import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
|
|
20
20
|
|
|
@@ -469,7 +469,7 @@ export interface RunRpcAttemptOptions {
|
|
|
469
469
|
/** Run one persistent RPC child until a stable `agent_settled` or control action. */
|
|
470
470
|
export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise<RpcSingleResult> {
|
|
471
471
|
const { agent, agentName, task, thinkingLevel, idleTimeoutMs, signal, onLive, control } = options;
|
|
472
|
-
const args: string[] = ["--mode", "rpc", "--exclude-tools", "
|
|
472
|
+
const args: string[] = ["--mode", "rpc", "--exclude-tools", SUBAGENT_TOOL_NAMES.join(",")];
|
|
473
473
|
if (options.sessionDir && options.sessionId) {
|
|
474
474
|
args.push("--session-dir", options.sessionDir);
|
|
475
475
|
args.push(sessionExists(options.sessionDir, options.sessionId) ? "--session" : "--session-id", options.sessionId);
|
|
@@ -478,7 +478,10 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
478
478
|
}
|
|
479
479
|
if (agent.model) args.push("--model", agent.model);
|
|
480
480
|
args.push("--thinking", thinkingLevel);
|
|
481
|
-
if (agent.tools
|
|
481
|
+
if (agent.tools) {
|
|
482
|
+
if (agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
|
|
483
|
+
else args.push("--no-tools");
|
|
484
|
+
}
|
|
482
485
|
|
|
483
486
|
let tmpPromptDir: string | null = null;
|
|
484
487
|
let tmpPromptPath: string | null = null;
|
package/src/runtime.ts
CHANGED
|
@@ -67,6 +67,8 @@ export interface SubagentThread {
|
|
|
67
67
|
lifecycleOperation?: ThreadLifecycleOperation;
|
|
68
68
|
sessionId?: string;
|
|
69
69
|
sessionDir?: string;
|
|
70
|
+
/** Active execution time accumulated across retained resume generations. */
|
|
71
|
+
elapsedMs: number;
|
|
70
72
|
/** Most recent generation result, retained for parked destructive-stop output. */
|
|
71
73
|
lastResult?: SingleResult;
|
|
72
74
|
/** A destructive stop retires context even if the active child settles later. */
|
|
@@ -92,6 +94,8 @@ export interface SubagentThread {
|
|
|
92
94
|
export interface SubagentRuntime {
|
|
93
95
|
configPath: string;
|
|
94
96
|
backgroundQueue: BackgroundTaskQueue;
|
|
97
|
+
/** Live parent tool names from ExtensionAPI, read again for each child launch. */
|
|
98
|
+
getActiveTools: () => string[];
|
|
95
99
|
/** False after session_shutdown; guards delivery and queue work. */
|
|
96
100
|
sessionActive: boolean;
|
|
97
101
|
/** Deliver a batch of completion messages to the main window, waking it only
|
|
@@ -128,6 +132,7 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
128
132
|
const runtime: SubagentRuntime = {
|
|
129
133
|
configPath,
|
|
130
134
|
backgroundQueue,
|
|
135
|
+
getActiveTools: () => pi.getActiveTools(),
|
|
131
136
|
sessionActive: true,
|
|
132
137
|
sendCompletionGroup: (items) => {
|
|
133
138
|
if (!runtime.sessionActive || items.length === 0) return;
|