@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/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
- /** Handshake or initial prompt ACK never came back. This is a startup/
70
- * transport miss, not a model/provider failure. */
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 child accepted a prompt; startup retries must never duplicate it. */
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) throw new Error(response.error || `RPC ${response.command} failed.`);
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 (!isRpcCommandTimeoutError(promptError.message)) result.rpcPromptRejected = true;
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, isRpcCommandTimeoutError(promptError.message));
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 queue work has fully
62
- * quiesced and released its concurrency slot. Auto-fix orchestration is part
63
- * of the parent generation and replaces/extends this promise. */
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
- * auto-fix internals that are not directly controllable. */
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
- // Auto-fix parents are flipped back to "running" while their chain
138
- // owns the logical run, so they are included without a special case.
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))
@@ -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: "prove and apply safe cleanup cuts (full tools)",
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 auto-fix rounds? (0 = main agent handles fixes)",
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
- announcedFeatures: base.announcedFeatures,
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 auto-fix rounds",
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 auto-fix rounds?", FIX_ROUNDS_STEPS, config.maxFixRounds, DEFAULT_MAX_FIX_ROUNDS);
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")) {