@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/README.md +82 -51
- package/agents/cleaner.md +13 -14
- package/agents/documenter.md +10 -17
- package/agents/explorer.md +6 -16
- package/agents/reviewer.md +28 -29
- package/agents/worker.md +14 -33
- package/package.json +1 -1
- package/src/announcements.ts +8 -0
- package/src/background.ts +21 -3
- package/src/dispatch.ts +721 -746
- package/src/durable.ts +336 -0
- package/src/fixloop.ts +30 -34
- package/src/format.ts +1 -8
- package/src/index.ts +7 -0
- package/src/monitor.ts +28 -29
- package/src/prompt.ts +4 -4
- package/src/rpc-run.ts +22 -228
- package/src/runtime.ts +69 -44
- package/src/session-fork.ts +7 -2
- 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/worktree.ts +144 -4
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
|
+
}
|