@ferris1225/pi-subagents 4.2.8 → 4.2.13
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/CHANGELOG.md +61 -0
- package/LICENSE +23 -23
- package/README.md +60 -4
- package/agents/executor.md +4 -3
- package/agents/explorer.md +37 -37
- package/package.json +11 -2
- package/src/background.ts +205 -205
- package/src/completion.ts +165 -165
- package/src/config.ts +308 -308
- package/src/dispatch.ts +10 -4
- package/src/format.ts +8 -6
- package/src/models.ts +203 -203
- package/src/monitor.ts +3 -2
- package/src/prompt.ts +1 -0
- package/src/recovery.ts +163 -163
- package/src/session-fork.ts +86 -86
- package/src/setup.ts +341 -341
- package/src/spawn.ts +663 -658
- package/src/status.ts +4 -3
- package/src/temp-hygiene.ts +230 -230
- package/src/tools.ts +1 -1
- package/src/ui.ts +248 -248
package/src/status.ts
CHANGED
|
@@ -27,10 +27,11 @@ const SEGMENT_ORDER = ["running", "interrupting", "starting", "queued", "repo la
|
|
|
27
27
|
|
|
28
28
|
type StatusContext = Pick<ExtensionContext, "hasUI" | "ui">;
|
|
29
29
|
|
|
30
|
-
/** One-line roll-up of
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
/** One-line roll-up of live runs plus anything that settled during this turn.
|
|
31
|
+
* Returns undefined when nothing is still active — a done-only leftover must
|
|
32
|
+
* not keep the footer up after the last sibling finishes. */
|
|
33
33
|
export function formatRunStatusLine(runs: readonly RunView[]): string | undefined {
|
|
34
|
+
if (!runs.some((run) => isRunActiveStatus(run.status))) return undefined;
|
|
34
35
|
const counts = new Map<string, number>();
|
|
35
36
|
for (const run of runs) {
|
|
36
37
|
const word = run.status === "queued"
|
package/src/temp-hygiene.ts
CHANGED
|
@@ -1,230 +1,230 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Temp hygiene: ownership markers and startup sweeps for the directories this
|
|
3
|
-
* extension creates under a project's durable root.
|
|
4
|
-
*
|
|
5
|
-
* Two classes live there and both are swept the same way. Transient per-run
|
|
6
|
-
* files (child prompt copies, the no-retry policy extension) sit in `tmp/`;
|
|
7
|
-
* retained child sessions and isolated worktrees sit in `sessions/` and
|
|
8
|
-
* `worktrees/`, where they must outlive the process that made them so a reload
|
|
9
|
-
* can resume from them. Every mkdtemp directory gets an owner marker with the
|
|
10
|
-
* creating pid. At extension load, directories whose owner is dead are removed;
|
|
11
|
-
* unmarked leftovers fall back to an age cap.
|
|
12
|
-
*
|
|
13
|
-
* Ownership is what makes this safe for the durable class. A retained session
|
|
14
|
-
* that no manifest record claims — a settled thread still resumable in the
|
|
15
|
-
* session that produced it — belongs to a live owner and survives; the same
|
|
16
|
-
* directory left behind by a crash does not. Callers sweeping durable roots
|
|
17
|
-
* additionally pass the paths their manifest still references, so parked work is
|
|
18
|
-
* never removed even if its owner is long gone.
|
|
19
|
-
*
|
|
20
|
-
* A live sibling pi instance never loses its directories: `kill(pid, 0)` only
|
|
21
|
-
* reports "no such process" when the pid genuinely does not exist, so a live
|
|
22
|
-
* owner always survives the sweep. Pid reuse merely delays cleanup until the
|
|
23
|
-
* reusing process exits or the age cap catches the directory.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { spawn } from "node:child_process";
|
|
27
|
-
import { type Dirent, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
28
|
-
import { join } from "node:path";
|
|
29
|
-
|
|
30
|
-
export const TEMP_OWNER_FILE_NAME = "owner.json";
|
|
31
|
-
|
|
32
|
-
/** Transient directories created under `<project>/tmp`: the child prompt
|
|
33
|
-
* copies (`pi-subagents-*`) and the no-retry policy extension
|
|
34
|
-
* (`pi-subagents-policy-*`). The singular `pi-subagent-` prefixes below belong
|
|
35
|
-
* to the durable roots and are swept separately, under a reference guard. */
|
|
36
|
-
const TEMP_DIR_PREFIXES = ["pi-subagents-"] as const;
|
|
37
|
-
|
|
38
|
-
/** Durable directories created under `<project>/sessions` and
|
|
39
|
-
* `<project>/worktrees`: retained child sessions (including resume forks) and
|
|
40
|
-
* isolated worktree groups. */
|
|
41
|
-
const DURABLE_DIR_PREFIXES = ["pi-subagent-session-", "pi-subagent-worktree-"] as const;
|
|
42
|
-
|
|
43
|
-
/** Durable subdirectories of a project root that hold owner-marked state. */
|
|
44
|
-
const DURABLE_SUBDIRS = ["sessions", "worktrees"] as const;
|
|
45
|
-
|
|
46
|
-
/** Unmarked directories (crash before the marker write) must outlive this age
|
|
47
|
-
* before removal. */
|
|
48
|
-
export const UNMARKED_TEMP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
49
|
-
|
|
50
|
-
interface TempOwner {
|
|
51
|
-
pid: number;
|
|
52
|
-
createdAt: number;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function isProcessAlive(pid: number): boolean {
|
|
56
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
57
|
-
try {
|
|
58
|
-
process.kill(pid, 0);
|
|
59
|
-
return true;
|
|
60
|
-
} catch (error) {
|
|
61
|
-
// EPERM means the process exists but belongs to another user.
|
|
62
|
-
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Best-effort marker write; a missing marker only delays cleanup. */
|
|
67
|
-
export function writeTempOwnerMarker(dir: string, now = Date.now()): void {
|
|
68
|
-
try {
|
|
69
|
-
writeFileSync(
|
|
70
|
-
join(dir, TEMP_OWNER_FILE_NAME),
|
|
71
|
-
`${JSON.stringify({ pid: process.pid, createdAt: now } satisfies TempOwner)}\n`,
|
|
72
|
-
"utf8",
|
|
73
|
-
);
|
|
74
|
-
} catch {
|
|
75
|
-
/* marker failures must never break the creating operation */
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function readTempOwnerMarker(dir: string): TempOwner | undefined {
|
|
80
|
-
try {
|
|
81
|
-
const parsed = JSON.parse(readFileSync(join(dir, TEMP_OWNER_FILE_NAME), "utf8")) as Partial<TempOwner>;
|
|
82
|
-
if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) return undefined;
|
|
83
|
-
return { pid: parsed.pid, createdAt: typeof parsed.createdAt === "number" ? parsed.createdAt : 0 };
|
|
84
|
-
} catch {
|
|
85
|
-
return undefined;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Terminate a whole process tree without waiting. Used on restore for child
|
|
90
|
-
* processes orphaned by a reload or crash that still hold a retained session. */
|
|
91
|
-
export function killProcessTree(pid: number): void {
|
|
92
|
-
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
93
|
-
if (process.platform === "win32") {
|
|
94
|
-
try {
|
|
95
|
-
spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
|
|
96
|
-
stdio: "ignore",
|
|
97
|
-
windowsHide: true,
|
|
98
|
-
}).once("error", () => undefined);
|
|
99
|
-
} catch {
|
|
100
|
-
/* the process is gone */
|
|
101
|
-
}
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
try {
|
|
105
|
-
process.kill(pid, "SIGKILL");
|
|
106
|
-
} catch {
|
|
107
|
-
/* the process is gone */
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export interface SweepOptions {
|
|
112
|
-
now?: number;
|
|
113
|
-
/** Injectable pid liveness probe for tests. */
|
|
114
|
-
isAlive?: (pid: number) => boolean;
|
|
115
|
-
/** Override the unmarked-directory age cap for tests. */
|
|
116
|
-
unmarkedMaxAgeMs?: number;
|
|
117
|
-
/** Directory-name prefixes this sweep owns; defaults to the transient set. */
|
|
118
|
-
prefixes?: readonly string[];
|
|
119
|
-
/** Directories a durable record still claims. Kept whatever their owner. */
|
|
120
|
-
keep?: (path: string) => boolean;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function removeDir(path: string): boolean {
|
|
124
|
-
try {
|
|
125
|
-
rmSync(path, { recursive: true, force: true });
|
|
126
|
-
return true;
|
|
127
|
-
} catch {
|
|
128
|
-
// Windows locks (antivirus, indexer) leave the directory for a later sweep.
|
|
129
|
-
return false;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function directoryAgeMs(entry: Dirent, dir: string, now: number): number | undefined {
|
|
134
|
-
try {
|
|
135
|
-
return now - statSync(join(dir, entry.name)).mtimeMs;
|
|
136
|
-
} catch {
|
|
137
|
-
return undefined;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Remove dead-owner and old-unmarked directories under `rootDir`.
|
|
142
|
-
* Returns how many directories were removed. */
|
|
143
|
-
export function sweepOrphanTempDirs(
|
|
144
|
-
rootDir: string,
|
|
145
|
-
options: SweepOptions = {},
|
|
146
|
-
): number {
|
|
147
|
-
const now = options.now ?? Date.now();
|
|
148
|
-
const isAlive = options.isAlive ?? isProcessAlive;
|
|
149
|
-
const unmarkedMaxAgeMs = options.unmarkedMaxAgeMs ?? UNMARKED_TEMP_MAX_AGE_MS;
|
|
150
|
-
const prefixes = options.prefixes ?? TEMP_DIR_PREFIXES;
|
|
151
|
-
let entries: Dirent[];
|
|
152
|
-
try {
|
|
153
|
-
entries = readdirSync(rootDir, { withFileTypes: true });
|
|
154
|
-
} catch {
|
|
155
|
-
return 0;
|
|
156
|
-
}
|
|
157
|
-
let removed = 0;
|
|
158
|
-
for (const entry of entries) {
|
|
159
|
-
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
160
|
-
if (!prefixes.some((prefix) => entry.name.startsWith(prefix))) continue;
|
|
161
|
-
const path = join(rootDir, entry.name);
|
|
162
|
-
if (options.keep?.(path)) continue;
|
|
163
|
-
const owner = readTempOwnerMarker(path);
|
|
164
|
-
if (owner) {
|
|
165
|
-
// An unmarked fresh sibling race is impossible here: the marker is
|
|
166
|
-
// written immediately after mkdtemp. A marked dir dies only with its
|
|
167
|
-
// owning process.
|
|
168
|
-
if (isAlive(owner.pid)) continue;
|
|
169
|
-
if (removeDir(path)) removed++;
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
const ageMs = directoryAgeMs(entry, rootDir, now);
|
|
173
|
-
if (ageMs !== undefined && ageMs > unmarkedMaxAgeMs && removeDir(path)) removed++;
|
|
174
|
-
}
|
|
175
|
-
return removed;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Sweep every project's transient tmp directory under the durable extension
|
|
179
|
-
* root, so crash leftovers from any checkout die on the next load. */
|
|
180
|
-
export function sweepProjectTempDirs(
|
|
181
|
-
durableRoot: string,
|
|
182
|
-
options: SweepOptions = {},
|
|
183
|
-
): number {
|
|
184
|
-
let projects: Dirent[];
|
|
185
|
-
try {
|
|
186
|
-
projects = readdirSync(durableRoot, { withFileTypes: true });
|
|
187
|
-
} catch {
|
|
188
|
-
return 0;
|
|
189
|
-
}
|
|
190
|
-
let removed = 0;
|
|
191
|
-
for (const project of projects) {
|
|
192
|
-
if (!project.isDirectory() || project.isSymbolicLink()) continue;
|
|
193
|
-
removed += sweepOrphanTempDirs(join(durableRoot, project.name, "tmp"), options);
|
|
194
|
-
}
|
|
195
|
-
return removed;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Sweep every project's retained sessions and isolated worktrees whose owning
|
|
199
|
-
* process is gone and which no durable record claims. Without this, a crash
|
|
200
|
-
* leaves a full worktree checkout and its session behind until the whole project
|
|
201
|
-
* goes idle for days — which never happens in a checkout still being worked in.
|
|
202
|
-
*
|
|
203
|
-
* `keep` must report every path the threads manifest still references; parked
|
|
204
|
-
* work outlives its owner by design. Removal is a plain recursive delete, the
|
|
205
|
-
* same as the idle-project rule: an abandoned worktree may leave a prunable
|
|
206
|
-
* registration in its origin repository, which `git worktree prune` and routine
|
|
207
|
-
* gc clear on their own. */
|
|
208
|
-
export function sweepProjectDurableDirs(
|
|
209
|
-
durableRoot: string,
|
|
210
|
-
options: SweepOptions = {},
|
|
211
|
-
): number {
|
|
212
|
-
let projects: Dirent[];
|
|
213
|
-
try {
|
|
214
|
-
projects = readdirSync(durableRoot, { withFileTypes: true });
|
|
215
|
-
} catch {
|
|
216
|
-
return 0;
|
|
217
|
-
}
|
|
218
|
-
let removed = 0;
|
|
219
|
-
for (const project of projects) {
|
|
220
|
-
if (!project.isDirectory() || project.isSymbolicLink()) continue;
|
|
221
|
-
for (const subdir of DURABLE_SUBDIRS) {
|
|
222
|
-
removed += sweepOrphanTempDirs(join(durableRoot, project.name, subdir), {
|
|
223
|
-
...options,
|
|
224
|
-
prefixes: options.prefixes ?? DURABLE_DIR_PREFIXES,
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
return removed;
|
|
229
|
-
}
|
|
230
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Temp hygiene: ownership markers and startup sweeps for the directories this
|
|
3
|
+
* extension creates under a project's durable root.
|
|
4
|
+
*
|
|
5
|
+
* Two classes live there and both are swept the same way. Transient per-run
|
|
6
|
+
* files (child prompt copies, the no-retry policy extension) sit in `tmp/`;
|
|
7
|
+
* retained child sessions and isolated worktrees sit in `sessions/` and
|
|
8
|
+
* `worktrees/`, where they must outlive the process that made them so a reload
|
|
9
|
+
* can resume from them. Every mkdtemp directory gets an owner marker with the
|
|
10
|
+
* creating pid. At extension load, directories whose owner is dead are removed;
|
|
11
|
+
* unmarked leftovers fall back to an age cap.
|
|
12
|
+
*
|
|
13
|
+
* Ownership is what makes this safe for the durable class. A retained session
|
|
14
|
+
* that no manifest record claims — a settled thread still resumable in the
|
|
15
|
+
* session that produced it — belongs to a live owner and survives; the same
|
|
16
|
+
* directory left behind by a crash does not. Callers sweeping durable roots
|
|
17
|
+
* additionally pass the paths their manifest still references, so parked work is
|
|
18
|
+
* never removed even if its owner is long gone.
|
|
19
|
+
*
|
|
20
|
+
* A live sibling pi instance never loses its directories: `kill(pid, 0)` only
|
|
21
|
+
* reports "no such process" when the pid genuinely does not exist, so a live
|
|
22
|
+
* owner always survives the sweep. Pid reuse merely delays cleanup until the
|
|
23
|
+
* reusing process exits or the age cap catches the directory.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { spawn } from "node:child_process";
|
|
27
|
+
import { type Dirent, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
|
|
30
|
+
export const TEMP_OWNER_FILE_NAME = "owner.json";
|
|
31
|
+
|
|
32
|
+
/** Transient directories created under `<project>/tmp`: the child prompt
|
|
33
|
+
* copies (`pi-subagents-*`) and the no-retry policy extension
|
|
34
|
+
* (`pi-subagents-policy-*`). The singular `pi-subagent-` prefixes below belong
|
|
35
|
+
* to the durable roots and are swept separately, under a reference guard. */
|
|
36
|
+
const TEMP_DIR_PREFIXES = ["pi-subagents-"] as const;
|
|
37
|
+
|
|
38
|
+
/** Durable directories created under `<project>/sessions` and
|
|
39
|
+
* `<project>/worktrees`: retained child sessions (including resume forks) and
|
|
40
|
+
* isolated worktree groups. */
|
|
41
|
+
const DURABLE_DIR_PREFIXES = ["pi-subagent-session-", "pi-subagent-worktree-"] as const;
|
|
42
|
+
|
|
43
|
+
/** Durable subdirectories of a project root that hold owner-marked state. */
|
|
44
|
+
const DURABLE_SUBDIRS = ["sessions", "worktrees"] as const;
|
|
45
|
+
|
|
46
|
+
/** Unmarked directories (crash before the marker write) must outlive this age
|
|
47
|
+
* before removal. */
|
|
48
|
+
export const UNMARKED_TEMP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
49
|
+
|
|
50
|
+
interface TempOwner {
|
|
51
|
+
pid: number;
|
|
52
|
+
createdAt: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isProcessAlive(pid: number): boolean {
|
|
56
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
57
|
+
try {
|
|
58
|
+
process.kill(pid, 0);
|
|
59
|
+
return true;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
// EPERM means the process exists but belongs to another user.
|
|
62
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Best-effort marker write; a missing marker only delays cleanup. */
|
|
67
|
+
export function writeTempOwnerMarker(dir: string, now = Date.now()): void {
|
|
68
|
+
try {
|
|
69
|
+
writeFileSync(
|
|
70
|
+
join(dir, TEMP_OWNER_FILE_NAME),
|
|
71
|
+
`${JSON.stringify({ pid: process.pid, createdAt: now } satisfies TempOwner)}\n`,
|
|
72
|
+
"utf8",
|
|
73
|
+
);
|
|
74
|
+
} catch {
|
|
75
|
+
/* marker failures must never break the creating operation */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readTempOwnerMarker(dir: string): TempOwner | undefined {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(readFileSync(join(dir, TEMP_OWNER_FILE_NAME), "utf8")) as Partial<TempOwner>;
|
|
82
|
+
if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) return undefined;
|
|
83
|
+
return { pid: parsed.pid, createdAt: typeof parsed.createdAt === "number" ? parsed.createdAt : 0 };
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Terminate a whole process tree without waiting. Used on restore for child
|
|
90
|
+
* processes orphaned by a reload or crash that still hold a retained session. */
|
|
91
|
+
export function killProcessTree(pid: number): void {
|
|
92
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
93
|
+
if (process.platform === "win32") {
|
|
94
|
+
try {
|
|
95
|
+
spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
|
|
96
|
+
stdio: "ignore",
|
|
97
|
+
windowsHide: true,
|
|
98
|
+
}).once("error", () => undefined);
|
|
99
|
+
} catch {
|
|
100
|
+
/* the process is gone */
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, "SIGKILL");
|
|
106
|
+
} catch {
|
|
107
|
+
/* the process is gone */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface SweepOptions {
|
|
112
|
+
now?: number;
|
|
113
|
+
/** Injectable pid liveness probe for tests. */
|
|
114
|
+
isAlive?: (pid: number) => boolean;
|
|
115
|
+
/** Override the unmarked-directory age cap for tests. */
|
|
116
|
+
unmarkedMaxAgeMs?: number;
|
|
117
|
+
/** Directory-name prefixes this sweep owns; defaults to the transient set. */
|
|
118
|
+
prefixes?: readonly string[];
|
|
119
|
+
/** Directories a durable record still claims. Kept whatever their owner. */
|
|
120
|
+
keep?: (path: string) => boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function removeDir(path: string): boolean {
|
|
124
|
+
try {
|
|
125
|
+
rmSync(path, { recursive: true, force: true });
|
|
126
|
+
return true;
|
|
127
|
+
} catch {
|
|
128
|
+
// Windows locks (antivirus, indexer) leave the directory for a later sweep.
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function directoryAgeMs(entry: Dirent, dir: string, now: number): number | undefined {
|
|
134
|
+
try {
|
|
135
|
+
return now - statSync(join(dir, entry.name)).mtimeMs;
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Remove dead-owner and old-unmarked directories under `rootDir`.
|
|
142
|
+
* Returns how many directories were removed. */
|
|
143
|
+
export function sweepOrphanTempDirs(
|
|
144
|
+
rootDir: string,
|
|
145
|
+
options: SweepOptions = {},
|
|
146
|
+
): number {
|
|
147
|
+
const now = options.now ?? Date.now();
|
|
148
|
+
const isAlive = options.isAlive ?? isProcessAlive;
|
|
149
|
+
const unmarkedMaxAgeMs = options.unmarkedMaxAgeMs ?? UNMARKED_TEMP_MAX_AGE_MS;
|
|
150
|
+
const prefixes = options.prefixes ?? TEMP_DIR_PREFIXES;
|
|
151
|
+
let entries: Dirent[];
|
|
152
|
+
try {
|
|
153
|
+
entries = readdirSync(rootDir, { withFileTypes: true });
|
|
154
|
+
} catch {
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
let removed = 0;
|
|
158
|
+
for (const entry of entries) {
|
|
159
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
160
|
+
if (!prefixes.some((prefix) => entry.name.startsWith(prefix))) continue;
|
|
161
|
+
const path = join(rootDir, entry.name);
|
|
162
|
+
if (options.keep?.(path)) continue;
|
|
163
|
+
const owner = readTempOwnerMarker(path);
|
|
164
|
+
if (owner) {
|
|
165
|
+
// An unmarked fresh sibling race is impossible here: the marker is
|
|
166
|
+
// written immediately after mkdtemp. A marked dir dies only with its
|
|
167
|
+
// owning process.
|
|
168
|
+
if (isAlive(owner.pid)) continue;
|
|
169
|
+
if (removeDir(path)) removed++;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const ageMs = directoryAgeMs(entry, rootDir, now);
|
|
173
|
+
if (ageMs !== undefined && ageMs > unmarkedMaxAgeMs && removeDir(path)) removed++;
|
|
174
|
+
}
|
|
175
|
+
return removed;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Sweep every project's transient tmp directory under the durable extension
|
|
179
|
+
* root, so crash leftovers from any checkout die on the next load. */
|
|
180
|
+
export function sweepProjectTempDirs(
|
|
181
|
+
durableRoot: string,
|
|
182
|
+
options: SweepOptions = {},
|
|
183
|
+
): number {
|
|
184
|
+
let projects: Dirent[];
|
|
185
|
+
try {
|
|
186
|
+
projects = readdirSync(durableRoot, { withFileTypes: true });
|
|
187
|
+
} catch {
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
let removed = 0;
|
|
191
|
+
for (const project of projects) {
|
|
192
|
+
if (!project.isDirectory() || project.isSymbolicLink()) continue;
|
|
193
|
+
removed += sweepOrphanTempDirs(join(durableRoot, project.name, "tmp"), options);
|
|
194
|
+
}
|
|
195
|
+
return removed;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Sweep every project's retained sessions and isolated worktrees whose owning
|
|
199
|
+
* process is gone and which no durable record claims. Without this, a crash
|
|
200
|
+
* leaves a full worktree checkout and its session behind until the whole project
|
|
201
|
+
* goes idle for days — which never happens in a checkout still being worked in.
|
|
202
|
+
*
|
|
203
|
+
* `keep` must report every path the threads manifest still references; parked
|
|
204
|
+
* work outlives its owner by design. Removal is a plain recursive delete, the
|
|
205
|
+
* same as the idle-project rule: an abandoned worktree may leave a prunable
|
|
206
|
+
* registration in its origin repository, which `git worktree prune` and routine
|
|
207
|
+
* gc clear on their own. */
|
|
208
|
+
export function sweepProjectDurableDirs(
|
|
209
|
+
durableRoot: string,
|
|
210
|
+
options: SweepOptions = {},
|
|
211
|
+
): number {
|
|
212
|
+
let projects: Dirent[];
|
|
213
|
+
try {
|
|
214
|
+
projects = readdirSync(durableRoot, { withFileTypes: true });
|
|
215
|
+
} catch {
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
let removed = 0;
|
|
219
|
+
for (const project of projects) {
|
|
220
|
+
if (!project.isDirectory() || project.isSymbolicLink()) continue;
|
|
221
|
+
for (const subdir of DURABLE_SUBDIRS) {
|
|
222
|
+
removed += sweepOrphanTempDirs(join(durableRoot, project.name, subdir), {
|
|
223
|
+
...options,
|
|
224
|
+
prefixes: options.prefixes ?? DURABLE_DIR_PREFIXES,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return removed;
|
|
229
|
+
}
|
|
230
|
+
|
package/src/tools.ts
CHANGED
|
@@ -340,7 +340,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
340
340
|
}
|
|
341
341
|
monitor.setStatus(runId, "failed");
|
|
342
342
|
if (stoppedResult) completionResults.push(stoppedResult);
|
|
343
|
-
|
|
343
|
+
// Leave the terminal row for the footer; beginTurn sweeps it.
|
|
344
344
|
runtime.retireThreadSession(thread);
|
|
345
345
|
// The destructive retire removes the durable record with the session;
|
|
346
346
|
// an id never resurrects after subagent_stop.
|