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,67 +1,81 @@
1
- import { I as volumeRootFor, U as projectDeviceSlots, _ as loadConfig$1, d as getConfigDir } from "./config-0jCcMM0E.mjs";
1
+ import { I as volumeRootFor, U as projectDeviceSlots, _ as loadConfig$1, d as getConfigDir, z as deviceSlotKey } from "./config-0jCcMM0E.mjs";
2
2
  import { t as getExecutor } from "./exec-DK8KaVHK.mjs";
3
3
  import { a as parseNdjsonLine } from "./ndjson-CDY9IkVC.mjs";
4
- import { A as workspaceLogErrorIndex, a as processCwd, j as workspaceLogsDir, l as isMetroRunning, m as readWorkspaceState$1, n as listeningPids, o as processCwds, r as listeningPidsByPort, s as resolveProjectMetro } from "./metro-Dzo30hvJ.mjs";
4
+ import { M as workspaceLogsDir, c as resolveProjectMetro, h as readWorkspaceState$1, i as listeningPidsByPort, j as workspaceLogErrorIndex, o as processCwd, r as listeningPids, s as processCwds, u as isMetroRunning } from "./metro-BPeY1YMh.mjs";
5
5
  import { c as gitCommonDirOnDisk, p as linkedWorktreesOnDisk, s as gitCommonDir, y as repoRoot } from "./worktree-DeBQSVtR.mjs";
6
- import { i as findProjectRoot, s as projectShortcut } from "./project-B8rbutVC.mjs";
7
- import { f as readWorkspaceLaunches, u as readRemoteSession } from "./state-DcDgzfu3.mjs";
8
- import { i as formatDuration } from "./command-output-BWCj5Vh-.mjs";
9
- import { i as listLeaseFiles } from "./device-lease-B9LAXIUH.mjs";
10
- import { n as createActivityReader } from "./activity-CCZJs__P.mjs";
11
- import { L as ownedAvdSerialResolver, N as ownedAvdDeviceProfile, u as androidToolPath } from "./android-75Sk2rIR.mjs";
12
- import { b as readParked, h as POOL_SETTING_REMEDY, t as resolveSupervisorTarget, y as parkedMaxSetting } from "./ownership-B8d4kXBx.mjs";
13
- import { _ as parseSimctlList, u as listAllIosSimsAsync } from "./ios-CZWOHTTc.mjs";
14
- import { a as buildStatusLine, i as buildReport, r as activeBuildState, s as parseActiveBuild, t as ACTIVE_BUILD_KEY } from "./build-progress-h60GPV27.mjs";
15
- import { T as readEasSessionLedger$1, w as easMachineStateRoot$1 } from "./device-remote-C0_o-tJH.mjs";
16
- import { a as deviceLeaseStates, d as poolLine, f as remoteDeviceLine, h as unprovisionedWorktrees, i as deviceLeaseLines, l as gitSummaryText, m as tightVolumes, n as activityLabel, o as diskLine, p as remoteDeviceState, r as capacity, s as environmentState, u as parseDfFree } from "./status-BUASYyy_.mjs";
6
+ import { l as projectShortcut, o as findServerWorkspace, r as detectIsExpo } from "./project-DGeclhvO.mjs";
7
+ import { f as readWorkspaceLaunches, u as readRemoteSession } from "./state-DwAU2_-D.mjs";
8
+ import { i as formatDuration } from "./command-output-BIL7rlX7.mjs";
9
+ import { i as listLeaseFiles } from "./device-lease-CL9dZf73.mjs";
10
+ import { n as createActivityReader, r as createDeviceProcessTables } from "./activity-DfVKZTEB.mjs";
11
+ import { P as ownedAvdDeviceProfile, R as ownedAvdSerialResolver, ct as parkedMaxSetting, lt as readParked, rt as POOL_SETTING_REMEDY, u as androidToolPath } from "./android-qx_31wJo.mjs";
12
+ import { t as resolveSupervisorTarget } from "./ownership-PUP4Dwai.mjs";
13
+ import { a as readWebRecord, l as webFacts } from "./state-l1pYwOre.mjs";
14
+ import { _ as parseSimctlList, u as listAllIosSimsAsync } from "./ios-CmEzZiOj.mjs";
15
+ import { a as buildStatusLine, i as buildReport, r as activeBuildState, s as parseActiveBuild, t as ACTIVE_BUILD_KEY } from "./build-progress-l7d-X-ZP.mjs";
16
+ import { T as readEasSessionLedger$1, w as easMachineStateRoot$1 } from "./device-remote-DT9ngZiD.mjs";
17
+ import { _ as withWebFacts, a as deviceLeaseStates, d as poolLine, f as remoteDeviceLine, g as unprovisionedWorktrees, h as tightVolumes, i as deviceLeaseLines, l as gitSummaryText, m as statusActivity, n as activityLabel, o as diskLine, p as remoteDeviceState, r as capacity, s as environmentState, u as parseDfFree } from "./status-sszP4XKU.mjs";
17
18
  import { a as statsProjectKey, r as readStats } from "./stats-D8rUTPHd.mjs";
18
19
  import { n as mergeState } from "./merge-state-ytFQCeh4.mjs";
19
- import { i as readSupervisorState } from "./stop-Bn6v-GvS.mjs";
20
- import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
20
+ import { i as readSupervisorState } from "./stop-BZ2qMiBP.mjs";
21
+ import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
21
22
  import { closeSync as closeSync$1, existsSync as existsSync$1, fstatSync as fstatSync$1, openSync as openSync$1, readFileSync as readFileSync$1, readSync as readSync$1, renameSync as renameSync$1, rmSync as rmSync$1, writeFileSync as writeFileSync$1 } from "fs";
22
23
  import { basename, dirname, join } from "path";
23
24
  import { rotatedLogPath } from "@stim-cli/core";
24
- import { ERROR_SOURCES, gitMergeCacheDir, logFiles, queryLogs, readIdleStop, readLastBuilds, recordMatches } from "@stim-cli/core/state";
25
- import { join as join$1, posix } from "node:path";
25
+ import { ERROR_SOURCES, MACHINE_OWNER_KINDS, gitMergeCacheDir, logFiles, queryLogs, readBuildHistory, readIdleStop, readLastBuilds, recordMatches, withStateReadCache } from "@stim-cli/core/state";
26
+ import { join as join$1, posix, resolve as resolve$1 } from "node:path";
26
27
  import { createHash, randomUUID } from "node:crypto";
27
28
  import { homedir, totalmem } from "os";
28
29
  import chalk from "chalk";
29
30
  //#region src/status-watch.ts
30
31
  const WATCH_FALLBACK_MS = 3e4;
31
- const WATCH_SIMCTL_INTERVAL_MS = 2e3;
32
+ const WATCH_LIGHT_INTERVAL_MS = 15e3;
33
+ const WATCH_SIMCTL_INTERVAL_MS = 5e3;
32
34
  const ADB_RESTART_MIN_MS = 1e3;
33
35
  const ADB_RESTART_MAX_MS = 6e4;
