@ferris1225/pi-subagents 4.1.17 → 4.1.18

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
@@ -205,8 +205,10 @@ subagent_control({ action: "resume", id: 7, objective: "Finish the tests." });
205
205
 
206
206
  Threads are durable while work is unfinished: parked sessions, worktree
207
207
  checkpoints, and result excerpts live under
208
- `~/.pi/agent/ferris-pi-subagents/<project>/` (grouped per project; not the OS
209
- temp directory) and are restored when pi reloads or restarts a reload
208
+ `~/.pi/agent/ferris-pi-subagents/<project>/` (grouped per project; nothing is
209
+ written to the OS temp directory transient per-run scratch lives in the
210
+ project's `tmp/` and is swept for dead owners on the next load) and are
211
+ restored when pi reloads or restarts — a reload
210
212
  interrupts a live run into a restorable checkpoint instead of losing it. A
211
213
  thread that completes or fails cleanly drops its durable record, so the
212
214
  threads manifest exists only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "4.1.17",
3
+ "version": "4.1.18",
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
5
  "type": "module",
6
6
  "license": "MIT",
package/src/dispatch.ts CHANGED
@@ -251,6 +251,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
251
251
  ...(request.worktreeId ? { worktreeId: request.worktreeId } : {}),
252
252
  });
253
253
  const onLive = makeLiveHandler(runId);
