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