stim 1.12.0 → 1.13.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 (71) hide show
  1. package/README.md +3 -1
  2. package/dist/{activity-CCZJs__P.mjs → activity-DfVKZTEB.mjs} +42 -25
  3. package/dist/{android-BQiKJYK0.mjs → android-BHfAu2mf.mjs} +1 -1
  4. package/dist/{android-B7X-8H7h.mjs → android-CD8pr8Q2.mjs} +62 -47
  5. package/dist/{android-cas-DzLPXiEn.mjs → android-cas-B9Kv9aIy.mjs} +9 -7
  6. package/dist/{android-75Sk2rIR.mjs → android-qx_31wJo.mjs} +395 -116
  7. package/dist/{app-install-CsZi0SAT.mjs → app-install-CDFY2nUL.mjs} +1 -1
  8. package/dist/{budget-DbzW5VTD.mjs → budget-E5o91NR1.mjs} +154 -37
  9. package/dist/{build-plan-CqntbR7b.mjs → build-plan-BTa93o_r.mjs} +21 -88
  10. package/dist/{build-progress-h60GPV27.mjs → build-progress-l7d-X-ZP.mjs} +75 -7
  11. package/dist/cdp-Dq_ypAlJ.mjs +115 -0
  12. package/dist/chrome-B5hWgQJS.mjs +61 -0
  13. package/dist/cli.mjs +16 -15
  14. package/dist/collector-run.mjs +14 -8
  15. package/dist/{command-output-BWCj5Vh-.mjs → command-output-BIL7rlX7.mjs} +1 -1
  16. package/dist/created-devices-Cd558h31.mjs +37 -0
  17. package/dist/{deps-CO-_7UOm.mjs → deps-2Wo81np9.mjs} +1 -1
  18. package/dist/{device-9r9xRYDa.mjs → device-Cf7vl-so.mjs} +6 -6
  19. package/dist/{device-lease-B9LAXIUH.mjs → device-lease-CL9dZf73.mjs} +52 -3
  20. package/dist/{device-pool-BoezJd-c.mjs → device-pool-MuoiyRcu.mjs} +3 -3
  21. package/dist/{device-remote-C0_o-tJH.mjs → device-remote-DT9ngZiD.mjs} +10 -10
  22. package/dist/{doctor-LfDlbR-N.mjs → doctor-D2iysNNH.mjs} +47 -18
  23. package/dist/{error-diagnostics-CCxjn31V.mjs → error-diagnostics-BoiOhck8.mjs} +6 -6
  24. package/dist/{gc-C4nQFcTX.mjs → gc-eA-PRBX8.mjs} +853 -126
  25. package/dist/{guide-BPBlh9ML.mjs → guide-BfZ0OmiP.mjs} +736 -110
  26. package/dist/{guide-status-i9ogHt4v.mjs → guide-status-BaMYu55M.mjs} +1 -1
  27. package/dist/{idle-S3tFZ53_.mjs → idle-DxLi5OkN.mjs} +42 -9
  28. package/dist/{in-use-D9XYEgFD.mjs → in-use-wYEaKQM9.mjs} +9 -5
  29. package/dist/{ios-CDzWL1mj.mjs → ios-CcfCeZ19.mjs} +98 -54
  30. package/dist/{ios-CZWOHTTc.mjs → ios-CmEzZiOj.mjs} +10 -5
  31. package/dist/{ios-device-D9cLxVr5.mjs → ios-device-BW-t_Lkv.mjs} +1 -1
  32. package/dist/{launch-verify-B2oR_kFN.mjs → launch-verify-CuJaBpEa.mjs} +33 -20
  33. package/dist/{logs-SzuYudOj.mjs → logs-DcSRbC2t.mjs} +11 -10
  34. package/dist/{metro-Dzo30hvJ.mjs → metro-BPeY1YMh.mjs} +4 -4
  35. package/dist/{named-ports-Dn1o61D9.mjs → named-ports-n7tnJsI8.mjs} +78 -6
  36. package/dist/native-runtime-CiiFHt0e.mjs +74 -0
  37. package/dist/{ownership-Dqnrjrio.mjs → ownership-Bz2kETAS.mjs} +1 -1
  38. package/dist/{ownership-B8d4kXBx.mjs → ownership-PUP4Dwai.mjs} +134 -209
  39. package/dist/page-BTu7I60K.mjs +29 -0
  40. package/dist/{ports-Dp_QTwTC.mjs → ports-C5UutqpJ.mjs} +4 -4
  41. package/dist/{project-B8rbutVC.mjs → project-DGeclhvO.mjs} +53 -17
  42. package/dist/{reload-nocmx8KB.mjs → reload-C-cV5yy_.mjs} +79 -20
  43. package/dist/{remote-cache-BRsRZru5.mjs → remote-cache--aehr4Pr.mjs} +3 -3
  44. package/dist/{server-bare-Gn3ssSdK.mjs → server-bare-Bq8BpSIo.mjs} +1 -1
  45. package/dist/server-expo-BeYwP_Xy.mjs +2 -0
  46. package/dist/{server-expo-CeT1nIIk.mjs → server-expo-DUCg52-K.mjs} +2 -2
  47. package/dist/{settings-bE-Gu3Ti.mjs → settings-ssVBToDR.mjs} +10 -10
  48. package/dist/{settings-BlyDn2Oz.mjs → settings-xB0Frkg6.mjs} +11 -2
  49. package/dist/settings.schema.json +53 -1
  50. package/dist/{simslim-COtEEP7x.mjs → simslim-Bbg9UWrt.mjs} +12 -6
  51. package/dist/{slot-launch-DIACGEfH.mjs → slot-launch-BIWEW-wI.mjs} +3 -3
  52. package/dist/{start-BboE1HIS.mjs → start-BZhItkiL.mjs} +11 -10
  53. package/dist/{start-BFstVBlx.mjs → start-Rw2GD3nV.mjs} +1 -1
  54. package/dist/{state-DcDgzfu3.mjs → state-DwAU2_-D.mjs} +1 -1
  55. package/dist/state-l1pYwOre.mjs +148 -0
  56. package/dist/{stats-DiCtmluY.mjs → stats-CaSwmsSZ.mjs} +2 -2
  57. package/dist/{status-DI9Tjo7T.mjs → status-TJlL0HWo.mjs} +624 -103
  58. package/dist/{status-BUASYyy_.mjs → status-sszP4XKU.mjs} +59 -2
  59. package/dist/{stim-desktop-D0xhpZt8.mjs → stim-desktop-CXt8-Z3J.mjs} +13 -2
  60. package/dist/{stop-Bn6v-GvS.mjs → stop-BZ2qMiBP.mjs} +37 -12
  61. package/dist/{stop-BI7QY76M.mjs → stop-DskDKNTU.mjs} +2 -2
  62. package/dist/supervisor-run.mjs +10 -10
  63. package/dist/web-run.d.mts +22 -0
  64. package/dist/web-run.mjs +481 -0
  65. package/dist/web-sWXd5iek.mjs +394 -0
  66. package/dist/{worktree-B5sTRskL.mjs → worktree-3cBa-bQt.mjs} +1 -1
  67. package/dist/{worktree-ChDL5fO1.mjs → worktree-D4Do_XiQ.mjs} +226 -19
  68. package/dist/{xcode-BzplpQzc.mjs → xcode-LM6A8cNZ.mjs} +5 -5
  69. package/package.json +5 -5
  70. package/dist/server-expo-eWi9Coof.mjs +0 -2
  71. package/dist/workspace-process-lock-BvvJTfo_.mjs +0 -57
@@ -1,24 +1,28 @@
1
- import { A as ownership_claim_exports, I as volumeRootFor, M as formatBytes, N as isOnMountedVolume, P as listMountedVolumes, U as projectDeviceSlots, _ as loadConfig$1, j as directorySize, p as getProject, x as removeProject } from "./config-0jCcMM0E.mjs";
1
+ import { A as ownership_claim_exports, E as withConfigLock$1, I as volumeRootFor, M as formatBytes, N as isOnMountedVolume, P as listMountedVolumes, U as projectDeviceSlots, _ as loadConfig$1, j as directorySize, k as acquireAvdClaim, p as getProject, x as removeProject } from "./config-0jCcMM0E.mjs";
2
2
  import { t as getExecutor } from "./exec-DK8KaVHK.mjs";
3
- import { N as workspaceStateFile, O as workspaceDir, j as workspaceLogsDir, v as workspaceLastUsed } from "./metro-Dzo30hvJ.mjs";
3
+ import { F as withDirLock, M as workspaceLogsDir, P as workspaceStateFile, k as workspaceDir, y as workspaceLastUsed } from "./metro-BPeY1YMh.mjs";
4
4
  import { C as sourceCheckoutOf, b as resolveFullRef, d as hasUncommittedWork, l as hasPopulatedSubmodules, m as listWorktrees, o as dirtyPaths, w as unpushedCommits } from "./worktree-DeBQSVtR.mjs";
5
- import { i as findProjectRoot, r as detectIsExpo } from "./project-B8rbutVC.mjs";
6
- import { h as shortUdid, o as formatLongDuration, u as plural } from "./command-output-BWCj5Vh-.mjs";
7
- import { d as removeExpiredLease, h as listBuildLocks, i as listLeaseFiles, r as leaseIsExpired } from "./device-lease-B9LAXIUH.mjs";
8
- import { A as listOrphanedAvdDirectories, L as ownedAvdSerialResolver, O as listAvds, P as ownedAvdDirectory, Q as isStimOwnedSim, T as isStimOwnedAvdName } from "./android-75Sk2rIR.mjs";
9
- import { h as POOL_SETTING_REMEDY, y as parkedMaxSetting } from "./ownership-B8d4kXBx.mjs";
10
- import { l as listAllIosSims } from "./ios-CZWOHTTc.mjs";
11
- import { a as parseIdleDuration, o as shutDownIdleDevices, r as idleDeviceLines, t as collectIdleDevices } from "./idle-S3tFZ53_.mjs";
12
- import { i as canonicalPath, o as listBuildSlots, r as workspaceInUse } from "./in-use-D9XYEgFD.mjs";
13
- import { f as resolveEasCliBin } from "./remote-cache-BRsRZru5.mjs";
14
- import { A as findOrphanedOwnedSessions, C as withEasProjectLock, E as removeEasSessionClaim, I as parseSessionListPage, L as stopSessionArgs, M as inspectSessionForTeardown, N as isDefinitiveMissingSessionError, P as listOwnedSessionsArgs, R as verifyStoppedSession, T as readEasSessionLedger$1, d as withRemoteSessionLock, j as getSessionArgs, w as easMachineStateRoot$1 } from "./device-remote-C0_o-tJH.mjs";
15
- import { A as includesWorkspaceOutputs, C as deviceSweepIsScoped, D as unverifiedDeviceCommand, E as findStaleProjectDevices, F as sizeCaches, M as selectCaches, N as trimCaches, O as withAndroidAvdSizes, P as discoverCaches, S as describeUnverifiableDevices, T as findStaleDeviceRecords, _ as collectParkedSims, a as REBUILD_COST, b as deleteProjectDevices, c as collectOrphanedWorkspaces, d as isInsideWorkspaces, f as listWorkspaceDirs, g as collectParkedAvds, h as collectDeviceLeases, i as machineNumber, j as planCacheEmptying, k as emptyCaches, l as collectWorkspaceOutputs, m as DEVICE_LIST_TIMEOUT_MS, o as WORKSPACE_OUTPUT_DIRS, p as sizeText, s as clearWorkspaceOutputs, u as deleteOrphanedWorkspaces, v as deleteParkedAvds, w as findOrphanedDevices, x as describeParkedSims, y as deleteParkedSims } from "./budget-DbzW5VTD.mjs";
16
- import { a as reclaimKeys, d as removeWorktreeTarget, m as reclaimProject, n as excludePodChurn, p as describeDereferenced, r as matchWorktreeEntry } from "./worktree-ChDL5fO1.mjs";
5
+ import { a as findProjectRoot, r as detectIsExpo } from "./project-DGeclhvO.mjs";
6
+ import { h as shortUdid, o as formatLongDuration, u as plural } from "./command-output-BIL7rlX7.mjs";
7
+ import { d as removeExpiredLease, h as listBuildLocks, i as listLeaseFiles, r as leaseIsExpired } from "./device-lease-CL9dZf73.mjs";
8
+ import { A as listInstalledSystemImages, B as parseAvdDeviceProfile, F as ownedAvdDirectory, R as ownedAvdSerialResolver, V as parseAvdSystemImage, a as androidHome, ct as parkedMaxSetting, f as avdIniPaths, g as avdStorageRoots, j as listOrphanedAvdDirectories, k as listAvds, lt as readParked, nt as isStimOwnedSim, p as avdNameAbsent, rt as POOL_SETTING_REMEDY, tt as isStimOwnedAvd, u as androidToolPath } from "./android-qx_31wJo.mjs";
9
+ import { n as readCreatedDevices$1, t as forgetCreatedDevice } from "./created-devices-Cd558h31.mjs";
10
+ import { l as listAllIosSims } from "./ios-CmEzZiOj.mjs";
11
+ import { a as parseIdleDuration, c as recordGcResult, l as takeGcResults, o as shutDownIdleDevices, r as idleDeviceLines, t as collectIdleDevices } from "./idle-DxLi5OkN.mjs";
12
+ import { i as canonicalPath, n as withIdleWorkspace, o as listBuildSlots, r as workspaceInUse } from "./in-use-wYEaKQM9.mjs";
13
+ import { r as readCollectors } from "./ownership-Bz2kETAS.mjs";
14
+ import { f as resolveEasCliBin } from "./remote-cache--aehr4Pr.mjs";
15
+ import { A as findOrphanedOwnedSessions, C as withEasProjectLock, E as removeEasSessionClaim, I as parseSessionListPage, L as stopSessionArgs, M as inspectSessionForTeardown, N as isDefinitiveMissingSessionError, P as listOwnedSessionsArgs, R as verifyStoppedSession, T as readEasSessionLedger$1, d as withRemoteSessionLock, j as getSessionArgs, w as easMachineStateRoot$1 } from "./device-remote-DT9ngZiD.mjs";
16
+ import { A as includesWorkspaceOutputs, C as deviceSweepIsScoped, D as unverifiedDeviceCommand, E as findStaleProjectDevices, F as sizeCaches, M as selectCaches, N as trimCaches, O as withAndroidAvdSizes, P as discoverCaches, S as describeUnverifiableDevices, T as findStaleDeviceRecords, _ as collectParkedSims, a as REBUILD_COST, b as deleteProjectDevices, c as collectOrphanedWorkspaces, d as isInsideWorkspaces, f as listWorkspaceDirs, g as collectParkedAvds, h as collectDeviceLeases, i as machineNumber, j as planCacheEmptying, k as emptyCaches, l as collectWorkspaceOutputs, m as DEVICE_LIST_TIMEOUT_MS, o as WORKSPACE_OUTPUT_DIRS, p as sizeText, s as clearWorkspaceOutputs, u as deleteOrphanedWorkspaces, v as deleteParkedAvds, w as findOrphanedDevices, x as describeParkedSims, y as deleteParkedSims } from "./budget-E5o91NR1.mjs";
17
+ import { _ as reclaimProject, a as reclaimKeys, d as removeWorktreeTarget, g as describeDereferenced, h as pullRequestLookups, m as endedPullRequest, n as excludePodChurn, p as describePullRequest, r as matchWorktreeEntry } from "./worktree-D4Do_XiQ.mjs";
17
18
  import { n as mergeState, t as fetchDefaultBranch } from "./merge-state-ytFQCeh4.mjs";
