@ferris1225/pi-subagents 4.0.1 → 4.1.2
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 +506 -478
- package/agents/cleaner.md +51 -41
- package/agents/documenter.md +44 -0
- package/agents/reviewer.md +71 -70
- package/agents/worker.md +4 -1
- package/package.json +2 -2
- package/src/agents.ts +12 -0
- package/src/announcements.ts +34 -7
- package/src/completion.ts +160 -160
- package/src/config.ts +86 -15
- package/src/dispatch.ts +637 -704
- package/src/fixloop.ts +266 -52
- package/src/index.ts +93 -93
- package/src/models.ts +189 -189
- package/src/monitor.ts +12 -3
- package/src/prompt.ts +47 -12
- package/src/recovery.ts +145 -145
- package/src/rpc-run.ts +23 -7
- package/src/runtime.ts +8 -7
- package/src/session-fork.ts +80 -80
- package/src/setup.ts +36 -5
- package/src/spawn.ts +45 -11
- package/src/thread-lifecycle.ts +203 -49
- package/src/tools.ts +23 -9
- package/src/widget.ts +4 -4
- package/src/worktree.ts +27 -4
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
|
@@ -66,10 +66,13 @@ export interface RpcSingleResult {
|
|
|
66
66
|
* model execution. This remains main-model handoff eligible even when an
|
|
67
67
|
* earlier, aborted objective left assistant text in the session. */
|
|
68
68
|
rpcPromptRejected?: boolean;
|
|
69
|
-
/**
|
|
70
|
-
* transport miss
|
|
69
|
+
/** Startup handshake failed before the initial prompt was dispatched. This
|
|
70
|
+
* transport miss is safe to retry and is not a model/provider failure. */
|
|
71
71
|
rpcStartupFailed?: boolean;
|
|
72
|
-
/** The
|
|
72
|
+
/** The parent dispatched the initial prompt command. Until its response is
|
|
73
|
+
* observed, Pi may already be running it, so startup retries must not replay it. */
|
|
74
|
+
rpcPromptDispatched?: boolean;
|
|
75
|
+
/** The child confirmed prompt acceptance (or emitted agent activity). */
|
|
73
76
|
rpcPromptAccepted?: boolean;
|
|
74
77
|
/** Pi emitted agent/turn/model/tool activity for this attempt. */
|
|
75
78
|
rpcActivity?: boolean;
|
|
@@ -415,6 +418,13 @@ interface RpcResponse {
|
|
|
415
418
|
data?: unknown;
|
|
416
419
|
}
|
|
417
420
|
|
|
421
|
+
class RpcCommandRejectedError extends Error {
|
|
422
|
+
constructor(message: string) {
|
|
423
|
+
super(message);
|
|
424
|
+
this.name = "RpcCommandRejectedError";
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
418
428
|
interface PendingRequest {
|
|
419
429
|
resolve: (response: RpcResponse) => void;
|
|
420
430
|
reject: (error: Error) => void;
|
|
@@ -642,7 +652,9 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
642
652
|
},
|
|
643
653
|
);
|
|
644
654
|
}).then((response) => {
|
|
645
|
-
if (!response.success)
|
|
655
|
+
if (!response.success) {
|
|
656
|
+
throw new RpcCommandRejectedError(response.error || `RPC ${response.command} failed.`);
|
|
657
|
+
}
|
|
646
658
|
return response;
|
|
647
659
|
});
|
|
648
660
|
};
|
|
@@ -734,7 +746,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
734
746
|
result.exitCode = 1;
|
|
735
747
|
result.stopReason = "error";
|
|
736
748
|
result.errorMessage = `Replacement prompt was rejected: ${promptError.message}`;
|
|
737
|
-
if (
|
|
749
|
+
if (promptError instanceof RpcCommandRejectedError) result.rpcPromptRejected = true;
|
|
738
750
|
finish();
|
|
739
751
|
terminate();
|
|
740
752
|
if (!closed) await processClosed.promise;
|
|
@@ -1076,7 +1088,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
1076
1088
|
result.stopReason = "error";
|
|
1077
1089
|
result.errorMessage = error.message;
|
|
1078
1090
|
if (startup) result.rpcStartupFailed = true;
|
|
1079
|
-
else result.rpcPromptRejected = true;
|
|
1091
|
+
else if (error instanceof RpcCommandRejectedError) result.rpcPromptRejected = true;
|
|
1080
1092
|
finish();
|
|
1081
1093
|
terminate();
|
|
1082
1094
|
};
|
|
@@ -1091,11 +1103,15 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
1091
1103
|
}
|
|
1092
1104
|
}
|
|
1093
1105
|
if (!finished && !initialPromptResolved && !control?.isParkRequested() && !control?.isStopRequested()) {
|
|
1106
|
+
// Pi starts the agent immediately after prompt preflight, before its
|
|
1107
|
+
// success response necessarily reaches stdout. From this point on, a
|
|
1108
|
+
// missing ACK is ambiguous and must never be recovered by replay.
|
|
1109
|
+
result.rpcPromptDispatched = true;
|
|
1094
1110
|
void send({ type: "prompt", message: asPlainTextRpcPrompt(options.prompt) }).then(
|
|
1095
1111
|
() => resolveInitialPrompt(true),
|
|
1096
1112
|
(error) => {
|
|
1097
1113
|
const promptError = error instanceof Error ? error : new Error(String(error));
|
|
1098
|
-
failBeforePrompt(promptError,
|
|
1114
|
+
failBeforePrompt(promptError, false);
|
|
1099
1115
|
},
|
|
1100
1116
|
);
|
|
1101
1117
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -58,9 +58,9 @@ export interface SubagentThread {
|
|
|
58
58
|
state: ThreadState;
|
|
59
59
|
control: RpcRunControl;
|
|
60
60
|
queueController?: AbortController;
|
|
61
|
-
/** Resolves only after the current generation's
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
/** Resolves only after the current generation's top-level child, downstream
|
|
62
|
+
* managed workflow, and queue work have fully quiesced and released their
|
|
63
|
+
* concurrency slot. */
|
|
64
64
|
generationCompletion: Promise<void>;
|
|
65
65
|
/** Synchronous CAS used by lifecycle controls across their async preflight. */
|
|
66
66
|
lifecycleVersion: number;
|
|
@@ -81,7 +81,8 @@ export interface SubagentThread {
|
|
|
81
81
|
fork: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
|
|
82
82
|
forkedFromRunId?: number;
|
|
83
83
|
forkChildRunIds: number[];
|
|
84
|
-
/** Dispatch-owned, generation-guarded worktree settlement hook.
|
|
84
|
+
/** Dispatch-owned, generation-guarded worktree settlement hook. Its apply
|
|
85
|
+
* runs under the canonical original-repository lane. */
|
|
85
86
|
finalizeIsolation: (generation: number, result?: SingleResult) => Promise<WorktreeFinalization | undefined>;
|
|
86
87
|
/** Best-effort shutdown notification for retained integration artifacts. */
|
|
87
88
|
notifyIsolationFailure?: (finalization: WorktreeFinalization) => void;
|
|
@@ -110,7 +111,7 @@ export interface SubagentRuntime {
|
|
|
110
111
|
* next generation. Shutdown invalidates these claims and waits for cleanup. */
|
|
111
112
|
preflightOperations: Set<Promise<void>>;
|
|
112
113
|
/** Every session directory retained for this parent session, including
|
|
113
|
-
*
|
|
114
|
+
* managed-workflow internals that are not directly controllable. */
|
|
114
115
|
sessionDirs: Set<string>;
|
|
115
116
|
retainSession: (result: Pick<SingleResult, "sessionDir">) => void;
|
|
116
117
|
retireThreadSession: (thread: SubagentThread) => void;
|
|
@@ -134,8 +135,8 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
134
135
|
// Computing this at delivery (emit) time — not when the item was
|
|
135
136
|
// pushed — reflects the current monitor state, since finishing runs
|
|
136
137
|
// are removed from the monitor before their completion is pushed.
|
|
137
|
-
//
|
|
138
|
-
//
|
|
138
|
+
// Managed-workflow parents remain "running" through documenter,
|
|
139
|
+
// reviewer, and any fix rounds, so they are included without a special case.
|
|
139
140
|
const active = monitor
|
|
140
141
|
.getRuns()
|
|
141
142
|
.filter((run) => isRunActiveStatus(run.status))
|
package/src/session-fork.ts
CHANGED
|
@@ -1,80 +1,80 @@
|
|
|
1
|
-
/** Pi SessionManager-backed cloning of a retained sub-agent session branch. */
|
|
2
|
-
|
|
3
|
-
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { existsSync } from "node:fs";
|
|
5
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
6
|
-
import { tmpdir } from "node:os";
|
|
7
|
-
import { join } from "node:path";
|
|
8
|
-
|
|
9
|
-
export interface ForkedSession {
|
|
10
|
-
sessionDir: string;
|
|
11
|
-
sessionId: string;
|
|
12
|
-
sessionFile: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Locate one retained session by its authoritative header id. */
|
|
16
|
-
export async function findRetainedSessionFile(
|
|
17
|
-
sessionDir: string,
|
|
18
|
-
sessionId: string,
|
|
19
|
-
): Promise<string> {
|
|
20
|
-
// The retained header may point at a worktree that has since been removed.
|
|
21
|
-
// The session id is authoritative inside this explicit private directory;
|
|
22
|
-
// listing the directory directly avoids a stale-cwd filter rejecting it.
|
|
23
|
-
const sessions = await SessionManager.listAll(sessionDir);
|
|
24
|
-
const matches = sessions.filter((session) => session.id === sessionId);
|
|
25
|
-
if (matches.length === 0) {
|
|
26
|
-
throw new Error(`Retained session ${sessionId} was not found in ${sessionDir}.`);
|
|
27
|
-
}
|
|
28
|
-
if (matches.length > 1) {
|
|
29
|
-
throw new Error(`Retained session id ${sessionId} is ambiguous in ${sessionDir}.`);
|
|
30
|
-
}
|
|
31
|
-
return matches[0].path;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Copy only the source file's active branch into a new isolated temp session
|
|
36
|
-
* directory. SessionManager performs all JSONL/tree handling; source state is
|
|
37
|
-
* never mutated.
|
|
38
|
-
*/
|
|
39
|
-
export async function forkRetainedSession(options: {
|
|
40
|
-
/** Cwd stored in the source session header (used for exact lookup). */
|
|
41
|
-
cwd: string;
|
|
42
|
-
/** Optional cwd for the cloned session header and future child tools. */
|
|
43
|
-
targetCwd?: string;
|
|
44
|
-
sessionDir: string;
|
|
45
|
-
sessionId: string;
|
|
46
|
-
}): Promise<ForkedSession> {
|
|
47
|
-
const sourceSessionFile = await findRetainedSessionFile(
|
|
48
|
-
options.sessionDir,
|
|
49
|
-
options.sessionId,
|
|
50
|
-
);
|
|
51
|
-
const sessionDir = await mkdtemp(join(tmpdir(), "pi-subagent-session-fork-"));
|
|
52
|
-
try {
|
|
53
|
-
// Supplying the new directory makes createBranchedSession write there.
|
|
54
|
-
// cwdOverride rewrites the cloned header so a settled isolated session can
|
|
55
|
-
// safely continue in its fresh worktree instead of a removed old path.
|
|
56
|
-
const manager = SessionManager.open(
|
|
57
|
-
sourceSessionFile,
|
|
58
|
-
sessionDir,
|
|
59
|
-
options.targetCwd ?? options.cwd,
|
|
60
|
-
);
|
|
61
|
-
const leafId = manager.getLeafId();
|
|
62
|
-
if (!leafId) throw new Error(`Retained session ${options.sessionId} has no active branch to fork.`);
|
|
63
|
-
const sessionFile = manager.createBranchedSession(leafId);
|
|
64
|
-
if (!sessionFile) throw new Error("Pi SessionManager did not create a persistent fork.");
|
|
65
|
-
// Pi defers branch files that contain no assistant response. Such a file
|
|
66
|
-
// cannot be resumed by RPC without creating a blank session, so reject
|
|
67
|
-
// rather than pretending context was preserved.
|
|
68
|
-
if (!existsSync(sessionFile)) {
|
|
69
|
-
throw new Error(`Forked session branch has no persisted assistant checkpoint at ${sessionFile}.`);
|
|
70
|
-
}
|
|
71
|
-
return {
|
|
72
|
-
sessionDir,
|
|
73
|
-
sessionId: manager.getSessionId(),
|
|
74
|
-
sessionFile,
|
|
75
|
-
};
|
|
76
|
-
} catch (error) {
|
|
77
|
-
await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
78
|
-
throw error;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
1
|
+
/** Pi SessionManager-backed cloning of a retained sub-agent session branch. */
|
|
2
|
+
|
|
3
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export interface ForkedSession {
|
|
10
|
+
sessionDir: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
sessionFile: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Locate one retained session by its authoritative header id. */
|
|
16
|
+
export async function findRetainedSessionFile(
|
|
17
|
+
sessionDir: string,
|
|
18
|
+
sessionId: string,
|
|
19
|
+
): Promise<string> {
|
|
20
|
+
// The retained header may point at a worktree that has since been removed.
|
|
21
|
+
// The session id is authoritative inside this explicit private directory;
|
|
22
|
+
// listing the directory directly avoids a stale-cwd filter rejecting it.
|
|
23
|
+
const sessions = await SessionManager.listAll(sessionDir);
|
|
24
|
+
const matches = sessions.filter((session) => session.id === sessionId);
|
|
25
|
+
if (matches.length === 0) {
|
|
26
|
+
throw new Error(`Retained session ${sessionId} was not found in ${sessionDir}.`);
|
|
27
|
+
}
|
|
28
|
+
if (matches.length > 1) {
|
|
29
|
+
throw new Error(`Retained session id ${sessionId} is ambiguous in ${sessionDir}.`);
|
|
30
|
+
}
|
|
31
|
+
return matches[0].path;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Copy only the source file's active branch into a new isolated temp session
|
|
36
|
+
* directory. SessionManager performs all JSONL/tree handling; source state is
|
|
37
|
+
* never mutated.
|
|
38
|
+
*/
|
|
39
|
+
export async function forkRetainedSession(options: {
|
|
40
|
+
/** Cwd stored in the source session header (used for exact lookup). */
|
|
41
|
+
cwd: string;
|
|
42
|
+
/** Optional cwd for the cloned session header and future child tools. */
|
|
43
|
+
targetCwd?: string;
|
|
44
|
+
sessionDir: string;
|
|
45
|
+
sessionId: string;
|
|
46
|
+
}): Promise<ForkedSession> {
|
|
47
|
+
const sourceSessionFile = await findRetainedSessionFile(
|
|
48
|
+
options.sessionDir,
|
|
49
|
+
options.sessionId,
|
|
50
|
+
);
|
|
51
|
+
const sessionDir = await mkdtemp(join(tmpdir(), "pi-subagent-session-fork-"));
|
|
52
|
+
try {
|
|
53
|
+
// Supplying the new directory makes createBranchedSession write there.
|
|
54
|
+
// cwdOverride rewrites the cloned header so a settled isolated session can
|
|
55
|
+
// safely continue in its fresh worktree instead of a removed old path.
|
|
56
|
+
const manager = SessionManager.open(
|
|
57
|
+
sourceSessionFile,
|
|
58
|
+
sessionDir,
|
|
59
|
+
options.targetCwd ?? options.cwd,
|
|
60
|
+
);
|
|
61
|
+
const leafId = manager.getLeafId();
|
|
62
|
+
if (!leafId) throw new Error(`Retained session ${options.sessionId} has no active branch to fork.`);
|
|
63
|
+
const sessionFile = manager.createBranchedSession(leafId);
|
|
64
|
+
if (!sessionFile) throw new Error("Pi SessionManager did not create a persistent fork.");
|
|
65
|
+
// Pi defers branch files that contain no assistant response. Such a file
|
|
66
|
+
// cannot be resumed by RPC without creating a blank session, so reject
|
|
67
|
+
// rather than pretending context was preserved.
|
|
68
|
+
if (!existsSync(sessionFile)) {
|
|
69
|
+
throw new Error(`Forked session branch has no persisted assistant checkpoint at ${sessionFile}.`);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
sessionDir,
|
|
73
|
+
sessionId: manager.getSessionId(),
|
|
74
|
+
sessionFile,
|
|
75
|
+
};
|
|
76
|
+
} catch (error) {
|
|
77
|
+
await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -13,6 +13,8 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
|
13
13
|
import {
|
|
14
14
|
AGENT_SCOPE_VALUES,
|
|
15
15
|
BUILTIN_AGENT_NAMES,
|
|
16
|
+
CLEANER_DEFAULTED_FEATURE,
|
|
17
|
+
DOCUMENTER_DEFAULTED_FEATURE,
|
|
16
18
|
DEFAULT_CONFIG,
|
|
17
19
|
DEFAULT_ENABLED_AGENTS,
|
|
18
20
|
DEFAULT_IDLE_TIMEOUT_SEC,
|
|
@@ -60,7 +62,8 @@ function actualAgentThinkingDefault(
|
|
|
60
62
|
const MODULE_HINTS: Record<string, string> = {
|
|
61
63
|
explorer: "read-only codebase recon (fast model)",
|
|
62
64
|
worker: "implement / fix / refactor / test (full tools)",
|
|
63
|
-
cleaner: "
|
|
65
|
+
cleaner: "apply proven cleanup and deduplicate code (full tools)",
|
|
66
|
+
documenter: "sync diff or whole-codebase comments/docs (full tools)",
|
|
64
67
|
reviewer: "read-only audits and pre-commit gates",
|
|
65
68
|
};
|
|
66
69
|
|
|
@@ -302,7 +305,7 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
302
305
|
if (maxConcurrency === undefined) return notifyCancelled(ctx);
|
|
303
306
|
const maxFixRounds = await pickCount(
|
|
304
307
|
ctx,
|
|
305
|
-
"Reviewer
|
|
308
|
+
"Reviewer worker-fix rounds? (0 = no automatic fixes)",
|
|
306
309
|
FIX_ROUNDS_STEPS,
|
|
307
310
|
base.maxFixRounds,
|
|
308
311
|
DEFAULT_MAX_FIX_ROUNDS,
|
|
@@ -329,7 +332,13 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
329
332
|
maxConcurrency,
|
|
330
333
|
maxFixRounds,
|
|
331
334
|
idleTimeoutSec,
|
|
332
|
-
|
|
335
|
+
// Full setup is an explicit decision point: mark role-enable migrations as
|
|
336
|
+
// processed so the user's saved selection is kept as-is.
|
|
337
|
+
announcedFeatures: [...new Set([
|
|
338
|
+
...base.announcedFeatures,
|
|
339
|
+
CLEANER_DEFAULTED_FEATURE,
|
|
340
|
+
DOCUMENTER_DEFAULTED_FEATURE,
|
|
341
|
+
])],
|
|
333
342
|
};
|
|
334
343
|
await saveConfig(next, configPath);
|
|
335
344
|
ctx.ui.notify(`pi-subagents configured with Auto thinking. Saved to ${configPath}`, "info");
|
|
@@ -343,7 +352,7 @@ async function updateRuntimeSetting(
|
|
|
343
352
|
"Proactive injection",
|
|
344
353
|
"Agent scope",
|
|
345
354
|
"Max concurrency",
|
|
346
|
-
"Reviewer
|
|
355
|
+
"Reviewer worker-fix rounds",
|
|
347
356
|
"Idle timeout",
|
|
348
357
|
]);
|
|
349
358
|
if (choice === undefined) return undefined;
|
|
@@ -361,7 +370,7 @@ async function updateRuntimeSetting(
|
|
|
361
370
|
if (value === undefined) return undefined;
|
|
362
371
|
next.maxConcurrency = value;
|
|
363
372
|
} else if (choice.startsWith("Reviewer")) {
|
|
364
|
-
const value = await pickCount(ctx, "Reviewer
|
|
373
|
+
const value = await pickCount(ctx, "Reviewer worker-fix rounds?", FIX_ROUNDS_STEPS, config.maxFixRounds, DEFAULT_MAX_FIX_ROUNDS);
|
|
365
374
|
if (value === undefined) return undefined;
|
|
366
375
|
next.maxFixRounds = value;
|
|
367
376
|
} else {
|
|
@@ -391,6 +400,28 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
|
|
|
391
400
|
const enabled = await pickEnabledAgents(ctx, config.enabledAgents);
|
|
392
401
|
if (enabled === undefined) return notifyCancelled(ctx);
|
|
393
402
|
next.enabledAgents = enabled;
|
|
403
|
+
// Newly enabling cleaner inherits the reviewer's configured model and
|
|
404
|
+
// thinking level, so the file reflects what cleaner will actually run
|
|
405
|
+
// instead of silently falling back to the current main model.
|
|
406
|
+
if (!config.enabledAgents.includes("cleaner") && enabled.includes("cleaner")) {
|
|
407
|
+
if (!next.agentModels.cleaner && config.agentModels.reviewer) {
|
|
408
|
+
next.agentModels.cleaner = config.agentModels.reviewer;
|
|
409
|
+
}
|
|
410
|
+
if (!next.agentThinkingLevels.cleaner && config.agentThinkingLevels.reviewer) {
|
|
411
|
+
next.agentThinkingLevels.cleaner = config.agentThinkingLevels.reviewer;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// Documenter intentionally follows the faster explorer route. Fresh
|
|
415
|
+
// installs leave it unselected; enabling it later inherits any explorer
|
|
416
|
+
// overrides instead of silently choosing a stronger model.
|
|
417
|
+
if (!config.enabledAgents.includes("documenter") && enabled.includes("documenter")) {
|
|
418
|
+
if (!next.agentModels.documenter && config.agentModels.explorer) {
|
|
419
|
+
next.agentModels.documenter = config.agentModels.explorer;
|
|
420
|
+
}
|
|
421
|
+
if (!next.agentThinkingLevels.documenter && config.agentThinkingLevels.explorer) {
|
|
422
|
+
next.agentThinkingLevels.documenter = config.agentThinkingLevels.explorer;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
394
425
|
next.agentModels = keepAgentEntries(next.agentModels, enabled);
|
|
395
426
|
next.agentThinkingLevels = keepAgentEntries(next.agentThinkingLevels, enabled);
|
|
396
427
|
} else if (choice.startsWith("Configure")) {
|