@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/README.md +161 -476
- package/agents/cleaner.md +51 -52
- package/agents/documenter.md +39 -46
- package/agents/explorer.md +40 -50
- package/agents/reviewer.md +29 -31
- package/agents/worker.md +15 -34
- package/package.json +1 -1
- package/src/announcements.ts +8 -0
- package/src/background.ts +23 -5
- package/src/config.ts +4 -2
- package/src/dispatch.ts +645 -746
- package/src/durable.ts +336 -0
- package/src/format.ts +1 -8
- package/src/index.ts +7 -0
- package/src/monitor.ts +29 -30
- package/src/prompt.ts +22 -40
- package/src/rpc-run.ts +22 -228
- package/src/runtime.ts +71 -46
- package/src/session-fork.ts +7 -2
- package/src/setup.ts +25 -193
- package/src/spawn.ts +31 -28
- package/src/temp-hygiene.ts +194 -0
- package/src/thread-lifecycle.ts +1410 -1324
- package/src/tools.ts +21 -108
- package/src/widget.ts +3 -3
- package/src/workflow.ts +248 -0
- package/src/worktree.ts +144 -4
- package/src/fixloop.ts +0 -382
package/src/setup.ts
CHANGED
|
@@ -1,25 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Interactive configuration wizard for /subagents-setup.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
202
|
-
* agent
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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:
|
|
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
|
|
355
|
-
"
|
|
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:
|
|
399
|
-
//
|
|
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
|
|
417
|
-
if (
|
|
418
|
-
next =
|
|
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
|
-
|
|
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?.
|
|
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?.
|
|
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?.
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
+
}
|