@ferris1225/pi-subagents 4.1.8 → 4.1.9

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/src/spawn.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { createHash, randomUUID } from "node:crypto";
12
12
  import { type Dirent, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
13
- import { mkdtemp, rm } from "node:fs/promises";
13
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
14
14
  import { tmpdir } from "node:os";
15
15
  import { basename, join, resolve } from "node:path";
16
16
  import type { Message } from "@earendil-works/pi-ai";
@@ -202,7 +202,7 @@ export function writeResultArtifact(output: string, agentName: string, cwd?: str
202
202
  }
203
203
 
204
204
  export function isFailedResult(result: SingleResult): boolean {
205
- if (result.parked) return false;
205
+
206
206
  return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
207
207
  }
208
208
 
@@ -308,12 +308,12 @@ async function waitForControlledRetry(
308
308
  ): Promise<boolean> {
309
309
  let remaining = normalizeStartupRetryDelay(delayMs);
310
310
  while (remaining > 0) {
311
- if (control?.isParkRequested() || control?.isStopRequested()) return false;
311
+ if (control?.isStopRequested()) return false;
312
312
  const slice = Math.min(remaining, 50);
313
313
  if (!(await waitForStartupRetry(slice, signal))) return false;
314
314
  remaining -= slice;
315
315
  }
316
- return !signal?.aborted && !control?.isParkRequested() && !control?.isStopRequested();
316
+ return !signal?.aborted && !control?.isStopRequested();
317
317
  }
318
318
 
319
319
  export function getResultOutput(result: SingleResult): string {
@@ -330,6 +330,12 @@ export function buildResumePrompt(task: string, reason: string): string {
330
330
  return `You are resuming an earlier sub-agent session after ${reason}. Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Current objective: ${task}. Pick up exactly where you left off and finish it. Do NOT redo searches, reads, or edits you already completed unless a step clearly failed. Continue now.`;
331
331
  }
332
332
 
333
+ /** Create a fresh private session directory under the given root. */
334
+ export async function createSessionDir(root: string = tmpdir()): Promise<string> {
335
+ await mkdir(root, { recursive: true });
336
+ return mkdtemp(join(root, "pi-subagent-session-"));
337
+ }
338
+
333
339
  export function buildFallbackResumeReason(fromModel?: string): string {
334
340
  return fromModel
335
341
  ? `the selected model (${fromModel}) failed at the model/provider level, so the current main model is continuing`
@@ -349,6 +355,10 @@ export interface RunSingleOptions {
349
355
  startupRetryDelaysMs?: readonly number[];
350
356
  sessionDir?: string;
351
357
  sessionId?: string;
358
+ /** Parent directory for a fresh session directory. Defaults to the OS temp
359
+ * dir; dispatch passes the durable state root so retained sessions survive
360
+ * reloads and restarts. */
361
+ sessionRoot?: string;
352
362
  /** Initial RPC prompt. Kept under the old name to limit caller churn. */
353
363
  stdinText?: string;
354
364
  /** Refresh parent-derived tools immediately before every startup retry and
@@ -366,7 +376,7 @@ export interface RunSingleOptions {
366
376
 
367
377
  function controlledDisposition(options: RunSingleOptions, base?: SingleResult): SingleResult | undefined {
368
378
  const control = options.control;
369
- if (!control?.isParkRequested() && !control?.isStopRequested()) return undefined;
379
+ if (!control?.isStopRequested()) return undefined;
370
380
  const result: SingleResult = base ?? {
371
381
  agent: options.agentName,
372
382
  task: control.getObjective(),
@@ -380,23 +390,14 @@ function controlledDisposition(options: RunSingleOptions, base?: SingleResult):
380
390
  sessionDir: options.sessionDir,
381
391
  };
382
392
  result.task = control.getObjective();
383
- if (control.isParkRequested()) {
384
- result.parked = true;
385
- result.exitCode = 0;
386
- result.stopReason = undefined;
387
- result.errorMessage = undefined;
388
- } else {
389
- result.parked = undefined;
390
- result.exitCode = 1;
391
- result.stopReason = "aborted";
392
- result.errorMessage = control.getStopMessage();
393
- }
393
+ result.exitCode = 1;
394
+ result.stopReason = "aborted";
395
+ result.errorMessage = control.getStopMessage();
394
396
  return result;
395
397
  }
396
398
 
397
399
  function signalAbortDisposition(options: RunSingleOptions, base: SingleResult): SingleResult | undefined {
398
400
  if (!options.signal?.aborted) return undefined;
399
- base.parked = undefined;
400
401
  base.exitCode = 1;
401
402
  base.stopReason = "aborted";
402
403
  base.errorMessage = "Subagent was aborted";
@@ -459,8 +460,17 @@ export async function runSingleAgentWithMainFallback(
459
460
  const startupDelays = customStartupDelays ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
460
461
 
461
462
  const sessionId = options.sessionId ?? randomUUID();
462
- const sessionDir = options.sessionDir ?? (await mkdtemp(join(tmpdir(), "pi-subagent-session-")));
463
+ const sessionDir = options.sessionDir ?? (await createSessionDir(options.sessionRoot));
463
464
  const baseOptions: RunSingleOptions = { ...options, sessionDir, sessionId };
465
+ if (!options.sessionDir) {
466
+ // Surface the fresh session immediately so the dispatching thread can
467
+ // persist a durable checkpoint before the child settles.
468
+ try {
469
+ options.onLive?.({ kind: "session", sessionId, sessionDir });
470
+ } catch {
471
+ /* never throw from event handling */
472
+ }
473
+ }
464
474
 
465
475
  const dispatchFailure = async (error: unknown): Promise<SingleResult> => {
466
476
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -489,14 +499,7 @@ export async function runSingleAgentWithMainFallback(
489
499
  let retries = 0;
490
500
  for (let attempt = 0; ; attempt++) {
491
501
  const immediate = controlledDisposition(opts);
492
- if (immediate) {
493
- if (immediate.parked && !options.sessionDir && !sessionExists(sessionDir, sessionId)) {
494
- await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
495
- immediate.sessionId = undefined;
496
- immediate.sessionDir = undefined;
497
- }
498
- return immediate;
499
- }
502
+ if (immediate) return immediate;
500
503
  const start = Date.now();
501
504
  try {
502
505
  const attemptOptions = opts.resolveAgentForAttempt
@@ -510,7 +513,7 @@ export async function runSingleAgentWithMainFallback(
510
513
  const durationMs = Date.now() - start;
511
514
  const controlled = controlledDisposition(opts, lastResult);
512
515
  if (controlled) return controlled;
513
- if (lastResult.parked || lastResult.stopReason === "aborted") return lastResult;
516
+ if (lastResult.stopReason === "aborted") return lastResult;
514
517
  if (!isRetryableStartupFailure(lastResult, durationMs)) {
515
518
  if (retries > 0 && !isFailedResult(lastResult)) lastResult.startupRetries = retries;
516
519
  return lastResult;
@@ -627,7 +630,7 @@ export async function runSingleAgentWithMainFallback(
627
630
  }
628
631
 
629
632
  result = await runWithStartupRetry(candidateOptions);
630
- if (result.parked || result.stopReason === "aborted") return finish(result);
633
+ if (result.stopReason === "aborted") return finish(result);
631
634
  if (!isModelLevelFailure(result)) return finish(result);
632
635
  // Any model-level failure advances immediately to the sole fallback (the
633
636
  // current main model). Retain selected-attempt tool diagnostics and usage;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Temp hygiene: ownership markers and startup sweeps for the directories this
3
+ * extension creates.
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).
10
+ *
11
+ * A live sibling pi instance never loses its directories: `kill(pid, 0)` only
12
+ * reports "no such process" when the pid genuinely does not exist, so a live
13
+ * owner always survives the sweep. Pid reuse merely delays cleanup until the
14
+ * reusing process exits or the age cap catches the directory.
15
+ */
16
+
17
+ import { spawn } from "node:child_process";
18
+ import { type Dirent, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ export const TEMP_OWNER_FILE_NAME = "owner.json";
23
+
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;
34
+
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. */
39
+ 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
+
44
+ interface TempOwner {
45
+ pid: number;
46
+ createdAt: number;
47
+ }
48
+
49
+ export function isProcessAlive(pid: number): boolean {
50
+ if (!Number.isInteger(pid) || pid <= 0) return false;
51
+ try {
52
+ process.kill(pid, 0);
53
+ return true;
54
+ } catch (error) {
55
+ // EPERM means the process exists but belongs to another user.
56
+ return (error as NodeJS.ErrnoException).code === "EPERM";
57
+ }
58
+ }
59
+
60
+ /** Best-effort marker write; a missing marker only delays cleanup. */
61
+ export function writeTempOwnerMarker(dir: string, now = Date.now()): void {
62
+ try {
63
+ writeFileSync(
64
+ join(dir, TEMP_OWNER_FILE_NAME),
65
+ `${JSON.stringify({ pid: process.pid, createdAt: now } satisfies TempOwner)}\n`,
66
+ "utf8",
67
+ );
68
+ } catch {
69
+ /* marker failures must never break the creating operation */
70
+ }
71
+ }
72
+
73
+ function readTempOwnerMarker(dir: string): TempOwner | undefined {
74
+ try {
75
+ const parsed = JSON.parse(readFileSync(join(dir, TEMP_OWNER_FILE_NAME), "utf8")) as Partial<TempOwner>;
76
+ if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) return undefined;
77
+ return { pid: parsed.pid, createdAt: typeof parsed.createdAt === "number" ? parsed.createdAt : 0 };
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ }
82
+
83
+ /** Terminate a whole process tree without waiting. Used on restore for child
84
+ * processes orphaned by a reload or crash that still hold a retained session. */
85
+ export function killProcessTree(pid: number): void {
86
+ if (!Number.isInteger(pid) || pid <= 0) return;
87
+ if (process.platform === "win32") {
88
+ try {
89
+ spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
90
+ stdio: "ignore",
91
+ windowsHide: true,
92
+ }).once("error", () => undefined);
93
+ } catch {
94
+ /* the process is gone */
95
+ }
96
+ return;
97
+ }
98
+ try {
99
+ process.kill(pid, "SIGKILL");
100
+ } catch {
101
+ /* the process is gone */
102
+ }
103
+ }
104
+
105
+ export interface SweepOptions {
106
+ now?: number;
107
+ /** Injectable pid liveness probe for tests. */
108
+ isAlive?: (pid: number) => boolean;
109
+ /** Override the unmarked-directory age cap for tests. */
110
+ unmarkedMaxAgeMs?: number;
111
+ }
112
+
113
+ function removeDir(path: string): boolean {
114
+ try {
115
+ rmSync(path, { recursive: true, force: true });
116
+ return true;
117
+ } catch {
118
+ // Windows locks (antivirus, indexer) leave the directory for a later sweep.
119
+ return false;
120
+ }
121
+ }
122
+
123
+ function directoryAgeMs(entry: Dirent, dir: string, now: number): number | undefined {
124
+ try {
125
+ return now - statSync(join(dir, entry.name)).mtimeMs;
126
+ } catch {
127
+ return undefined;
128
+ }
129
+ }
130
+
131
+ /** Remove OS-temp directories owned by dead processes plus old unmarked
132
+ * legacy leaks. Returns how many directories were removed. */
133
+ export function sweepOrphanTempDirs(
134
+ rootDir: string = tmpdir(),
135
+ options: SweepOptions = {},
136
+ ): number {
137
+ const now = options.now ?? Date.now();
138
+ const isAlive = options.isAlive ?? isProcessAlive;
139
+ const unmarkedMaxAgeMs = options.unmarkedMaxAgeMs ?? UNMARKED_TEMP_MAX_AGE_MS;
140
+ let entries: Dirent[];
141
+ try {
142
+ entries = readdirSync(rootDir, { withFileTypes: true });
143
+ } catch {
144
+ return 0;
145
+ }
146
+ let removed = 0;
147
+ for (const entry of entries) {
148
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
149
+ if (TEMP_DIR_EXCLUDED_NAMES.has(entry.name)) continue;
150
+ if (!TEMP_DIR_PREFIXES.some((prefix) => entry.name.startsWith(prefix))) continue;
151
+ const path = join(rootDir, entry.name);
152
+ const owner = readTempOwnerMarker(path);
153
+ if (owner) {
154
+ // An unmarked fresh sibling race is impossible here: the marker is
155
+ // written immediately after mkdtemp. A marked dir dies only with its
156
+ // owning process.
157
+ if (isAlive(owner.pid)) continue;
158
+ if (removeDir(path)) removed++;
159
+ continue;
160
+ }
161
+ const ageMs = directoryAgeMs(entry, rootDir, now);
162
+ if (ageMs !== undefined && ageMs > unmarkedMaxAgeMs && removeDir(path)) removed++;
163
+ }
164
+ return removed;
165
+ }
166
+
167
+ /** Remove state-root directories no manifest record references. Fresh
168
+ * directories (a run just created but not yet recorded) are protected by the
169
+ * age cap, since the sweep only runs at extension load before new work. */
170
+ export function sweepUnreferencedState(
171
+ stateRoot: string,
172
+ referencedPaths: ReadonlySet<string>,
173
+ options: SweepOptions = {},
174
+ ): number {
175
+ const now = options.now ?? Date.now();
176
+ const maxAgeMs = options.unmarkedMaxAgeMs ?? UNREFERENCED_STATE_MAX_AGE_MS;
177
+ const pathKey = (path: string): string => (process.platform === "win32" ? path.toLowerCase() : path);
178
+ const referenced = new Set([...referencedPaths].map(pathKey));
179
+ let entries: Dirent[];
180
+ try {
181
+ entries = readdirSync(stateRoot, { withFileTypes: true });
182
+ } catch {
183
+ return 0;
184
+ }
185
+ let removed = 0;
186
+ for (const entry of entries) {
187
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
188
+ const path = join(stateRoot, entry.name);
189
+ if (referenced.has(pathKey(path))) continue;
190
+ const ageMs = directoryAgeMs(entry, stateRoot, now);
191
+ if (ageMs !== undefined && ageMs > maxAgeMs && removeDir(path)) removed++;
192
+ }
193
+ return removed;
194
+ }