19
+ import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from "node:fs";
18
20
  import { InvalidArgumentError } from "commander";
19
- import { existsSync, readFileSync, readdirSync, statSync } from "fs";
21
+ import { closeSync as closeSync$1, existsSync as existsSync$1, openSync as openSync$1, readFileSync as readFileSync$1, readSync as readSync$1, readdirSync as readdirSync$1, renameSync as renameSync$1, rmSync as rmSync$1, statSync as statSync$1, utimesSync, writeFileSync as writeFileSync$1 } from "fs";
20
22
  import { basename, dirname, isAbsolute, join } from "path";
23
+ import { LOG_ROTATE_BYTES, rotatedLogPath } from "@stim-cli/core";
21
24
  import { settingDefinition } from "@stim-cli/core/state";
25
+ import { dirname as dirname$1, join as join$1 } from "node:path";
22
26
  import chalk from "chalk";
23
27
  //#region src/commands/gc/eas-sessions.ts
24
28
  const EAS_OPERATION_TIMEOUT_MS = 3e4;
@@ -39,18 +43,18 @@ function readRecordedRemoteSessionIds(roots) {
39
43
  let safe = true;
40
44
  for (const root of new Set(roots)) {
41
45
  try {
42
- if (!statSync(root).isDirectory()) throw new Error("path is not a directory");
43
- readdirSync(root);
46
+ if (!statSync$1(root).isDirectory()) throw new Error("path is not a directory");
47
+ readdirSync$1(root);
44
48
  } catch (error) {
45
49
  safe = false;
46
50
  notices.push(`${root} is not available as a readable workspace directory: ${describeError(error)}`);
47
51
  continue;
48
52
  }
49
53
  const path = workspaceStateFile(root);
50
- if (!existsSync(path)) continue;
54
+ if (!existsSync$1(path)) continue;
51
55
  let state;
52
56
  try {
53
- state = JSON.parse(readFileSync(path, "utf-8"));
57
+ state = JSON.parse(readFileSync$1(path, "utf-8"));
54
58
  } catch (error) {
55
59
  safe = false;
56
60
  notices.push(`${path} could not be read as valid JSON: ${describeError(error)}`);
@@ -86,8 +90,8 @@ function claimStateAbsence(claim) {
86
90
  const stateDir = dirname(claim.stateFile);
87
91
  let entries;
88
92
  try {
89
- if (!statSync(stateDir).isDirectory()) throw new Error("path is not a directory");
90
- entries = readdirSync(stateDir);
93
+ if (!statSync$1(stateDir).isDirectory()) throw new Error("path is not a directory");
94
+ entries = readdirSync$1(stateDir);
91
95
  } catch (error) {
92
96
  return {
93
97
  absent: false,
@@ -100,7 +104,7 @@ function claimStateAbsence(claim) {
100
104
  };
101
105
  let state;
102
106
  try {
103
- state = JSON.parse(readFileSync(claim.stateFile, "utf-8"));
107
+ state = JSON.parse(readFileSync$1(claim.stateFile, "utf-8"));
104
108
  } catch (error) {
105
109
  return {
106
110
  absent: false,
@@ -217,8 +221,8 @@ function easLockRoots(projectRoot, config) {
217
221
  }
218
222
  function unavailableLockRootNotice(roots) {
219
223
  for (const root of roots) try {
220
- if (!statSync(root).isDirectory()) throw new Error("path is not a directory");
221
- readdirSync(root);
224
+ if (!statSync$1(root).isDirectory()) throw new Error("path is not a directory");
225
+ readdirSync$1(root);
222
226
  } catch (error) {
223
227
  return `EAS session sweep skipped: ${root} is not available for remote-session lock inspection: ${describeError(error)}`;
224
228
  }
@@ -227,7 +231,7 @@ function unavailableLockRootNotice(roots) {
227
231
  function malformedRemoteSessionLockNotice(roots) {
228
232
  for (const root of roots) {
229
233
  const lockPath = join(workspaceDir(root), "remote-session.lock");
230
- if (!existsSync(lockPath)) continue;
234
+ if (!existsSync$1(lockPath)) continue;
231
235
  if ((0, ownership_claim_exports.readClaimSet)(lockPath).unresolved.length === 0) continue;
232
236
  return `EAS session sweep skipped: the remote-session lock at ${lockPath} has a malformed owner record.`;
233
237
  }
@@ -281,6 +285,13 @@ async function withRemoteSessionGcLocks(projectRoot, deps, fn) {
281
285
  }
282
286
  async function deleteEasSessions(easSessionSweep, deps) {
283
287
  let deleteFailures = 0;
288
+ const refuseAll = (detail) => {
289
+ deleteFailures += easSessionSweep.orphaned.length;
290
+ for (const session of easSessionSweep.orphaned) recordGcResult("easSession", "failed", session.name, {
291
+ id: session.id,
292
+ detail
293
+ });
294
+ };
284
295
  if (easSessionSweep.orphaned.length && easSessionSweep.deletionSafe && easSessionSweep.projectScope) {
285
296
  const projectScope = easSessionSweep.projectScope;
286
297
  const withProjectLock = deps.withEasProjectLock ?? withEasProjectLock;
@@ -290,7 +301,7 @@ async function deleteEasSessions(easSessionSweep, deps) {
290
301
  finalSweepStarted = true;
291
302
  return withRemoteSessionGcLocks(projectScope, deps, async (lockedDeps) => {
292
303
  if (lockedDeps.easSweepBlockedNotice) {
293
- deleteFailures += easSessionSweep.orphaned.length;
304
+ refuseAll(`EAS session deletion refused: ${lockedDeps.easSweepBlockedNotice}`);
294
305
  console.log(chalk.red(`EAS session deletion refused: ${lockedDeps.easSweepBlockedNotice}`));
295
306
  return;
296
307
  }
@@ -313,10 +324,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
313
324
  console.log(chalk.red(`EAS session ${sessionId} is resolved, but its ownership claim could not be removed: ${detail}. The claim was retained for reconciliation.`));
314
325
  };
315
326
  if (registryExpanded) {
316
- deleteFailures += easSessionSweep.orphaned.length;
327
+ refuseAll("EAS session deletion refused: registered workspace roots changed after classification.");
317
328
  console.log(chalk.red("EAS session deletion refused: registered workspace roots changed after classification."));
318
329
  } else if (!currentRecords?.safe || !ledger.safe) {
319
- deleteFailures += easSessionSweep.orphaned.length;
330
+ refuseAll("EAS session deletion refused: the session records could not be read safely.");
320
331
  for (const notice of [...currentRecords?.notices ?? [], ...ledger.notice ? [ledger.notice] : []]) console.log(chalk.red(`EAS session deletion refused: ${notice}`));
321
332
  } else {
322
333
  const recorded = new Set(currentRecords.ids);
@@ -325,6 +336,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
325
336
  for (const session of easSessionSweep.orphaned) {
326
337
  if (recorded.has(session.id)) {
327
338
  console.log(chalk.dim(`EAS session ${session.id} has a workspace record and was left running.`));
339
+ recordGcResult("easSession", "kept", session.name, {
340
+ id: session.id,
341
+ detail: "it has a workspace record and was left running"
342
+ });
328
343
  continue;
329
344
  }
330
345
  const claim = ledger.claims.get(session.id);
@@ -332,11 +347,19 @@ async function deleteEasSessions(easSessionSweep, deps) {
332
347
  if (!claim || claim.name !== session.name || claim.platform !== session.platform || !claimState?.absent || claimState.notice) {
333
348
  deleteFailures++;
334
349
  console.log(chalk.red(`Could not verify fixed ownership for EAS session ${session.id}: ${claimState?.notice ?? "the claim is missing, mismatched, or still recorded in workspace state"}.`));
350
+ recordGcResult("easSession", "failed", session.name, {
351
+ id: session.id,
352
+ detail: `Could not verify fixed ownership for EAS session ${session.id}: ${claimState?.notice ?? "the claim is missing, mismatched, or still recorded in workspace state"}.`
353
+ });
335
354
  continue;
336
355
  }
337
356
  if (!eas) {
338
357
  deleteFailures++;
339
358
  console.log(chalk.red(`Could not verify EAS session ${session.id}: eas-cli is not available.`));
359
+ recordGcResult("easSession", "failed", session.name, {
360
+ id: session.id,
361
+ detail: `Could not verify EAS session ${session.id}: eas-cli is not available.`
362
+ });
340
363
  continue;
341
364
  }
342
365
  const options = {
@@ -354,6 +377,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
354
377
  } else {
355
378
  deleteFailures++;
356
379
  console.log(chalk.red(`Could not verify EAS session ${session.id}: ${describeError(error)}`));
380
+ recordGcResult("easSession", "failed", session.name, {
381
+ id: session.id,
382
+ detail: `Could not verify EAS session ${session.id}: ${describeError(error)}`
383
+ });
357
384
  }
358
385
  continue;
359
386
  }
@@ -366,6 +393,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
366
393
  if (inspection.action === "refused") {
367
394
  deleteFailures++;
368
395
  console.log(chalk.red(`Could not stop EAS session ${session.id}: ${inspection.reason}`));
396
+ recordGcResult("easSession", "failed", session.name, {
397
+ id: session.id,
398
+ detail: `Could not stop EAS session ${session.id}: ${inspection.reason}`
399
+ });
369
400
  continue;
370
401
  }
371
402
  let stopped;
@@ -374,15 +405,24 @@ async function deleteEasSessions(easSessionSweep, deps) {
374
405
  } catch (error) {
375
406
  deleteFailures++;
376
407
  console.log(chalk.red(`Could not stop EAS session ${session.id}: ${describeError(error)}`));
408
+ recordGcResult("easSession", "failed", session.name, {
409
+ id: session.id,
410
+ detail: `Could not stop EAS session ${session.id}: ${describeError(error)}`
411
+ });
377
412
  continue;
378
413
  }
379
414
  const verified = verifyStoppedSession(stopped, session.id);
380
415
  if (!verified.ok) {
381
416
  deleteFailures++;
382
417
  console.log(chalk.red(`Could not verify EAS session ${session.id} stopped: ${verified.reason}`));
418
+ recordGcResult("easSession", "failed", session.name, {
419
+ id: session.id,
420
+ detail: `Could not verify EAS session ${session.id} stopped: ${verified.reason}`
421
+ });
383
422
  continue;
384
423
  }
385
424
  console.log(chalk.green(`Stopped EAS session ${session.id} (${session.name})`));
425
+ recordGcResult("easSession", "done", session.name, { id: session.id });
386
426
  reconcileClaim(session.id);
387
427
  }
388
428
  }
@@ -393,8 +433,8 @@ async function deleteEasSessions(easSessionSweep, deps) {
393
433
  machineRoot: deps.easLedgerRoot
394
434
  });
395
435
  } catch (error) {
396
- deleteFailures += easSessionSweep.orphaned.length;
397
436
  const phase = finalSweepStarted ? "failed" : "lock acquisition failed";
437
+ refuseAll(`EAS session deletion ${phase}: ${describeError(error)}`);
398
438
  console.log(chalk.red(`EAS session deletion ${phase}: ${describeError(error)}`));
399
439
  }
400
440
  }
@@ -418,6 +458,11 @@ function skip(code, text) {
418
458
  text
419
459
  };
420
460
  }
461
+ function pullRequestNote(lookup) {
462
+ const pr = lookup && "pullRequest" in lookup ? lookup.pullRequest : null;
463
+ if (!pr) return null;
464
+ return pr.containsHead ? describePullRequest(pr) : `${describePullRequest(pr)}, and HEAD has commits it does not`;
465
+ }
421
466
  /**
422
467
  * Why gc keeps a worktree, or null when it removes it. A worktree that would be removed is still kept while its last
423
468
  * activity, or its merge into the default branch, is less than `grace.ms` old, and kept when that activity is unknown.
@@ -432,10 +477,17 @@ function graceBlocker(facts, grace) {
432
477
  if (grace.ms <= 0) return null;
433
478
  if (!facts.activity) return skip("activity-unknown", "its last activity could not be read");
434
479
  const merged = facts.merge?.merged ? facts.merge : null;
435
- const latest = merged && merged.mergedAt > facts.activity.at ? {
480
+ const ended = endedPullRequest(facts.pullRequest);
481
+ const events = [facts.activity];
482
+ if (merged) events.push({
436
483
  at: merged.mergedAt,
437
484
  basis: `merged into ${merged.into}`
438
- } : facts.activity;
485
+ });
486
+ if (ended?.endedAt) events.push({
487
+ at: ended.endedAt,
488
+ basis: describePullRequest(ended)
489
+ });
490
+ const latest = events.reduce((a, b) => b.at > a.at ? b : a);
439
491
  const eligibleAt = latest.at + grace.ms;
440
492
  if (grace.now >= eligibleAt) return null;
441
493
  const ago = formatLongDuration(Math.max(0, grace.now - latest.at));
@@ -452,22 +504,31 @@ function removalBlocker(facts, olderThan) {
452
504
  if (facts.locked) return skip("locked", "locked with git worktree lock");
453
505
  if (facts.inUse.length) return skip("in-use", `in use: ${facts.inUse.join("; ")}`);
454
506
  if (facts.porcelain === null) return skip("status-unreadable", "git status could not be read");
455
- if (excludePodChurn(facts.porcelain).lines.length) return skip("dirty", "dirty: uncommitted changes or untracked files");
507
+ const dirty = excludePodChurn(facts.porcelain).lines.length;
508
+ if (dirty) return skip("dirty", `dirty: ${plural(dirty, "uncommitted or untracked file")}`);
456
509
  if (facts.unpushed === null) return skip("unpushed-unchecked", "unpushed commits could not be checked");
457
510
  const merged = facts.merge?.merged ? facts.merge : null;
458
- if (facts.unpushed.length && !merged?.coversUnpushed) return skip("unpushed", `unpushed: ${plural(facts.unpushed.length, "commit")} on no remote or other branch`);
511
+ const ended = endedPullRequest(facts.pullRequest);
512
+ if (facts.unpushed.length && !merged?.coversUnpushed && ended?.state !== "merged") return skip("unpushed", `unpushed: ${plural(facts.unpushed.length, "commit")} on no remote or other branch`);
459
513
  if (facts.submodules) return skip("submodules", "initialized submodules");
460
- if (merged) return null;
514
+ if (merged || ended) return null;
461
515
  const notMerged = facts.merge && !facts.merge.merged ? facts.merge : null;
462
- if (olderThan === null) return skip(notMerged?.unknown ? "merge-unknown" : "not-merged", notMerged?.detail ?? "not merged");
463
- const also = notMerged ? `; ${notMerged.detail}` : "";
516
+ const details = [notMerged?.detail, pullRequestNote(facts.pullRequest)].filter(Boolean);
517
+ if (olderThan === null) return skip(notMerged?.unknown ? "merge-unknown" : "not-merged", details.join("; ") || "not merged");
518
+ const also = details.map((detail) => `; ${detail}`).join("");
464
519
  if (facts.idleDays === null) return skip("last-use-unknown", `recently used: its last use is unknown${also}`);
465
520
  if (facts.idleDays < olderThan) return skip("recently-used", `recently used ${facts.idleDays}d ago${also}`);
466
521
  return null;
467
522
  }
468
- /** Why gc removes a worktree it did not skip: `merged into origin/main` or `idle 9d`. */
523
+ /** Why gc removes a worktree it did not skip: `merged into origin/main`, `PR #12 closed` or `idle 9d`. */
469
524
  function worktreeRemovalReason(candidate) {
470
- return candidate.merge?.merged ? `merged into ${candidate.merge.into}` : `idle ${candidate.idleDays ?? 0}d`;
525
+ if (candidate.merge?.merged) return `merged into ${candidate.merge.into}`;
526
+ const ended = endedPullRequest(candidate.pullRequest);
527
+ return ended ? describePullRequest(ended) : `idle ${candidate.idleDays ?? 0}d`;
528
+ }
529
+ /** The pull request note a kept worktree's report line carries, such as `PR #12 merged`, or null. */
530
+ function worktreePullRequestNote(candidate) {
531
+ return pullRequestNote(candidate.pullRequest);
471
532
  }
472
533
  function lastUsedOf(keys) {
473
534
  const times = keys.map(workspaceLastUsed).filter(Number.isFinite);
@@ -477,13 +538,26 @@ function idleDaysOf(keys, now) {
477
538
  const last = lastUsedOf(keys);
478
539
  return Number.isFinite(last) ? Math.max(0, Math.floor((now - last) / DAY_MS)) : null;
479
540
  }
480
- function inUseOf(keys, checks) {
481
- return [.../* @__PURE__ */ new Set([...keys.flatMap((key) => workspaceInUse(key, checks)), ...deviceUseOf(keys)])];
541
+ function deviceReads() {
542
+ let sims;
543
+ let resolver;
544
+ return {
545
+ sims: () => {
546
+ if (sims === void 0) try {
547
+ sims = listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
548
+ } catch {
549
+ sims = null;
550
+ }
551
+ return sims;
552
+ },
553
+ avdSerial: (avdName) => (resolver ??= ownedAvdSerialResolver({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }))(avdName)
554
+ };
555
+ }
556
+ function inUseOf(keys, checks, devices = deviceReads()) {
557
+ return [.../* @__PURE__ */ new Set([...keys.flatMap((key) => workspaceInUse(key, checks)), ...deviceUseOf(keys, devices)])];
482
558
  }
483
- function deviceUseOf(keys) {
559
+ function deviceUseOf(keys, devices) {
484
560
  const reasons = [];
485
- let sims;
486
- let avdSerial;
487
561
  for (const key of keys) {
488
562
  let slots;
489
563
  try {
@@ -495,23 +569,16 @@ function deviceUseOf(keys) {
495
569
  for (const { platforms } of slots) {
496
570
  const ios = platforms.ios;
497
571
  if (ios?.owned && ios.deviceUdid) {
498
- if (sims === void 0) try {
499
- sims = listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
500
- } catch {
501
- sims = null;
502
- }
572
+ const sims = devices.sims();
503
573
  const sim = sims?.find((entry) => entry.udid === ios.deviceUdid);
504
574
  if (sims === null) reasons.push(`the state of its owned simulator ${ios.deviceUdid} cannot be read`);
505
575
  else if (sim && sim.state !== "Shutdown") reasons.push(`its owned simulator ${sim.name} is ${sim.state}; \`stim stop\` in ${key} shuts it down`);
506
576
  }
507
577
  const android = platforms.android;
508
- if (android?.owned && android.avdName) {
509
- avdSerial ??= ownedAvdSerialResolver({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
510
- try {
511
- if (avdSerial(android.avdName).serial) reasons.push(`its owned emulator ${android.avdName} is running; \`stim stop\` in ${key} shuts it down`);
512
- } catch {
513
- reasons.push(`the state of its owned emulator ${android.avdName} cannot be read`);
514
- }
578
+ if (android?.owned && android.avdName) try {
579
+ if (devices.avdSerial(android.avdName).serial) reasons.push(`its owned emulator ${android.avdName} is running; \`stim stop\` in ${key} shuts it down`);
580
+ } catch {
581
+ reasons.push(`the state of its owned emulator ${android.avdName} cannot be read`);
515
582
  }
516
583
  }
517
584
  }
@@ -525,7 +592,7 @@ function newestMtime(paths) {
525
592
  for (const { path, basis } of paths) {
526
593
  let mtime;
527
594
  try {
528
- mtime = statSync(path).mtimeMs;
595
+ mtime = statSync$1(path).mtimeMs;
529
596
  } catch (error) {
530
597
  if (error.code === "ENOENT") continue;
531
598
  return "unreadable";
@@ -540,7 +607,7 @@ function newestMtime(paths) {
540
607
  function logFiles$1(key) {
541
608
  const dir = workspaceLogsDir(key);
542
609
  try {
543
- return readdirSync(dir).map((name) => ({
610
+ return readdirSync$1(dir).map((name) => ({
544
611
  path: join(dir, name),
545
612
  basis: "a Stim log write"
546
613
  }));
@@ -601,7 +668,7 @@ function porcelainOf(path, gitAnswered) {
601
668
  function candidateRoots() {
602
669
  const registered = Object.keys(loadConfig$1()?.projects ?? {}).filter(isAbsolute);
603
670
  const recorded = listWorkspaceDirs().flatMap((entry) => entry.projectRoot ? [entry.projectRoot] : []);
604
- return [.../* @__PURE__ */ new Set([...registered, ...recorded])].filter((root) => existsSync(root)).toSorted();
671
+ return [.../* @__PURE__ */ new Set([...registered, ...recorded])].filter((root) => existsSync$1(root)).toSorted();
605
672
  }
606
673
  function checkMergeStates(pending, idle, grace) {
607
674
  const repos = /* @__PURE__ */ new Map();
@@ -629,9 +696,11 @@ function checkMergeStates(pending, idle, grace) {
629
696
  }
630
697
  /**
631
698
  * Classifies each Stim-managed linked worktree. A merged one is removable; with `idle`, so is one unused for
632
- * `olderThan` days. Merge state is checked only where it decides the verdict, after one fetch per repository.
699
+ * `olderThan` days. Device use is read last, in one pass against one simulator list and one AVD resolver, so
700
+ * the resolver's time budget covers only device reads. Pull requests are asked once per repository, all
701
+ * repositories at once. Merge state is checked only where it decides the verdict, after one fetch per repository.
633
702
  */
634
- function collectWorktreeSweep({ idle, olderThan, now }) {
703
+ async function collectWorktreeSweep({ idle, olderThan, now }) {
635
704
  const days = idle ? olderThan ?? DEFAULT_WORKTREE_IDLE_DAYS : null;
636
705
  const grace = {
637
706
  ms: graceMsSetting(),
@@ -647,6 +716,8 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
647
716
  keys: [root],
648
717
  idleDays: null,
649
718
  merge: null,
719
+ head: null,
720
+ pullRequest: null,
650
721
  skipCode: "not-a-worktree",
651
722
  skipped: "not inside a git worktree",
652
723
  eligibleAt: null
@@ -655,8 +726,7 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
655
726
  }
656
727
  groups.set(entry.path, [...groups.get(entry.path) ?? [], root]);
657
728
  }
658
- const worktrees = [];
659
- const pending = [];
729
+ const inspected = [];
660
730
  for (const [path, roots] of groups) {
661
731
  const entries = listWorktrees(path);
662
732
  const entry = matchWorktreeEntry(entries, path);
@@ -666,6 +736,7 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
666
736
  const linked = !("refusal" in source) && entry !== null && source.path !== entry.path;
667
737
  const activity = linked && grace.ms > 0 ? worktreeActivityOf(path, keys) : null;
668
738
  const gitAnswered = linked ? hasUncommittedWork(path) : null;
739
+ const head = linked ? resolveFullRef(path, "HEAD") : null;
669
740
  const facts = {
670
741
  source: "refusal" in source ? source : linked ? "linked" : "source",
671
742
  bare: Boolean(entry?.bare),
@@ -673,25 +744,48 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
673
744
  porcelain: porcelainOf(path, gitAnswered),
674
745
  unpushed: linked ? unpushedCommits(path) : null,
675
746
  submodules: linked && hasPopulatedSubmodules(path),
676
- inUse: linked ? inUseOf(keys, { managedLocks: true }) : [],
747
+ inUse: [],
677
748
  idleDays,
678
749
  merge: null,
750
+ pullRequest: null,
679
751
  activity
680
752
  };
753
+ inspected.push({
754
+ candidate: {
755
+ path,
756
+ keys,
757
+ idleDays,
758
+ merge: null,
759
+ head,
760
+ pullRequest: null,
761
+ skipCode: null,
762
+ skipped: null,
763
+ eligibleAt: null
764
+ },
765
+ facts,
766
+ repo: "refusal" in source ? null : source.path,
767
+ branch: head && entry?.branch ? entry.branch : null
768
+ });
769
+ }
770
+ const linked = inspected.filter(({ facts }) => facts.source === "linked");
771
+ for (const { candidate, facts } of linked) facts.inUse = candidate.keys.flatMap((key) => workspaceInUse(key, { managedLocks: true }));
772
+ const devices = deviceReads();
773
+ for (const { candidate, facts } of linked) facts.inUse = [.../* @__PURE__ */ new Set([...facts.inUse, ...deviceUseOf(candidate.keys, devices)])];
774
+ await lookUpPullRequests(inspected);
775
+ const worktrees = [];
776
+ const pending = [];
777
+ for (const { candidate, facts, repo } of inspected) {
681
778
  const verdict = worktreeSkipReason(facts, days, grace);
682
- const candidate = {
683
- path,
684
- keys,
685
- idleDays,
686
- merge: null,
779
+ Object.assign(candidate, {
780
+ pullRequest: facts.pullRequest,
687
781
  skipCode: verdict?.code ?? null,
688
782
  skipped: verdict?.text ?? null,
689
783
  eligibleAt: verdict?.eligibleAt ?? null
690
- };
691
- if (verdict && MERGE_DECIDES.has(verdict.code) && !("refusal" in source)) pending.push({
784
+ });
785
+ if (verdict && MERGE_DECIDES.has(verdict.code) && repo !== null) pending.push({
692
786
  candidate,
693
787
  facts,
694
- repo: source.path
788
+ repo
695
789
  });
696
790
  worktrees.push(candidate);
697
791
  }
@@ -706,6 +800,26 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
706
800
  worktrees: listed
707
801
  };
708
802
  }
803
+ async function lookUpPullRequests(inspected) {
804
+ const repos = /* @__PURE__ */ new Map();
805
+ for (const { candidate, facts, repo, branch } of inspected) {
806
+ if (repo === null || branch === null || candidate.head === null) continue;
807
+ const query = {
808
+ cwd: candidate.path,
809
+ branch,
810
+ head: candidate.head
811
+ };
812
+ repos.set(repo, [...repos.get(repo) ?? [], {
813
+ facts,
814
+ query
815
+ }]);
816
+ }
817
+ const lookup = pullRequestLookups();
818
+ await Promise.all([...repos].map(async ([repo, entries]) => {
819
+ const answers = await lookup(repo, entries.map(({ query }) => query));
820
+ entries.forEach(({ facts }, i) => facts.pullRequest = answers[i] ?? null);
821
+ }));
822
+ }
709
823
  async function removeWorktrees(sweep, { now = Date.now() } = {}) {
710
824
  let failures = 0;
711
825
  for (const candidate of sweep.worktrees) {
@@ -715,8 +829,9 @@ async function removeWorktrees(sweep, { now = Date.now() } = {}) {
715
829
  const keys = [.../* @__PURE__ */ new Set([...candidate.keys, ...lockedKeys])];
716
830
  const unlocked = keys.filter((key) => !lockedKeys.includes(key));
717
831
  const reasons = [...inUseOf(lockedKeys, { managedLocks: false }), ...inUseOf(unlocked, { managedLocks: true })].map((r) => `in use: ${r}`);
718
- if (candidate.merge?.merged) {
719
- if (resolveFullRef(candidate.path, "HEAD") !== candidate.merge.head) reasons.push("its HEAD moved since gc checked it");
832
+ const mergedHead = candidate.merge?.merged ? candidate.merge.head : endedPullRequest(candidate.pullRequest) ? candidate.head : null;
833
+ if (mergedHead) {
834
+ if (resolveFullRef(candidate.path, "HEAD") !== mergedHead) reasons.push("its HEAD moved since gc checked it");
720
835
  } else {
721
836
  const idleDays = idleDaysOf(keys, now);
722
837
  if (idleDays === null || !sweep.idle || idleDays < sweep.idle.olderThan) reasons.push(`used ${idleDays === null ? "at an unknown time" : `${idleDays}d ago`} since gc checked it`);
@@ -724,6 +839,7 @@ async function removeWorktrees(sweep, { now = Date.now() } = {}) {
724
839
  if (sweep.graceMs > 0) {
725
840
  const recent = graceBlocker({
726
841
  merge: candidate.merge,
842
+ pullRequest: candidate.pullRequest,
727
843
  activity: worktreeActivityOf(candidate.path, keys)
728
844
  }, {
729
845
  ms: sweep.graceMs,
@@ -735,21 +851,29 @@ async function removeWorktrees(sweep, { now = Date.now() } = {}) {
735
851
  return reasons;
736
852
  };
737
853
  let removed = false;
854
+ let error = null;
738
855
  try {
739
- const mergedHead = candidate.merge?.merged && candidate.merge.coversUnpushed ? candidate.merge.head : void 0;
856
+ const ended = endedPullRequest(candidate.pullRequest);
857
+ const mergedHead = candidate.merge?.merged && candidate.merge.coversUnpushed ? candidate.merge.head : ended?.state === "merged" ? candidate.head ?? void 0 : void 0;
740
858
  removed = await removeWorktreeTarget(candidate.path, {
741
859
  linkedOnly: true,
742
860
  guard,
743
861
  mergedHead
744
862
  });
745
- } catch (error) {
746
- console.error(chalk.red(`Could not remove ${candidate.path}: ${error?.message || String(error)}`));
863
+ } catch (thrown) {
864
+ error = thrown?.message || String(thrown);
865
+ console.error(chalk.red(`Could not remove ${candidate.path}: ${error}`));
747
866
  }
748
- if (removed) console.log(chalk.green(`Removed the worktree ${candidate.path} (${worktreeRemovalReason(candidate)})`));
749
- else if (kept.length) console.log(chalk.yellow(`Kept the worktree ${candidate.path}: ${kept.join("; ")}`));
750
- else {
867
+ if (removed) {
868
+ console.log(chalk.green(`Removed the worktree ${candidate.path} (${worktreeRemovalReason(candidate)})`));
869
+ recordGcResult("worktree", "done", candidate.path, { detail: worktreeRemovalReason(candidate) });
870
+ } else if (kept.length) {
871
+ console.log(chalk.yellow(`Kept the worktree ${candidate.path}: ${kept.join("; ")}`));
872
+ recordGcResult("worktree", "kept", candidate.path, { detail: kept.join("; ") });
873
+ } else {
751
874
  failures++;
752
875
  console.error(chalk.yellow(`Kept the worktree ${candidate.path}; see the lines above for why.`));
876
+ recordGcResult("worktree", "failed", candidate.path, { detail: error ?? "stim worktree remove did not remove it; see the details" });
753
877
  }
754
878
  }
755
879
  return failures;
@@ -804,7 +928,7 @@ function unresolvedLockLines(locks, slots) {
804
928
  " run that yourself once you know nothing is building with it"
805
929
  ];
806
930
  }
807
- function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invalidProjects = [], orphanedWorkspaces = [], parkedSims = [], parkedAvds = [], orphanedDevices = [], unverifiedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
931
+ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invalidProjects = [], orphanedWorkspaces = [], parkedSims = [], parkedAvds = [], orphanedDevices = [], unverifiedDevices = [], staleDevices = [], staleDeviceRecords = [], staleLedgerEntries = [], buildLocks = {
808
932
  stale: [],
809
933
  live: [],
810
934
  unresolved: []
@@ -820,7 +944,7 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
820
944
  orphaned: [],
821
945
  notices: [],
822
946
  deletionSafe: true
823
- }, caches = [], workspaceOutputs = null, worktreeSweep = null, cacheScope = null, olderThan = null }, { now = Date.now() } = {}) {
947
+ }, caches = [], workspaceOutputs = null, workspaceLogs = [], worktreeSweep = null, cacheScope = null, olderThan = null }, { now = Date.now() } = {}) {
824
948
  const lines = [];
825
949
  const staleLocks = buildLocks?.stale ?? [];
826
950
  const liveLocks = buildLocks?.live ?? [];
@@ -836,11 +960,13 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
836
960
  orphanedDevices,
837
961
  staleDevices,
838
962
  staleDeviceRecords,
963
+ staleLedgerEntries,
839
964
  staleLocks,
840
965
  staleSlots,
841
966
  expiredLeases,
842
967
  easSessionSweep.orphaned,
843
968
  workspaceOutputs?.workspaces.filter((entry) => entry.willClear) ?? [],
969
+ workspaceLogs.filter((entry) => entry.willTrim),
844
970
  worktreeSweep?.worktrees.filter((entry) => !entry.skipped) ?? []
845
971
  ].every((found) => found.length === 0)) {
846
972
  const reasons = [];
@@ -862,7 +988,7 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
862
988
  for (const d of orphanedDevices) lines.push(` ${d.kind} ${d.name} (${d.id})${deviceSizeSuffix(d)}`);
863
989
  }
864
990
  if (unverifiedDevices.length) {
865
- lines.push(`Unrecognized stim-* devices (${unverifiedDevices.length}) - NOT deleted, because Stim has no record of creating them:`);
991
+ lines.push(`Unrecognized stim-* devices (${unverifiedDevices.length}) - NOT deleted, because this Stim home has no record of creating them:`);
866
992
  for (const d of unverifiedDevices) {
867
993
  lines.push(` ${d.kind} ${d.name} (${d.id})`);
868
994
  lines.push(` ${unverifiedDeviceCommand(d)}`);
@@ -885,6 +1011,11 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
885
1011
  }
886
1012
  lines.push(" --delete clears the RECORD only; there is no device left to touch.");
887
1013
  }
1014
+ if (staleLedgerEntries.length) {
1015
+ lines.push(`Stale device ledger entries (${staleLedgerEntries.length}) - Stim created these devices or browser profiles; they are gone:`);
1016
+ for (const entry of staleLedgerEntries) lines.push(` ${entry.kind} ${entry.id} is not on this machine`);
1017
+ lines.push(" --delete forgets the LEDGER ENTRY only; there is no device left to touch.");
1018
+ }
888
1019
  if (easSessionSweep.orphaned.length) {
889
1020
  lines.push(`Orphaned EAS sessions (${easSessionSweep.orphaned.length}) - current EAS project only:`);
890
1021
  for (const session of easSessionSweep.orphaned) {
@@ -929,9 +1060,21 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
929
1060
  lines.push(`Skipped (${skipped.length}) - not classified as dead:`);
930
1061
  for (const entry of skipped) lines.push(` ${entry.dir}: ${entry.reason}`);
931
1062
  }
1063
+ lines.push(...workspaceLogLines(workspaceLogs));
932
1064
  lines.push(...cacheLines(caches, workspaceOutputs));
933
1065
  return lines;
934
1066
  }
1067
+ function workspaceLogLines(workspaceLogs) {
1068
+ const over = workspaceLogs.filter((w) => w.trimBytes > 0);
1069
+ if (!over.length) return [];
1070
+ const total = workspaceLogs.reduce((n, w) => n + w.bytes, 0);
1071
+ const lines = [`Workspace logs (${formatBytes(total)} in ${workspaceLogs.length} workspace${workspaceLogs.length === 1 ? "" : "s"}) - ${over.length} with a log over twice the 8 MiB cap:`];
1072
+ for (const w of over) {
1073
+ lines.push(` ${formatBytes(w.bytes).padStart(10)} ${w.projectRoot ?? w.dir}`);
1074
+ lines.push(w.willTrim ? ` would TRIM ${formatBytes(w.trimBytes)}: Metro, client and device logs keep their newest 8 MiB` : ` kept: ${w.keptReason}`);
1075
+ }
1076
+ return lines;
1077
+ }
935
1078
  function leaseGarbageLines({ expired, kept }) {
936
1079
  const lines = [];
937
1080
  if (expired.length) {
@@ -950,7 +1093,7 @@ function leaseGarbageLines({ expired, kept }) {
950
1093
  return lines;
951
1094
  }
952
1095
  function deviceSizeSuffix(device) {
953
- return device.kind === "android" && device.bytes !== void 0 ? ` - ${formatBytes(device.bytes)} on disk` : "";
1096
+ return device.bytes !== void 0 ? ` - ${formatBytes(device.bytes)} on disk` : "";
954
1097
  }
955
1098
  function namedPortLines(ports = []) {
956
1099
  if (!ports.length) return [];
@@ -1018,11 +1161,14 @@ function worktreeSweepLines(sweep) {
1018
1161
  if (!sweep || !sweep.idle && !sweep.worktrees.length) return [];
1019
1162
  const removable = sweep.worktrees.filter((w) => !w.skipped);
1020
1163
  const idle = !sweep.idle ? "" : sweep.idle.defaulted ? `, or clean, pushed and idle ${sweep.idle.olderThan}d or more (the default without --older-than)` : `, or clean, pushed and idle ${sweep.idle.olderThan}d or more`;
1021
- const lines = [`Linked worktrees (${removable.length} removable, ${sweep.worktrees.length - removable.length} kept) - clean and merged into the default branch${idle}:`];
1164
+ const lines = [`Linked worktrees (${removable.length} removable, ${sweep.worktrees.length - removable.length} kept) - clean and merged into the default branch, or with a merged or closed pull request${idle}:`];
1165
+ const unknown = new Set(sweep.worktrees.flatMap((w) => w.pullRequest && "unavailable" in w.pullRequest ? [w.pullRequest.unavailable] : []));
1166
+ for (const reason of unknown) lines.push(` Pull request state unknown (${reason}); merges are judged from git alone.`);
1022
1167
  for (const w of sweep.worktrees) {
1023
1168
  const age = w.idleDays === null ? "" : ` (idle ${w.idleDays}d)`;
1024
1169
  lines.push(` ${w.path}${age}`);
1025
- lines.push(w.skipped ? ` kept: ${w.skipped}` : ` would be REMOVED by \`stim worktree remove\`: ${worktreeRemovalReason(w)}`);
1170
+ const note = worktreePullRequestNote(w);
1171
+ lines.push(w.skipped ? ` kept: ${w.skipped}${note && !w.skipped.includes(note) ? ` (${note})` : ""}` : ` would be REMOVED by \`stim worktree remove\`: ${worktreeRemovalReason(w)}`);
1026
1172
  }
1027
1173
  if (removable.length) lines.push(" --delete runs it without --force; use, idleness and HEAD are re-checked under its removal locks.");
1028
1174
  return lines;
@@ -1036,7 +1182,16 @@ function jsonLock({ path, platform, key, pid, projectRoot }) {
1036
1182
  projectRoot
1037
1183
  };
1038
1184
  }
1039
- function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [], invalidProjects = [], orphanedWorkspaces = [], worktreeSweep = null, parkedSims = [], parkedAvds = [], orphanedDevices = [], unverifiedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
1185
+ function jsonPullRequest(lookup) {
1186
+ const pr = lookup && "pullRequest" in lookup ? lookup.pullRequest : null;
1187
+ return pr ? {
1188
+ number: pr.number,
1189
+ state: pr.state,
1190
+ url: pr.url,
1191
+ containsHead: pr.containsHead
1192
+ } : null;
1193
+ }
1194
+ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [], invalidProjects = [], orphanedWorkspaces = [], worktreeSweep = null, parkedSims = [], parkedAvds = [], orphanedDevices = [], unverifiedDevices = [], staleDevices = [], staleDeviceRecords = [], staleLedgerEntries = [], buildLocks = {
1040
1195
  stale: [],
1041
1196
  live: [],
1042
1197
  unresolved: []
@@ -1052,7 +1207,7 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
1052
1207
  orphaned: [],
1053
1208
  notices: [],
1054
1209
  deletionSafe: true
1055
- }, workspaceOutputs = null, caches = [] }) {
1210
+ }, workspaceOutputs = null, workspaceLogs = [], caches = [] }) {
1056
1211
  return {
1057
1212
  deadProjects: deadProjects.map((path) => ({ path })),
1058
1213
  invalidProjects: invalidProjects.map((path) => ({ path })),
@@ -1070,6 +1225,8 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
1070
1225
  path: w.path,
1071
1226
  idleDays: w.idleDays,
1072
1227
  mergedInto: w.merge?.merged ? w.merge.into : null,
1228
+ pullRequest: jsonPullRequest(w.pullRequest),
1229
+ pullRequestUnknown: w.pullRequest && "unavailable" in w.pullRequest ? w.pullRequest.unavailable : null,
1073
1230
  willRemove: w.skipCode === null,
1074
1231
  reason: w.skipCode,
1075
1232
  detail: w.skipped ?? worktreeRemovalReason(w),
@@ -1120,6 +1277,10 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
1120
1277
  project: r.project,
1121
1278
  slot: r.slot ?? null
1122
1279
  })),
1280
+ staleLedgerEntries: staleLedgerEntries.map(({ kind, id }) => ({
1281
+ kind,
1282
+ id
1283
+ })),
1123
1284
  idleDevices: idleDevices.map(({ kind, id, name, project, slot, lastActivityAt, idleForMs, buildInProgress }) => ({
1124
1285
  kind,
1125
1286
  id,
@@ -1171,6 +1332,15 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
1171
1332
  path: dir,
1172
1333
  detail: reason
1173
1334
  })),
1335
+ workspaceLogs: workspaceLogs.map((w) => ({
1336
+ dir: w.dir,
1337
+ projectRoot: w.projectRoot,
1338
+ bytes: w.bytes,
1339
+ trimBytes: w.trimBytes,
1340
+ willTrim: w.willTrim,
1341
+ reason: w.keptCode,
1342
+ detail: w.keptReason
1343
+ })),
1174
1344
  workspaceBuildOutputs: (workspaceOutputs?.workspaces ?? []).map((w) => ({
1175
1345
  dir: w.dir,
1176
1346
  projectRoot: w.projectRoot,
@@ -1192,11 +1362,505 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
1192
1362
  };
1193
1363
  }
1194
1364
  //#endregion
1365
+ //#region src/commands/gc/ledger.ts
1366
+ /** `sims` must be a complete listing, unavailable simulators included, taken after `ledger` was read. */
1367
+ function findStaleLedgerEntries(ledger, sims) {
1368
+ const listed = new Set(sims.map((sim) => sim.udid));
1369
+ return [...ledger.ios].filter((udid) => !listed.has(udid)).map((id) => ({
1370
+ kind: "ios",
1371
+ id
1372
+ }));
1373
+ }
1374
+ /**
1375
+ * `registered` must come from an emulator listing that succeeded after `ledger` was read. A name stays while an
1376
+ * unfinished setup reserves it, or while any AVD root could still hold it.
1377
+ */
1378
+ function findStaleAndroidLedgerEntries(ledger, registered, config) {
1379
+ const listed = new Set(registered);
1380
+ return [...ledger.android].filter((name) => !listed.has(name) && isStaleAvdName(name, config)).map((id) => ({
1381
+ kind: "android",
1382
+ id
1383
+ }));
1384
+ }
1385
+ function isStaleAvdName(name, config) {
1386
+ try {
1387
+ return !reservedForSetup(name, config) && avdNameAbsent(name);
1388
+ } catch {
1389
+ return false;
1390
+ }
1391
+ }
1392
+ function reservedForSetup(name, config) {
1393
+ return Object.values(config?.projects ?? {}).some((project) => projectDeviceSlots(project).some(({ platforms }) => platforms.android?.setupIncomplete && platforms.android.avdName === name));
1394
+ }
1395
+ function forgetStaleAvdName(name) {
1396
+ let claim = null;
1397
+ try {
1398
+ claim = acquireAvdClaim(name);
1399
+ return withConfigLock$1(() => {
1400
+ if (!isStaleAvdName(name, loadConfig$1())) return false;
1401
+ forgetCreatedDevice("android", name);
1402
+ return true;
1403
+ });
1404
+ } finally {
1405
+ (0, ownership_claim_exports.releaseClaim)(claim);
1406
+ }
1407
+ }
1408
+ /**
1409
+ * Browser profiles the ledger lists whose directory is gone while the workspaces directory that held it is
1410
+ * present and the workspace is not a link to a missing volume, so an unmounted volume never reads as a deleted
1411
+ * profile.
1412
+ */
1413
+ function staleBrowserProfiles(ledger, exists = existsSync) {
1414
+ return [...ledger.web].filter((path) => isStaleProfile(path, exists)).map((id) => ({
1415
+ kind: "web",
1416
+ id
1417
+ }));
1418
+ }
1419
+ function isStaleProfile(path, exists) {
1420
+ const workspace = dirname$1(dirname$1(path));
1421
+ return !exists(path) && exists(dirname$1(workspace)) && !isDanglingLink(workspace);
1422
+ }
1423
+ function isDanglingLink(path) {
1424
+ try {
1425
+ return lstatSync(path).isSymbolicLink() && !existsSync(path);
1426
+ } catch {
1427
+ return false;
1428
+ }
1429
+ }
1430
+ function forgetStaleLedgerEntries(entries) {
1431
+ let failures = 0;
1432
+ for (const entry of entries) {
1433
+ if (entry.kind === "web" && !isStaleProfile(entry.id, existsSync)) continue;
1434
+ try {
1435
+ if (entry.kind === "android") {
1436
+ if (!forgetStaleAvdName(entry.id)) {
1437
+ console.log(chalk.dim(`Kept the ledger entry for android ${entry.id}: the AVD or its setup reappeared`));
1438
+ continue;
1439
+ }
1440
+ } else forgetCreatedDevice(entry.kind, entry.id);
1441
+ console.log(chalk.green(`Forgot the ledger entry for ${entry.kind} ${entry.id} (not on this machine)`));
1442
+ } catch (error) {
1443
+ failures++;
1444
+ console.log(chalk.red(`Could not forget the ledger entry for ${entry.kind} ${entry.id}: ${error?.message || error}`));
1445
+ }
1446
+ }
1447
+ return failures;
1448
+ }
1449
+ //#endregion
1450
+ //#region src/commands/gc/logs.ts
1451
+ const CAPPED_LOGS = [
1452
+ "metro.ndjson",
1453
+ "client.ndjson",
1454
+ "device.ndjson"
1455
+ ];
1456
+ const TRIM_ABOVE_BYTES = 2 * LOG_ROTATE_BYTES;
1457
+ function fileSize(path) {
1458
+ try {
1459
+ const stat = statSync$1(path);
1460
+ return stat.isFile() ? stat.size : 0;
1461
+ } catch {
1462
+ return 0;
1463
+ }
1464
+ }
1465
+ function cappedFiles(logsDir) {
1466
+ return CAPPED_LOGS.flatMap((name) => {
1467
+ const log = join(logsDir, name);
1468
+ return [{
1469
+ file: log,
1470
+ log
1471
+ }, {
1472
+ file: rotatedLogPath(log),
1473
+ log
1474
+ }];
1475
+ });
1476
+ }
1477
+ function trimmable(size) {
1478
+ return size > TRIM_ABOVE_BYTES ? size - LOG_ROTATE_BYTES : 0;
1479
+ }
1480
+ function excess(logsDir) {
1481
+ return cappedFiles(logsDir).reduce((sum, { file }) => sum + trimmable(fileSize(file)), 0);
1482
+ }
1483
+ function collectorReason(root) {
1484
+ const platforms = Object.keys(readCollectors(root));
1485
+ return platforms.length ? `a device log collector is recorded for ${platforms.join(", ")}` : null;
1486
+ }
1487
+ function collectWorkspaceLogs({ exclude = [] } = {}) {
1488
+ const mountedVolumes = listMountedVolumes();
1489
+ const entries = [];
1490
+ for (const { dir, projectRoot, problem } of listWorkspaceDirs()) {
1491
+ if (exclude.includes(dir)) continue;
1492
+ if (projectRoot !== null && !isOnMountedVolume(projectRoot, mountedVolumes)) continue;
1493
+ const logsDir = join(dir, "logs");
1494
+ let names;
1495
+ try {
1496
+ names = readdirSync$1(logsDir);
1497
+ } catch {
1498
+ continue;
1499
+ }
1500
+ const bytes = names.reduce((sum, name) => sum + fileSize(join(logsDir, name)), 0);
1501
+ if (bytes === 0) continue;
1502
+ const trimBytes = excess(logsDir);
1503
+ let keptCode = null;
1504
+ let keptReason = null;
1505
+ if (trimBytes > 0) {
1506
+ if (projectRoot === null) {
1507
+ keptCode = "unresolved";
1508
+ keptReason = `workspace directory not resolved: ${problem ?? "unknown project root"}`;
1509
+ } else {
1510
+ const inUse = workspaceInUse(projectRoot);
1511
+ const collector = collectorReason(projectRoot);
1512
+ if (inUse.length) {
1513
+ keptCode = "in-use";
1514
+ keptReason = `in use: ${inUse.join("; ")}`;
1515
+ } else if (collector) {
1516
+ keptCode = "collector";
1517
+ keptReason = collector;
1518
+ }
1519
+ }
1520
+ }
1521
+ entries.push({
1522
+ dir,
1523
+ projectRoot,
1524
+ bytes,
1525
+ trimBytes,
1526
+ willTrim: trimBytes > 0 && keptCode === null,
1527
+ keptCode,
1528
+ keptReason
1529
+ });
1530
+ }
1531
+ return entries;
1532
+ }
1533
+ function trimToNewest({ file, log }, maxBytes) {
1534
+ if (!trimmable(fileSize(file))) return 0;
1535
+ return withDirLock(`${log}.lock`, () => {
1536
+ const { size, atime, mtime } = statSync$1(file);
1537
+ if (!trimmable(size)) return 0;
1538
+ const tail = Buffer.alloc(maxBytes);
1539
+ const fd = openSync$1(file, "r");
1540
+ try {
1541
+ readSync$1(fd, tail, 0, maxBytes, size - maxBytes);
1542
+ } finally {
1543
+ closeSync$1(fd);
1544
+ }
1545
+ const newline = tail.indexOf(10);
1546
+ const kept = newline === -1 ? Buffer.alloc(0) : tail.subarray(newline + 1);
1547
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
1548
+ try {
1549
+ writeFileSync$1(tmp, kept);
1550
+ utimesSync(tmp, atime, mtime);
1551
+ renameSync$1(tmp, file);
1552
+ } catch (error) {
1553
+ rmSync$1(tmp, { force: true });
1554
+ throw error;
1555
+ }
1556
+ return size - kept.length;
1557
+ });
1558
+ }
1559
+ async function trimWorkspaceLogs(entries) {
1560
+ let failures = 0;
1561
+ let trimmed = 0;
1562
+ for (const entry of entries) {
1563
+ const root = entry.projectRoot;
1564
+ if (!entry.willTrim || root === null) {
1565
+ if (entry.keptReason) console.log(chalk.dim(`Kept the logs of ${root ?? entry.dir}: ${entry.keptReason}`));
1566
+ continue;
1567
+ }
1568
+ let run;
1569
+ try {
1570
+ run = await withIdleWorkspace(root, () => {
1571
+ const collector = collectorReason(root);
1572
+ if (collector) return {
1573
+ kept: collector,
1574
+ bytes: 0
1575
+ };
1576
+ return {
1577
+ kept: null,
1578
+ bytes: cappedFiles(join(entry.dir, "logs")).reduce((sum, capped) => sum + trimToNewest(capped, LOG_ROTATE_BYTES), 0)
1579
+ };
1580
+ }, { purpose: "gc" });
1581
+ } catch (error) {
1582
+ failures++;
1583
+ console.log(chalk.red(`Could not trim the logs of ${root}: ${error?.message || String(error)}`));
1584
+ continue;
1585
+ }
1586
+ if (!run.ran || run.value.kept) {
1587
+ const kept = run.ran ? run.value.kept : `in use: ${run.reasons.join("; ")}`;
1588
+ console.log(chalk.yellow(`Kept the logs of ${root}: ${kept}`));
1589
+ continue;
1590
+ }
1591
+ trimmed += run.value.bytes;
1592
+ console.log(chalk.green(`Trimmed the logs of ${root} (${formatBytes(run.value.bytes)})`));
1593
+ }
1594
+ if (trimmed) console.log(chalk.dim(`Trimmed ${formatBytes(trimmed)} of workspace logs.`));
1595
+ return failures;
1596
+ }
1597
+ //#endregion
1598
+ //#region src/commands/gc/inventory.ts
1599
+ const IOS_SIMULATOR_PLATFORM = "com.apple.platform.iphonesimulator";
1600
+ function text(value) {
1601
+ return typeof value === "string" && value.length > 0 ? value : null;
1602
+ }
1603
+ function parseSimRuntimeList(jsonOutput) {
1604
+ const parsed = JSON.parse(jsonOutput);
1605
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Expected simctl runtime list to return a JSON object.");
1606
+ const runtimes = [];
1607
+ for (const [key, value] of Object.entries(parsed)) {
1608
+ if (value === null || typeof value !== "object" || Array.isArray(value)) continue;
1609
+ const entry = value;
1610
+ if (entry.platformIdentifier !== IOS_SIMULATOR_PLATFORM) continue;
1611
+ runtimes.push({
1612
+ identifier: text(entry.identifier) ?? key,
1613
+ runtimeIdentifier: text(entry.runtimeIdentifier),
1614
+ version: text(entry.version),
1615
+ build: text(entry.build),
1616
+ sizeBytes: typeof entry.sizeBytes === "number" && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null,
1617
+ deletable: entry.deletable === true,
1618
+ lastUsedAt: text(entry.lastUsedAt)
1619
+ });
1620
+ }
1621
+ return runtimes;
1622
+ }
1623
+ function unlistedRuntimes(jsonOutput, images) {
1624
+ const known = new Set(images.map((image) => image.runtimeIdentifier));
1625
+ const parsed = JSON.parse(jsonOutput);
1626
+ if (!Array.isArray(parsed.runtimes)) throw new Error("Expected simctl list runtimes to return a runtimes array.");
1627
+ return parsed.runtimes.flatMap((value) => {
1628
+ if (value === null || typeof value !== "object") return [];
1629
+ const entry = value;
1630
+ const identifier = text(entry.identifier);
1631
+ if (!identifier || !/\.iOS-/.test(identifier) || known.has(identifier)) return [];
1632
+ return [{
1633
+ identifier,
1634
+ runtimeIdentifier: identifier,
1635
+ version: text(entry.version),
1636
+ build: text(entry.buildversion),
1637
+ sizeBytes: null,
1638
+ deletable: false,
1639
+ lastUsedAt: null
1640
+ }];
1641
+ });
1642
+ }
1643
+ function modelName(deviceTypeIdentifier) {
1644
+ return deviceTypeIdentifier.replace(/^com\.apple\.CoreSimulator\.SimDeviceType\./, "").replaceAll("-", " ");
1645
+ }
1646
+ function sdkmanagerCommand(pkg) {
1647
+ const tool = androidToolPath("sdkmanager");
1648
+ return `${tool === "sdkmanager" ? tool : JSON.stringify(tool)} --uninstall ${JSON.stringify(pkg)}`;
1649
+ }
1650
+ function buildInventory({ sims, avds, runtimes, systemImages, config, deadProjects = [] }) {
1651
+ const dead = new Set(deadProjects);
1652
+ const assigned = /* @__PURE__ */ new Map();
1653
+ for (const [project, record] of Object.entries(config?.projects ?? {})) {
1654
+ if (dead.has(project)) continue;
1655
+ let slots;
1656
+ try {
1657
+ slots = projectDeviceSlots(record);
1658
+ } catch {
1659
+ continue;
1660
+ }
1661
+ for (const { slot, platforms } of slots) {
1662
+ if (platforms.ios?.deviceUdid) assigned.set(`ios:${platforms.ios.deviceUdid}`, {
1663
+ project,
1664
+ slot
1665
+ });
1666
+ if (platforms.android?.avdName) assigned.set(`android:${platforms.android.avdName}`, {
1667
+ project,
1668
+ slot
1669
+ });
1670
+ }
1671
+ }
1672
+ const parked = /* @__PURE__ */ new Set([...readParked("ios", { config }).map((sim) => `ios:${sim.udid}`), ...readParked("android", { config }).map((avd) => `android:${avd.name}`)]);
1673
+ const owner = (key, name, created) => {
1674
+ const workspace = assigned.get(key);
1675
+ if (workspace && created()) return {
1676
+ owner: "workspace",
1677
+ project: workspace.project,
1678
+ slot: workspace.slot
1679
+ };
1680
+ if (parked.has(key)) return {
1681
+ owner: "parked",
1682
+ project: null,
1683
+ slot: null
1684
+ };
1685
+ if (!name.startsWith("stim-")) return {
1686
+ owner: "user",
1687
+ project: null,
1688
+ slot: null
1689
+ };
1690
+ return {
1691
+ owner: created() ? "orphaned" : "otherStimHome",
1692
+ project: null,
1693
+ slot: null
1694
+ };
1695
+ };
1696
+ const devices = [...sims.map((sim) => ({
1697
+ kind: "ios",
1698
+ id: sim.udid,
1699
+ name: sim.name,
1700
+ model: modelName(sim.deviceTypeIdentifier),
1701
+ runtime: sim.runtime,
1702
+ state: sim.state,
1703
+ lastUsedAt: sim.lastUsedAt ?? null,
1704
+ bytes: sim.dataPathSize ?? null,
1705
+ directory: sim.dataPath ? sim.dataPath.replace(/\/data\/?$/, "") : null,
1706
+ ...owner(`ios:${sim.udid}`, sim.name, () => isStimOwnedSim({
1707
+ udid: sim.udid,
1708
+ name: sim.name
1709
+ }))
1710
+ })), ...avds.map((avd) => ({
1711
+ kind: "android",
1712
+ id: avd.name,
1713
+ name: avd.name,
1714
+ model: avd.deviceProfile,
1715
+ runtime: avd.systemImage,
1716
+ state: null,
1717
+ lastUsedAt: avd.lastUsedAt,
1718
+ bytes: null,
1719
+ directory: avd.directory,
1720
+ ...owner(`android:${avd.name}`, avd.name, () => isStimOwnedAvd(avd.name))
1721
+ }))];
1722
+ const simsByRuntime = /* @__PURE__ */ new Map();
1723
+ for (const sim of sims) simsByRuntime.set(sim.runtime, (simsByRuntime.get(sim.runtime) ?? 0) + 1);
1724
+ const avdsByImage = /* @__PURE__ */ new Map();
1725
+ for (const avd of avds) if (avd.systemImage) avdsByImage.set(avd.systemImage, (avdsByImage.get(avd.systemImage) ?? 0) + 1);
1726
+ return {
1727
+ devices,
1728
+ runtimes: runtimes.map((runtime) => ({
1729
+ identifier: runtime.identifier,
1730
+ runtimeIdentifier: runtime.runtimeIdentifier,
1731
+ version: runtime.version,
1732
+ build: runtime.build,
1733
+ bytes: runtime.sizeBytes,
1734
+ lastUsedAt: runtime.lastUsedAt,
1735
+ deviceCount: runtime.runtimeIdentifier ? simsByRuntime.get(runtime.runtimeIdentifier) ?? 0 : 0,
1736
+ command: runtime.deletable ? `xcrun simctl runtime delete ${runtime.identifier}` : null
1737
+ })),
1738
+ systemImages: systemImages.map((image) => ({
1739
+ package: image.pkg,
1740
+ directory: image.directory,
1741
+ avdCount: avdsByImage.get(image.pkg) ?? 0,
1742
+ command: sdkmanagerCommand(image.pkg)
1743
+ }))
1744
+ };
1745
+ }
1746
+ function modifiedAt(path) {
1747
+ try {
1748
+ return statSync(path).mtime.toISOString();
1749
+ } catch {
1750
+ return null;
1751
+ }
1752
+ }
1753
+ function isMissing(error) {
1754
+ const code = error.code;
1755
+ return code === "ENOENT" || code === "ENOTDIR";
1756
+ }
1757
+ function unreadableNotice(what, error) {
1758
+ const notice = `${what} not listed: ${describeError(error)}`;
1759
+ if (process.platform !== "darwin" || error.code !== "EPERM") return notice;
1760
+ return `${notice}. macOS privacy protection blocked the read; allow ${process.env.STIM_DESKTOP_APP ? "Stim Desktop" : "the app that runs stim"} under System Settings > Privacy & Security > Files and Folders (Removable Volumes for an external disk) or Full Disk Access`;
1761
+ }
1762
+ function listAvdRecords(notices, roots = avdStorageRoots()) {
1763
+ const records = /* @__PURE__ */ new Map();
1764
+ for (const root of roots) {
1765
+ let names;
1766
+ try {
1767
+ names = readdirSync(root);
1768
+ } catch (error) {
1769
+ if (!isMissing(error)) notices.push(unreadableNotice(`AVDs in ${root}`, error));
1770
+ continue;
1771
+ }
1772
+ for (const entry of names) {
1773
+ if (!entry.endsWith(".ini")) continue;
1774
+ const name = entry.slice(0, -4);
1775
+ if (records.has(name)) continue;
1776
+ let ini;
1777
+ try {
1778
+ ini = readFileSync(join$1(root, entry), "utf8");
1779
+ } catch (error) {
1780
+ if (!isMissing(error)) notices.push(unreadableNotice(`AVD ${name}`, error));
1781
+ continue;
1782
+ }
1783
+ let directory = null;
1784
+ let config = "";
1785
+ let unreadable = null;
1786
+ for (const candidate of avdIniPaths(root, ini)) try {
1787
+ config = readFileSync(join$1(candidate, "config.ini"), "utf8");
1788
+ directory = candidate;
1789
+ break;
1790
+ } catch (error) {
1791
+ if (!isMissing(error)) unreadable ??= error;
1792
+ }
1793
+ if (!directory && unreadable) notices.push(unreadableNotice(`details of AVD ${name}`, unreadable));
1794
+ records.set(name, {
1795
+ name,
1796
+ directory,
1797
+ systemImage: parseAvdSystemImage(config),
1798
+ deviceProfile: parseAvdDeviceProfile(config),
1799
+ lastUsedAt: directory ? modifiedAt(join$1(directory, "hardware-qemu.ini")) : null
1800
+ });
1801
+ }
1802
+ }
1803
+ return [...records.values()];
1804
+ }
1805
+ function simctl(args) {
1806
+ return getExecutor().runFile("xcrun", ["simctl", ...args], {
1807
+ timeoutMs: DEVICE_LIST_TIMEOUT_MS,
1808
+ killSignal: "SIGKILL"
1809
+ });
1810
+ }
1811
+ function collectInventory(config, deadProjects) {
1812
+ const notices = [];
1813
+ let sims = [];
1814
+ let runtimes = [];
1815
+ if (process.platform === "darwin") {
1816
+ try {
1817
+ sims = listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
1818
+ } catch (error) {
1819
+ notices.push(`simulators not listed: ${describeError(error)}`);
1820
+ }
1821
+ try {
1822
+ runtimes = parseSimRuntimeList(simctl([
1823
+ "runtime",
1824
+ "list",
1825
+ "-j"
1826
+ ]));
1827
+ } catch (error) {
1828
+ notices.push(`simulator runtime images not listed: ${describeError(error)}`);
1829
+ }
1830
+ try {
1831
+ runtimes.push(...unlistedRuntimes(simctl([
1832
+ "list",
1833
+ "runtimes",
1834
+ "-j"
1835
+ ]), runtimes));
1836
+ } catch (error) {
1837
+ notices.push(`simulator runtimes not listed: ${describeError(error)}`);
1838
+ }
1839
+ }
1840
+ const imagesRoot = join$1(androidHome(), "system-images");
1841
+ const images = listInstalledSystemImages((error) => notices.push(unreadableNotice("Android system images", error))).map((image) => ({
1842
+ pkg: image.pkg,
1843
+ directory: join$1(imagesRoot, `android-${image.api}`, image.tag, image.arch)
1844
+ }));
1845
+ return {
1846
+ ...buildInventory({
1847
+ sims,
1848
+ avds: listAvdRecords(notices),
1849
+ runtimes,
1850
+ systemImages: images,
1851
+ config,
1852
+ deadProjects
1853
+ }),
1854
+ notices
1855
+ };
1856
+ }
1857
+ //#endregion
1195
1858
  //#region src/commands/gc.ts
1196
1859
  function removeInvalidProjectEntries(invalidProjects) {
1197
1860
  for (const path of invalidProjects) {
1198
1861
  removeProject(path);
1199
1862
  console.log(chalk.green(`Removed the invalid registry entry ${path}`));
1863
+ recordGcResult("project", "done", path);
1200
1864
  }
1201
1865
  }
1202
1866
  async function collectGcReport({ olderThan = null, cache = null, worktrees = false, now = Date.now(), lastTouched = workspaceLastUsed } = {}, deps = {}) {
@@ -1214,6 +1878,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1214
1878
  unverifiedDevices: [],
1215
1879
  staleDevices: [],
1216
1880
  staleDeviceRecords: [],
1881
+ staleLedgerEntries: [],
1217
1882
  buildLocks: {
1218
1883
  stale: [],
1219
1884
  live: [],
@@ -1243,6 +1908,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1243
1908
  olderThan,
1244
1909
  now
1245
1910
  }) : null,
1911
+ workspaceLogs: [],
1246
1912
  worktreeSweep: null,
1247
1913
  cacheScope: scope,
1248
1914
  olderThan,
@@ -1282,7 +1948,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1282
1948
  else invalidProjects.push(path);
1283
1949
  continue;
1284
1950
  }
1285
- if (existsSync(path)) continue;
1951
+ if (existsSync$1(path)) continue;
1286
1952
  if (!isOnMountedVolume(path, mountedVolumes)) {
1287
1953
  const volume = volumeRootFor(path);
1288
1954
  skipped.push({
@@ -1298,6 +1964,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1298
1964
  let unverifiedDevices = [];
1299
1965
  let staleDevices = [];
1300
1966
  let staleDeviceRecords = [];
1967
+ let staleLedgerEntries = staleBrowserProfiles(readCreatedDevices$1());
1301
1968
  let idleDevices = [];
1302
1969
  const unsweepableReason = cfg === null ? "no Stim config found" : deviceSweepIsScoped() ? "STIM_HOME scopes this config, but simulators and AVDs are machine-global" : null;
1303
1970
  if (unsweepableReason) {
@@ -1313,8 +1980,14 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1313
1980
  } else {
1314
1981
  let sims = [];
1315
1982
  let simsChecked = true;
1983
+ const ledger = readCreatedDevices$1();
1316
1984
  try {
1317
- sims = listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
1985
+ const allSims = listAllIosSims({
1986
+ timeoutMs: DEVICE_LIST_TIMEOUT_MS,
1987
+ includeUnavailable: true
1988
+ });
1989
+ staleLedgerEntries = [...staleLedgerEntries, ...findStaleLedgerEntries(ledger, allSims)];
1990
+ sims = allSims.filter((sim) => sim.available);
1318
1991
  } catch {
1319
1992
  simsChecked = false;
1320
1993
  deviceSweepNotices.push(`ios device sweep skipped: simulator tooling did not answer within ${DEVICE_LIST_TIMEOUT_MS / 1e3}s`);
@@ -1334,6 +2007,8 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1334
2007
  avdsChecked = false;
1335
2008
  deviceSweepNotices.push(`android data sweep skipped: ${error.message}`);
1336
2009
  }
2010
+ if (avdsChecked) staleLedgerEntries = [...staleLedgerEntries, ...findStaleAndroidLedgerEntries(ledger, avds, loadConfig$1())];
2011
+ const registeredAvds = new Set(avds);
1337
2012
  avds = [.../* @__PURE__ */ new Set([...avds, ...orphanedAvdDirectories.map((entry) => entry.name)])];
1338
2013
  const isMounted = (path) => isOnMountedVolume(path, mountedVolumes);
1339
2014
  const found = findOrphanedDevices({
@@ -1341,13 +2016,12 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1341
2016
  avds,
1342
2017
  config: cfg,
1343
2018
  isMounted,
1344
- deadProjects,
1345
- isOwned: (device) => device.kind === "ios" ? isStimOwnedSim({
1346
- udid: device.id,
1347
- name: device.name
1348
- }) : isStimOwnedAvdName(device.id, ownedAvdDirectory(device.id) ?? orphanedAvdDirectories.find((entry) => entry.name === device.id)?.directory ?? null)
2019
+ deadProjects
2020
+ });
2021
+ unverifiedDevices = found.unverified.flatMap((device) => {
2022
+ const directories = device.kind === "android" && !registeredAvds.has(device.id) ? orphanedAvdDirectories.filter((entry) => entry.name === device.id) : [];
2023
+ return directories.length ? directories.map(({ directory }) => Object.assign({}, device, { directory })) : [device];
1349
2024
  });
1350
- unverifiedDevices = found.unverified;
1351
2025
  orphanedDevices = withAndroidAvdSizes(found.orphaned.flatMap((device) => {
1352
2026
  const directories = device.kind === "android" ? orphanedAvdDirectories.filter((entry) => entry.name === device.name) : [];
1353
2027
  return directories.length ? directories.map((orphanedDirectory) => Object.assign({}, device, { orphanedDirectory })) : [device];
@@ -1379,6 +2053,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1379
2053
  }
1380
2054
  const locks = listBuildLocks();
1381
2055
  const slots = listBuildSlots();
2056
+ const goneWorkspaceDirs = [...workspaceDirs.orphaned.map((entry) => entry.dir), ...deadProjects.map(workspaceDir)];
1382
2057
  const deviceLeases = collectDeviceLeases(now);
1383
2058
  return {
1384
2059
  skipped,
@@ -1396,6 +2071,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1396
2071
  unverifiedDevices,
1397
2072
  staleDevices,
1398
2073
  staleDeviceRecords,
2074
+ staleLedgerEntries,
1399
2075
  buildLocks: {
1400
2076
  stale: locks.filter((l) => !l.alive && !l.unresolved),
1401
2077
  live: locks.filter((l) => l.alive),
@@ -1414,9 +2090,10 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1414
2090
  workspaceOutputs: collectWorkspaceOutputs({
1415
2091
  olderThan,
1416
2092
  now,
1417
- exclude: [...workspaceDirs.orphaned.map((entry) => entry.dir), ...deadProjects.map(workspaceDir)]
2093
+ exclude: goneWorkspaceDirs
1418
2094
  }),
1419
- worktreeSweep: collectWorktreeSweep({
2095
+ workspaceLogs: collectWorkspaceLogs({ exclude: goneWorkspaceDirs }),
2096
+ worktreeSweep: await collectWorktreeSweep({
1420
2097
  idle: worktrees,
1421
2098
  olderThan,
1422
2099
  now
@@ -1524,7 +2201,51 @@ async function sweep(opts, deps) {
1524
2201
  precollectedEasSessionSweep: easSessionSweep
1525
2202
  });
1526
2203
  }
2204
+ async function pruneDeadProjects(deadProjects) {
2205
+ let deleteFailures = 0;
2206
+ for (const path of deadProjects) {
2207
+ if (existsSync$1(path) || !isOnMountedVolume(path)) {
2208
+ console.log(chalk.yellow(`Kept ${path}: its absence can no longer be confirmed.`));
2209
+ recordGcResult("project", "kept", path, { detail: "its absence can no longer be confirmed" });
2210
+ continue;
2211
+ }
2212
+ const result = await reclaimProject(path).catch((error) => {
2213
+ deleteFailures++;
2214
+ console.log(chalk.red(`Could not prune ${path}; its registry entry was kept: ${error.message}`));
2215
+ recordGcResult("project", "failed", path, { detail: `its registry entry was kept: ${error.message}` });
2216
+ return null;
2217
+ });
2218
+ if (!result) continue;
2219
+ if (result.keptEntry) {
2220
+ console.log(chalk.yellow(`Could not fully prune ${path}; its registry entry was kept.`));
2221
+ recordGcResult("project", "kept", path, { detail: "it could not be fully pruned; its registry entry was kept" });
2222
+ } else {
2223
+ console.log(chalk.green(`Pruned ${path}`));
2224
+ recordGcResult("project", "done", path);
2225
+ }
2226
+ for (const dir of result.removedWorkspaceDirs) console.log(chalk.dim(` removed workspace output ${dir}`));
2227
+ for (const dir of result.failedWorkspaceDirs) {
2228
+ console.log(chalk.red(` could not remove workspace output ${dir}`));
2229
+ recordGcResult("workspaceDirectory", "failed", dir, { detail: `could not remove it while pruning ${path}` });
2230
+ deleteFailures += 1;
2231
+ }
2232
+ if (result.killedPid) console.log(chalk.dim(` killed orphaned Metro pid ${result.killedPid}`));
2233
+ if (result.stoppedSession) console.log(chalk.dim(` stopped remote session ${result.stoppedSession}`));
2234
+ if (result.stoppedTunnel) console.log(chalk.dim(` stopped ${result.stoppedTunnel} tunnel`));
2235
+ for (const name of result.deletedDevices) recordGcResult("device", "done", name);
2236
+ for (const s of result.skippedDevices) {
2237
+ console.log(chalk.yellow(` ${s.name}: ${s.reason}`));
2238
+ recordGcResult("device", result.failedDevices.includes(s) ? "failed" : "kept", s.name, {
2239
+ id: s.udid ?? null,
2240
+ detail: s.reason
2241
+ });
2242
+ }
2243
+ deleteFailures += result.failedDevices.length;
2244
+ }
2245
+ return deleteFailures;
2246
+ }
1527
2247
  async function runGcCore(opts, deps) {
2248
+ takeGcResults();
1528
2249
  const olderThan = typeof opts.olderThan === "number" ? opts.olderThan : null;
1529
2250
  const cache = typeof opts.cache === "string" && opts.cache.trim() ? opts.cache : null;
1530
2251
  const report = await collectGcReport({
@@ -1547,10 +2268,11 @@ async function runGcCore(opts, deps) {
1547
2268
  const all = report.all;
1548
2269
  for (const line of formatGcReport(report)) console.log(line);
1549
2270
  const { deadProjects, invalidProjects, orphanedDevices, staleDevices, staleDeviceRecords, buildLocks, buildSlots, deviceLeases, easSessionSweep, caches } = report;
1550
- const actionable = deadProjects.length + invalidProjects.length > 0 || report.orphanedWorkspaces.length > 0 || Boolean(report.workspaceOutputs?.workspaces.some((entry) => entry.willClear)) || Boolean(report.worktreeSweep?.worktrees.some((entry) => !entry.skipped)) || report.parkedSims.length > 0 || report.parkedAvds.length > 0 || orphanedDevices.length > 0 || staleDevices.length > 0 || staleDeviceRecords.length > 0 || buildLocks.stale.length > 0 || buildSlots.stale.length > 0 || deviceLeases.expired.length > 0 || easSessionSweep.orphaned.length > 0 || (olderThan !== null || all) && caches.length > 0;
2271
+ const actionable = deadProjects.length + invalidProjects.length > 0 || report.orphanedWorkspaces.length > 0 || Boolean(report.workspaceOutputs?.workspaces.some((entry) => entry.willClear)) || report.workspaceLogs.some((entry) => entry.willTrim) || Boolean(report.worktreeSweep?.worktrees.some((entry) => !entry.skipped)) || report.parkedSims.length > 0 || report.parkedAvds.length > 0 || orphanedDevices.length > 0 || staleDevices.length > 0 || staleDeviceRecords.length > 0 || report.staleLedgerEntries.length > 0 || buildLocks.stale.length > 0 || buildSlots.stale.length > 0 || deviceLeases.expired.length > 0 || easSessionSweep.orphaned.length > 0 || (olderThan !== null || all) && caches.length > 0;
1551
2272
  const idle = opts.idle ?? null;
1552
2273
  const idleFailures = idle === null ? 0 : shutDownIdleDevices(report.idleDevices, idle, () => collectIdleDevices(loadConfig$1(), listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }), report.deadProjects)).failures;
1553
2274
  if (idleFailures) process.exitCode = 1;
2275
+ const inventory = opts.json && !opts.delete && idle === null && report.cacheScope === null ? collectInventory(loadConfig$1(), deadProjects) : null;
1554
2276
  const payload = (failures) => ({
1555
2277
  mode: opts.delete ? "delete" : "dry-run",
1556
2278
  idle,
@@ -1559,7 +2281,9 @@ async function runGcCore(opts, deps) {
1559
2281
  worktreeSweep: report.worktreeSweep?.idle ?? null,
1560
2282
  actionable,
1561
2283
  failures,
1562
- sections: gcReportSections(report)
2284
+ sections: gcReportSections(report),
2285
+ results: takeGcResults(),
2286
+ inventory
1563
2287
  });
1564
2288
  if (!opts.delete) {
1565
2289
  if (all) console.log(chalk.dim("\nDry run. Re-run with --delete to empty the caches above."));
@@ -1569,34 +2293,13 @@ async function runGcCore(opts, deps) {
1569
2293
  }
1570
2294
  let deleteFailures = idleFailures;
1571
2295
  deleteFailures += report.workspaceOutputs ? (await clearWorkspaceOutputs(report.workspaceOutputs, { olderThan })).failures : 0;
2296
+ deleteFailures += await trimWorkspaceLogs(report.workspaceLogs);
1572
2297
  deleteFailures += deleteParkedSims(report.parkedSims, deps) + deleteParkedAvds(report.parkedAvds);
1573
2298
  removeInvalidProjectEntries(invalidProjects);
1574
- for (const path of deadProjects) {
1575
- if (existsSync(path) || !isOnMountedVolume(path)) {
1576
- console.log(chalk.yellow(`Kept ${path}: its absence can no longer be confirmed.`));
1577
- continue;
1578
- }
1579
- const result = await reclaimProject(path).catch((error) => {
1580
- deleteFailures++;
1581
- console.log(chalk.red(`Could not prune ${path}; its registry entry was kept: ${error.message}`));
1582
- return null;
1583
- });
1584
- if (!result) continue;
1585
- if (result.keptEntry) console.log(chalk.yellow(`Could not fully prune ${path}; its registry entry was kept.`));
1586
- else console.log(chalk.green(`Pruned ${path}`));
1587
- for (const dir of result.removedWorkspaceDirs) console.log(chalk.dim(` removed workspace output ${dir}`));
1588
- for (const dir of result.failedWorkspaceDirs) {
1589
- console.log(chalk.red(` could not remove workspace output ${dir}`));
1590
- deleteFailures += 1;
1591
- }
1592
- if (result.killedPid) console.log(chalk.dim(` killed orphaned Metro pid ${result.killedPid}`));
1593
- if (result.stoppedSession) console.log(chalk.dim(` stopped remote session ${result.stoppedSession}`));
1594
- if (result.stoppedTunnel) console.log(chalk.dim(` stopped ${result.stoppedTunnel} tunnel`));
1595
- for (const s of result.skippedDevices) console.log(chalk.yellow(` ${s.name}: ${s.reason}`));
1596
- deleteFailures += result.failedDevices.length;
1597
- }
2299
+ deleteFailures += await pruneDeadProjects(deadProjects);
1598
2300
  deleteFailures += await deleteOrphanedWorkspaces(report.orphanedWorkspaces);
1599
2301
  deleteFailures += deleteProjectDevices(orphanedDevices, staleDevices, staleDeviceRecords);
2302
+ deleteFailures += forgetStaleLedgerEntries(report.staleLedgerEntries);
1600
2303
  for (const lock of buildLocks.stale) {
1601
2304
  const cleared = (0, ownership_claim_exports.clearFreeClaimSet)({
1602
2305
  root: lock.path,
@@ -1605,13 +2308,22 @@ async function runGcCore(opts, deps) {
1605
2308
  if (cleared.status === "held") continue;
1606
2309
  if (cleared.status === "refused") {
1607
2310
  console.log(chalk.yellow(`Left the build lock at ${lock.path} alone: ${cleared.reason}`));
2311
+ recordGcResult("buildLock", "kept", `${lock.platform} build lock`, {
2312
+ id: lock.path,
2313
+ detail: cleared.reason
2314
+ });
1608
2315
  continue;
1609
2316
  }
1610
2317
  if (cleared.status === "failed") {
1611
2318
  deleteFailures++;
1612
2319
  console.log(chalk.red(`Failed to clear the build lock at ${lock.path}: ${cleared.reason}`));
2320
+ recordGcResult("buildLock", "failed", `${lock.platform} build lock`, {
2321
+ id: lock.path,
2322
+ detail: cleared.reason
2323
+ });
1613
2324
  continue;
1614
2325
  }
2326
+ recordGcResult("buildLock", "done", `${lock.platform} build lock`, { id: lock.path });
1615
2327
  console.log(chalk.green(`Cleared the ${lock.platform} build lock left by pid ${lock.pid ?? "?"} (${lock.projectRoot || "unrecorded workspace"})`));
1616
2328
  }
1617
2329
  for (const slot of buildSlots.stale) {
@@ -1622,21 +2334,36 @@ async function runGcCore(opts, deps) {
1622
2334
  if (cleared.status === "held") continue;
1623
2335
  if (cleared.status === "refused") {
1624
2336
  console.log(chalk.yellow(`Left the build slot at ${slot.path} alone: ${cleared.reason}`));
2337
+ recordGcResult("buildSlot", "kept", `build slot ${slot.index ?? "?"}`, {
2338
+ id: slot.path,
2339
+ detail: cleared.reason
2340
+ });
1625
2341
  continue;
1626
2342
  }
1627
2343
  if (cleared.status === "failed") {
1628
2344
  deleteFailures++;
1629
2345
  console.log(chalk.red(`Failed to clear the build slot at ${slot.path}: ${cleared.reason}`));
2346
+ recordGcResult("buildSlot", "failed", `build slot ${slot.index ?? "?"}`, {
2347
+ id: slot.path,
2348
+ detail: cleared.reason
2349
+ });
1630
2350
  continue;
1631
2351
  }
2352
+ recordGcResult("buildSlot", "done", `build slot ${slot.index ?? "?"}`, { id: slot.path });
1632
2353
  console.log(chalk.green(`Cleared build slot ${slot.index ?? "?"} left by pid ${slot.pid ?? "?"} (${slot.projectRoot || "unrecorded workspace"})`));
1633
2354
  }
1634
2355
  for (const entry of deviceLeases.expired) try {
1635
- if (removeExpiredLease(entry)) console.log(chalk.green(`Cleared the expired ${entry.platform} device lease on ${entry.id ?? entry.name} (held by ${entry.lease?.holder ?? "an unrecorded workspace"})`));
1636
- else console.log(chalk.dim(`${entry.name} is no longer an expired lease; left alone.`));
2356
+ if (removeExpiredLease(entry)) {
2357
+ recordGcResult("deviceLease", "done", `${entry.platform} lease on ${entry.id ?? entry.name}`, { id: entry.path });
2358
+ console.log(chalk.green(`Cleared the expired ${entry.platform} device lease on ${entry.id ?? entry.name} (held by ${entry.lease?.holder ?? "an unrecorded workspace"})`));
2359
+ } else console.log(chalk.dim(`${entry.name} is no longer an expired lease; left alone.`));
1637
2360
  } catch (err) {
1638
2361
  deleteFailures++;
1639
2362
  console.log(chalk.red(`Failed to clear the device lease at ${entry.path}: ${err?.message || err}`));
2363
+ recordGcResult("deviceLease", "failed", `${entry.platform} lease on ${entry.id ?? entry.name}`, {
2364
+ id: entry.path,
2365
+ detail: err?.message || String(err)
2366
+ });
1640
2367
  }
1641
2368
  deleteFailures += await deleteEasSessions(easSessionSweep, deps);
1642
2369
  if (report.worktreeSweep) deleteFailures += await removeWorktrees(report.worktreeSweep);
@@ -1651,7 +2378,7 @@ async function runGcCore(opts, deps) {
1651
2378
  return payload(deleteFailures);
1652
2379
  }
1653
2380
  function gcCommand(program) {
1654
- program.command("gc").description("Report what Stim has left behind: dead project entries, orphaned workspace directories, clean Stim-managed linked worktrees whose branch is merged, orphaned owned devices and EAS sessions, records of devices that no longer exist, build locks whose builder is gone, expired physical-device leases, the shared build caches, and the build outputs of each workspace. Reports by default; pass --delete to act.").option("--worktrees", "also remove clean, idle Stim-managed linked worktrees, not only merged ones: with --delete run `stim worktree remove` (never --force) on each; idle means unused for --older-than days, 7 without it").option("--delete", "actually prune the reported entries, reap the reported devices, and clear the build outputs of every workspace not in use").option("--older-than <days>", "also reap owned devices whose workspace has not been used this long, trim shared cache entries nothing has used in that time, and clear workspace build outputs only for workspaces idle this long and parked devices only once parked this long", (v) => {
2381
+ program.command("gc").description("Report what Stim has left behind: dead project entries, orphaned workspace directories, clean Stim-managed linked worktrees whose branch is merged or whose pull request was merged or closed, orphaned owned devices and EAS sessions, records of devices that no longer exist, build locks whose builder is gone, expired physical-device leases, the shared build caches, and the build outputs and logs of each workspace. Reports by default; pass --delete to act.").option("--worktrees", "also remove clean, idle Stim-managed linked worktrees, not only merged ones: with --delete run `stim worktree remove` (never --force) on each; idle means unused for --older-than days, 7 without it").option("--delete", "actually prune the reported entries, reap the reported devices, clear the build outputs of every workspace not in use, and trim its Metro, client and device logs to their newest 8 MiB").option("--older-than <days>", "also reap owned devices whose workspace has not been used this long, trim shared cache entries nothing has used in that time, and clear workspace build outputs only for workspaces idle this long and parked devices only once parked this long", (v) => {
1655
2382
  const n = parseInt(v, 10);
1656
2383
  if (!Number.isFinite(n) || String(n) !== String(v).trim()) throw new InvalidArgumentError("must be a whole number of days, e.g. --older-than 30");
1657
2384
  return n;