34
36
  const SIMCTL_TIMEOUT_MS = 1e4;
35
37
  /**
36
- * Coalesces change signals into refreshes: a burst of triggers runs `run` once
37
- * after `debounceMs` of quiet, at most one run is in flight, and a trigger that
38
- * arrives during a run causes exactly one more run after it.
38
+ * Coalesces change signals into refreshes: a burst of full triggers runs `run('full')` once after `debounceMs` of
39
+ * quiet, at most one run is in flight, and a trigger that arrives during a run causes exactly one more run after it.
40
+ * A `light` trigger runs `run('light')` no sooner than `debounceMs` from the trigger and `lightIntervalMs` from the
41
+ * start of the previous run, unless a queued full run covers it; later light triggers do not postpone it.
39
42
  */
40
- function createRefreshScheduler({ debounceMs, run }) {
43
+ function createRefreshScheduler({ debounceMs, lightIntervalMs = 0, run }) {
41
44
  let timer = null;
45
+ let queued = "full";
42
46
  let running = false;
43
- let pending = false;
47
+ let pending = null;
44
48
  let stopped = false;
49
+ let lastRunAt = -Infinity;
45
50
  const fire = async () => {
46
51
  timer = null;
47
52
  running = true;
53
+ lastRunAt = Date.now();
48
54
  try {
49
- await run();
55
+ await run(queued);
50
56
  } finally {
51
57
  running = false;
52
58
  if (pending) {
53
- pending = false;
54
- trigger();
59
+ const next = pending;
60
+ pending = null;
61
+ trigger(next);
55
62
  }
56
63
  }
57
64
  };
58
- function trigger(delayMs = debounceMs) {
65
+ function trigger(kind = "full", delayMs = debounceMs) {
59
66
  if (stopped) return;
60
67
  if (running) {
61
- pending = true;
68
+ pending = pending === "full" ? "full" : kind;
69
+ return;
70
+ }
71
+ if (kind === "light") {
72
+ if (timer) return;
73
+ queued = "light";
74
+ timer = setTimeout(() => void fire(), Math.max(delayMs, lastRunAt + lightIntervalMs - Date.now()));
62
75
  return;
63
76
  }
64
77
  if (timer) clearTimeout(timer);
78
+ queued = "full";
65
79
  timer = setTimeout(() => void fire(), delayMs);
66
80
  }
67
81
  return {
@@ -74,17 +88,19 @@ function createRefreshScheduler({ debounceMs, run }) {
74
88
  };
75
89
  }
76
90
  /**
77
- * Whether a change to `name` in a watched `$STIM_HOME` directory can change
78
- * the status payload. A null name means the platform did not report one.
91
+ * Which refresh a change to `name` in a watched `$STIM_HOME` directory needs: `light` for a log append, which can
92
+ * change only the log-derived fields, `full` for anything else that can change the payload, null for none. A null
93
+ * name means the platform did not report one.
79
94
  */
80
- function changeAffectsStatus(dir, name) {
81
- if (name === null) return true;
82
- if (name.includes(".lock")) return false;
95
+ function statusChange(dir, name) {
96
+ if (name?.includes(".lock")) return null;
97
+ if (dir === "logs") return "light";
98
+ if (name === null) return "full";
83
99
  switch (dir) {
84
- case "home": return name.startsWith("config.json") || name === "workspaces" || name === "device-locks";
85
- case "workspace": return name.startsWith("state.json") || name === "logs";
86
- case "eas": return name.startsWith("sessions.json");
87
- default: return true;
100
+ case "home": return name.startsWith("config.json") || name === "workspaces" || name === "device-locks" ? "full" : null;
101
+ case "workspace": return name.startsWith("state.json") || name === "logs" ? "full" : null;
102
+ case "eas": return name.startsWith("sessions.json") ? "full" : null;
103
+ default: return "full";
88
104
  }
89
105
  }
90
106
  /** A stable summary of every simulator's state, or null when the listing is unreadable. */
@@ -132,9 +148,13 @@ function watchStatusSources({ home, onChange, platform = process.platform, simct
132
148
  if (watchers.has(dir)) continue;
133
149
  try {
134
150
  const watcher = watch(dir, (_event, name) => {
135
- if (!changeAffectsStatus(kind, name === null ? null : String(name))) return;
136
- if (kind === "home" || kind === "workspaces" || kind === "workspace") reconcile();
137
- onChange();
151
+ const change = statusChange(kind, name === null ? null : String(name));
152
+ if (!change) return;
153
+ if (kind === "home" || kind === "workspaces" || kind === "workspace") {
154
+ listingStaleBefore = Date.now();
155
+ reconcile();
156
+ }
157
+ onChange(change);
138
158
  });
139
159
  watcher.on("error", () => {
140
160
  watcher.close();
@@ -144,13 +164,16 @@ function watchStatusSources({ home, onChange, platform = process.platform, simct
144
164
  } catch {}
145
165
  }
146
166
  };
147
- const adb = trackAdbDevices(onChange);
167
+ const adb = trackAdbDevices(() => onChange("full"));
148
168
  let simctl = null;
169
+ let listing = null;
170
+ let listingStaleBefore = 0;
149
171
  let simTimer = null;
150
172
  if (platform === "darwin") {
151
173
  let last = null;
152
174
  const check = () => {
153
175
  if (simctl || stopped) return;
176
+ const startedAt = Date.now();
154
177
  let out = "";
155
178
  const child = getExecutor().spawn("xcrun", [
156
179
  "simctl",
@@ -175,7 +198,11 @@ function watchStatusSources({ home, onChange, platform = process.platform, simct
175
198
  simctl = null;
176
199
  const signature = simulatorSignature(out);
177
200
  if (signature === null || stopped) return;
178
- if (last !== null && signature !== last) onChange();
201
+ listing = {
202
+ at: startedAt,
203
+ json: out
204
+ };
205
+ if (last !== null && signature !== last) onChange("full");
179
206
  last = signature;
180
207
  });
181
208
  };
@@ -185,6 +212,7 @@ function watchStatusSources({ home, onChange, platform = process.platform, simct
185
212
  reconcile();
186
213
  return {
187
214
  reconcile,
215
+ simulatorListing: () => listing && listing.at > listingStaleBefore && Date.now() - listing.at <= 2 * simctlIntervalMs ? listing.json : null,
188
216
  stop() {
189
217
  stopped = true;
190
218
  for (const watcher of watchers.values()) watcher.close();
@@ -238,7 +266,7 @@ function trackAdbDevices(onChange) {
238
266
  }
239
267
  //#endregion
240
268
  //#region src/diagnostics/error-index.ts
241
- const INDEX_VERSION = 1;
269
+ const INDEX_VERSION = 3;
242
270
  const HEAD_BYTES = 1024;
243
271
  const TAIL_BYTES = 256;
244
272
  const CHUNK_BYTES = 4194304;
@@ -251,7 +279,7 @@ const CANDIDATE_PATTERNS = [
251
279
  const ERROR_CRITERIA = {
252
280
  errorsOnly: true,
253
281
  sources: ERROR_SOURCES,
254
- includeNativeCrashes: true
282
+ includeAppDeviceErrors: true
255
283
  };
256
284
  /**
257
285
  * The number of records `queryLogs({ dir, errorsOnly: true })` returns, without parsing every
@@ -291,8 +319,9 @@ function summarize(path, known) {
291
319
  return null;
292
320
  }
293
321
  try {
294
- const { dev, ino, size } = fstatSync$1(fd);
295
- const key = `${dev}:${ino}`;
322
+ const stat = fstatSync$1(fd, { bigint: true });
323
+ const key = `${stat.dev}:${stat.ino}`;
324
+ const size = Number(stat.size);
296
325
  const cached = known[key];
297
326
  const base = cached && stillExtends(fd, cached, size) ? cached : null;
298
327
  const start = base ?? {
@@ -370,12 +399,17 @@ function collect(record, markers, errors) {
370
399
  const ts = typeof record.ts === "number" && Number.isFinite(record.ts) ? record.ts : null;
371
400
  if (record.marker === true && ts !== null) {
372
401
  const metro = record.src === "metro";
402
+ const web = !metro && record.platform === "web";
373
403
  const slot = record.slot ?? "default";
374
- const key = metro ? "metro" : JSON.stringify(["launch", slot]);
404
+ const key = metro ? "metro" : web ? "web" : JSON.stringify(["launch", slot]);
375
405
  if ((markers[key]?.ts ?? -Infinity) < ts) markers[key] = metro ? {
376
406
  src: "metro",
377
407
  marker: true,
378
408
  ts
409
+ } : web ? {
410
+ platform: "web",
411
+ marker: true,
412
+ ts
379
413
  } : {
380
414
  marker: true,
381
415
  ts,
@@ -386,6 +420,7 @@ function collect(record, markers, errors) {
386
420
  src: record.src,
387
421
  level: record.level,
388
422
  event: record.event,
423
+ platform: record.platform,
389
424
  ts: ts ?? void 0,
390
425
  slot: record.slot
391
426
  });
@@ -413,6 +448,7 @@ function writeIndex(file, index) {
413
448
  //#region src/workspace/git-summary.ts
414
449
  const GIT_TIMEOUT_MS = 3e3;
415
450
  const MERGE_BUDGET_MS = 250;
451
+ const MERGE_TIMEOUT_BACKOFF_MS = 3e5;
416
452
  const ORIGIN_PREFIX = "refs/remotes/origin/";
417
453
  /** Counts the entries of `git status --porcelain=v2 --branch` output. `head` is null on an unborn branch. */
418
454
  function parseGitStatus(text) {
@@ -508,11 +544,33 @@ function mergedInto(path, head, target, budget) {
508
544
  const targetKey = `${target.ref} ${target.sha}`;
509
545
  const cached = readMergeCache(path);
510
546
  const known = cached?.head === head ? cached : null;
511
- if (known?.target === targetKey) return known.mergedInto;
512
- if (budget.deadline !== void 0 && Date.now() > budget.deadline) return known?.target.startsWith(`${target.ref} `) ? known.mergedInto : null;
547
+ if (known?.target === targetKey) return {
548
+ into: known.mergedInto,
549
+ deferred: false
550
+ };
551
+ const standIn = known?.target.startsWith(`${target.ref} `) ? known.mergedInto : null;
552
+ const timeoutKey = `${head} ${targetKey}`;
553
+ const timedOut = mergeTimeouts.get(path);
554
+ if (timedOut?.key === timeoutKey && Date.now() - timedOut.at < MERGE_TIMEOUT_BACKOFF_MS) return {
555
+ into: standIn,
556
+ deferred: false
557
+ };
558
+ if (budget.deadline !== void 0 && Date.now() > budget.deadline) return {
559
+ into: standIn,
560
+ deferred: true
561
+ };
513
562
  budget.deadline ??= Date.now() + MERGE_BUDGET_MS;
514
563
  const state = mergeState(path, target, { timeoutMs: GIT_TIMEOUT_MS });
515
- if (!state.merged && state.timedOut) return null;
564
+ if (!state.merged && state.timedOut) {
565
+ mergeTimeouts.set(path, {
566
+ key: timeoutKey,
567
+ at: Date.now()
568
+ });
569
+ return {
570
+ into: standIn,
571
+ deferred: false
572
+ };
573
+ }
516
574
  const verdict = state.merged ? state.into : null;
517
575
  writeMergeCache({
518
576
  path,
@@ -520,29 +578,97 @@ function mergedInto(path, head, target, budget) {
520
578
  target: targetKey,
521
579
  mergedInto: verdict
522
580
  });
523
- return verdict;
581
+ return {
582
+ into: verdict,
583
+ deferred: false
584
+ };
524
585
  }
586
+ const mergeTimeouts = /* @__PURE__ */ new Map();
525
587
  const recent = /* @__PURE__ */ new Map();
588
+ function gitDirsOf(worktree) {
589
+ try {
590
+ const dotGit = join$1(worktree, ".git");
591
+ const pointer = statSync(dotGit).isDirectory() ? null : /^gitdir: (.+)$/m.exec(readFileSync(dotGit, "utf-8"))?.[1]?.trim();
592
+ const gitDir = pointer ? resolve$1(worktree, pointer) : pointer === null ? dotGit : null;
593
+ if (!gitDir) return null;
594
+ try {
595
+ return {
596
+ gitDir,
597
+ commonDir: resolve$1(gitDir, readFileSync(join$1(gitDir, "commondir"), "utf-8").trim())
598
+ };
599
+ } catch {
600
+ return {
601
+ gitDir,
602
+ commonDir: gitDir
603
+ };
604
+ }
605
+ } catch {
606
+ return null;
607
+ }
608
+ }
609
+ function fileStamp(path) {
610
+ try {
611
+ const stat = statSync(path, { bigint: true });
612
+ return `${stat.ino}:${stat.mtimeNs}:${stat.size}`;
613
+ } catch {
614
+ return "-";
615
+ }
616
+ }
617
+ /**
618
+ * Stamps the git files a summary depends on: the worktree's index, HEAD and reflog, and the common dir's packed refs,
619
+ * last fetch, origin/HEAD, default-branch ref, branch ref and upstream ref. Edits, creations and deletions of files
620
+ * that are not staged touch none of them. `missing` when the worktree has no git dir, such as a deleted worktree git
621
+ * still lists; it is then stamped by its `.git` entry alone.
622
+ */
623
+ function gitFilesStamp(worktree, refs) {
624
+ const dirs = gitDirsOf(worktree.path);
625
+ if (!dirs) return {
626
+ stamp: fileStamp(join$1(worktree.path, ".git")),
627
+ missing: true
628
+ };
629
+ const { gitDir, commonDir } = dirs;
630
+ return {
631
+ stamp: [
632
+ join$1(gitDir, "index"),
633
+ join$1(gitDir, "HEAD"),
634
+ join$1(gitDir, "logs", "HEAD"),
635
+ join$1(commonDir, "packed-refs"),
636
+ join$1(commonDir, "FETCH_HEAD"),
637
+ join$1(commonDir, "refs", "remotes", "origin", "HEAD"),
638
+ ...refs.target ? [join$1(commonDir, refs.target)] : [],
639
+ ...worktree.branch ? [join$1(commonDir, "refs", "heads", worktree.branch)] : [],
640
+ ...refs.upstream ? [join$1(commonDir, "refs", "remotes", refs.upstream), join$1(commonDir, "refs", "heads", refs.upstream)] : []
641
+ ].map(fileStamp).join(" "),
642
+ missing: false
643
+ };
644
+ }
526
645
  /**
527
646
  * Reads every worktree's git summary in parallel, each git call bounded by a timeout. A worktree `skip` rejects, or
528
647
  * one git cannot answer in time, maps to null. After every read, merge verdicts missing from the cache are judged one
529
648
  * at a time; no new judgement starts 250 ms after the first, and a verdict for the same HEAD judged at an older
530
- * default-branch commit stands in, because a merged branch stays merged. With `maxAgeMs`, a summary read that
531
- * recently in this process is reused.
649
+ * default-branch commit stands in, because a merged branch stays merged. A judgement that timed out is not retried
650
+ * for the same HEAD and target for five minutes. With `maxAgeMs`, a summary made that recently in this process is
651
+ * reused while the git files it depends on are unchanged, so a change that is not staged can take up to `maxAgeMs`
652
+ * to show; so is the null of a worktree whose directory is gone.
532
653
  */
533
654
  async function readWorktreeGit(worktrees, { skip, maxAgeMs = 0 }) {
534
655
  const now = Date.now();
535
656
  const targets = /* @__PURE__ */ new Map();
536
657
  const reads = await Promise.all(worktrees.map(async (worktree) => {
537
- const memo = recent.get(worktree.path);
538
- if (memo && now - memo.at < maxAgeMs) return {
539
- path: worktree.path,
540
- memo: memo.git
541
- };
542
658
  if (skip(worktree)) return {
543
659
  path: worktree.path,
544
660
  memo: null
545
661
  };
662
+ const memo = maxAgeMs > 0 ? recent.get(worktree.path) : void 0;
663
+ const refs = memo?.refs ?? {
664
+ upstream: null,
665
+ target: null
666
+ };
667
+ const files = maxAgeMs > 0 ? gitFilesStamp(worktree, refs) : null;
668
+ if (memo && memo.files !== null && memo.files === files?.stamp && now - memo.at < maxAgeMs) return {
669
+ path: worktree.path,
670
+ memo: memo.git
671
+ };
546
672
  const repository = worktree.repository;
547
673
  if (repository && !targets.has(repository)) targets.set(repository, defaultBranchOf(repository));
548
674
  const [out, target] = await Promise.all([git(worktree.path, [
@@ -554,23 +680,43 @@ async function readWorktreeGit(worktrees, { skip, maxAgeMs = 0 }) {
554
680
  return {
555
681
  path: worktree.path,
556
682
  status: out === null ? null : parseGitStatus(out),
557
- target
683
+ target,
684
+ files,
685
+ refs
558
686
  };
559
687
  }));
560
688
  const budget = {};
561
689
  const summaries = /* @__PURE__ */ new Map();
562
690
  for (const read of reads) {
563
- if ("memo" in read || !read.status) {
564
- summaries.set(read.path, "memo" in read ? read.memo ?? null : null);
691
+ if ("memo" in read) {
692
+ summaries.set(read.path, read.memo ?? null);
693
+ continue;
694
+ }
695
+ if (!read.status) {
696
+ if (read.files?.missing) recent.set(read.path, {
697
+ at: now,
698
+ files: read.files.stamp,
699
+ refs: read.refs,
700
+ git: null
701
+ });
702
+ summaries.set(read.path, null);
565
703
  continue;
566
704
  }
567
705
  const { head, ...counts } = read.status;
706
+ const merge = head && read.target ? mergedInto(read.path, head, read.target, budget) : null;
568
707
  const summary = {
569
708
  ...counts,
570
- mergedInto: head && read.target ? mergedInto(read.path, head, read.target, budget) : null
709
+ mergedInto: merge?.into ?? null
710
+ };
711
+ const refs = {
712
+ upstream: counts.upstream,
713
+ target: read.target?.ref ?? null
571
714
  };
715
+ const settled = !merge?.deferred && refs.upstream === read.refs.upstream && refs.target === read.refs.target;
572
716
  recent.set(read.path, {
573
717
  at: now,
718
+ files: settled ? read.files?.stamp ?? null : null,
719
+ refs,
574
720
  git: summary
575
721
  });
576
722
  summaries.set(read.path, summary);
@@ -578,25 +724,268 @@ async function readWorktreeGit(worktrees, { skip, maxAgeMs = 0 }) {
578
724
  return summaries;
579
725
  }
580
726
  //#endregion
727
+ //#region src/devices/app-process.ts
728
+ const bundleIdsByApp = /* @__PURE__ */ new Map();
729
+ function simAppPaths(commands, udid) {
730
+ const marker = `/Devices/${udid}/data/Containers/Bundle/Application/`;
731
+ const paths = /* @__PURE__ */ new Set();
732
+ for (const command of commands) {
733
+ const at = command.indexOf(marker);
734
+ if (!command.startsWith("/") || at < 0 || /[\s=]\//.test(command.slice(0, at))) continue;
735
+ const rest = command.slice(at + marker.length);
736
+ const app = /^[^/]+\/[^/]+\.app\//.exec(rest)?.[0];
737
+ if (!app || /^[^/\s]*\//.test(rest.slice(app.length))) continue;
738
+ paths.add(command.slice(0, at + marker.length) + app.slice(0, -1));
739
+ }
740
+ return [...paths];
741
+ }
742
+ function androidAppRunning(psOutput, packageName) {
743
+ return psOutput.split("\n").some((line) => {
744
+ return /^\s*\d+\s+(\S+)\s*$/.exec(line)?.[1] === packageName;
745
+ });
746
+ }
747
+ function plistBundleId(appPath) {
748
+ const cached = bundleIdsByApp.get(appPath);
749
+ if (cached) return cached;
750
+ const id = getExecutor().runFileQuiet("plutil", [
751
+ "-extract",
752
+ "CFBundleIdentifier",
753
+ "raw",
754
+ "-o",
755
+ "-",
756
+ join$1(appPath, "Info.plist")
757
+ ], { timeoutMs: 2e3 })?.trim() || null;
758
+ if (id) bundleIdsByApp.set(appPath, id);
759
+ return id;
760
+ }
761
+ function createAppProcessReader(tables, bundleIdOf = plistBundleId) {
762
+ return ({ platform, id, appId }) => {
763
+ const result = (state) => ({
764
+ id: appId,
765
+ state
766
+ });
767
+ if (platform === "android") {
768
+ const output = tables.android(id);
769
+ if (output === null) return result("unknown");
770
+ return result(androidAppRunning(output, appId) ? "running" : "stopped");
771
+ }
772
+ const host = tables.host();
773
+ if (host === null) return result("unknown");
774
+ const ids = simAppPaths(host.map((row) => row.command), id).map(bundleIdOf);
775
+ if (ids.includes(appId)) return result("running");
776
+ return result(ids.includes(null) ? "unknown" : "stopped");
777
+ };
778
+ }
779
+ //#endregion
780
+ //#region src/machine-usage.ts
781
+ const SHARED = [
782
+ {
783
+ name: "CoreSimulator services",
784
+ pattern: /\/CoreSimulator\.framework\/|\/Simulator\.app\/Contents\/MacOS\//
785
+ },
786
+ {
787
+ name: "adb server",
788
+ pattern: /(^|\/)adb\s.*\bfork-server\b/
789
+ },
790
+ {
791
+ name: "Android emulator services",
792
+ pattern: /\/emulator\/netsimd(\s|$)/
793
+ },
794
+ {
795
+ name: "Gradle daemon",
796
+ pattern: /\borg\.gradle\.launcher\.daemon\.bootstrap\.GradleDaemon\b/
797
+ },
798
+ {
799
+ name: "Kotlin daemon",
800
+ pattern: /\bKotlinCompileDaemon\b/
801
+ },
802
+ {
803
+ name: "Watchman",
804
+ pattern: /(^|\/)watchman(\s|$)/
805
+ }
806
+ ];
807
+ const SERVER = /(^|[\s/])stim-server(\.mjs)?(\s|$)/;
808
+ const LAUNCHD_SIM = /^launchd_sim\s.*\/Devices\/([^/\s]+)\//;
809
+ const MAX_DEPTH = 64;
810
+ const EMULATOR = /(?:^|\/)(?:qemu-system-[^\s/]+|emulator)(?:\s.*)?\s(?:-avd\s+|@)(\S+)/;
811
+ function emulatorAvd(command) {
812
+ return EMULATOR.exec(command)?.[1] ?? null;
813
+ }
814
+ function workspaceOwner(kind, env, name, id) {
815
+ return {
816
+ kind,
817
+ name,
818
+ workspace: env.path,
819
+ id,
820
+ owned: true
821
+ };
822
+ }
823
+ function rank(owner) {
824
+ return MACHINE_OWNER_KINDS.indexOf(owner.kind);
825
+ }
826
+ /**
827
+ * Attributes every process in `processes` to at most one owner: the owner of its nearest ancestor (itself included)
828
+ * that is a root. Roots are each booted simulator's `launchd_sim`, each emulator launcher and qemu process, and each
829
+ * workspace's verified supervisor and Metro, running build and Chrome. Only a process under none of those falls back
830
+ * to stim-server or a machine-wide service in `SHARED`, so a `simctl` a workspace's log collector runs stays with the
831
+ * workspace. A process with no root ancestor is left out.
832
+ */
833
+ function attributeMachineUsage({ processes, environments, roots, simNames }) {
834
+ const owners = /* @__PURE__ */ new Map();
835
+ const rootOf = /* @__PURE__ */ new Map();
836
+ const fallbackOf = /* @__PURE__ */ new Map();
837
+ const claim = (pid, key, owner, into = rootOf) => {
838
+ if (!owners.has(key)) owners.set(key, owner);
839
+ into.set(pid, key);
840
+ };
841
+ const sims = /* @__PURE__ */ new Map();
842
+ const emulators = /* @__PURE__ */ new Map();
843
+ for (const env of environments) for (const device of [{
844
+ slot: "default",
845
+ ios: env.ios,
846
+ android: env.android
847
+ }, ...env.slots ?? []]) {
848
+ const slot = device.slot === "default" ? {} : { slot: device.slot };
849
+ if (device.ios?.state === "Booted") sims.set(device.ios.udid.toUpperCase(), {
850
+ kind: "simulator",
851
+ name: device.ios.name ?? device.ios.udid,
852
+ workspace: env.path,
853
+ ...slot,
854
+ id: device.ios.udid,
855
+ owned: device.ios.owned
856
+ });
857
+ if (device.android?.name && !device.android.physical && device.android.serial) emulators.set(device.android.name, {
858
+ kind: "emulator",
859
+ name: device.android.name,
860
+ workspace: env.path,
861
+ ...slot,
862
+ id: device.android.name,
863
+ owned: device.android.owned
864
+ });
865
+ }
866
+ for (const p of processes) {
867
+ const shared = SHARED.find(({ pattern }) => pattern.test(p.command));
868
+ if (shared) {
869
+ const owner = {
870
+ kind: "shared",
871
+ name: shared.name,
872
+ workspace: null,
873
+ id: null,
874
+ owned: false
875
+ };
876
+ claim(p.pid, `shared:${shared.name}`, owner, fallbackOf);
877
+ continue;
878
+ }
879
+ if (SERVER.test(p.command)) {
880
+ claim(p.pid, "server", {
881
+ kind: "server",
882
+ name: "stim-server",
883
+ workspace: null,
884
+ id: null,
885
+ owned: false
886
+ }, fallbackOf);
887
+ continue;
888
+ }
889
+ const udid = LAUNCHD_SIM.exec(p.command)?.[1]?.toUpperCase();
890
+ if (udid) {
891
+ const owner = sims.get(udid) ?? {
892
+ kind: "simulator",
893
+ name: simNames[udid] ?? udid,
894
+ workspace: null,
895
+ id: udid,
896
+ owned: false
897
+ };
898
+ claim(p.pid, `simulator:${udid}`, owner);
899
+ continue;
900
+ }
901
+ const avd = emulatorAvd(p.command);
902
+ if (avd) {
903
+ const owner = emulators.get(avd) ?? {
904
+ kind: "emulator",
905
+ name: avd,
906
+ workspace: null,
907
+ id: avd,
908
+ owned: false
909
+ };
910
+ claim(p.pid, `emulator:${avd}`, owner);
911
+ }
912
+ }
913
+ const present = new Set(processes.map((p) => p.pid));
914
+ const byPath = new Map(environments.map((env) => [env.path, env]));
915
+ for (const root of roots) {
916
+ const env = byPath.get(root.path);
917
+ if (!env) continue;
918
+ const metroPids = [root.supervisorPid, env.metro?.running ? env.metro.pid : null];
919
+ for (const pid of metroPids) if (pid && present.has(pid)) {
920
+ const port = env.metro?.port ?? null;
921
+ claim(pid, `metro:${env.path}`, workspaceOwner("metro", env, port ? `Metro :${port}` : "Metro", port ? String(port) : null));
922
+ }
923
+ if (root.build && present.has(root.build.pid)) {
924
+ const label = root.build.platform === "ios" ? "iOS build" : "Android build";
925
+ claim(root.build.pid, `build:${env.path}`, workspaceOwner("build", env, label, root.build.platform));
926
+ }
927
+ for (const pid of root.browserPids) if (present.has(pid)) claim(pid, `browser:${env.path}`, workspaceOwner("browser", env, "Chrome", null));
928
+ }
929
+ const parentOf = new Map(processes.map((p) => [p.pid, p.ppid]));
930
+ const totals = /* @__PURE__ */ new Map();
931
+ for (const p of processes) {
932
+ let pid = p.pid;
933
+ let key;
934
+ let fallback;
935
+ for (let depth = 0; pid !== void 0 && pid > 1 && depth < MAX_DEPTH; depth++) {
936
+ key = rootOf.get(pid);
937
+ if (key) break;
938
+ fallback ??= fallbackOf.get(pid);
939
+ pid = parentOf.get(pid);
940
+ }
941
+ key ??= fallback;
942
+ if (!key) continue;
943
+ const total = totals.get(key) ?? {
944
+ rssKb: 0,
945
+ cpu: 0,
946
+ processes: 0
947
+ };
948
+ total.rssKb += p.rssKb;
949
+ total.cpu += p.cpuPercent;
950
+ total.processes += 1;
951
+ totals.set(key, total);
952
+ }
953
+ const list = [];
954
+ for (const [key, owner] of owners) {
955
+ const total = totals.get(key);
956
+ if (total) list.push({
957
+ ...owner,
958
+ cpuPercent: Math.round(total.cpu),
959
+ residentMb: Math.round(total.rssKb / 1024),
960
+ processes: total.processes
961
+ });
962
+ }
963
+ list.sort((x, y) => rank(x) - rank(y) || (x.workspace ?? "~").localeCompare(y.workspace ?? "~") || x.name.localeCompare(y.name));
964
+ return { owners: list };
965
+ }
966
+ //#endregion
581
967
  //#region src/commands/status.ts
582
- const WATCH_GIT_MAX_AGE_MS = 5e3;
968
+ const WATCH_GIT_MAX_AGE_MS = 6e4;
583
969
  function formatGb(mb) {
584
970
  return `${(mb / 1024).toFixed(1)} GB`;
585
971
  }
586
972
  function statusCommand(program) {
587
973
  program.command("status").description("Show every environment on this machine: devices, ports, what is actually running, and anything stuck.").option("--json", "print the state as JSON").option("--watch", "keep running and print the state again each time it changes").action(async (opts) => {
588
974
  if (opts.watch) return watchStatus(Boolean(opts.json));
589
- for (const line of await statusLines(Boolean(opts.json), 0)) console.log(line);
975
+ const json = Boolean(opts.json);
976
+ for (const line of renderStatus(await readStatus(0), json)) console.log(line);
590
977
  });
591
978
  }
592
- async function statusLines(json, gitMaxAgeMs) {
593
- const out = [];
979
+ function readStatus(gitMaxAgeMs, simctlListing = null) {
980
+ return withStateReadCache(() => readStatusFacts(gitMaxAgeMs, simctlListing));
981
+ }
982
+ async function readStatusFacts(gitMaxAgeMs, simctlListing) {
594
983
  const cfg = loadConfig$1();
595
984
  const projects = Object.entries(cfg?.projects || {});
596
- const cwdRoot = findProjectRoot(process.cwd());
985
+ const cwdRoot = findServerWorkspace(process.cwd())?.root ?? null;
597
986
  const worktrees = linkedWorktrees([process.cwd(), ...projects.map(([path]) => path)]);
598
987
  const orphanWorktrees = unprovisionedWorktrees(worktrees, projects.map(([p]) => p));
599
- const simsRead = listAllIosSimsAsync();
988
+ const simsRead = simctlListing === null ? listAllIosSimsAsync() : Promise.resolve().then(() => parseSimctlList(simctlListing));
600
989
  simsRead.catch(() => {});
601
990
  const ports = await portLookup(projects.flatMap(([, proj]) => proj.metroPort ? [proj.metroPort] : []));
602
991
  const processes = Promise.all(projects.map(async ([path, proj]) => {
@@ -630,6 +1019,7 @@ async function statusLines(json, gitMaxAgeMs) {
630
1019
  const history = readStats().record?.history;
631
1020
  const states = [];
632
1021
  const labelOnlyRoots = [];
1022
+ const launchesByState = [];
633
1023
  const easLedger = readEasSessionLedger$1();
634
1024
  const leaseNow = Date.now();
635
1025
  const leaseFiles = listLeaseFiles();
@@ -637,11 +1027,25 @@ async function statusLines(json, gitMaxAgeMs) {
637
1027
  root: cwdRoot,
638
1028
  now: leaseNow
639
1029
  });
1030
+ const roots = [];
640
1031
  for (const [i, [path, proj]] of projects.entries()) {
641
1032
  const { metro, supervisor } = running[i];
642
1033
  const saved = readWorkspaceState$1(path);
643
1034
  const builds = workspaceBuilds(path, saved, history);
644
- states.push(environmentState({
1035
+ const web = webFacts(readWebRecord(path));
1036
+ const activeBuild = parseActiveBuild(saved?.[ACTIVE_BUILD_KEY]);
1037
+ roots.push({
1038
+ path,
1039
+ supervisorPid: supervisor?.status === "ours" ? supervisor.pid : null,
1040
+ build: activeBuild && builds.build?.state === "running" ? {
1041
+ platform: activeBuild.platform,
1042
+ pid: activeBuild.claim.pid
1043
+ } : null,
1044
+ browserPids: browserPids(web)
1045
+ });
1046
+ const launches = readWorkspaceLaunches(path);
1047
+ launchesByState.push(launches);
1048
+ states.push(withWebFacts(environmentState({
645
1049
  ...proj,
646
1050
  __path: path
647
1051
  }, {
@@ -654,38 +1058,86 @@ async function statusLines(json, gitMaxAgeMs) {
654
1058
  logs: logs[i],
655
1059
  remote: remoteDeviceState(readRemoteSession(path), easLedger, path),
656
1060
  idleStop: readIdleStop(saved),
657
- launches: readWorkspaceLaunches(path),
1061
+ launches,
658
1062
  leasedIds: new Set(deviceLeaseStates(leaseFiles, {
659
1063
  root: path,
660
1064
  now: leaseNow
661
1065
  }).flatMap((lease) => lease.mine && !lease.expired && lease.id ? [lease.id] : [])),
662
1066
  now: leaseNow
663
- }));
1067
+ }), web));
664
1068
  const state = states[states.length - 1];
665
1069
  if (state) Object.assign(state, builds);
666
- labelOnlyRoots.push(Boolean(proj.worktreeRoot && !proj.bundleId && !state?.metro && !state?.ios && !state?.android));
667
- }
668
- const readActivity = createActivityReader();
669
- for (const state of states) for (const device of [{
670
- slot: "default",
671
- ios: state.ios,
672
- android: state.android
673
- }, ...state.slots ?? []]) {
674
- const base = {
675
- slot: device.slot,
676
- workspace: state.path
677
- };
678
- if (device.ios?.state === "Booted") device.ios.activity = readActivity({
679
- ...base,
680
- platform: "ios",
681
- id: device.ios.udid
682
- });
683
- if (device.android && !device.android.physical && device.android.serial) device.android.activity = readActivity({
684
- ...base,
685
- platform: "android",
686
- id: device.android.serial
687
- });
1070
+ labelOnlyRoots.push(Boolean(proj.worktreeRoot && !proj.bundleId && !state?.metro && !state?.ios && !state?.android && !state?.web));
688
1071
  }
1072
+ const tables = createDeviceProcessTables();
1073
+ readDeviceProcesses(states, tables, {
1074
+ projects,
1075
+ launchesByState
1076
+ });
1077
+ const sims = Object.values(simsByUdid);
1078
+ const simNames = Object.fromEntries(sims.map((sim) => [sim.udid.toUpperCase(), sim.name]));
1079
+ const machine = readMachineUsage({
1080
+ states,
1081
+ roots,
1082
+ simNames,
1083
+ tables,
1084
+ busy: sims.some((sim) => sim.state === "Booted")
1085
+ });
1086
+ return {
1087
+ projects,
1088
+ states,
1089
+ labelOnlyRoots,
1090
+ leases,
1091
+ leaseNow,
1092
+ orphanWorktrees,
1093
+ simsAvailable,
1094
+ simctlError,
1095
+ cwdRoot,
1096
+ tables,
1097
+ machine,
1098
+ roots,
1099
+ simNames
1100
+ };
1101
+ }
1102
+ function browserPids(web) {
1103
+ if (!web) return [];
1104
+ const chrome = web.record.chromeProcess?.pid;
1105
+ if (web.status === "running") return [web.record.pid, ...chrome ? [chrome] : []];
1106
+ return web.status === "orphaned" && chrome ? [chrome] : [];
1107
+ }
1108
+ /**
1109
+ * Attributes the host process table to its owners. Reads the table only when a simulator is booted, a workspace is
1110
+ * live or a build runs, so an idle machine runs no `ps`.
1111
+ */
1112
+ function readMachineUsage({ states, roots, simNames, tables, busy }) {
1113
+ const processes = busy || states.some((state) => state.live || state.build?.state === "running") ? tables.host() : null;
1114
+ return processes ? attributeMachineUsage({
1115
+ processes,
1116
+ environments: states,
1117
+ roots,
1118
+ simNames
1119
+ }) : null;
1120
+ }
1121
+ /**
1122
+ * Rereads only the error counts, device activity and machine usage. With `machine`, and when the snapshot measured
1123
+ * machine usage, it reads the host process table again; it reuses every other subprocess fact of the full read.
1124
+ */
1125
+ function refreshLightFacts(snapshot, machine) {
1126
+ if (machine && snapshot.machine) snapshot.tables = {
1127
+ host: createDeviceProcessTables().host,
1128
+ android: snapshot.tables.android
1129
+ };
1130
+ withStateReadCache(() => {
1131
+ for (const state of snapshot.states) state.logs = logFacts(state.path);
1132
+ readDeviceProcesses(snapshot.states, snapshot.tables, null);
1133
+ });
1134
+ if (machine && snapshot.machine) snapshot.machine = readMachineUsage({
1135
+ ...snapshot,
1136
+ busy: true
1137
+ });
1138
+ }
1139
+ function renderStatus({ projects, states, labelOnlyRoots, leases, leaseNow, orphanWorktrees, simsAvailable, simctlError, cwdRoot, machine }, json) {
1140
+ const out = [];
689
1141
  const totalMemoryMb = Math.round(totalmem() / 1048576);
690
1142
  const cap = capacity(states, totalMemoryMb);
691
1143
  const pools = ["ios", "android"].map((platform) => {
@@ -708,7 +1160,8 @@ async function statusLines(json, gitMaxAgeMs) {
708
1160
  capacity: cap,
709
1161
  deviceLeases: leases,
710
1162
  unprovisionedWorktrees: orphanWorktrees,
711
- simctlAvailable: simsAvailable
1163
+ simctlAvailable: simsAvailable,
1164
+ machine
712
1165
  }));
713
1166
  return out;
714
1167
  }
@@ -729,7 +1182,7 @@ async function statusLines(json, gitMaxAgeMs) {
729
1182
  const marker = path === cwdRoot ? chalk.bold.cyan(`* ${shortcut}`) : shortcut;
730
1183
  const idle = state.live ? "" : chalk.dim(" [idle]");
731
1184
  out.push(`\n${marker}${idle} ${chalk.dim(`(${path})`)}`);
732
- out.push(labelOnlyRoots[i] ? chalk.dim(" worktree root (holds the label; the app registers its own entry)") : chalk.dim(` app: ${proj.bundleId ?? "?"} (${proj.isExpo ? "expo" : "bare"})`));
1185
+ out.push(labelOnlyRoots[i] ? chalk.dim(" worktree root (holds the label; the app registers its own entry)") : chalk.dim(` app: ${proj.bundleId ?? "?"} (${proj.isExpo ?? detectIsExpo(path) ? "expo" : "bare"})`));
733
1186
  if (state.metro) {
734
1187
  const label = state.metro.running ? chalk.green(`running (pid ${state.metro.pid})`) : state.metro.idleStop ? chalk.dim(`stopped (idle) after ${state.metro.idleStop.idleMinutes}m; \`stim start\`, \`ios\` or \`android\` restarts it`) : chalk.dim("not running");
735
1188
  out.push(` metro: port ${state.metro.port} ${label}`);
@@ -759,14 +1212,18 @@ async function statusLines(json, gitMaxAgeMs) {
759
1212
  if (deviceState.ios) {
760
1213
  const booted = deviceState.ios.state === "Booted" ? chalk.green("booted") : chalk.dim(deviceState.ios.state.toLowerCase());
761
1214
  const owned = deviceState.ios.owned ? chalk.dim(" (owned)") : "";
762
- out.push(` ios${slotLabel}: ${chalk.cyan(deviceState.ios.name ?? deviceState.ios.udid)} ${booted}${owned}${activitySuffix(deviceState.ios.activity)}`);
1215
+ out.push(` ios${slotLabel}: ${chalk.cyan(deviceState.ios.name ?? deviceState.ios.udid)} ${booted}${owned}${activitySuffix(deviceState.ios.activity)}${appSuffix(deviceState.ios.app)}`);
763
1216
  }
764
1217
  if (deviceState.android) {
765
1218
  const kind = deviceState.android.physical ? chalk.dim("(physical)") : chalk.dim("(emulator)");
766
1219
  const observed = deviceState.android.state ? ` ${deviceState.android.state}${deviceState.android.serial ? ` (${deviceState.android.serial})` : ""}` : "";
767
- out.push(` android${slotLabel}: ${chalk.cyan(deviceState.android.name)} ${kind}${observed}${deviceState.android.owned ? chalk.dim(" (owned)") : ""}${activitySuffix(deviceState.android.activity)}`);
1220
+ out.push(` android${slotLabel}: ${chalk.cyan(deviceState.android.name)} ${kind}${observed}${deviceState.android.owned ? chalk.dim(" (owned)") : ""}${activitySuffix(deviceState.android.activity)}${appSuffix(deviceState.android.app)}`);
768
1221
  }
769
1222
  }
1223
+ if (state.web) {
1224
+ const browser = state.web.running ? chalk.green(`running (pid ${state.web.pid}, DevTools ${state.web.cdpEndpoint})`) : chalk.dim("not running");
1225
+ out.push(` web: ${chalk.cyan(state.web.url)} ${browser}${state.web.headless ? "" : chalk.dim(" (headed)")}`);
1226
+ }
770
1227
  for (const remote of state.remoteDevices ?? []) out.push(` ${remoteDeviceLine(remote)}`);
771
1228
  for (const w of state.warnings) out.push(chalk.yellow(` ! ${w}`));
772
1229
  }
@@ -798,13 +1255,17 @@ async function statusLines(json, gitMaxAgeMs) {
798
1255
  }
799
1256
  async function watchStatus(json) {
800
1257
  let last = null;
1258
+ let snapshot = null;
801
1259
  let sources = null;
802
1260
  const scheduler = createRefreshScheduler({
803
1261
  debounceMs: 250,
804
- run: async () => {
1262
+ lightIntervalMs: WATCH_LIGHT_INTERVAL_MS,
1263
+ run: async (kind) => {
805
1264
  let text;
806
1265
  try {
807
- text = (await statusLines(json, WATCH_GIT_MAX_AGE_MS)).join("\n");
1266
+ if (kind === "light" && snapshot) refreshLightFacts(snapshot, json);
1267
+ else snapshot = await readStatus(WATCH_GIT_MAX_AGE_MS, sources?.simulatorListing());
1268
+ text = renderStatus(snapshot, json).join("\n");
808
1269
  } catch (error) {
809
1270
  console.error(chalk.red(String(error?.message || error)));
810
1271
  return;
@@ -816,9 +1277,11 @@ async function watchStatus(json) {
816
1277
  process.stdout.write(`${!json && process.stdout.isTTY ? "\x1B[2J\x1B[H" : ""}${text}\n`);
817
1278
  }
818
1279
  });
819
- const fallback = setInterval(() => scheduler.trigger(), WATCH_FALLBACK_MS);
1280
+ const fallback = setInterval(() => scheduler.trigger("full"), WATCH_FALLBACK_MS);
1281
+ const machine = setInterval(() => json && snapshot?.machine && scheduler.trigger("light"), WATCH_LIGHT_INTERVAL_MS);
820
1282
  const finish = () => {
821
1283
  clearInterval(fallback);
1284
+ clearInterval(machine);
822
1285
  scheduler.stop();
823
1286
  sources?.stop();
824
1287
  process.exit(0);
@@ -828,9 +1291,9 @@ async function watchStatus(json) {
828
1291
  process.stdout.on("error", finish);
829
1292
  sources = watchStatusSources({
830
1293
  home: getConfigDir(),
831
- onChange: () => scheduler.trigger()
1294
+ onChange: (kind) => scheduler.trigger(kind)
832
1295
  });
833
- scheduler.trigger(0);
1296
+ scheduler.trigger("full", 0);
834
1297
  await new Promise(() => {});
835
1298
  }
836
1299
  async function readGitInto(worktrees, orphans, maxAgeMs) {
@@ -857,6 +1320,62 @@ function activitySuffix(activity) {
857
1320
  const label = activityLabel(activity, Date.now());
858
1321
  return label ? ` -- ${activity?.state === "driven" ? chalk.magenta(label) : chalk.dim(label)}` : "";
859
1322
  }
1323
+ function readDeviceProcesses(states, tables, apps) {
1324
+ const readActivity = createActivityReader({ tables });
1325
+ const readAppProcess = createAppProcessReader(tables);
1326
+ for (const [i, state] of states.entries()) {
1327
+ const appIdOn = (platform, slot, deviceId) => {
1328
+ if (!apps) return void 0;
1329
+ const project = apps.projects[i][1];
1330
+ const launch = apps.launchesByState[i][deviceSlotKey(platform, slot)];
1331
+ return launch?.deviceId === deviceId ? launch.appId : platform === "ios" ? project.bundleId : project.androidPackage;
1332
+ };
1333
+ for (const device of [{
1334
+ slot: "default",
1335
+ ios: state.ios,
1336
+ android: state.android
1337
+ }, ...state.slots ?? []]) {
1338
+ const base = {
1339
+ slot: device.slot,
1340
+ workspace: state.path
1341
+ };
1342
+ if (device.ios?.state === "Booted") {
1343
+ const id = device.ios.udid;
1344
+ device.ios.activity = statusActivity(readActivity({
1345
+ ...base,
1346
+ platform: "ios",
1347
+ id
1348
+ }));
1349
+ const appId = device.ios.owned ? appIdOn("ios", device.slot, id) : void 0;
1350
+ if (appId) device.ios.app = readAppProcess({
1351
+ platform: "ios",
1352
+ id,
1353
+ appId
1354
+ });
1355
+ }
1356
+ if (device.android && !device.android.physical && device.android.serial) {
1357
+ const id = device.android.serial;
1358
+ device.android.activity = statusActivity(readActivity({
1359
+ ...base,
1360
+ platform: "android",
1361
+ id
1362
+ }));
1363
+ const appId = device.android.owned ? appIdOn("android", device.slot, id) : void 0;
1364
+ if (appId) device.android.app = readAppProcess({
1365
+ platform: "android",
1366
+ id,
1367
+ appId
1368
+ });
1369
+ }
1370
+ }
1371
+ }
1372
+ }
1373
+ function appSuffix(app) {
1374
+ if (!app) return "";
1375
+ if (app.state === "running") return chalk.dim(` -- ${app.id} running`);
1376
+ if (app.state === "stopped") return ` -- ${chalk.yellow(`${app.id} not running`)}`;
1377
+ return chalk.dim(` -- ${app.id} process unknown`);
1378
+ }
860
1379
  function linkedWorktrees(paths) {
861
1380
  return [...new Set(paths.flatMap((path) => gitCommonDirOnDisk(path) ?? []))].flatMap((common) => {
862
1381
  const repository = basename(common) === ".git" ? dirname(common) : common;
@@ -865,9 +1384,11 @@ function linkedWorktrees(paths) {
865
1384
  }
866
1385
  function workspaceBuilds(path, saved, history) {
867
1386
  const lastBuilds = readLastBuilds(saved);
1387
+ const builds = readBuildHistory(saved);
868
1388
  return {
869
1389
  build: workspaceBuild(path, saved, history),
870
- ...lastBuilds.ios || lastBuilds.android ? { lastBuilds } : {}
1390
+ ...lastBuilds.ios || lastBuilds.android ? { lastBuilds } : {},
1391
+ ...builds.ios || builds.android ? { builds } : {}
871
1392
  };
872
1393
  }
873
1394
  function workspaceBuild(path, saved, history) {