@ferris1225/pi-subagents 4.2.4 → 4.2.5
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 +1 -1
- package/package.json +55 -55
- package/src/durable.ts +105 -33
- package/src/rpc-run.ts +993 -993
- package/src/runtime.ts +3 -3
- package/src/thread-lifecycle.ts +6 -2
- package/src/tools.ts +1 -1
package/README.md
CHANGED
|
@@ -315,8 +315,8 @@ that removes it, so this directory does not grow without bound:
|
|
|
315
315
|
| Path | Holds | Removed |
|
|
316
316
|
| ------------------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------- |
|
|
317
317
|
| `pi-subagents.json` | Your configuration | Never — it is yours |
|
|
318
|
-
| `pi-subagents-threads.json` | One record per interrupted thread | When the thread settles, or after 30 days |
|
|
319
318
|
| `pi-subagents-recovery.json` | Worktree integration and cleanup failures | When the retained patch or worktree it points at is gone |
|
|
319
|
+
| `ferris-pi-subagents/<project>/pi-subagents-threads.json` | One record per interrupted thread | When the thread settles, or after 30 days |
|
|
320
320
|
| `ferris-pi-subagents/<project>/sessions/` | Retained child sessions that a resume continues from | When the pi session that produced it ends, or its owner is gone |
|
|
321
321
|
| `ferris-pi-subagents/<project>/worktrees/` | Isolated checkouts for parallel writers | On integration, or when its owning process is gone |
|
|
322
322
|
| `ferris-pi-subagents/<project>/results/` | Full text of truncated results | After 7 days, or beyond 50 per project |
|
package/package.json
CHANGED
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "4.2.
|
|
4
|
-
"description": "A managed sub-agent team for pi: specialized roles, pre-commit documentation sync, retained threads, auto-fix chains, model fallback, and Git worktree isolation.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"publishConfig": {
|
|
8
|
-
"access": "public"
|
|
9
|
-
},
|
|
10
|
-
"keywords": [
|
|
11
|
-
"pi-package",
|
|
12
|
-
"pi-extension",
|
|
13
|
-
"subagent",
|
|
14
|
-
"sub-agent",
|
|
15
|
-
"delegation",
|
|
16
|
-
"code-cleanup",
|
|
17
|
-
"dead-code"
|
|
18
|
-
],
|
|
19
|
-
"files": [
|
|
20
|
-
"src",
|
|
21
|
-
"agents",
|
|
22
|
-
"README.md",
|
|
23
|
-
"LICENSE"
|
|
24
|
-
],
|
|
25
|
-
"pi": {
|
|
26
|
-
"extensions": [
|
|
27
|
-
"./src/index.ts"
|
|
28
|
-
]
|
|
29
|
-
},
|
|
30
|
-
"scripts": {
|
|
31
|
-
"check": "tsc --noEmit",
|
|
32
|
-
"test": "vitest run tests",
|
|
33
|
-
"prepack": "npm run check && npm test"
|
|
34
|
-
},
|
|
35
|
-
"peerDependencies": {
|
|
36
|
-
"@earendil-works/pi-agent-core": ">=0.84.4",
|
|
37
|
-
"@earendil-works/pi-ai": ">=0.84.4",
|
|
38
|
-
"@earendil-works/pi-coding-agent": ">=0.84.4",
|
|
39
|
-
"@earendil-works/pi-tui": ">=0.84.4",
|
|
40
|
-
"typebox": "*"
|
|
41
|
-
},
|
|
42
|
-
"devDependencies": {
|
|
43
|
-
"@earendil-works/pi-agent-core": "^0.84.4",
|
|
44
|
-
"@earendil-works/pi-ai": "^0.84.4",
|
|
45
|
-
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
46
|
-
"@earendil-works/pi-tui": "^0.84.4",
|
|
47
|
-
"@types/node": "^22.10.0",
|
|
48
|
-
"typebox": "^1.3.9",
|
|
49
|
-
"typescript": "^5.9.0",
|
|
50
|
-
"vitest": "^4.1.0"
|
|
51
|
-
},
|
|
52
|
-
"engines": {
|
|
53
|
-
"node": ">=22.19.0"
|
|
54
|
-
}
|
|
55
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@ferris1225/pi-subagents",
|
|
3
|
+
"version": "4.2.5",
|
|
4
|
+
"description": "A managed sub-agent team for pi: specialized roles, pre-commit documentation sync, retained threads, auto-fix chains, model fallback, and Git worktree isolation.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"pi-package",
|
|
12
|
+
"pi-extension",
|
|
13
|
+
"subagent",
|
|
14
|
+
"sub-agent",
|
|
15
|
+
"delegation",
|
|
16
|
+
"code-cleanup",
|
|
17
|
+
"dead-code"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"agents",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./src/index.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"check": "tsc --noEmit",
|
|
32
|
+
"test": "vitest run tests",
|
|
33
|
+
"prepack": "npm run check && npm test"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@earendil-works/pi-agent-core": ">=0.84.4",
|
|
37
|
+
"@earendil-works/pi-ai": ">=0.84.4",
|
|
38
|
+
"@earendil-works/pi-coding-agent": ">=0.84.4",
|
|
39
|
+
"@earendil-works/pi-tui": ">=0.84.4",
|
|
40
|
+
"typebox": "*"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@earendil-works/pi-agent-core": "^0.84.4",
|
|
44
|
+
"@earendil-works/pi-ai": "^0.84.4",
|
|
45
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
46
|
+
"@earendil-works/pi-tui": "^0.84.4",
|
|
47
|
+
"@types/node": "^22.10.0",
|
|
48
|
+
"typebox": "^1.3.9",
|
|
49
|
+
"typescript": "^5.9.0",
|
|
50
|
+
"vitest": "^4.1.0"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=22.19.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/durable.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Durable thread state:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* the OS temp
|
|
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.
|
|
6
7
|
*
|
|
7
8
|
* Only parked threads are ever recorded: a thread that settles normally drops
|
|
8
|
-
* its record, so
|
|
9
|
-
*
|
|
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
|
|
10
11
|
* full transcripts; the retained Pi session files and worktrees they point at
|
|
11
12
|
* remain the actual context. Writes are atomic (tmp+rename) and serialized
|
|
12
13
|
* through the same withFileMutationQueue as the recovery manifest.
|
|
@@ -108,7 +109,15 @@ interface ThreadsManifest {
|
|
|
108
109
|
records: ThreadRecord[];
|
|
109
110
|
}
|
|
110
111
|
|
|
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 {
|
|
112
121
|
return join(dirname(configPath), THREADS_MANIFEST_FILE_NAME);
|
|
113
122
|
}
|
|
114
123
|
|
|
@@ -181,9 +190,9 @@ function normalizeRecord(value: unknown): ThreadRecord | undefined {
|
|
|
181
190
|
};
|
|
182
191
|
}
|
|
183
192
|
|
|
184
|
-
|
|
193
|
+
async function readManifestRecords(path: string): Promise<ThreadRecord[]> {
|
|
185
194
|
try {
|
|
186
|
-
const parsed = JSON.parse(await readFile(
|
|
195
|
+
const parsed = JSON.parse(await readFile(path, "utf8")) as {
|
|
187
196
|
records?: unknown;
|
|
188
197
|
};
|
|
189
198
|
if (!Array.isArray(parsed.records)) return [];
|
|
@@ -196,8 +205,28 @@ export async function readThreadRecords(configPath: string): Promise<ThreadRecor
|
|
|
196
205
|
}
|
|
197
206
|
}
|
|
198
207
|
|
|
199
|
-
|
|
200
|
-
|
|
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> {
|
|
201
230
|
if (records.length === 0) {
|
|
202
231
|
await rm(path, { force: true });
|
|
203
232
|
return;
|
|
@@ -217,26 +246,26 @@ async function writeManifest(configPath: string, records: readonly ThreadRecord[
|
|
|
217
246
|
}
|
|
218
247
|
|
|
219
248
|
export async function upsertThreadRecord(configPath: string, record: ThreadRecord): Promise<void> {
|
|
220
|
-
const path = getThreadsManifestPath(configPath);
|
|
249
|
+
const path = getThreadsManifestPath(configPath, record.cwd);
|
|
221
250
|
await withFileMutationQueue(path, async () => {
|
|
222
|
-
const records = await
|
|
251
|
+
const records = await readManifestRecords(path);
|
|
223
252
|
const index = records.findIndex((candidate) => candidate.runId === record.runId);
|
|
224
253
|
const merged: ThreadRecord = index === -1
|
|
225
254
|
? record
|
|
226
255
|
: { ...record, createdAt: records[index]!.createdAt };
|
|
227
256
|
if (index === -1) records.push(merged);
|
|
228
257
|
else records[index] = merged;
|
|
229
|
-
await writeManifest(
|
|
258
|
+
await writeManifest(path, records);
|
|
230
259
|
});
|
|
231
260
|
}
|
|
232
261
|
|
|
233
|
-
export async function removeThreadRecord(configPath: string, runId: number): Promise<void> {
|
|
234
|
-
const path = getThreadsManifestPath(configPath);
|
|
262
|
+
export async function removeThreadRecord(configPath: string, runId: number, cwd: string): Promise<void> {
|
|
263
|
+
const path = getThreadsManifestPath(configPath, cwd);
|
|
235
264
|
await withFileMutationQueue(path, async () => {
|
|
236
|
-
const records = await
|
|
265
|
+
const records = await readManifestRecords(path);
|
|
237
266
|
const next = records.filter((record) => record.runId !== runId);
|
|
238
267
|
if (next.length === records.length) return;
|
|
239
|
-
await writeManifest(
|
|
268
|
+
await writeManifest(path, next);
|
|
240
269
|
});
|
|
241
270
|
}
|
|
242
271
|
|
|
@@ -334,22 +363,65 @@ export async function pruneThreadRecords(
|
|
|
334
363
|
configPath: string,
|
|
335
364
|
now = Date.now(),
|
|
336
365
|
): Promise<void> {
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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);
|
|
347
380
|
}
|
|
348
|
-
changed
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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);
|
|
353
425
|
}
|
|
354
426
|
|
|
355
427
|
/** Paths a manifest still references; used by the state-root sweep so
|