@ferris1225/pi-subagents 4.1.15 → 4.1.17

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.
@@ -1,61 +1,75 @@
1
- /** Session-start recovery, stale-config migration, and widget installation. */
2
-
3
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { existsSync } from "node:fs";
5
- import { loadConfig, saveConfig } from "./config.ts";
6
- import { availableModelsInScope, filterUnavailableModelOverrides } from "./models.ts";
7
- import { announceRecoveryRecords } from "./recovery.ts";
8
- import type { SubagentRuntime } from "./runtime.ts";
9
- import { pruneResultArtifacts } from "./spawn.ts";
10
- import { installActiveRunsWidget } from "./widget.ts";
11
-
12
- /**
13
- * One-time-per-stale-override migration: keep agent model selections Pi still
14
- * reports as available, drop the rest back to dynamic main-model routing, and
15
- * tell the user what was removed. Saving the cleaned config is what makes it
16
- * one-time — the dropped refs no longer exist to re-trigger the notice.
17
- */
18
- async function migrateUnavailableAgentModels(
19
- ctx: { ui: { notify: (message: string, kind: "info" | "warning" | "error") => void } } & Parameters<typeof availableModelsInScope>[0],
20
- runtime: SubagentRuntime,
21
- ): Promise<void> {
22
- try {
23
- const config = await loadConfig(runtime.configPath);
24
- const overrides = Object.entries(config.agentModels);
25
- if (overrides.length === 0) return;
26
- const { kept, dropped } = filterUnavailableModelOverrides(config.agentModels, availableModelsInScope(ctx));
27
- if (dropped.length === 0) return;
28
- await saveConfig({ ...config, agentModels: kept }, runtime.configPath);
29
- const list = dropped.map(({ agent, ref }) => `${agent}: ${ref}`).join(", ");
30
- ctx.ui.notify(
31
- `pi-subagents: removed stale agent model overrides that are no longer available (${list}). Those agents now follow the current main model; run /subagents-setup to re-pick.`,
32
- "warning",
33
- );
34
- } catch {
35
- /* migration failures are non-fatal */
36
- }
37
- }
38
-
39
- export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime): void {
40
- pi.on("session_start", async (_event, ctx) => {
41
- pruneResultArtifacts();
42
- if (!existsSync(runtime.configPath)) {
43
- ctx.ui.notify(
44
- "pi-subagents: no configuration yet — run /subagents-setup to pick agents, models, and thinking strengths. Defaults (all five agents on the main model) apply until then.",
45
- "info",
46
- );
47
- }
48
- await announceRecoveryRecords(runtime.configPath, ctx);
49
- await migrateUnavailableAgentModels(ctx, runtime);
50
- if (!runtime.restoredNotified && runtime.restoredRunIds.length > 0) {
51
- runtime.restoredNotified = true;
52
- const ids = runtime.restoredRunIds.map((id) => `#${id}`).join(", ");
53
- ctx.ui.notify(
54
- `pi-subagents: restored ${runtime.restoredRunIds.length} interrupted thread${runtime.restoredRunIds.length === 1 ? "" : "s"} from the previous session (${ids}). subagent_status lists them; subagent_control resume continues one.`,
55
- "info",
56
- );
57
- }
58
- if (ctx.mode !== "tui") return;
59
- installActiveRunsWidget(ctx);
60
- });
61
- }
1
+ /** Session-start recovery, stale-config migration, and widget installation. */
2
+
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { existsSync } from "node:fs";
5
+ import { loadConfig, saveConfig } from "./config.ts";
6
+ import { availableModelsInScope, filterUnavailableModelOverrides } from "./models.ts";
7
+ import { announceRecoveryRecords } from "./recovery.ts";
8
+ import type { SubagentRuntime } from "./runtime.ts";
9
+ import { installActiveRunsWidget } from "./widget.ts";
10
+
11
+ /**
12
+ * One-time-per-stale-override migration: keep agent model selections Pi still
13
+ * reports as available, drop the rest back to dynamic main-model routing, and
14
+ * tell the user what was removed. Saving the cleaned config is what makes it
15
+ * one-time the dropped refs no longer exist to re-trigger the notice.
16
+ */
17
+ async function migrateUnavailableAgentModels(
18
+ ctx: { ui: { notify: (message: string, kind: "info" | "warning" | "error") => void } } & Parameters<typeof availableModelsInScope>[0],
19
+ runtime: SubagentRuntime,
20
+ ): Promise<void> {
21
+ try {
22
+ const config = await loadConfig(runtime.configPath);
23
+ const overrides = Object.entries(config.agentModels);
24
+ if (overrides.length === 0) return;
25
+ const { kept, dropped } = filterUnavailableModelOverrides(config.agentModels, availableModelsInScope(ctx));
26
+ if (dropped.length === 0) return;
27
+ await saveConfig({ ...config, agentModels: kept }, runtime.configPath);
28
+ const list = dropped.map(({ agent, ref }) => `${agent}: ${ref}`).join(", ");
29
+ ctx.ui.notify(
30
+ `pi-subagents: removed stale agent model overrides that are no longer available (${list}). Those agents now follow the current main model; run /subagents-setup to re-pick.`,
31
+ "warning",
32
+ );
33
+ } catch {
34
+ /* migration failures are non-fatal */
35
+ }
36
+ }
37
+
38
+ export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime): void {
39
+ pi.on("session_start", async (_event, ctx) => {
40
+ if (!existsSync(runtime.configPath)) {
41
+ ctx.ui.notify(
42
+ "pi-subagents: no configuration yet — run /subagents-setup to pick agents, models, and thinking strengths. Defaults (all five agents on the main model) apply until then.",
43
+ "info",
44
+ );
45
+ }
46
+ await announceRecoveryRecords(runtime.configPath, ctx);
47
+ await migrateUnavailableAgentModels(ctx, runtime);
48
+ if (!runtime.restoredNotified && runtime.restoredRunIds.length > 0) {
49
+ runtime.restoredNotified = true;
50
+ const ids = runtime.restoredRunIds.map((id) => `#${id}`).join(", ");
51
+ ctx.ui.notify(
52
+ `pi-subagents: restored ${runtime.restoredRunIds.length} interrupted thread${runtime.restoredRunIds.length === 1 ? "" : "s"} from the previous session (${ids}). subagent_status lists them; subagent_control resume continues one.`,
53
+ "info",
54
+ );
55
+ }
56
+ if (ctx.mode !== "tui") return;
57
+ installActiveRunsWidget(ctx);
58
+ });
59
+
60
+ // Compaction failures are otherwise silent in long orchestration sessions
61
+ // where subagent results accumulate; aborted (user-cancelled) compactions
62
+ // are deliberate and not worth a notice.
63
+ pi.on("session_compact_failed", async (event, ctx) => {
64
+ if (event.aborted && !event.errorMessage) return;
65
+ const detail = event.errorMessage ? `: ${event.errorMessage}` : "";
66
+ if (event.willRetry) {
67
+ ctx.ui.notify(`pi-subagents: session compaction failed${detail} — retrying automatically.`, "warning");
68
+ return;
69
+ }
70
+ ctx.ui.notify(
71
+ `pi-subagents: session compaction failed${detail}. Long threads may hit context limits soon; run /compact to retry or trim old results.`,
72
+ "error",
73
+ );
74
+ });
75
+ }
package/src/background.ts CHANGED
@@ -9,7 +9,9 @@
9
9
  * the user and the main agent instead of it vanishing into the queue.
