@indigoai-us/hq-cli 5.167.0 → 5.169.0

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/command-catalog.generated.d.ts +88 -1
  3. package/dist/command-catalog.generated.js +112 -1
  4. package/dist/commands/daemon.d.ts +2 -0
  5. package/dist/commands/daemon.js +79 -22
  6. package/dist/commands/lanes.js +146 -31
  7. package/dist/commands/mesh.js +13 -19
  8. package/dist/commands/outpost-pruner.d.ts +3 -0
  9. package/dist/commands/outpost-pruner.js +125 -0
  10. package/dist/lib/daemon/catalog.d.ts +11 -1
  11. package/dist/lib/daemon/catalog.js +31 -6
  12. package/dist/lib/daemon/install.d.ts +7 -0
  13. package/dist/lib/daemon/install.js +18 -2
  14. package/dist/lib/daemon/legacy.js +6 -1
  15. package/dist/lib/daemon/machine-type.d.ts +4 -1
  16. package/dist/lib/daemon/machine-type.js +29 -9
  17. package/dist/lib/daemon/migrate.js +2 -2
  18. package/dist/lib/daemon/remote-control.d.ts +32 -9
  19. package/dist/lib/daemon/remote-control.js +55 -11
  20. package/dist/lib/daemon/run.d.ts +6 -0
  21. package/dist/lib/daemon/run.js +43 -2
  22. package/dist/lib/daemon/tools.d.ts +52 -0
  23. package/dist/lib/daemon/tools.js +95 -0
  24. package/dist/lib/daemon/updater.d.ts +23 -15
  25. package/dist/lib/daemon/updater.js +97 -60
  26. package/dist/lib/lanes/mesh-events.d.ts +4 -0
  27. package/dist/lib/lanes/mesh-events.js +72 -4
  28. package/dist/lib/lanes/outpost-pruner-install.d.ts +37 -0
  29. package/dist/lib/lanes/outpost-pruner-install.js +226 -0
  30. package/dist/lib/lanes/outpost-worktree-pruner-flag.d.ts +19 -0
  31. package/dist/lib/lanes/outpost-worktree-pruner-flag.js +76 -0
  32. package/dist/lib/lanes/outpost-worktree-pruner.d.ts +60 -0
  33. package/dist/lib/lanes/outpost-worktree-pruner.js +1135 -0
  34. package/dist/lib/mesh/live/session-events-client.js +3 -2
  35. package/dist/lib/mesh/project-board.d.ts +7 -0
  36. package/dist/lib/mesh/project-board.js +29 -1
  37. package/package.json +1 -1
