@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/README.md +384 -337
- package/agents/cleaner.md +50 -45
- package/agents/documenter.md +40 -42
- package/agents/explorer.md +40 -45
- package/agents/reviewer.md +82 -82
- package/agents/synthesizer.md +39 -0
- package/agents/worker.md +43 -45
- package/package.json +55 -55
- package/src/agents.ts +25 -5
- package/src/announcements.ts +78 -75
- package/src/background.ts +11 -0
- package/src/completion.ts +19 -9
- package/src/config.ts +3 -10
- package/src/dispatch.ts +817 -647
- package/src/durable.ts +443 -402
- package/src/format.ts +173 -179
- package/src/index.ts +6 -6
- package/src/models.ts +4 -6
- package/src/monitor.ts +56 -5
- package/src/prompt.ts +14 -21
- package/src/recovery.ts +145 -145
- package/src/rpc-run.ts +993 -993
- package/src/runtime.ts +22 -4
- package/src/session-fork.ts +2 -0
- package/src/setup.ts +23 -43
- package/src/spawn.ts +668 -654
- package/src/temp-hygiene.ts +230 -174
- package/src/thread-lifecycle.ts +1487 -1399
- package/src/tools.ts +384 -712
- package/src/widget.ts +195 -157
- package/src/workflow.ts +24 -8
- package/src/worktree.ts +18 -0
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 {
|
|
19
|
-
import
|
|
20
|
-
import type {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
...(
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
...(
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
*
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
...(
|
|
287
|
-
|
|
288
|
-
...(
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
+
}
|