@ferris1225/pi-subagents 4.1.17 → 4.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/package.json +1 -1
- package/src/dispatch.ts +4 -2
- package/src/rpc-run.ts +16 -7
- package/src/session-fork.ts +4 -5
- package/src/spawn.ts +4 -0
- package/src/temp-hygiene.ts +37 -29
- package/src/thread-lifecycle.ts +6 -3
- package/src/worktree.ts +5 -5
package/README.md
CHANGED
|
@@ -205,8 +205,10 @@ subagent_control({ action: "resume", id: 7, objective: "Finish the tests." });
|
|
|
205
205
|
|
|
206
206
|
Threads are durable while work is unfinished: parked sessions, worktree
|
|
207
207
|
checkpoints, and result excerpts live under
|
|
208
|
-
`~/.pi/agent/ferris-pi-subagents/<project>/` (grouped per project;
|
|
209
|
-
|
|
208
|
+
`~/.pi/agent/ferris-pi-subagents/<project>/` (grouped per project; nothing is
|
|
209
|
+
written to the OS temp directory — transient per-run scratch lives in the
|
|
210
|
+
project's `tmp/` and is swept for dead owners on the next load) and are
|
|
211
|
+
restored when pi reloads or restarts — a reload
|
|
210
212
|
interrupts a live run into a restorable checkpoint instead of losing it. A
|
|
211
213
|
thread that completes or fails cleanly drops its durable record, so the
|
|
212
214
|
threads manifest exists only
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.18",
|
|
4
4
|
"description": "A managed sub-agent team for pi: specialized roles, pre-commit documentation sync, retained threads, auto-fix chains, model fallback, and Git worktree isolation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/dispatch.ts
CHANGED
|
@@ -251,6 +251,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
251
251
|
...(request.worktreeId ? { worktreeId: request.worktreeId } : {}),
|
|
252
252
|
});
|
|
253
253
|
const onLive = makeLiveHandler(runId);
|
|
254
|
+
const projectRoot = getProjectRoot(runtime.configPath, request.executionCwd);
|
|
254
255
|
try {
|
|
255
256
|
const result = await runSingleAgentWithMainFallback(
|
|
256
257
|
{
|
|
@@ -266,7 +267,8 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
266
267
|
onLive,
|
|
267
268
|
makeDetails: makeDetails("single", true),
|
|
268
269
|
idleTimeoutMs: stageConfig.idleTimeoutSec * 1000,
|
|
269
|
-
sessionRoot: join(
|
|
270
|
+
sessionRoot: join(projectRoot, "sessions"),
|
|
271
|
+
scratchRoot: join(projectRoot, "tmp"),
|
|
270
272
|
...(stage.session
|
|
271
273
|
? { sessionId: stage.session.sessionId, sessionDir: stage.session.sessionDir, stdinText: task }
|
|
272
274
|
: {}),
|
|
@@ -407,7 +409,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
407
409
|
? { sessionId: gateReview.sessionId, sessionDir: gateReview.sessionDir }
|
|
408
410
|
: undefined;
|
|
409
411
|
if (reviewVerdict(getResultOutput(gateReview)) !== "fail" || !gateSession || !canContinue()) break;
|
|
410
|
-
const fixStage: WorkflowStage = { agent: "reviewer", relation: "fix", status: "pending" };
|
|
412
|
+
const fixStage: WorkflowStage = { agent: "reviewer", relation: "review fix", status: "pending" };
|
|
411
413
|
workflowStages.push(fixStage);
|
|
412
414
|
publishWorkflowStages();
|
|
413
415
|
const fixResult = await launchStep(
|
package/src/rpc-run.ts
CHANGED
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
11
11
|
import { existsSync, readdirSync, unlinkSync, rmdirSync } from "node:fs";
|
|
12
|
-
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
12
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
14
13
|
import { basename, join } from "node:path";
|
|
15
14
|
import { StringDecoder } from "node:string_decoder";
|
|
16
15
|
import type { Message } from "@earendil-works/pi-ai";
|
|
@@ -332,9 +331,11 @@ interface ChildRetryPolicyExtension {
|
|
|
332
331
|
* maxRetries=0. It uses Pi's public extension and pi-ai compatibility APIs, so
|
|
333
332
|
* it works in Node and standalone/Bun builds without touching user settings. */
|
|
334
333
|
export async function writeChildRetryPolicyExtension(
|
|
334
|
+
scratchRoot: string,
|
|
335
335
|
modelRef?: string,
|
|
336
336
|
): Promise<ChildRetryPolicyExtension> {
|
|
337
|
-
|
|
337
|
+
await mkdir(scratchRoot, { recursive: true });
|
|
338
|
+
const dir = await mkdtemp(join(scratchRoot, "pi-subagents-policy-"));
|
|
338
339
|
writeTempOwnerMarker(dir);
|
|
339
340
|
const filePath = join(dir, "no-provider-retries.mjs");
|
|
340
341
|
const slash = modelRef?.indexOf("/") ?? -1;
|
|
@@ -363,8 +364,13 @@ export async function writeChildRetryPolicyExtension(
|
|
|
363
364
|
}
|
|
364
365
|
}
|
|
365
366
|
|
|
366
|
-
async function writePromptToTempFile(
|
|
367
|
-
|
|
367
|
+
async function writePromptToTempFile(
|
|
368
|
+
scratchRoot: string,
|
|
369
|
+
agentName: string,
|
|
370
|
+
prompt: string,
|
|
371
|
+
): Promise<{ dir: string; filePath: string }> {
|
|
372
|
+
await mkdir(scratchRoot, { recursive: true });
|
|
373
|
+
const dir = await mkdtemp(join(scratchRoot, "pi-subagents-"));
|
|
368
374
|
writeTempOwnerMarker(dir);
|
|
369
375
|
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
370
376
|
const filePath = join(dir, `prompt-${safeName}.md`);
|
|
@@ -425,6 +431,9 @@ export interface RunRpcAttemptOptions {
|
|
|
425
431
|
idleTimeoutMs: number;
|
|
426
432
|
sessionDir?: string;
|
|
427
433
|
sessionId?: string;
|
|
434
|
+
/** Transient scratch root (child prompt files, retry-policy extension):
|
|
435
|
+
* the project-scoped tmp directory, never the OS temp directory. */
|
|
436
|
+
scratchRoot: string;
|
|
428
437
|
prompt: string;
|
|
429
438
|
signal?: AbortSignal;
|
|
430
439
|
onLive?: (event: SubagentLiveEvent) => void;
|
|
@@ -454,7 +463,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
454
463
|
let tmpPromptDir: string | null = null;
|
|
455
464
|
let tmpPromptPath: string | null = null;
|
|
456
465
|
if (agent.systemPrompt.trim()) {
|
|
457
|
-
const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
|
|
466
|
+
const tmp = await writePromptToTempFile(options.scratchRoot, agent.name, agent.systemPrompt);
|
|
458
467
|
tmpPromptDir = tmp.dir;
|
|
459
468
|
tmpPromptPath = tmp.filePath;
|
|
460
469
|
args.push("--append-system-prompt", tmpPromptPath);
|
|
@@ -462,7 +471,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
462
471
|
|
|
463
472
|
let retryPolicy: ChildRetryPolicyExtension;
|
|
464
473
|
try {
|
|
465
|
-
retryPolicy = await writeChildRetryPolicyExtension(agent.model);
|
|
474
|
+
retryPolicy = await writeChildRetryPolicyExtension(options.scratchRoot, agent.model);
|
|
466
475
|
args.push("--extension", retryPolicy.filePath);
|
|
467
476
|
} catch (error) {
|
|
468
477
|
if (tmpPromptDir) await rm(tmpPromptDir, { recursive: true, force: true }).catch(() => undefined);
|
package/src/session-fork.ts
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
6
|
-
import { tmpdir } from "node:os";
|
|
7
6
|
import { join } from "node:path";
|
|
8
7
|
|
|
9
8
|
export interface ForkedSession {
|
|
@@ -43,15 +42,15 @@ export async function forkRetainedSession(options: {
|
|
|
43
42
|
targetCwd?: string;
|
|
44
43
|
sessionDir: string;
|
|
45
44
|
sessionId: string;
|
|
46
|
-
/** Parent directory for the cloned branch
|
|
47
|
-
*
|
|
48
|
-
targetRoot
|
|
45
|
+
/** Parent directory for the cloned branch: the project-scoped durable
|
|
46
|
+
* sessions root, so forks never land in the OS temp directory. */
|
|
47
|
+
targetRoot: string;
|
|
49
48
|
}): Promise<ForkedSession> {
|
|
50
49
|
const sourceSessionFile = await findRetainedSessionFile(
|
|
51
50
|
options.sessionDir,
|
|
52
51
|
options.sessionId,
|
|
53
52
|
);
|
|
54
|
-
const root = options.targetRoot
|
|
53
|
+
const root = options.targetRoot;
|
|
55
54
|
await mkdir(root, { recursive: true });
|
|
56
55
|
const sessionDir = await mkdtemp(join(root, "pi-subagent-session-fork-"));
|
|
57
56
|
try {
|
package/src/spawn.ts
CHANGED
|
@@ -367,6 +367,9 @@ export interface RunSingleOptions {
|
|
|
367
367
|
/** Parent directory for a fresh session directory: the project-scoped
|
|
368
368
|
* sessions root, so retained sessions survive reloads and restarts. */
|
|
369
369
|
sessionRoot: string;
|
|
370
|
+
/** Parent directory for per-attempt transient files (child prompt, retry
|
|
371
|
+
* policy): the project-scoped tmp root, so nothing lands in the OS temp. */
|
|
372
|
+
scratchRoot: string;
|
|
370
373
|
/** Initial RPC prompt. Kept under the old name to limit caller churn. */
|
|
371
374
|
stdinText?: string;
|
|
372
375
|
/** Refresh parent-derived tools immediately before every startup retry and
|
|
@@ -440,6 +443,7 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
440
443
|
idleTimeoutMs,
|
|
441
444
|
sessionDir: options.sessionDir,
|
|
442
445
|
sessionId: options.sessionId,
|
|
446
|
+
scratchRoot: options.scratchRoot,
|
|
443
447
|
prompt,
|
|
444
448
|
signal: options.signal,
|
|
445
449
|
onLive: options.onLive,
|
package/src/temp-hygiene.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Temp hygiene: ownership markers and startup sweeps for the
|
|
3
|
-
* extension creates.
|
|
2
|
+
* Temp hygiene: ownership markers and startup sweeps for the transient
|
|
3
|
+
* directories this extension creates.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* load
|
|
9
|
-
*
|
|
5
|
+
* Transient per-run files (child prompt copies, the no-retry policy extension)
|
|
6
|
+
* live under the project's durable tmp directory, never the OS temp dir. Each
|
|
7
|
+
* mkdtemp directory gets an owner marker with the creating pid. At extension
|
|
8
|
+
* load, directories whose owner is dead are removed; unmarked leftovers fall
|
|
9
|
+
* back to an age cap.
|
|
10
10
|
*
|
|
11
11
|
* A live sibling pi instance never loses its directories: `kill(pid, 0)` only
|
|
12
12
|
* reports "no such process" when the pid genuinely does not exist, so a live
|
|
@@ -16,30 +16,19 @@
|
|
|
16
16
|
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
18
|
import { type Dirent, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
19
|
-
import { tmpdir } from "node:os";
|
|
20
19
|
import { join } from "node:path";
|
|
21
20
|
|
|
22
21
|
export const TEMP_OWNER_FILE_NAME = "owner.json";
|
|
23
22
|
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"pi-subagent-session-fork-",
|
|
30
|
-
"pi-subagent-worktree-",
|
|
31
|
-
"pi-subagents-policy-",
|
|
32
|
-
"pi-subagents-",
|
|
33
|
-
] as const;
|
|
23
|
+
/** Transient directories created under `<project>/tmp`: the child prompt
|
|
24
|
+
* copies (`pi-subagents-*`) and the no-retry policy extension
|
|
25
|
+
* (`pi-subagents-policy-*`). Durable sessions and worktrees use the singular
|
|
26
|
+
* `pi-subagent-` prefixes and live in their own roots, never swept here. */
|
|
27
|
+
const TEMP_DIR_PREFIXES = ["pi-subagents-"] as const;
|
|
34
28
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
/** Unmarked directories (legacy leaks) must outlive this age before removal. */
|
|
29
|
+
/** Unmarked directories (crash before the marker write) must outlive this age
|
|
30
|
+
* before removal. */
|
|
39
31
|
export const UNMARKED_TEMP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
40
|
-
/** State-root directories missing from every manifest record (crash between
|
|
41
|
-
* directory creation and the first record persist) after this age. */
|
|
42
|
-
export const UNREFERENCED_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
|
|
43
32
|
|
|
44
33
|
interface TempOwner {
|
|
45
34
|
pid: number;
|
|
@@ -128,10 +117,10 @@ function directoryAgeMs(entry: Dirent, dir: string, now: number): number | undef
|
|
|
128
117
|
}
|
|
129
118
|
}
|
|
130
119
|
|
|
131
|
-
/** Remove
|
|
132
|
-
*
|
|
120
|
+
/** Remove dead-owner and old-unmarked transient directories under `rootDir`.
|
|
121
|
+
* Returns how many directories were removed. */
|
|
133
122
|
export function sweepOrphanTempDirs(
|
|
134
|
-
rootDir: string
|
|
123
|
+
rootDir: string,
|
|
135
124
|
options: SweepOptions = {},
|
|
136
125
|
): number {
|
|
137
126
|
const now = options.now ?? Date.now();
|
|
@@ -146,7 +135,6 @@ export function sweepOrphanTempDirs(
|
|
|
146
135
|
let removed = 0;
|
|
147
136
|
for (const entry of entries) {
|
|
148
137
|
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
149
|
-
if (TEMP_DIR_EXCLUDED_NAMES.has(entry.name)) continue;
|
|
150
138
|
if (!TEMP_DIR_PREFIXES.some((prefix) => entry.name.startsWith(prefix))) continue;
|
|
151
139
|
const path = join(rootDir, entry.name);
|
|
152
140
|
const owner = readTempOwnerMarker(path);
|
|
@@ -164,3 +152,23 @@ export function sweepOrphanTempDirs(
|
|
|
164
152
|
return removed;
|
|
165
153
|
}
|
|
166
154
|
|
|
155
|
+
/** Sweep every project's transient tmp directory under the durable extension
|
|
156
|
+
* root, so crash leftovers from any checkout die on the next load. */
|
|
157
|
+
export function sweepProjectTempDirs(
|
|
158
|
+
durableRoot: string,
|
|
159
|
+
options: SweepOptions = {},
|
|
160
|
+
): number {
|
|
161
|
+
let projects: Dirent[];
|
|
162
|
+
try {
|
|
163
|
+
projects = readdirSync(durableRoot, { withFileTypes: true });
|
|
164
|
+
} catch {
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
let removed = 0;
|
|
168
|
+
for (const project of projects) {
|
|
169
|
+
if (!project.isDirectory() || project.isSymbolicLink()) continue;
|
|
170
|
+
removed += sweepOrphanTempDirs(join(durableRoot, project.name, "tmp"), options);
|
|
171
|
+
}
|
|
172
|
+
return removed;
|
|
173
|
+
}
|
|
174
|
+
|
package/src/thread-lifecycle.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
12
12
|
import { existsSync } from "node:fs";
|
|
13
13
|
import { rm } from "node:fs/promises";
|
|
14
14
|
import { realpath } from "node:fs/promises";
|
|
15
|
-
import { join, resolve } from "node:path";
|
|
15
|
+
import { join, resolve, dirname } from "node:path";
|
|
16
16
|
import {
|
|
17
17
|
discoverAgents,
|
|
18
18
|
isWriteCapableAgent,
|
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
buildResumePrompt,
|
|
69
69
|
getProjectRoot,
|
|
70
70
|
getResultOutput,
|
|
71
|
+
PROJECT_ROOTS_DIR_NAME,
|
|
71
72
|
RpcRunControl,
|
|
72
73
|
isFailedResult,
|
|
73
74
|
isModelLevelFailure,
|
|
@@ -81,7 +82,7 @@ import {
|
|
|
81
82
|
import {
|
|
82
83
|
isProcessAlive,
|
|
83
84
|
killProcessTree,
|
|
84
|
-
|
|
85
|
+
sweepProjectTempDirs,
|
|
85
86
|
} from "./temp-hygiene.ts";
|
|
86
87
|
import {
|
|
87
88
|
createWorktreeIsolation,
|
|
@@ -418,6 +419,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
418
419
|
const projectRoot = getProjectRoot(runtime.configPath, originalCwd);
|
|
419
420
|
const sessionsRoot = join(projectRoot, "sessions");
|
|
420
421
|
const worktreesRoot = join(projectRoot, "worktrees");
|
|
422
|
+
const scratchRoot = join(projectRoot, "tmp");
|
|
421
423
|
const previousWorktree = existingThread?.worktree;
|
|
422
424
|
let worktree = seed?.worktree ?? previousWorktree;
|
|
423
425
|
if (isolation === "worktree") {
|
|
@@ -586,6 +588,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
586
588
|
makeDetails: makeDetails("single", true),
|
|
587
589
|
idleTimeoutMs: activeIdleTimeoutMs,
|
|
588
590
|
sessionRoot: sessionsRoot,
|
|
591
|
+
scratchRoot,
|
|
589
592
|
...(priorSessionId && priorSessionDir
|
|
590
593
|
? {
|
|
591
594
|
sessionId: priorSessionId,
|
|
@@ -1384,7 +1387,7 @@ export async function bootstrapDurableState(runtime: SubagentRuntime): Promise<v
|
|
|
1384
1387
|
/* retention is best-effort */
|
|
1385
1388
|
}
|
|
1386
1389
|
try {
|
|
1387
|
-
|
|
1390
|
+
sweepProjectTempDirs(join(dirname(runtime.configPath), PROJECT_ROOTS_DIR_NAME));
|
|
1388
1391
|
} catch {
|
|
1389
1392
|
/* temp hygiene is best-effort */
|
|
1390
1393
|
}
|
package/src/worktree.ts
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
12
12
|
import { existsSync } from "node:fs";
|
|
13
13
|
import { copyFile, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
14
|
-
import { tmpdir } from "node:os";
|
|
15
14
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
16
15
|
|
|
17
16
|
export type IsolationMode = "shared" | "worktree";
|
|
@@ -216,8 +215,9 @@ export interface WorktreeSnapshot {
|
|
|
216
215
|
|
|
217
216
|
export interface WorktreeCreateOptions {
|
|
218
217
|
runner?: CommandRunner;
|
|
219
|
-
/**
|
|
220
|
-
|
|
218
|
+
/** Parent directory for the worktree group: the project-scoped durable
|
|
219
|
+
* worktrees root, so isolation never lands in the OS temp directory. */
|
|
220
|
+
tempBaseDir: string;
|
|
221
221
|
/** Complete source generation checkpoint merged onto the current HEAD. */
|
|
222
222
|
seedCheckpoint?: WorktreeCheckpoint;
|
|
223
223
|
/** The seed is already present in the parent checkout, so only later edits
|
|
@@ -672,11 +672,11 @@ class GitWorktreeIsolation implements WorktreeIsolation {
|
|
|
672
672
|
*/
|
|
673
673
|
export async function createWorktreeIsolation(
|
|
674
674
|
cwd: string,
|
|
675
|
-
options: WorktreeCreateOptions
|
|
675
|
+
options: WorktreeCreateOptions,
|
|
676
676
|
): Promise<WorktreeIsolation> {
|
|
677
677
|
const runner = options.runner ?? runCommand;
|
|
678
678
|
const target = await resolveWorktreeTarget(cwd, runner);
|
|
679
|
-
const tempBase =
|
|
679
|
+
const tempBase = resolve(options.tempBaseDir);
|
|
680
680
|
await mkdir(tempBase, { recursive: true });
|
|
681
681
|
const tempDir = await mkdtemp(join(tempBase, "pi-subagent-worktree-"));
|
|
682
682
|
const worktreePath = join(tempDir, "worktree");
|