@@ -0,0 +1,1135 @@
1
+ /** Safe, host-local cleanup for terminal HQ worktrees on outposts. */
2
+ import { spawnSync } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ const PRESSURE_THRESHOLD_PERCENT = 78;
7
+ const DEFAULT_MIN_AGE_DAYS = 14;
8
+ const LIVE_STATES = new Set(["running", "queued", "blocked", "awaiting_input", "waiting", "live"]);
9
+ const TERMINAL_STATES = new Set(["done", "failed", "interrupted", "cancelled", "killed", "ended", "lost", "idle"]);
10
+ const REFERENCE_KEYS = /^(cwd|working[_-]?directory|worktree(?:[_-]?path)?|repository[_-]?path|repo[_-]?path)$/i;
11
+ const METADATA_NAMES = new Set(["spawn.json", "session.json", "metadata.json", "state.json"]);
12
+ const THREAD_RECORD_NAME = /^T-[A-Za-z0-9._-]+\.json$/;
13
+ const WORKER_LOG_READ_CHUNK_BYTES = 64 * 1024;
14
+ const WORKER_LOG_CARRY_BYTES = 8 * 1024;
15
+ function command(binary, args, timeoutMs = 15_000) {
16
+ const result = spawnSync(binary, args, {
17
+ encoding: "utf8",
18
+ timeout: timeoutMs,
19
+ stdio: ["ignore", "pipe", "pipe"],
20
+ });
21
+ if (result.error || result.status === null) {
22
+ return { status: -1, stdout: "", stderr: "" };
23
+ }
24
+ return {
25
+ status: result.status,
26
+ stdout: result.stdout ?? "",
27
+ stderr: result.stderr ?? "",
28
+ };
29
+ }
30
+ function git(repo, args, timeoutMs = 15_000) {
31
+ return command("git", ["-C", repo, ...args], timeoutMs);
32
+ }
33
+ function gitReadOnly(repo, args, timeoutMs = 15_000) {
34
+ const result = spawnSync("git", ["-C", repo, ...args], {
35
+ encoding: "utf8",
36
+ timeout: timeoutMs,
37
+ stdio: ["ignore", "pipe", "pipe"],
38
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
39
+ });
40
+ if (result.error || result.status === null)
41
+ return { status: -1, stdout: "", stderr: "" };
42
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
43
+ }
44
+ function isWithin(parent, child) {
45
+ const relative = path.relative(parent, child);
46
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
47
+ }
48
+ function safeRealpath(value) {
49
+ try {
50
+ return fs.realpathSync(value);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ function isPathReferenceKey(key) {
57
+ return REFERENCE_KEYS.test(key);
58
+ }
59
+ function collectReferences(value, hqRoot, out = []) {
60
+ if (!value || typeof value !== "object")
61
+ return out;
62
+ if (Array.isArray(value)) {
63
+ for (const child of value)
64
+ collectReferences(child, hqRoot, out);
65
+ return out;
66
+ }
67
+ for (const [key, child] of Object.entries(value)) {
68
+ if (isPathReferenceKey(key) && typeof child === "string" && child.trim()) {
69
+ const raw = child.trim();
70
+ out.push(path.resolve(path.isAbsolute(raw) ? raw : path.join(hqRoot, raw)));
71
+ }
72
+ else {
73
+ collectReferences(child, hqRoot, out);
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ function objectState(value) {
79
+ for (const key of ["state", "status", "liveness_state", "livenessState", "lifecycle_state"]) {
80
+ const state = value[key];
81
+ if (typeof state === "string")
82
+ return state.toLowerCase();
83
+ }
84
+ return undefined;
85
+ }
86
+ function isLogPathDelimiter(value) {
87
+ return /[\s"'\\;&|<>)\]} ,]/.test(value);
88
+ }
89
+ function worktreePathsInLogWindow(text, hqRoot, worktrees, out, atEof) {
90
+ const absolutePrefix = `${path.resolve(hqRoot, "workspace", "worktrees")}${path.sep}`;
91
+ const prefixes = [
92
+ { value: absolutePrefix, absolute: true },
93
+ { value: "workspace/worktrees/", absolute: false },
94
+ { value: "./workspace/worktrees/", absolute: false },
95
+ ];
96
+ for (const prefix of prefixes) {
97
+ let offset = 0;
98
+ while (offset < text.length) {
99
+ const start = text.indexOf(prefix.value, offset);
100
+ if (start < 0)
101
+ break;
102
+ offset = start + prefix.value.length;
103
+ if (!prefix.absolute && start > 0 && /[A-Za-z0-9_./-]/.test(text[start - 1]))
104
+ continue;
105
+ let end = offset;
106
+ while (end < text.length && !isLogPathDelimiter(text[end]))
107
+ end += 1;
108
+ if (end === text.length && !atEof)
109
+ continue;
110
+ const rawPath = text.slice(start, end);
111
+ const resolved = path.resolve(path.isAbsolute(rawPath) ? rawPath : path.join(hqRoot, rawPath));
112
+ for (const worktree of worktrees) {
113
+ if (isWithin(worktree.path, resolved))
114
+ out.add(worktree.path);
115
+ }
116
+ }
117
+ }
118
+ }
119
+ function scanPathLogFile(filePath, hqRoot, worktrees) {
120
+ let fileStat;
121
+ try {
122
+ fileStat = fs.lstatSync(filePath);
123
+ }
124
+ catch (err) {
125
+ return err.code === "ENOENT"
126
+ ? { status: "missing", worktrees: [] }
127
+ : { status: "failed", worktrees: [] };
128
+ }
129
+ if (!fileStat.isFile() || fileStat.isSymbolicLink())
130
+ return { status: "failed", worktrees: [] };
131
+ let fd;
132
+ try {
133
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
134
+ }
135
+ catch {
136
+ return { status: "failed", worktrees: [] };
137
+ }
138
+ const referencedWorktrees = new Set();
139
+ let bytesRead = 0;
140
+ let tail = Buffer.alloc(0);
141
+ let status;
142
+ try {
143
+ const openedStat = fs.fstatSync(fd);
144
+ if (!openedStat.isFile() || openedStat.dev !== fileStat.dev || openedStat.ino !== fileStat.ino || openedStat.size !== fileStat.size) {
145
+ return { status: "failed", worktrees: [] };
146
+ }
147
+ const chunk = Buffer.allocUnsafe(WORKER_LOG_READ_CHUNK_BYTES);
148
+ while (true) {
149
+ const count = fs.readSync(fd, chunk, 0, chunk.length, bytesRead);
150
+ if (count === 0)
151
+ break;
152
+ bytesRead += count;
153
+ const window = Buffer.concat([tail, chunk.subarray(0, count)]);
154
+ worktreePathsInLogWindow(window.toString("utf8"), hqRoot, worktrees, referencedWorktrees, false);
155
+ tail = Buffer.from(window.subarray(Math.max(0, window.length - WORKER_LOG_CARRY_BYTES)));
156
+ }
157
+ const finalStat = fs.fstatSync(fd);
158
+ let pathStat;
159
+ try {
160
+ pathStat = fs.lstatSync(filePath);
161
+ }
162
+ catch {
163
+ return { status: "failed", worktrees: [] };
164
+ }
165
+ if (!pathStat || pathStat.isSymbolicLink() || finalStat.dev !== openedStat.dev || finalStat.ino !== openedStat.ino ||
166
+ finalStat.size !== bytesRead || finalStat.mtimeMs !== openedStat.mtimeMs ||
167
+ pathStat.dev !== finalStat.dev || pathStat.ino !== finalStat.ino || pathStat.size !== finalStat.size || pathStat.mtimeMs !== finalStat.mtimeMs) {
168
+ return { status: "failed", worktrees: [] };
169
+ }
170
+ worktreePathsInLogWindow(tail.toString("utf8"), hqRoot, worktrees, referencedWorktrees, true);
171
+ status = bytesRead === 0 ? "empty" : "read";
172
+ }
173
+ catch {
174
+ status = "failed";
175
+ }
176
+ finally {
177
+ try {
178
+ fs.closeSync(fd);
179
+ }
180
+ catch {
181
+ status = "failed";
182
+ }
183
+ }
184
+ return { status, worktrees: [...referencedWorktrees] };
185
+ }
186
+ function validLaneId(laneId) {
187
+ return Boolean(laneId) && laneId !== "." && laneId !== ".." && !laneId.includes("/") && !laneId.includes("\\");
188
+ }
189
+ function scanWorkerOutLog(hqRoot, laneId, worktrees) {
190
+ if (!validLaneId(laneId))
191
+ return { status: "failed", worktrees: [] };
192
+ const runRoot = path.join(hqRoot, "workspace", "lanes-runs");
193
+ const runDir = path.join(runRoot, laneId);
194
+ const logsDir = path.join(runDir, "logs");
195
+ const workerOut = path.join(logsDir, "worker.out");
196
+ try {
197
+ if (!isWithin(runRoot, runDir) || path.dirname(runDir) !== runRoot)
198
+ return { status: "failed", worktrees: [] };
199
+ const runStat = fs.lstatSync(runDir);
200
+ if (!runStat.isDirectory() || runStat.isSymbolicLink())
201
+ return { status: "failed", worktrees: [] };
202
+ const logsStat = fs.lstatSync(logsDir);
203
+ if (!logsStat.isDirectory() || logsStat.isSymbolicLink())
204
+ return { status: "failed", worktrees: [] };
205
+ }
206
+ catch (err) {
207
+ if (err.code === "ENOENT")
208
+ return { status: "no_logs_dir", worktrees: [] };
209
+ return { status: "failed", worktrees: [] };
210
+ }
211
+ const scan = scanPathLogFile(workerOut, hqRoot, worktrees);
212
+ if (scan.status === "missing")
213
+ return { status: "missing_worker_out", worktrees: [] };
214
+ return { status: scan.status, worktrees: scan.worktrees };
215
+ }
216
+ function scanClaudeTranscript(sessionId, hqRoot, worktrees) {
217
+ if (!sessionId || sessionId === "." || sessionId === ".." || /[/\\\0]/.test(sessionId)) {
218
+ return { status: "failed", worktrees: [] };
219
+ }
220
+ const home = path.resolve(process.env.HOME?.trim() || os.homedir());
221
+ try {
222
+ const homeStat = fs.lstatSync(home);
223
+ if (!homeStat.isDirectory() || homeStat.isSymbolicLink())
224
+ return { status: "failed", worktrees: [] };
225
+ }
226
+ catch {
227
+ return { status: "failed", worktrees: [] };
228
+ }
229
+ let homeEntries;
230
+ try {
231
+ homeEntries = fs.readdirSync(home, { withFileTypes: true });
232
+ }
233
+ catch {
234
+ return { status: "failed", worktrees: [] };
235
+ }
236
+ const transcripts = [];
237
+ let failed = false;
238
+ for (const claudeEntry of homeEntries) {
239
+ if (!claudeEntry.name.startsWith(".claude"))
240
+ continue;
241
+ if (claudeEntry.isSymbolicLink()) {
242
+ failed = true;
243
+ continue;
244
+ }
245
+ if (!claudeEntry.isDirectory())
246
+ continue;
247
+ const projectsDir = path.join(home, claudeEntry.name, "projects");
248
+ let projectsStat;
249
+ try {
250
+ projectsStat = fs.lstatSync(projectsDir);
251
+ }
252
+ catch (err) {
253
+ if (err.code !== "ENOENT")
254
+ failed = true;
255
+ continue;
256
+ }
257
+ if (!projectsStat.isDirectory() || projectsStat.isSymbolicLink()) {
258
+ failed = true;
259
+ continue;
260
+ }
261
+ let projectEntries;
262
+ try {
263
+ projectEntries = fs.readdirSync(projectsDir, { withFileTypes: true });
264
+ }
265
+ catch (err) {
266
+ if (err.code !== "ENOENT")
267
+ failed = true;
268
+ continue;
269
+ }
270
+ for (const projectEntry of projectEntries) {
271
+ if (projectEntry.isSymbolicLink()) {
272
+ failed = true;
273
+ continue;
274
+ }
275
+ if (!projectEntry.isDirectory())
276
+ continue;
277
+ const transcriptPath = path.join(projectsDir, projectEntry.name, `${sessionId}.jsonl`);
278
+ let transcriptStat;
279
+ try {
280
+ transcriptStat = fs.lstatSync(transcriptPath);
281
+ }
282
+ catch (err) {
283
+ if (err.code !== "ENOENT")
284
+ failed = true;
285
+ continue;
286
+ }
287
+ if (!transcriptStat.isFile() || transcriptStat.isSymbolicLink()) {
288
+ failed = true;
289
+ continue;
290
+ }
291
+ if (!isWithin(home, transcriptPath)) {
292
+ failed = true;
293
+ continue;
294
+ }
295
+ transcripts.push(transcriptPath);
296
+ }
297
+ }
298
+ if (failed)
299
+ return { status: "failed", worktrees: [] };
300
+ if (transcripts.length === 0)
301
+ return { status: "missing", worktrees: [] };
302
+ const referenced = new Set();
303
+ for (const transcript of transcripts) {
304
+ const scan = scanPathLogFile(transcript, hqRoot, worktrees);
305
+ if (scan.status !== "read" && scan.status !== "empty")
306
+ return { status: "failed", worktrees: [] };
307
+ for (const worktree of scan.worktrees)
308
+ referenced.add(worktree);
309
+ }
310
+ return { status: "read", worktrees: [...referenced] };
311
+ }
312
+ function hasSpawnRecord(hqRoot, laneId) {
313
+ if (!validLaneId(laneId))
314
+ return null;
315
+ try {
316
+ fs.lstatSync(path.join(hqRoot, "workspace", "lanes-runs", laneId, "spawn.json"));
317
+ return true;
318
+ }
319
+ catch (err) {
320
+ const code = err.code;
321
+ return code === "ENOENT" ? false : null;
322
+ }
323
+ }
324
+ function createdAtMillis(value) {
325
+ if (typeof value !== "string" || value.trim() === "")
326
+ return null;
327
+ const parsed = Date.parse(value);
328
+ return Number.isFinite(parsed) ? parsed : null;
329
+ }
330
+ function noteLiveLane(record, laneId, state, liveLaneIds, liveLaneStates, liveLaneProviders, liveLaneCreatedAt, liveLaneSessionIds, laneHasProviderSession) {
331
+ liveLaneIds.add(laneId);
332
+ if (!liveLaneStates.has(laneId))
333
+ liveLaneStates.set(laneId, state);
334
+ if (!liveLaneProviders.has(laneId) && typeof record.provider === "string") {
335
+ liveLaneProviders.set(laneId, record.provider.toLowerCase());
336
+ }
337
+ if (!liveLaneCreatedAt.has(laneId))
338
+ liveLaneCreatedAt.set(laneId, createdAtMillis(record.created_at));
339
+ if (!liveLaneSessionIds.has(laneId)) {
340
+ const providerSessionId = record.provider_session_id;
341
+ if (providerSessionId !== null && providerSessionId !== undefined &&
342
+ !(typeof providerSessionId === "string" && providerSessionId.trim() === "")) {
343
+ laneHasProviderSession.add(laneId);
344
+ liveLaneSessionIds.set(laneId, typeof providerSessionId === "string" ? providerSessionId.trim() : null);
345
+ }
346
+ else {
347
+ liveLaneSessionIds.set(laneId, null);
348
+ }
349
+ }
350
+ else {
351
+ const providerSessionId = record.provider_session_id;
352
+ if (providerSessionId !== null && providerSessionId !== undefined &&
353
+ !(typeof providerSessionId === "string" && providerSessionId.trim() === "")) {
354
+ if (!laneHasProviderSession.has(laneId) && typeof providerSessionId === "string") {
355
+ liveLaneSessionIds.set(laneId, providerSessionId.trim());
356
+ }
357
+ laneHasProviderSession.add(laneId);
358
+ }
359
+ }
360
+ }
361
+ function scanMetadataDirectory(root, hqRoot, liveLaneIds, terminalLaneIds, liveLaneStates, liveLaneProviders, liveLaneCreatedAt, liveLaneSessionIds, laneHasProviderSession, unresolvedAnonymousLiveLaneStartedAt, references) {
362
+ let complete = true;
363
+ const visit = (dir) => {
364
+ let entries;
365
+ try {
366
+ entries = fs.readdirSync(dir, { withFileTypes: true });
367
+ }
368
+ catch (err) {
369
+ const code = err.code;
370
+ if (code === "ENOENT")
371
+ return;
372
+ complete = false;
373
+ return;
374
+ }
375
+ for (const entry of entries) {
376
+ if (entry.isSymbolicLink())
377
+ continue;
378
+ const fullPath = path.join(dir, entry.name);
379
+ if (entry.isDirectory()) {
380
+ visit(fullPath);
381
+ continue;
382
+ }
383
+ if (!entry.isFile() || (!METADATA_NAMES.has(entry.name) && !THREAD_RECORD_NAME.test(entry.name)))
384
+ continue;
385
+ // Checkpoint threads have cwd references but no lifecycle state; unknown state is retained below.
386
+ let parsed;
387
+ try {
388
+ parsed = JSON.parse(fs.readFileSync(fullPath, "utf8"));
389
+ }
390
+ catch (err) {
391
+ const code = err.code;
392
+ if (code !== "ENOENT")
393
+ complete = false;
394
+ continue;
395
+ }
396
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
397
+ continue;
398
+ const record = parsed;
399
+ const laneId = typeof record.lane_id === "string" ? record.lane_id : undefined;
400
+ const state = objectState(record);
401
+ const laneKnownTerminal = laneId !== undefined && terminalLaneIds.has(laneId);
402
+ const laneKnownLive = laneId !== undefined && liveLaneIds.has(laneId);
403
+ const stateLive = state !== undefined && LIVE_STATES.has(state);
404
+ const stateTerminal = state !== undefined && TERMINAL_STATES.has(state);
405
+ const stateUnknown = state === undefined;
406
+ const recordReferences = collectReferences(record, hqRoot);
407
+ if (stateLive && laneId) {
408
+ noteLiveLane(record, laneId, state, liveLaneIds, liveLaneStates, liveLaneProviders, liveLaneCreatedAt, liveLaneSessionIds, laneHasProviderSession);
409
+ }
410
+ if (stateLive && !laneId && recordReferences.length === 0) {
411
+ unresolvedAnonymousLiveLaneStartedAt.push(createdAtMillis(record.created_at));
412
+ }
413
+ const live = stateLive || laneKnownLive || (!laneKnownTerminal && !stateTerminal && stateUnknown);
414
+ for (const reference of recordReferences) {
415
+ references.push({ path: reference, laneId, live });
416
+ }
417
+ }
418
+ };
419
+ visit(root);
420
+ return complete;
421
+ }
422
+ function scanLaneReferences(hqRoot, worktrees) {
423
+ const references = [];
424
+ const liveLaneIds = new Set();
425
+ const liveLaneStates = new Map();
426
+ const liveLaneProviders = new Map();
427
+ const liveLaneCreatedAt = new Map();
428
+ const liveLaneSessionIds = new Map();
429
+ const laneHasProviderSession = new Set();
430
+ const unresolvedLiveLaneStartedAt = [];
431
+ const terminalLaneIds = new Set();
432
+ const laneDir = path.join(hqRoot, "workspace", "lanes", "lanes");
433
+ let complete = true;
434
+ let entries = [];
435
+ try {
436
+ entries = fs.readdirSync(laneDir, { withFileTypes: true });
437
+ }
438
+ catch (err) {
439
+ const code = err.code;
440
+ if (code !== "ENOENT")
441
+ complete = false;
442
+ }
443
+ for (const entry of entries) {
444
+ if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(".json"))
445
+ continue;
446
+ const fullPath = path.join(laneDir, entry.name);
447
+ let parsed;
448
+ try {
449
+ parsed = JSON.parse(fs.readFileSync(fullPath, "utf8"));
450
+ }
451
+ catch (err) {
452
+ if (err.code !== "ENOENT")
453
+ complete = false;
454
+ continue;
455
+ }
456
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
457
+ complete = false;
458
+ continue;
459
+ }
460
+ const record = parsed;
461
+ const laneId = typeof record.lane_id === "string" ? record.lane_id : undefined;
462
+ const state = typeof record.state === "string" ? record.state.toLowerCase() : "";
463
+ const live = LIVE_STATES.has(state);
464
+ const terminal = TERMINAL_STATES.has(state);
465
+ if (laneId && live) {
466
+ noteLiveLane(record, laneId, state, liveLaneIds, liveLaneStates, liveLaneProviders, liveLaneCreatedAt, liveLaneSessionIds, laneHasProviderSession);
467
+ }
468
+ if (laneId && terminal)
469
+ terminalLaneIds.add(laneId);
470
+ const recordReferences = collectReferences(record, hqRoot);
471
+ if (live && !laneId && recordReferences.length === 0) {
472
+ unresolvedLiveLaneStartedAt.push(createdAtMillis(record.created_at));
473
+ }
474
+ for (const reference of recordReferences) {
475
+ references.push({ path: reference, laneId, live });
476
+ }
477
+ }
478
+ for (const relative of [path.join("workspace", "lanes-runs"), path.join("workspace", "threads")]) {
479
+ complete = scanMetadataDirectory(path.join(hqRoot, relative), hqRoot, liveLaneIds, terminalLaneIds, liveLaneStates, liveLaneProviders, liveLaneCreatedAt, liveLaneSessionIds, laneHasProviderSession, unresolvedLiveLaneStartedAt, references) && complete;
480
+ }
481
+ for (const laneId of liveLaneIds) {
482
+ const log = scanWorkerOutLog(hqRoot, laneId, worktrees);
483
+ for (const worktree of log.worktrees) {
484
+ references.push({ path: worktree, laneId, live: true, reason: "referenced_by_live_lane_log" });
485
+ }
486
+ if (log.status === "failed") {
487
+ unresolvedLiveLaneStartedAt.push(liveLaneCreatedAt.get(laneId) ?? null);
488
+ continue;
489
+ }
490
+ const provider = liveLaneProviders.get(laneId);
491
+ const sessionId = liveLaneSessionIds.get(laneId);
492
+ if (provider === "claude" && laneHasProviderSession.has(laneId)) {
493
+ if (!sessionId) {
494
+ unresolvedLiveLaneStartedAt.push(liveLaneCreatedAt.get(laneId) ?? null);
495
+ continue;
496
+ }
497
+ const transcript = scanClaudeTranscript(sessionId, hqRoot, worktrees);
498
+ for (const worktree of transcript.worktrees) {
499
+ references.push({ path: worktree, laneId, live: true, reason: "referenced_by_live_lane_log" });
500
+ }
501
+ if (transcript.status === "read")
502
+ continue;
503
+ unresolvedLiveLaneStartedAt.push(liveLaneCreatedAt.get(laneId) ?? null);
504
+ continue;
505
+ }
506
+ if (log.status === "read")
507
+ continue;
508
+ const spawnRecord = hasSpawnRecord(hqRoot, laneId);
509
+ const neverSpawnedQueuedLane = liveLaneStates.get(laneId) === "queued" &&
510
+ !laneHasProviderSession.has(laneId) && spawnRecord === false;
511
+ if ((log.status === "empty" || log.status === "no_logs_dir") && neverSpawnedQueuedLane)
512
+ continue;
513
+ unresolvedLiveLaneStartedAt.push(liveLaneCreatedAt.get(laneId) ?? null);
514
+ }
515
+ return { references, complete, unresolvedLiveLaneStartedAt };
516
+ }
517
+ function currentProcessUserId() {
518
+ return typeof process.getuid === "function" ? process.getuid() : null;
519
+ }
520
+ const defaultProcessPathReader = {
521
+ readdirSync: (entryPath) => fs.readdirSync(entryPath),
522
+ readlinkSync: (entryPath) => fs.readlinkSync(entryPath),
523
+ uid: (entryPath) => fs.statSync(entryPath).uid,
524
+ };
525
+ function scanProcessPaths(procRoot, childName, currentUid = currentProcessUserId(), reader = defaultProcessPathReader) {
526
+ const paths = [];
527
+ let entries;
528
+ try {
529
+ entries = reader.readdirSync(procRoot);
530
+ }
531
+ catch {
532
+ return { paths, complete: false };
533
+ }
534
+ let complete = true;
535
+ for (const pid of entries) {
536
+ if (!/^\d+$/.test(pid))
537
+ continue;
538
+ const processDirectory = path.join(procRoot, pid);
539
+ let ownerUid;
540
+ try {
541
+ ownerUid = reader.uid(processDirectory);
542
+ }
543
+ catch (err) {
544
+ const code = err.code;
545
+ if (code !== "ENOENT" && code !== "ESRCH")
546
+ complete = false;
547
+ continue;
548
+ }
549
+ const base = path.join(processDirectory, childName);
550
+ // An inaccessible foreign UID cannot own this user's worktree; same-user failures stay fail-closed.
551
+ let descriptors;
552
+ if (childName === "fd") {
553
+ try {
554
+ descriptors = reader.readdirSync(base);
555
+ }
556
+ catch (err) {
557
+ const code = err.code;
558
+ if (code !== "ENOENT" && code !== "ESRCH" &&
559
+ !(code === "EACCES" && currentUid !== null && ownerUid !== currentUid))
560
+ complete = false;
561
+ continue;
562
+ }
563
+ }
564
+ else {
565
+ descriptors = [""];
566
+ }
567
+ for (const descriptor of descriptors) {
568
+ const entryPath = descriptor ? path.join(base, descriptor) : base;
569
+ try {
570
+ const target = reader.readlinkSync(entryPath);
571
+ if (path.isAbsolute(target))
572
+ paths.push(path.resolve(target));
573
+ }
574
+ catch (err) {
575
+ const code = err.code;
576
+ if (code !== "ENOENT" && code !== "ESRCH" &&
577
+ !(code === "EACCES" && currentUid !== null && ownerUid !== currentUid))
578
+ complete = false;
579
+ }
580
+ }
581
+ }
582
+ return { paths, complete };
583
+ }
584
+ function newestModificationTime(root) {
585
+ let newest = 0;
586
+ const visit = (entryPath) => {
587
+ const stat = fs.lstatSync(entryPath);
588
+ newest = Math.max(newest, stat.mtimeMs);
589
+ if (!stat.isDirectory())
590
+ return;
591
+ for (const child of fs.readdirSync(entryPath))
592
+ visit(path.join(entryPath, child));
593
+ };
594
+ visit(root);
595
+ return newest;
596
+ }
597
+ function newestLaneGuardModificationTime(candidate) {
598
+ const rootStat = (() => {
599
+ try {
600
+ return fs.lstatSync(candidate.path);
601
+ }
602
+ catch {
603
+ return null;
604
+ }
605
+ })();
606
+ if (!rootStat || !rootStat.isDirectory() || rootStat.isSymbolicLink())
607
+ return null;
608
+ const gitDirResult = git(candidate.path, ["rev-parse", "--absolute-git-dir"]);
609
+ if (gitDirResult.status !== 0)
610
+ return null;
611
+ const rawGitDir = gitDirResult.stdout.trim();
612
+ if (!rawGitDir || !path.isAbsolute(rawGitDir))
613
+ return null;
614
+ const gitDir = path.resolve(rawGitDir);
615
+ let newest = rootStat.mtimeMs;
616
+ for (const name of ["index", "HEAD"]) {
617
+ let stat;
618
+ try {
619
+ stat = fs.lstatSync(path.join(gitDir, name));
620
+ }
621
+ catch {
622
+ return null;
623
+ }
624
+ if (!stat.isFile() || stat.isSymbolicLink())
625
+ return null;
626
+ newest = Math.max(newest, stat.mtimeMs);
627
+ }
628
+ return newest;
629
+ }
630
+ function parseWorktreeList(stdout) {
631
+ const result = [];
632
+ for (const block of stdout.split(/\n\n+/)) {
633
+ let worktreePath = null;
634
+ let branch = null;
635
+ let head = null;
636
+ let detached = false;
637
+ for (const line of block.split("\n")) {
638
+ if (line.startsWith("worktree "))
639
+ worktreePath = line.slice("worktree ".length);
640
+ else if (line.startsWith("branch "))
641
+ branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
642
+ else if (line.startsWith("HEAD "))
643
+ head = line.slice("HEAD ".length);
644
+ else if (line === "detached")
645
+ detached = true;
646
+ }
647
+ if (worktreePath)
648
+ result.push({ path: worktreePath, branch, head, detached });
649
+ }
650
+ return result;
651
+ }
652
+ function discoverCandidates(hqRoot) {
653
+ const workspaceRoot = path.join(hqRoot, "workspace", "worktrees");
654
+ const reposRoot = path.join(hqRoot, "repos");
655
+ const candidates = [];
656
+ if (!fs.existsSync(workspaceRoot))
657
+ return candidates;
658
+ for (const repoEntry of fs.readdirSync(workspaceRoot, { withFileTypes: true })) {
659
+ if (!repoEntry.isDirectory() || repoEntry.isSymbolicLink())
660
+ continue;
661
+ const repoWorktrees = path.join(workspaceRoot, repoEntry.name);
662
+ for (const worktreeEntry of fs.readdirSync(repoWorktrees, { withFileTypes: true })) {
663
+ if (!worktreeEntry.isDirectory() || worktreeEntry.isSymbolicLink())
664
+ continue;
665
+ const candidatePath = path.join(repoWorktrees, worktreeEntry.name);
666
+ if (!fs.existsSync(path.join(candidatePath, ".git")))
667
+ continue;
668
+ const top = git(candidatePath, ["rev-parse", "--show-toplevel"]);
669
+ const common = git(candidatePath, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
670
+ if (top.status !== 0 || common.status !== 0)
671
+ continue;
672
+ const canonicalCandidate = safeRealpath(top.stdout.trim());
673
+ const canonicalPath = safeRealpath(candidatePath);
674
+ const commonDir = safeRealpath(common.stdout.trim());
675
+ if (!canonicalCandidate || !canonicalPath || !commonDir || canonicalCandidate !== canonicalPath)
676
+ continue;
677
+ const repo = path.dirname(commonDir);
678
+ const canonicalRepo = safeRealpath(repo);
679
+ const canonicalReposRoot = safeRealpath(reposRoot);
680
+ if (!canonicalRepo || !canonicalReposRoot || !isWithin(canonicalReposRoot, canonicalRepo))
681
+ continue;
682
+ const listing = git(canonicalRepo, ["worktree", "list", "--porcelain"]);
683
+ if (listing.status !== 0)
684
+ continue;
685
+ const registered = parseWorktreeList(listing.stdout).find((worktree) => safeRealpath(worktree.path) === canonicalCandidate);
686
+ if (!registered?.head)
687
+ continue;
688
+ candidates.push({ path: canonicalCandidate, repo: canonicalRepo, branch: registered.branch, head: registered.head, detached: registered.detached });
689
+ }
690
+ }
691
+ return candidates;
692
+ }
693
+ function advertisedDefaultBranch(repo) {
694
+ const remoteHead = git(repo, ["ls-remote", "--symref", "origin", "HEAD"], 10_000);
695
+ if (remoteHead.status !== 0)
696
+ return null;
697
+ const lines = remoteHead.stdout.split(/\r?\n/);
698
+ const refLine = lines.find((line) => line.startsWith("ref: refs/heads/") && line.endsWith("\tHEAD"));
699
+ const hashLine = lines.find((line) => /^[0-9a-f]{40,64}\tHEAD$/.test(line));
700
+ if (!refLine || !hashLine)
701
+ return null;
702
+ const ref = refLine.slice("ref: ".length).split("\t", 1)[0];
703
+ if (!ref || git(repo, ["check-ref-format", ref]).status !== 0)
704
+ return null;
705
+ return { ref, hash: hashLine.split("\t", 1)[0] };
706
+ }
707
+ function defaultRemoteBranch(repo) {
708
+ for (let attempt = 0; attempt < 2; attempt += 1) {
709
+ const advertised = advertisedDefaultBranch(repo);
710
+ if (!advertised)
711
+ return null;
712
+ const branchName = advertised.ref.slice("refs/heads/".length);
713
+ const trackingRef = `refs/remotes/origin/${branchName}`;
714
+ const fetched = git(repo, ["fetch", "--no-tags", "origin", `+${advertised.ref}:${trackingRef}`], 30_000);
715
+ if (fetched.status !== 0)
716
+ return null;
717
+ const local = git(repo, ["rev-parse", "--verify", `${trackingRef}^{commit}`]);
718
+ if (local.status !== 0)
719
+ return null;
720
+ const current = advertisedDefaultBranch(repo);
721
+ if (!current)
722
+ return null;
723
+ if (current.ref === advertised.ref && current.hash === local.stdout.trim()) {
724
+ return { remote: "origin", ref: local.stdout.trim() };
725
+ }
726
+ }
727
+ return null;
728
+ }
729
+ function remoteBranchConfig(repo, branch) {
730
+ const remote = git(repo, ["config", "--get", `branch.${branch}.remote`]);
731
+ const mergeRef = git(repo, ["config", "--get", `branch.${branch}.merge`]);
732
+ if (remote.status !== 0 || mergeRef.status !== 0)
733
+ return null;
734
+ const name = remote.stdout.trim();
735
+ const merge = mergeRef.stdout.trim();
736
+ if (!name || name === "." || !merge.startsWith("refs/heads/"))
737
+ return null;
738
+ return { remote: name, mergeRef: merge };
739
+ }
740
+ function hasNoUnpushedCommits(candidate, remote) {
741
+ const result = git(candidate.repo, ["rev-list", "--count", candidate.head, "--not", `--remotes=${remote}`]);
742
+ return result.status === 0 && result.stdout.trim() === "0";
743
+ }
744
+ function isAncestor(repo, head, base) {
745
+ const result = git(repo, ["merge-base", "--is-ancestor", head, base]);
746
+ return result.status === 0;
747
+ }
748
+ function remoteBranchExists(repo, remote, mergeRef) {
749
+ const branchName = mergeRef.replace(/^refs\/heads\//, "");
750
+ const result = git(repo, ["ls-remote", "--heads", remote, `refs/heads/${branchName}`], 10_000);
751
+ if (result.status !== 0)
752
+ return null;
753
+ return result.stdout.trim().length > 0;
754
+ }
755
+ function eligibilityReason(candidate, defaultBranches) {
756
+ const status = gitReadOnly(candidate.path, ["status", "--porcelain=v1", "--untracked-files=all"]);
757
+ if (status.status !== 0)
758
+ return "worktree_status_unavailable";
759
+ if (status.stdout.trim())
760
+ return "worktree_not_clean";
761
+ if (!defaultBranches.has(candidate.repo))
762
+ defaultBranches.set(candidate.repo, defaultRemoteBranch(candidate.repo));
763
+ const defaultBranch = defaultBranches.get(candidate.repo) ?? null;
764
+ if (!defaultBranch)
765
+ return "default_remote_branch_unavailable";
766
+ if (candidate.detached) {
767
+ return isAncestor(candidate.repo, candidate.head, defaultBranch.ref) ? null : "detached_commit_not_on_default";
768
+ }
769
+ if (!candidate.branch)
770
+ return "branch_unavailable";
771
+ if (isAncestor(candidate.repo, candidate.head, defaultBranch.ref))
772
+ return null;
773
+ const upstream = remoteBranchConfig(candidate.repo, candidate.branch);
774
+ if (!upstream)
775
+ return "upstream_unavailable";
776
+ if (!hasNoUnpushedCommits(candidate, upstream.remote))
777
+ return "unpushed_commits";
778
+ const exists = remoteBranchExists(candidate.repo, upstream.remote, upstream.mergeRef);
779
+ if (exists === null)
780
+ return "remote_branch_status_unverified";
781
+ if (!exists)
782
+ return null;
783
+ return "branch_not_merged_or_deleted";
784
+ }
785
+ function refreshCandidate(candidate) {
786
+ const head = git(candidate.path, ["rev-parse", "--verify", "HEAD"]);
787
+ const branch = git(candidate.path, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
788
+ if (head.status !== 0)
789
+ return null;
790
+ return {
791
+ ...candidate,
792
+ head: head.stdout.trim(),
793
+ branch: branch.status === 0 ? branch.stdout.trim().replace(/^refs\/heads\//, "") : null,
794
+ detached: branch.status !== 0,
795
+ };
796
+ }
797
+ function liveReferenceReason(candidate, liveness, processCwds, processFiles) {
798
+ if (!liveness.complete)
799
+ return "lane_session_scan_incomplete";
800
+ if (!processCwds.complete)
801
+ return "process_cwd_scan_incomplete";
802
+ for (const reference of liveness.references) {
803
+ if (!reference.live)
804
+ continue;
805
+ const refPath = safeRealpath(reference.path) ?? path.resolve(reference.path);
806
+ if (isWithin(candidate.path, refPath))
807
+ return reference.reason ?? "referenced_by_live_lane";
808
+ }
809
+ for (const processCwd of processCwds.paths) {
810
+ if (isWithin(candidate.path, processCwd))
811
+ return "process_cwd_under_worktree";
812
+ }
813
+ if (!processFiles.complete)
814
+ return "process_fd_scan_incomplete";
815
+ for (const processFile of processFiles.paths) {
816
+ if (isWithin(candidate.path, processFile))
817
+ return "process_holds_worktree_file_open";
818
+ }
819
+ if (liveness.unresolvedLiveLaneStartedAt.length > 0) {
820
+ if (liveness.unresolvedLiveLaneStartedAt.some((createdAt) => createdAt === null)) {
821
+ return "live_lane_worktree_unresolved";
822
+ }
823
+ const newest = newestLaneGuardModificationTime(candidate);
824
+ if (newest === null || liveness.unresolvedLiveLaneStartedAt.some((createdAt) => createdAt !== null && newest >= createdAt)) {
825
+ return "live_lane_worktree_unresolved";
826
+ }
827
+ }
828
+ return null;
829
+ }
830
+ function pnpmStorePath() {
831
+ const result = command("pnpm", ["store", "path"], 10_000);
832
+ if (result.status !== 0)
833
+ return null;
834
+ const value = result.stdout.trim().split(/\r?\n/).at(-1)?.trim();
835
+ return value ? path.resolve(value) : null;
836
+ }
837
+ function allowedUserStore(storePath) {
838
+ const home = path.resolve(process.env.HOME?.trim() || os.homedir());
839
+ const xdgCache = path.resolve(process.env.XDG_CACHE_HOME?.trim() || path.join(home, ".cache"));
840
+ const xdgData = path.resolve(process.env.XDG_DATA_HOME?.trim() || path.join(home, ".local", "share"));
841
+ const allowedRoots = [
842
+ xdgCache,
843
+ path.join(home, ".cache"),
844
+ path.join(xdgData, "pnpm", "store"),
845
+ ].map((candidate) => safeRealpath(candidate)).filter((value) => value !== null);
846
+ const canonicalStore = safeRealpath(storePath);
847
+ return canonicalStore !== null && allowedRoots.some((root) => isWithin(root, canonicalStore));
848
+ }
849
+ function prunePackageStore(procRoot) {
850
+ const store = pnpmStorePath();
851
+ if (!store)
852
+ return { attempted: false, reason: "pnpm_store_path_unavailable" };
853
+ if (!allowedUserStore(store))
854
+ return { attempted: false, reason: "pnpm_store_outside_user_cache" };
855
+ const openFiles = scanProcessPaths(procRoot, "fd");
856
+ if (!openFiles.complete)
857
+ return { attempted: false, reason: "process_fd_scan_incomplete" };
858
+ const canonicalStore = safeRealpath(store);
859
+ if (!canonicalStore)
860
+ return { attempted: false, reason: "pnpm_store_path_unavailable" };
861
+ if (openFiles.paths.some((openPath) => isWithin(canonicalStore, openPath))) {
862
+ return { attempted: false, reason: "process_holds_pnpm_store_open" };
863
+ }
864
+ const result = command("pnpm", ["store", "prune"], 120_000);
865
+ return result.status === 0
866
+ ? { attempted: true }
867
+ : { attempted: true, reason: "pnpm_store_prune_failed" };
868
+ }
869
+ function diskUsedPercent(hqRoot) {
870
+ try {
871
+ const stats = fs.statfsSync(hqRoot);
872
+ if (stats.blocks <= 0)
873
+ return null;
874
+ const used = stats.blocks - stats.bfree;
875
+ return Math.round((used / stats.blocks) * 1000) / 10;
876
+ }
877
+ catch {
878
+ return null;
879
+ }
880
+ }
881
+ function availableBytes(root) {
882
+ try {
883
+ const stats = fs.statfsSync(root);
884
+ return stats.bavail * stats.bsize;
885
+ }
886
+ catch {
887
+ return null;
888
+ }
889
+ }
890
+ function acquireRunLock(hqRoot) {
891
+ const workspace = path.join(hqRoot, "workspace");
892
+ const lockPath = path.join(workspace, ".outpost-worktree-pruner.lock");
893
+ fs.mkdirSync(workspace, { recursive: true });
894
+ const openLock = () => {
895
+ let fd;
896
+ try {
897
+ fd = fs.openSync(lockPath, "wx", 0o600);
898
+ }
899
+ catch (err) {
900
+ const code = err.code;
901
+ if (code === "EEXIST")
902
+ return null;
903
+ throw err;
904
+ }
905
+ try {
906
+ fs.writeSync(fd, `${process.pid}\n`);
907
+ return { path: lockPath, fd };
908
+ }
909
+ catch (err) {
910
+ let closeError;
911
+ let unlinkError;
912
+ try {
913
+ fs.closeSync(fd);
914
+ }
915
+ catch (error) {
916
+ closeError = error;
917
+ }
918
+ try {
919
+ fs.unlinkSync(lockPath);
920
+ }
921
+ catch (error) {
922
+ if (error.code !== "ENOENT")
923
+ unlinkError = error;
924
+ }
925
+ if (closeError || unlinkError) {
926
+ throw new AggregateError([err, ...(closeError ? [closeError] : []), ...(unlinkError ? [unlinkError] : [])], "pruner lock setup failed and cleanup was incomplete");
927
+ }
928
+ throw err;
929
+ }
930
+ };
931
+ const lock = openLock();
932
+ if (lock)
933
+ return lock;
934
+ let ownerPid;
935
+ try {
936
+ const rawPid = fs.readFileSync(lockPath, "utf8").trim();
937
+ ownerPid = Number(rawPid);
938
+ }
939
+ catch (err) {
940
+ if (err.code === "ENOENT")
941
+ return openLock();
942
+ throw err;
943
+ }
944
+ if (!Number.isSafeInteger(ownerPid) || ownerPid <= 0 || ownerPid === process.pid)
945
+ return null;
946
+ try {
947
+ process.kill(ownerPid, 0);
948
+ return null;
949
+ }
950
+ catch (err) {
951
+ if (err.code !== "ESRCH")
952
+ return null;
953
+ }
954
+ try {
955
+ fs.unlinkSync(lockPath);
956
+ }
957
+ catch (err) {
958
+ if (err.code !== "ENOENT")
959
+ return null;
960
+ }
961
+ return openLock();
962
+ }
963
+ function releaseRunLock(lock) {
964
+ fs.closeSync(lock.fd);
965
+ try {
966
+ fs.unlinkSync(lock.path);
967
+ }
968
+ catch (err) {
969
+ if (err.code !== "ENOENT")
970
+ throw err;
971
+ }
972
+ }
973
+ function emptyResult(options, skipped, pressurePercent) {
974
+ return {
975
+ event: "outpost.worktree_pruner.run",
976
+ trigger: options.trigger,
977
+ dry_run: options.dryRun,
978
+ feature_enabled: options.featureEnabled,
979
+ skipped,
980
+ ...(pressurePercent === undefined ? {} : { pressure_percent: pressurePercent }),
981
+ min_age_days: options.minAgeDays,
982
+ candidates: [],
983
+ removed: [],
984
+ kept: [],
985
+ bytes_freed: 0,
986
+ bytes_freed_basis: "statfs_available_byte_delta",
987
+ package_store: { attempted: false, reason: "not_run" },
988
+ };
989
+ }
990
+ /**
991
+ * Prune only registered worktrees beneath workspace/worktrees after every
992
+ * local, git, age, and liveness check has passed. The function never shells
993
+ * out to rm and never follows symlinks while estimating or checking age.
994
+ */
995
+ export function runOutpostWorktreePruner(options) {
996
+ const hqRoot = path.resolve(options.hqRoot);
997
+ const minAgeDays = options.minAgeDays ?? DEFAULT_MIN_AGE_DAYS;
998
+ if (!Number.isFinite(minAgeDays) || minAgeDays < 1 || minAgeDays > 365) {
999
+ throw new RangeError("minAgeDays must be between 1 and 365");
1000
+ }
1001
+ if (!options.dryRun && !options.featureEnabled) {
1002
+ return emptyResult({ ...options, minAgeDays }, "feature_flag_off");
1003
+ }
1004
+ let pressurePercent;
1005
+ if (options.trigger === "pressure" && !options.dryRun) {
1006
+ const measuredPressure = (options.readDiskUsedPercent ?? diskUsedPercent)(hqRoot);
1007
+ if (measuredPressure === null)
1008
+ return emptyResult({ ...options, minAgeDays }, "disk_pressure_unavailable");
1009
+ if (measuredPressure < PRESSURE_THRESHOLD_PERCENT) {
1010
+ return emptyResult({ ...options, minAgeDays }, "below_pressure_line", measuredPressure);
1011
+ }
1012
+ pressurePercent = measuredPressure;
1013
+ }
1014
+ const runLock = acquireRunLock(hqRoot);
1015
+ if (!runLock)
1016
+ return emptyResult({ ...options, minAgeDays }, "already_running", pressurePercent ?? undefined);
1017
+ try {
1018
+ const result = {
1019
+ event: "outpost.worktree_pruner.run",
1020
+ trigger: options.trigger,
1021
+ dry_run: options.dryRun,
1022
+ feature_enabled: options.featureEnabled,
1023
+ ...(pressurePercent === undefined ? {} : { pressure_percent: pressurePercent }),
1024
+ min_age_days: minAgeDays,
1025
+ candidates: [],
1026
+ removed: [],
1027
+ kept: [],
1028
+ bytes_freed: 0,
1029
+ bytes_freed_basis: "statfs_available_byte_delta",
1030
+ package_store: { attempted: false, reason: options.dryRun ? "dry_run" : "not_run" },
1031
+ };
1032
+ const worktrees = discoverCandidates(hqRoot);
1033
+ const liveness = scanLaneReferences(hqRoot, worktrees);
1034
+ const procRoot = options.processRoot ?? "/proc";
1035
+ const currentUid = options.processUserId ?? currentProcessUserId();
1036
+ const processCwds = scanProcessPaths(procRoot, "cwd", currentUid, options.processPathReader);
1037
+ const processFiles = scanProcessPaths(procRoot, "fd", currentUid, options.processPathReader);
1038
+ const now = options.now ?? Date.now();
1039
+ const minAgeMs = minAgeDays * 24 * 60 * 60 * 1000;
1040
+ const defaultBranches = new Map();
1041
+ for (const candidate of worktrees) {
1042
+ const keep = (reason) => {
1043
+ result.kept.push({ path: candidate.path, reason });
1044
+ };
1045
+ const liveReason = liveReferenceReason(candidate, liveness, processCwds, processFiles);
1046
+ if (liveReason) {
1047
+ keep(liveReason);
1048
+ continue;
1049
+ }
1050
+ const reason = eligibilityReason(candidate, defaultBranches);
1051
+ if (reason) {
1052
+ keep(reason);
1053
+ continue;
1054
+ }
1055
+ let newestMtime;
1056
+ try {
1057
+ newestMtime = newestModificationTime(candidate.path);
1058
+ }
1059
+ catch {
1060
+ keep("worktree_age_scan_failed");
1061
+ continue;
1062
+ }
1063
+ if (now - newestMtime < minAgeMs) {
1064
+ keep("worktree_too_recent");
1065
+ continue;
1066
+ }
1067
+ result.candidates.push(candidate.path);
1068
+ if (options.dryRun)
1069
+ continue;
1070
+ const freeBefore = availableBytes(hqRoot);
1071
+ const secondLiveness = liveReferenceReason(candidate, scanLaneReferences(hqRoot, worktrees), scanProcessPaths(procRoot, "cwd", currentUid, options.processPathReader), scanProcessPaths(procRoot, "fd", currentUid, options.processPathReader));
1072
+ if (secondLiveness) {
1073
+ result.candidates.pop();
1074
+ keep(secondLiveness);
1075
+ continue;
1076
+ }
1077
+ let latestMtime;
1078
+ try {
1079
+ latestMtime = newestModificationTime(candidate.path);
1080
+ }
1081
+ catch {
1082
+ result.candidates.pop();
1083
+ keep("worktree_age_scan_failed");
1084
+ continue;
1085
+ }
1086
+ if (now - latestMtime < minAgeMs) {
1087
+ result.candidates.pop();
1088
+ keep("worktree_too_recent");
1089
+ continue;
1090
+ }
1091
+ const refreshed = refreshCandidate(candidate);
1092
+ if (!refreshed) {
1093
+ result.candidates.pop();
1094
+ keep("worktree_head_unavailable");
1095
+ continue;
1096
+ }
1097
+ const changedReason = eligibilityReason(refreshed, defaultBranches);
1098
+ if (changedReason) {
1099
+ result.candidates.pop();
1100
+ keep(changedReason);
1101
+ continue;
1102
+ }
1103
+ const removed = git(candidate.repo, ["worktree", "remove", candidate.path], 120_000);
1104
+ if (removed.status !== 0) {
1105
+ result.candidates.pop();
1106
+ keep("git_worktree_remove_failed");
1107
+ continue;
1108
+ }
1109
+ const pruned = git(candidate.repo, ["worktree", "prune"], 30_000);
1110
+ const freeAfter = availableBytes(hqRoot);
1111
+ const bytesFreed = freeBefore !== null && freeAfter !== null ? Math.max(0, freeAfter - freeBefore) : 0;
1112
+ result.removed.push({ path: candidate.path, bytes_freed: bytesFreed });
1113
+ result.bytes_freed += bytesFreed;
1114
+ if (pruned.status !== 0)
1115
+ result.kept.push({ path: candidate.repo, reason: "git_worktree_prune_failed" });
1116
+ }
1117
+ if (!options.dryRun) {
1118
+ const storeSpaceBefore = availableBytes(hqRoot);
1119
+ result.package_store = (options.runPackageStorePrune ?? (() => prunePackageStore(procRoot)))();
1120
+ const storeSpaceAfter = availableBytes(hqRoot);
1121
+ if (result.package_store.attempted && storeSpaceBefore !== null && storeSpaceAfter !== null) {
1122
+ const storeBytesFreed = Math.max(0, storeSpaceAfter - storeSpaceBefore);
1123
+ result.package_store.bytes_freed = storeBytesFreed;
1124
+ result.bytes_freed += storeBytesFreed;
1125
+ }
1126
+ }
1127
+ return result;
1128
+ }
1129
+ finally {
1130
+ releaseRunLock(runLock);
1131
+ }
1132
+ }
1133
+ export const OUTPOST_WORKTREE_PRUNER_DEFAULT_MIN_AGE_DAYS = DEFAULT_MIN_AGE_DAYS;
1134
+ export const OUTPOST_WORKTREE_PRUNER_PRESSURE_THRESHOLD_PERCENT = PRESSURE_THRESHOLD_PERCENT;
1135
+ //# sourceMappingURL=outpost-worktree-pruner.js.map