254
+ const projectRoot = getProjectRoot(runtime.configPath, request.executionCwd);
254
255
  try {
255
256
  const result = await runSingleAgentWithMainFallback(
256
257
  {
@@ -266,7 +267,8 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
266
267
  onLive,
267
268
  makeDetails: makeDetails("single", true),
268
269
  idleTimeoutMs: stageConfig.idleTimeoutSec * 1000,
269
- sessionRoot: join(getProjectRoot(runtime.configPath, request.executionCwd), "sessions"),
270
+ sessionRoot: join(projectRoot, "sessions"),
271
+ scratchRoot: join(projectRoot, "tmp"),
270
272
  ...(stage.session
271
273
  ? { sessionId: stage.session.sessionId, sessionDir: stage.session.sessionDir, stdinText: task }
272
274
  : {}),
@@ -407,7 +409,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
407
409
  ? { sessionId: gateReview.sessionId, sessionDir: gateReview.sessionDir }
408
410
  : undefined;
409
411
  if (reviewVerdict(getResultOutput(gateReview)) !== "fail" || !gateSession || !canContinue()) break;
410
- const fixStage: WorkflowStage = { agent: "reviewer", relation: "fix", status: "pending" };
412
+ const fixStage: WorkflowStage = { agent: "reviewer", relation: "review fix", status: "pending" };
411
413
  workflowStages.push(fixStage);
412
414
  publishWorkflowStages();
413
415
  const fixResult = await launchStep(
package/src/rpc-run.ts CHANGED
@@ -9,8 +9,7 @@
9
9
 
10
10
  import { spawn, type ChildProcess } from "node:child_process";
11
11
  import { existsSync, readdirSync, unlinkSync, rmdirSync } from "node:fs";
12
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
13
- import { tmpdir } from "node:os";
12
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
14
13
  import { basename, join } from "node:path";
15
14
  import { StringDecoder } from "node:string_decoder";
16
15
  import type { Message } from "@earendil-works/pi-ai";
@@ -332,9 +331,11 @@ interface ChildRetryPolicyExtension {
332
331
  * maxRetries=0. It uses Pi's public extension and pi-ai compatibility APIs, so
333
332
  * it works in Node and standalone/Bun builds without touching user settings. */
334
333
  export async function writeChildRetryPolicyExtension(
334
+ scratchRoot: string,
335
335
  modelRef?: string,
336
336
  ): Promise<ChildRetryPolicyExtension> {
337
- const dir = await mkdtemp(join(tmpdir(), "pi-subagents-policy-"));
337
+ await mkdir(scratchRoot, { recursive: true });
338
+ const dir = await mkdtemp(join(scratchRoot, "pi-subagents-policy-"));
338
339
  writeTempOwnerMarker(dir);
339
340
  const filePath = join(dir, "no-provider-retries.mjs");
340
341
  const slash = modelRef?.indexOf("/") ?? -1;
@@ -363,8 +364,13 @@ export async function writeChildRetryPolicyExtension(
363
364
  }
364
365
  }
365
366
 
366
- async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
367
- const dir = await mkdtemp(join(tmpdir(), "pi-subagents-"));
367
+ async function writePromptToTempFile(
368
+ scratchRoot: string,
369
+ agentName: string,
370
+ prompt: string,
371
+ ): Promise<{ dir: string; filePath: string }> {
372
+ await mkdir(scratchRoot, { recursive: true });
373
+ const dir = await mkdtemp(join(scratchRoot, "pi-subagents-"));
368
374
  writeTempOwnerMarker(dir);
369
375
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
370
376
  const filePath = join(dir, `prompt-${safeName}.md`);
@@ -425,6 +431,9 @@ export interface RunRpcAttemptOptions {
425
431
  idleTimeoutMs: number;
426
432
  sessionDir?: string;
427
433
  sessionId?: string;
434
+ /** Transient scratch root (child prompt files, retry-policy extension):
435
+ * the project-scoped tmp directory, never the OS temp directory. */
436
+ scratchRoot: string;
428
437
  prompt: string;
429
438
  signal?: AbortSignal;
430
439
  onLive?: (event: SubagentLiveEvent) => void;
@@ -454,7 +463,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
454
463
  let tmpPromptDir: string | null = null;
455
464
  let tmpPromptPath: string | null = null;
456
465
  if (agent.systemPrompt.trim()) {
457
- const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
466
+ const tmp = await writePromptToTempFile(options.scratchRoot, agent.name, agent.systemPrompt);
458
467
  tmpPromptDir = tmp.dir;
459
468
  tmpPromptPath = tmp.filePath;
460
469
  args.push("--append-system-prompt", tmpPromptPath);
@@ -462,7 +471,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
462
471
 
463
472
  let retryPolicy: ChildRetryPolicyExtension;
464
473
  try {
465
- retryPolicy = await writeChildRetryPolicyExtension(agent.model);
474
+ retryPolicy = await writeChildRetryPolicyExtension(options.scratchRoot, agent.model);
466
475
  args.push("--extension", retryPolicy.filePath);
467
476
  } catch (error) {
468
477
  if (tmpPromptDir) await rm(tmpPromptDir, { recursive: true, force: true }).catch(() => undefined);
@@ -3,7 +3,6 @@
3
3
  import { SessionManager } from "@earendil-works/pi-coding-agent";
4
4
  import { existsSync } from "node:fs";
5
5
  import { mkdir, mkdtemp, rm } from "node:fs/promises";
6
- import { tmpdir } from "node:os";
7
6
  import { join } from "node:path";
8
7
 
9
8
  export interface ForkedSession {
@@ -43,15 +42,15 @@ export async function forkRetainedSession(options: {
43
42
  targetCwd?: string;
44
43
  sessionDir: string;
45
44
  sessionId: string;
46
- /** Parent directory for the cloned branch. Defaults to the OS temp dir;
47
- * dispatch passes the durable state root. */
48
- targetRoot?: string;
45
+ /** Parent directory for the cloned branch: the project-scoped durable
46
+ * sessions root, so forks never land in the OS temp directory. */
47
+ targetRoot: string;
49
48
  }): Promise<ForkedSession> {
50
49
  const sourceSessionFile = await findRetainedSessionFile(
51
50
  options.sessionDir,
52
51
  options.sessionId,
53
52
  );
54
- const root = options.targetRoot ?? tmpdir();
53
+ const root = options.targetRoot;
55
54
  await mkdir(root, { recursive: true });
56
55
  const sessionDir = await mkdtemp(join(root, "pi-subagent-session-fork-"));
57
56
  try {
package/src/spawn.ts CHANGED
@@ -367,6 +367,9 @@ export interface RunSingleOptions {
367
367
  /** Parent directory for a fresh session directory: the project-scoped
368
368
  * sessions root, so retained sessions survive reloads and restarts. */
369
369
  sessionRoot: string;
370
+ /** Parent directory for per-attempt transient files (child prompt, retry
371
+ * policy): the project-scoped tmp root, so nothing lands in the OS temp. */
372
+ scratchRoot: string;
370
373
  /** Initial RPC prompt. Kept under the old name to limit caller churn. */
371
374
  stdinText?: string;
372
375
  /** Refresh parent-derived tools immediately before every startup retry and
@@ -440,6 +443,7 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
440
443
  idleTimeoutMs,
441
444
  sessionDir: options.sessionDir,
442
445
  sessionId: options.sessionId,
446
+ scratchRoot: options.scratchRoot,
443
447
  prompt,
444
448
  signal: options.signal,
445
449
  onLive: options.onLive,
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Temp hygiene: ownership markers and startup sweeps for the directories this
3
- * extension creates.
2
+ * Temp hygiene: ownership markers and startup sweeps for the transient
3
+ * directories this extension creates.
4
4
  *
5
- * Every short-lived temp directory (child prompt/policy files) gets an owner
6
- * marker with the creating pid. At extension load, directories whose owner is
7
- * dead are removed; unmarked legacy leaks fall back to an age cap. The same
8
- * load pass sweeps the durable state root for directories no manifest record
9
- * references anymore (crashes between creation and the first record write).
5
+ * Transient per-run files (child prompt copies, the no-retry policy extension)
6
+ * live under the project's durable tmp directory, never the OS temp dir. Each
7
+ * mkdtemp directory gets an owner marker with the creating pid. At extension
8
+ * load, directories whose owner is dead are removed; unmarked leftovers fall
9
+ * back to an age cap.
10
10
  *
11
11
  * A live sibling pi instance never loses its directories: `kill(pid, 0)` only
12
12
  * reports "no such process" when the pid genuinely does not exist, so a live
@@ -16,30 +16,19 @@
16
16
 
17
17
  import { spawn } from "node:child_process";
18
18
  import { type Dirent, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
19
- import { tmpdir } from "node:os";
20
19
  import { join } from "node:path";
21
20
 
22
21
  export const TEMP_OWNER_FILE_NAME = "owner.json";
23
22
 
24
- /** Directories this extension creates in the OS temp dir. Session and
25
- * worktree prefixes cover legacy leaks from versions that used tmpdir for
26
- * retained state; policy/prompt prefixes cover per-run transient files. */
27
- const TEMP_DIR_PREFIXES = [
28
- "pi-subagent-session-",
29
- "pi-subagent-session-fork-",
30
- "pi-subagent-worktree-",
31
- "pi-subagents-policy-",
32
- "pi-subagents-",
33
- ] as const;
23
+ /** Transient directories created under `<project>/tmp`: the child prompt
24
+ * copies (`pi-subagents-*`) and the no-retry policy extension
25
+ * (`pi-subagents-policy-*`). Durable sessions and worktrees use the singular
26
+ * `pi-subagent-` prefixes and live in their own roots, never swept here. */
27
+ const TEMP_DIR_PREFIXES = ["pi-subagents-"] as const;
34
28
 
35
- /** Owned by pruneResultArtifacts; never swept here. */
36
- const TEMP_DIR_EXCLUDED_NAMES = new Set(["pi-subagents-results"]);
37
-
38
- /** Unmarked directories (legacy leaks) must outlive this age before removal. */
29
+ /** Unmarked directories (crash before the marker write) must outlive this age
30
+ * before removal. */
39
31
  export const UNMARKED_TEMP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
40
- /** State-root directories missing from every manifest record (crash between
41
- * directory creation and the first record persist) after this age. */
42
- export const UNREFERENCED_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
43
32
 
44
33
  interface TempOwner {
45
34
  pid: number;
@@ -128,10 +117,10 @@ function directoryAgeMs(entry: Dirent, dir: string, now: number): number | undef
128
117
  }
129
118
  }
130
119
 
131
- /** Remove OS-temp directories owned by dead processes plus old unmarked
132
- * legacy leaks. Returns how many directories were removed. */
120
+ /** Remove dead-owner and old-unmarked transient directories under `rootDir`.
121
+ * Returns how many directories were removed. */
133
122
  export function sweepOrphanTempDirs(
134
- rootDir: string = tmpdir(),
123
+ rootDir: string,
135
124
  options: SweepOptions = {},
136
125
  ): number {
137
126
  const now = options.now ?? Date.now();
@@ -146,7 +135,6 @@ export function sweepOrphanTempDirs(
146
135
  let removed = 0;
147
136
  for (const entry of entries) {
148
137
  if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
149
- if (TEMP_DIR_EXCLUDED_NAMES.has(entry.name)) continue;
150
138
  if (!TEMP_DIR_PREFIXES.some((prefix) => entry.name.startsWith(prefix))) continue;
151
139
  const path = join(rootDir, entry.name);
152
140
  const owner = readTempOwnerMarker(path);
@@ -164,3 +152,23 @@ export function sweepOrphanTempDirs(
164
152
  return removed;
165
153
  }
166
154
 
155
+ /** Sweep every project's transient tmp directory under the durable extension
156
+ * root, so crash leftovers from any checkout die on the next load. */
157
+ export function sweepProjectTempDirs(
158
+ durableRoot: string,
159
+ options: SweepOptions = {},
160
+ ): number {
161
+ let projects: Dirent[];
162
+ try {
163
+ projects = readdirSync(durableRoot, { withFileTypes: true });
164
+ } catch {
165
+ return 0;
166
+ }
167
+ let removed = 0;
168
+ for (const project of projects) {
169
+ if (!project.isDirectory() || project.isSymbolicLink()) continue;
170
+ removed += sweepOrphanTempDirs(join(durableRoot, project.name, "tmp"), options);
171
+ }
172
+ return removed;
173
+ }
174
+
@@ -12,7 +12,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { existsSync } from "node:fs";
13
13
  import { rm } from "node:fs/promises";
14
14
  import { realpath } from "node:fs/promises";
15
- import { join, resolve } from "node:path";
15
+ import { join, resolve, dirname } from "node:path";
16
16
  import {
17
17
  discoverAgents,
18
18
  isWriteCapableAgent,
@@ -68,6 +68,7 @@ import {
68
68
  buildResumePrompt,
69
69
  getProjectRoot,
70
70
  getResultOutput,
71
+ PROJECT_ROOTS_DIR_NAME,
71
72
  RpcRunControl,
72
73
  isFailedResult,
73
74
  isModelLevelFailure,
@@ -81,7 +82,7 @@ import {
81
82
  import {
82
83
  isProcessAlive,
83
84
  killProcessTree,
84
- sweepOrphanTempDirs,
85
+ sweepProjectTempDirs,
85
86
  } from "./temp-hygiene.ts";
86
87
  import {
87
88
  createWorktreeIsolation,
@@ -418,6 +419,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
418
419
  const projectRoot = getProjectRoot(runtime.configPath, originalCwd);
419
420
  const sessionsRoot = join(projectRoot, "sessions");
420
421
  const worktreesRoot = join(projectRoot, "worktrees");
422
+ const scratchRoot = join(projectRoot, "tmp");
421
423
  const previousWorktree = existingThread?.worktree;
422
424
  let worktree = seed?.worktree ?? previousWorktree;
423
425
  if (isolation === "worktree") {
@@ -586,6 +588,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
586
588
  makeDetails: makeDetails("single", true),
587
589
  idleTimeoutMs: activeIdleTimeoutMs,
588
590
  sessionRoot: sessionsRoot,
591
+ scratchRoot,
589
592
  ...(priorSessionId && priorSessionDir
590
593
  ? {
591
594
  sessionId: priorSessionId,
@@ -1384,7 +1387,7 @@ export async function bootstrapDurableState(runtime: SubagentRuntime): Promise<v
1384
1387
  /* retention is best-effort */
1385
1388
  }
1386
1389
  try {
1387
- sweepOrphanTempDirs();
1390
+ sweepProjectTempDirs(join(dirname(runtime.configPath), PROJECT_ROOTS_DIR_NAME));
1388
1391
  } catch {
1389
1392
  /* temp hygiene is best-effort */
1390
1393
  }
package/src/worktree.ts CHANGED
@@ -11,7 +11,6 @@
11
11
  import { spawn, type ChildProcess } from "node:child_process";
12
12
  import { existsSync } from "node:fs";
13
13
  import { copyFile, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
14
- import { tmpdir } from "node:os";
15
14
  import { isAbsolute, join, relative, resolve } from "node:path";
16
15
 
17
16
  export type IsolationMode = "shared" | "worktree";
@@ -216,8 +215,9 @@ export interface WorktreeSnapshot {
216
215
 
217
216
  export interface WorktreeCreateOptions {
218
217
  runner?: CommandRunner;
219
- /** Test hook; production uses the OS temp directory. */
220
- tempBaseDir?: string;
218
+ /** Parent directory for the worktree group: the project-scoped durable
219
+ * worktrees root, so isolation never lands in the OS temp directory. */
220
+ tempBaseDir: string;
221
221
  /** Complete source generation checkpoint merged onto the current HEAD. */
222
222
  seedCheckpoint?: WorktreeCheckpoint;
223
223
  /** The seed is already present in the parent checkout, so only later edits
@@ -672,11 +672,11 @@ class GitWorktreeIsolation implements WorktreeIsolation {
672
672
  */
673
673
  export async function createWorktreeIsolation(
674
674
  cwd: string,
675
- options: WorktreeCreateOptions = {},
675
+ options: WorktreeCreateOptions,
676
676
  ): Promise<WorktreeIsolation> {
677
677
  const runner = options.runner ?? runCommand;
678
678
  const target = await resolveWorktreeTarget(cwd, runner);
679
- const tempBase = options.tempBaseDir ? resolve(options.tempBaseDir) : tmpdir();
679
+ const tempBase = resolve(options.tempBaseDir);
680
680
  await mkdir(tempBase, { recursive: true });
681
681
  const tempDir = await mkdtemp(join(tempBase, "pi-subagent-worktree-"));
682
682
  const worktreePath = join(tempDir, "worktree");