@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 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",
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: 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.
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 the manifest file exists exactly while unfinished work needs
9
- * it and disappears on its own. Records are small path/state snapshots, never
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
- export function getThreadsManifestPath(configPath: string): string {
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
- export async function readThreadRecords(configPath: string): Promise<ThreadRecord[]> {
193
+ async function readManifestRecords(path: string): Promise<ThreadRecord[]> {
185
194
  try {
186
- const parsed = JSON.parse(await readFile(getThreadsManifestPath(configPath), "utf8")) as {
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
- async function writeManifest(configPath: string, records: readonly ThreadRecord[]): Promise<void> {
200
- const path = getThreadsManifestPath(configPath);
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 readThreadRecords(configPath);
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(configPath, records);
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 readThreadRecords(configPath);
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(configPath, next);
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 path = getThreadsManifestPath(configPath);
338
- await withFileMutationQueue(path, async () => {
339
- const records = await readThreadRecords(configPath);
340
- if (records.length === 0) return;
341
- let changed = false;
342
- const kept: ThreadRecord[] = [];
343
- for (const record of records) {
344
- if (now - record.updatedAt <= PARKED_RECORD_MAX_AGE_MS) {
345
- kept.push(record);
346
- continue;
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 = true;
349
- await discardRecordArtifacts(record);
350
- }
351
- if (changed) await writeManifest(configPath, kept);
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