@ferris1225/pi-subagents 4.3.5 → 4.3.7

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.
@@ -17,6 +17,7 @@ import { SUBAGENT_TOOL_NAMES, type AgentConfig } from "../delegation/agents.ts";
17
17
  import type { ThinkingLevel } from "../configuration/config.ts";
18
18
  import { writeTempOwnerMarker } from "../isolation/temp-hygiene.ts";
19
19
  import {
20
+ asPlainTextRpcPrompt,
20
21
  emptyUsage,
21
22
  RpcRunControl,
22
23
  type AttemptControl,
@@ -25,6 +26,8 @@ import {
25
26
  type SubagentLiveEvent,
26
27
  } from "./rpc-control.ts";
27
28
 
29
+ export { asPlainTextRpcPrompt };
30
+
28
31
  export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
29
32
  export const SUBAGENT_KILL_GRACE_MS = 5_000;
30
33
  /** ACK budget after the child is known to be reading RPC. */
@@ -33,6 +36,8 @@ export const RPC_COMMAND_TIMEOUT_MS = 30_000;
33
36
  /** clear_queue is stop-path hygiene ahead of the abort: give it its own short
34
37
  * budget so a hung response cannot eat into the abort-settle window. */
35
38
  const RPC_CLEAR_QUEUE_TIMEOUT_MS = 2_000;
39
+ /** Keep logical stop responsive when a steering ACK is lost. */
40
+ const RPC_STEER_ACK_TIMEOUT_MS = 2_000;
36
41
  /** Time allowed for the child to boot and answer get_state. */
37
42
  export const RPC_READY_TIMEOUT_MS = 60_000;
38
43
  export const RPC_ABORT_SETTLE_TIMEOUT_MS = 5_000;
@@ -41,14 +46,6 @@ export function isRpcCommandTimeoutError(message?: string): boolean {
41
46
  return typeof message === "string" && message.includes("Timed out waiting for RPC response");
42
47
  }
43
48
 
44
- /** Prevent RPC prompt expansion when a control objective itself starts with
45
- * slash (for example `/subagents-setup`). The original text stays verbatim
46
- * below a non-command prefix and therefore always starts a model turn. */
47
- export function asPlainTextRpcPrompt(message: string): string {
48
- if (!message.trimStart().startsWith("/")) return message;
49
- return `Treat the following as plain-text sub-agent instructions, not a Pi command:\n\n${message}`;
50
- }
51
-
52
49
  export function currentSubagentDepth(env: NodeJS.ProcessEnv = process.env): number {
53
50
  const raw = env[DEPTH_ENV_VAR];
54
51
  const parsed = raw === undefined ? 0 : Number.parseInt(raw, 10);
@@ -452,29 +449,24 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
452
449
  });
453
450
  });
454
451
 
