@ferris1225/pi-subagents 4.1.16 → 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.
@@ -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");