@ferris1225/pi-subagents 4.1.8 → 4.1.11

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/setup.ts CHANGED
@@ -1,25 +1,21 @@
1
1
  /**
2
2
  * Interactive configuration wizard for /subagents-setup.
3
3
  *
4
- * The UI intentionally has no backup pool or global thinking menu. Each agent
5
- * gets one optional model override; failures hand directly to the current main
6
- * model. Thinking defaults to Auto and manual choices are limited to levels Pi
7
- * reports as supported by the selected model.
4
+ * The wizard stays one level deep and exposes only what most users touch:
5
+ * which agents run, the model each runs on, and the delegation directive
6
+ * toggle. Everything else (per-agent thinking, agent scope, idle timeout,
7
+ * result lines, notifications) is config-file-only; model failures hand
8
+ * directly to the current main model, and thinking defaults to capability-
9
+ * aware Auto.
8
10
  */
9
11
 
10
12
  import { stat } from "node:fs/promises";
11
- import type { Api, Model } from "@earendil-works/pi-ai";
12
13
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
14
  import {
14
- AGENT_SCOPE_VALUES,
15
15
  BUILTIN_AGENT_NAMES,
16
16
  DEFAULT_CONFIG,
17
17
  DEFAULT_ENABLED_AGENTS,
18
- DEFAULT_IDLE_TIMEOUT_SEC,
19
- DEFAULT_THINKING_LEVEL,
20
- type AgentScope,
21
18
  type SubagentsConfig,
22
- type ThinkingLevel,
23
19
  errorMessage,
24
20
  getConfigPath,
25
21
  loadConfig,
@@ -31,28 +27,9 @@ import {
31
27
  availableModelsInScope,
32
28
  buildModelPickerItems,
33
29
  currentModelRef,
34
- findModelByRef,
35
30
  modelRef,
36
- resolveThinkingLevel,
37
- supportedThinkingLevels,
38
31
  } from "./models.ts";
39
32
  import { promptSelectMany, promptSelectOne } from "./ui.ts";
40
- import { discoverAgents } from "./agents.ts";
41
-
42
- const AUTO_THINKING = "__auto_thinking__";
43
-
44
- function actualAgentThinkingDefault(
45
- ctx: ExtensionCommandContext,
46
- config: SubagentsConfig,
47
- agentName: string,
48
- ): ThinkingLevel {
49
- const { agents } = discoverAgents(ctx.cwd, {
50
- scope: config.agentScope,
51
- enabledNames: config.enabledAgents,
52
- projectTrusted: ctx.isProjectTrusted(),
53
- });
54
- return agents.find((agent) => agent.name === agentName)?.thinking ?? DEFAULT_THINKING_LEVEL;
55
- }
56
33
 
57
34
  /** Short, selection-friendly descriptions for the built-in agents. */
58
35
  const MODULE_HINTS: Record<string, string> = {
@@ -121,61 +98,6 @@ async function pickAgentModel(
121
98
  return pickConfiguredModel(ctx, `Model for "${agentName}"?`, currentRef, escNote);
122
99
  }
123
100
 
124
- const THINKING_LEVEL_HINTS: Record<ThinkingLevel, string> = {
125
- off: "no reasoning tokens",
126
- minimal: "minimal reasoning",
127
- low: "light reasoning",
128
- medium: "balanced reasoning",
129
- high: "deep reasoning",
130
- xhigh: "extra-deep reasoning",
131
- max: "strongest reasoning",
132
- };
133
-
134
- function effectiveModelForChoice(
135
- ctx: ExtensionCommandContext,
136
- choice: string,
137
- ): Model<Api> | undefined {
138
- if (choice === CURRENT_MAIN_MODEL) return ctx.model;
139
- return findModelByRef(availableModelsInScope(ctx), choice);
140
- }
141
-
142
- /** Auto is the default. Manual rows are exactly the levels Pi exposes for the
143
- * selected model; unsupported xhigh/max entries never appear. */
144
- async function pickAgentStrength(
145
- ctx: ExtensionCommandContext,
146
- agentName: string,
147
- model: Model<Api> | undefined,
148
- current: ThinkingLevel | undefined,
149
- agentDefault: ThinkingLevel,
150
- escNote = "cancels this setup pass",
151
- ): Promise<ThinkingLevel | typeof AUTO_THINKING | undefined> {
152
- const supported = supportedThinkingLevels(model);
153
- const automatic = resolveThinkingLevel(model, agentDefault);
154
- // No model metadata, or a non-reasoning model whose only valid value is off:
155
- // Auto is already the complete and least surprising choice.
156
- if (supported.length <= 1) return AUTO_THINKING;
157
-
158
- const currentEffective = current ? resolveThinkingLevel(model, current) : undefined;
159
- const modelName = model ? modelRef(model) : "current main model";
160
- const options = [
161
- {
162
- value: AUTO_THINKING,
163
- label: `auto — ${automatic} for ${modelName}${current === undefined ? " (current, recommended)" : " (recommended)"}`,
164
- },
165
- ...supported.map((level) => ({
166
- value: level,
167
- label: `${level} — ${THINKING_LEVEL_HINTS[level]}${current !== undefined && currentEffective === level ? " (current)" : ""}`,
168
- })),
169
- ];
170
- return promptSelectOne(
171
- ctx,
172
- `Thinking for "${agentName}"?`,
173
- `Only levels supported by ${modelName} are shown • Enter selects • Esc ${escNote}`,
174
- options,
175
- current === undefined ? AUTO_THINKING : currentEffective,
176
- ) as Promise<ThinkingLevel | typeof AUTO_THINKING | undefined>;
177
- }
178
-
179
101
  async function pickAgentToConfigure(
180
102
  ctx: ExtensionCommandContext,
181
103
  enabledAgents: readonly string[],
@@ -195,11 +117,10 @@ async function pickAgentToConfigure(
195
117
  interface ConfiguredAgentChoice {
196
118
  name: string;
197
119
  model: string;
198
- strength: ThinkingLevel | typeof AUTO_THINKING;
199
120
  }
200
121
 
201
- /** Configure one agent while preserving the UI back stack: thinking model →
202
- * agent selection. Esc from agent selection ends this configuration pass. */
122
+ /** Configure agents while preserving the UI back stack: model selection returns
123
+ * to the agent picker on Esc; agent-picker Esc ends this configuration pass. */
203
124
  async function configureOneAgent(
204
125
  ctx: ExtensionCommandContext,
205
126
  config: SubagentsConfig,
@@ -207,27 +128,14 @@ async function configureOneAgent(
207
128
  while (true) {
208
129
  const name = await pickAgentToConfigure(ctx, config.enabledAgents);
209
130
  if (name === undefined) return undefined;
210
-
211
- while (true) {
212
- const modelChoice = await pickAgentModel(
213
- ctx,
214
- name,
215
- config.agentModels[name],
216
- "returns to agent selection",
217
- );
218
- if (modelChoice === undefined) break;
219
- const model = effectiveModelForChoice(ctx, modelChoice);
220
- const strength = await pickAgentStrength(
221
- ctx,
222
- name,
223
- model,
224
- config.agentThinkingLevels[name],
225
- actualAgentThinkingDefault(ctx, config, name),
226
- "returns to model selection",
227
- );
228
- if (strength === undefined) continue;
229
- return { name, model: modelChoice, strength };
230
- }
131
+ const model = await pickAgentModel(
132
+ ctx,
133
+ name,
134
+ config.agentModels[name],
135
+ "returns to agent selection",
136
+ );
137
+ if (model === undefined) continue;
138
+ return { name, model };
231
139
  }
232
140
  }
233
141
 
@@ -239,40 +147,6 @@ async function pickInjection(ctx: ExtensionCommandContext, current: boolean): Pr
239
147
  return choice.startsWith("On");
240
148
  }
241
149
 
242
- const IDLE_TIMEOUT_STEPS = [0, 30, 60, 90, 120, 180, 300, 600];
243
-
244
- async function pickCount(
245
- ctx: ExtensionCommandContext,
246
- title: string,
247
- steps: readonly number[],
248
- current: number,
249
- defaultValue: number,
250
- ): Promise<number | undefined> {
251
- const values = [...new Set([...steps, current])].sort((a, b) => a - b);
252
- const options = values.map((value) => {
253
- const tags = [value === current ? "current" : "", value === defaultValue ? "default" : ""]
254
- .filter(Boolean)
255
- .join(", ");
256
- return tags ? `${value} (${tags})` : String(value);
257
- });
258
- const choice = await ctx.ui.select(title, options);
259
- return choice === undefined ? undefined : Number.parseInt(choice, 10);
260
- }
261
-
262
- async function pickScope(ctx: ExtensionCommandContext, current: AgentScope): Promise<AgentScope | undefined> {
263
- const labels: Record<AgentScope, string> = {
264
- user: "user — built-in + ~/.pi/agent/agents (default)",
265
- project: "project — built-in + nearest .pi/agents only",
266
- both: "both — user agents, overridden by project agents",
267
- };
268
- const options = AGENT_SCOPE_VALUES.map((scope) =>
269
- scope === current ? `${labels[scope]} (current)` : labels[scope],
270
- );
271
- const choice = await ctx.ui.select("Which agent directories to discover from?", options);
272
- if (choice === undefined) return undefined;
273
- return AGENT_SCOPE_VALUES.find((scope) => choice.startsWith(scope));
274
- }
275
-
276
150
  function keepAgentEntries<T>(record: Record<string, T>, enabled: readonly string[]): Record<string, T> {
277
151
  const keep = new Set(enabled);
278
152
  return Object.fromEntries(Object.entries(record).filter(([name]) => keep.has(name)));
@@ -291,16 +165,6 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
291
165
 
292
166
  const injection = await pickInjection(ctx, base.proactiveInjection);
293
167
  if (injection === undefined) return false;
294
- const scope = await pickScope(ctx, base.agentScope);
295
- if (scope === undefined) return false;
296
- const idleTimeoutSec = await pickCount(
297
- ctx,
298
- "Idle timeout in seconds? (0 = disabled)",
299
- IDLE_TIMEOUT_STEPS,
300
- base.idleTimeoutSec,
301
- DEFAULT_IDLE_TIMEOUT_SEC,
302
- );
303
- if (idleTimeoutSec === undefined) return false;
304
168
 
305
169
  const next: SubagentsConfig = {
306
170
  enabledAgents: enabled,
@@ -310,49 +174,20 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
310
174
  notifyOnReviewPass: base.notifyOnReviewPass,
311
175
  maxResultLines: base.maxResultLines,
312
176
  proactiveInjection: injection,
313
- agentScope: scope,
314
- idleTimeoutSec,
177
+ agentScope: base.agentScope,
178
+ idleTimeoutSec: base.idleTimeoutSec,
315
179
  };
316
180
  await saveConfig(next, configPath);
317
181
  ctx.ui.notify(`pi-subagents configured with Auto thinking. Saved to ${configPath}`, "info");
318
182
  return true;
319
183
  }
320
184
 
321
- async function updateRuntimeSetting(
322
- ctx: ExtensionCommandContext,
323
- config: SubagentsConfig,
324
- ): Promise<SubagentsConfig | undefined> {
325
- while (true) {
326
- const choice = await ctx.ui.select("Runtime setting", [
327
- "Proactive injection",
328
- "Agent scope",
329
- "Idle timeout",
330
- ]);
331
- if (choice === undefined) return undefined;
332
- const next = { ...config };
333
- if (choice.startsWith("Proactive")) {
334
- const value = await pickInjection(ctx, config.proactiveInjection);
335
- if (value === undefined) continue;
336
- next.proactiveInjection = value;
337
- } else if (choice.startsWith("Agent scope")) {
338
- const value = await pickScope(ctx, config.agentScope);
339
- if (value === undefined) continue;
340
- next.agentScope = value;
341
- } else {
342
- const value = await pickCount(ctx, "Idle timeout in seconds?", IDLE_TIMEOUT_STEPS, config.idleTimeoutSec, DEFAULT_IDLE_TIMEOUT_SEC);
343
- if (value === undefined) continue;
344
- next.idleTimeoutSec = value;
345
- }
346
- return next;
347
- }
348
- }
349
-
350
185
  async function runMenu(ctx: ExtensionCommandContext, configPath: string, config: SubagentsConfig): Promise<void> {
351
186
  while (true) {
352
187
  const choice = await ctx.ui.select("pi-subagents settings", [
353
188
  "Enable/disable agents",
354
- "Configure an agent (model + thinking)",
355
- "Runtime settings",
189
+ "Configure agent models",
190
+ "Proactive injection",
356
191
  "Full re-setup",
357
192
  ]);
358
193
  if (choice === undefined) return;
@@ -395,17 +230,14 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
395
230
  next.agentModels = keepAgentEntries(next.agentModels, enabled);
396
231
  next.agentThinkingLevels = keepAgentEntries(next.agentThinkingLevels, enabled);
397
232
  } else if (choice.startsWith("Configure")) {
398
- // Per-agent loop: thinking Esc returns to that agent's model picker;
399
- // model Esc returns to the agent picker; agent-picker Esc saves completed
400
- // choices and returns to this settings menu.
233
+ // Per-agent loop: model Esc returns to the agent picker; agent-picker
234
+ // Esc saves completed choices and returns to this settings menu.
401
235
  let configuredAny = false;
402
236
  while (true) {
403
237
  const picked = await configureOneAgent(ctx, next);
404
238
  if (!picked) break;
405
239
  configuredAny = true;
406
240
  next.agentModels = applyAgentModelChoice(next.agentModels, picked.name, picked.model);
407
- if (picked.strength === AUTO_THINKING) delete next.agentThinkingLevels[picked.name];
408
- else next.agentThinkingLevels[picked.name] = picked.strength;
409
241
  }
410
242
  if (!configuredAny) continue;
411
243
  await saveConfig(next, configPath);
@@ -413,9 +245,9 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
413
245
  config = next;
414
246
  continue;
415
247
  } else {
416
- const updated = await updateRuntimeSetting(ctx, next);
417
- if (updated === undefined) continue;
418
- next = updated;
248
+ const injection = await pickInjection(ctx, next.proactiveInjection);
249
+ if (injection === undefined) continue;
250
+ next.proactiveInjection = injection;
419
251
  }
420
252
 
421
253
  await saveConfig(next, configPath);
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
+ }