455
- const send = async (command: Record<string, unknown>, timeoutMs = commandTimeoutMs): Promise<RpcResponse> => {
452
+ const send = async <T extends { type: string }>(command: T, timeoutMs = commandTimeoutMs): Promise<RpcResponse> => {
456
453
  if (finished || closed) throw new Error("Subagent RPC process is no longer active.");
457
454
  const id = `req_${++requestId}`;
458
455
  const payload = { ...command, id };
459
456
  return new Promise<RpcResponse>((resolve, reject) => {
460
457
  const pending: PendingRequest = { resolve, reject };
458
+ pending.timer = setTimeout(() => {
459
+ pendingRequests.delete(id);
460
+ reject(new Error(`Timed out waiting for RPC response to ${String(command.type)}.`));
461
+ }, timeoutMs);
462
+ if (typeof pending.timer.unref === "function") pending.timer.unref();
461
463
  pendingRequests.set(id, pending);
462
- void writeLine(payload).then(
463
- () => {
464
- if (!pendingRequests.has(id)) return;
465
- pending.timer = setTimeout(() => {
466
- pendingRequests.delete(id);
467
- reject(new Error(`Timed out waiting for RPC response to ${String(command.type)}.`));
468
- }, timeoutMs);
469
- if (typeof pending.timer.unref === "function") pending.timer.unref();
470
- },
471
- (error) => {
472
- if (!pendingRequests.has(id)) return;
473
- pendingRequests.delete(id);
474
- if (pending.timer) clearTimeout(pending.timer);
475
- reject(error instanceof Error ? error : new Error(String(error)));
476
- },
477
- );
464
+ void writeLine(payload).catch((error) => {
465
+ if (!pendingRequests.has(id)) return;
466
+ pendingRequests.delete(id);
467
+ if (pending.timer) clearTimeout(pending.timer);
468
+ reject(error instanceof Error ? error : new Error(String(error)));
469
+ });
478
470
  }).then((response) => {
479
471
  if (!response.success) {
480
472
  throw new RpcCommandRejectedError(response.error || `RPC ${response.command} failed.`);
@@ -537,6 +529,9 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
537
529
  };
538
530
 
539
531
  const attemptControl: AttemptControl = {
532
+ async steer(command): Promise<void> {
533
+ await send(command, RPC_STEER_ACK_TIMEOUT_MS);
534
+ },
540
535
  async stop(reason = "Subagent was aborted"): Promise<void> {
541
536
  if (finished) {
542
537
  if (!closed) await processClosed.promise;
@@ -792,7 +787,10 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
792
787
 
793
788
  if (signal) {
794
789
  abortHandler = () => {
795
- void attemptControl.stop("Subagent was aborted").catch(() => undefined);
790
+ const stopping = control
791
+ ? control.stop("Subagent was aborted")
792
+ : attemptControl.stop("Subagent was aborted");
793
+ void stopping.catch(() => undefined);
796
794
  };
797
795
  if (signal.aborted) abortHandler();
798
796
  else signal.addEventListener("abort", abortHandler, { once: true });
@@ -344,8 +344,21 @@ export function getResultOutput(result: SingleResult): string {
344
344
  return getFinalOutput(result.messages) || "(no output)";
345
345
  }
346
346
 
347
+ /** Continuation rules shared by every resume flavor. The workspace clause is
348
+ * what keeps a retained context from becoming a liability: a parked or settled
349
+ * thread may return after main integrated sibling worktrees or edited the tree
350
+ * itself, so a file read in an earlier generation is not proof of its content. */
351
+ const RESUME_CONTINUATION_RULES =
352
+ "Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Do not redo searches, reads, or edits that already succeeded. The workspace may have changed while this thread was inactive: before editing a file, re-read it unless you read it during this continuation. Finish with the result-only handoff your role requires.";
353
+
347
354
  export function buildResumePrompt(task: string, reason: string): string {
348
- return `You are resuming an earlier sub-agent session after ${reason}. Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Current objective: ${task}. Pick up exactly where you left off and finish it. Do NOT redo searches, reads, or edits you already completed unless a step clearly failed. Continue now.`;
355
+ return `You are resuming an earlier sub-agent session after ${reason}. ${RESUME_CONTINUATION_RULES} Current objective: ${task}. Pick up exactly where you left off and finish it. Continue now.`;
356
+ }
357
+
358
+ /** A resume with an appended objective continues the same thread: the new
359
+ * objective is guidance layered on retained context, not a restart. */
360
+ export function buildAppendedObjectivePrompt(previousTask: string, objective: string): string {
361
+ return `You are continuing an earlier sub-agent session with an appended objective from the parent. ${RESUME_CONTINUATION_RULES} Previous objective: ${previousTask}. Appended objective: ${objective}. Complete the appended objective on top of the work already done, without restarting from scratch. Continue now.`;
349
362
  }
350
363
 
351
364
  /** Create a fresh private session directory under the given root. The owner
@@ -440,12 +453,7 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
440
453
  const disposition = controlledDisposition(options);
441
454
  if (disposition) return disposition;
442
455
  const objective = control?.getObjective() ?? options.task;
443
- let prompt = options.stdinText ?? `Task: ${objective}`;
444
- if (control && objective !== options.task) {
445
- prompt = options.sessionDir && sessionExists(options.sessionDir, options.sessionId ?? "")
446
- ? `Abandon the previous objective. New objective: ${objective}`
447
- : `Task: ${objective}`;
448
- }
456
+ const prompt = options.stdinText ?? `Task: ${objective}`;
449
457
  const result = await runRpcAgentAttempt({
450
458
  defaultCwd: options.defaultCwd,
451
459
  agent,
@@ -0,0 +1,145 @@
1
+ /** Canonical filesystem guards for paths recovered from durable manifests. */
2
+
3
+ import { existsSync } from "node:fs";
4
+ import { lstat, realpath } from "node:fs/promises";
5
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { getProjectRoot, getSubagentsRoot } from "../execution/spawn.ts";
7
+
8
+ const SESSION_DIR_NAME = /^pi-subagent-session-(?:fork-)?.+$/i;
9
+ const WORKTREE_DIR_NAME = /^pi-subagent-worktree-.+$/i;
10
+ const PROJECT_DIR_NAME = /^.+-[0-9a-f]{12}$/i;
11
+
12
+ function comparable(path: string): string {
13
+ const normalized = resolve(path);
14
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
15
+ }
16
+
17
+ function isCanonicalAbsolute(path: string): boolean {
18
+ return isAbsolute(path) && path === resolve(path);
19
+ }
20
+
21
+ export function samePath(left: string, right: string): boolean {
22
+ return comparable(left) === comparable(right);
23
+ }
24
+
25
+ async function isPlainPath(path: string, kind: "directory" | "file"): Promise<boolean> {
26
+ try {
27
+ const entry = await lstat(path);
28
+ return !entry.isSymbolicLink() && (kind === "directory" ? entry.isDirectory() : entry.isFile());
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ async function isDirectRealChild(root: string, candidate: string): Promise<boolean> {
35
+ try {
36
+ const [realRoot, realCandidate] = await Promise.all([realpath(root), realpath(candidate)]);
37
+ return samePath(dirname(realCandidate), realRoot);
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ async function isManagedDirectory(
44
+ root: string,
45
+ candidate: string,
46
+ namePattern: RegExp,
47
+ ): Promise<boolean> {
48
+ if (!isCanonicalAbsolute(candidate) || !samePath(dirname(candidate), root) || !namePattern.test(basename(candidate))) {
49
+ return false;
50
+ }
51
+ if (!existsSync(candidate)) return true;
52
+ return await isPlainPath(candidate, "directory") && await isDirectRealChild(root, candidate);
53
+ }
54
+
55
+ async function isManagedContainer(root: string, candidate: string, name: string): Promise<boolean> {
56
+ if (!samePath(candidate, join(root, name))) return false;
57
+ if (!existsSync(candidate)) return true;
58
+ return await isPlainPath(candidate, "directory") && await isDirectRealChild(root, candidate);
59
+ }
60
+
61
+ async function hasCanonicalProjectRoot(configPath: string, cwd: string, projectRoot: string): Promise<boolean> {
62
+ const subagentsRoot = getSubagentsRoot(configPath);
63
+ if (!samePath(projectRoot, getProjectRoot(configPath, cwd))) return false;
64
+ if (!isAbsolute(projectRoot) || !samePath(dirname(projectRoot), subagentsRoot)) return false;
65
+ if (!existsSync(projectRoot)) return true;
66
+ return await isPlainPath(projectRoot, "directory") && await isDirectRealChild(subagentsRoot, projectRoot);
67
+ }
68
+
69
+ export async function isManagedSessionDir(
70
+ configPath: string,
71
+ cwd: string,
72
+ sessionDir: string,
73
+ ): Promise<boolean> {
74
+ const projectRoot = getProjectRoot(configPath, cwd);
75
+ if (!await hasCanonicalProjectRoot(configPath, cwd, projectRoot)) return false;
76
+ const sessionsRoot = join(projectRoot, "sessions");
77
+ if (!await isManagedContainer(projectRoot, sessionsRoot, "sessions")) return false;
78
+ return isManagedDirectory(sessionsRoot, sessionDir, SESSION_DIR_NAME);
79
+ }
80
+
81
+ export interface PersistedWorktreePaths {
82
+ cwd: string;
83
+ worktreePath: string;
84
+ tempDir: string;
85
+ patchPath: string;
86
+ }
87
+
88
+ export async function isManagedWorktreeLayout(
89
+ configPath: string,
90
+ cwd: string,
91
+ worktree: PersistedWorktreePaths,
92
+ ): Promise<boolean> {
93
+ const projectRoot = getProjectRoot(configPath, cwd);
94
+ if (!await hasCanonicalProjectRoot(configPath, cwd, projectRoot)) return false;
95
+ const worktreesRoot = join(projectRoot, "worktrees");
96
+ if (!await isManagedContainer(projectRoot, worktreesRoot, "worktrees")) return false;
97
+ if (!await isManagedDirectory(worktreesRoot, worktree.tempDir, WORKTREE_DIR_NAME)) return false;
98
+ if (!isCanonicalAbsolute(worktree.worktreePath) || !samePath(worktree.worktreePath, join(worktree.tempDir, "worktree"))) return false;
99
+ if (!isCanonicalAbsolute(worktree.patchPath) || !samePath(worktree.patchPath, join(worktree.tempDir, "changes.patch"))) return false;
100
+ if (!isCanonicalAbsolute(worktree.cwd)) return false;
101
+ if (existsSync(worktree.worktreePath)) {
102
+ if (!await isPlainPath(worktree.worktreePath, "directory")) return false;
103
+ if (!await isDirectRealChild(worktree.tempDir, worktree.worktreePath)) return false;
104
+ }
105
+ if (existsSync(worktree.patchPath)) {
106
+ if (!await isPlainPath(worktree.patchPath, "file")) return false;
107
+ if (!await isDirectRealChild(worktree.tempDir, worktree.patchPath)) return false;
108
+ }
109
+ return true;
110
+ }
111
+
112
+ /** Return a recovery group only when every persisted artifact has the fixed
113
+ * `<root>/<project>/worktrees/<group>/{worktree,changes.patch}` shape. Existing
114
+ * path components must also stay inside that shape after junction resolution. */
115
+ export async function managedRecoveryGroup(
116
+ configPath: string,
117
+ paths: { worktreePath?: string; patchPath?: string },
118
+ ): Promise<string | undefined> {
119
+ if (paths.worktreePath && !isCanonicalAbsolute(paths.worktreePath)) return undefined;
120
+ if (paths.patchPath && !isCanonicalAbsolute(paths.patchPath)) return undefined;
121
+ const fromWorktree = paths.worktreePath ? dirname(paths.worktreePath) : undefined;
122
+ const fromPatch = paths.patchPath ? dirname(paths.patchPath) : undefined;
123
+ const group = fromWorktree ?? fromPatch;
124
+ if (!group || (fromWorktree && fromPatch && !samePath(fromWorktree, fromPatch))) return undefined;
125
+ if (paths.worktreePath && !samePath(paths.worktreePath, join(group, "worktree"))) return undefined;
126
+ if (paths.patchPath && !samePath(paths.patchPath, join(group, "changes.patch"))) return undefined;
127
+ const worktreesRoot = dirname(group);
128
+ const projectRoot = dirname(worktreesRoot);
129
+ const subagentsRoot = getSubagentsRoot(configPath);
130
+ if (!samePath(worktreesRoot, join(projectRoot, "worktrees")) || !samePath(dirname(projectRoot), subagentsRoot)) {
131
+ return undefined;
132
+ }
133
+ if (!await isManagedDirectory(subagentsRoot, projectRoot, PROJECT_DIR_NAME)) return undefined;
134
+ if (!await isManagedContainer(projectRoot, worktreesRoot, "worktrees")) return undefined;
135
+ if (!await isManagedDirectory(worktreesRoot, group, WORKTREE_DIR_NAME)) return undefined;
136
+ if (paths.worktreePath && existsSync(paths.worktreePath)) {
137
+ if (!await isPlainPath(paths.worktreePath, "directory")) return undefined;
138
+ if (!await isDirectRealChild(group, paths.worktreePath)) return undefined;
139
+ }
140
+ if (paths.patchPath && existsSync(paths.patchPath)) {
141
+ if (!await isPlainPath(paths.patchPath, "file")) return undefined;
142
+ if (!await isDirectRealChild(group, paths.patchPath)) return undefined;
143
+ }
144
+ return group;
145
+ }
@@ -6,7 +6,8 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
6
  import { dirname, join } from "node:path";
7
7
  import { stripVTControlCharacters } from "node:util";
8
8
  import { getSubagentsRoot } from "../execution/spawn.ts";
9
- import { removeWorktreeGroup, worktreeGroupDir, type WorktreeFinalization } from "./worktree.ts";
9
+ import { managedRecoveryGroup } from "./managed-paths.ts";
10
+ import { removeWorktreeGroup, type WorktreeFinalization } from "./worktree.ts";
10
11
 
11
12
  export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
12
13
  const RECOVERY_MANIFEST_VERSION = 1;
@@ -15,7 +16,7 @@ export interface RecoveryRecord {
15
16
  runId: number;
16
17
  createdAt: number;
17
18
  integrated: boolean;
18
- /** Repository a cleanup retry can prune stale worktree metadata against. */
19
+ /** Legacy diagnostic metadata; cleanup authorization comes only from managed paths. */
19
20
  originalRoot?: string;
20
21
  worktreePath?: string;
21
22
  patchPath?: string;
@@ -49,38 +50,62 @@ function normalizeRecord(value: unknown): RecoveryRecord | undefined {
49
50
 
50
51
  interface RecoveryManifestRead {
51
52
  valid: boolean;
53
+ sourceCount: number;
52
54
  records: RecoveryRecord[];
53
55
  }
54
56
 
55
57
  async function readManifest(path: string): Promise<RecoveryManifestRead> {
56
58
  try {
57
59
  const parsed = JSON.parse(await readFile(path, "utf8")) as { records?: unknown };
58
- if (!Array.isArray(parsed.records)) return { valid: false, records: [] };
60
+ if (!Array.isArray(parsed.records)) return { valid: false, sourceCount: 0, records: [] };
59
61
  return {
60
62
  valid: true,
63
+ sourceCount: parsed.records.length,
61
64
  records: parsed.records.flatMap((record) => {
62
65
  const normalized = normalizeRecord(record);
63
66
  return normalized ? [normalized] : [];
64
67
  }),
65
68
  };
66
69
  } catch {
67
- return { valid: false, records: [] };
70
+ return { valid: false, sourceCount: 0, records: [] };
68
71
  }
69
72
  }
70
73
 
74
+ async function validatedRecords(configPath: string, records: readonly RecoveryRecord[]): Promise<RecoveryRecord[]> {
75
+ const groups = await Promise.all(records.map((record) => managedRecoveryGroup(configPath, record)));
76
+ return records.filter((_record, index) => groups[index] !== undefined);
77
+ }
78
+
79
+ export async function referencedRecoveryPaths(
80
+ configPath: string,
81
+ records: readonly RecoveryRecord[],
82
+ ): Promise<Set<string>> {
83
+ const groups = await Promise.all(records.map((record) => managedRecoveryGroup(configPath, record)));
84
+ return new Set(groups.filter((group): group is string => group !== undefined));
85
+ }
86
+
71
87
  export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
72
- return (await readManifest(getRecoveryManifestPath(configPath))).records;
88
+ const path = getRecoveryManifestPath(configPath);
89
+ return withFileMutationQueue(path, async () => {
90
+ const manifest = await readManifest(path);
91
+ const records = await validatedRecords(configPath, manifest.records);
92
+ if (!manifest.valid || records.length !== manifest.sourceCount) await writeManifest(path, records);
93
+ return records;
94
+ });
73
95
  }
74
96
 
75
- /** Move the previous agent-root manifest into the internal-state root without
76
- * dropping retained artifact pointers. Invalid legacy files stay untouched. */
97
+ /** Move valid records from the previous agent-root manifest into the internal-state
98
+ * root. Invalid legacy records are removed without touching their referenced paths. */
77
99
  export async function relocateRecoveryManifest(configPath: string): Promise<void> {
78
100
  const legacyPath = join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
79
101
  const currentPath = getRecoveryManifestPath(configPath);
80
102
  if (legacyPath === currentPath || !existsSync(legacyPath)) return;
81
103
  await withFileMutationQueue(legacyPath, async () => {
82
104
  const legacy = await readManifest(legacyPath);
83
- if (!legacy.valid) return;
105
+ if (!legacy.valid) {
106
+ await rm(legacyPath, { force: true });
107
+ return;
108
+ }
84
109
  await persistRecoveryRecords(configPath, legacy.records);
85
110
  await rm(legacyPath, { force: true });
86
111
  });
@@ -118,8 +143,13 @@ export async function persistRecoveryRecords(
118
143
  const path = getRecoveryManifestPath(configPath);
119
144
  await withFileMutationQueue(path, async () => {
120
145
  const merged = new Map<string, RecoveryRecord>();
121
- for (const record of await readRecoveryRecords(configPath)) merged.set(recoveryKey(record), record);
122
- for (const record of records) merged.set(recoveryKey(record), record);
146
+ const existing = await readManifest(path);
147
+ for (const record of await validatedRecords(configPath, existing.records)) {
148
+ merged.set(recoveryKey(record), record);
149
+ }
150
+ for (const record of await validatedRecords(configPath, records)) {
151
+ merged.set(recoveryKey(record), record);
152
+ }
123
153
  await writeManifest(path, [...merged.values()]);
124
154
  });
125
155
  }
@@ -157,11 +187,10 @@ export async function announceRecoveryRecords(
157
187
  if (records.length === 0) return;
158
188
  for (const record of records) {
159
189
  if (!record.integrated || !record.worktreePath) continue;
160
- const groupDir = worktreeGroupDir(record.worktreePath);
190
+ const groupDir = await managedRecoveryGroup(configPath, record);
161
191
  if (!groupDir) continue;
162
192
  if (!existsSync(record.worktreePath) && !(record.patchPath ? existsSync(record.patchPath) : false)) continue;
163
193
  await removeWorktreeGroup({
164
- originalRoot: record.originalRoot,
165
194
  worktreePath: record.worktreePath,
166
195
  tempDir: groupDir,
167
196
  });
@@ -14,8 +14,8 @@
14
14
  * that no manifest record claims — a settled thread still resumable in the
15
15
  * session that produced it — belongs to a live owner and survives; the same
16
16
  * directory left behind by a crash does not. Callers sweeping durable roots
17
- * additionally pass the paths their manifest still references, so parked work is
18
- * never removed even if its owner is long gone.
17
+ * additionally pass the paths their thread and recovery manifests still reference,
18
+ * so parked and retained work is never removed even if its owner is long gone.
19
19
  *
20
20
  * A live sibling pi instance never loses its directories: `kill(pid, 0)` only
21
21
  * reports "no such process" when the pid genuinely does not exist, so a live
@@ -200,11 +200,11 @@ export function sweepProjectTempDirs(
200
200
  * leaves a full worktree checkout and its session behind until the whole project
201
201
  * goes idle for days — which never happens in a checkout still being worked in.
202
202
  *
203
- * `keep` must report every path the threads manifest still references; parked
204
- * work outlives its owner by design. Removal is a plain recursive delete, the
205
- * same as the idle-project rule: an abandoned worktree may leave a prunable
206
- * registration in its origin repository, which `git worktree prune` and routine
207
- * gc clear on their own. */
203
+ * `keep` must report every path the thread and recovery manifests still reference;
204
+ * parked and retained recovery work outlives its owner by design. Removal is a
205
+ * plain recursive delete, as in idle-project cleanup. An abandoned worktree may
206
+ * leave a prunable registration in its origin repository, which
207
+ * `git worktree prune` and routine gc clear on their own. */
208
208
  export function sweepProjectDurableDirs(
209
209
  durableRoot: string,
210
210
  options: SweepOptions = {},
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { existsSync, symlinkSync } from "node:fs";
12
12
  import { copyFile, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
13
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
13
+ import { isAbsolute, join, relative, resolve } from "node:path";
14
14
  import {
15
15
  GIT_COMMAND_TIMEOUT_MS,
16
16
  GIT_OUTPUT_MAX_BYTES,
@@ -187,16 +187,6 @@ export function isPathInside(root: string, candidate: string): boolean {
187
187
  return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
188
188
  }
189
189
 
190
- /** The temp group directory backing a worktree path, when the path actually
191
- * names our `<group>/worktree` layout. Recovery deletes only paths read back
192
- * from the manifest through this guard. */
193
- export function worktreeGroupDir(worktreePath: string): string | undefined {
194
- const group = dirname(worktreePath);
195
- return basename(worktreePath) === "worktree" && basename(group).startsWith(WORKTREE_TEMP_DIR_PREFIX)
196
- ? group
197
- : undefined;
198
- }
199
-
200
190
  /** Delete one isolated worktree group: Git's own removal keeps metadata
201
191
  * authoritative, but Git on Windows cannot always delete deep checkouts
202
192
  * ("Filename too long"), so the Node removal decides the outcome and the prune
@@ -15,19 +15,22 @@
15
15
 
16
16
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
17
17
  import { existsSync, type Dirent, readdirSync, statSync } from "node:fs";
18
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
18
+ import { mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
19
19
  import { uptime } from "node:os";
20
- import { dirname, join } from "node:path";
20
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
21
21
  import type { UsageStats } from "../execution/rpc-control.ts";
22
22
  import type { SubagentThread } from "./runtime.ts";
23
23
  import { getResultOutput, isFailedResult, getProjectRoot, getSubagentsRoot, type SingleResult } from "../execution/spawn.ts";
24
+ import { isManagedSessionDir, isManagedWorktreeLayout, samePath } from "../isolation/managed-paths.ts";
25
+ import { readRecoveryRecords, referencedRecoveryPaths } from "../isolation/recovery.ts";
24
26
  import {
25
27
  isPathInside,
28
+ normalizeWorktreeSnapshot,
29
+ resolveRepositoryRoot,
26
30
  restoreWorktreeIsolation,
27
31
  type IsolationMode,
28
- normalizeWorktreeSnapshot,
29
- worktreeSnapshot,
30
32
  type WorktreeSnapshot,
33
+ worktreeSnapshot,
31
34
  } from "../isolation/worktree.ts";
32
35
 
33
36
  export const THREADS_MANIFEST_FILE_NAME = "pi-subagents-threads.json";
@@ -35,7 +38,7 @@ const THREADS_MANIFEST_VERSION = 1;
35
38
 
36
39
  /** Project directories whose newest file has not been touched for this long
37
40
  * are deleted wholesale at session start, so per-project sessions/worktrees/results
38
- * can never accumulate forever. Parked threads' manifest references always
41
+ * can never accumulate forever. Valid thread and recovery manifest references always
39
42
  * win over the age rule. */
40
43
  export const PROJECT_ROOT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1_000;
41
44
 
@@ -190,19 +193,80 @@ function normalizeRecord(value: unknown): ThreadRecord | undefined {
190
193
  };
191
194
  }
192
195
 
193
- async function readManifestRecords(path: string): Promise<ThreadRecord[]> {
196
+ interface ThreadManifestRead {
197
+ valid: boolean;
198
+ sourceCount: number;
199
+ records: ThreadRecord[];
200
+ }
201
+
202
+ async function readManifest(path: string): Promise<ThreadManifestRead> {
194
203
  try {
195
- const parsed = JSON.parse(await readFile(path, "utf8")) as {
196
- records?: unknown;
204
+ const parsed = JSON.parse(await readFile(path, "utf8")) as { records?: unknown };
205
+ if (!Array.isArray(parsed.records)) return { valid: false, sourceCount: 0, records: [] };
206
+ return {
207
+ valid: true,
208
+ sourceCount: parsed.records.length,
209
+ records: parsed.records.flatMap((record) => {
210
+ const normalized = normalizeRecord(record);
211
+ return normalized ? [normalized] : [];
212
+ }),
197
213
  };
198
- if (!Array.isArray(parsed.records)) return [];
199
- return parsed.records.flatMap((record) => {
200
- const normalized = normalizeRecord(record);
201
- return normalized ? [normalized] : [];
202
- });
203
214
  } catch {
204
- return [];
215
+ return { valid: false, sourceCount: 0, records: [] };
216
+ }
217
+ }
218
+
219
+ async function readManifestRecords(path: string): Promise<ThreadRecord[]> {
220
+ return (await readManifest(path)).records;
221
+ }
222
+
223
+ async function validateThreadRecord(
224
+ configPath: string,
225
+ manifestPath: string,
226
+ record: ThreadRecord,
227
+ ): Promise<boolean> {
228
+ if (
229
+ !isAbsolute(record.cwd) ||
230
+ !isAbsolute(record.executionCwd) ||
231
+ record.cwd !== resolve(record.cwd) ||
232
+ record.executionCwd !== resolve(record.executionCwd) ||
233
+ !samePath(dirname(manifestPath), getProjectRoot(configPath, record.cwd))
234
+ ) {
235
+ return false;
205
236
  }
237
+ if ((record.sessionId === undefined) !== (record.sessionDir === undefined)) return false;
238
+ if (record.sessionDir && !await isManagedSessionDir(configPath, record.cwd, record.sessionDir)) return false;
239
+ if (record.isolation === "shared") {
240
+ return record.worktree === undefined && samePath(record.executionCwd, record.cwd);
241
+ }
242
+ const worktree = record.worktree;
243
+ if (!worktree || !await isManagedWorktreeLayout(configPath, record.cwd, worktree)) return false;
244
+ try {
245
+ const canonicalCwd = await realpath(record.cwd);
246
+ const canonicalRoot = await resolveRepositoryRoot(record.cwd);
247
+ if (!samePath(worktree.originalCwd, canonicalCwd) || !samePath(worktree.originalRoot, canonicalRoot)) {
248
+ return false;
249
+ }
250
+ if (!isPathInside(canonicalRoot, canonicalCwd)) return false;
251
+ const restoredCwd = join(worktree.worktreePath, relative(canonicalRoot, canonicalCwd));
252
+ if (!samePath(worktree.cwd, restoredCwd) || !samePath(record.executionCwd, restoredCwd)) return false;
253
+ if (existsSync(worktree.cwd)) {
254
+ const [realWorktree, realCwd] = await Promise.all([realpath(worktree.worktreePath), realpath(worktree.cwd)]);
255
+ if (!samePath(realCwd, join(realWorktree, relative(canonicalRoot, canonicalCwd)))) return false;
256
+ }
257
+ return true;
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
263
+ async function validatedManifestRecords(
264
+ configPath: string,
265
+ path: string,
266
+ records: readonly ThreadRecord[],
267
+ ): Promise<ThreadRecord[]> {
268
+ const validity = await Promise.all(records.map((record) => validateThreadRecord(configPath, path, record)));
269
+ return records.filter((_record, index) => validity[index]);
206
270
  }
207
271
 
208
272
  /** Manifest paths of every project that has a durable root. */
@@ -221,7 +285,12 @@ function projectManifestPaths(durableRoot: string): string[] {
221
285
  export async function readThreadRecords(configPath: string): Promise<ThreadRecord[]> {
222
286
  const manifests = await Promise.all(
223
287
  projectManifestPaths(getSubagentsRoot(configPath))
224
- .map((path) => readManifestRecords(path)),
288
+ .map((path) => withFileMutationQueue(path, async () => {
289
+ const manifest = await readManifest(path);
290
+ const validated = await validatedManifestRecords(configPath, path, manifest.records);
291
+ if (!manifest.valid || validated.length !== manifest.sourceCount) await writeManifest(path, validated);
292
+ return validated;
293
+ })),
225
294
  );
226
295
  return manifests.flat();
227
296
  }
@@ -367,11 +436,14 @@ export async function pruneThreadRecords(
367
436
  const durableRoot = getSubagentsRoot(configPath);
368
437
  for (const path of projectManifestPaths(durableRoot)) {
369
438
  await withFileMutationQueue(path, async () => {
370
- const records = await readManifestRecords(path);
371
- if (records.length === 0) return;
372
- let changed = false;
439
+ const manifest = await readManifest(path);
440
+ const records = manifest.records;
441
+ if (manifest.valid && records.length === 0) return;
442
+ const validity = await Promise.all(records.map((record) => validateThreadRecord(configPath, path, record)));
443
+ let changed = !manifest.valid || manifest.sourceCount !== records.length || validity.some((valid) => !valid);
373
444
  const kept: ThreadRecord[] = [];
374
- for (const record of records) {
445
+ for (const [index, record] of records.entries()) {
446
+ if (!validity[index]) continue;
375
447
  if (now - record.updatedAt <= PARKED_RECORD_MAX_AGE_MS) {
376
448
  kept.push(record);
377
449
  continue;
@@ -384,8 +456,8 @@ export async function pruneThreadRecords(
384
456
  }
385
457
  }
386
458
 
387
- /** Paths a manifest still references; used by the state-root sweep so
388
- * freshly created-but-unrecorded directories are never touched. */
459
+ /** Paths a thread manifest still references; combined with recovery references by
460
+ * startup retention before any durable directory is swept. */
389
461
  export function referencedDurablePaths(records: readonly ThreadRecord[]): Set<string> {
390
462
  const paths = new Set<string>();
391
463
  for (const record of records) {
@@ -436,13 +508,15 @@ function isIdleSince(root: string, cutoffMs: number, now: number): boolean {
436
508
  }
437
509
 
438
510
  /** Delete project directories under the ferris-pi-subagents root that have
439
- * been idle past PROJECT_ROOT_MAX_AGE_MS. A directory containing any path the
440
- * threads manifest still references is never touched, so parked work outlives
441
- * the age rule. Returns the removed directory names. */
511
+ * been idle past PROJECT_ROOT_MAX_AGE_MS. A directory containing any path a valid
512
+ * thread or recovery manifest still references is never touched. Returns the removed
513
+ * directory names. */
442
514
  export async function pruneStaleProjectRoots(configPath: string, options: { now?: number } = {}): Promise<string[]> {
443
515
  const now = options.now ?? Date.now();
444
516
  const records = await readThreadRecords(configPath).catch(() => [] as ThreadRecord[]);
517
+ const recoveryRecords = await readRecoveryRecords(configPath).catch(() => []);
445
518
  const referenced = referencedDurablePaths(records);
519
+ for (const path of await referencedRecoveryPaths(configPath, recoveryRecords)) referenced.add(path);
446
520
  const root = getSubagentsRoot(configPath);
447
521
  let projects: Dirent[];
448
522
  try {