@ferris1225/pi-subagents 4.1.7 → 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 +94 -65
- 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 +30 -67
- package/src/background.ts +25 -12
- package/src/config.ts +9 -170
- package/src/dispatch.ts +721 -747
- package/src/durable.ts +336 -0
- package/src/fixloop.ts +37 -37
- package/src/format.ts +1 -8
- package/src/index.ts +8 -1
- package/src/models.ts +16 -0
- package/src/monitor.ts +28 -29
- package/src/prompt.ts +7 -8
- package/src/rpc-run.ts +22 -228
- package/src/runtime.ts +72 -50
- package/src/session-fork.ts +7 -2
- package/src/setup.ts +0 -41
- package/src/spawn.ts +32 -29
- package/src/temp-hygiene.ts +194 -0
- package/src/thread-lifecycle.ts +1410 -1327
- package/src/tools.ts +21 -108
- package/src/widget.ts +3 -3
- package/src/worktree.ts +144 -4
|
@@ -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
|
+
}
|