@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/README.md +375 -346
- package/agents/executor.md +53 -53
- package/package.json +3 -5
- package/src/agents.ts +237 -237
- package/src/announcements.ts +81 -78
- package/src/dispatch.ts +615 -541
- package/src/durable.ts +517 -510
- package/src/format.ts +181 -165
- package/src/index.ts +102 -100
- package/src/monitor.ts +1 -1
- package/src/prompt.ts +69 -69
- package/src/rpc-run.ts +987 -993
- package/src/runtime.ts +348 -312
- package/src/status.ts +66 -0
- package/src/thread-lifecycle.ts +1341 -1324
- package/src/tools.ts +384 -384
- package/src/widget.ts +268 -266
- package/src/worktree.ts +974 -943
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (typeof
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
...(typeof raw.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (typeof
|
|
161
|
-
|
|
162
|
-
if (typeof raw.
|
|
163
|
-
if (typeof raw.
|
|
164
|
-
if (typeof raw.
|
|
165
|
-
if (raw.
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
if (
|
|
169
|
-
return
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
...(typeof raw.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
...(typeof raw.
|
|
189
|
-
...(raw.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return [];
|
|
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
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
for (const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
if (
|
|
508
|
-
}
|
|
509
|
-
return
|
|
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
|
+
}
|