@ferris1225/pi-subagents 4.2.5 → 4.2.8

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