@ferris1225/pi-subagents 4.1.18 → 4.1.21

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/durable.ts CHANGED
@@ -1,402 +1,443 @@
1
- /**
2
- * Durable thread state: a manifest next to the config that lets interrupted
3
- * (parked) sub-agent threads survive pi reloads and restarts, plus the durable
4
- * state root that keeps their retained sessions and isolated worktrees out of
5
- * the OS temp directory.
6
- *
7
- * Only parked threads are ever recorded: a thread that settles normally drops
8
- * its record, so the manifest file exists exactly while unfinished work needs
9
- * it and disappears on its own. Records are small path/state snapshots, never
10
- * full transcripts; the retained Pi session files and worktrees they point at
11
- * remain the actual context. Writes are atomic (tmp+rename) and serialized
12
- * through the same withFileMutationQueue as the recovery manifest.
13
- */
14
-
15
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
16
- import { existsSync, type Dirent, readdirSync, statSync } from "node:fs";
17
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
18
- import { dirname, join } from "node:path";
19
- import type { UsageStats } from "./rpc-run.ts";
20
- import type { SubagentThread } from "./runtime.ts";
21
- import { getResultOutput, isFailedResult, getProjectRoot, PROJECT_ROOTS_DIR_NAME, type SingleResult } from "./spawn.ts";
22
- import {
23
- isPathInside,
24
- restoreWorktreeIsolation,
25
- type IsolationMode,
26
- normalizeWorktreeSnapshot,
27
- worktreeSnapshot,
28
- type WorktreeSnapshot,
29
- } from "./worktree.ts";
30
-
31
- export const THREADS_MANIFEST_FILE_NAME = "pi-subagents-threads.json";
32
- const THREADS_MANIFEST_VERSION = 1;
33
-
34
- /** Project directories whose newest file has not been touched for this long
35
- * are deleted wholesale on load, so per-project sessions/worktrees/results
36
- * can never accumulate forever. Parked threads' manifest references always
37
- * win over the age rule. */
38
- export const PROJECT_ROOT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1_000;
39
-
40
- /** Fixed retention: parked work (which may hold unintegrated changes) stops
41
- * being resumable after a month. Older manifests may still carry settled
42
- * records from previous versions; restore discards them on sight. */
43
- export const PARKED_RECORD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
44
-
45
- /** Result excerpts are for status display after restore, not full transcripts. */
46
- const RESULT_SUMMARY_MAX_CHARS = 4_000;
47
-
48
- export interface ThreadResultSummary {
49
- agent: string;
50
- task: string;
51
- exitCode: number;
52
- failed: boolean;
53
- stopReason?: string;
54
- usage: UsageStats;
55
- model?: string;
56
- thinking?: string;
57
- output: string;
58
- }
59
-
60
- export interface ThreadRecord {
61
- runId: number;
62
- createdAt: number;
63
- updatedAt: number;
64
- generation: number;
65
- agentName: string;
66
- task: string;
67
- cwd: string;
68
- executionCwd: string;
69
- thinkingLevel?: string;
70
- isolation: IsolationMode;
71
- state: "parked" | "completed" | "failed";
72
- elapsedMs: number;
73
- sessionId?: string;
74
- sessionDir?: string;
75
- worktree?: WorktreeSnapshot;
76
- childPids: number[];
77
- resultSummary?: ThreadResultSummary;
78
- }
79
-
80
- interface ThreadsManifest {
81
- version: number;
82
- records: ThreadRecord[];
83
- }
84
-
85
- export function getThreadsManifestPath(configPath: string): string {
86
- return join(dirname(configPath), THREADS_MANIFEST_FILE_NAME);
87
- }
88
-
89
- function normalizeUsage(value: unknown): UsageStats {
90
- const raw = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
91
- const num = (key: string): number => (typeof raw[key] === "number" && Number.isFinite(raw[key]) ? raw[key] : 0);
92
- return {
93
- input: num("input"),
94
- output: num("output"),
95
- cacheRead: num("cacheRead"),
96
- cacheWrite: num("cacheWrite"),
97
- cost: num("cost"),
98
- contextTokens: num("contextTokens"),
99
- turns: num("turns"),
100
- };
101
- }
102
-
103
- function normalizeResultSummary(value: unknown): ThreadResultSummary | undefined {
104
- if (!value || typeof value !== "object") return undefined;
105
- const raw = value as Record<string, unknown>;
106
- if (typeof raw.agent !== "string" || !raw.agent) return undefined;
107
- if (typeof raw.output !== "string") return undefined;
108
- return {
109
- agent: raw.agent,
110
- task: typeof raw.task === "string" ? raw.task : raw.agent,
111
- exitCode: typeof raw.exitCode === "number" ? raw.exitCode : 0,
112
- failed: raw.failed === true,
113
- ...(typeof raw.stopReason === "string" && raw.stopReason ? { stopReason: raw.stopReason } : {}),
114
- usage: normalizeUsage(raw.usage),
115
- ...(typeof raw.model === "string" && raw.model ? { model: raw.model } : {}),
116
- ...(typeof raw.thinking === "string" && raw.thinking ? { thinking: raw.thinking } : {}),
117
- output: raw.output,
118
- };
119
- }
120
-
121
- function normalizeRecord(value: unknown): ThreadRecord | undefined {
122
- if (!value || typeof value !== "object") return undefined;
123
- const raw = value as Record<string, unknown>;
124
- if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
125
- if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
126
- if (typeof raw.updatedAt !== "number" || !Number.isFinite(raw.updatedAt)) return undefined;
127
- if (typeof raw.agentName !== "string" || !raw.agentName) return undefined;
128
- if (typeof raw.task !== "string" || !raw.task) return undefined;
129
- if (typeof raw.cwd !== "string" || !raw.cwd) return undefined;
130
- if (raw.isolation !== "shared" && raw.isolation !== "worktree") return undefined;
131
- if (raw.state !== "parked" && raw.state !== "completed" && raw.state !== "failed") return undefined;
132
- const worktree = raw.worktree === undefined ? undefined : normalizeWorktreeSnapshot(raw.worktree);
133
- if (worktree === null) return undefined;
134
- return {
135
- runId: raw.runId,
136
- createdAt: raw.createdAt,
137
- updatedAt: raw.updatedAt,
138
- generation: typeof raw.generation === "number" && Number.isInteger(raw.generation) && raw.generation >= 0 ? raw.generation : 0,
139
- agentName: raw.agentName,
140
- task: raw.task,
141
- cwd: raw.cwd,
142
- executionCwd: typeof raw.executionCwd === "string" && raw.executionCwd ? raw.executionCwd : raw.cwd,
143
- ...(typeof raw.thinkingLevel === "string" && raw.thinkingLevel ? { thinkingLevel: raw.thinkingLevel } : {}),
144
- isolation: raw.isolation,
145
- state: raw.state,
146
- elapsedMs: typeof raw.elapsedMs === "number" && Number.isFinite(raw.elapsedMs) ? Math.max(0, raw.elapsedMs) : 0,
147
- ...(typeof raw.sessionId === "string" && raw.sessionId ? { sessionId: raw.sessionId } : {}),
148
- ...(typeof raw.sessionDir === "string" && raw.sessionDir ? { sessionDir: raw.sessionDir } : {}),
149
- ...(worktree ? { worktree } : {}),
150
- childPids: Array.isArray(raw.childPids)
151
- ? raw.childPids.filter((pid): pid is number => typeof pid === "number" && Number.isInteger(pid) && pid > 0)
152
- : [],
153
- ...(raw.resultSummary === undefined ? {} : { resultSummary: normalizeResultSummary(raw.resultSummary) }),
154
- };
155
- }
156
-
157
- export async function readThreadRecords(configPath: string): Promise<ThreadRecord[]> {
158
- try {
159
- const parsed = JSON.parse(await readFile(getThreadsManifestPath(configPath), "utf8")) as {
160
- records?: unknown;
161
- };
162
- if (!Array.isArray(parsed.records)) return [];
163
- return parsed.records.flatMap((record) => {
164
- const normalized = normalizeRecord(record);
165
- return normalized ? [normalized] : [];
166
- });
167
- } catch {
168
- return [];
169
- }
170
- }
171
-
172
- async function writeManifest(configPath: string, records: readonly ThreadRecord[]): Promise<void> {
173
- const path = getThreadsManifestPath(configPath);
174
- if (records.length === 0) {
175
- await rm(path, { force: true });
176
- return;
177
- }
178
- await mkdir(dirname(path), { recursive: true });
179
- const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
180
- try {
181
- const manifest: ThreadsManifest = {
182
- version: THREADS_MANIFEST_VERSION,
183
- records: [...records],
184
- };
185
- await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
186
- await rename(temporaryPath, path);
187
- } finally {
188
- await rm(temporaryPath, { force: true }).catch(() => undefined);
189
- }
190
- }
191
-
192
- export async function upsertThreadRecord(configPath: string, record: ThreadRecord): Promise<void> {
193
- const path = getThreadsManifestPath(configPath);
194
- await withFileMutationQueue(path, async () => {
195
- const records = await readThreadRecords(configPath);
196
- const index = records.findIndex((candidate) => candidate.runId === record.runId);
197
- const merged: ThreadRecord = index === -1
198
- ? record
199
- : { ...record, createdAt: records[index]!.createdAt };
200
- if (index === -1) records.push(merged);
201
- else records[index] = merged;
202
- await writeManifest(configPath, records);
203
- });
204
- }
205
-
206
- export async function removeThreadRecord(configPath: string, runId: number): Promise<void> {
207
- const path = getThreadsManifestPath(configPath);
208
- await withFileMutationQueue(path, async () => {
209
- const records = await readThreadRecords(configPath);
210
- const next = records.filter((record) => record.runId !== runId);
211
- if (next.length === records.length) return;
212
- await writeManifest(configPath, next);
213
- });
214
- }
215
-
216
- function truncateSummary(text: string): string {
217
- if (text.length <= RESULT_SUMMARY_MAX_CHARS) return text;
218
- return `${text.slice(0, RESULT_SUMMARY_MAX_CHARS - 1)}…`;
219
- }
220
-
221
- function summarizeResult(result: SingleResult): ThreadResultSummary | undefined {
222
- if (!result) return undefined;
223
- return {
224
- agent: result.agent,
225
- task: result.task,
226
- exitCode: result.exitCode,
227
- failed: isFailedResult(result),
228
- ...(result.stopReason ? { stopReason: result.stopReason } : {}),
229
- usage: result.usage,
230
- ...(result.model ? { model: result.model } : {}),
231
- ...(result.thinking ? { thinking: result.thinking } : {}),
232
- output: truncateSummary(getResultOutput(result)),
233
- };
234
- }
235
-
236
- /** Project a live thread into its durable record. Only handles whose
237
- * filesystem is still meaningful are persisted; finalized-and-removed
238
- * worktrees keep just their checkpoint commit for continuation resumes. */
239
- export function threadRecordFromThread(
240
- thread: SubagentThread,
241
- state: "parked" | "completed" | "failed",
242
- previous?: ThreadRecord,
243
- now = Date.now(),
244
- ): ThreadRecord {
245
- const worktree = thread.worktree ? worktreeSnapshot(thread.worktree) : undefined;
246
- return {
247
- runId: thread.id,
248
- createdAt: previous?.createdAt ?? now,
249
- updatedAt: now,
250
- generation: thread.generation,
251
- agentName: thread.agentName,
252
- task: thread.task,
253
- cwd: thread.cwd,
254
- executionCwd: thread.executionCwd,
255
- ...(thread.thinkingLevel ? { thinkingLevel: thread.thinkingLevel } : {}),
256
- isolation: thread.isolation,
257
- state,
258
- elapsedMs: thread.elapsedMs,
259
- ...(thread.sessionId && thread.sessionDir ? { sessionId: thread.sessionId, sessionDir: thread.sessionDir } : {}),
260
- ...(worktree ? { worktree } : {}),
261
- childPids: thread.control?.getChildPids?.() ?? [],
262
- ...(thread.lastResult ? { resultSummary: summarizeResult(thread.lastResult) } : {}),
263
- };
264
- }
265
-
266
- /** Rebuild a displayable in-turn result from a persisted summary. The retained
267
- * session holds the real context; this only lets subagent_wait/status show
268
- * what the previous session's generation concluded. */
269
- export function restoredResultFromSummary(record: ThreadRecord): SingleResult | undefined {
270
- const summary = record.resultSummary;
271
- if (!summary) return undefined;
272
- return {
273
- agent: summary.agent,
274
- task: summary.task,
275
- exitCode: summary.exitCode,
276
- messages: summary.output
277
- ? [{
278
- role: "assistant",
279
- content: [{ type: "text", text: summary.output }],
280
- stopReason: "stop",
281
- } as SingleResult["messages"][number]]
282
- : [],
283
- stderr: "",
284
- usage: summary.usage,
285
- isolation: record.isolation,
286
- ...(summary.model ? { model: summary.model } : {}),
287
- ...(summary.thinking ? { thinking: summary.thinking } : {}),
288
- ...(summary.stopReason ? { stopReason: summary.stopReason } : {}),
289
- ...(record.sessionId && record.sessionDir ? { sessionId: record.sessionId, sessionDir: record.sessionDir } : {}),
290
- };
291
- }
292
-
293
- async function discardRecordArtifacts(record: ThreadRecord): Promise<void> {
294
- if (record.sessionDir) {
295
- await rm(record.sessionDir, { recursive: true, force: true }).catch(() => undefined);
296
- }
297
- if (record.worktree && (record.worktree.state === "active" || record.worktree.state === "retained")) {
298
- const worktree = await restoreWorktreeIsolation(record.worktree).catch(() => undefined);
299
- await worktree?.discard().catch(() => undefined);
300
- }
301
- }
302
-
303
- /** Drop records past their retention age along with their artifacts. Runs at
304
- * extension load; the fixed age honors the no-config-knobs policy. */
305
- export async function pruneThreadRecords(
306
- configPath: string,
307
- now = Date.now(),
308
- ): Promise<void> {
309
- const path = getThreadsManifestPath(configPath);
310
- await withFileMutationQueue(path, async () => {
311
- const records = await readThreadRecords(configPath);
312
- if (records.length === 0) return;
313
- let changed = false;
314
- const kept: ThreadRecord[] = [];
315
- for (const record of records) {
316
- if (now - record.updatedAt <= PARKED_RECORD_MAX_AGE_MS) {
317
- kept.push(record);
318
- continue;
319
- }
320
- changed = true;
321
- await discardRecordArtifacts(record);
322
- }
323
- if (changed) await writeManifest(configPath, kept);
324
- });
325
- }
326
-
327
- /** Paths a manifest still references; used by the state-root sweep so
328
- * freshly created-but-unrecorded directories are never touched. */
329
- export function referencedDurablePaths(records: readonly ThreadRecord[]): Set<string> {
330
- const paths = new Set<string>();
331
- for (const record of records) {
332
- if (record.sessionDir) paths.add(record.sessionDir);
333
- if (record.worktree) {
334
- paths.add(record.worktree.tempDir);
335
- if (existsSync(record.worktree.worktreePath)) paths.add(record.worktree.worktreePath);
336
- }
337
- }
338
- return paths;
339
- }
340
-
341
- /** Newest modification time anywhere under root (directories count via their
342
- * own entries); undefined when root cannot be read. */
343
- function newestMtimeMs(root: string, now: number = Date.now()): number | undefined {
344
- let newest: number | undefined;
345
- const stack: string[] = [root];
346
- while (stack.length > 0) {
347
- const dir = stack.pop()!;
348
- let entries: Dirent[];
349
- try {
350
- entries = readdirSync(dir, { withFileTypes: true });
351
- } catch {
352
- continue;
353
- }
354
- for (const entry of entries) {
355
- const path = join(dir, entry.name);
356
- let mtime: number;
357
- try {
358
- mtime = statSync(path).mtimeMs;
359
- } catch {
360
- continue;
361
- }
362
- if (mtime > 0 && mtime <= now && (newest === undefined || mtime > newest)) newest = mtime;
363
- if (entry.isDirectory() && !entry.isSymbolicLink()) stack.push(path);
364
- }
365
- }
366
- return newest;
367
- }
368
-
369
- /** Delete project directories under the ferris-pi-subagents root that have
370
- * been idle past PROJECT_ROOT_MAX_AGE_MS. A directory containing any path the
371
- * threads manifest still references is never touched, so parked work outlives
372
- * the age rule. Returns the removed directory names. */
373
- export async function pruneStaleProjectRoots(configPath: string, options: { now?: number } = {}): Promise<string[]> {
374
- const now = options.now ?? Date.now();
375
- const records = await readThreadRecords(configPath).catch(() => [] as ThreadRecord[]);
376
- const referenced = referencedDurablePaths(records);
377
- const root = join(dirname(configPath), PROJECT_ROOTS_DIR_NAME);
378
- let projects: Dirent[];
379
- try {
380
- projects = readdirSync(root, { withFileTypes: true });
381
- } catch {
382
- return [];
383
- }
384
- const removed: string[] = [];
385
- for (const project of projects) {
386
- if (!project.isDirectory() || project.isSymbolicLink()) continue;
387
- const projectDir = join(root, project.name);
388
- if (containsReferencedPath(projectDir, referenced)) continue;
389
- const newest = newestMtimeMs(projectDir, now);
390
- if (newest === undefined || now - newest <= PROJECT_ROOT_MAX_AGE_MS) continue;
391
- await rm(projectDir, { recursive: true, force: true }).catch(() => undefined);
392
- if (!existsSync(projectDir)) removed.push(project.name);
393
- }
394
- return removed;
395
- }
396
-
397
- function containsReferencedPath(projectDir: string, referenced: ReadonlySet<string>): boolean {
398
- for (const path of referenced) {
399
- if (isPathInside(projectDir, path)) return true;
400
- }
401
- return false;
402
- }
1
+ /**
2
+ * Durable thread state: a manifest next to the config that lets interrupted
3
+ * (parked) sub-agent threads survive pi reloads and restarts, plus the durable
4
+ * state root that keeps their retained sessions and isolated worktrees out of
5
+ * the OS temp directory.
6
+ *
7
+ * Only parked threads are ever recorded: a thread that settles normally drops
8
+ * its record, so the manifest file exists exactly while unfinished work needs
9
+ * it and disappears on its own. Records are small path/state snapshots, never
10
+ * full transcripts; the retained Pi session files and worktrees they point at
11
+ * remain the actual context. Writes are atomic (tmp+rename) and serialized
12
+ * through the same withFileMutationQueue as the recovery manifest.
13
+ */
14
+
15
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
16
+ import { existsSync, type Dirent, readdirSync, statSync } from "node:fs";
17
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
18
+ import { uptime } from "node:os";
19
+ import { dirname, join } from "node:path";
20
+ import type { UsageStats } from "./rpc-run.ts";
21
+ import type { SubagentThread } from "./runtime.ts";
22
+ import { getResultOutput, isFailedResult, getProjectRoot, PROJECT_ROOTS_DIR_NAME, type SingleResult } from "./spawn.ts";
23
+ import {
24
+ isPathInside,
25
+ restoreWorktreeIsolation,
26
+ type IsolationMode,
27
+ normalizeWorktreeSnapshot,
28
+ worktreeSnapshot,
29
+ type WorktreeSnapshot,
30
+ } from "./worktree.ts";
31
+
32
+ export const THREADS_MANIFEST_FILE_NAME = "pi-subagents-threads.json";
33
+ const THREADS_MANIFEST_VERSION = 1;
34
+
35
+ /** Project directories whose newest file has not been touched for this long
36
+ * are deleted wholesale on load, so per-project sessions/worktrees/results
37
+ * can never accumulate forever. Parked threads' manifest references always
38
+ * win over the age rule. */
39
+ export const PROJECT_ROOT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1_000;
40
+
41
+ /** Fixed retention: parked work (which may hold unintegrated changes) stops
42
+ * being resumable after a month. Older manifests may still carry settled
43
+ * records from previous versions; restore discards them on sight. */
44
+ export const PARKED_RECORD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
45
+
46
+ /** Result excerpts are for status display after restore, not full transcripts. */
47
+ const RESULT_SUMMARY_MAX_CHARS = 4_000;
48
+
49
+ /** Boot-id comparisons allow this much slack. Uptime is reported at
50
+ * second granularity and wall-clock adjustments (NTP steps, suspend accounting
51
+ * that differs per platform) move the derived timestamp a little between
52
+ * processes. A reboot moves it by the whole previous uptime, so the distinction
53
+ * that matters here survives a tolerance this wide. */
54
+ const BOOT_ID_TOLERANCE_MS = 60_000;
55
+
56
+ export interface ThreadResultSummary {
57
+ agent: string;
58
+ task: string;
59
+ exitCode: number;
60
+ failed: boolean;
61
+ stopReason?: string;
62
+ usage: UsageStats;
63
+ model?: string;
64
+ thinking?: string;
65
+ output: string;
66
+ }
67
+
68
+ export interface ThreadRecord {
69
+ runId: number;
70
+ createdAt: number;
71
+ updatedAt: number;
72
+ generation: number;
73
+ agentName: string;
74
+ task: string;
75
+ cwd: string;
76
+ executionCwd: string;
77
+ thinkingLevel?: string;
78
+ isolation: IsolationMode;
79
+ /** Persisted only when the dispatch opted out of the automatic gate, so a
80
+ * restored resume never surprises the caller with a full review. */
81
+ review?: "none";
82
+ state: "parked" | "completed" | "failed";
83
+ elapsedMs: number;
84
+ sessionId?: string;
85
+ sessionDir?: string;
86
+ worktree?: WorktreeSnapshot;
87
+ childPids: number[];
88
+ /** Boot this record's `childPids` were observed in; see `isCurrentBoot`. */
89
+ bootId?: number;
90
+ resultSummary?: ThreadResultSummary;
91
+ }
92
+
93
+ /** Approximate timestamp of the machine's current boot. */
94
+ export function currentBootId(now = Date.now()): number {
95
+ return Math.round(now - uptime() * 1_000);
96
+ }
97
+
98
+ /** Whether a record's `childPids` can still name processes of this boot. Pids
99
+ * are only unique within a boot: after a restart the same number belongs to
100
+ * whatever claimed it, so restore must not signal them. Records written before
101
+ * this field existed carry no boot id and count as unverifiable — leaving a
102
+ * stray child alive costs a resumable session nothing, while killing an
103
+ * unrelated process tree is not recoverable. */
104
+ export function isCurrentBoot(record: ThreadRecord, now = Date.now()): boolean {
105
+ if (record.bootId === undefined) return false;
106
+ return Math.abs(record.bootId - currentBootId(now)) <= BOOT_ID_TOLERANCE_MS;
107
+ }
108
+
109
+ interface ThreadsManifest {
110
+ version: number;
111
+ records: ThreadRecord[];
112
+ }
113
+
114
+ export function getThreadsManifestPath(configPath: string): string {
115
+ return join(dirname(configPath), THREADS_MANIFEST_FILE_NAME);
116
+ }
117
+
118
+ function normalizeUsage(value: unknown): UsageStats {
119
+ const raw = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
120
+ const num = (key: string): number => (typeof raw[key] === "number" && Number.isFinite(raw[key]) ? raw[key] : 0);
121
+ return {
122
+ input: num("input"),
123
+ output: num("output"),
124
+ cacheRead: num("cacheRead"),
125
+ cacheWrite: num("cacheWrite"),
126
+ cost: num("cost"),
127
+ contextTokens: num("contextTokens"),
128
+ turns: num("turns"),
129
+ };
130
+ }
131
+
132
+ function normalizeResultSummary(value: unknown): ThreadResultSummary | undefined {
133
+ if (!value || typeof value !== "object") return undefined;
134
+ const raw = value as Record<string, unknown>;
135
+ if (typeof raw.agent !== "string" || !raw.agent) return undefined;
136
+ if (typeof raw.output !== "string") return undefined;
137
+ return {
138
+ agent: raw.agent,
139
+ task: typeof raw.task === "string" ? raw.task : raw.agent,
140
+ exitCode: typeof raw.exitCode === "number" ? raw.exitCode : 0,
141
+ failed: raw.failed === true,
142
+ ...(typeof raw.stopReason === "string" && raw.stopReason ? { stopReason: raw.stopReason } : {}),
143
+ usage: normalizeUsage(raw.usage),
144
+ ...(typeof raw.model === "string" && raw.model ? { model: raw.model } : {}),
145
+ ...(typeof raw.thinking === "string" && raw.thinking ? { thinking: raw.thinking } : {}),
146
+ output: raw.output,
147
+ };
148
+ }
149
+
150
+ function normalizeRecord(value: unknown): ThreadRecord | undefined {
151
+ if (!value || typeof value !== "object") return undefined;
152
+ const raw = value as Record<string, unknown>;
153
+ if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
154
+ if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
155
+ if (typeof raw.updatedAt !== "number" || !Number.isFinite(raw.updatedAt)) return undefined;
156
+ if (typeof raw.agentName !== "string" || !raw.agentName) return undefined;
157
+ if (typeof raw.task !== "string" || !raw.task) return undefined;
158
+ if (typeof raw.cwd !== "string" || !raw.cwd) return undefined;
159
+ if (raw.isolation !== "shared" && raw.isolation !== "worktree") return undefined;
160
+ if (raw.state !== "parked" && raw.state !== "completed" && raw.state !== "failed") return undefined;
161
+ const worktree = raw.worktree === undefined ? undefined : normalizeWorktreeSnapshot(raw.worktree);
162
+ if (worktree === null) return undefined;
163
+ return {
164
+ runId: raw.runId,
165
+ createdAt: raw.createdAt,
166
+ updatedAt: raw.updatedAt,
167
+ generation: typeof raw.generation === "number" && Number.isInteger(raw.generation) && raw.generation >= 0 ? raw.generation : 0,
168
+ agentName: raw.agentName,
169
+ task: raw.task,
170
+ cwd: raw.cwd,
171
+ executionCwd: typeof raw.executionCwd === "string" && raw.executionCwd ? raw.executionCwd : raw.cwd,
172
+ ...(typeof raw.thinkingLevel === "string" && raw.thinkingLevel ? { thinkingLevel: raw.thinkingLevel } : {}),
173
+ isolation: raw.isolation,
174
+ ...(raw.review === "none" ? { review: "none" as const } : {}),
175
+ state: raw.state,
176
+ elapsedMs: typeof raw.elapsedMs === "number" && Number.isFinite(raw.elapsedMs) ? Math.max(0, raw.elapsedMs) : 0,
177
+ ...(typeof raw.sessionId === "string" && raw.sessionId ? { sessionId: raw.sessionId } : {}),
178
+ ...(typeof raw.sessionDir === "string" && raw.sessionDir ? { sessionDir: raw.sessionDir } : {}),
179
+ ...(worktree ? { worktree } : {}),
180
+ childPids: Array.isArray(raw.childPids)
181
+ ? raw.childPids.filter((pid): pid is number => typeof pid === "number" && Number.isInteger(pid) && pid > 0)
182
+ : [],
183
+ ...(typeof raw.bootId === "number" && Number.isFinite(raw.bootId) ? { bootId: raw.bootId } : {}),
184
+ ...(raw.resultSummary === undefined ? {} : { resultSummary: normalizeResultSummary(raw.resultSummary) }),
185
+ };
186
+ }
187
+
188
+ export async function readThreadRecords(configPath: string): Promise<ThreadRecord[]> {
189
+ try {
190
+ const parsed = JSON.parse(await readFile(getThreadsManifestPath(configPath), "utf8")) as {
191
+ records?: unknown;
192
+ };
193
+ if (!Array.isArray(parsed.records)) return [];
194
+ return parsed.records.flatMap((record) => {
195
+ const normalized = normalizeRecord(record);
196
+ return normalized ? [normalized] : [];
197
+ });
198
+ } catch {
199
+ return [];
200
+ }
201
+ }
202
+
203
+ async function writeManifest(configPath: string, records: readonly ThreadRecord[]): Promise<void> {
204
+ const path = getThreadsManifestPath(configPath);
205
+ if (records.length === 0) {
206
+ await rm(path, { force: true });
207
+ return;
208
+ }
209
+ await mkdir(dirname(path), { recursive: true });
210
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
211
+ try {
212
+ const manifest: ThreadsManifest = {
213
+ version: THREADS_MANIFEST_VERSION,
214
+ records: [...records],
215
+ };
216
+ await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
217
+ await rename(temporaryPath, path);
218
+ } finally {
219
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
220
+ }
221
+ }
222
+
223
+ export async function upsertThreadRecord(configPath: string, record: ThreadRecord): Promise<void> {
224
+ const path = getThreadsManifestPath(configPath);
225
+ await withFileMutationQueue(path, async () => {
226
+ const records = await readThreadRecords(configPath);
227
+ const index = records.findIndex((candidate) => candidate.runId === record.runId);
228
+ const merged: ThreadRecord = index === -1
229
+ ? record
230
+ : { ...record, createdAt: records[index]!.createdAt };
231
+ if (index === -1) records.push(merged);
232
+ else records[index] = merged;
233
+ await writeManifest(configPath, records);
234
+ });
235
+ }
236
+
237
+ export async function removeThreadRecord(configPath: string, runId: number): Promise<void> {
238
+ const path = getThreadsManifestPath(configPath);
239
+ await withFileMutationQueue(path, async () => {
240
+ const records = await readThreadRecords(configPath);
241
+ const next = records.filter((record) => record.runId !== runId);
242
+ if (next.length === records.length) return;
243
+ await writeManifest(configPath, next);
244
+ });
245
+ }
246
+
247
+ function truncateSummary(text: string): string {
248
+ if (text.length <= RESULT_SUMMARY_MAX_CHARS) return text;
249
+ return `${text.slice(0, RESULT_SUMMARY_MAX_CHARS - 1)}…`;
250
+ }
251
+
252
+ function summarizeResult(result: SingleResult): ThreadResultSummary | undefined {
253
+ if (!result) return undefined;
254
+ return {
255
+ agent: result.agent,
256
+ task: result.task,
257
+ exitCode: result.exitCode,
258
+ failed: isFailedResult(result),
259
+ ...(result.stopReason ? { stopReason: result.stopReason } : {}),
260
+ usage: result.usage,
261
+ ...(result.model ? { model: result.model } : {}),
262
+ ...(result.thinking ? { thinking: result.thinking } : {}),
263
+ output: truncateSummary(getResultOutput(result)),
264
+ };
265
+ }
266
+
267
+ /** Project a live thread into its durable record. Only handles whose
268
+ * filesystem is still meaningful are persisted; finalized-and-removed
269
+ * worktrees keep just their checkpoint commit for continuation resumes. */
270
+ export function threadRecordFromThread(
271
+ thread: SubagentThread,
272
+ state: "parked" | "completed" | "failed",
273
+ previous?: ThreadRecord,
274
+ now = Date.now(),
275
+ ): ThreadRecord {
276
+ const worktree = thread.worktree ? worktreeSnapshot(thread.worktree) : undefined;
277
+ return {
278
+ runId: thread.id,
279
+ createdAt: previous?.createdAt ?? now,
280
+ updatedAt: now,
281
+ generation: thread.generation,
282
+ agentName: thread.agentName,
283
+ task: thread.task,
284
+ cwd: thread.cwd,
285
+ executionCwd: thread.executionCwd,
286
+ ...(thread.thinkingLevel ? { thinkingLevel: thread.thinkingLevel } : {}),
287
+ isolation: thread.isolation,
288
+ ...(thread.review === "none" ? { review: "none" as const } : {}),
289
+ state,
290
+ elapsedMs: thread.elapsedMs,
291
+ ...(thread.sessionId && thread.sessionDir ? { sessionId: thread.sessionId, sessionDir: thread.sessionDir } : {}),
292
+ ...(worktree ? { worktree } : {}),
293
+ childPids: thread.control?.getChildPids?.() ?? [],
294
+ bootId: currentBootId(now),
295
+ ...(thread.lastResult ? { resultSummary: summarizeResult(thread.lastResult) } : {}),
296
+ };
297
+ }
298
+
299
+ /** Rebuild a displayable in-turn result from a persisted summary. The retained
300
+ * session holds the real context; this only lets a restored thread report
301
+ * what the previous session's generation concluded. */
302
+ export function restoredResultFromSummary(record: ThreadRecord): SingleResult | undefined {
303
+ const summary = record.resultSummary;
304
+ if (!summary) return undefined;
305
+ return {
306
+ agent: summary.agent,
307
+ task: summary.task,
308
+ exitCode: summary.exitCode,
309
+ messages: summary.output
310
+ ? [{
311
+ role: "assistant",
312
+ content: [{ type: "text", text: summary.output }],
313
+ stopReason: "stop",
314
+ } as SingleResult["messages"][number]]
315
+ : [],
316
+ stderr: "",
317
+ usage: summary.usage,
318
+ isolation: record.isolation,
319
+ ...(summary.model ? { model: summary.model } : {}),
320
+ ...(summary.thinking ? { thinking: summary.thinking } : {}),
321
+ ...(summary.stopReason ? { stopReason: summary.stopReason } : {}),
322
+ ...(record.sessionId && record.sessionDir ? { sessionId: record.sessionId, sessionDir: record.sessionDir } : {}),
323
+ };
324
+ }
325
+
326
+ async function discardRecordArtifacts(record: ThreadRecord): Promise<void> {
327
+ if (record.sessionDir) {
328
+ await rm(record.sessionDir, { recursive: true, force: true }).catch(() => undefined);
329
+ }
330
+ if (record.worktree && (record.worktree.state === "active" || record.worktree.state === "retained")) {
331
+ const worktree = await restoreWorktreeIsolation(record.worktree).catch(() => undefined);
332
+ await worktree?.discard().catch(() => undefined);
333
+ }
334
+ }
335
+
336
+ /** Drop records past their retention age along with their artifacts. Runs at
337
+ * extension load; the fixed age honors the no-config-knobs policy. */
338
+ export async function pruneThreadRecords(
339
+ configPath: string,
340
+ now = Date.now(),
341
+ ): Promise<void> {
342
+ const path = getThreadsManifestPath(configPath);
343
+ await withFileMutationQueue(path, async () => {
344
+ const records = await readThreadRecords(configPath);
345
+ if (records.length === 0) return;
346
+ let changed = false;
347
+ const kept: ThreadRecord[] = [];
348
+ for (const record of records) {
349
+ if (now - record.updatedAt <= PARKED_RECORD_MAX_AGE_MS) {
350
+ kept.push(record);
351
+ continue;
352
+ }
353
+ changed = true;
354
+ await discardRecordArtifacts(record);
355
+ }
356
+ if (changed) await writeManifest(configPath, kept);
357
+ });
358
+ }
359
+
360
+ /** Paths a manifest still references; used by the state-root sweep so
361
+ * freshly created-but-unrecorded directories are never touched. */
362
+ export function referencedDurablePaths(records: readonly ThreadRecord[]): Set<string> {
363
+ const paths = new Set<string>();
364
+ for (const record of records) {
365
+ if (record.sessionDir) paths.add(record.sessionDir);
366
+ if (record.worktree) {
367
+ paths.add(record.worktree.tempDir);
368
+ if (existsSync(record.worktree.worktreePath)) paths.add(record.worktree.worktreePath);
369
+ }
370
+ }
371
+ return paths;
372
+ }
373
+
374
+ /** Whether everything under root was last modified before `cutoffMs` — the only
375
+ * question the age rule asks. Returns false the moment one fresh entry turns up,
376
+ * so a project still in use costs a few stats instead of a full walk of its
377
+ * retained sessions and worktree checkouts on every load. A root with no usable
378
+ * timestamp at all also reports false: a directory nothing could be read from is
379
+ * never the one to delete. */
380
+ function isIdleSince(root: string, cutoffMs: number, now: number): boolean {
381
+ let sawTimestamp = false;
382
+ const stack: string[] = [root];
383
+ while (stack.length > 0) {
384
+ const dir = stack.pop()!;
385
+ let entries: Dirent[];
386
+ try {
387
+ entries = readdirSync(dir, { withFileTypes: true });
388
+ } catch {
389
+ continue;
390
+ }
391
+ for (const entry of entries) {
392
+ const path = join(dir, entry.name);
393
+ let mtime: number;
394
+ try {
395
+ mtime = statSync(path).mtimeMs;
396
+ } catch {
397
+ continue;
398
+ }
399
+ // A timestamp in the future carries no usable age: it neither keeps a
400
+ // root alive nor lets one age out.
401
+ if (mtime > 0 && mtime <= now) {
402
+ if (mtime >= cutoffMs) return false;
403
+ sawTimestamp = true;
404
+ }
405
+ if (entry.isDirectory() && !entry.isSymbolicLink()) stack.push(path);
406
+ }
407
+ }
408
+ return sawTimestamp;
409
+ }
410
+
411
+ /** Delete project directories under the ferris-pi-subagents root that have
412
+ * been idle past PROJECT_ROOT_MAX_AGE_MS. A directory containing any path the
413
+ * threads manifest still references is never touched, so parked work outlives
414
+ * the age rule. Returns the removed directory names. */
415
+ export async function pruneStaleProjectRoots(configPath: string, options: { now?: number } = {}): Promise<string[]> {
416
+ const now = options.now ?? Date.now();
417
+ const records = await readThreadRecords(configPath).catch(() => [] as ThreadRecord[]);
418
+ const referenced = referencedDurablePaths(records);
419
+ const root = join(dirname(configPath), PROJECT_ROOTS_DIR_NAME);
420
+ let projects: Dirent[];
421
+ try {
422
+ projects = readdirSync(root, { withFileTypes: true });
423
+ } catch {
424
+ return [];
425
+ }
426
+ const removed: string[] = [];
427
+ for (const project of projects) {
428
+ if (!project.isDirectory() || project.isSymbolicLink()) continue;
429
+ const projectDir = join(root, project.name);
430
+ if (containsReferencedPath(projectDir, referenced)) continue;
431
+ if (!isIdleSince(projectDir, now - PROJECT_ROOT_MAX_AGE_MS, now)) continue;
432
+ await rm(projectDir, { recursive: true, force: true }).catch(() => undefined);
433
+ if (!existsSync(projectDir)) removed.push(project.name);
434
+ }
435
+ return removed;
436
+ }
437
+
438
+ function containsReferencedPath(projectDir: string, referenced: ReadonlySet<string>): boolean {
439
+ for (const path of referenced) {
440
+ if (isPathInside(projectDir, path)) return true;
441
+ }
442
+ return false;
443
+ }