@ferris1225/pi-subagents 4.2.7 → 4.2.12

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,163 +1,163 @@
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 { removeWorktreeGroup, worktreeGroupDir, 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
- /** Repository a cleanup retry can prune stale worktree metadata against. */
18
- originalRoot?: string;
19
- worktreePath?: string;
20
- patchPath?: string;
21
- error?: string;
22
- }
23
-
24
- interface RecoveryManifest {
25
- version: number;
26
- records: RecoveryRecord[];
27
- }
28
-
29
- export function getRecoveryManifestPath(configPath: string): string {
30
- return join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
31
- }
32
-
33
- function normalizeRecord(value: unknown): RecoveryRecord | undefined {
34
- if (!value || typeof value !== "object") return undefined;
35
- const raw = value as Record<string, unknown>;
36
- if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
37
- if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
38
- return {
39
- runId: raw.runId,
40
- createdAt: raw.createdAt,
41
- integrated: raw.integrated === true,
42
- ...(typeof raw.originalRoot === "string" && raw.originalRoot ? { originalRoot: raw.originalRoot } : {}),
43
- ...(typeof raw.worktreePath === "string" && raw.worktreePath ? { worktreePath: raw.worktreePath } : {}),
44
- ...(typeof raw.patchPath === "string" && raw.patchPath ? { patchPath: raw.patchPath } : {}),
45
- ...(typeof raw.error === "string" && raw.error ? { error: raw.error } : {}),
46
- };
47
- }
48
-
49
- export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
50
- try {
51
- const parsed = JSON.parse(await readFile(getRecoveryManifestPath(configPath), "utf8")) as {
52
- records?: unknown;
53
- };
54
- if (!Array.isArray(parsed.records)) return [];
55
- return parsed.records.flatMap((record) => {
56
- const normalized = normalizeRecord(record);
57
- return normalized ? [normalized] : [];
58
- });
59
- } catch {
60
- return [];
61
- }
62
- }
63
-
64
- function recoveryKey(record: RecoveryRecord): string {
65
- return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
66
- }
67
-
68
- async function writeManifest(path: string, records: readonly RecoveryRecord[]): Promise<void> {
69
- if (records.length === 0) {
70
- await rm(path, { force: true });
71
- return;
72
- }
73
- await mkdir(dirname(path), { recursive: true });
74
- const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
75
- try {
76
- const manifest: RecoveryManifest = {
77
- version: RECOVERY_MANIFEST_VERSION,
78
- records: [...records],
79
- };
80
- await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
81
- await rename(temporaryPath, path);
82
- } finally {
83
- await rm(temporaryPath, { force: true }).catch(() => undefined);
84
- }
85
- }
86
-
87
- /** Merge retained artifacts into the durable manifest. */
88
- export async function persistRecoveryRecords(
89
- configPath: string,
90
- records: readonly RecoveryRecord[],
91
- ): Promise<void> {
92
- if (records.length === 0) return;
93
- const path = getRecoveryManifestPath(configPath);
94
- await withFileMutationQueue(path, async () => {
95
- const merged = new Map<string, RecoveryRecord>();
96
- for (const record of await readRecoveryRecords(configPath)) merged.set(recoveryKey(record), record);
97
- for (const record of records) merged.set(recoveryKey(record), record);
98
- await writeManifest(path, [...merged.values()]);
99
- });
100
- }
101
-
102
- export function recoveryRecordFromFinalization(
103
- runId: number,
104
- finalization: WorktreeFinalization,
105
- now = Date.now(),
106
- ): RecoveryRecord {
107
- return {
108
- runId,
109
- createdAt: now,
110
- integrated: finalization.integrated,
111
- ...(finalization.originalRoot ? { originalRoot: finalization.originalRoot } : {}),
112
- ...(finalization.worktreePath ? { worktreePath: finalization.worktreePath } : {}),
113
- ...(finalization.patchPath ? { patchPath: finalization.patchPath } : {}),
114
- ...(finalization.error ? { error: finalization.error } : {}),
115
- };
116
- }
117
-
118
- /** Show retained recovery paths on every later session start until the user
119
- * removes the artifacts. Records whose changes already landed only need the
120
- * worktree group deleted — the step whose failure retained them — so each
121
- * session start retries that removal first and forgets records it completes.
122
- * Stale records are pruned automatically. */
123
- export async function announceRecoveryRecords(
124
- configPath: string,
125
- ctx: {
126
- hasUI?: boolean;
127
- ui: { notify(message: string, kind: "info" | "warning" | "error"): void };
128
- },
129
- ): Promise<void> {
130
- if (ctx.hasUI === false) return;
131
- const records = await readRecoveryRecords(configPath);
132
- if (records.length === 0) return;
133
- for (const record of records) {
134
- if (!record.integrated || !record.worktreePath) continue;
135
- const groupDir = worktreeGroupDir(record.worktreePath);
136
- if (!groupDir) continue;
137
- if (!existsSync(record.worktreePath) && !(record.patchPath ? existsSync(record.patchPath) : false)) continue;
138
- await removeWorktreeGroup({
139
- originalRoot: record.originalRoot,
140
- worktreePath: record.worktreePath,
141
- tempDir: groupDir,
142
- });
143
- }
144
- const live = records.filter((record) =>
145
- (record.worktreePath ? existsSync(record.worktreePath) : false) ||
146
- (record.patchPath ? existsSync(record.patchPath) : false),
147
- );
148
- if (live.length !== records.length) {
149
- const path = getRecoveryManifestPath(configPath);
150
- await withFileMutationQueue(path, () => writeManifest(path, live)).catch(() => undefined);
151
- }
152
- for (const record of live) {
153
- const paths = [
154
- record.worktreePath ? `worktree ${stripVTControlCharacters(record.worktreePath)}` : undefined,
155
- record.patchPath ? `patch ${stripVTControlCharacters(record.patchPath)}` : undefined,
156
- ].filter(Boolean).join(" · ");
157
- const reason = record.error ? ` · ${stripVTControlCharacters(record.error)}` : "";
158
- ctx.ui.notify(
159
- `pi-subagents recovery for run #${record.runId}: ${record.integrated ? "changes were applied but cleanup failed" : "integration failed"}${paths ? ` · retained ${paths}` : ""}${reason}`,
160
- "error",
161
- );
162
- }
163
- }
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 { removeWorktreeGroup, worktreeGroupDir, 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
+ /** Repository a cleanup retry can prune stale worktree metadata against. */
18
+ originalRoot?: string;
19
+ worktreePath?: string;
20
+ patchPath?: string;
21
+ error?: string;
22
+ }
23
+
24
+ interface RecoveryManifest {
25
+ version: number;
26
+ records: RecoveryRecord[];
27
+ }
28
+
29
+ export function getRecoveryManifestPath(configPath: string): string {
30
+ return join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
31
+ }
32
+
33
+ function normalizeRecord(value: unknown): RecoveryRecord | undefined {
34
+ if (!value || typeof value !== "object") return undefined;
35
+ const raw = value as Record<string, unknown>;
36
+ if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
37
+ if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
38
+ return {
39
+ runId: raw.runId,
40
+ createdAt: raw.createdAt,
41
+ integrated: raw.integrated === true,
42
+ ...(typeof raw.originalRoot === "string" && raw.originalRoot ? { originalRoot: raw.originalRoot } : {}),
43
+ ...(typeof raw.worktreePath === "string" && raw.worktreePath ? { worktreePath: raw.worktreePath } : {}),
44
+ ...(typeof raw.patchPath === "string" && raw.patchPath ? { patchPath: raw.patchPath } : {}),
45
+ ...(typeof raw.error === "string" && raw.error ? { error: raw.error } : {}),
46
+ };
47
+ }
48
+
49
+ export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
50
+ try {
51
+ const parsed = JSON.parse(await readFile(getRecoveryManifestPath(configPath), "utf8")) as {
52
+ records?: unknown;
53
+ };
54
+ if (!Array.isArray(parsed.records)) return [];
55
+ return parsed.records.flatMap((record) => {
56
+ const normalized = normalizeRecord(record);
57
+ return normalized ? [normalized] : [];
58
+ });
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ function recoveryKey(record: RecoveryRecord): string {
65
+ return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
66
+ }
67
+
68
+ async function writeManifest(path: string, records: readonly RecoveryRecord[]): Promise<void> {
69
+ if (records.length === 0) {
70
+ await rm(path, { force: true });
71
+ return;
72
+ }
73
+ await mkdir(dirname(path), { recursive: true });
74
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
75
+ try {
76
+ const manifest: RecoveryManifest = {
77
+ version: RECOVERY_MANIFEST_VERSION,
78
+ records: [...records],
79
+ };
80
+ await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
81
+ await rename(temporaryPath, path);
82
+ } finally {
83
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
84
+ }
85
+ }
86
+
87
+ /** Merge retained artifacts into the durable manifest. */
88
+ export async function persistRecoveryRecords(
89
+ configPath: string,
90
+ records: readonly RecoveryRecord[],
91
+ ): Promise<void> {
92
+ if (records.length === 0) return;
93
+ const path = getRecoveryManifestPath(configPath);
94
+ await withFileMutationQueue(path, async () => {
95
+ const merged = new Map<string, RecoveryRecord>();
96
+ for (const record of await readRecoveryRecords(configPath)) merged.set(recoveryKey(record), record);
97
+ for (const record of records) merged.set(recoveryKey(record), record);
98
+ await writeManifest(path, [...merged.values()]);
99
+ });
100
+ }
101
+
102
+ export function recoveryRecordFromFinalization(
103
+ runId: number,
104
+ finalization: WorktreeFinalization,
105
+ now = Date.now(),
106
+ ): RecoveryRecord {
107
+ return {
108
+ runId,
109
+ createdAt: now,
110
+ integrated: finalization.integrated,
111
+ ...(finalization.originalRoot ? { originalRoot: finalization.originalRoot } : {}),
112
+ ...(finalization.worktreePath ? { worktreePath: finalization.worktreePath } : {}),
113
+ ...(finalization.patchPath ? { patchPath: finalization.patchPath } : {}),
114
+ ...(finalization.error ? { error: finalization.error } : {}),
115
+ };
116
+ }
117
+
118
+ /** Show retained recovery paths on every later session start until the user
119
+ * removes the artifacts. Records whose changes already landed only need the
120
+ * worktree group deleted — the step whose failure retained them — so each
121
+ * session start retries that removal first and forgets records it completes.
122
+ * Stale records are pruned automatically. */
123
+ export async function announceRecoveryRecords(
124
+ configPath: string,
125
+ ctx: {
126
+ hasUI?: boolean;
127
+ ui: { notify(message: string, kind: "info" | "warning" | "error"): void };
128
+ },
129
+ ): Promise<void> {
130
+ if (ctx.hasUI === false) return;
131
+ const records = await readRecoveryRecords(configPath);
132
+ if (records.length === 0) return;
133
+ for (const record of records) {
134
+ if (!record.integrated || !record.worktreePath) continue;
135
+ const groupDir = worktreeGroupDir(record.worktreePath);
136
+ if (!groupDir) continue;
137
+ if (!existsSync(record.worktreePath) && !(record.patchPath ? existsSync(record.patchPath) : false)) continue;
138
+ await removeWorktreeGroup({
139
+ originalRoot: record.originalRoot,
140
+ worktreePath: record.worktreePath,
141
+ tempDir: groupDir,
142
+ });
143
+ }
144
+ const live = records.filter((record) =>
145
+ (record.worktreePath ? existsSync(record.worktreePath) : false) ||
146
+ (record.patchPath ? existsSync(record.patchPath) : false),
147
+ );
148
+ if (live.length !== records.length) {
149
+ const path = getRecoveryManifestPath(configPath);
150
+ await withFileMutationQueue(path, () => writeManifest(path, live)).catch(() => undefined);
151
+ }
152
+ for (const record of live) {
153
+ const paths = [
154
+ record.worktreePath ? `worktree ${stripVTControlCharacters(record.worktreePath)}` : undefined,
155
+ record.patchPath ? `patch ${stripVTControlCharacters(record.patchPath)}` : undefined,
156
+ ].filter(Boolean).join(" · ");
157
+ const reason = record.error ? ` · ${stripVTControlCharacters(record.error)}` : "";
158
+ ctx.ui.notify(
159
+ `pi-subagents recovery for run #${record.runId}: ${record.integrated ? "changes were applied but cleanup failed" : "integration failed"}${paths ? ` · retained ${paths}` : ""}${reason}`,
160
+ "error",
161
+ );
162
+ }
163
+ }
package/src/runtime.ts CHANGED
@@ -131,6 +131,12 @@ export interface SubagentRuntime {
131
131
 
132
132
  export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
133
133
  const backgroundQueue = new BackgroundTaskQueue(resolveSubagentConcurrency());
134
+ // Compaction reads the whole history, summarizes it, and replaces it. A
135
+ // completion injected into that window can be swallowed by the summary,
136
+ // silently losing a result a child spent minutes producing — so delivery is
137
+ // held until compaction settles (either outcome) instead.
138
+ let compactionInFlight = false;
139
+ let heldCompletions: CompletionMessageItem[] = [];
134
140
 
135
141
  const runtime: SubagentRuntime = {
136
142
  configPath,
@@ -142,6 +148,10 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
142
148
  restoredNotified: false,
143
149
  sendCompletionGroup: (items) => {
144
150
  if (!runtime.sessionActive || items.length === 0) return;
151
+ if (compactionInFlight) {
152
+ heldCompletions.push(...items);
153
+ return;
154
+ }
145
155
  // A result arriving for one run does not mean sibling runs are done.
146
156
  // Computing this at delivery (emit) time — not when the item was
147
157
  // pushed — reflects the current monitor state, since finishing runs
@@ -242,6 +252,10 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
242
252
  }
243
253
  const preflights = [...runtime.preflightOperations];
244
254
  runtime.completionBatcher.dispose();
255
+ // Held items are dropped exactly like the batcher's abandoned ones: the
256
+ // session is gone, so there is no window left to deliver them into.
257
+ heldCompletions = [];
258
+ compactionInFlight = false;
245
259
  runtime.backgroundQueue.cancelAll();
246
260
  // Await live RPC process-tree cleanup and continuation preflight rollback
247
261
  // before persisting records or releasing ownership maps.
@@ -308,6 +322,25 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
308
322
  },
309
323
  };
310
324
 
325
+ // Hold delivery across compaction. `session_before_compact` opens the
326
+ // window; BOTH terminal events close it, because a failed or aborted
327
+ // compaction that never released would strand every held result forever.
328
+ pi.on("session_before_compact", () => {
329
+ compactionInFlight = true;
330
+ });
331
+ const releaseHeldCompletions = (): void => {
332
+ compactionInFlight = false;
333
+ if (heldCompletions.length === 0) return;
334
+ const items = heldCompletions;
335
+ heldCompletions = [];
336
+ // Re-enter the normal path now that the gate is open: the active-runs
337
+ // footer and the steer/nextTurn choice must reflect delivery time, not
338
+ // the moment the items were held.
339
+ runtime.sendCompletionGroup(items);
340
+ };
341
+ pi.on("session_compact", releaseHeldCompletions);
342
+ pi.on("session_compact_failed", releaseHeldCompletions);
343
+
311
344
  runtime.completionBatcher = createCompletionBatcher<CompletionMessageItem>({
312
345
  emit: runtime.sendCompletionGroup,
313
346
  });
@@ -1,86 +1,86 @@
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 { mkdir, mkdtemp, rm } from "node:fs/promises";
6
- import { join } from "node:path";
7
- import { writeTempOwnerMarker } from "./temp-hygiene.ts";
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
- /** Parent directory for the cloned branch: the project-scoped durable
47
- * sessions root, so forks never land in the OS temp directory. */
48
- targetRoot: string;
49
- }): Promise<ForkedSession> {
50
- const sourceSessionFile = await findRetainedSessionFile(
51
- options.sessionDir,
52
- options.sessionId,
53
- );
54
- const root = options.targetRoot;
55
- await mkdir(root, { recursive: true });
56
- const sessionDir = await mkdtemp(join(root, "pi-subagent-session-fork-"));
57
- writeTempOwnerMarker(sessionDir);
58
- try {
59
- // Supplying the new directory makes createBranchedSession write there.
60
- // cwdOverride rewrites the cloned header so a settled isolated session can
61
- // safely continue in its fresh worktree instead of a removed old path.
62
- const manager = SessionManager.open(
63
- sourceSessionFile,
64
- sessionDir,
65
- options.targetCwd ?? options.cwd,
66
- );
67
- const leafId = manager.getLeafId();
68
- if (!leafId) throw new Error(`Retained session ${options.sessionId} has no active branch to fork.`);
69
- const sessionFile = manager.createBranchedSession(leafId);
70
- if (!sessionFile) throw new Error("Pi SessionManager did not create a persistent fork.");
71
- // Pi defers branch files that contain no assistant response. Such a file
72
- // cannot be resumed by RPC without creating a blank session, so reject
73
- // rather than pretending context was preserved.
74
- if (!existsSync(sessionFile)) {
75
- throw new Error(`Forked session branch has no persisted assistant checkpoint at ${sessionFile}.`);
76
- }
77
- return {
78
- sessionDir,
79
- sessionId: manager.getSessionId(),
80
- sessionFile,
81
- };
82
- } catch (error) {
83
- await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
84
- throw error;
85
- }
86
- }
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 { mkdir, mkdtemp, rm } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { writeTempOwnerMarker } from "./temp-hygiene.ts";
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
+ /** Parent directory for the cloned branch: the project-scoped durable
47
+ * sessions root, so forks never land in the OS temp directory. */
48
+ targetRoot: string;
49
+ }): Promise<ForkedSession> {
50
+ const sourceSessionFile = await findRetainedSessionFile(
51
+ options.sessionDir,
52
+ options.sessionId,
53
+ );
54
+ const root = options.targetRoot;
55
+ await mkdir(root, { recursive: true });
56
+ const sessionDir = await mkdtemp(join(root, "pi-subagent-session-fork-"));
57
+ writeTempOwnerMarker(sessionDir);
58
+ try {
59
+ // Supplying the new directory makes createBranchedSession write there.
60
+ // cwdOverride rewrites the cloned header so a settled isolated session can
61
+ // safely continue in its fresh worktree instead of a removed old path.
62
+ const manager = SessionManager.open(
63
+ sourceSessionFile,
64
+ sessionDir,
65
+ options.targetCwd ?? options.cwd,
66
+ );
67
+ const leafId = manager.getLeafId();
68
+ if (!leafId) throw new Error(`Retained session ${options.sessionId} has no active branch to fork.`);
69
+ const sessionFile = manager.createBranchedSession(leafId);
70
+ if (!sessionFile) throw new Error("Pi SessionManager did not create a persistent fork.");
71
+ // Pi defers branch files that contain no assistant response. Such a file
72
+ // cannot be resumed by RPC without creating a blank session, so reject
73
+ // rather than pretending context was preserved.
74
+ if (!existsSync(sessionFile)) {
75
+ throw new Error(`Forked session branch has no persisted assistant checkpoint at ${sessionFile}.`);
76
+ }
77
+ return {
78
+ sessionDir,
79
+ sessionId: manager.getSessionId(),
80
+ sessionFile,
81
+ };
82
+ } catch (error) {
83
+ await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
84
+ throw error;
85
+ }
86
+ }