10
10
  */
11
11
 
12
- export type BackgroundTask = (signal: AbortSignal) => Promise<void>;
12
+ import { cpus } from "node:os";
13
+
14
+ export type BackgroundTask = (signal: AbortSignal, controller: AbortController) => Promise<void>;
13
15
 
14
16
  interface PendingTask {
15
17
  task: BackgroundTask;
@@ -27,13 +29,14 @@ interface PendingTask {
27
29
  onError?: (error: unknown) => void | Promise<void>;
28
30
  }
29
31
 
30
- /** How many sub-agent processes may run at once. This paces execution only
31
- * it never rejects work, so a wider parallel `subagent` call simply queues.
32
- * Fixed by design: the queue sheds load by waiting, so the knob bought nothing
33
- * worth its maintenance. Only manually dispatched top-level generations hold
34
- * slots; runtime-initiated managed continuations (gate reviews, documentation
35
- * sync) suspend their task's slot so they never starve manual dispatches. */
36
- export const MAX_CONCURRENT_SUBAGENTS = 4;
32
+ /** How many sub-agent processes may run at once, derived from the host instead
33
+ * of being fixed: children wait on model I/O far more than on CPU, so the pool
34
+ * scales with cores while the bounds keep tiny machines usable and huge ones
35
+ * from fanning out into an API-rate-limit wall. Pacing only the queue never
36
+ * rejects work; a wider parallel `subagent` call simply waits for a slot. */
37
+ export function resolveSubagentConcurrency(cpuCount: number = cpus().length): number {
38
+ return Math.min(16, Math.max(4, Math.floor(cpuCount / 2)));
39
+ }
37
40
 
38
41
  export class BackgroundTaskQueue {
39
42
  private concurrency: number;
@@ -77,9 +80,22 @@ export class BackgroundTaskQueue {
77
80
  return this.completions.get(controller) ?? Promise.resolve();
78
81
  }
79
82
 
83
+ /** Slot count, exposed so dispatch/status output can state the real pacing
84
+ * limit instead of leaving queued work looking like an unexplained cap. */
85
+ get capacity(): number {
86
+ return this.concurrency;
87
+ }
88
+
80
89
  /** Stop counting a running task toward the concurrency limit. Its body keeps
81
90
  * running under the same abort signal; completion still releases everything
82
- * waitForTask/waitForIdle promise. Frees a slot for queued work immediately. */
91
+ * waitForTask/waitForIdle promise. Frees a slot for queued work immediately.
92
+ *
93
+ * Used by tasks whose execution is serialized elsewhere anyway (managed
94
+ * workflow continuations, shared-checkout writers waiting on the repository
95
+ * lane): letting such a task also hold a global slot would let waiters
96
+ * starve independent work that could start right away. The controller is
97
+ * handed to the task body directly, so a task can always suspend itself
98
+ * without racing the enqueue() caller's assignment. */
83
99
  suspend(controller: AbortController | undefined): void {
84
100
  if (!controller || this.stopped) return;
85
101
  if (!this.active.delete(controller)) return;
@@ -149,7 +165,7 @@ export class BackgroundTaskQueue {
149
165
  }
150
166
 
151
167
  this.active.add(entry.controller);
152
- void entry.task(entry.controller.signal)
168
+ void entry.task(entry.controller.signal, entry.controller)
153
169
  .catch(async (error: unknown) => {
154
170
  // Cancellation is not a failure: aborted work (e.g. session
155
171
  // shutdown) must never be reported as an exception.
package/src/completion.ts CHANGED
@@ -139,6 +139,9 @@ export interface ActiveRunFoot {
139
139
  agent: string;
140
140
  /** Optional content label (task-derived) shown next to the agent name. */
141
141
  label?: string;
142
+ /** True when the run is waiting for a free process slot rather than
143
+ * executing; stated so pacing is never mistaken for a stall. */
144
+ queued?: boolean;
142
145
  }
143
146
 
144
147
  /**
@@ -153,7 +156,10 @@ export function formatActiveRunsFooter(runs: readonly ActiveRunFoot[], maxListed
153
156
  if (runs.length === 0) return "";
154
157
  const listed = runs.slice(0, maxListed);
155
158
  const items = listed
156
- .map((run) => `#${run.id} ${run.agent}${run.label ? `·${run.label}` : ""}`)
159
+ .map((run) => {
160
+ const tagged = run.queued ? " (queued, starts when a slot frees)" : "";
161
+ return `#${run.id} ${run.agent}${run.label ? `·${run.label}` : ""}${tagged}`;
162
+ })
157
163
  .join(", ");
158
164
  const more = runs.length > listed.length ? `, +${runs.length - listed.length} more` : "";
159
165
  return `\n\n⚠ ${runs.length} other run${runs.length === 1 ? "" : "s"} still active: ${items}${more}. Do not conclude the overall task yet — wait for their results (they wake you automatically) or check subagent_status.`;
package/src/dispatch.ts CHANGED
@@ -9,10 +9,9 @@
9
9
  import { StringEnum } from "@earendil-works/pi-ai";
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import { Text } from "@earendil-works/pi-tui";
12
- import { resolve } from "node:path";
12
+ import { join, resolve } from "node:path";
13
13
  import { Type } from "typebox";
14
- import { discoverAgents, resolveAgentTools, type AgentConfig } from "./agents.ts";
15
- import { getStateRoot } from "./durable.ts";
14
+ import { discoverAgents, isWriteCapableAgent, resolveAgentTools, type AgentConfig } from "./agents.ts";
16
15
  import { loadConfig } from "./config.ts";
17
16
  import { formatUsage, queuedResult } from "./format.ts";
18
17
  import {
@@ -35,6 +34,7 @@ import {
35
34
  import type { SubagentRuntime } from "./runtime.ts";
36
35
  import { persistThreadCheckpoint } from "./thread-lifecycle.ts";
37
36
  import {
37
+ getProjectRoot,
38
38
  getResultOutput,
39
39
  isFailedResult,
40
40
  reviewVerdict,
@@ -84,9 +84,23 @@ const SubagentParams = Type.Object({
84
84
  isolation: IsolationSchema,
85
85
  });
86
86
 
87
- export function defaultIsolationMode(mode: "single" | "parallel", agentName: string, requested?: IsolationMode): IsolationMode {
87
+ /** Roles that default to worktree isolation in parallel dispatches even when
88
+ * the live catalog cannot be consulted (render-only call sites). Custom
89
+ * write-capable agents join them via isWriteCapableAgent on the execute path. */
90
+ const WORKTREE_DEFAULT_AGENTS = new Set(["worker", "cleaner", "documenter"]);
91
+
92
+ /** Resolve the default isolation for a dispatch. Parallel write-capable agents
93
+ * get a detached worktree: shared writers serialize on the repository lane, so
94
+ * defaulting them to shared would turn one parallel batch into a convoy that
95
+ * also parks process slots. Explicit requests always win. */
96
+ export function defaultIsolationMode(
97
+ mode: "single" | "parallel",
98
+ agentName: string,
99
+ requested?: IsolationMode,
100
+ writeCapable = WORKTREE_DEFAULT_AGENTS.has(agentName),
101
+ ): IsolationMode {
88
102
  if (requested) return requested;
89
- return mode === "parallel" && agentName === "worker" ? "worktree" : "shared";
103
+ return mode === "parallel" && writeCapable ? "worktree" : "shared";
90
104
  }
91
105
 
92
106
  function workflowStageStatus(result: SingleResult, relation?: string): WorkflowStageStatus {
@@ -100,12 +114,13 @@ function workflowStageStatus(result: SingleResult, relation?: string): WorkflowS
100
114
 
101
115
  /** The runtime-granted write continuation of a failed gate: same reviewer
102
116
  * role, model, and retained session, but the read-only boundary is lifted for
103
- * this one stage so it applies its own fix instructions. */
117
+ * this one stage so it applies its own fix instructions. One line only: the
118
+ * full fix-stage contract is already in the retained session's prompt. */
104
119
  function withReviewerFixStageAgent(agent: AgentConfig): AgentConfig {
105
120
  return {
106
121
  ...agent,
107
122
  tools: undefined,
108
- systemPrompt: `${agent.systemPrompt.trimEnd()}\n\nRuntime workflow context: FIX STAGE — your gate just returned REVIEW_FAIL. Your read-only boundary is lifted for this stage only: apply your own fix instructions exactly as you specified them, run the narrowest decisive checks, and report what changed. Never edit during a review and never emit a verdict in a fix stage.`,
123
+ systemPrompt: `${agent.systemPrompt.trimEnd()}\n\nRuntime workflow context: FIX STAGE — your gate just returned REVIEW_FAIL. Your read-only boundary is lifted for this stage only: apply your own fix instructions exactly as specified, verify, and report what changed; never edit during a review and never emit a verdict here.`,
109
124
  };
110
125
  }
111
126
 
@@ -187,6 +202,17 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
187
202
  (mode: "single" | "parallel", background = false) =>
188
203
  (results: SingleResult[]): SubagentDetails => ({ mode, results, background });
189
204
 
205
+ /** Pacing note appended to dispatch confirmations whenever runs are actually
206
+ * waiting: states the real slot capacity so ordinary queueing is never
207
+ * mistaken for a hard dispatch limit. Empty when everything is running. */
208
+ const queuePacingNote = (): string => {
209
+ const runs = monitor.getRuns();
210
+ const waiting = runs.filter((run) => run.status === "queued").length;
211
+ if (waiting === 0) return "";
212
+ const running = runs.filter((run) => run.status === "running" || run.status === "interrupting").length;
213
+ return ` Pacing: ${running} running · ${waiting} waiting for a free process slot (capacity ${runtime.backgroundQueue.capacity}); waiting runs start automatically as slots free — keep dispatching independent units.`;
214
+ };
215
+
190
216
  /** Launch one workflow-internal child in a fresh model context. It sees the
191
217
  * parent's exact repository/worktree state and is registered by its own id,
192
218
  * but never enters top-level lifecycle policy or completion delivery.
@@ -240,7 +266,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
240
266
  onLive,
241
267
  makeDetails: makeDetails("single", true),
242
268
  idleTimeoutMs: stageConfig.idleTimeoutSec * 1000,
243
- sessionRoot: getStateRoot(runtime.configPath),
269
+ sessionRoot: join(getProjectRoot(runtime.configPath, request.executionCwd), "sessions"),
244
270
  ...(stage.session
245
271
  ? { sessionId: stage.session.sessionId, sessionDir: stage.session.sessionDir, stdinText: task }
246
272
  : {}),
@@ -372,10 +398,9 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
372
398
  "final review",
373
399
  reviewStage,
374
400
  );
375
- // The failing gate owns its fixes until it passes: the same retained
401
+ // The failing gate owns its fixes: the same retained
376
402
  // reviewer session applies its own fix instructions with write access,
377
- // then a fresh gate re-scans the complete diff. Nobody outside the
378
- // loop has to guess what satisfies the gate; the cap only stops
403
+ // then a converging re-review verifies the fixes. The cap only stops
379
404
  // pathological burn and hands the still-failing gate to the main agent.
380
405
  for (let round = 1; round <= MAX_REVIEW_FIX_ROUNDS; round++) {
381
406
  const gateSession = gateReview.sessionId && gateReview.sessionDir
@@ -430,8 +455,9 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
430
455
  label: "Subagent",
431
456
  description: [
432
457
  "Dispatch enabled agents as isolated leaf Pi child processes, singly or in parallel. Dispatching never blocks your turn — runs proceed in the background and each completion resumes you automatically; never poll or restate delivered results.",
433
- "Put every genuinely independent unit in one `tasks` array (no per-call cap; extras queue for a free process slot). Single tasks share the checkout; parallel workers default to detached Git worktreeswrite-capable agents only, and setup failure never silently falls back to shared.",
434
- "Successful worker/cleaner runs get one automatic reviewer gate; a failing gate is fixed by the reviewer itself in a write-enabled continuation of the same session and re-reviewed until it passes. A REVIEW_FAIL from a gate you dispatched directly returns its findings to you — fix them inline or via a briefed worker without waiting for the user.",
458
+ "Put every genuinely independent unit in one `tasks` array (no per-call cap). Process slots scale with the machine; when all slots are busy the extra runs simply wait and start automatically as slots free waiting is pacing, never a rejection or a limit on how much you may dispatch.",
459
+ "Every parallel write-capable agent (worker, cleaner, documenter, custom writers) defaults to a detached Git worktree, so writers run concurrently; shared mode serializes same-repository writers. Explicit `shared` keeps the caller's checkout; setup failure never silently falls back to shared.",
460
+ "Successful worker/cleaner runs get one automatic reviewer gate; a failing gate is fixed by the reviewer itself in a write-enabled continuation of the same session and re-reviewed in bounded, converging rounds. A REVIEW_FAIL from a gate you dispatched directly returns its findings to you — fix them inline or via a briefed worker without waiting for the user.",
435
461
  "A configured child-model failure continues the retained session on the current main model. Resume a parked or settled thread with subagent_control by run id; use subagent_stop for destructive cancellation.",
436
462
  ].join(" "),
437
463
  promptSnippet:
@@ -504,11 +530,17 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
504
530
  // Preserve caller order (and deterministic completion batching) while
505
531
  // preparing each isolated filesystem before its queue entry can start.
506
532
  for (const item of params.tasks) {
533
+ const catalogAgent = agents.find((candidate) => candidate.name === item.agent);
507
534
  results.push(await startBackground(
508
535
  item.agent,
509
536
  item.task,
510
537
  item.cwd,
511
- defaultIsolationMode("parallel", item.agent, item.isolation as IsolationMode | undefined),
538
+ defaultIsolationMode(
539
+ "parallel",
540
+ item.agent,
541
+ item.isolation as IsolationMode | undefined,
542
+ catalogAgent ? isWriteCapableAgent(catalogAgent) : undefined,
543
+ ),
512
544
  ));
513
545
  }
514
546
  const startedRuns = results.filter((result) => result.exitCode === -1);
@@ -533,7 +565,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
533
565
  ...(failureLines.length > 0
534
566
  ? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
535
567
  : []),
536
- ].join("\n");
568
+ ].join("\n") + queuePacingNote();
537
569
  return {
538
570
  content: [{ type: "text", text }],
539
571
  details: makeDetails("parallel", true)(results),
@@ -551,7 +583,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
551
583
  }
552
584
  const runRef = result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`;
553
585
  return {
554
- content: [{ type: "text", text: `Started ${runRef} in the background. It never blocks you — dispatch more independent units now or keep working; its result resumes you automatically when you are idle.` }],
586
+ content: [{ type: "text", text: `Started ${runRef} in the background. It never blocks you — dispatch more independent units now or keep working; its result resumes you automatically when you are idle.${queuePacingNote()}` }],
555
587
  details: makeDetails("single", true)([result]),
556
588
  };
557
589
 
package/src/durable.ts CHANGED
@@ -13,13 +13,14 @@
13
13
  */
14
14
 
15
15
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
16
- import { existsSync } from "node:fs";
16
+ import { existsSync, type Dirent, readdirSync, statSync } from "node:fs";
17
17
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
18
18
  import { dirname, join } from "node:path";
19
19
  import type { UsageStats } from "./rpc-run.ts";
20
20
  import type { SubagentThread } from "./runtime.ts";
21
- import { getResultOutput, isFailedResult, type SingleResult } from "./spawn.ts";
21
+ import { getResultOutput, isFailedResult, getProjectRoot, PROJECT_ROOTS_DIR_NAME, type SingleResult } from "./spawn.ts";
22
22
  import {
23
+ isPathInside,
23
24
  restoreWorktreeIsolation,
24
25
  type IsolationMode,
25
26
  normalizeWorktreeSnapshot,
@@ -29,7 +30,12 @@ import {
29
30
 
30
31
  export const THREADS_MANIFEST_FILE_NAME = "pi-subagents-threads.json";
31
32
  const THREADS_MANIFEST_VERSION = 1;
32
- export const STATE_DIR_NAME = "pi-subagents-state";
33
+
34
+ /** Project directories whose newest file has not been touched for this long
35
+ * are deleted wholesale on load, so per-project sessions/worktrees/results
36
+ * can never accumulate forever. Parked threads' manifest references always
37
+ * win over the age rule. */
38
+ export const PROJECT_ROOT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1_000;
33
39
 
34
40
  /** Fixed retention: parked work (which may hold unintegrated changes) stops
35
41
  * being resumable after a month. Older manifests may still carry settled
@@ -76,10 +82,6 @@ interface ThreadsManifest {
76
82
  records: ThreadRecord[];
77
83
  }
78
84
 
79
- export function getStateRoot(configPath: string): string {
80
- return join(dirname(configPath), STATE_DIR_NAME);
81
- }
82
-
83
85
  export function getThreadsManifestPath(configPath: string): string {
84
86
  return join(dirname(configPath), THREADS_MANIFEST_FILE_NAME);
85
87
  }
@@ -335,3 +337,66 @@ export function referencedDurablePaths(records: readonly ThreadRecord[]): Set<st
335
337
  }
336
338
  return paths;
337
339
  }
340
+
341
+ /** Newest modification time anywhere under root (directories count via their
342
+ * own entries); undefined when root cannot be read. */
343
+ function newestMtimeMs(root: string, now: number = Date.now()): number | undefined {
344
+ let newest: number | undefined;
345
+ const stack: string[] = [root];
346
+ while (stack.length > 0) {
347
+ const dir = stack.pop()!;
348
+ let entries: Dirent[];
349
+ try {
350
+ entries = readdirSync(dir, { withFileTypes: true });
351
+ } catch {
352
+ continue;
353
+ }
354
+ for (const entry of entries) {
355
+ const path = join(dir, entry.name);
356
+ let mtime: number;
357
+ try {
358
+ mtime = statSync(path).mtimeMs;
359
+ } catch {
360
+ continue;
361
+ }
362
+ if (mtime > 0 && mtime <= now && (newest === undefined || mtime > newest)) newest = mtime;
363
+ if (entry.isDirectory() && !entry.isSymbolicLink()) stack.push(path);
364
+ }
365
+ }
366
+ return newest;
367
+ }
368
+
369
+ /** Delete project directories under the ferris-pi-subagents root that have
370
+ * been idle past PROJECT_ROOT_MAX_AGE_MS. A directory containing any path the
371
+ * threads manifest still references is never touched, so parked work outlives
372
+ * the age rule. Returns the removed directory names. */
373
+ export async function pruneStaleProjectRoots(configPath: string, options: { now?: number } = {}): Promise<string[]> {
374
+ const now = options.now ?? Date.now();
375
+ const records = await readThreadRecords(configPath).catch(() => [] as ThreadRecord[]);
376
+ const referenced = referencedDurablePaths(records);
377
+ const root = join(dirname(configPath), PROJECT_ROOTS_DIR_NAME);
378
+ let projects: Dirent[];
379
+ try {
380
+ projects = readdirSync(root, { withFileTypes: true });
381
+ } catch {
382
+ return [];
383
+ }
384
+ const removed: string[] = [];
385
+ for (const project of projects) {
386
+ if (!project.isDirectory() || project.isSymbolicLink()) continue;
387
+ const projectDir = join(root, project.name);
388
+ if (containsReferencedPath(projectDir, referenced)) continue;
389
+ const newest = newestMtimeMs(projectDir, now);
390
+ if (newest === undefined || now - newest <= PROJECT_ROOT_MAX_AGE_MS) continue;
391
+ await rm(projectDir, { recursive: true, force: true }).catch(() => undefined);
392
+ if (!existsSync(projectDir)) removed.push(project.name);
393
+ }
394
+ return removed;
395
+ }
396
+
397
+ function containsReferencedPath(projectDir: string, referenced: ReadonlySet<string>): boolean {
398
+ for (const path of referenced) {
399
+ if (isPathInside(projectDir, path)) return true;
400
+ }
401
+ return false;
402
+ }