@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/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 the runs worth reporting active ones plus the runs
31
- * that settled during this turn or undefined when there is nothing to say
32
- * and the status entry should disappear instead of showing zeros. */
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"
@@ -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
- monitor.removeRun(runId);
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.