@ferris1225/pi-subagents 4.2.2 → 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
@@ -127,7 +127,9 @@ main agent inspects the actual changes before calling anything done.
127
127
  as a lane wait, not as slot queueing, and its process slot is already released.
128
128
  - Setup and integration failures keep the useful patch and worktree, and record
129
129
  where they are in `~/.pi/agent/pi-subagents-recovery.json`. Every later session
130
- start repeats that notice until you remove the artifacts.
130
+ start repeats that notice until you remove the artifacts. When the changes had
131
+ already been applied and only the cleanup failed, the next session start
132
+ removes the retained copy itself and clears the notice.
131
133
 
132
134
  ## Threads: resume, stop
133
135
 
@@ -313,8 +315,8 @@ that removes it, so this directory does not grow without bound:
313
315
  | Path | Holds | Removed |
314
316
  | ------------------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------- |
315
317
  | `pi-subagents.json` | Your configuration | Never — it is yours |
316
- | `pi-subagents-threads.json` | One record per interrupted thread | When the thread settles, or after 30 days |
317
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 |
318
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 |
319
321
  | `ferris-pi-subagents/<project>/worktrees/` | Isolated checkouts for parallel writers | On integration, or when its owning process is gone |
320
322
  | `ferris-pi-subagents/<project>/results/` | Full text of truncated results | After 7 days, or beyond 50 per project |
@@ -26,15 +26,12 @@ Thoroughness scales with the task (default medium): quick = targeted lookups in
26
26
 
27
27
  ## Final response
28
28
 
29
- Return only actionable retrieval results:
29
+ Return only retrieval results, one bare bullet per finding — a single line: path, the fact, nothing else:
30
30
 
31
31
  ```text
32
- ## Findings
33
- - `path/to/file.ts:10-50` — fact the caller needs
34
- ## Start Here
35
- - `path/to/file.ts` — first symbol/section to verify and why
36
- ## Gaps
37
- - unresolved uncertainty (omit this section when none)
32
+ - `path/to/file.ts:10-50` — the fact
33
+ Start here: `path/to/file.ts` — entry symbol and why (only when the caller could not guess it)
34
+ Gaps: unresolved uncertainty (only when real)
38
35
  ```
39
36
 
40
- Do not repeat the task brief, inventory every file opened, paste nonessential code, or narrate search/tool chronology; report only unresolved blockers. Terse and factual: exact paths and line numbers, compressed evidence. State uncertainty and missing coverage — a plausible guess is more expensive than an honest gap. Keep the final response comfortably below the 40-line delivery cap unless the requested findings genuinely require more.
37
+ No preamble or closing summary. Do not repeat the task brief, inventory every file opened, paste nonessential code, or narrate the search; every line must carry a path with a fact or name a gap — delete anything else. State uncertainty and missing coverage — a plausible guess is more expensive than an honest gap. Stay under 15 lines by default; go longer only when the brief genuinely demands a wide survey — the 40-line delivery cap truncates your tail (usually the Gaps) and the caller pays for every line.
package/package.json CHANGED
@@ -1,55 +1,55 @@
1
- {
2
- "name": "@ferris1225/pi-subagents",
3
- "version": "4.2.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: 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
package/src/recovery.ts CHANGED
@@ -5,7 +5,7 @@ import { existsSync } from "node:fs";
5
5
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
6
  import { dirname, join } from "node:path";
7
7
  import { stripVTControlCharacters } from "node:util";
8
- import type { WorktreeFinalization } from "./worktree.ts";
8
+ import { removeWorktreeGroup, worktreeGroupDir, type WorktreeFinalization } from "./worktree.ts";
9
9
 
10
10
  export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
11
11
  const RECOVERY_MANIFEST_VERSION = 1;
@@ -14,6 +14,8 @@ export interface RecoveryRecord {
14
14
  runId: number;
15
15
  createdAt: number;
16
16
  integrated: boolean;
17
+ /** Repository a cleanup retry can prune stale worktree metadata against. */
18
+ originalRoot?: string;
17
19
  worktreePath?: string;
18
20
  patchPath?: string;
19
21
  error?: string;
@@ -37,6 +39,7 @@ function normalizeRecord(value: unknown): RecoveryRecord | undefined {
37
39
  runId: raw.runId,
38
40
  createdAt: raw.createdAt,
39
41
  integrated: raw.integrated === true,
42
+ ...(typeof raw.originalRoot === "string" && raw.originalRoot ? { originalRoot: raw.originalRoot } : {}),
40
43
  ...(typeof raw.worktreePath === "string" && raw.worktreePath ? { worktreePath: raw.worktreePath } : {}),
41
44
  ...(typeof raw.patchPath === "string" && raw.patchPath ? { patchPath: raw.patchPath } : {}),
42
45
  ...(typeof raw.error === "string" && raw.error ? { error: raw.error } : {}),
@@ -105,6 +108,7 @@ export function recoveryRecordFromFinalization(
105
108
  runId,
106
109
  createdAt: now,
107
110
  integrated: finalization.integrated,
111
+ ...(finalization.originalRoot ? { originalRoot: finalization.originalRoot } : {}),
108
112
  ...(finalization.worktreePath ? { worktreePath: finalization.worktreePath } : {}),
109
113
  ...(finalization.patchPath ? { patchPath: finalization.patchPath } : {}),
110
114
  ...(finalization.error ? { error: finalization.error } : {}),
@@ -112,7 +116,10 @@ export function recoveryRecordFromFinalization(
112
116
  }
113
117
 
114
118
  /** Show retained recovery paths on every later session start until the user
115
- * removes the artifacts. Stale records are pruned automatically. */
119
+ * removes the artifacts. Records whose changes already landed only need the
120
+ * worktree group deleted — the step whose failure retained them — so each
121
+ * session start retries that removal first and forgets records it completes.
122
+ * Stale records are pruned automatically. */
116
123
  export async function announceRecoveryRecords(
117
124
  configPath: string,
118
125
  ctx: {
@@ -123,6 +130,17 @@ export async function announceRecoveryRecords(
123
130
  if (ctx.hasUI === false) return;
124
131
  const records = await readRecoveryRecords(configPath);
125
132
  if (records.length === 0) return;
133
+ for (const record of records) {
134
+ if (!record.integrated || !record.worktreePath) continue;
135
+ const groupDir = worktreeGroupDir(record.worktreePath);
136
+ if (!groupDir) continue;
137
+ if (!existsSync(record.worktreePath) && !(record.patchPath ? existsSync(record.patchPath) : false)) continue;
138
+ await removeWorktreeGroup({
139
+ originalRoot: record.originalRoot,
140
+ worktreePath: record.worktreePath,
141
+ tempDir: groupDir,
142
+ });
143
+ }
126
144
  const live = records.filter((record) =>
127
145
  (record.worktreePath ? existsSync(record.worktreePath) : false) ||
128
146
  (record.patchPath ? existsSync(record.patchPath) : false),