@ferris1225/pi-subagents 4.1.16 → 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 +8 -4
- package/package.json +9 -9
- package/src/announcements.ts +16 -0
- package/src/dispatch.ts +4 -2
- package/src/rpc-run.ts +993 -962
- 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/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");
|