stim 0.0.0 → 1.1.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +162 -0
  3. package/dist/android-CC5bUer7.mjs +204 -0
  4. package/dist/android-DnZlT3aU.mjs +2279 -0
  5. package/dist/android-cas-DGlTStWu.mjs +1078 -0
  6. package/dist/android-cas-compiler.d.mts +1 -0
  7. package/dist/android-cas-compiler.mjs +71 -0
  8. package/dist/android-xmyGBNy5.mjs +665 -0
  9. package/dist/app-install-oZIQRbdD.mjs +1141 -0
  10. package/dist/build-lock-vE45ZzJZ.mjs +645 -0
  11. package/dist/build-slots-CgacA9zQ.mjs +152 -0
  12. package/dist/cache-manifest-oeH18ERr.mjs +66 -0
  13. package/dist/cache-manifest.d.mts +27 -0
  14. package/dist/cache-manifest.mjs +2 -0
  15. package/dist/cli.d.mts +1 -0
  16. package/dist/cli.mjs +40 -0
  17. package/dist/collector-run.d.mts +119 -0
  18. package/dist/collector-run.mjs +541 -0
  19. package/dist/command-output-8wHMEde0.mjs +289 -0
  20. package/dist/config-D7uu8Gbq.mjs +381 -0
  21. package/dist/deps-64wVnpZc.mjs +435 -0
  22. package/dist/dev-client-Bvky1hya.mjs +557 -0
  23. package/dist/device-DJKv5Qej.mjs +250 -0
  24. package/dist/device-lease-D-G3SgVe.mjs +335 -0
  25. package/dist/device-pool-BlHXnYYs.mjs +408 -0
  26. package/dist/device-remote-CCgkG82J.mjs +1209 -0
  27. package/dist/doctor-BRF9aAJ3.mjs +530 -0
  28. package/dist/doctor-DlVxTtMq.mjs +1137 -0
  29. package/dist/error-diagnostics-B5IJwv3-.mjs +636 -0
  30. package/dist/errors-B6W7FkxQ.mjs +15 -0
  31. package/dist/exec-bsN9MJXb.mjs +63 -0
  32. package/dist/gc-DhXdgSdt.mjs +1651 -0
  33. package/dist/guide-DrhrDBo5.mjs +4319 -0
  34. package/dist/ios-BZxKPuqP.mjs +382 -0
  35. package/dist/ios-CK1Vrk6Q.mjs +2721 -0
  36. package/dist/ios-device-CYbuRNXR.mjs +81 -0
  37. package/dist/ios-device-CdSm0GDw.mjs +413 -0
  38. package/dist/logs-Dxoy16Qv.mjs +176 -0
  39. package/dist/logs-query-oqn47Ffx.mjs +273 -0
  40. package/dist/metro-B01H3EuD.mjs +184 -0
  41. package/dist/metro-store-CDDZHtPl.mjs +92 -0
  42. package/dist/ndjson-DcAtEx_K.mjs +131 -0
  43. package/dist/ndjson-DvHSsNU9.d.mts +16 -0
  44. package/dist/ownership-B9QIdQOw.mjs +475 -0
  45. package/dist/ownership-D8HmSv2E.mjs +60 -0
  46. package/dist/ownership-claim-CeJLEpjD.mjs +569 -0
  47. package/dist/process-output-C7IlIzd7.mjs +40 -0
  48. package/dist/project-COj6nzzh.mjs +243 -0
  49. package/dist/reclaim-BPvN2HlP.mjs +347 -0
  50. package/dist/reload-DYZEBbZy.mjs +332 -0
  51. package/dist/remote-cache-DOi5-zLj.mjs +934 -0
  52. package/dist/server-bare-BYHqEeyw.mjs +352 -0
  53. package/dist/server-expo-BEMr_m6p.mjs +312 -0
  54. package/dist/server-expo-rb0k-0w7.mjs +2 -0
  55. package/dist/settings-BSwrY4FZ.mjs +687 -0
  56. package/dist/spawn-entry-nCE6m770.mjs +14 -0
  57. package/dist/start-ByCylYtB.mjs +822 -0
  58. package/dist/state-IYEY-zi8.mjs +268 -0
  59. package/dist/stats-DZ4Q5J-7.mjs +62 -0
  60. package/dist/stats-wqxPm7Tv.mjs +307 -0
  61. package/dist/status-Dia0XTqG.mjs +363 -0
  62. package/dist/stop-BApyaF21.mjs +591 -0
  63. package/dist/stop-D6o5zAdm.mjs +3 -0
  64. package/dist/supervisor-run.d.mts +84 -0
  65. package/dist/supervisor-run.mjs +210 -0
  66. package/dist/workspace-process-lock-CxOO9-pf.mjs +47 -0
  67. package/dist/worktree-Dx3ZC5lS.mjs +539 -0
  68. package/dist/worktree-DxEmUI_n.mjs +1322 -0
  69. package/dist/xcode-CDB6fytw.mjs +1705 -0
  70. package/package.json +61 -8
  71. package/shim/android-cas-pch.cmake +35 -0
  72. package/shim/android-cas.gradle +29 -0
  73. package/shim/android-cas.toolchain.cmake +12 -0
  74. package/shim/android-optimizations.gradle +30 -0
  75. package/shim/bundle-response.cjs +46 -0
  76. package/shim/bundle-response.d.cts +5 -0
  77. package/shim/expo-metro-config.cjs +188 -0
  78. package/shim/metro-cache-generation.cjs +20 -0
  79. package/shim/metro-cache-generation.d.cts +5 -0
  80. package/skill/SKILL.md +14 -0
@@ -0,0 +1,4319 @@
1
+ import { t as ANDROID_AVD_CONFIG_HELP } from "./settings-BSwrY4FZ.mjs";
2
+ import chalk from "chalk";
3
+ //#endregion
4
+ //#region src/guide/index.ts
5
+ const TOPICS = {
6
+ agent: {
7
+ summary: "The normal coding-agent workflow, safety rules, and topic routing",
8
+ body: () => `AGENT WORKFLOW
9
+
10
+ Use Stim to run React Native and Expo apps without sharing a Metro port or
11
+ device with another workspace. Prefer plain output: it streams each phase and
12
+ ends with the facts the next step needs. Use --json only when a script must
13
+ parse a stable payload.
14
+
15
+ TWO WORKFLOWS
16
+
17
+ SINGLE CHECKOUT: work in place, on whatever branch the task needs, in one
18
+ directory. start, ios, android, logs, stop, and never a linked worktree. That
19
+ directory is your workspace, and no rule below about keeping the source checkout
20
+ fit as a seed applies to it.
21
+
22
+ WORKTREE: the checkout you cloned is a seed. It stays clean and on the default
23
+ branch, and every task gets a linked worktree warmed from it. In this workflow
24
+ the source checkout is infrastructure, not a workspace: you edit, build, and run
25
+ in the worktree, and you keep the seed fit to copy. Every rule below about the
26
+ source checkout's fitness as a seed belongs to this workflow.
27
+
28
+ Doctor reports that fitness -- how far behind the seed is, uncommitted tracked
29
+ changes, an interrupted rebase or merge, a detached HEAD, a diverged branch, a
30
+ branch that is not the default one -- only once the repository has at least
31
+ one linked worktree, so read it from inside the worktree. A single-checkout
32
+ session is never told that its own branch is a problem.
33
+
34
+ NORMAL WORKFLOW
35
+
36
+ Work in the current checkout by default. When the task needs another branch or
37
+ an isolated environment, take the worktree workflow: create a linked worktree
38
+ with Git and warm its ignored state. If a harness already created this linked
39
+ worktree, run stim worktree warm here instead of creating another one. It
40
+ copies missing ignored paths from the source checkout, including eligible .env
41
+ and local configuration files. It preserves the branch, tracked files, and
42
+ every existing destination entry; existing ignored directories are skipped
43
+ whole, not filled in. Add --refresh to fast-forward the source checkout and
44
+ install what moved there before the copy; it refuses a source checkout with local
45
+ work and never switches branches. Read guide lifecycle options for exclusions
46
+ and incomplete-copy remedies.
47
+
48
+ Wait for warm to exit successfully (exit code 0) before running stim start,
49
+ stim ios, stim android, or a dependency install in that worktree. If the shell
50
+ tool returns a running session or job ID, poll or wait for that job to finish;
51
+ the ID is not completion. Concurrent writes to the destination are unsafe:
52
+ warm checks for existing entries before copying, not during the copy. Do not
53
+ edit files, install dependencies, or run another warm in that worktree until
54
+ it finishes; concurrent files can be overwritten or removed.
55
+ If warm fails or reports incomplete, resolve the reported failure first.
56
+
57
+ Before native worktree work, run doctor for the platform in scope. It checks
58
+ the source checkout from a linked worktree. Fix relevant findings and inspect the
59
+ upstream gap; in the single-checkout workflow those seed findings do not
60
+ appear. It also prints the running CLI version and the stim installation
61
+ resolved from PATH. If that resolved installation is older than another one,
62
+ fix PATH or the installation before continuing so commands and guidance match.
63
+ Doctor reports cross-volume staging and build-cache copies; read guide settings
64
+ for placement overrides and guide lifecycle options for warm behavior.
65
+ For iOS Debug architecture findings, review the project's overrides and imported
66
+ Podfile helpers using guide lifecycle options. Doctor --fix does not change them.
67
+ For a linked native library carrying Git metadata, add the printed .git entries to
68
+ .fingerprintignore only when the native build does not read Git state.
69
+
70
+ stim doctor --platform ios # or: --platform android
71
+
72
+ For stale Android CMake launcher findings, stop native builds and run
73
+ stim doctor --fix --platform android in the affected checkout before warming
74
+ more worktrees. It removes affected ignored, untracked generated .cxx
75
+ configurations, including installed native modules; the next build recreates
76
+ them. It preserves source, custom launcher settings, and the shared ccache.
77
+
78
+ # Skip Git creation if the harness already created this linked worktree.
79
+ git worktree add -b <branch> <worktree-path> HEAD
80
+ cd <worktree-path>
81
+ stim worktree warm
82
+
83
+ stim start
84
+ stim ios # or: stim android
85
+
86
+ # Reproduce the affected behavior and capture the baseline errors.
87
+ stim logs --errors
88
+
89
+ # If a native process exits with no report, inspect the captured device output.
90
+ # An empty query is not proof that a crashed app was healthy.
91
+ # See guide logs for JS/native symbolication and capture limits.
92
+
93
+ # Edit JavaScript or TypeScript; Fast Refresh applies the change.
94
+ # For UI work, wait for the expected UI and repeat the affected interaction
95
+ # on the reported device. Keep using the existing automation session, if any.
96
+ stim logs --errors
97
+ # Retain proof before cleanup: a screenshot, recording, or relevant runtime output.
98
+
99
+ stim stop
100
+ stim worktree remove
101
+
102
+ RULES DURING THE LOOP
103
+
104
+ - Run Stim from the app directory: the one whose package.json depends on
105
+ react-native or expo. Anywhere else -- a monorepo root, a tools package --
106
+ start, ios and android refuse with STIM_NO_PROJECT naming that package.json,
107
+ and doctor reports it as a finding.
108
+ - Put runtime .stim.json beside that app's package.json. Monorepo apps do not
109
+ inherit a repository-root runtime file. Keep repository-wide worktree-copy
110
+ rules at the source checkout root; see guide settings for the two scopes.
111
+ - Run start before a debug ios or android build. If it returns STIM_NO_METRO,
112
+ run stim start and retry.
113
+ - Run ios or android again after a native input changes. A JavaScript-only
114
+ change does not need one.
115
+ - For stale Metro transforms or file-map state, use stim start --reset-cache.
116
+ It restarts only this app's verified owned Metro, preserving devices and other
117
+ apps' caches. See guide lifecycle for reset scope and Expo requirements.
118
+ - Reload is not part of the normal workflow. Use stim reload on an owned local
119
+ simulator or emulator when an error screen remains after the fix, and on
120
+ Android after a failed first bundle load. It reloads JavaScript and never
121
+ restarts the app. An iOS app whose first bundle failed never connects to
122
+ Metro, so reload cannot reach it and says to use the device's own Reload
123
+ control. For a physical device that reached Metro, use agent-device metro
124
+ reload with the reported port. The detected iOS Local Network first-load
125
+ remedy uses UI automation instead because that app never established a Metro
126
+ connection.
127
+ - A successful stim reload confirms that the request was sent, not that new
128
+ JavaScript loaded or the screen recovered. Verify the expected UI on the
129
+ reported device and inspect stim logs --errors before claiming recovery.
130
+ - If launch reports an app error but also says the native process is alive,
131
+ the app did not crash. Fix JavaScript or TypeScript and use Fast Refresh. If
132
+ the error screen remains, follow the printed reload remedy instead of
133
+ running ios or android again. If launch says FATAL because the app process exited,
134
+ fix the crash and run the platform command again; Metro cannot restart it.
135
+ - A native build can outlive a shell timeout. Retry the same command and
136
+ follow its printed remedy if waiting times out. See guide lifecycle concurrency.
137
+ - ios and android install the app, launch it, and check readiness. Trust the
138
+ exact device, app, Metro, and launch facts in the final summary. Use the full
139
+ reported device ID. Never assume a simulator named booted belongs to this
140
+ workspace.
141
+ - After each ios or android run, give the user one compact result: exact device,
142
+ app id, launch state, cache result, total duration, and whether stim logs
143
+ --errors passed. Include a remedy only when action remains. Do not repeat the
144
+ phase transcript.
145
+ - An OK summary with no launch qualifier proves the launch. "bundle requested,
146
+ still building" means Metro has not finished; wait and query the logs. For
147
+ launch UNVERIFIED, follow the printed remedy before claiming success. JSON
148
+ reports these as true, "bundling", and "unverified" in launched.
149
+ WARNING means the native launch completed with app errors, or an app readiness
150
+ signal was expected but not confirmed; inspect the output before claiming a healthy UI.
151
+ - A clean logs --errors check requires exit code 0 AND no matching errors in
152
+ captured logs. Exit code 0 alone means the query succeeded, even when errors
153
+ were printed. Human output shows "No matching log records" on stderr for
154
+ zero matches; JSON mode prints zero bytes. This does not prove launch or log
155
+ capture succeeded. Do not read the NDJSON files directly.
156
+ - Use stim status when resuming a workspace or recovering missing device,
157
+ port, server, or build facts. A normal start and platform run already print
158
+ them. Use stim doctor when a build is unexpectedly slow or the environment
159
+ looks incomplete.
160
+
161
+ OWNERSHIP AND DELETION
162
+
163
+ Stim creates, boots, and deletes only devices it created. Owned simulators use
164
+ the stim-<label> (<model> <runtime>) name. Never point Stim at a user-created
165
+ emulator or simulator.
166
+
167
+ worktree remove parks the workspace's simulator or emulator for later adoption.
168
+ A parked device is Stim-owned: never delete one by hand. gc --delete clears verified
169
+ entries and keeps failures; see guide lifecycle pool. First launch on a
170
+ physical iPhone can need the one-time taps named by the remedy.
171
+
172
+ stim android --device [serial] and stim ios --device [udid] install on a
173
+ connected physical device. Stim never creates, boots, shuts down, or deletes
174
+ hardware. It records a temporary lease, not an owned-device registry entry.
175
+
176
+ A --device run leases that device for the run. stim device lock ios --for 10m
177
+ holds it across runs; stim device unlock gives it back. Never delete another
178
+ workspace's lease file under ~/.stim/device-locks; gc --delete removes expired
179
+ ones.
180
+
181
+ stop and worktree remove release this workspace's leases. On a physical
182
+ iPhone, stop also closes the app by ending its log collector; it does not
183
+ shut down the phone or uninstall the app.
184
+
185
+ Treat a refusal as an ownership or state mismatch: read its code and remedy.
186
+ Never reach for --force first.
187
+ Stim leaves externally started servers alone. Stop them with their original
188
+ tool; neither a matching port nor --force grants process ownership.
189
+
190
+ Ask the user before these actions:
191
+
192
+ - worktree remove, because it deletes the worktree and gives up its owned
193
+ device. It works with any linked worktree, warmed or not, without requiring
194
+ a Stim registry entry. Git-created branches are kept; a branch with an
195
+ existing Stim ownership record is deleted only when it has no unique commits.
196
+ - worktree remove --force, because it also discards uncommitted and untracked
197
+ files.
198
+ - gc --delete, because it deletes orphaned resources. gc --delete --cache all
199
+ empties the shared build caches instead; it inspects nothing else.
200
+ - stop when the workspace owns an EAS session, because it irreversibly ends
201
+ that remote session. For a local device, stop shuts it down but does not
202
+ delete it. An explicit stop shuts down a Stim-owned simulator even when
203
+ another process uses it. It never shuts down an unowned simulator.
204
+
205
+ SANDBOXES
206
+
207
+ An agent harness that sandboxes shell commands usually permits writes inside
208
+ the project and little else. Stim also needs writes to STIM_HOME (~/.stim by
209
+ default), simulator service access, and local access to the adb server. When
210
+ those sit outside the harness allowlist, the failure looks like an unwritable
211
+ directory or unavailable device service rather than a broken machine. Decide
212
+ at the start of a session whether to run Stim outside the sandbox or ask the
213
+ user to allow those operations. guide errors sandbox lists the exact
214
+ requirements.
215
+
216
+ LOAD ADVANCED GUIDANCE WHEN NEEDED
217
+
218
+ stim guide # list topics
219
+ stim guide errors # index of every refusal code and message
220
+ stim guide errors <CODE> # one refusal, e.g. stim guide errors STIM_NO_METRO
221
+ stim guide errors sandbox # running under a sandboxing harness
222
+ stim guide errors unverified # launch unverified, and the Local Network reason
223
+ stim guide errors fallbacks # swap, cache, and install notes on a release cache hit
224
+ stim guide lifecycle # the ordered flow, consent rules, and capacity
225
+ stim guide lifecycle verification # reproduce, edit, verify the UI, and retain proof
226
+ stim guide lifecycle readiness # add optional app readiness logs; no package required
227
+ stim guide lifecycle builds # build optimizations, optional cache warm-up, fingerprints
228
+ stim guide lifecycle concurrency # shared builds, wait timeouts, capacity limits
229
+ stim guide lifecycle options # every flag, Android variants, --device-type, --system-image
230
+ stim guide lifecycle devices # ios --device and android --device on a physical phone
231
+ stim guide lifecycle release # Release configurations and ...Release variants
232
+ stim guide facts # the --json payloads
233
+ stim guide facts devmenu # the Expo dev menu or Tools button over the app
234
+ stim guide metro # supervisor, custom Metro, tunnels, and remote devices
235
+ stim guide logs # filters, record shape, and capture limits
236
+ stim guide cleanup # what reclaims a device, and what deletes
237
+ stim guide cleanup collector # an unproven collector pid; why the app on a phone closed
238
+ stim guide settings # configuration files and supported keys
239
+
240
+ A refusal prints a CODE such as STIM_NO_METRO. Run stim guide errors <CODE>
241
+ with the code exactly as printed and read only that section; every code in a
242
+ shared header (STIM_BAD_ARG / STIM_NO_PROJECT) resolves to the same section. A
243
+ refusal with no code is quoted by message in stim guide errors. A topic with
244
+ sections called bare prints its section index, so read the narrowest section
245
+ before release configurations or Android variants; remote devices; custom
246
+ Metro processes or tunnels; cache misses, bypasses, or concurrent builds;
247
+ capacity limits; cache statistics from stim stats; worktree carry-over;
248
+ fingerprint exclusions; gc; --force; cleanup failures; or unfamiliar states
249
+ and error codes. Ordinary stim stop and an authorized clean
250
+ stim worktree remove do not need the cleanup guide.`
251
+ },
252
+ facts: {
253
+ summary: "The --json payloads: `start`, `ios`, `android`, `reload`, `stop`, `status`, `doctor`, `device lock`/`unlock`, and the error contract",
254
+ preamble: () => `FACTS CONTRACT
255
+
256
+ \`start\`, \`ios\`, \`android\`, \`reload\`, \`stop\`, \`status\`, \`stats\`, \`doctor\`,
257
+ and \`device lock\`/\`device unlock\` each print exactly ONE line of JSON on
258
+ stdout for \`--json\`. Every other line goes to stderr, so it is always safe
259
+ to pipe. \`logs --json\` is the one exception: it is NDJSON, one record per
260
+ line by design (see \`guide logs\`), not this single-payload contract.`,
261
+ sections: {
262
+ payloads: {
263
+ summary: "every field of the start, ios, android and reload payloads, the error contract, the device rules",
264
+ body: () => ` stim start --json
265
+
266
+ port the Metro port RESERVED for this workspace
267
+ supervisorPid the detached supervisor's pid, or NULL when a dev server was
268
+ already answering that Stim did not start
269
+ mode "bare-inproc" | "expo-child" | null (see \`guide metro\`)
270
+ logsDir where the NDJSON timeline is written
271
+ alreadyRunning true when nothing needed starting
272
+
273
+ stim ios --json
274
+
275
+ platform "ios"
276
+ udid the owned simulator this workspace installed onto, or the
277
+ phone's UDID on \`--device\`. A physical device gets no
278
+ owned-device registry entry; its ID is stored in a temporary
279
+ lease. \`stop\` releases workspace leases and \`gc --delete\`
280
+ removes expired lease files
281
+ deviceName its name, or null
282
+ deviceType the owned simulator's MODEL, as
283
+ \`xcrun simctl list devicetypes\` names it ("iPad Pro 13-inch
284
+ (M4)"). Read from the simulator itself, so a run driven by
285
+ the ios.deviceType setting reports it too, not only a
286
+ \`--device-type\` run. Null on \`--device\` and on a
287
+ simulator Stim does not own
288
+ runtime that simulator's iOS runtime version ("18.5"), from the same
289
+ record. Null on the same paths as deviceType
290
+ fingerprint the @expo/fingerprint hash of the native inputs, AS STORED.
291
+ A run that had to \`expo prebuild\` or \`pod install\`
292
+ rewrote fingerprinted files while it worked (the generated
293
+ native directory, package.json's scripts, the app config,
294
+ Podfile.lock), so the hash it looked up is not the hash the
295
+ tree has afterwards. The artifact is stored under the hash
296
+ computed AFTER those steps -- the one the next run in this
297
+ tree computes -- and this field reports that one. The shift
298
+ is printed on stderr as one dim line naming both short
299
+ hashes. A prebuild shift is RE-LOOKED-UP before anything
300
+ compiles (\`cache
301
+ hit 6564e2.. (post-prebuild key)\`), so a cold tree -- a
302
+ fresh worktree or clone of a CNG app -- installs an entry
303
+ another workspace already built instead of compiling
304
+ beside it. Android also fingerprints after Gradle because
305
+ Gradle plugins can rewrite native inputs while they build;
306
+ its artifact is stored only under that post-build hash. A
307
+ stable second fingerprint prints no shift line. If the iOS
308
+ fingerprint after prebuild or pod install, or the Android
309
+ fingerprint after Gradle, cannot be computed, the build is
310
+ installed but not cached, and fingerprint and cacheKey are null
311
+ configuration the Xcode configuration that was built ("Release" from
312
+ --configuration or the ios.configuration setting); null for
313
+ the default Debug
314
+ scheme the explicit shared Xcode scheme selected by --scheme;
315
+ absent for automatic selection; not the app URL scheme
316
+ cacheKey the shared-build-cache key derived from it (the
317
+ configuration is part of it: -release-sim vs -debug-sim)
318
+ cacheHit WHICH LEVEL answered, not a boolean:
319
+ "local" this machine's shared cache (free, instant)
320
+ "remote" the project's own Expo buildCacheProvider (a
321
+ download; it is copied into the local cache on
322
+ the way past, so the next workspace is "local")
323
+ false nothing answered, so it was compiled
324
+ webPreviewUrl only on a remote device that has one (an EAS Simulator
325
+ session): a browser URL showing that device's screen. Absent
326
+ on a local device. Hand it to the human -- it is the only way
327
+ to see a device that is not on this machine. Never open it ON
328
+ the device; it is a page, not a deep link.
329
+ cacheSkipped true only when --no-build-cache was passed: "nothing was
330
+ looked up", which is a different fact from "nothing was found"
331
+ compilationCache
332
+ Xcode compilation-cache activity for a compiled iOS app:
333
+ { status: "reported", hits, cacheableTasks, hitRatePercent }
334
+ status is "not-run" when the artifact cache supplied the app.
335
+ status is "unavailable" when Xcode did not print reliable
336
+ statistics. This field is separate from cacheHit
337
+ waitedForBuild { pid, ms } when ANOTHER workspace was already compiling this
338
+ exact fingerprint and this run waited for its artifact instead
339
+ of compiling a second copy
340
+ (see \`guide lifecycle concurrency\`); null when nothing was
341
+ waited for.
342
+ cacheHit is "local" either way -- the artifact did come from
343
+ the local cache -- so this is what separates "it was already
344
+ there" (free) from "it was there twelve minutes later" (still
345
+ cheaper than a second build). Both commands carry it
346
+ appPath the .app that was installed
347
+ bundleId the iOS bundle id that was launched
348
+ installSkipped true when the artifact was ALREADY on the device byte for
349
+ byte, so nothing was installed and the run went straight to
350
+ launch (see \`guide lifecycle builds\`). false means an
351
+ install ran.
352
+ Always false on \`--device\`: proving a phone already holds
353
+ the bundle would cost more than installing it
354
+ launched true, "bundling", or "unverified". THE THREE ARE DIFFERENT
355
+ FACTS and only the last one is a problem.
356
+ true Metro finished the bundle response, then the app stayed
357
+ alive through a three-second stability window.
358
+ The command checks process liveness when the
359
+ platform exposes it. Errors from that window
360
+ are printed even when the app stays alive,
361
+ EXCEPT the device log's, which is COUNTED into
362
+ one \`launch\` line instead (see
363
+ \`guide logs\`). The agent decides whether a
364
+ nonfatal error matters.
365
+ IT IS NOT A PAINTED SCREEN. Stim observes the
366
+ bundle and the process, never a frame, and a
367
+ cold app can keep rendering for a minute or
368
+ more after this, which is why the stderr line
369
+ reads \`bundle loaded, process alive, stable
370
+ for 3s -- the first screen may still be
371
+ rendering\`. Poll the UI before you trust a
372
+ screenshot. Optional app-declared readiness
373
+ adds a separate stderr readiness phase; it
374
+ does not change this field. See
375
+ \`guide lifecycle readiness\`
376
+ "bundling" the request DID arrive and Metro was still
377
+ building or delivering when the bundle timeout closed.
378
+ The wiring is proven; the JS has simply not
379
+ run yet (a cold bundle of ~10k modules takes
380
+ longer than the window). Nothing to do --
381
+ no remedy list is printed for it -- and
382
+ \`logs --source metro\` shows the build
383
+ finishing
384
+ "unverified" nothing was observed at all: usually a
385
+ dev-client server picker awaiting a tap
386
+ See \`guide facts devmenu\` for the dev menu and its button.
387
+ metroPort the port the app was wired to; NULL on a non-Debug
388
+ configuration, whose JS is embedded and which is launched
389
+ with no dev server at all. There, \`launched\` is verified
390
+ by the app process staying alive after launch (a bad
391
+ embedded bundle crashes within seconds), not by a bundle
392
+ request. A process that exits fails the command. An iOS
393
+ launch with no process id is "unverified", and
394
+ \`stim logs --errors\` has the device log that says why
395
+ logs { dir }
396
+ durationMs wall time for the whole run
397
+
398
+ stim android --json
399
+
400
+ platform "android"
401
+ serial the owned emulator (always "emulator-<consolePort>")
402
+ avdName the AVD's NAME (stim-<label>). The serial is a slot --
403
+ emulator-5554 is whatever booted into that console port
404
+ first -- so this is what addresses the emulator in
405
+ \`emulator -avd\`, avdmanager, or a device tool. The console
406
+ port is CHOSEN AND RECORDED under the global config lock
407
+ BEFORE the emulator starts, then passed to it as \`-port\`,
408
+ so two workspaces booting at the same moment cannot land on
409
+ one serial. A boot that fails releases the port again and
410
+ keeps the AVD recorded for \`gc\`
411
+ deviceName the same name, matching the iOS payload's field
412
+ systemImage the sdkmanager package id the owned AVD was created from
413
+ ("system-images;android-36;google_apis;arm64-v8a"), read from
414
+ the AVD's own config.ini, so a run driven by the
415
+ android.systemImage setting reports it too, not only a
416
+ \`--system-image\` run. Null on \`--device\` and on an
417
+ emulator Stim does not own
418
+ fingerprint / cacheKey / cacheHit / cacheSkipped / waitedForBuild /
419
+ appPath / installSkipped / launched
420
+ as above -- cacheKey keys on the VARIANT here
421
+ (<fingerprint>-productionrelease-sim). A Debug artifact for
422
+ a proven target ABI also ends in that ABI
423
+ (<fingerprint>-debug-sim-arm64-v8a)
424
+ variant the gradle variant that was built ("productionDebug" from
425
+ --variant or the android.variant setting); null for the
426
+ default assembleDebug. A variant whose name ENDS IN Release
427
+ is a release build: its JS is embedded and no dev server is
428
+ used
429
+ metroPort the port the app was wired to; NULL on a release-shaped
430
+ variant, exactly as on a non-Debug iOS configuration.
431
+ There, \`launched\` is verified by the app PROCESS being
432
+ alive on the device a moment after launch (\`pidof\`, then
433
+ \`ps -A\`), not by a bundle request -- "unverified" means
434
+ no process was found fails the command, and
435
+ \`stim logs --errors\` has the device log that says why
436
+ bundleId the ANDROID PACKAGE NAME the launch, the port wiring and
437
+ the remedies all target -- read from the BUILT APK's
438
+ manifest, which on a flavored project is the flavor's
439
+ applicationId, not what the project files say
440
+ debugHttpHost "10.0.2.2:<port>" on an emulator, "localhost:<port>" on a
441
+ physical device, when the app's SharedPreferences were
442
+ pointed at this workspace's Metro; null when they were not.
443
+ A healthy run reverses only <port> -> <port>, which is what
444
+ that host resolves to. Only when the write fails does Stim
445
+ also reverse 8081 -> <port>, so the app's compiled-in
446
+ default still finds this workspace's Metro
447
+ debugHttpHostNote
448
+ why the write did not land, when it did not. A launch
449
+ survives it -- this is the difference between the two
450
+ devClientUrl the expo-dev-client deep link that was opened, or null for
451
+ a plain launcher start. This is the command that puts the
452
+ app back on THIS workspace's bundle
453
+ ccache the Android C++ compilation cache, the counterpart of the
454
+ iOS compilationCache field:
455
+ { status: "reported", hits, misses, hitRatePercent }
456
+ status is "not-run" when the artifact cache supplied the
457
+ APK. status is "unavailable" when no C++ compile went
458
+ through ccache -- ccache absent from PATH, a project that
459
+ sets its own CMake compiler launcher, or a Gradle run whose
460
+ native work was all up to date. None of the three is an
461
+ error, and this field is separate from cacheHit
462
+ logs the workspace log directory
463
+ durationMs wall time for the whole run
464
+
465
+ stim reload [ios|android] --json
466
+
467
+ Exit 0 and this payload confirm that the reload request was sent. They do
468
+ not prove that new JavaScript loaded or that the screen recovered. The
469
+ command does not observe completion. Verify the expected UI on deviceId
470
+ and inspect stim logs --errors before claiming recovery.
471
+
472
+ platform "ios" | "android"
473
+ deviceId the exact owned simulator UDID or emulator serial targeted
474
+ deviceName the owned simulator or AVD name
475
+ appId the live bundle id or Android package
476
+ metroPort the workspace's verified Metro port
477
+ strategy how the reload was addressed.
478
+ "metro-websocket" -- Metro named its clients and Stim
479
+ addressed every peer matching this platform. A workspace
480
+ Metro serves one app, so those peers are this app on however
481
+ many devices are attached to that port.
482
+ "metro-broadcast" -- this Metro cannot name its clients, so
483
+ the reload went to all of them and Stim cannot confirm appId
484
+ was among them. Verify the UI on deviceId; if it did not
485
+ change, reload from the app's own error screen or dev menu
486
+ targets how many peers the reload was addressed to, or null when
487
+ broadcast. Greater than 1 means several devices are running
488
+ this app on that Metro and the request addressed all of
489
+ them, not only deviceId. Completion is not observed
490
+
491
+ stim doctor --json
492
+
493
+ project the resolved app root
494
+ platform "ios" | "android" | null
495
+ stim { runningVersion, runningPath, resolved, installations,
496
+ versions, highestVersion, resolvedIsOlder }
497
+ resolved is the first executable named stim on PATH;
498
+ installations contains every distinct real executable on
499
+ PATH and the version each reports. resolvedIsOlder is true
500
+ only when that first executable is below the highest version
501
+ available from this invocation or PATH
502
+ findings the diagnostic findings; a lower resolved Stim is a
503
+ costs-time finding with a PATH or installation remedy
504
+
505
+ ON FAILURE
506
+ \`start\`, \`ios\` and \`android\` all print the error contract instead,
507
+ still one line on stdout, and exit 1:
508
+
509
+ { "code": "STIM_NO_METRO", "message": "...", "remedy": "..." }
510
+
511
+ If a native build returned before the failure, this payload also carries
512
+ \`ccache\` (Android) or \`compilationCache\` (iOS), with the status and
513
+ counters described above. This includes failed builds and later install
514
+ or launch failures. The field is absent when no native build returned.
515
+
516
+ Branch on \`code\`, never on the message text. \`guide errors\` enumerates
517
+ every code.
518
+
519
+ RULES
520
+ - Never hardcode or guess a udid/serial/port. Read them from the payload.
521
+ - Pass them EXPLICITLY to every device tool you drive yourself
522
+ (agent-device, xcrun simctl, adb -s, idb).
523
+ - Never assume "booted" is your simulator. Other agents have theirs booted
524
+ too.
525
+ - Every device Stim creates or boots is one Stim created, named
526
+ stim-<label> (<model> <runtime>) on iOS. New local device labels combine
527
+ the git worktree directory and app directory names, e.g.
528
+ pr6460-tlon-mobile. Equal names collapse to one; outside git, the app
529
+ directory name is used. An iOS name collision adds the workspace ID
530
+ after the model and runtime, preserving it when the label is truncated.
531
+ Existing owned iOS simulators are renamed on reuse. Android keeps
532
+ existing and adopted AVD names. The exceptions are
533
+ \`android --device\` and
534
+ \`ios --device\`, which use a connected physical device Stim never
535
+ creates, boots, or deletes.`
536
+ },
537
+ devmenu: {
538
+ summary: "why the Expo dev menu or Tools button is or is not over the app, per platform and device kind",
539
+ body: () => ` EVERY DEV-CLIENT DEEP LINK CARRIES disableOnboarding=1
540
+ INSIDE ITS PROJECT URL
541
+ (\`...?url=http%3A%2F%2Fhost%3Aport%2F%3FdisableOnboarding%3D1&disableFab=1\`),
542
+ and expo-dev-launcher finishes its own dev-menu ONBOARDING
543
+ when it reads it. That is all the flag does: it sets
544
+ EXDevMenuIsOnboardingFinished. ON iOS it has to sit on the
545
+ PROJECT url -- the value of the \`url\` parameter -- because
546
+ that is the URL the launcher hands to the check; on the
547
+ outer deep link it does nothing there. Android reads it on
548
+ either.
549
+ ON A SIMULATOR, before a local dev-client openurl, Stim
550
+ preapproves CoreSimulatorBridge for exactly the installed
551
+ bundle id and discovered scheme on its owned simulator. That
552
+ suppresses iOS's first-launch confirmation;
553
+ unrelated schemes remain unapproved. It also writes
554
+ EXDevMenuShowsAtLaunch=false and
555
+ EXDevMenuShowFloatingActionButton=false, which the flag does
556
+ NOT cover, and those together are what keep the menu and its
557
+ button off a simulator entirely, so device automation opens
558
+ on the app. The
559
+ unverified warning therefore leads with the picker, then
560
+ prints the openurl
561
+ retry. ON LOCAL ANDROID the same deep link also carries the
562
+ \`EXDevMenuDisableAutoLaunch\` boolean intent extra, which
563
+ the launcher reads to set EXDevMenuShowsAtLaunch=false and
564
+ EXDevMenuIsOnboardingFinished=true. It stops the menu
565
+ opening automatically, but does NOT set expo-dev-menu's
566
+ showFab preference, so its floating Tools button can remain.
567
+ Remote Android opens only the URL, so that intent-extra
568
+ suppression does not apply there.
569
+ Every Stim deep link also carries an outer \`disableFab=1\`
570
+ query parameter. Versions with expo/expo#49651 use that as a
571
+ session-only override; earlier versions ignore it. Stim does
572
+ not rewrite expo-dev-menu's private SharedPreferences XML:
573
+ that internal file is not a supported API, and changing it
574
+ would persist over the user's own Tools-button setting. The
575
+ list leads with the supported launch command (\`am start -a
576
+ android.intent.action.VIEW -d '<devClientUrl>'
577
+ --ez EXDevMenuDisableAutoLaunch true\`).
578
+ ON A PHONE NONE OF THAT PREAPPROVAL APPLIES. The
579
+ preapproval and that write both go
580
+ through \`simctl spawn defaults write\`, and devicectl has
581
+ no defaults command; the one file route,
582
+ \`devicectl device copy to --domain-type appDataContainer\`
583
+ onto Library/Preferences/<bundleId>.plist with the app
584
+ terminated, copies successfully and then loses the seeded
585
+ keys, because cfprefsd serves its cached domain and rewrites
586
+ the file. THE FLAG ALONE DOES NOT COVER A PHONE:
587
+ EXDevMenuShowsAtLaunch defaults to TRUE on iOS
588
+ (DevMenuPreferences.setup), and DevMenuManager arms its
589
+ auto-launch observer when \`showsAtLaunch ||
590
+ shouldShowOnboarding()\`, so finishing onboarding clears
591
+ only the second half. THE LAUNCH ARGUMENTS COVER THE REST.
592
+ The device launch ends in
593
+ \`<bundleId> -- -EXDevMenuShowsAtLaunch 0
594
+ -EXDevMenuShowFloatingActionButton 0\`: devicectl passes
595
+ everything after \`--\` to the app, and NSUserDefaults reads
596
+ the argument domain AHEAD of the persisted one, so the menu
597
+ and its floating button are off for that launch and nothing
598
+ is written to the phone. So a fresh install comes up on the
599
+ app, not on the menu, and with no floating button.
600
+ THE FAB IS REAL ON A PHONE, and a screenshot is the only
601
+ way to see it: about four seconds after launch a blue gear
602
+ labelled Tools appears top-right over the app, the label
603
+ fades after roughly ten seconds, and the gear stays as a
604
+ translucent grey circle for the life of the app. It carries
605
+ no accessibility label after the fade, so
606
+ \`agent-device snapshot -i\` stops listing it. Measured
607
+ with the argument on: the corner is clean at 4s and at 12s.
608
+ Stim's own launch is the only one that
609
+ carries these: an app started ANOTHER way -- a home-screen
610
+ tap, a relaunch without the arguments -- still gets the
611
+ stored value, and on a fresh install that is the menu
612
+ (runtime version, Close, Reload, Go home) and the button.
613
+ \`agent-device press 'label="Close"'\` dismisses it -- or
614
+ \`snapshot -i\` and the ref. The onboarding key the flag
615
+ writes and the Local Network grant both survive an
616
+ UPGRADE install. Android's intent extra prevents the menu's
617
+ automatic launch; versions with expo/expo#49651 also honor
618
+ the session-only FAB flag in Stim's deep link.
619
+ The phone's unverified remedy is also ROUTED, not a fixed
620
+ list. When this launch's device records carry the Local
621
+ Network path reason, the remedy leads with that evidence and
622
+ with \`agent-device alert get\`, \`alert accept\`, then
623
+ \`snapshot -i\` and \`press 'label="Reload"'\` -- the grant
624
+ alone does not reload the dev client. Otherwise the network
625
+ list stays. Routing changes no record's level, so nothing new
626
+ reaches \`logs --errors\`. The OTHER first-launch tap,
627
+ developer trust, has no API at all and is always the user's.
628
+ \`guide errors unverified\` has the signature and the
629
+ full commands.`
630
+ },
631
+ stats: {
632
+ summary: "the stats payload, what counts as a run, hit, miss and failed, timeSavedMs, the heartbeat estimate",
633
+ body: () => ` stim stats --json
634
+
635
+ { "version": 1,
636
+ "project": { "key": "<path>", "ios": <bucket|null>,
637
+ "android": <bucket|null> } | null,
638
+ "machine": { "ios": <bucket|null>, "android": <bucket|null> } }
639
+
640
+ \`project\` is null outside a project; a platform with no run yet is null.
641
+ A bucket carries runs, failed, hits, misses, coldRuns, coldRunMs, hitRuns,
642
+ hitRunMs, timeSavedMs, firstRunAt and lastRunAt, plus lastColdBuildMs and
643
+ lastPodsMs once the project has compiled or installed pods. Milliseconds are
644
+ integers.
645
+
646
+ HOW A RUN IS COUNTED (\`stats\`)
647
+ Every \`ios\` or \`android\` invocation that got as far as computing a
648
+ cache key is one run, in this project's bucket and in the machine-wide one.
649
+ The project key is the app's path IN THE SOURCE CHECKOUT, so every
650
+ worktree of a repository pools into one bucket and two apps in a monorepo
651
+ do not. A run that ends through an error or an uncaught exception counts
652
+ only as \`failed\`; \`launched: "unverified"\` or \`"bundling"\` is a
653
+ success. Otherwise the run's own \`cacheHit\` decides: "local" or "remote"
654
+ is a HIT, false is a MISS -- including a release run on a phone and a swap
655
+ that fell back to a full build. A miss adds its \`durationMs\` to the cold
656
+ runs; a hit adds it to the hit runs and credits \`timeSavedMs\` with this
657
+ project's mean cold run BEFORE it, minus its own duration, floored at zero.
658
+ A hit that WAITED for another workspace's build (\`waitedForBuild\`) counts
659
+ as a hit and is credited nothing: the compile it skipped was paid for in the
660
+ wait, and with no cold run recorded for this project and platform there is
661
+ nothing to compare against, so it credits nothing either. The saved figure
662
+ is therefore an ESTIMATE and is printed as one. Nothing per run is stored;
663
+ the file is $STIM_HOME/stats.json (see \`guide lifecycle builds\`).
664
+
665
+ A run also keeps the duration of its own two long phases in that bucket:
666
+ the build phase of a miss that compiled (lastColdBuildMs) and the last
667
+ \`pod install\` (lastPodsMs). The last value only, not a series. THAT IS
668
+ WHERE THE HEARTBEAT ESTIMATE COMES FROM. A later run reads this project's
669
+ bucket before it compiles, and prints:
670
+
671
+ build still compiling (1m00s of ~3m10s)
672
+ pods still installing (1m30s of ~1m40s)
673
+
674
+ The \`~\` value is THIS PROJECT'S LAST COLD BUILD, or its last
675
+ \`pod install\`, and never a mean: a project's build time drifts with its
676
+ size, so the most recent run is the best single guess. Past the estimate
677
+ the line reads \`(4m00s, usually ~3m10s)\`, because a slower machine is not
678
+ a hang. A project with no record yet gets \`(1m00s)\`, the elapsed alone,
679
+ and a warm run has no long phase to size. That read takes no lock and
680
+ ignores what it cannot read, so nothing about statistics can change a
681
+ run's outcome.`
682
+ }
683
+ }
684
+ },
685
+ metro: {
686
+ summary: "The dev server: `stim start`, the supervisor, and starting your own",
687
+ body: () => `THE DEV SERVER
688
+
689
+ stim start
690
+ stim start --remote # prepare Metro for a remote device
691
+
692
+ Reserves (or reuses) this workspace's Metro port, starts the dev server under a
693
+ detached SUPERVISOR, and waits until it both answers AND verifies as this
694
+ project's before exiting. You get your shell back with a bundler running: no
695
+ backgrounding idiom, no sleep, no poll loop, and no chance of building against
696
+ another worktree's bundler.
697
+
698
+ --json one line of facts on stdout, everything else on stderr:
699
+ { port, supervisorPid, mode, logsDir, alreadyRunning }
700
+ --wait <seconds> how long to wait for server and remote tunnel readiness
701
+ (default 60 for each)
702
+ --remote expose Metro for a remote device
703
+
704
+ Plain \`stim start\` is local and does not create a public tunnel. Remote intent
705
+ comes from \`start --remote\`, \`ios.remote\`, or \`android.remote\`. The
706
+ \`metro.tunnel\` setting selects the provider after remote intent exists.
707
+
708
+ REMOTE DEVICE BACKENDS
709
+ Metro exposure and device selection are separate:
710
+
711
+ stim start --remote prepare public Metro
712
+ stim ios --remote proxy use an agent-device daemon
713
+ stim android --remote eas create an EAS Simulator session
714
+
715
+ The command or the matching ios.remote/android.remote setting selects the
716
+ backend. Environment variables never select the backend.
717
+
718
+ The proxy backend connects to an agent-device daemon on another machine. It
719
+ requires AGENT_DEVICE_DAEMON_BASE_URL and
720
+ AGENT_DEVICE_DAEMON_AUTH_TOKEN. Stim creates no remote session for it.
721
+
722
+ The EAS backend needs eas-cli and an account with EAS Simulator access. An
723
+ EAS session is billable. EAS does not inherit the proxy credentials. Always
724
+ tear the session down: \`stop\`, \`worktree remove\`, and \`gc --delete\`
725
+ can end sessions that Stim proves it owns.
726
+
727
+ IDEMPOTENT
728
+ A healthy dev server on the reserved port is a no-op: \`start\` prints the
729
+ facts with alreadyRunning: true and starts nothing. A foreign process holding
730
+ the reserved port moves the RESERVATION instead, so the project is never
731
+ stranded on a port it can never use.
732
+
733
+ WHAT THE SUPERVISOR IS
734
+ One detached process per workspace. There is no machine-wide daemon, nothing
735
+ to install, and no cross-project state. It hosts the dev server, writes its
736
+ output as NDJSON into the global workspace logs directory
737
+ ($STIM_HOME/workspaces/<project>--<digest>/logs; see \`guide logs\`), and
738
+ records itself in that workspace's state.json before it starts serving. Two modes,
739
+ chosen by ecosystem detection:
740
+
741
+ bare-inproc bare React Native: Metro is hosted INSIDE the supervisor,
742
+ from the project's own node_modules, with Stim's reporter
743
+ attached. Bundler events, in-app console logs and redboxes
744
+ all arrive structured.
745
+ expo-child Expo: the project's own \`expo start --port <port>\` runs as
746
+ a child and its stdout is parsed into records. Levels are
747
+ INFERRED from each line, so those records carry raw: true.
748
+
749
+ In expo-child mode, remote intent plus metro.tunnel "expo" makes \`start\` pass
750
+ \`--tunnel\` and EXPO_UNSTABLE_TUNNEL_V2=1 (the legacy ws-tunnel path is
751
+ locked to port 8081, which every reserved port but the first collides with)
752
+ and records the URL Expo reports under state.json's metroTunnel. This has to
753
+ happen here: \`ios --remote <proxy|eas>\` / \`android --remote <proxy|eas>\`
754
+ cannot add \`--tunnel\` to
755
+ an already-running dev server. A later \`start --remote\` refuses with a
756
+ stop-and-restart remedy when a healthy local Expo supervisor has no recorded
757
+ Expo tunnel. See \`guide settings\` for metro.tunnel.
758
+
759
+ \`stim status\` reports the pid, the mode, and whether it is answering.
760
+ \`stim stop\` is the inverse of \`start\`: it halts the supervisor, reaps
761
+ the device-log collectors, shuts the owned device down (never deletes it)
762
+ and frees the port.
763
+
764
+ ENVIRONMENT: the supervisor -- and through it the dev server, including a
765
+ metro.config.js evaluated inside the expo child -- inherits the environment
766
+ of the \`start\` call that SPAWNED it. A later \`start\` that finds a healthy
767
+ supervisor is a no-op and cannot change a running supervisor's env: to apply
768
+ a new env var, \`stop\` first, then \`start\` with it set.
769
+
770
+ The supervisor's own stdio goes to the global workspace logs/supervisor.log, which is NOT
771
+ part of the NDJSON timeline. It is what a supervisor that died before it
772
+ could write a structured record leaves behind. In expo-child mode the child's
773
+ output is parsed into the TIMELINE instead, so a dev server that dies on a
774
+ config error leaves supervisor.log empty and its death cry in metro.ndjson.
775
+ A failed \`start\` quotes both for you: the supervisor.log tail when it has
776
+ one, and this attempt's error records from the timeline.
777
+
778
+ STARTING YOUR OWN BUNDLER STILL WORKS
779
+ A dev server YOU started is detected and left alone: \`start\` reports it
780
+ with supervisorPid: null and mode: null, exits 0, and starts nothing over it.
781
+ Starting a second bundler on a working one is the actual failure. For
782
+ \`start --remote\`, an external Expo server also needs metro.publicUrl because
783
+ Stim cannot add Expo tunnel mode to a process it does not supervise.
784
+
785
+ Start it from INSIDE the project directory, on the reserved port, or nothing
786
+ can attribute it to you:
787
+
788
+ Expo npx expo start --port <port>
789
+ Bare React Native npx react-native start --port <port>
790
+ Has its own start script run it and append --port <port>; it may carry
791
+ flags that matter (e.g. --client-logs)
792
+ Monorepo run from the APP directory, not the repo root
793
+
794
+ The reserved port comes from \`stim status\` or from a previous
795
+ \`start --json\`. Then \`stim ios\` accepts it: its Metro gate checks that
796
+ the process on the port answers /status AND runs from inside this project,
797
+ and yours does.
798
+
799
+ The cost is logs. Stim captures only a dev server it hosted, so
800
+ \`stim logs\` stays empty -- which is indistinguishable from a clean run --
801
+ and finding output is back to redirecting it to a file yourself. Prefer
802
+ \`start\`.
803
+
804
+ \`stop\` leaves an externally started server running. Stop it with the tool
805
+ that started it.`
806
+ },
807
+ logs: {
808
+ summary: "Querying the merged NDJSON timeline, and what --errors means",
809
+ body: () => `LOGS
810
+
811
+ stim logs [filters]
812
+
813
+ Reads every *.ndjson file in the global workspace logs directory, merges them into one timeline
814
+ ordered by timestamp, prints what matches, and EXITS. The file set is
815
+ discovered, not enumerated.
816
+
817
+ EXIT 0 MEANS THE QUERY SUCCEEDED, whether or not records matched. A clean
818
+ \`stim logs --errors\` check requires exit code 0 AND no matching errors in
819
+ captured logs. An empty result does not prove launch or log capture succeeded;
820
+ a workspace with no log directory also returns an empty result.
821
+
822
+ For zero matches: STDOUT IS EMPTY, exit code 0, and
823
+ one dim note on STDERR reading \`No matching log records in <logs dir>\`
824
+ (human mode only -- \`--json\` prints nothing at all, on either stream).
825
+ The only exit-1 paths are a malformed query and no project.
826
+
827
+ FLAGS
828
+ --source <s...> metro, client, device, build (one or more), or all. An
829
+ unknown value is REJECTED rather than quietly matching
830
+ nothing.
831
+ --level <l> minimum level: debug, info, warn, error, fatal
832
+ --since <d> only records newer than this: 30s, 5m, 2h
833
+ --grep <re> only records whose msg matches this regular expression
834
+ --tail <n> only the last n MATCHING records (applied after filtering,
835
+ so --level error --tail 5 is the last five ERRORS)
836
+ --errors errors and fatals since the last marker, from metro, client
837
+ and build, plus confirmed native app-crash reports.
838
+ Capped at 20 printed records.
839
+ --follow keep streaming until interrupted (Ctrl+C is exit 0)
840
+ --json the raw records, one per line, so stdout is valid NDJSON.
841
+ ZERO matches is ZERO bytes on stdout (an empty NDJSON
842
+ stream), exit 0 -- parse stdout line by line, never as one
843
+ JSON document. The "No matching log records" note is human
844
+ mode only, on stderr.
845
+
846
+ --ERRORS, PRECISELY
847
+ Level error or fatal, from metro, client and build, plus device records with
848
+ event native_crash (app/device/time-correlated OS reports or fatal app console output), timestamped after the
849
+ marker that closes their window. Three rules, and a field test
850
+ caught all three wrong at once -- it returned 3,004 iOS syslog lines on a
851
+ healthy app while hiding a real startup crash.
852
+
853
+ SCOPE. General device logs are NOT in the default scope. A device log is the OS talking:
854
+ \`simctl log stream\` is predicated on the app's PROCESS, and inside that
855
+ process Apple's frameworks log thousands of Error-typed lines (nw_socket,
856
+ SecTrust, WebKit, CoreUI) that have nothing to do with your app. The proven
857
+ ones are demoted to info by the collector; the scope rule covers the rest.
858
+ The metro stream carries exactly one demotion of its own, and it is Stim's
859
+ doing: the dev-client deep link \`ios\`/\`android\` open to wire the app to
860
+ your port arrives inside the app as a link, and React Navigation logs at
861
+ error that no navigator handled a NAVIGATE to \`expo-development-client\`.
862
+ There is no such screen and there is not meant to be, so that one record is
863
+ recorded at info -- it made every healthy cold launch report 1 error. A real
864
+ unhandled NAVIGATE names a route your app has, and is still an error.
865
+ A native crash can happen before JS or Metro exists. Confirmed native_crash
866
+ records are included without admitting the rest of the device noise. Opt back in with
867
+ \`--source device\` or \`--source all\`; a plain \`logs\` with no --errors
868
+ has always shown everything. ON A PHYSICAL IPHONE opting back in buys less
869
+ than it does on a simulator: the device console carries no severity, so
870
+ \`--source device --errors\` there reports crash and refusal lines only,
871
+ never a level. Read a phone's device records with a plain \`logs
872
+ --source device\`.
873
+
874
+ THE WINDOW. A marker closes the window for the sources it can speak for:
875
+ a BUNDLE marker (src metro: bundle_build_done / bundle_build_failed, or
876
+ Expo's "Bundled" / "Bundling failed" lines) is written when a bundle
877
+ attempt FINISHES, success or failure. It resets METRO errors from
878
+ before the attempt -- a resolve failure you fixed and rebuilt is
879
+ history, and when bundles fail back to back only the newest attempt's
880
+ errors are reported -- and nothing else. A failed attempt's own summary
881
+ and details land at or after its marker, so they stay reported.
882
+ a LAUNCH marker (src build, written before \`ios\` / \`android\` attempts
883
+ launch) resets EVERYTHING. It precedes the tool call so an immediate
884
+ native crash is not hidden by a marker written after the process died.
885
+ A finished bundle is not evidence that the app which loaded it is fine.
886
+ In the field case the app threw at 16:03:54 and Metro wrote its marker at
887
+ 16:03:55, one second later, because the bundler finishes accounting for a
888
+ build after the client has already evaluated it. Under one marker for all
889
+ sources that crash was reported as nothing at all. The cost of the rule is
890
+ the safe direction: a client redbox that Fast Refresh already fixed keeps
891
+ being reported until the next launch.
892
+
893
+ OUTPUT. Every logs command, including --errors and --follow, shows full captured
894
+ error, component and native stacks, with no frame or message-length limit.
895
+ --errors shows the first 20 matching error records, plus stack context.
896
+ This record-count limit never truncates a stack. A footer reports hidden
897
+ records; use its printed --tail value to include all records before grouping.
898
+ To read the complete timeline without the default error-record limit:
899
+ stim logs --source all
900
+ This includes all sources, levels and history, not just the latest errors;
901
+ add --since, --level or --grep to narrow it. --source all selects sources,
902
+ not stack depth. For untouched captured records use:
903
+ stim logs --source all --json
904
+ Neither form can restore text the runtime truncated before capture.
905
+ In non-follow human output, an Expo error includes its immediately
906
+ following code frame and Call Stack lines. Bare React Native symbolication is
907
+ shown as separate context because Metro does not provide an error correlation
908
+ identifier. Context does not change the error count or the raw error records
909
+ returned by --json. --json is never capped, and neither is an explicit --tail.
910
+
911
+ In --follow mode the marker window is dropped -- every error arriving from
912
+ then on is by definition after the last marker seen.
913
+
914
+ \`stim status\` reports the same count per workspace, as
915
+ logs.errorsSinceMarker: the same query, the same scope, so the two can never
916
+ disagree about whether this workspace is failing.
917
+
918
+ THE RECORD
919
+ { ts, src, level, msg } always. ts is epoch milliseconds; src is one of
920
+ metro / client / device / build; level is one of the five above.
921
+ Optional fields:
922
+ event the producer's own event name (bundle_build_done, client_log, ...)
923
+ stack frames of { file, line, column, fn }, passed through as reported
924
+ marker true on the records that close an error window
925
+ deviceTs Android logcat's original epoch milliseconds; ts is aligned to
926
+ host time using a bounded clock query at each collector attachment
927
+ clockOffsetMs the offset added to deviceTs; absent if the query failed.
928
+ A collector_clock warning then says timestamps retain device time.
929
+ raw true when the level was inferred from a line of text rather than
930
+ reported by the producer (every expo-child record)
931
+
932
+ WHAT WRITES WHAT
933
+ metro.ndjson the bundler, in both supervisor modes
934
+ client.ndjson in-app console logs and redboxes -- BARE PROJECTS ONLY.
935
+ In expo-child mode everything Expo prints lands in
936
+ metro.ndjson with raw: true, so \`--source client\`
937
+ returns nothing there.
938
+ device.ndjson the device-log collector uses \`simctl log stream\`
939
+ predicated on the app, or \`adb logcat\` filtered to
940
+ the app's pid. Local iOS simulator capture starts
941
+ before launch; Android attaches once the pid is known
942
+ and reads buffered logcat records. This
943
+ is where a native crash that never reached JS shows up
944
+ -- and, on iOS, where every Apple framework running in
945
+ the app's process also logs. The proven noise sources
946
+ are recorded at info rather than error; the rest is why
947
+ --errors leaves this source out unless asked. A VERIFIED
948
+ LAUNCH counts these records and prints one line:
949
+
950
+ launch 9 general device error-level records
951
+ (not confirmed app errors); inspect with
952
+ stim logs --errors --source device
953
+
954
+ The count does not attribute unknown OS errors to the app.
955
+ Known JavaScript errors also print individually: Android
956
+ ReactNativeJS records, and iOS com.facebook.react.log /
957
+ javascript records. These can interrupt an optional
958
+ readiness wait even without a client or Metro copy.
959
+ Client and Metro errors still print individually.
960
+ A native app that loaded its bundle but reported app errors
961
+ ends with WARNING rather than OK. This does not change the
962
+ launch JSON or exit code: launched describes bundle/process
963
+ evidence, not a healthy UI. Inspect the errors and readiness.
964
+
965
+ Only human ios/android launch output previews up to
966
+ ten frames per error, component or native stack. App-source
967
+ frames take priority; selected frames retain captured
968
+ order, with an omitted-frame count. Escaped
969
+ component stacks print one frame per line. Long bundle
970
+ URLs are shortened and labeled unsymbolicated; shortening
971
+ is not source-map resolution. A Metro error body that an
972
+ Android DebugServerException embeds prints as the
973
+ error's message and import stack, not its JSON. All
974
+ logs commands show full
975
+ captured stacks, including \`stim logs --errors\`.
976
+ Use \`stim logs --source all\` for all sources/history,
977
+ or add \`--json\` for raw
978
+ records. This preview does not alter logs or JSON.
979
+
980
+ Symbolication is best effort. Human launch and non-follow
981
+ logs queries ask the verified workspace Metro's /symbolicate
982
+ endpoint to resolve captured JS coordinates, with a 2s
983
+ request deadline and raw-coordinate fallback. Successful
984
+ launch context is retained separately under logs/error-context
985
+ so stopping Metro does not discard resolved evidence.
986
+ Expo code frames are attached; uncorrelated bare Metro
987
+ symbolication events remain separate. A component stack
988
+ never substitutes for a missing error stack. Historical
989
+ stacks need matching sources/maps, not a rebuilt bundle.
990
+
991
+ Cross-source copies combine only with matching error
992
+ title, stack location, compatible platform and timing.
993
+ Same-source repeats and raw JSON records stay intact.
994
+ Human queries may attach a correlated device component
995
+ stack to a selected Metro error, but never an unrelated
996
+ device error.
997
+
998
+ iOS simulator cold launch attaches app stdout/stderr and
999
+ passes Expo's initial URL directly. Fatal output is available
1000
+ before delayed OS reports, which can take about a minute
1001
+ or longer to appear; rerun logs --errors for those
1002
+ before stopping or releasing the device. Collection requires
1003
+ this workspace's current launch and device ownership or lease;
1004
+ afterward only already-captured reports remain available.
1005
+ The app cache holds console files, so external STIM_HOME
1006
+ paths do not violate the app sandbox. Crash evidence is
1007
+ retained in workspace logs. --follow does not poll for
1008
+ delayed OS reports.
1009
+ Native iOS simulator reports match app ID, simulator ID
1010
+ and launch time. atos resolves app addresses only after
1011
+ the local binary UUID matches the report. Android reads
1012
+ the crash buffer even when the app PID has exited. Java
1013
+ traces remain readable; C/C++ resolution uses NDK tools
1014
+ and matching local ELF build IDs. General OS noise remains
1015
+ excluded from --errors. Full native evidence is in
1016
+ logs --source device --json. Physical iPhone capture stays
1017
+ console-only; missing reports or symbols are not proof
1018
+ of a healthy app. No symbol downloads are performed.
1019
+ Android may keep a crashed Java PID alive behind its
1020
+ crash dialog. Follow the app-scoped force-stop remedy
1021
+ printed by android after fixing the crash, then rerun
1022
+ stim android. Metro reload alone cannot recover it.
1023
+
1024
+ The connection refusal \`TCP Conn ... Failed :
1025
+ error 0:61 [61]\` (61 is ECONNREFUSED) is not even
1026
+ counted. The app got its bundle over this workspace's
1027
+ Metro and outlived the stability window, so it
1028
+ recovered. A refusal before the
1029
+ launch verifies still prints, as does every record on a
1030
+ launch that does not verify, and the record stays an
1031
+ error in device.ndjson either way; read it with
1032
+ \`logs --errors --source device\`.
1033
+
1034
+ ON A PHYSICAL IPHONE THE SAME FILE CARRIES LESS, and the difference is not
1035
+ cosmetic. \`simctl spawn\` is simulator-only and there is no devicectl
1036
+ console subcommand, so a device run reads
1037
+ \`devicectl device process launch --console\`, which connects the app's own
1038
+ stdout and stderr and nothing else. Stim launches it with
1039
+ OS_ACTIVITY_DT_MODE, which makes os_log mirror itself onto that stderr --
1040
+ without it React Native's own logging, which goes through os_log, would not
1041
+ appear at all. What the mirror carries, and what it drops:
1042
+
1043
+ ts KEPT the device's own timestamp, off the mirrored line
1044
+ proc KEPT as name(pid), from the mirrored line, not a path
1045
+ category KEPT only when the logger has a subsystem; \`javascript\` and
1046
+ \`native\` for React Native's own log calls
1047
+ msg KEPT a multi-line message arrives as separate records
1048
+ subsystem LOST the mirror never prints it
1049
+ level LOST Default, Error and Fault all render identically, and
1050
+ Debug is not mirrored at all
1051
+
1052
+ So every device record from a phone is \`raw: true\` and \`info\`, except
1053
+ the lines that OPEN with a marker the runtime itself prints: an uncaught
1054
+ ObjC exception, a libc++abi termination, an assertion failure, or a Swift
1055
+ fatal error. The match is anchored, so an app logging ABOUT a crash stays
1056
+ info. devicectl's own \`ERROR:\` is read only on a line with no mirror
1057
+ prefix, because that is the only kind devicectl writes. Severity cannot be
1058
+ recovered, so it is not guessed. The NOISE_RULES that demote Apple's framework chatter key on
1059
+ subsystem and cannot fire either -- but they have less to do, because
1060
+ --console carries only the app's streams rather than every framework
1061
+ logging inside its process.
1062
+
1063
+ \`log collect --device-udid\` WOULD carry all six fields, in the same NDJSON
1064
+ the simulator path parses. It is not used because it requires root
1065
+ (\`log: Must be root to collect logs from attached device\`) and produces an
1066
+ archive rather than a stream. Streaming with full fidelity needs
1067
+ libimobiledevice or pymobiledevice3, which are third-party installs Stim
1068
+ does not require. See appandflow/stim#179.
1069
+ build-ios.ndjson the xcodebuild / gradle transcript at level debug, the
1070
+ build-android.ndjson extracted diagnostics at level error, and the launch as
1071
+ a marker record. One RUN's worth: each build starts the
1072
+ file over, so the first error in it always belongs to
1073
+ the run that pointed you at it.
1074
+
1075
+ Only a dev server Stim hosted is captured. If you started the bundler
1076
+ yourself, the metro and client sources stay empty -- which is not a sign of a
1077
+ clean build. The device and build sources are written either way, because
1078
+ \`ios\` / \`android\` produce them.
1079
+
1080
+ A collector is killed and replaced on the next \`ios\` / \`android\` run for
1081
+ that platform, and reaped by \`stop\`.`
1082
+ },
1083
+ errors: {
1084
+ summary: "Every refusal Stim can print: an index of codes, and one section for each",
1085
+ sectionHint: "<CODE>",
1086
+ preamble: () => `WHAT STIM REFUSES, AND WHY
1087
+
1088
+ Every refusal from \`ios\` / \`android\` carries a stable CODE. Branch on the
1089
+ code, never on the message.`,
1090
+ sections: {
1091
+ STIM_WORKSPACE_STATE: {
1092
+ summary: "$STIM_HOME/workspaces could not be prepared, or the digest directory belongs to another project",
1093
+ aliases: ["STIM_WORKSPACE_COLLISION"],
1094
+ separator: "--- BUILD-PATH CODES (`stim ios` / `stim android`) ---",
1095
+ body: () => `STIM_WORKSPACE_STATE / STIM_WORKSPACE_COLLISION
1096
+ Stim could not prepare this project's global workspace directory under
1097
+ $STIM_HOME/workspaces. Check that STIM_HOME is writable and has free
1098
+ space. An EPERM on a directory the user CAN write is a sandbox, not a
1099
+ permission bit -- see \`stim guide errors sandbox\`. COLLISION means the
1100
+ readable-name-plus-digest directory already has a workspace.json for a
1101
+ different canonical project path; do not overwrite it until you identify
1102
+ which workspace owns it.`
1103
+ },
1104
+ STIM_NO_METRO: {
1105
+ summary: "nothing provably this workspace's dev server holds the reserved port (ios, android, reload)",
1106
+ body: () => `STIM_NO_METRO
1107
+ Nothing that could be proven to be THIS workspace's dev server holds the
1108
+ reserved port -- or no port is reserved at all. The gate fires in about a
1109
+ second, before the device is even booted, rather than after four minutes of
1110
+ compiling an app that could not load a bundle. Run \`stim start\` first.
1111
+ \`--no-metro-check\` overrides it and wires the app to the reservation (or to
1112
+ 8081 when there is none). A non-Debug \`ios --configuration\` never emits
1113
+ this: a release-shaped build embeds its JS, so the gate does not run at all.
1114
+ A port held by SOMETHING ELSE reports what: usually a bundler started from
1115
+ the wrong directory (the repo root instead of the app dir in a monorepo), or
1116
+ another repo's Metro. Restart it from inside the project, or free the port
1117
+ and run \`stim start\` to get a fresh reservation.
1118
+
1119
+ Reload requires the recorded launch's port to be this workspace's live
1120
+ Metro. It refuses a missing, changed, unresponsive, or foreign port.`
1121
+ },
1122
+ STIM_NO_FINGERPRINT: {
1123
+ summary: "@expo/fingerprint produced no hash, so the shared cache cannot be addressed",
1124
+ body: () => `STIM_NO_FINGERPRINT
1125
+ \`@expo/fingerprint\` produced no hash, so the shared build cache cannot be
1126
+ addressed. Stim uses its declared @expo/fingerprint dependency directly,
1127
+ independently of the target project's package graph. This is a refusal
1128
+ rather than a silent full build because an unaddressable cache means every
1129
+ workspace on the commit compiles from scratch, forever.`
1130
+ },
1131
+ STIM_PREBUILD_FAILED: {
1132
+ summary: "expo prebuild could not generate the native directory",
1133
+ body: () => `STIM_PREBUILD_FAILED
1134
+ \`expo prebuild\` could not generate the missing native directory. The
1135
+ extracted output is above the code; the transcript is in
1136
+ the global workspace logs/build-<platform>.ndjson file.`
1137
+ },
1138
+ STIM_DEPS_FAILED: {
1139
+ summary: "pod install or gradle sync failed; the bundler ladder and BUNDLE_FROZEN",
1140
+ body: () => `STIM_DEPS_FAILED
1141
+ \`pod install\` (iOS) or the gradle dependency sync (Android) failed. On iOS
1142
+ this runs only when Podfile.lock and Pods/Manifest.lock disagree, or Pods is
1143
+ absent -- which is exactly what a carried worktree produces.
1144
+ WHICH POD COMMAND: when the project root has a Gemfile and a Gemfile.lock
1145
+ that resolves cocoapods, pods go through bundler -- \`bundle check --dry-run\`,
1146
+ then \`bundle install\` only when that reports missing gems, then \`bundle exec
1147
+ pod install\` -- so the CocoaPods the lockfile pins is the one that writes
1148
+ Podfile.lock. Everything else gets plain \`pod install\`: no Gemfile, a Gemfile
1149
+ with no Gemfile.lock (\`bundle install\` would CREATE that tracked file in
1150
+ your checkout, which Stim will not do), and a Gemfile.lock that
1151
+ pins something other than pods, such as a fastlane-only bundle. When
1152
+ \`bundle\` is not on PATH the run prints one dim \`pods\` note and uses plain
1153
+ \`pod install\`. The \`pods\` phase line always names the command that ran, and
1154
+ the gem steps heartbeat under the \`gems\` label.
1155
+ Bundler runs with BUNDLE_FROZEN, so a Gemfile that no longer matches its
1156
+ Gemfile.lock FAILS the build rather than quietly falling back to unpinned
1157
+ pods -- silently using a different CocoaPods than the lockfile pins is the
1158
+ bug this path exists to kill. Run \`bundle install\` yourself and keep the
1159
+ result. Gems themselves are installed wherever BUNDLE_PATH points -- the
1160
+ project's own \`.bundle/config\` (vendor/bundle in the React Native template),
1161
+ or the environment. When that lands inside the project, Stim says so in a dim
1162
+ note naming which of the two set it; Gemfile.lock is never edited either way.
1163
+ \`worktree warm --refresh\` reports the same code for the install it runs in
1164
+ the SOURCE CHECKOUT -- the lockfile's own command (\`pnpm install\`,
1165
+ \`yarn install\`, \`bun install\`, \`npm ci\`) or that same pod ladder. The
1166
+ message names the command, quotes its last lines, and nothing is copied: fix
1167
+ the source checkout, then warm again.`
1168
+ },
1169
+ STIM_BUILD_FAILED: {
1170
+ summary: "xcodebuild or gradle failed; the two Android APK refusals; a damaged compilation-cache object",
1171
+ body: () => `STIM_BUILD_FAILED
1172
+ xcodebuild or gradle failed. The EXTRACTED diagnostics are printed (capped),
1173
+ not the transcript. Read the log path on the next line for the rest.
1174
+ Two Android refusals share this code without gradle itself failing:
1175
+ - MORE THAN ONE debug APK under android/app/build/outputs/apk and nothing
1176
+ configured to pick one (a project with product flavors, several flavors
1177
+ already built). Stim will not guess which flavor to install: the
1178
+ refusal lists the candidates -- pass \`--variant <name>\` or set the
1179
+ android.variant setting (e.g. "productionDebug") to the one you want.
1180
+ Flavors declared plainly in android/app/build.gradle are caught before
1181
+ the build instead (STIM_BAD_ARG); this one remains for the declarations
1182
+ that parse cannot read.
1183
+ - NO APK for the configured variant: the android.variant / --variant value
1184
+ does not name a real variant (\`./gradlew :app:tasks\` in android/ lists
1185
+ the assemble tasks).
1186
+ AN APK OLDER THAN THE BUILD IS NOT A REFUSAL. \`assembleDebug\` packages every
1187
+ flavor, so a later \`--variant previewDebug\` finds a current APK that gradle
1188
+ reports UP-TO-DATE and repackages nothing. Stim installs it. Gradle owns task
1189
+ freshness and the fingerprint owns cache freshness; Stim does not second-guess
1190
+ either from the file's mtime.
1191
+
1192
+ "failed to scan dependencies for source ..." on pods you did not touch (ios)
1193
+ The compilation cache holds a damaged object. Xcode reports it per source
1194
+ file, so it names whichever targets reach the object first -- often pods such
1195
+ as sqlite3, nanopb or libwebp -- and the list changes between runs. The
1196
+ transcript carries the cause:
1197
+ error: CAS-based dependency scan failed: not a IncludeTreeRoot node kind
1198
+ A cache write that a full disk or a killed build cut short leaves such an
1199
+ object, and upgrading the CLI does not clear it. Empty that one cache with
1200
+ \`gc --delete --cache "compilation cache"\`, then build again. The next
1201
+ build is a cold one.`
1202
+ },
1203
+ fallbacks: {
1204
+ summary: "release cache-hit notes that are not codes: swap failure, asset gate, uninstall, device fallbacks",
1205
+ body: () => `FALLBACK NOTES THAT ARE NOT CODES (release cache hits)
1206
+ On a release cache hit Stim regenerates this workspace's JS bundle into a
1207
+ COPY of the cached artifact before installing it -- \`ios --configuration
1208
+ Release\` into a copy of the .app, \`android --variant ...Release\` into a
1209
+ copy of the APK. When any step of that swap fails (the bundle command,
1210
+ hermesc, the re-sign, zipalign, apksigner), the run does NOT install the
1211
+ cached artifact -- its baked-in JS is the builder's, not yours -- and does
1212
+ NOT fail: it prints a \`swap failed at <step>: ... --
1213
+ building fresh instead\` note on stderr and falls back to a full build. If
1214
+ the run then fails, the code is the build's own (STIM_BUILD_FAILED etc.);
1215
+ the swap note above it says why the cache hit was not used. A swap that
1216
+ merely finds no hermesc notes it and embeds the plain JS bundle instead --
1217
+ that is a note, not a fallback.
1218
+
1219
+ ANDROID'S ASSET GATE is the second, and it is not a failure at all. Before
1220
+ re-packing, Stim compares this workspace's freshly emitted asset tree
1221
+ against the assets the cached APK carries. Any added, removed or changed
1222
+ asset prints
1223
+
1224
+ swap this workspace's asset set differs from the cached APK's
1225
+ (1 added, 0 changed, 0 removed; e.g. added
1226
+ res/drawable-mdpi/new_logo.png) -- building fresh instead
1227
+
1228
+ and the run does a full gradle build. There is nothing to fix: a drawable
1229
+ has a row in resources.arsc that only AAPT can write, so an APK cannot be
1230
+ made to carry an asset it was not built with, and installing one whose JS
1231
+ references a missing asset would 404 at runtime. Add an image, pay for one
1232
+ full build; the APK it produces becomes the new cache entry.
1233
+
1234
+ THE UNINSTALL NOTE is the third, and it COSTS THE APP'S DATA. A re-packed
1235
+ APK is signed with this machine's debug keystore, so the moment it meets a
1236
+ copy signed by CI the install is refused with
1237
+ INSTALL_FAILED_UPDATE_INCOMPATIBLE (or INSTALL_FAILED_VERSION_DOWNGRADE) and
1238
+ nothing but removing the package resolves it. A RELEASE run therefore
1239
+ uninstalls the package once, retries the install once, and prints
1240
+
1241
+ install com.example.app was already installed with a different signer,
1242
+ so it was uninstalled (its data went with it) before this APK
1243
+ could be installed
1244
+
1245
+ Debug runs never do this. A debug run meets the conflict on a physical
1246
+ device that already carries a store build, and there the colliding package is
1247
+ the user's real app: losing its data to a silent uninstall would be a worse
1248
+ bug than the one it fixes. A debug run fails with STIM_INSTALL_FAILED and
1249
+ hands you the uninstall to run yourself.
1250
+
1251
+ ON A PHONE the same uninstall costs one thing more: iOS drops the Settings >
1252
+ General > VPN & Device Management trust entry when the last app from that
1253
+ developer goes, and it clears the app's Local Network permission with it. The
1254
+ note says so, because the reinstall succeeds and then the LAUNCH is refused
1255
+ until someone taps Trust again. The retry is one uninstall and one install --
1256
+ it is not a way around a tap that has no API.
1257
+
1258
+ THE DEVICE FALLBACKS are the fourth, and both print a \`cache\` note and
1259
+ build fresh rather than failing:
1260
+
1261
+ cache a cached Release device app carries its builder's JS, and the
1262
+ device JS swap lands with phase 6 of appandflow/stim#178 --
1263
+ building fresh instead, which bakes in this workspace's JS
1264
+
1265
+ and a signing-gate refusal on a CACHED artifact -- an expired or foreign
1266
+ profile, a phone the profile does not name, an identity this keychain does not
1267
+ hold -- which prints the gate's own reason with \`-- building fresh instead\`.
1268
+ The same refusal on a FRESHLY BUILT app is a code (STIM_NO_PROFILE,
1269
+ STIM_PROFILE_MISMATCH, STIM_NO_SIGNING_IDENTITY), not a note: building again
1270
+ would produce the same app and refuse again.`
1271
+ },
1272
+ STIM_BUILD_WAIT_TIMEOUT: {
1273
+ summary: "waited ~90 minutes for another workspace's build of the same fingerprint",
1274
+ body: () => `STIM_BUILD_WAIT_TIMEOUT
1275
+ This run was waiting for ANOTHER workspace's build of the same fingerprint
1276
+ (see \`guide lifecycle concurrency\`), and no artifact arrived within ~90
1277
+ minutes.
1278
+ Replacement builders share that deadline, including time spent acquiring
1279
+ the lock between waits. A live builder may be wedged, or successive builders
1280
+ may have failed. The message names the current pid and lock directory:
1281
+ check the pid, and if it is not really building, remove that directory and
1282
+ run the command again.`
1283
+ },
1284
+ STIM_CLAIM_REFUSED: {
1285
+ summary: "a build lock exists whose holder cannot be identified; Stim neither removes it nor waits on it",
1286
+ body: () => `STIM_CLAIM_REFUSED
1287
+ A build lock records the holder's process IDENTITY, not just its pid, so a
1288
+ recycled pid reads as a gone builder rather than a live one, and a builder
1289
+ busy in a long \`simctl\` or gradle call reads as live rather than as stale.
1290
+ This code is the one state that cannot be decided: the claim file is truncated
1291
+ or not JSON, its identity token does not decode, or the holder spawned the
1292
+ process doing the work and was killed before recording which one.
1293
+ Stim will not remove a claim it cannot prove is dead, and it will not wait on
1294
+ one either -- a silent wait on a lock nobody holds is what this replaces. The
1295
+ message names the claim and the exact, shell-quoted removal that clears it --
1296
+ just that claim's file, not the lock directory around it; run that, then
1297
+ run the command again. Nothing was built, installed or removed.
1298
+ \`worktree warm\` reports it for the repository-wide warm claim under
1299
+ ~/.stim/warm-locks on both paths, including a \`--refresh\` that spawned its
1300
+ install and was killed before recording which process: nothing was refreshed
1301
+ and nothing was copied. One case is NOT this code for a plain warm: a
1302
+ STIM_HOME (or a warm-locks path under it) that is a file rather than a
1303
+ directory. No claim can be stored there at all, which is a filesystem state
1304
+ that predates claims, so plain \`warm\` degrades to the unsynchronised copy it
1305
+ performed before them and \`--refresh\` still refuses.`
1306
+ },
1307
+ STIM_CLAIM_UNAVAILABLE: {
1308
+ summary: "this process has no recordable identity, so no build lock or build slot can be taken at all",
1309
+ body: () => `STIM_CLAIM_UNAVAILABLE
1310
+ Every ownership claim records the holder's process identity, captured through
1311
+ the \`unique-pid\` native module. This code is that capture failing: no
1312
+ prebuilt binary for this platform and architecture, or the OS refusing to
1313
+ report this process's start identity.
1314
+ Stim refuses rather than building without a claim. A run with no claim is
1315
+ invisible to every other run, so the single-flight lock and
1316
+ concurrency.maxBuilds would both be off at once, and two builds could compile
1317
+ the same fingerprint while each believed it was alone. Reinstall Stim so the
1318
+ module for this platform is present, then run the command again. Nothing was
1319
+ built, installed or removed.
1320
+ \`worktree warm --refresh\` refuses for the same reason, because it writes to
1321
+ the source checkout. Plain \`worktree warm\` does NOT: it prints one dim
1322
+ \`lock unavailable (...)\` line and copies unsynchronised. A copy only
1323
+ reads, so running it with no claim is what it did before the lock existed,
1324
+ while refusing it would break a warm that works today -- an unwritable
1325
+ STIM_HOME included.
1326
+ It still reads the claim set first, which takes no claim: a live \`--refresh\`
1327
+ claim, or a claim it cannot resolve, makes even the unsynchronised copy
1328
+ refuse, and the line names what it found. Only a repository nothing is
1329
+ warming is copied without a claim.`
1330
+ },
1331
+ STIM_INSTALL_FAILED: {
1332
+ summary: "simctl, adb, or devicectl refused the artifact; the one signer-conflict retry",
1333
+ body: () => `STIM_INSTALL_FAILED
1334
+ The artifact built or came from cache, but \`simctl install\` / \`adb install\` /
1335
+ \`devicectl device install app\` refused it. A signature or architecture
1336
+ mismatch, or a full device.
1337
+ On a PHONE (\`ios --device\`) the message carries devicectl's own text and the
1338
+ remedy names the cause it recognises: the phone is locked, the host is not
1339
+ trusted, Developer Mode is off, storage is full, or the app already on the
1340
+ phone was signed by a different team. Only that last one is retried -- one
1341
+ \`devicectl device uninstall app\`, one reinstall, and a warning that the
1342
+ app's data went with it -- along with the phone's developer trust and its
1343
+ Local Network permission, which iOS clears on an uninstall, so the launch
1344
+ after it may need the trust tap again. This is gated on \`--device\` rather
1345
+ than on the configuration, because every device run is signed, Debug
1346
+ included.
1347
+ On Android a signature or downgrade conflict names the package that is really
1348
+ installed -- the built APK's applicationId, which on a flavored project is
1349
+ the flavor's id and not the gradle namespace -- and gives you the
1350
+ \`adb -s <serial> uninstall <applicationId>\` that clears it. Re-running after
1351
+ that is a cache hit: one install, no build.`
1352
+ },
1353
+ STIM_LAUNCH_FAILED: {
1354
+ summary: "installed but would not start; the developer-trust tap on a phone",
1355
+ body: () => `STIM_LAUNCH_FAILED
1356
+ Installed, but the app would not start. On Android this usually means no
1357
+ launchable activity resolved.
1358
+ On a PHONE it means the app never appeared in the device's own process list
1359
+ after \`devicectl device process launch\`, and the devicectl lines that
1360
+ explain it are quoted under the message. The refusal a first launch usually
1361
+ hits is the DEVELOPER TRUST one -- SpringBoard reports
1362
+ FBSOpenApplicationErrorDomain 3 with the reason Security -- and its remedy is
1363
+ the only one a human has to perform on the phone: Settings > General >
1364
+ VPN & Device Management, tap the developer profile under DEVELOPER APP, tap
1365
+ Trust, then run the command again. It is a per-developer-certificate tap, not
1366
+ a per-build one -- but an uninstall clears it, including the one Stim's own
1367
+ signer-conflict retry performs.`
1368
+ },
1369
+ STIM_NO_SCHEME: {
1370
+ summary: "Xcode schemes unavailable or no unambiguous app scheme in ios/",
1371
+ body: () => `STIM_NO_SCHEME
1372
+ Stim could not list or select an app scheme in ios/. Share the intended app
1373
+ scheme so xcodebuild can see it. Select an available exact name with
1374
+ \`stim ios --scheme <name>\`. An unknown explicit name prints available choices.
1375
+ Without an explicit selector, a workspace
1376
+ name match wins; otherwise Stim accepts a sole non-test scheme, or a listed
1377
+ scheme matching app.json. Unmatched ambiguous schemes are refused.`
1378
+ },
1379
+ STIM_NO_PROFILE: {
1380
+ summary: "no or undecodable embedded.mobileprovision; build once from Xcode",
1381
+ separator: "--- iOS SIGNING CODES (`ios --device`, and only there) ---",
1382
+ context: `A simulator build needs no signature, which is why none of these can fire on
1383
+ the normal path. A device build carries one, and Stim re-seals any bundle it
1384
+ modifies with the identity the bundle already names -- so it checks, before
1385
+ spending a build or a bundle, that the check can succeed.`,
1386
+ body: () => `STIM_NO_PROFILE
1387
+ The built or cached .app has no embedded.mobileprovision, or
1388
+ \`security cms -D\` could not decode the one it has. The first means the build
1389
+ produced an unsigned app -- almost always a simulator-sliced artifact.
1390
+ Set a team and a Development profile for the target's configuration in
1391
+ Xcode > Signing & Capabilities, then BUILD ONCE FROM XCODE to install the
1392
+ profile. Stim will not do that step: registering a device or minting a
1393
+ profile changes your Apple Developer account, so Stim never passes
1394
+ -allowProvisioningUpdates.`
1395
+ },
1396
+ STIM_PROFILE_MISMATCH: {
1397
+ summary: "the profile is expired, has no ProvisionedDevices, or does not name this UDID",
1398
+ context: `A simulator build needs no signature, which is why none of these can fire on
1399
+ the normal path. A device build carries one, and Stim re-seals any bundle it
1400
+ modifies with the identity the bundle already names -- so it checks, before
1401
+ spending a build or a bundle, that the check can succeed.`,
1402
+ body: () => `STIM_PROFILE_MISMATCH
1403
+ The profile inside the app cannot admit this phone. Three shapes, and the
1404
+ message names which one and the profile type it found:
1405
+ - it expired, or carries no ExpirationDate at all;
1406
+ - it is an App Store or enterprise profile, which carries no
1407
+ ProvisionedDevices list -- so Stim cannot PROVE the phone is admitted and
1408
+ refuses rather than guessing. Local device runs need a development
1409
+ profile;
1410
+ - it is a development or ad hoc profile whose device list does not name
1411
+ this UDID. Register the UDID at developer.apple.com, regenerate the
1412
+ profile, and build once from Xcode.`
1413
+ },
1414
+ STIM_NO_SIGNING_IDENTITY: {
1415
+ summary: "no single keychain identity resolves; ios.signingIdentitySha1 for two certificates",
1416
+ context: `A simulator build needs no signature, which is why none of these can fire on
1417
+ the normal path. A device build carries one, and Stim re-seals any bundle it
1418
+ modifies with the identity the bundle already names -- so it checks, before
1419
+ spending a build or a bundle, that the check can succeed.`,
1420
+ body: () => `STIM_NO_SIGNING_IDENTITY
1421
+ No single keychain identity could be resolved to re-seal with. Either
1422
+ \`security find-identity -v -p codesigning\` lists nothing, or the identity
1423
+ the artifact's own profile names is absent, or two certificates share that
1424
+ common name and Stim -- being non-interactive -- will not pick one.
1425
+ Open Xcode > Settings > Accounts and download your certificates, or unlock
1426
+ the login keychain with \`security unlock-keychain\`. For the two-certificate
1427
+ case, set ios.signingIdentitySha1 to the SHA-1 hash beside the one you want.`
1428
+ },
1429
+ STIM_CODESIGN_FAILED: {
1430
+ summary: "codesign failed on the modified copy; the cache entry is untouched and the run builds fresh",
1431
+ context: `A simulator build needs no signature, which is why none of these can fire on
1432
+ the normal path. A device build carries one, and Stim re-seals any bundle it
1433
+ modifies with the identity the bundle already names -- so it checks, before
1434
+ spending a build or a bundle, that the check can succeed.`,
1435
+ body: () => `STIM_CODESIGN_FAILED
1436
+ \`codesign --force --sign\` or \`codesign --verify --strict\` exited non-zero
1437
+ on the modified copy. The verbatim codesign stderr is quoted, because it is
1438
+ the answer: a locked login keychain reports errSecInternalComponent, an
1439
+ ambiguous identity reports that it matched more than one. Unlock the keychain
1440
+ and confirm exactly one identity matches the name. The cache entry itself is
1441
+ never modified -- the failure is on a temporary copy, and the run builds
1442
+ fresh.`
1443
+ },
1444
+ STIM_NO_LAN_ADDRESS: {
1445
+ summary: "the Mac has no non-internal IPv4 interface; a tunnel cannot help a phone",
1446
+ separator: "--- iOS DEVICE DEBUG REACHABILITY CODES (`ios --device` in Debug) ---",
1447
+ context: `A phone does not share the host's loopback and USB carries no reverse forward,
1448
+ so a Debug run on one is wired to a LAN origin instead of localhost. Both codes
1449
+ fire BEFORE the build, because a refusal that costs a build is a bad refusal.`,
1450
+ body: () => `STIM_NO_LAN_ADDRESS
1451
+ This Mac reports no non-internal IPv4 interface, so there is no address to
1452
+ give the phone: it is offline, or on nothing but utun/awdl/bridge. Join a
1453
+ Wi-Fi or Ethernet network, or connect this Mac by cable, and run again.
1454
+ Deliberately NOT "set metro.publicUrl": neither channel to a phone carries a
1455
+ URL. The dev-client deep link composes http://<host>:<port> itself, and
1456
+ ip.txt is read by RCTBundleURLProvider, which prefixes the scheme. A tunnel
1457
+ cannot be expressed to a phone, so --device ignores metro.publicUrl,
1458
+ metro.tunnel and metro.ngrokUrl and says so when one is set.`
1459
+ },
1460
+ STIM_LAN_METRO_UNREACHABLE: {
1461
+ summary: "the LAN origin did not answer as this workspace's Metro; ios.lanHost on a multi-NIC Mac",
1462
+ context: `A phone does not share the host's loopback and USB carries no reverse forward,
1463
+ so a Debug run on one is wired to a LAN origin instead of localhost. Both codes
1464
+ fire BEFORE the build, because a refusal that costs a build is a bad refusal.`,
1465
+ body: () => `STIM_LAN_METRO_UNREACHABLE
1466
+ The chosen LAN origin did not answer as THIS workspace's Metro: no answer, a
1467
+ 5xx, or a dev server that is not this one -- the message says which.
1468
+ \`stim start\` prints the port it reserved. On a Mac with several interfaces
1469
+ the first en* is not necessarily the one the phone shares: set ios.lanHost to
1470
+ the address it can reach (see \`guide settings\`).
1471
+ What this gate CANNOT prove is that the phone can reach the origin: macOS
1472
+ routes a host connection to its own address over loopback, so the gate passes
1473
+ through a firewall that will block the phone. That evidence only ever arrives
1474
+ from the phone's own bundle request, which is what \`launched\` reports.`
1475
+ },
1476
+ unverified: {
1477
+ summary: "launched: \"unverified\" with the Local Network path reason, and the routed recovery",
1478
+ context: `A phone does not share the host's loopback and USB carries no reverse forward,
1479
+ so a Debug run on one is wired to a LAN origin instead of localhost.`,
1480
+ body: () => `LAUNCH UNVERIFIED, LOCAL NETWORK NOT GRANTED (not a code -- a routed remedy)
1481
+ An app that has not been granted Local Network reaches nothing on the LAN,
1482
+ and CFNetwork reports each attempt as NSURLErrorDomain -1009 "The Internet
1483
+ connection appears to be offline." with the path reason
1484
+
1485
+ _NSURLErrorNWPathKey=unsatisfied (Local network prohibited)
1486
+
1487
+ THAT REASON IS THE WHOLE MATCH. The rest of the block -- POSIX error 50
1488
+ (ENETDOWN), \`failed to connect 1:50\`, \`error code: -1009 [1:50]\` -- is
1489
+ generic and says nothing about the permission: Wi-Fi turned off gives the
1490
+ identical errno with the reason \`unsatisfied (No network route)\`, and a
1491
+ cellular-only route gives \`unsatisfied (Denied over cellular interface)\`.
1492
+ Matching those would print this remedy at a phone that simply is not on the
1493
+ network, and would drop the same-SSID check that is the actual fix, so they
1494
+ are not matched.
1495
+ THE PROMPT AND A PRIOR DENIAL READ THE SAME. iOS emits this reason while the
1496
+ prompt is unanswered and after a Don't Allow, which persists across upgrade
1497
+ installs. The remedy covers both: if the first \`alert get\` finds no alert,
1498
+ it was denied earlier and the only fix is the switch under Settings > Privacy
1499
+ & Security > Local Network, which has no API.
1500
+ The reason is read out of THIS launch's device records -- since the launch,
1501
+ and from the app's pid when it is known. It is NOT origin-scoped: the record
1502
+ that carries the reason carries no URL (the failing URL lands in a
1503
+ continuation line with no process prefix, which the pid filter drops), so
1504
+ scoping to this workspace's Metro origin would never fire. That is sound
1505
+ anyway, because the permission gates every LAN connection the app makes, so
1506
+ even a third-party SDK's prohibited connection proves the app cannot reach
1507
+ this workspace's Metro either. Matching only picks the remedy: no record's
1508
+ level changes, so the device source stays out of \`logs --errors\`
1509
+ (\`guide logs\`: severity is never guessed on a phone).
1510
+ When it matches, \`launched: "unverified"\` leads with that evidence and with
1511
+ the recovery, in this order:
1512
+
1513
+ agent-device alert get --platform ios --udid <udid>
1514
+ agent-device alert accept --platform ios --udid <udid>
1515
+ agent-device snapshot -i --platform ios --udid <udid>
1516
+ agent-device press 'label="Reload"' --platform ios --udid <udid>
1517
+
1518
+ \`alert get\` reads the alert without opening anything, so it works while the
1519
+ app sits behind it. THE GRANT ALONE IS NOT ENOUGH: the dev client does not
1520
+ retry, and stays on "Failed to load app ... The Internet connection appears
1521
+ to be offline." with a Reload button, which is why the last two lines are
1522
+ there. The text form of the press target is \`label="Reload"\` (or
1523
+ \`text="Reload"\`); a bare \`press "Reload"\` is rejected. Stim's own launch
1524
+ ends in \`-- -EXDevMenuShowsAtLaunch 0 -EXDevMenuShowFloatingActionButton 0\`,
1525
+ so the Expo dev menu is not over the app, fresh install or not. An app started
1526
+ ANOTHER way does not carry those arguments and \`snapshot -i\` can show the
1527
+ menu instead: \`agent-device press 'label="Close"'\` dismisses it, then press
1528
+ Reload. See \`guide facts devmenu\`.
1529
+
1530
+ A BARE APP (no expo-dev-client) gets the same first two commands and a
1531
+ different third. The prompt fires the same way, because it is fired by any
1532
+ LAN connection to the Metro host, and the path reason is CFNetwork's either
1533
+ way -- the classifier reads that reason alone and knows nothing about dev
1534
+ clients. What differs is the screen: a bare app is expected to show React
1535
+ Native's RedBox, "Could not connect to development server". Read the screen
1536
+ with \`agent-device snapshot -i\` and press Reload by the ref or label it
1537
+ reports; neither that text nor the button's accessibility label has been read
1538
+ off hardware.
1539
+
1540
+ \`agent-device metro reload\` does NOT recover either screen. It only reaches
1541
+ an app already connected to Metro's websocket, and an app stopped by this
1542
+ permission never connected.
1543
+
1544
+ Without agent-device,
1545
+ \`xcrun devicectl device process launch --device <udid> --terminate-existing
1546
+ [--payload-url '<devClientUrl>'] <bundleId>
1547
+ [-- -EXDevMenuShowsAtLaunch 0 -EXDevMenuShowFloatingActionButton 0]\`
1548
+ also recovers, and it costs the device log:
1549
+ it replaces the process the collector follows, so
1550
+ \`stim logs --source device\` stops for the rest of that run. For a dev
1551
+ client, pressing Reload is cheaper and keeps the collector alive. For a bare
1552
+ app the relaunch is the cleanest recovery, because it re-reads ip.txt. By
1553
+ hand it is two taps either way: Allow, then Reload.
1554
+
1555
+ WHAT HAS ACTUALLY RUN: the dev-client path above was performed on a phone --
1556
+ the alert, the accept, the unchanged error screen, the Reload press, and the
1557
+ bundle that followed. The bare path has NOT been exercised on hardware; there
1558
+ is no provisioned bare project to run it on. Its signature and its remedy are
1559
+ reasoned from the same CFNetwork evidence and from React Native's own
1560
+ RedBox, not observed.
1561
+
1562
+ THE OTHER ONE-TIME TAP HAS NO API. The developer-trust tap (Settings >
1563
+ General > VPN & Device Management) is refused to automation by the same gate
1564
+ that refuses the app, agent-device's own runner included, so its remedy is
1565
+ "ask the user" and nothing else. An uninstall clears both.`
1566
+ },
1567
+ STIM_NO_DEVICE: {
1568
+ summary: "no usable phone, or the owned simulator or emulator could not be created or booted",
1569
+ separator: "--- DEVICE AND CAPACITY CODES ---",
1570
+ body: () => `STIM_NO_DEVICE
1571
+ With \`--device\`, no physical device answered the selection: none connected,
1572
+ a named serial/UDID that is not connected, several connected with none named
1573
+ (the refusal lists them), or one that is connected but unusable -- an
1574
+ unauthorized Android device, or an iPhone that is unpaired or has Developer
1575
+ Mode off. Hardware is never created or booted, so there is nothing to retry
1576
+ into existence: fix the cable, the trust prompt, or Developer Mode.
1577
+ Otherwise the owned simulator/emulator could not be created or could not
1578
+ reach a booted state. \`stim doctor\` checks the toolchain; \`stim status\` says what
1579
+ Stim thinks it owns. Re-running the command creates a fresh owned device
1580
+ when the recorded one is gone.
1581
+ On iOS a slow first boot is waited out for up to ten minutes while the
1582
+ simulator still reports Booting -- a long silent wait on a loaded machine
1583
+ is patience, not a hang. The failure names the udid and the wait.
1584
+ On Android the emulator's own stdio is captured to
1585
+ the global workspace logs/emulator.log (truncated per boot), and when it printed a
1586
+ \`FATAL |\` / \`ERROR |\` / \`PANIC:\` line THAT is the message and the remedy
1587
+ you get -- the disk-space refusal ("Not enough space to create userdata
1588
+ partition") is the case this exists for. The generic toolchain remedy above
1589
+ is only what you see when neither the log nor the failure itself identifies
1590
+ the cause. An ENOSPC failure points at disk space instead: owned Android AVDs
1591
+ normally live under ~/.android/avd, and a booted AVD can use several GB. A
1592
+ boot whose emulator process exited is also reported at once rather than after
1593
+ the full cold-boot timeout.
1594
+
1595
+ "this project's sim is X, but --device-type asked for Y"
1596
+ The project already owns a simulator of a different model, and Stim will
1597
+ not silently boot a different one. Reap it (\`worktree remove\`, or
1598
+ \`gc --delete\`) and run \`stim ios\` again to create the requested model.
1599
+ That loses the old sim's app state.`
1600
+ },
1601
+ STIM_DEVICE_BUSY: {
1602
+ summary: "another workspace holds the lease on that phone and the wait ran out",
1603
+ body: () => `STIM_DEVICE_BUSY
1604
+ Only on a \`--device\` run. Another workspace holds the lease on that phone,
1605
+ and the wait ran out: the message names the holder root, the device, and the
1606
+ expiry as a clock time and a remaining duration, and \`--json\` adds
1607
+ \`lease: { platform, id, deviceName, holder, expiresAt }\`. In order, the
1608
+ remedies are: wait longer with \`--wait <seconds>\`, pick another device by
1609
+ id, or \`--no-wait\`, which installs with NO lease -- and when both
1610
+ workspaces build the same app id, that install terminates the app the holder
1611
+ is running. Two other cases refuse with this code and no wait at all: a lease
1612
+ file that does not parse (\`lease\` fields null, the file named -- nothing may
1613
+ take that device until it is dealt with), and this workspace's OWN lease with
1614
+ no token left in its \`state.json\` (its workspace directory was recreated).
1615
+ The remedy for that last one is \`stim device unlock\`, which releases by
1616
+ holder rather than by token.`
1617
+ },
1618
+ STIM_DEVICE_LOST: {
1619
+ summary: "the lease was gone or re-held at the pre-install check; rerun",
1620
+ body: () => `STIM_DEVICE_LOST
1621
+ Only on a \`--device\` run. The run held a lease, and the raise before the
1622
+ install found it gone or held under another token -- another workspace took
1623
+ the device in that window. The message names the new holder and its expiry.
1624
+ Run the command again; it waits for that lease under \`--wait <seconds>\`.
1625
+ AFTER the install has started this is not a failure: the app is already on
1626
+ the phone, so the run prints one warning, continues, and reports
1627
+ \`lease: null\` in \`--json\`.`
1628
+ },
1629
+ STIM_AT_CAPACITY: {
1630
+ summary: "concurrency.maxDevices reached; a refusal, not a queue",
1631
+ body: () => `STIM_AT_CAPACITY
1632
+ Only when concurrency.maxDevices is set (it is UNSET by default, so this never
1633
+ fires unless you opted in). Booting a NEW owned device would exceed the cap:
1634
+ the machine already has that many Stim-owned devices booted. It is a refusal, not
1635
+ a queue -- \`ios\`/\`android\` are interactive-shaped, so Stim does not make
1636
+ you wait at a prompt. The remedy is fixed: stop an environment
1637
+ (\`stim stop\`) to free a device, or raise concurrency.maxDevices. A
1638
+ workspace whose OWN device is already booted is never refused -- re-running
1639
+ \`ios\` on an environment you already have is idempotent. (The build cap
1640
+ behaves differently: a compile WAITS for a free slot rather than refusing.
1641
+ See \`guide lifecycle concurrency\`.)`
1642
+ },
1643
+ STIM_BUILD_SLOT_TIMEOUT: {
1644
+ summary: "the maxBuilds wait gave up with every slot held by a running process",
1645
+ body: () => `STIM_BUILD_SLOT_TIMEOUT
1646
+ Only when concurrency.maxBuilds is set. The build cap does not refuse, it
1647
+ WAITS -- this code is that wait giving up: ~90 minutes elapsed and every one
1648
+ of the N slots was still held by a running process, or by a holder Stim could
1649
+ not identify. A dead
1650
+ builder's slot is reclaimed within a poll, and a recycled pid does not hold a
1651
+ slot, so this is never a slot leaked by a crash; it is either that many
1652
+ genuinely long compiles, or a slot directory whose owner is not really
1653
+ building. A slot whose holder cannot be identified at all is skipped while any
1654
+ other slot is merely busy, and becomes STIM_CLAIM_REFUSED only when no slot is
1655
+ left to wait for. Slots live under ~/.stim/build-slots and
1656
+ the message names the directory: remove the slot of a builder that is not
1657
+ building, or raise concurrency.maxBuilds
1658
+ (\`guide lifecycle concurrency\`).`
1659
+ },
1660
+ STIM_NO_REMOTE_SESSION: {
1661
+ summary: "the backend could not use agent-device, or metro.tunnel names an unusable provider",
1662
+ separator: "--- REMOTE-DEVICE CODES (`ios --remote <proxy|eas>` / `android --remote <proxy|eas>`) ---",
1663
+ body: () => `STIM_NO_REMOTE_SESSION
1664
+ The selected backend could not use agent-device, or metro.tunnel names a
1665
+ provider or mode this workspace cannot use (e.g. "expo" on a bare RN
1666
+ project). The remedy line says which. Nothing was created yet.`
1667
+ },
1668
+ STIM_REMOTE_PROXY_CONFIG: {
1669
+ summary: "--remote proxy needs AGENT_DEVICE_DAEMON_BASE_URL and AGENT_DEVICE_DAEMON_AUTH_TOKEN",
1670
+ body: () => `STIM_REMOTE_PROXY_CONFIG
1671
+ \`--remote proxy\` requires AGENT_DEVICE_DAEMON_BASE_URL and
1672
+ AGENT_DEVICE_DAEMON_AUTH_TOKEN. These variables provide credentials after
1673
+ proxy is selected. They never select the backend.`
1674
+ },
1675
+ STIM_REMOTE_EAS_UNAVAILABLE: {
1676
+ summary: "--remote eas needs eas-cli",
1677
+ body: () => `STIM_REMOTE_EAS_UNAVAILABLE
1678
+ \`--remote eas\` requires eas-cli. Proxy environment variables do not change
1679
+ this selection and are not passed to EAS.`
1680
+ },
1681
+ STIM_REMOTE_PLATFORM_MISMATCH: {
1682
+ summary: "the recorded remote session belongs to the other platform; stop, then rerun",
1683
+ body: () => `STIM_REMOTE_PLATFORM_MISMATCH
1684
+ This workspace already has a recorded remote session, and it belongs to the
1685
+ OTHER platform ("Session <id> belongs to android, not ios"). A workspace
1686
+ holds one remote session, and Stim will not end the recorded one to make
1687
+ room -- it may be mid-run for whoever started it. Run \`stim stop\` for this
1688
+ workspace, then re-run with the platform you want. Nothing was created here.`
1689
+ },
1690
+ STIM_REMOTE_SESSION_STATE: {
1691
+ summary: "the EAS session was created but its state could not be recorded, so Stim stopped it",
1692
+ body: () => `STIM_REMOTE_SESSION_STATE
1693
+ The EAS session was created and is healthy, but recording it in this
1694
+ workspace's state failed (an unwritable STIM_HOME, a full disk). A session
1695
+ nothing references is a session nothing will ever stop, so Stim stopped the
1696
+ one it had just created and removed its ownership claim before reporting:
1697
+ this code means nothing is running and nothing is still billing. Repair the
1698
+ state storage the message names, then run the remote command again.`
1699
+ },
1700
+ STIM_REMOTE_SESSION_CLEANUP: {
1701
+ summary: "Stim could not prove an EAS session ended; eas simulator:stop --id",
1702
+ body: () => `STIM_REMOTE_SESSION_CLEANUP
1703
+ Stim tried to end an EAS session and could not PROVE it ended: \`eas
1704
+ simulator:stop\` failed, or its output did not confirm the stop, or the
1705
+ session stopped but its claim in the machine ledger could not be removed.
1706
+ This is a refusal rather than a note because a session that did not stop
1707
+ BILLS until its duration cap. The remedy names the exact command --
1708
+ \`eas simulator:stop --id <id>\` -- and for a ledger that outlived its
1709
+ session, the ledger path to repair. The same code covers a recorded session
1710
+ that could not be verified before replacement: inspect it, then \`stim stop\`.`
1711
+ },
1712
+ STIM_REMOTE_METRO_WRONG: {
1713
+ summary: "the tunnel reaches a Metro that is not this workspace's",
1714
+ body: () => `STIM_REMOTE_METRO_WRONG
1715
+ The gate that proves a tunnel still reaches THIS workspace's Metro failed --
1716
+ before a session or a build, whether the tunnel is Expo's own, one Stim
1717
+ started (metro.tunnel: cloudflared/ngrok/auto), or a named metro.publicUrl.
1718
+ The usual cause: the tunnel was built for a port this workspace no longer
1719
+ holds (a stale one survived a \`stop\`/\`start\` that reserved a different
1720
+ port), and it now serves ANOTHER workspace's dev server -- healthy, and
1721
+ wrong. Re-run \`stim start\` (it prints the port it reserved) and, for a
1722
+ manual tunnel, rebuild it against that port.`
1723
+ },
1724
+ STIM_REMOTE_METRO_UNREACHABLE: {
1725
+ summary: "a remote start could not create its managed tunnel or tell the device where Metro is",
1726
+ body: () => `STIM_REMOTE_METRO_UNREACHABLE
1727
+ A remote start could not create its selected managed tunnel, or the device
1728
+ could not be told where Metro is. Follows the same remedy as
1729
+ STIM_NO_REMOTE_SESSION's tunnel guidance -- set metro.tunnel, or use
1730
+ metro.publicUrl for an existing endpoint.`
1731
+ },
1732
+ STIM_RELOAD_AMBIGUOUS: {
1733
+ summary: "both owned apps are live; name the platform",
1734
+ separator: "--- RELOAD CODES (`stim reload [ios|android]`) ---",
1735
+ body: () => `STIM_RELOAD_AMBIGUOUS
1736
+ Both owned apps are live. Name ios or android; Stim never guesses.`
1737
+ },
1738
+ STIM_RELOAD_RELEASE: {
1739
+ summary: "the live app has embedded JS; run a Debug build first",
1740
+ body: () => `STIM_RELOAD_RELEASE
1741
+ The live app was launched with embedded JavaScript. Run the platform command
1742
+ with a Debug configuration or variant first.`
1743
+ },
1744
+ STIM_RELOAD_STOPPED: {
1745
+ summary: "the recorded app is gone, its device is not live and owned, or the process could not be proven",
1746
+ aliases: ["STIM_RELOAD_UNOWNED", "STIM_RELOAD_PROBE_FAILED"],
1747
+ body: () => `STIM_RELOAD_STOPPED / STIM_RELOAD_UNOWNED / STIM_RELOAD_PROBE_FAILED
1748
+ The recorded app is gone, its exact device is not live and owned by this
1749
+ workspace, or simctl/adb could not prove the process exists. No launch or
1750
+ device lifecycle action is taken; follow the printed platform-command or
1751
+ process-probe remedy.`
1752
+ },
1753
+ STIM_RELOAD_FAILED: {
1754
+ summary: "the reload failed; the remedy differs by shape -- read it before acting",
1755
+ body: () => `STIM_RELOAD_FAILED
1756
+ The Metro websocket reload did not reach a peer Stim could identify. Two
1757
+ shapes reach this code and the remedy differs. Read it rather than assuming.
1758
+
1759
+ METRO DID NOT ANSWER. The probe timed out after 2 seconds, or the socket
1760
+ errored. Nothing is known about the app, so the remedy is to run the same
1761
+ reload again, and to check the dev server with stim doctor if it keeps
1762
+ timing out. Do not touch the device for this one.
1763
+
1764
+ METRO REPORTS NO PEER FOR THE APP. Stim broadcasts a reload anyway before
1765
+ giving up, because matching is best-effort and an unmatched peer may still be
1766
+ this app, so VERIFY THE UI FIRST -- the app may already have recovered. If it
1767
+ did not, retry: a client reconnects to Metro every 2 seconds, which is also
1768
+ this probe's timeout, so a single miss can be a reconnect window rather than
1769
+ an app that never connected. If it stays unreachable on iOS, an error in the
1770
+ first bundle leaves the app without a packager connection at all and no retry
1771
+ will make it a peer. The remedy then routes the agent to the device's own
1772
+ controls in its existing automation session: press the error screen's Reload
1773
+ button, or open the dev menu and press Reload when no error screen is
1774
+ showing. The printed agent-device open command is the last resort; it
1775
+ relaunches that app on that device with this workspace's Metro port and loses
1776
+ in-memory state. Keep the existing --session flag and verify afterward.
1777
+
1778
+ MORE THAN ONE MATCHING PEER IS NOT A FAILURE. A workspace Metro serves one
1779
+ app, so several matching peers are that app on several devices. Stim reloads
1780
+ every one of them and reports the count in the facts as targets.`
1781
+ },
1782
+ STIM_WORKTREE_REMOVAL_IN_PROGRESS: {
1783
+ summary: "a managed remote start found worktree remove holding the lock; wait, then rerun",
1784
+ separator: "--- DEV-SERVER CODES (`stim start`) ---",
1785
+ body: () => `STIM_WORKTREE_REMOVAL_IN_PROGRESS
1786
+ A managed remote start found that \`stim worktree remove\` owns the
1787
+ worktree lock. The start did not register the project or create a tunnel.
1788
+ Wait for removal to finish, then run \`stim start --remote\` again.`
1789
+ },
1790
+ STIM_REMOTE_START_REQUIRED: {
1791
+ summary: "a running server cannot gain a remote tunnel; stop, then start --remote, or metro.publicUrl",
1792
+ body: () => `STIM_REMOTE_START_REQUIRED
1793
+ A healthy bare or Expo server was started without its required remote
1794
+ tunnel. A running server cannot gain that option. For a Stim supervisor,
1795
+ run \`stim stop\`, then \`stim start --remote\`. For an external server,
1796
+ configure metro.publicUrl or let Stim supervise the server.`
1797
+ },
1798
+ STIM_BARE_DEPS: {
1799
+ summary: "the supervisor cannot host Metro from the project's node_modules; the @stim-cli/metro capture note",
1800
+ aliases: ["STIM_BARE_LOAD", "STIM_BARE_API"],
1801
+ body: () => `STIM_BARE_DEPS / STIM_BARE_LOAD / STIM_BARE_API (bare RN)
1802
+ The supervisor hosts Metro out of the PROJECT's node_modules, so metro,
1803
+ @react-native/dev-middleware and @react-native-community/cli-server-api must
1804
+ be installed there and must match the project's React Native. DEPS = not
1805
+ resolvable (install them), LOAD = installed but threw while loading,
1806
+ API = loaded but is not the API Stim expects (mismatched versions).
1807
+
1808
+ "@stim-cli/metro is not installed ... so bundler and client logs will not be
1809
+ captured" (in metro.ndjson, bare RN)
1810
+ The dev server is serving; only capture is missing, so \`logs\` would report
1811
+ a quiet timeline for a broken build. Install \`@stim-cli/metro\` as a
1812
+ devDependency of the project.`
1813
+ },
1814
+ STIM_EXPO_BIN: {
1815
+ summary: "node_modules/.bin/expo is missing; install dependencies",
1816
+ body: () => `STIM_EXPO_BIN (Expo)
1817
+ node_modules/.bin/expo does not exist. Install the project's dependencies.`
1818
+ },
1819
+ STIM_METRO_TIMEOUT: {
1820
+ summary: "the supervisor is alive but Metro or the tunnel was not ready within the wait; --wait 180",
1821
+ body: () => `STIM_METRO_TIMEOUT
1822
+ "The dev server did not answer on port <n> within <s>s."
1823
+ The supervisor is alive, but Metro or its requested Expo tunnel is not ready.
1824
+ \`start\` has already
1825
+ printed the last lines of the global workspace logs/supervisor.log above this -- read
1826
+ them. A cold Metro on a large graph can genuinely need more than the default
1827
+ 60s: re-run with \`--wait 180\`. Otherwise \`stim stop\`, then \`start\`.`
1828
+ },
1829
+ STIM_SUPERVISOR_EXITED: {
1830
+ summary: "the dev server failed outright; the quoted supervisor.log tail is the real error",
1831
+ body: () => `STIM_SUPERVISOR_EXITED
1832
+ "The supervisor exited (<code|signal>) before the dev server came up"
1833
+ The dev server failed outright, and the quoted evidence is the real error:
1834
+ the supervisor.log tail if it wrote one, plus this attempt's error records
1835
+ from the timeline (an expo child's config error -- a PluginError, a bad app
1836
+ config -- lands THERE, not in supervisor.log). \`stim logs --errors\` has
1837
+ the full records. Fix that and run \`start\` again.
1838
+
1839
+ "Cannot reuse or replace the recorded supervisor: ..."
1840
+ A live record has no verifiable OS process identity, or the workspace and
1841
+ registry records disagree. Nothing new was started; the old process is
1842
+ left running. If inspection is denied, retry with permission to inspect
1843
+ Stim's processes. For a legacy record, stop the server with the tool that
1844
+ started it before retrying. Never reconstruct ownership from a process
1845
+ name, port, or wall-clock timestamp.`
1846
+ },
1847
+ STIM_BAD_ARG: {
1848
+ summary: "an argument, setting, directory, flavor, or device name refused before anything starts",
1849
+ aliases: ["STIM_NO_PROJECT"],
1850
+ body: () => `STIM_BAD_ARG / STIM_NO_PROJECT
1851
+ The command refused before doing anything: an unusable --wait value, a known
1852
+ setting with the wrong type ("Invalid <key> setting <value>. Expected <shape>."
1853
+ -- \`guide settings\` names the type each key takes), an invalid
1854
+ Metro tunnel setting, an invalid android.dataPartitionSizeGb value, an unsafe
1855
+ android.avdConfig key or fragment, a malformed ios.signingIdentity,
1856
+ ios.signingIdentitySha1 or ios.lanHost value, \`--device\` with an empty
1857
+ serial or UDID, \`--device\` together with \`--remote\`, a working directory
1858
+ with no package.json above it, or one whose nearest package.json does not
1859
+ parse or depends on neither react-native nor expo, so the directory is not
1860
+ an app (the refusal names that package.json and says which of the two it
1861
+ is; \`doctor\` reports the same directory as a finding), an
1862
+ android/app/build.gradle that declares product flavors with
1863
+ no variant selected (the refusal names the debug variants), or a
1864
+ \`--device-type\`, \`--runtime\` or \`--system-image\` name that is BLANK or
1865
+ is not installed on this machine. For the unknown-name case the installed
1866
+ names are printed in the message -- the versions \`xcrun simctl list
1867
+ runtimes\` reports, the models those runtimes can actually CREATE (not the
1868
+ whole \`simctl list devicetypes\` table, which also names watchOS, tvOS and
1869
+ visionOS models no iOS runtime offers), or the system images the SDK has --
1870
+ so the remedy is to re-run with one of them. An ios.deviceType, ios.runtime
1871
+ or android.systemImage setting is checked the same way, and the check applies
1872
+ even when this workspace ALREADY owns a device, so a name that could never
1873
+ create anything is caught rather than left to a later run.
1874
+ These errors are caught before the port is reserved and before any build or
1875
+ device work, so nothing was started. The one listing they need
1876
+ (\`simctl list runtimes\`, the SDK's system-images directory) runs only when
1877
+ a name was actually given, and a listing that fails is reported as
1878
+ STIM_NO_DEVICE naming the tool, never as a crash.`
1879
+ },
1880
+ STIM_LOCK_REFUSED: {
1881
+ summary: "a directory lock is held by a removal, which is never waited out",
1882
+ separator: "--- COORDINATION CODES (any command that shares a resource) ---",
1883
+ body: () => `STIM_LOCK_REFUSED
1884
+ A directory lock that serialises two commands over the same thing -- this
1885
+ workspace's managed tunnel, its managed remote worktree, the machine's EAS
1886
+ project ledger -- is held by a REMOVAL, and a removal is never waited out:
1887
+ what it protects will not exist when the lock frees. Nothing was created.
1888
+ The message names the lock and the purpose holding it (\`worktree removal\`,
1889
+ \`workspace removal\` -- both are \`stim worktree remove\`). Let it finish,
1890
+ then run the command again. \`start --remote\` reports this same case as
1891
+ STIM_WORKTREE_REMOVAL_IN_PROGRESS instead.`
1892
+ },
1893
+ STIM_LOCK_TIMEOUT: {
1894
+ summary: "a lock held past the wait; workspace-process and short directory locks",
1895
+ body: () => `STIM_LOCK_TIMEOUT
1896
+ The same locks, held by an ordinary command that is still running, for
1897
+ longer than the wait -- 60s by default, 4 minutes for the remote-session and
1898
+ EAS project locks, and ~90 minutes for the \`worktree warm\` lock, which one
1899
+ \`--refresh\` can hold for a whole dependency install. That wait prints its
1900
+ elapsed waiting time and holder every 30 seconds (\`lock waiting 40s
1901
+ for stim worktree warm --refresh (pid 41233)\`) and the refusal names the same holder
1902
+ and the lock directory under ~/.stim/warm-locks.
1903
+ A lock whose owner died is taken over automatically (its recorded
1904
+ process identity is checked every poll), so this means another Stim command really
1905
+ is working on this workspace: wait for it and retry. If nothing is running,
1906
+ the message names the lock directory and removing it is safe. The same error
1907
+ code also covers the short directory-lock timeout below.
1908
+
1909
+ "Timed out waiting for the lock at <path>."
1910
+ Short directory locks serialize writes to config, workspace state, device
1911
+ leases, ownership records, metadata, and cache manifests. The path identifies
1912
+ the lock. A lock older than 10s is taken over automatically. Wait for the
1913
+ command holding it; if none is running, remove the named directory.`
1914
+ },
1915
+ teardown: {
1916
+ summary: "an unmanaged port, an unverified supervisor, and a failed device teardown",
1917
+ separator: "--- TEARDOWN AND WORKSPACE REFUSALS ---",
1918
+ body: () => `"metro refusing to kill port <n>: ... runs from <dir>, outside
1919
+ <project>" (stop)
1920
+ Stim only signals processes it launched and whose saved identity still
1921
+ matches. Stop an externally started server with the tool that started it.
1922
+ Matching this workspace's port or directory does not authorize cleanup,
1923
+ and stop has no override for process ownership.
1924
+
1925
+ "stop refusing to signal supervisor pid <n>: ..." (stop)
1926
+ The records disagree, the saved OS identity is unavailable, or it records a
1927
+ port this project did not reserve. If process inspection is denied, retry
1928
+ with permission to inspect the processes Stim started. A pid is a number the OS reuses, so it is not
1929
+ signalled. The port reservation is KEPT -- it is the only handle a retry
1930
+ has. Check \`ps -p <n>\` and \`stim status\` before signalling by hand.
1931
+
1932
+ "supervisor pid <n> did not exit within 10s of SIGTERM" (stop)
1933
+ Deliberately not escalated to SIGKILL: the supervisor may be mid-write on the
1934
+ very log files \`logs\` reads. The device is left alone and the port stays
1935
+ reserved. Re-run \`stop\`, or signal it yourself: kill -9 -<n> (note the
1936
+ minus -- it is a process group).
1937
+
1938
+ "teardown failed: <reason>"
1939
+ Stim could not release the owned device and keeps its record for a retry.
1940
+ \`worktree remove\` exits 1 without removing the worktree while the device is
1941
+ still tracked. Fix the reported cause and re-run.`
1942
+ },
1943
+ remove: {
1944
+ summary: "worktree remove refused a dirty tree: what it restores itself and what --force discards",
1945
+ body: () => `"Refusing to remove <path>: uncommitted changes / untracked files / commits
1946
+ not on any remote" (worktree remove)
1947
+ A native build rewrites tracked files, and Stim now RESTORES the one class
1948
+ it can prove is not work: when the only dirt left is \`pod install\` churn
1949
+ (\`<app>/ios/Podfile.lock\`, \`<app>/ios/*.xcodeproj/project.pbxproj\`,
1950
+ tracked and unstaged), \`worktree remove\` runs the checkout itself and says
1951
+ so per file -- those files die with the worktree either way, and a lockfile
1952
+ change anyone meant would have been committed. ONE other dirty path and the
1953
+ whole set is refused, churn included, so this never eats real work.
1954
+ When it does refuse, the refusal PRINTS THE DIRTY PATHS, and the restore
1955
+ command under it carries those same paths: run it as printed rather than
1956
+ reaching for --force.
1957
+ It is built from what git reported, so in a monorepo it names
1958
+ \`apps/<app>/ios/Podfile.lock\` rather than an \`ios/...\` example that would
1959
+ fail with "did not match any file(s) known to git".
1960
+ A setup script that rewrites tracked assets (brand icons, generated config)
1961
+ produces the same refusal, with the same treatment: restore the paths the
1962
+ refusal actually named.
1963
+ Use --force only when you genuinely intend to discard work; it deletes
1964
+ uncommitted and untracked files permanently.`
1965
+ },
1966
+ STIM_MAIN_DIRTY: {
1967
+ summary: "warm --refresh will not move a source checkout with local work or an operation in progress",
1968
+ body: () => `STIM_MAIN_DIRTY
1969
+ \`worktree warm --refresh\` writes to the SOURCE CHECKOUT, and it refuses one
1970
+ it cannot move: tracked files with uncommitted changes (the refusal names
1971
+ them), or a rebase or merge in progress. Untracked files are not a reason to
1972
+ refuse -- but git itself refuses a fast-forward that would overwrite one, and
1973
+ that reports this code too, quoting git. The remedy is the exact line that
1974
+ clears it: commit, \`git -C <source-checkout> stash push -u -m warm-refresh\`,
1975
+ or \`git -C <source-checkout> rebase --abort\`. Nothing was installed or
1976
+ copied. Plain \`stim worktree warm\` does not care: it copies from a dirty
1977
+ source checkout exactly as it always has.`
1978
+ },
1979
+ STIM_MAIN_DETACHED: {
1980
+ summary: "warm --refresh needs a branch to fast-forward, not a detached HEAD",
1981
+ body: () => `STIM_MAIN_DETACHED
1982
+ The source checkout's HEAD is detached, so there is no branch to fast-forward
1983
+ and no upstream to fast-forward it to. Run
1984
+ \`git -C <source-checkout> checkout <branch>\` and warm again. \`--refresh\`
1985
+ never picks a branch for you; a checkout whose job is to seed worktrees
1986
+ should sit on a branch someone chose.`
1987
+ },
1988
+ STIM_MAIN_DIVERGED: {
1989
+ summary: "the source checkout is both ahead of and behind its upstream; warm --refresh will not merge",
1990
+ body: () => `STIM_MAIN_DIVERGED
1991
+ The source checkout's branch has commits its upstream does not, AND its
1992
+ upstream has commits it does not. A fast-forward is impossible, and
1993
+ \`--refresh\` will not merge or reset someone else's checkout to make one:
1994
+ that decision is yours. Rebase or merge it yourself, then warm again. The
1995
+ message reports both counts. Nothing was installed or copied.`
1996
+ },
1997
+ STIM_DEPS_INCOMPLETE: {
1998
+ summary: "the last install recorded for the lockfile on disk now did not finish, so warm will not copy it",
1999
+ body: () => `STIM_DEPS_INCOMPLETE
2000
+ \`worktree warm\` refuses to copy dependencies the source checkout never
2001
+ finished installing. \`--refresh\` records a COMPLETED install of the lockfile
2002
+ it read under ~/.stim/warm-installs; an install that failed, or whose process
2003
+ was killed, leaves that record saying unfinished. A plain warm reads it after
2004
+ it takes its claim and before it copies, and this code is what it prints when
2005
+ the unfinished install is of the lockfile AS IT STANDS NOW. Without it the
2006
+ copy carries a partial node_modules and exits 0, and nothing else in the run
2007
+ says so: the refresh reported its own STIM_DEPS_FAILED in its own terminal,
2008
+ and a refresh that was killed reported nothing anywhere. Nothing was copied.
2009
+ Run \`stim worktree warm --refresh\`: it reinstalls rather than skipping for
2010
+ exactly the same record, and a plain warm copies once that install completes.
2011
+ Two states deliberately do NOT produce this code. A record of a DIFFERENT
2012
+ lockfile says nothing about the one on disk now, whose dependencies may well
2013
+ have been installed since; and a repository with no record at all -- every
2014
+ repository before its first \`--refresh\` -- copies as it always has.
2015
+ In a monorepo the record is keyed on the directory that owns the lockfile,
2016
+ which is usually the repository root, so every app of it reads the same one.`
2017
+ },
2018
+ warm: {
2019
+ summary: "two warm refusals whose text is incomplete, known and not fixed",
2020
+ body: () => `"Could not warm this worktree: EACCES: permission denied, mkdir
2021
+ '<home>/warm-locks/<name>.lock'" (worktree warm --refresh)
2022
+ A STIM_HOME that \`--refresh\` cannot write. The refusal itself is right -- it
2023
+ writes to the source checkout, so it will not run without a claim -- but it
2024
+ carries no \`failed: <CODE>\` line, because EACCES is not a Stim code, and no
2025
+ fix line. Make STIM_HOME writable, or set STIM_HOME to somewhere writable,
2026
+ then run it again. A plain warm degrades in this state rather than refusing.
2027
+
2028
+ "... the claim path is a file, not a claim directory", with a \`rm -rf\` that
2029
+ changes nothing (worktree warm --refresh)
2030
+ When \`$STIM_HOME/warm-locks\` or STIM_HOME itself is a regular FILE, the
2031
+ refusal names \`warm-locks/<name>.lock\` -- a path that cannot exist under a
2032
+ file -- so running the printed removal does nothing and the next run refuses
2033
+ identically. Remove the file that is in the way and run it again. A plain
2034
+ warm copies unsynchronised in this state.
2035
+
2036
+ Both are documented rather than fixed: appandflow/stim#696.`
2037
+ },
2038
+ carry: {
2039
+ summary: "worktree warm copy results, lockfile mismatches, and remedies",
2040
+ body: () => `"carry incomplete: ... ignored entries copied, ... kept, ... failed"
2041
+ (worktree warm)
2042
+ At least one entry could not be copied. The command exits 1 and names each
2043
+ failure. Existing entries stay untouched; any files already published remain.
2044
+ Inspect failed paths before retrying, because existing directories are skipped
2045
+ whole. "complete" means the eligible copy finished, not that dependencies
2046
+ are installed or match this branch. Progress and results go to stderr,
2047
+ with empty stdout.
2048
+
2049
+ "carry carried <dir>/Pods does not match the <dir>/Podfile.lock on disk here"
2050
+ Warm copied ignored Pods from the source checkout, but their Manifest.lock
2051
+ differs from the tracked Podfile.lock in this worktree. Warm does not change
2052
+ tracked files. Run the printed pod-install command before building directly.
2053
+ \`stim ios\` detects a mismatch and runs \`pod install\` for you.
2054
+
2055
+ "carry carried <dir>/Pods but there is no <dir>/Podfile.lock"
2056
+ Warm copied Pods but the destination has no Podfile.lock. Follow the printed
2057
+ pod-install command before building.
2058
+
2059
+ "carry carried dependencies may be stale: they do not match ..."
2060
+ The source checkout's lockfile differs from this branch's lockfile. Run the
2061
+ printed package-manager command before building. A carry whose lockfile
2062
+ matches is silent; the warning means a real difference.
2063
+
2064
+ If the source checkout has no dependencies to copy, use this project's
2065
+ package manager to install them. Warm does not install dependencies or prove
2066
+ the app is ready, unless \`--refresh\` installed them in the SOURCE CHECKOUT
2067
+ first; even then the copy can still carry a lockfile this branch does not
2068
+ have, which is exactly what these carry warnings report.`
2069
+ },
2070
+ environment: {
2071
+ summary: "npx registry E401/E404, the Node floor, no free Metro port, the reservation race",
2072
+ separator: "--- ENVIRONMENT ---",
2073
+ body: () => `"npm error code E401 / E404" while \`npx\` resolves the stim package
2074
+ The repo probably pins a private registry in \`.npmrc\`, so \`npx\` looked for
2075
+ the package there instead of on npm. Use the public registry for this command:
2076
+
2077
+ npx --registry=https://registry.npmjs.org stim <command>
2078
+
2079
+ A line such as \`npm warn exec ... will be installed\` is normal when using
2080
+ the no-install form.
2081
+
2082
+ "Unsupported engine" or a syntax error before Stim starts
2083
+ Stim requires Node 20.19.4 or later on Node 20, or Node 22.12.0 or later.
2084
+ Switch Node versions, then run the command again.
2085
+
2086
+
2087
+ "Found no free Metro port between ..."
2088
+ 200 consecutive ports are claimed or occupied. \`stim status\` shows what
2089
+ Stim knows about; the rest is other software.
2090
+
2091
+ "Could not reserve a Metro port after 5 attempts"
2092
+ Several commands raced for the same ports and each one lost. Nothing is
2093
+ wrong; retry.`
2094
+ },
2095
+ sandbox: {
2096
+ summary: "running under a sandboxing harness: EPERM under STIM_HOME, CoreSimulatorService, adb",
2097
+ body: () => `RUNNING UNDER A SANDBOX
2098
+
2099
+ An agent harness that sandboxes shell commands typically permits writes
2100
+ inside the project and blocks the rest. Three things Stim needs sit outside
2101
+ that boundary, and none of the failures names the sandbox:
2102
+
2103
+ EPERM: operation not permitted, mkdir '<STIM_HOME>/workspaces/...'
2104
+ writes to STIM_HOME (~/.stim unless set)
2105
+
2106
+ CoreSimulatorService connection became invalid (macOS)
2107
+ Unable to locate device set: ... Code=61 "Connection refused"
2108
+ the simulator service simctl talks to over XPC
2109
+
2110
+ ADB server didn't ACK
2111
+ could not install *smartsocket* listener: Operation not permitted
2112
+ the adb server socket on tcp:5037
2113
+
2114
+ Measured on Claude Code and on Codex: the three fail the same way in both,
2115
+ so this is the shape of the problem, not one harness's quirk. Codex also
2116
+ blocks network egress by default, which breaks a cache lookup and a fetch.
2117
+
2118
+ \`stim doctor\` names this when a write to STIM_HOME actually fails, not
2119
+ merely when a harness that can sandbox is present, and
2120
+ \`stim doctor --fix\` writes the three keys into .claude/settings.local.json,
2121
+ the per-user file, merging with what is there. It refuses under Codex, which
2122
+ has no per-path allowance to add, and refuses any settings file it cannot
2123
+ parse rather than replace it: comments make one unparseable here even though
2124
+ Claude Code accepts them. Claude Code reads project settings from the
2125
+ directory a session starts in, so in a monorepo the file has to sit at that
2126
+ root to count, and a file written inside a worktree goes when the worktree
2127
+ does.
2128
+
2129
+ Two ways out, and choosing at the start of a session beats discovering it
2130
+ three failures in. Either run Stim with the harness's sandbox disabled, or
2131
+ allow the three. In Claude Code that is settings.json:
2132
+
2133
+ sandbox.filesystem.allowWrite ["~/.stim"]
2134
+ sandbox.network.allowMachLookup ["com.apple.coresimulator.*"]
2135
+ sandbox.network.allowLocalBinding true
2136
+
2137
+ In Codex the sandbox is one flag, \`codex -s\`, with no per-path allowance:
2138
+ workspace-write still refuses STIM_HOME.
2139
+
2140
+ A git credential helper is often blocked too. It prints \`failed to store\`
2141
+ on a fetch that otherwise succeeded, and is safe to ignore.`
2142
+ },
2143
+ STIM_CONFIG_CORRUPT: {
2144
+ summary: "~/.stim/config.json is not valid JSON and Stim never resets it",
2145
+ body: () => `STIM_CONFIG_CORRUPT ("Stim config at <path> is not valid JSON")
2146
+ Any command can raise it: every command reads ~/.stim/config.json first.
2147
+ The file holding every owned-device record will not parse, and Stim never
2148
+ resets it for you -- a silent reset would orphan every simulator it names.
2149
+ Repair the file, or move it aside (\`mv <path> <path>.broken\`) and accept
2150
+ that the devices it recorded become orphans you delete by hand.`
2151
+ }
2152
+ }
2153
+ },
2154
+ lifecycle: {
2155
+ summary: "The full worktree -> start -> ios/android -> logs -> teardown flow, with sections for builds, devices and flags",
2156
+ preamble: () => `ENVIRONMENT LIFECYCLE
2157
+
2158
+ Two workflows share steps 2 through 6.
2159
+
2160
+ SINGLE CHECKOUT: work in place, on a branch, in one directory. There is no
2161
+ step 1, and step 7 reclaims the environment without deleting the tree, which
2162
+ stays because it is the source checkout. That directory is your workspace, and
2163
+ no rule here about keeping the source checkout fit as a seed applies to it.
2164
+
2165
+ WORKTREE: the checkout you cloned is a seed. It stays clean and on the default
2166
+ branch so every worktree warmed from it starts current. Here the source checkout
2167
+ is infrastructure, not a workspace, and every rule below about its fitness as
2168
+ a seed belongs to this workflow.
2169
+
2170
+ # 1. Create a linked worktree with Git, unless a harness already did.
2171
+ # Choose the branch, path, and base ref with Git.
2172
+ git worktree add -b app/412 ../app-412 HEAD
2173
+ cd ../app-412
2174
+ stim worktree warm
2175
+
2176
+ # Warm copies missing ignored state from the source checkout; it does not
2177
+ # install dependencies.
2178
+ # In a monorepo, enter the app directory before starting the dev server.
2179
+
2180
+ # Optional: bring the SOURCE CHECKOUT up to date first, then copy that.
2181
+ stim worktree warm --refresh
2182
+ lock acquired
2183
+ checkout main 3 commits behind origin/main -> fast-forwarded to 9f2c1a3
2184
+ deps source /w/main: pnpm-lock.yaml changed -> pnpm install (41s)
2185
+ pods source /w/main/apps/mobile: ios/Podfile.lock unchanged -> skipped
2186
+
2187
+ # 2. The dev server, under a detached supervisor. Blocks until it is
2188
+ # verifiably THIS project's, then hands your shell back.
2189
+ stim start
2190
+ port 8082 (reserved)
2191
+ supervisor pid 41233
2192
+
2193
+ # If stale Metro transforms or file-map state require recovery:
2194
+ stim start --reset-cache
2195
+ # Restarts only this app's verified owned Metro, retaining its port/devices.
2196
+ # Uses a fresh persistent cache namespace, not deletion of shared cache files.
2197
+ # Other apps and worktrees keep their caches. Subsequent starts reuse the new
2198
+ # namespace; another reset changes it again. Native build caches are unchanged.
2199
+ # Expo requires SDK 54+ and Stim's config adapter. Custom file-map cache
2200
+ # managers must honor Metro's fileMapCacheDirectory for file-map invalidation.
2201
+
2202
+ # 3. Owned device booted, native inputs fingerprinted, cached build
2203
+ # installed (or built), app launched wired to port 8082, device-log
2204
+ # collector attached.
2205
+ stim ios # or: stim android
2206
+ device stim-app-412 (iPhone 17 26.5) (BF2A..) booted (9s)
2207
+ fingerprint a3f9b1.. hit (2s)
2208
+ install from cache (3s)
2209
+ launch com.example.app (1s)
2210
+
2211
+ # 4. Reproduce the affected behavior and inspect the baseline errors.
2212
+ # For a clean check, require exit 0 AND no matching errors.
2213
+ # Human mode prints "No matching log records" on stderr for zero matches.
2214
+ # Exit 0 alone means the query succeeded, even when it printed errors.
2215
+ stim logs --errors
2216
+
2217
+ # 5. Edit the JS. Fast Refresh applies it; no Stim command is involved.
2218
+ # For UI work, wait for the expected UI and repeat the affected interaction
2219
+ # on the reported device, using the existing automation session if any.
2220
+ stim logs --errors
2221
+ # Retain proof: a screenshot, recording, or relevant runtime output.
2222
+ # See: stim guide lifecycle verification
2223
+
2224
+ # 6. Pausing: supervisor halted, collectors reaped, owned device SHUT DOWN
2225
+ # (never deleted), port freed. Coming back costs a boot, not a create.
2226
+ stim stop
2227
+
2228
+ # 7. Remove this linked worktree and its environment. This also works for
2229
+ # unwarmed worktrees with no Stim registry entry. Git-created branches stay.
2230
+ stim worktree remove
2231
+
2232
+ Steps 2 and 3 are ordered, not interchangeable: \`ios\` and \`android\` never
2233
+ start the bundler, and refuse with STIM_NO_METRO when nothing holds the
2234
+ reserved port. That refusal costs a second; the alternative costs four minutes
2235
+ and produces an app that cannot load a bundle.
2236
+
2237
+ Repeat step 3 whenever a NATIVE input changes. A JS-only edit needs nothing --
2238
+ that is what Fast Refresh over the running dev server is for. \`stim reload\` is
2239
+ not part of the normal workflow. It is the explicit recovery path when Fast
2240
+ Refresh cannot clear the current screen, and on Android after a failed first
2241
+ bundle load. It reloads JavaScript and never restarts the app. Use \`stim
2242
+ reload ios\` or \`stim reload android\` to select a platform when both owned
2243
+ apps are live. For a physical device that reached Metro, use \`agent-device
2244
+ metro reload --metro-port <reported-port>\`. The detected iOS Local Network
2245
+ first-load remedy uses agent-device UI automation because no Metro peer exists
2246
+ yet. It never builds, installs, boots, or cold-launches. It acts only on a live
2247
+ app on this workspace's owned local simulator or emulator, and refuses release
2248
+ builds, stopped or unowned devices, a missing or foreign Metro, and an
2249
+ ambiguous no-platform request. Every reload goes over this workspace's Metro
2250
+ websocket, on both platforms. It never reopens a development-client URL,
2251
+ because that restarts the app rather than reloading its JavaScript.
2252
+
2253
+ How the message is addressed depends on the dev server, and \`strategy\` in
2254
+ the facts reports which you got. Where Metro can name its clients, Stim
2255
+ addresses every peer matching the platform and reports \`metro-websocket\`. A
2256
+ workspace Metro serves one app, so those peers are this app on however many
2257
+ devices are attached to that port, and \`targets\` says how many peers the request
2258
+ addressed.
2259
+ The bare React Native dev server cannot name its clients at all, so Stim
2260
+ broadcasts: \`metro-broadcast\` means every app connected to that Metro
2261
+ was sent a reload request and Stim cannot confirm \`appId\` was among them. Verify the UI on
2262
+ \`deviceId\`; if it did not change, reload from the app's own error screen or
2263
+ dev menu.
2264
+
2265
+ When Metro reports no peer for the app, Stim broadcasts a reload anyway before
2266
+ giving up, because matching is best-effort and an unmatched peer may still be
2267
+ this app. So verify the UI first: it may already have recovered. If it did not,
2268
+ retry: a client reconnects every 2 seconds, which is also this probe's timeout,
2269
+ so a single miss can be a reconnect window rather than an app that never
2270
+ connected. If it stays unreachable on iOS, an error in the first bundle leaves
2271
+ the app without a packager connection at all, and no retry will make it a peer.
2272
+ The command then routes the agent to the device's own controls in its existing
2273
+ automation session: press the error screen's Reload button, or open the dev
2274
+ menu and press Reload when no error screen is showing, and relaunch only when
2275
+ neither is reachable. Stim does not take over that stateful session.
2276
+
2277
+ When Metro itself does not answer within the probe's 2 seconds, nothing is
2278
+ known about the app. The command says to retry and check the dev server rather
2279
+ than sending the agent to the device.
2280
+
2281
+ A successful reload confirms that the request was sent. It does not wait for
2282
+ new JavaScript or observe the resulting UI. Verify the expected screen or
2283
+ interaction on the reported device and inspect \`stim logs --errors\` before
2284
+ claiming recovery; exit 0 alone does not prove it.
2285
+
2286
+ An iOS simulator launch command has a 60-second deadline. This is separate
2287
+ from bundle delivery and readiness verification: it gives CoreSimulator time
2288
+ to accept the launch, not the app extra time to report readiness. A timeout
2289
+ still fails the launch; Stim does not automatically retry it.
2290
+
2291
+ For an unverified Android launch, follow the emitted device-specific remedy.
2292
+ If the app is stuck before loading its first bundle, restart its process with
2293
+ the printed force-stop and launcher commands. Foregrounding the same process
2294
+ does not restart initialization. Confirm the bundle request and expected UI.
2295
+
2296
+ DESTRUCTIVE COMMANDS -- ask the user first
2297
+ gc --delete deletes orphaned stim-* devices, tens of GB
2298
+ gc --delete --cache all empties the shared build caches every project uses
2299
+ gc --delete --cache <name>
2300
+ empties only the caches that carry <name>
2301
+ worktree remove --force discards uncommitted and untracked work
2302
+
2303
+ Permanent local deletion lives in exactly TWO commands: \`worktree remove\`
2304
+ (the workspace you name) and \`gc --delete\` (the machine). For a local device,
2305
+ \`stop\` shuts it down and never deletes it. For a recorded EAS session,
2306
+ \`stop\` irreversibly ends the session. Externally started servers are left
2307
+ alone, even when they use the reserved port. There is no \`--delete\` flag on
2308
+ \`stop\`.
2309
+
2310
+ CAPACITY
2311
+ A booted iOS sim is roughly 1-2 GB of RAM, an Android emulator 2-3 GB. On a
2312
+ 16 GB machine plan for 2-3 live environments. Nothing enforces this;
2313
+ \`stim status\` is how you check -- it reports every workspace on the
2314
+ machine, not just this one.
2315
+
2316
+ TWO REPORTS, TWO QUESTIONS
2317
+ "What is running" is \`stim status\`: live state, right now. "How much the
2318
+ cache saved" is \`stim stats\`: aggregate counters for this project and for
2319
+ the machine, with a hit rate and an estimate of the time saved (see
2320
+ \`guide facts stats\`).`,
2321
+ sections: {
2322
+ readiness: {
2323
+ summary: "implement optional pending/ready app logs, deadlines, errors, and platform isolation",
2324
+ body: () => `OPTIONAL APP READINESS
2325
+
2326
+ No package or SDK is needed. This applies to debug ios/android launches with
2327
+ Metro verification enabled, not release builds or --no-metro-check.
2328
+
2329
+ 1. Add this at app startup, before essential initialization:
2330
+
2331
+ if (__DEV__) console.info('[stim:readiness] pending');
2332
+
2333
+ 2. Use the app's real ready state: essential initialization succeeded, usable
2334
+ content rendered, and the splash screen hidden. From that success path:
2335
+
2336
+ if (__DEV__) console.info('[stim:readiness] ready');
2337
+
2338
+ In an Expo root component with an existing isReady state:
2339
+
2340
+ useEffect(() => {
2341
+ if (!isReady) return;
2342
+ let active = true;
2343
+ SplashScreen.hideAsync().then(() => {
2344
+ if (active && __DEV__) console.info('[stim:readiness] ready');
2345
+ }).catch(console.error);
2346
+ return () => { active = false; };
2347
+ }, [isReady]);
2348
+
2349
+ Import useEffect from react and SplashScreen as a namespace from
2350
+ expo-splash-screen. Keep the pending log outside the component at module
2351
+ scope, before startup work. Use the project's existing splash lifecycle;
2352
+ do not add a splash dependency to a bare app just for this integration.
2353
+ Static imports run before module-scope statements. If imported startup work
2354
+ must opt into the wait, emit pending from an earlier app entry module before
2355
+ loading that work. A failure before pending keeps the default check.
2356
+ Cover login, onboarding, and deep links. Do not report ready merely on
2357
+ root mount, on a timer, in a failure handler, or in finally.
2358
+
2359
+ 3. Run the platform command from the app directory. Check for the readiness
2360
+ phase and inspect the UI on the reported device. Exercise a slow success,
2361
+ a missing ready message, and a startup error. Retain the relevant output.
2362
+
2363
+ Without an observed pending, Stim keeps its default 3-second stability window
2364
+ after bundle delivery. Managed Metro servers report the native bundle response
2365
+ finishing; build-complete output alone does not close an observed request.
2366
+ Without response capture, Stim falls back to the build-complete marker.
2367
+ When Android reports queued JavaScript loading, Stim waits for a device
2368
+ JavaScript log before starting stability, bounded by the bundle timeout.
2369
+ Pending must be observed before the stability window closes. It opts into
2370
+ waiting for ready until 30 seconds after the same completion signal.
2371
+ Repeated pending messages never extend the deadline. Ready can end the wait
2372
+ early; an app error or process exit interrupts it. No ready means readiness
2373
+ not confirmed, not a crash or successful readiness.
2374
+
2375
+ Only exact standalone info/debug messages captured from this app's device log
2376
+ for this platform and current launch are accepted. Stale, other-platform,
2377
+ error-level, and embedded example messages are ignored. Unlabelled shared
2378
+ Metro output cannot identify a platform. If pending is not captured in time,
2379
+ including when device logs are unavailable, the default check applies.
2380
+
2381
+ The app declares readiness; Stim does not inspect a rendered frame. This does
2382
+ not change launched JSON semantics or the need to inspect the expected UI and
2383
+ stim logs --errors. A reload request does not run this launch check.`
2384
+ },
2385
+ verification: {
2386
+ summary: "reproduce the affected behavior, verify the change on the reported device, and retain proof",
2387
+ body: () => `VERIFY THE CHANGE
2388
+
2389
+ For a UI change or bug fix, decide what observable result would prove the task
2390
+ is complete. A successful build, a live process, or an empty log query does
2391
+ not prove that result.
2392
+
2393
+ 1. On the device reported by ios or android, reproduce the affected behavior
2394
+ before editing and capture the baseline with stim logs --errors. If the
2395
+ issue does not reproduce, record what you tried instead of claiming it did.
2396
+ 2. Make the change. JavaScript and TypeScript normally use Fast Refresh;
2397
+ native input changes need another ios or android run. Follow the printed
2398
+ recovery remedy if an error screen remains or the native process exited.
2399
+ 3. Use the full reported device ID with your UI automation tool. Continue in
2400
+ the existing session for that device when one exists. Wait for the expected
2401
+ content or control before inspecting the screen; launch evidence alone
2402
+ does not prove that the first screen has rendered. Bound the wait and report
2403
+ verification as incomplete if the expected state never appears.
2404
+ 4. Repeat the affected interaction and check its expected outcome, then run
2405
+ stim logs --errors. Read the records, not just the exit code. Earlier client
2406
+ errors can remain after Fast Refresh; compare their timestamps with the
2407
+ reproduction and check whether they recur. Do not relaunch just to clear
2408
+ the log window. An empty query does not prove log capture succeeded.
2409
+ 5. Retain a screenshot for a visible result or a short recording for an
2410
+ interaction. Report what you exercised, what you observed, any unresolved
2411
+ errors, and the proof location before stopping the app or removing the
2412
+ worktree. If device access or another prerequisite prevents verification,
2413
+ name the missing check.
2414
+
2415
+ For a change without a UI effect, use the relevant runtime output or test
2416
+ result as proof instead of requiring an unrelated screenshot.`
2417
+ },
2418
+ progress: {
2419
+ summary: "phase lines, the label set, heartbeats and their ~ estimate, what warm, start, stop and remove print",
2420
+ body: () => `PROGRESS ON A LONG RUN
2421
+ Native build progress goes to stderr. In \`--json\` mode, stdout carries only
2422
+ the result payload. Plain \`start\` also prints progress on stdout. Every
2423
+ progress line has the same shape -- two spaces, a label padded to eleven
2424
+ columns, the FACT, and the time the step cost:
2425
+
2426
+ <label> <fact> (<duration>)
2427
+
2428
+ The labels are a closed set, and nothing else is ever printed in that
2429
+ column:
2430
+
2431
+ branch build cache caches carry checkout
2432
+ deps device
2433
+ devices error failed findings fingerprint gems
2434
+ install installs ip.txt lan launch lease
2435
+ lock log
2436
+ logs meaning metro pods port prebuild
2437
+ project readiness ready remedy removed resolved
2438
+ result
2439
+ services
2440
+ setting settings setup state stats stop
2441
+ storage swap verify version workspace
2442
+
2443
+ \`app\` and \`compilation cache\` join them in the stdout block a successful
2444
+ run ends with. When a native build runs, its compilation-cache result is
2445
+ printed once as a \`cache\` progress line as soon as the build returns,
2446
+ including a failed build. It survives a later install or launch failure.
2447
+ The stdout \`compilation cache\` line is only for an artifact-cache hit
2448
+ (compilation did not run). A line states a fact; the reason a fact matters
2449
+ lives in this guide, not in the run output. Both platforms use the
2450
+ same words, so \`build ok (51.8s)\` and
2451
+ \`launch com.example.app (2s)\` read the same on iOS and Android; the
2452
+ artifact name is in the \`--json\` payload.
2453
+
2454
+ A step that costs real time is named and timed, including the step that
2455
+ creates or reconciles the owned device:
2456
+
2457
+ device stim-app-412 (BF2A..) created (2m14s)
2458
+
2459
+ On iOS that step does not wait the boot out. It creates the simulator, asks
2460
+ it to boot, and hands the wait back, so the run fingerprints the native
2461
+ inputs and resolves the build cache while \`simctl bootstatus\` is still
2462
+ running; it joins the boot before it installs anything. The
2463
+ \`device ... booted\` and \`fingerprint ...\` lines each report their own
2464
+ elapsed time, and those two overlap -- adding every line up overstates the
2465
+ run.
2466
+
2467
+ A step that is still running heartbeats every 30 seconds, on the 30-second
2468
+ grid, so the values read 30s, 1m00s, 1m30s and never repeat. A heartbeat
2469
+ reuses its phase's label and column and names what the phase is doing, never
2470
+ the build tool's own last line -- that transcript is in the build log
2471
+ (\`logs --source build\`):
2472
+
2473
+ build still compiling (1m00s of ~3m10s)
2474
+ build still compiling (4m00s, usually ~3m10s)
2475
+ build still compiling (1m00s)
2476
+ pods still installing (1m30s of ~1m40s)
2477
+ build waiting on /w/app-411 (pid 41233, 1m30s elapsed)
2478
+
2479
+ The \`~\` value is an estimate, never a countdown; the third line is a
2480
+ project with no record to estimate from yet. \`guide facts stats\` says where
2481
+ the number comes from.
2482
+
2483
+ The lifecycle commands use the same column. \`worktree warm\` reports
2484
+ copied and kept entries on stderr, with empty stdout:
2485
+
2486
+ carry copied node_modules from /w/main
2487
+ carry complete: 1 ignored entries copied, 0 kept, 0 failed
2488
+
2489
+ \`--refresh\` always prints a \`lock\` line, then its own facts, one per
2490
+ step, before those. A plain warm prints the \`lock\` line only when its copy
2491
+ actually waited for another warm:
2492
+
2493
+ lock acquired (waited 12s for stim worktree warm --refresh pid 41233)
2494
+ checkout janic/wip 2 commits behind origin/janic/wip -> fast-forwarded to 4b81e0c
2495
+ not the default branch (main); worktrees seeded from this copy
2496
+ carry janic/wip's dependencies
2497
+ deps source /w/main: pnpm-lock.yaml unchanged -> skipped
2498
+ pods source /w/main/apps/mobile: ios/Podfile.lock changed -> pod install (1m12s)
2499
+
2500
+ A wait reports how long this caller has waited and names the holder:
2501
+ \`lock waiting 40s for stim worktree warm --refresh (pid 41233)\`.
2502
+
2503
+ \`start\` names the port, the supervisor mode and its pid on one line
2504
+ (\`metro starting on port 8083 (expo-child, supervisor pid 13724)\`),
2505
+ and \`stop\` reports what it released:
2506
+
2507
+ stop supervisor pid 34856
2508
+ stop collector ios pid 45268
2509
+ device shut down stim-e2e-2
2510
+ port released 8084
2511
+
2512
+ \`worktree remove\` reports itself the same way: the branch decision, the
2513
+ owned device, any released device lease, and this workspace's own state
2514
+ directory, each on its own line. Nothing prints on stdout; even the removed
2515
+ path is on stderr:
2516
+
2517
+ branch kept app/412 (Stim did not create it)
2518
+ device parked stim-parked (iPhone 17 26.5) 9c1f (9C1F..)
2519
+ lease released the ios lease on 00008101-000A10913C89001E (it ran until 14:32:10)
2520
+ workspace removed /w/.stim/workspaces/3f9c2a
2521
+ removed /w/app-412
2522
+
2523
+ Removal works with any linked worktree, whether warmed or not, and does not
2524
+ require a Stim registry entry. Git-created branches are kept. An existing
2525
+ Stim ownership record permits deleting a branch only when it has no unique
2526
+ commits; otherwise the command reports why it kept it. On the source checkout,
2527
+ \`worktree remove\` reclaims only the
2528
+ environment -- the same \`device\`, \`lease\` and \`workspace\` lines, ending
2529
+ with a sentence instead of a \`removed\` line, because the checkout itself
2530
+ is never touched: \`Reclaimed the environment; the working tree stays (it
2531
+ is the source checkout).\`
2532
+
2533
+ A GAP BETWEEN HEARTBEATS IS NOT A HANG. Stim runs device tools
2534
+ synchronously, so a long \`simctl\`, \`adb\` or copy call holds the timer
2535
+ until it returns; the next heartbeat then lands on the grid, which is why an
2536
+ elapsed value can jump. Read the phase lines, not the wall clock, before
2537
+ killing a run.`
2538
+ },
2539
+ pool: {
2540
+ summary: "parked and adopted simulators: what park and adoption clear or keep, the model and runtime match",
2541
+ body: () => ` THE SIMULATOR POOL
2542
+ \`worktree remove\` PARKS this workspace's owned simulator instead of
2543
+ deleting it, and the next workspace that wants the same model and runtime
2544
+ ADOPTS it. A simulator that has booted before boots in about 9s; a freshly
2545
+ created one costs about 30s, and \`simctl erase\` puts most of that back, so
2546
+ a parked simulator keeps its app installed and is cleaned in pieces:
2547
+
2548
+ at park shut down, the app's data cleared on disk (Documents,
2549
+ Library, tmp, SystemData: NSUserDefaults, AsyncStorage,
2550
+ SQLite), renamed \`stim-parked (<model> <runtime>) <4 hex>\`
2551
+ at adoption renamed for the adopting workspace, then, inside the boot
2552
+ the run pays anyway, \`simctl privacy reset all\` and
2553
+ \`simctl keychain reset\`; at install, every OTHER app the
2554
+ previous workspace left is uninstalled
2555
+
2556
+ A parked simulator KEEPS its system state: pasteboard, Safari data, photos,
2557
+ contacts, calendars, installed profiles, Simulator settings, app-group
2558
+ containers, and device-level defaults. Isolation covers the app's data, the
2559
+ privacy grants, the keychain and the installed apps -- not a clean system
2560
+ image. Set the bound to 0 when a project needs one.
2561
+
2562
+ Adoption matches the device type AND the runtime EXACTLY: a ticket that asks
2563
+ for an iPad never gets an iPhone, and a request for iOS 18.5 never gets 26.5.
2564
+ No match creates a new simulator, as before. After a runtime upgrade the
2565
+ parked simulators on the old runtime are never adopted; they leave by
2566
+ eviction or \`gc --delete\`.
2567
+
2568
+ The pool targets at most \`pool.iosParkedMax\` simulators (default 3, about
2569
+ 2.5 GB each). Past that the oldest parked one is deleted:
2570
+
2571
+ device parked stim-parked (iPhone 17 26.5) 9c1f (9C1F..)
2572
+ device deleted stim-parked (iPhone 17 26.5) 4b02 (pool over 3)
2573
+
2574
+ A failed or unverifiable deletion keeps its ownership record so \`gc\` can
2575
+ retry it. The reported pool can temporarily exceed the bound rather than
2576
+ orphaning a simulator.
2577
+
2578
+ and an adopting run says so where a plain boot would say \`booted\`:
2579
+
2580
+ device stim-app-412 (iPhone 17 26.5) (9C1F..) adopted (11s)
2581
+
2582
+ That time includes the two resets, so it runs longer than a plain boot.
2583
+ \`stim status\` prints one line while the pool is not empty:
2584
+
2585
+ pool: 2 parked iOS simulators (max 3)
2586
+
2587
+ \`stim gc\` reports the pool, and \`stim gc --delete\` empties every entry
2588
+ it can re-verify:
2589
+
2590
+ Parked simulators (2, 5.1 GB):
2591
+ ios stim-parked (iPhone 17 26.5) 9c1f (9C1F..) iPhone 17 26.5 parked 3d ago 2.6 GB
2592
+ --delete attempts every parked simulator and keeps failures.
2593
+
2594
+ If simulator listing or deletion fails, \`gc --delete\` reports the failure
2595
+ and keeps that entry. It never turns an unverified absence into a dropped
2596
+ ownership record.
2597
+
2598
+ That deletion works even under a redirected \`STIM_HOME\`, where the sweep
2599
+ for unlisted \`stim-\` devices stays refused: a parked record in THIS config
2600
+ proves that simulator is Stim's and parked by this home. \`stop\` never
2601
+ parks -- it shuts the owned simulator down and keeps it assigned. Neither
2602
+ does \`gc --delete\`, which is deleting what it finds.
2603
+
2604
+ ANDROID EMULATOR POOL
2605
+ Android uses the same bounded park/adopt lifecycle, with
2606
+ \`pool.androidParkedMax\` (default 3) or STIM_POOL_ANDROID_PARKED_MAX.
2607
+ A redirected STIM_HOME disables parking unless that environment override
2608
+ is set. Zero disables parking and adoption. \`stop\` keeps the assignment;
2609
+ \`worktree remove\` parks eligible AVDs, and \`gc --delete\` empties the pool.
2610
+
2611
+ Adoption matches the system image, data partition size, and the creation
2612
+ settings from android.avdConfig / android.avdConfigFile. The AVD keeps its
2613
+ original stim-<label> name so its Quick Boot snapshot can survive reuse.
2614
+ Normal desktop boots allow Quick Boot; headless Linux disables snapshots.
2615
+ Incompatible AVDs stay parked until eviction or GC. AVDs created by older
2616
+ versions without a recorded creation configuration are deleted at removal.
2617
+
2618
+ When a fresh AVD's preferred name belongs to another workspace or a parked
2619
+ entry, Stim adds a short suffix instead of taking over that device. Always
2620
+ use the actual device name and serial reported by the platform command.
2621
+
2622
+ Android cleanup happens AFTER boot, before install or launch: \`adb shell
2623
+ pm clear\` clears the adopting app's data while retaining its APK, and
2624
+ other third-party apps are uninstalled. If ADB goes offline or closes the
2625
+ connection, Stim waits for boot readiness and retries cleanup for up to 30
2626
+ seconds, verifying the same owned AVD before each destructive command.
2627
+ Failed cleanup blocks launch and remains pending for a retry. The installed APK's SHA-256 must match the
2628
+ requested artifact before Stim skips installation; a package name or cache
2629
+ key alone is insufficient, including for release builds with swapped JS.
2630
+ If the retained APK has a conflicting signer or version, adoption uninstalls
2631
+ it and retries installation. Adoption stays pending until installation succeeds.
2632
+
2633
+ Parked AVDs retain app data until adoption. System apps, shared storage,
2634
+ accounts and device settings also remain: this is not a factory reset.
2635
+ Set the Android bound to 0 when a project needs a fresh device. Status lists
2636
+ parked Android emulators; GC reports their system image, age and disk size.
2637
+ `
2638
+ },
2639
+ builds: {
2640
+ summary: "optional cache warm-up, build optimizations, fingerprints, .fingerprintignore, install unchanged, runtime state",
2641
+ body: () => `OPTIONAL CACHE WARM-UP FOR REPEATED NATIVE WORK
2642
+ When several native worktrees are coming, build the source checkout once to
2643
+ seed the shared caches before warming the linked worktrees. Skip this extra
2644
+ build for one-off or JavaScript-only work. For local simulator or emulator
2645
+ work, run these commands in the source checkout's app directory:
2646
+
2647
+ stim doctor --platform ios # or: --platform android
2648
+ stim start
2649
+ stim ios # or: stim android
2650
+ stim stop
2651
+
2652
+ Follow the normal ownership and consent rules in guide agent.
2653
+
2654
+ IOS SCHEME SELECTION
2655
+ Pass \`stim ios --scheme "App Staging"\` to select an exact shared Xcode
2656
+ app scheme. Unknown names refuse with the available choices. This is the
2657
+ Xcode scheme, not the app's URL scheme. Combine it with --configuration
2658
+ when choosing both an app scheme and a build configuration.
2659
+
2660
+ Without --scheme, automatic selection is unchanged:
2661
+ Stim keeps a scheme matching the workspace/project name, or the sole non-test
2662
+ scheme. When neither identifies one, it also checks the static top-level name
2663
+ in the app directory's app.json against Xcode's listed schemes. It does not
2664
+ execute app config or choose an arbitrary scheme from an ambiguous list.
2665
+ If selection fails, pass --scheme with an available name, or share the app
2666
+ scheme in Xcode first. See guide errors STIM_NO_SCHEME.
2667
+
2668
+ Explicit schemes have separate artifact keys, shared-build locks, and Xcode
2669
+ build directories. Stim identifies the resulting application from Xcode's
2670
+ resolved build settings, even when the scheme and product names differ;
2671
+ ambiguous products refuse rather than installing another app. Local and
2672
+ configured Stim cache providers use the scheme-specific key. The older Expo
2673
+ buildCacheProvider tier is skipped for explicit schemes because a provider
2674
+ may key only on the fingerprint and return another scheme's app. Omitting
2675
+ --scheme retains the existing cache keys and provider behavior.
2676
+
2677
+ AN ARTIFACT THE DEVICE ALREADY HOLDS IS NOT INSTALLED AGAIN
2678
+ Both platforms store the artifact verbatim, so its hash is its identity.
2679
+ Before installing, Stim hashes the artifact it is about to install and the
2680
+ one the device already has -- \`pm path\` then \`sha256sum\` on Android, the
2681
+ \`simctl get_app_container\` bundle on iOS. Byte-identical means the install
2682
+ is skipped. The phase still reports the cost of proving that identity, but
2683
+ avoids the ~43s a 400MB APK can cost to copy and install over USB.
2684
+
2685
+ install unchanged (emulator-5584 already has this build) (0.4s)
2686
+
2687
+ On iOS the install line names the identity proof separately from the Expo
2688
+ dev-client preference writes, so a slow simulator command is never charged
2689
+ to an install that did not run:
2690
+
2691
+ install unchanged (stim-app already has this build) (0.4s)
2692
+ install dev client prepared (0.9s)
2693
+
2694
+ The skip needs PROOF. A package that is not installed, a split install, an
2695
+ image without \`sha256sum\`, and any adb or simctl failure all read as
2696
+ "cannot determine", and the run installs exactly as it always did. A release
2697
+ run swaps this workspace's JS into a COPY of the artifact, which is a
2698
+ different artifact and is therefore always installed.
2699
+
2700
+ \`--json\` carries installSkipped so a caller can tell a skipped run from an
2701
+ installed one.
2702
+
2703
+ RUNTIME STATE AND BUILD INPUTS
2704
+ Runtime state is stored outside the project tree under
2705
+ $STIM_HOME/workspaces/<project>--<digest>/ (default ~/.stim/workspaces/).
2706
+ The aggregate run counters \`stats\` prints live beside it in
2707
+ $STIM_HOME/stats.json, one bucket per project and platform plus a machine-wide
2708
+ one; nothing per run is kept there.
2709
+ No .gitignore entry is created or required.
2710
+ Native preparation can change project files: expo prebuild generates native
2711
+ sources, and pod install can update Podfile.lock. Review those changes before
2712
+ committing. The shared caches need no project-file edits. The defaults below can be changed
2713
+ with machine or project optimization settings; see \`guide settings\`:
2714
+
2715
+ ios xcodebuild carries COMPILATION_CACHE_ENABLE_CACHING, a shared
2716
+ COMPILATION_CACHE_CAS_PATH and a clang prefix mapping of this
2717
+ workspace's root, so compiled output crosses worktrees with no
2718
+ Podfile post_install block. Xcode 26+ only, and skipped entirely
2719
+ when the project configured ccache (the two defeat each other).
2720
+ android gradlew carries --build-cache, so task outputs cross worktrees with
2721
+ no org.gradle.caching=true in gradle.properties. Debug builds also
2722
+ carry -PreactNativeArchitectures=<target ABI>, using the owned
2723
+ emulator system-image ABI or the physical device's primary ABI.
2724
+ Unknown targets and Release builds stay universal.
2725
+ ccache the same gradlew run carries an absolute
2726
+ CMAKE_C_COMPILER_LAUNCHER / CMAKE_CXX_COMPILER_LAUNCHER plus
2727
+ CCACHE_DIR, CCACHE_BASEDIR, CCACHE_NOHASHDIR, CCACHE_SLOPPINESS
2728
+ and CCACHE_MAXSIZE whenever a ccache binary is on PATH, so the C++
2729
+ objects cross worktrees as well. Nothing is set when ccache is
2730
+ absent, or when the project passes a CMake compiler launcher of
2731
+ its own.
2732
+ start the dev server gets a shared Metro FileStore APPENDED to whatever
2733
+ the project configured -- in-process on a bare project, and through
2734
+ Expo's config override on SDK 54+. Expo SDK 53 and older use their
2735
+ normal Metro cache. Turn it off machine-wide with
2736
+ { "optimizations": { "metroSharedCache": false } } in
2737
+ ~/.stim/config.json; see \`guide settings\`. A project that calls
2738
+ \`sharedCacheStores()\` from @stim-cli/metro in its own metro
2739
+ config also gets the \`cache.provider\` tier behind that store.
2740
+
2741
+ Every Stim Android build uses a CMake staging profile, including the default
2742
+ ccache mode. The first run with this layout configures a separate build tree;
2743
+ changing modes selects another profile. See \`guide settings\` for cleanup.
2744
+
2745
+ Each reports its cache setup. \`stim doctor\` checks missing or stale setup
2746
+ when a build is blocked or slow. It reports what Stim cannot handle itself
2747
+ (a missing dev client, ccache absent from PATH or a .cxx that predates the
2748
+ launcher, a fingerprint no fresh worktree reproduces, a provider on a key this
2749
+ SDK ignores) and settings for builds outside Stim.
2750
+
2751
+ IOS DEBUG ARCHITECTURES
2752
+ Doctor reads Xcode's effective Debug simulator settings for app targets and
2753
+ generated Pods, including xcconfig inheritance and SDK-specific overrides.
2754
+ It warns when ONLY_ACTIVE_ARCH=NO leaves multiple architectures after ARCHS,
2755
+ VALID_ARCHS, and EXCLUDED_ARCHS are combined. A Podfile post_install helper can
2756
+ cause this even when the app target already uses ONLY_ACTIVE_ARCH=YES.
2757
+ Review that override for local Debug builds; preserve intentional Release and
2758
+ distribution settings. Regenerate Pods through the project's normal workflow
2759
+ after changing a helper, then rerun \`stim doctor --platform ios\`.
2760
+ Doctor never evaluates Podfile Ruby, installs Pods, or changes architecture
2761
+ settings, including under --fix. This inspection runs only in doctor, with
2762
+ 30 seconds per Xcode query and 60 seconds total. Missing generated projects,
2763
+ failed metadata queries, and unresolved settings produce an unverified note
2764
+ rather than an architecture warning or a verified clean result.
2765
+
2766
+ WHY ANDROID NEEDS CCACHE, AND WHAT IT COSTS
2767
+ Every AGP CMake task is uncacheable by Gradle, so --build-cache serves not one
2768
+ C++ compile. Without a launcher a fresh worktree recompiles every translation
2769
+ unit, which on a React Native app with native modules is most of a first build.
2770
+ The shared objects live at $STIM_HOME/ccache (default ~/.stim/ccache), which is
2771
+ registered for \`gc\` and prunes itself at CCACHE_MAXSIZE.
2772
+
2773
+ CCACHE_BASEDIR rewrites paths under the workspace root relative to the compile
2774
+ directory and CCACHE_NOHASHDIR keeps the working directory out of the hash;
2775
+ together they are what lets an object built in one worktree match in another.
2776
+ The trade-off is the same class as the iOS CAS one: an object reused from
2777
+ worktree A carries A's directory as its DWARF comp_dir, so a debugger stepping
2778
+ into reused C++ resolves sources against that path.
2779
+
2780
+ When Stim supplies ccache, its Gradle init script defaults Android app and
2781
+ library CMake builds to CMAKE_DISABLE_PRECOMPILE_HEADERS=ON. PCH inputs can
2782
+ retain a previous worktree's paths even with upstream timestamp fixes, causing
2783
+ the header and its consuming objects to miss. Compiling ordinary headers
2784
+ instead favors reuse across worktrees at the cost of a slower cold C++ build.
2785
+ This does not edit dependency sources or change iOS builds. Without Stim's
2786
+ ccache setup, auto PCH behavior is unchanged. Explicit optimizations.android.pch
2787
+ on/off applies with any compiler cache selection. A module with an explicit
2788
+ CMAKE_DISABLE_PRECOMPILE_HEADERS argument in its default config, build types,
2789
+ or product flavors keeps that choice; CMake target-level PCH overrides also
2790
+ take precedence. Direct Gradle builds do not receive Stim's init script.
2791
+
2792
+ EXPERIMENTAL ANDROID CAS
2793
+ optimizations.android.compilerCache="cas" with android.casToolchain under the
2794
+ same optimizations object selects a private Apple Clang toolchain manifest
2795
+ on macOS. STIM_ANDROID_CAS_TOOLCHAIN also selects CAS in auto mode. It retains
2796
+ PCH and replaces the ccache setup for that invocation.
2797
+ Compiler results live under $STIM_HOME/android-cas/<toolchain-id>; APK cache
2798
+ keys include that ID. This is a development prototype requiring a compatible
2799
+ linker and NDK copy, not an automatically installed backend. Because the
2800
+ manifest lives outside the repository it can rot: when the setting holds any
2801
+ value that is not an absolute path, or the manifest it names is missing or
2802
+ unreadable, the build warns once naming the setting and the file it came from,
2803
+ then compiles through the cache the selection leaves -- ccache, or none when
2804
+ compilerCache is none. It never refuses. See
2805
+ https://stim.appandflow.com/docs/android-cas for setup, evidence, and limits.
2806
+ Compiler/PCH modes have separate generated directories under each module's
2807
+ .cxx/stim-<profile> (or custom staging root). Switching modes in Stim selects
2808
+ the matching directory; direct Gradle builds keep their own configuration.
2809
+ Generated CAS directories still depend on Stim's environment and adapter paths.
2810
+
2811
+ For older, unprofiled builds, the launcher persists in the project. AGP writes
2812
+ it into each
2813
+ .cxx/**/CMakeCache.txt on the first configure, so a plain \`./gradlew\` in that
2814
+ checkout also compiles through ccache -- and a .cxx configured BEFORE the
2815
+ variables existed can keep compiling without them until it is cleared once.
2816
+ \`stim doctor\` reports stale configurations in this checkout's app and installed
2817
+ native modules. Stop native builds, then run \`stim doctor --fix --platform android\`:
2818
+ it removes only affected ignored, untracked legacy .cxx configurations and
2819
+ reruns the diagnostics. Managed profiles and disabled compiler caches are
2820
+ left alone. A cas selection Stim cannot use resolves to ccache, so that
2821
+ checkout's legacy configurations become eligible for the same repair. A config
2822
+ the repair cannot read repairs nothing. The next build recreates legacy output. It refuses directories
2823
+ outside the checkout and configured custom launchers. Shared ccache entries and
2824
+ source files are preserved. Its cache-lock check cannot detect --no-build-cache,
2825
+ release-swap fallback, or direct Gradle builds; stop all native builds and keep
2826
+ them stopped until repair finishes.
2827
+ Run it before copying the checkout into worktrees.
2828
+
2829
+ THE BUILD CACHE HAS THREE LEVELS
2830
+ 1. Stim's own, on this machine: a directory under ~/.stim shared by
2831
+ every worktree, keyed on the @expo/fingerprint hash of the native inputs.
2832
+ Free, instant, offline, and the only level a project without any
2833
+ provider has.
2834
+ 2. The project's own cache provider, on ANY project including bare React
2835
+ Native: \`cache.provider\` in the settings, a module implementing the
2836
+ @stim-cli/cache contract (see \`guide settings\`). Consulted only when
2837
+ level one misses, and its hit is stored into level one before install.
2838
+ The same contract serves the Metro transform cache.
2839
+ 3. On an EXPO project only, the provider the project ALREADY configured for
2840
+ Expo (\`expo.buildCacheProvider\` -- "eas", or a module of its own).
2841
+ Consulted only when levels one and two miss, bounded so a slow or expired
2842
+ remote cannot stall the loop, and a hit is copied into level one on the
2843
+ way past so the next workspace on this machine gets it for free. After a
2844
+ build, the result is stored locally AND handed to both providers, which
2845
+ run independently. An ABI-targeted Android Debug build skips this Expo
2846
+ tier because its run-options contract cannot distinguish ABIs; levels one
2847
+ and two remain ABI-keyed and active.
2848
+
2849
+ Stim never configures a provider and never suggests changing one: a
2850
+ project without one is a perfectly ordinary local-only project (doctor does
2851
+ not ask for one either -- a provider only serves builds run OUTSIDE Stim).
2852
+
2853
+ A provider that fails to load, times out, or errors produces ONE note per
2854
+ failure class and the run continues on the local cache. \`gc\` reports,
2855
+ trims, and clears local caches only: the provider contract has no delete
2856
+ operation, so no local command can remove data a team or CI system shares.
2857
+
2858
+ A MISS explains itself when it can. When this workspace's previous build
2859
+ stored its fingerprint sources beside the cache entry, the fingerprint line
2860
+ gains " -- N sources changed: <up to three paths>", and the full list
2861
+ (capped at 20 names) lands in the build log as a fingerprint_diff record.
2862
+
2863
+ THE KEY CAN MOVE MID-RUN, and the run says so in two facts rather than two
2864
+ explanations. \`expo prebuild\` and \`pod install\` rewrite fingerprinted
2865
+ files while they work, so the run fingerprints again afterwards:
2866
+
2867
+ fingerprint dcbd8d.. -> 6564e2.. (after prebuild, pod install)
2868
+ cache hit 6564e2.. (post-prebuild/pod install key)
2869
+
2870
+ The first line means the artifact, the \`lastBuild\` record and any remote
2871
+ upload are stored under the SECOND hash -- the one the next run in this tree
2872
+ computes, and therefore the one it looks up. The second line only appears on
2873
+ a tree that was COLD: the first lookup ran on the pre-prebuild hash and
2874
+ could not find an entry another workspace had already stored under the
2875
+ post-prebuild one, so re-resolving under the moved key installs it instead
2876
+ of compiling beside it. No second line means nothing was found there and the
2877
+ run compiles.
2878
+
2879
+ If the iOS fingerprint after prebuild or pod install is unavailable, Stim
2880
+ installs the build but skips local storage and remote uploads. fingerprint
2881
+ and cacheKey are null in the result and lastBuild; the old key is not reused.
2882
+ Android does the same if its post-Gradle fingerprint cannot be computed.
2883
+ These null fields mean unavailable cache information, not an install failure.
2884
+
2885
+ WHAT MAKES THE CACHE ACTUALLY HIT: .FINGERPRINTIGNORE
2886
+ Every entry is keyed on what the tree hashes, so two workspaces share an
2887
+ entry only when they hash alike. A file that changes without changing the
2888
+ BUILD is what breaks that, and it fails silently -- a cache that never hits
2889
+ looks exactly like a cache that is not there.
2890
+
2891
+ Stim ignores two paths a fresh checkout never has and no native build reads:
2892
+ android/local.properties and android/.idea. A project does not repeat those.
2893
+ Everything else is the project's call, including a lockfile whose checksums
2894
+ embed machine paths -- ignoring a path any project might read turns a slow
2895
+ build into a wrong one.
2896
+
2897
+ A linked native library (a \`link:\` or \`file:\` dependency, or a workspace
2898
+ symlink) brings its checkout's .git into a directory the fingerprint hashes
2899
+ whole; Git rewrites that metadata on every commit, checkout, or worktree, so
2900
+ workspaces rarely agree. \`stim doctor\` names the entries to ignore when the
2901
+ native build does not read Git state: the .git path as the fingerprint sees
2902
+ it and its /**/* form, which is what skips a .git directory's contents.
2903
+ Ignore those entries only, never the package.
2904
+
2905
+ \`.fingerprintignore\` at the project root (same syntax as .gitignore) is the
2906
+ answer. Put in it only what genuinely cannot change the native build: a
2907
+ generated report, a local env file, a lockfile whose checksums embed absolute
2908
+ machine paths (\`ios/Podfile.lock\` is the usual one -- pod checksums can bake
2909
+ in a machine path, and \`pod install\` rewrites it on a plain re-install).
2910
+ Never ignore a real native input -- a Podfile, a gradle file, the app config
2911
+ -- to force a hit: that trades a slow build for a wrong one.
2912
+
2913
+ \`stim doctor\` measures this directly rather than reading the file: it
2914
+ fingerprints HEAD in a temporary clean worktree, compares, and reports a
2915
+ mismatch naming the differing sources. Untracked, non-gitignored files under
2916
+ ios/ or android/ count too -- they are hashed like any other source, so a
2917
+ stray file there moves the key on your machine and nowhere else.`
2918
+ },
2919
+ concurrency: {
2920
+ summary: "waiting on another workspace's build, --no-build-cache, concurrency.maxBuilds and maxDevices",
2921
+ body: () => `ONE COMPILE PER FINGERPRINT, ACROSS EVERY WORKSPACE
2922
+ The cache makes the SECOND workspace on a commit free -- but only once the
2923
+ first has finished. Three agents starting within the same minute all miss it,
2924
+ and without this all three compile the same app at once, fighting for the
2925
+ same cores. So when both cache levels miss, the run takes a LOCK on
2926
+ <fingerprint, platform> (a directory under ~/.stim/build-locks). Exactly
2927
+ one workspace compiles; the others print
2928
+
2929
+ build /w/app-412 is already building a3f9b1.. (pid 41233) -- tail ...
2930
+ build waiting on /w/app-412 (pid 41233, 4m elapsed) -- tail ...
2931
+ build waited 12m41s for /w/app-412's build -> installed from cache
2932
+
2933
+ and install the artifact the builder stored. They report cacheHit: "local"
2934
+ plus waitedForBuild: { pid, ms }.
2935
+
2936
+ Native preparation can move the builder's cache key. A released lock with no
2937
+ artifact at the original key does not prove the build failed. The waiter
2938
+ rechecks after its own native preparation; a matching post-preparation cache
2939
+ hit retains waitedForBuild. Artifacts remain stored only under their final
2940
+ fingerprint, never under the old key.
2941
+
2942
+ Nothing can deadlock on it. The lock records the holder's process IDENTITY,
2943
+ so a builder that crashes, is killed, or whose build simply fails frees it:
2944
+ the waiters see a released lock with no artifact, and one of them takes over
2945
+ and builds. A recycled pid reads as a gone builder, and a builder busy in a
2946
+ long synchronous tool call reads as live -- age is never a reason to take a
2947
+ lock. The one state that cannot be decided (a truncated claim, an identity
2948
+ token that does not decode) is STIM_CLAIM_REFUSED: Stim names the claim and
2949
+ the command that removes it rather than guessing. A process whose own identity
2950
+ cannot be captured takes no lock and no slot, and refuses with
2951
+ STIM_CLAIM_UNAVAILABLE rather than building unprotected. The
2952
+ other waiters keep waiting for that holder. All replacement builders share
2953
+ one ~90-minute deadline, including lock acquisition between waits; reaching
2954
+ it returns STIM_BUILD_WAIT_TIMEOUT naming the current holder and lock.
2955
+
2956
+ --no-build-cache looks nothing up -- not the local cache, not either
2957
+ provider -- and takes no lock and never waits, because it asked for a compile
2958
+ of its own. It still STORES the result, over the entry it was told not to
2959
+ trust, and still uploads it. Use it when a cached artifact is suspect; the
2960
+ --json payload reports cacheSkipped: true so a caller can tell that run apart
2961
+ from a plain miss.
2962
+
2963
+ OPT-IN CONCURRENCY LIMITS (UNLIMITED BY DEFAULT)
2964
+ Stim imposes NO limits of its own: unset is exactly the behaviour above --
2965
+ every build compiles, every device boots. When a machine cannot host as many
2966
+ parallel builds or booted simulators as there are agents, two MACHINE-level
2967
+ caps rein it in. They live under a top-level \`concurrency\` key in
2968
+ ~/.stim/config.json (not per-project -- the resource being shared is the
2969
+ machine's), and STIM_MAX_BUILDS / STIM_MAX_DEVICES override the file.
2970
+ Absent, 0, or any non-positive value means NO enforcement.
2971
+
2972
+ concurrency.maxBuilds how many builds COMPILE at once. It is a semaphore
2973
+ of N slots (~/.stim/build-slots). A run takes a
2974
+ slot AFTER the single-flight lock -- a workspace
2975
+ waiting to install another's identical artifact
2976
+ never burns a slot -- so it caps distinct compiles,
2977
+ not waiters. A full slate WAITS (this is batch work),
2978
+ printing the same kind of progress line the build
2979
+ lock does, and a dead builder frees its slot within
2980
+ a poll (process identity, like the lock).
2981
+
2982
+ concurrency.maxDevices how many Stim-owned devices are BOOTED at once. Checked
2983
+ at device time, before a sim is created or booted.
2984
+ At the cap, a NEW device is REFUSED with
2985
+ STIM_AT_CAPACITY (interactive-shaped: it does not
2986
+ queue). A workspace whose own device is already
2987
+ booted is never refused.
2988
+ See \`guide errors STIM_AT_CAPACITY\`.
2989
+
2990
+ \`stim doctor\` prints one note echoing the caps and the current live count,
2991
+ but ONLY when a cap is set. \`stim gc\` reports stale build slots the way it
2992
+ reports stale build locks, and \`gc --delete\` clears them. There is no
2993
+ \`stim config\` command: set these by editing ~/.stim/config.json or via
2994
+ the two env vars (see \`guide settings\`).`
2995
+ },
2996
+ options: {
2997
+ summary: "every flag per command, Android variants and flavors, the per-run simulator model, runtime and system image",
2998
+ body: () => `THE OPTION SURFACE, IN FULL
2999
+ start --json --wait <seconds> --remote --reset-cache
3000
+ ios --json --no-metro-check --no-build-cache --scheme <name> --configuration <name> --device-type <name> --runtime <version> --device [udid] --wait <seconds> --no-wait --remote <proxy|eas>
3001
+ android --json --no-metro-check --no-build-cache --variant <name> --system-image <id> --device [serial] --wait <seconds> --no-wait --remote <proxy|eas>
3002
+ reload [ios|android] --json
3003
+ device lock <ios|android> [id] --for <duration> --wait <seconds> --json;
3004
+ unlock [ios|android] --json
3005
+ logs --source --level --since --grep --tail --follow --errors --json
3006
+ stop --json
3007
+ status --json (already machine-wide)
3008
+ stats --json (this project and machine-wide)
3009
+ doctor --json --fix --platform <ios|android>
3010
+ (--platform keeps shared checks and filters native findings)
3011
+ gc --delete --older-than <days> --cache <name|all>
3012
+ worktree warm --refresh; remove [path] --force
3013
+
3014
+ That is the whole surface today, and it is deliberately small. It can grow
3015
+ when a flag is genuinely the best answer -- but project-specific knowledge
3016
+ (release builds, variants, device targets) belongs in a script the repo owns,
3017
+ not in a flag here.
3018
+
3019
+ \`stim worktree warm\` takes one flag, \`--refresh\`. Run it anywhere inside
3020
+ the current linked worktree to copy missing ignored entries from its source
3021
+ checkout, regardless of either branch's HEAD. Both roots must be registered
3022
+ worktrees of the same Git repository; the source checkout must be available.
3023
+ Running it in the source checkout refuses.
3024
+
3025
+ \`--refresh\` WRITES TO THE SOURCE CHECKOUT before the copy, which is why it
3026
+ is opt-in: it fetches, fast-forwards whatever branch is checked out there to
3027
+ its \`@{upstream}\`, and installs what the new commits moved. It refuses a
3028
+ source checkout it cannot move -- uncommitted changes to tracked files or a
3029
+ rebase or merge in progress (STIM_MAIN_DIRTY), a detached HEAD
3030
+ (STIM_MAIN_DETACHED), or a branch that is both ahead and behind
3031
+ (STIM_MAIN_DIVERGED) -- and each refusal names the git line that clears it.
3032
+ Untracked files are not a reason to refuse. A branch with no upstream is
3033
+ left where it is. A fetch that fails is a fact, not a refusal: the run
3034
+ continues on the local state. It never switches branches, never merges, and
3035
+ never resets; fast-forwarding a feature branch is safe, so it only WARNS
3036
+ that the copy will carry that branch's dependencies. The default branch it
3037
+ compares against is \`worktree.defaultBranch\` in the repository-root
3038
+ .stim.json, else \`git symbolic-ref --short refs/remotes/origin/HEAD\`;
3039
+ when neither answers it says so and makes no warning.
3040
+
3041
+ Dependencies install where the lockfile is, which in a monorepo is the
3042
+ repository root, when the lockfile moved or nothing is installed. Pods run
3043
+ for the app the command was invoked from, and only that app, when its
3044
+ ios/Podfile.lock moved or ios/Pods/Manifest.lock does not match it. Each
3045
+ completed or skipped deps and pods step names its source directory and
3046
+ reason. A failed install refuses with STIM_DEPS_FAILED and nothing is copied.
3047
+
3048
+ An install that did not finish is remembered, and a PLAIN warm reads that
3049
+ before it copies. The refresh records the completed install of the lockfile it
3050
+ read under ~/.stim/warm-installs; a plain warm reads that record after it
3051
+ takes its claim, and refuses with STIM_DEPS_INCOMPLETE, copying nothing, when
3052
+ the unfinished install is of the lockfile as it stands NOW -- otherwise the
3053
+ copy carries a partial node_modules and exits 0, and only the refresh's own
3054
+ terminal ever said the install failed. A record of a different lockfile does
3055
+ not block a copy, and a repository with no record copies exactly as it did
3056
+ before the record existed. The remedy is \`--refresh\`, which reinstalls rather
3057
+ than skipping on the same record, so a refresh killed mid-install -- which
3058
+ writes no failure anywhere -- is recovered the same way a failed one is.
3059
+
3060
+ One lock per repository serialises this, whether or not you pass the flag:
3061
+ \`--refresh\` holds it exclusively, and every copy holds it shared, so a copy
3062
+ can never read a node_modules a refresh is rewriting. Two plain warms of the
3063
+ same repository run together. The lock is keyed on the repository root, so
3064
+ two apps of one monorepo share it. It is the same ownership claim the build
3065
+ locks take: the holder's process IDENTITY decides, so a holder that dies frees
3066
+ it, a recycled pid cannot keep it, and a refresh whose install runs in a
3067
+ spawned process group holds it while that group lives. A wait prints the
3068
+ holder every 30s and gives up with STIM_LOCK_TIMEOUT; a claim Stim cannot
3069
+ resolve refuses with STIM_CLAIM_REFUSED and names the removal rather than
3070
+ waiting on it or removing it.
3071
+ A plain warm that cannot record a claim at all -- an unwritable STIM_HOME, a
3072
+ STIM_HOME on a read-only mount or under a dangling symlink, a STIM_HOME that
3073
+ is a file, or no \`unique-pid\` build for this platform
3074
+ (STIM_CLAIM_UNAVAILABLE) -- says so in one dim line and copies without it,
3075
+ exactly as it did before the lock existed, because a copy only reads.
3076
+ \`--refresh\` refuses instead, because it writes. Before that unsynchronised
3077
+ copy starts, warm still READS the claim set, which needs no claim of its own:
3078
+ if a refresh holds this repository, or a claim in it cannot be resolved, the
3079
+ copy refuses rather than reading a tree that is being rewritten. That read is
3080
+ not a lock, so a refresh that starts after it is not covered; it closes the
3081
+ window in which one is already installing. Closing the rest needs a claim,
3082
+ which is the one thing that state cannot record, so the residual is
3083
+ documented rather than fixed (appandflow/stim#696), as is the
3084
+ \`acquired (waited 0s ...)\` a contended-but-fast wait prints.
3085
+
3086
+ Wait for warm to exit successfully (exit code 0) before running start,
3087
+ ios, android, or a dependency install in that worktree. If a shell tool
3088
+ yields a running session or job ID, poll or wait for completion; empty
3089
+ stdout or a returned job ID does not mean the copy has finished. Concurrent
3090
+ writes to the destination are unsafe: existing entries are checked before
3091
+ copying, not during it. Do not edit files, run another warm, or start any
3092
+ other writer in that worktree until warm finishes. Concurrent files can be
3093
+ overwritten or removed.
3094
+
3095
+ Warm copies installed dependencies, Pods, native output, and other ignored
3096
+ paths eligible under the source checkout's Git ignore rules, including .env
3097
+ and local configuration. The source's nonempty
3098
+ .worktreeexclude replaces its resolved worktree.exclude setting. Nested
3099
+ registered worktrees, .DerivedData, and android/build/generated/autolinking
3100
+ caches are excluded, including in nested apps. Gradle regenerates autolinking
3101
+ for the destination checkout on its next build. Warm also skips paths
3102
+ overlapping a nested destination worktree or below a symlink ancestor.
3103
+
3104
+ Warm copies directly into the destination, without intermediate staging.
3105
+ Keep the source checkout and the linked worktree on the same volume to
3106
+ retain copy-on-write cloning where supported. Cross-volume copies require
3107
+ full file data; doctor reports that cost. STIM_TMPDIR and machine tempDir do
3108
+ not affect warming.
3109
+
3110
+ Existing entries, including dangling symlinks, stay untouched. An existing
3111
+ ignored directory such as node_modules is skipped WHOLE; missing children are not
3112
+ filled in. Warm does not copy tracked changes, switch branches, or build,
3113
+ and it installs dependencies only under \`--refresh\`, in the source checkout. stdout stays empty; stderr reports copied, kept,
3114
+ and failed entry counts. A copy failure exits 1 and reports incomplete;
3115
+ files copied before a failure remain. Inspect the named failed entry
3116
+ before retrying: a partially copied directory will be kept on the retry.
3117
+ A completed copy is not proof that dependencies match this branch. Follow
3118
+ any lockfile remedies before building, and install missing dependencies
3119
+ with the project's package manager when the source has none to copy.
3120
+
3121
+ \`android --variant <name>\` selects the gradle variant to assemble and
3122
+ install on a project with product flavors -- \`--variant productionDebug\`
3123
+ runs \`assembleProductionDebug\`, finds the APK in apk/production/debug/ and
3124
+ keys the build cache on the variant. It overrides the android.variant
3125
+ setting (see \`guide settings\`), which is the app-level default; unset,
3126
+ the plain \`assembleDebug\` flow is unchanged. The --json payload's
3127
+ \`variant\` field reports what was built (null for the default).
3128
+ When neither is set and android/app/build.gradle declares more than one
3129
+ product flavor, \`android\` refuses BEFORE gradle runs and names the debug
3130
+ variants to choose from, because \`assembleDebug\` would build every flavor
3131
+ and leave nothing to pick from. That parse is best-effort: flavors built
3132
+ from a variable, a loop, or an applied script are not detected, and such a
3133
+ project builds as before.
3134
+
3135
+ \`ios --device-type <name>\` and \`ios --runtime <version>\` choose the
3136
+ MODEL and the iOS version of the simulator this workspace owns --
3137
+ \`--device-type "iPad Pro 13-inch (M4)" --runtime 18.5\` is how a ticket that
3138
+ says "happens on iPad on iOS 18.5" gets reproduced without writing a
3139
+ \`.stim.json\`. \`android --system-image <id>\` is the Android half, taking
3140
+ the sdkmanager package id
3141
+ ("system-images;android-36;google_apis;arm64-v8a"). Each overrides its
3142
+ setting (ios.deviceType, ios.runtime, android.systemImage) for that one
3143
+ invocation, exactly as \`--configuration\` overrides ios.configuration.
3144
+
3145
+ A name that is not INSTALLED on this machine refuses with STIM_BAD_ARG
3146
+ before anything is created, and the message lists the installed names, so a
3147
+ wrong guess is one command, not a created simulator. A blank value is the
3148
+ same refusal.
3149
+
3150
+ What counts as installed for \`--device-type\` is what an installed RUNTIME
3151
+ can create, not what \`xcrun simctl list devicetypes\` prints: that table
3152
+ also names watchOS, tvOS and visionOS models, and older iPhones no current
3153
+ runtime supports, none of which \`simctl create\` would accept. So the
3154
+ refusal lists the models the installed runtimes offer -- narrowed to the one
3155
+ runtime when \`--runtime\` also resolved, which is what catches a pair like
3156
+ \`--device-type "iPhone 8" --runtime 26.5\` that each half would pass alone.
3157
+ \`--runtime\` takes a version (\`26.5\`) or a runtime's full name
3158
+ (\`iOS 26.5\`), exactly; no prefix or suffix matches.
3159
+
3160
+ These flags describe a device that does not exist yet. When this workspace
3161
+ ALREADY owns a simulator and \`--device-type\` names a different model,
3162
+ Stim refuses rather than silently booting the wrong one: reap the current
3163
+ sim with \`stim worktree remove\` (or \`stim gc --delete\`), then run
3164
+ \`stim ios\` again to create the requested one. \`--runtime\` and
3165
+ \`--system-image\` apply at creation only, so an existing device keeps the
3166
+ version it was made with. The --json payload reports what was actually
3167
+ used: \`deviceType\` and \`runtime\` on iOS, \`systemImage\` on Android,
3168
+ read from the device itself, so a settings-driven run reports them too.`
3169
+ },
3170
+ devices: {
3171
+ summary: "ios --device and android --device on a phone: what the run skips, signing, the LAN wiring, the collector",
3172
+ body: () => ` \`android --device [serial]\` installs and launches on a physical device
3173
+ connected to this machine instead of this workspace's owned emulator. With
3174
+ no serial it takes the first device it can lease (\`guide lifecycle lease\`).
3175
+ It cannot be combined with --remote.
3176
+
3177
+ A \`--device\` run LEASES the device from just after the build until it
3178
+ exits, so a second workspace cannot install over it mid-run
3179
+ (\`guide lifecycle lease\`).
3180
+
3181
+ The build, the fingerprint, the build cache and the Metro port gate are
3182
+ unchanged. What is skipped is everything that manages an owned device:
3183
+ no capacity check, no AVD creation, no boot wait, and no owned-device
3184
+ registry entry. The app is pointed at
3185
+ localhost:<port>, which the adb reverse serves, instead of the emulator's
3186
+ 10.0.2.2. Stim never creates, boots, shuts down, or deletes hardware.
3187
+
3188
+ \`ios --device [udid]\` selects a connected iPhone, the same way
3189
+ \`android --device\` selects a connected phone: with no UDID it takes the
3190
+ first device it can lease (\`guide lifecycle lease\`), and an iPhone
3191
+ that is unpaired or has Developer Mode off is refused with the fix. It
3192
+ cannot be combined with --remote, and it never creates, boots, or deletes
3193
+ hardware -- there is no capacity check, no simulator creation, no boot wait,
3194
+ and no owned-device registry entry. Like
3195
+ \`android --device\`, it leases the phone for the run
3196
+ (\`guide lifecycle lease\`).
3197
+
3198
+ \`stop\` releases this workspace's leases and stops its log collectors.
3199
+ On a physical iPhone that also closes the app, because its collector owns
3200
+ the devicectl launch session. \`gc --delete\` removes expired lease files;
3201
+ neither command shuts down the phone or uninstalls the app.
3202
+
3203
+ A device build is LOCAL-TIER ONLY. Its cache key is
3204
+ \`<fingerprint>-<configuration>-device\`, so a device app can never collide
3205
+ with the simulator one, and neither the build-cache provider nor the Expo
3206
+ remote cache is read or written on a \`--device\` run: every entry they hold
3207
+ is keyed for the simulator, so consulting them would either install a
3208
+ simulator slice on a phone or publish an iphoneos app under a key simulator
3209
+ builds resolve.
3210
+
3211
+ THE BUILD is the \`iphoneos\` slice for the selected phone -- \`-sdk
3212
+ iphoneos\`, the project's own signing settings, no signing flags on the argv.
3213
+ It is installed with \`devicectl device install app\` and launched with
3214
+ \`devicectl device process launch\`. Every device install is SIGNED, Debug
3215
+ included, so the signing gate runs before it: the app's own
3216
+ embedded.mobileprovision must be unexpired and must name this phone, and when
3217
+ Stim modifies the bundle the identity that profile names must be in this
3218
+ machine's keychain (see \`guide errors STIM_NO_PROFILE\`). A gate refusal on
3219
+ a CACHED app falls back to a full build; on a freshly built one it exits on
3220
+ its own code, because building again would produce the same app.
3221
+
3222
+ DEBUG REACHES METRO OVER THE LAN, because a phone shares no loopback with
3223
+ the host and USB carries no reverse forward. Stim picks a non-internal IPv4
3224
+ address (en0 first, RN's own order from react-native-xcode.sh), gates it as
3225
+ this workspace's Metro, and then wires the app to it: an expo-dev-client app
3226
+ through the deep link, passed to devicectl as \`--payload-url\` and followed
3227
+ by \`-- -EXDevMenuShowsAtLaunch 0 -EXDevMenuShowFloatingActionButton 0\`,
3228
+ which is how a phone gets what a simulator gets from a defaults write, and a
3229
+ bare app by writing \`<addr>:<port>\` into the app bundle's ip.txt --
3230
+ RCTBundleURLProvider's own mechanism, which honours a colon-bearing value
3231
+ verbatim and never consults the compiled RCT_METRO_PORT. Stim never sets
3232
+ that define: it would put the reserved port into a compiled input and fork
3233
+ the device cache per workspace.
3234
+ ip.txt is a sealed resource, so Stim writes it on a COPY of the artifact and
3235
+ re-seals that copy with \`codesign\`. THE ORDER IS STORE, THEN COPY, THEN
3236
+ MUTATE: the cache entry stays the pristine, shareable artifact, and the
3237
+ per-run address lives only in the copy that is installed and then deleted.
3238
+
3239
+ A RELEASE device run builds fresh every time for now. A cached Release app
3240
+ carries its BUILDER's JS, and the device JS swap (which has to re-seal what
3241
+ it injects) lands with phase 6 of appandflow/stim#178, so the cache hit is
3242
+ refused rather than installed with someone else's JavaScript.
3243
+
3244
+ THE DEVICE LOG COLLECTOR IS THE LAUNCH. \`devicectl\` connects an app's
3245
+ streams only when it is the process that starts the app, so the collector
3246
+ runs \`devicectl device process launch --console --terminate-existing\`
3247
+ itself rather than attaching after the fact the way the simulator collector
3248
+ does. The run then reads the app's pid from the phone's own process list
3249
+ (\`devicectl device info processes\`), because \`--console\` blocks until
3250
+ the app exits and its \`--json-output\` is written only then. That device pid
3251
+ is also what proves a RELEASE launch: a device pid means nothing to the host,
3252
+ so nothing on the host is ever signalled with it. Otherwise the collector is
3253
+ the same process as every other collector -- one per platform per workspace,
3254
+ titled with its --root, killed and replaced on the next \`ios\` run whose pid
3255
+ still proves it is this workspace's, and reaped by \`stop\`. One difference in
3256
+ the ordering: a device run stops the PREVIOUS collector before it installs,
3257
+ not while starting its own, because an upgrade install terminates the running
3258
+ app -- which would end that collector's devicectl non-zero and record a
3259
+ failure for a normal reinstall. Unplugging the phone ends devicectl, which
3260
+ ends the collector: it removes its own registration and exits. A separately
3261
+ held \`device lock\` lease survives collector exit until released or expired;
3262
+ \`gc --delete\` can remove its expired lease file. Whether it closes with collector_stopped or
3263
+ collector_failed follows devicectl's exit code, which no one has watched a
3264
+ cable-pull produce yet. See \`guide logs\` for what the device stream can
3265
+ and cannot carry, and appandflow/stim#179.
3266
+
3267
+ THE APP RUNS FOR AS LONG AS THE COLLECTOR DOES. Because the collector is the
3268
+ launch, the app is attached to it: \`stop\` (and any other end of that
3269
+ collector -- a crash, the host sleeping, the cable coming out) closes the app
3270
+ on the phone. It stays INSTALLED. Collector exit removes the collector's
3271
+ registration, not a separately held device lease; \`stop\` also releases
3272
+ this workspace's leases. See \`guide cleanup collector\`.
3273
+
3274
+ THERE IS NO INSTALL SKIP ON A PHONE. The simulator path skips the install
3275
+ when the device already holds the same bundle byte for byte, which it proves
3276
+ by hashing the installed container; there is no cheap equivalent through
3277
+ devicectl, so a device run always installs. It is an upgrade install: the
3278
+ app's data, and the Local Network permission the phone granted it, survive.`
3279
+ },
3280
+ lease: {
3281
+ summary: "run-scoped leases, --wait and --no-wait, device lock and unlock, which phone an id-less --device picks",
3282
+ body: () => `THE DEVICE LEASE ON A \`--device\` RUN
3283
+ A physical device is shared, so a \`--device\` run takes a lease on it. The
3284
+ lease step sits AFTER the build (a build touches no device) and before the
3285
+ install, and the run releases what it took when the command exits: on
3286
+ success, on a failure, on an exception, and on a Ctrl-C or a SIGTERM, which
3287
+ it catches to give the device back before exiting 130/143. Only SIGKILL
3288
+ escapes that, and then the lease expires on its own. Before each device step
3289
+ -- install, launch, the log collector, verification -- the run raises the
3290
+ expiry to now plus the larger of 60 seconds and that step's own upper bound,
3291
+ because a child process is synchronous and no timer can tick during an
3292
+ install. A run killed with SIGKILL therefore leaves the device leased for at
3293
+ most the current step's bound, never less than 60 seconds.
3294
+
3295
+ If nobody holds the device, the run takes a lease of its own and gives it
3296
+ back at exit. If another workspace holds it, the run WAITS: \`--wait
3297
+ <seconds>\` (default 60) polls every 2 seconds, prints a waiting line to
3298
+ stderr at once and then every 30 seconds with the holder, the device and the
3299
+ holder's expiry, and refuses with STIM_DEVICE_BUSY when it runs out. It
3300
+ keeps waiting past the holder's own expiry, because the holder can release
3301
+ early. \`--wait 0\` refuses at once. \`--no-wait\` changes only that case: the
3302
+ run proceeds with NO lease and prints one warning naming the holder and its
3303
+ expiry, plus what the install costs: the same app id means it TERMINATES the
3304
+ holder's running app, a different one means the launch only backgrounds it,
3305
+ and when Stim cannot read the holder's app id it says so rather than
3306
+ guessing. A free device is leased as usual under \`--no-wait\`. The two flags
3307
+ together are STIM_BAD_ARG, and so is either one without \`--device\`, because
3308
+ an owned simulator or emulator has no contention.
3309
+
3310
+ A successful \`--device\` run reports \`lease: { kind, expiresAt }\` in its
3311
+ \`--json\`; a run that proceeded without one, or lost one after the install,
3312
+ reports \`lease: null\`. \`stim status\` lists every lease file on the
3313
+ machine, and \`stop\` releases the ones this workspace holds.
3314
+
3315
+ HOLDING A DEVICE ACROSS RUNS
3316
+ A run-scoped lease dies with the command, which is not enough for a
3317
+ device-tool session: the next workspace's \`ios --device\` would install over
3318
+ the app you are driving. \`stim device lock\` grants a DECLARED lease that
3319
+ outlives the run:
3320
+
3321
+ stim device lock ios --for 10m # or: android; add a UDID/serial to name one
3322
+ stim ios --device # builds, installs, launches; raises the lease
3323
+ ... device-tool work on the phone ...
3324
+ stim device unlock # give it back; or let it expire
3325
+
3326
+ \`--for\` takes a whole number of seconds or minutes, 10s to 30m, and
3327
+ defaults to 5m; anything else is STIM_BAD_ARG. \`--wait <seconds>\`
3328
+ (default 60, \`0\` refuses at once) is the same wait a run does. Both
3329
+ commands need a project and refuse outside one with STIM_NO_PROJECT, and
3330
+ \`lock\` runs the same resolver \`--device\` does, so an unpaired phone or
3331
+ one with Developer Mode off is refused with that resolver's own remedy
3332
+ before any lease is written.
3333
+
3334
+ Locking a device this workspace already holds SETS the expiry to now plus
3335
+ \`--for\`, which can shorten it. Locking a different device of the same
3336
+ platform releases the first one: a workspace holds at most one lease per
3337
+ platform. Nothing else moves an expiry -- not the app running afterwards, not
3338
+ device-tool work, not \`status\`. Only \`lock\` and a run's own steps do.
3339
+
3340
+ \`stim device unlock\` releases every lease this workspace holds, or only
3341
+ the platform named. Releasing nothing is not an error: it says so on stderr,
3342
+ and \`--json\` prints an empty list. It releases by holder, so it still
3343
+ works when the workspace directory was recreated and the token is gone.
3344
+
3345
+ With no id, \`lock\` and a \`--device\` run pick from the POOL of connected
3346
+ devices, so two phones on one machine no longer refuse.
3347
+
3348
+ THE POOL: WHICH DEVICE AN ID-LESS \`--device\` PICKS
3349
+ Candidates are the connected devices the resolver already accepts: on iOS,
3350
+ wired, paired, with Developer Mode on; on Android, every serial adb reports
3351
+ in the \`device\` state that is not an emulator, TCP serials included. Then,
3352
+ in order:
3353
+
3354
+ 1. the device this workspace already leases, when it is among them;
3355
+ 2. otherwise the first one not leased -- or leased and EXPIRED -- in
3356
+ case-folded id order.
3357
+
3358
+ Ids are sorted on, never names: adb has no name without one \`getprop\` per
3359
+ serial, and models repeat.
3360
+
3361
+ A device this workspace leases that is NOT connected refuses with
3362
+ STIM_NO_DEVICE naming it, rather than quietly moving to another phone. Naming
3363
+ a different one with \`--device <id>\` refuses the same way, because a
3364
+ workspace holds at most one lease per platform: \`stim device unlock\` first.
3365
+
3366
+ Candidates with none free is the wait: under \`--wait <seconds>\` the poll
3367
+ re-LISTS devices, so a phone plugged in mid-wait is picked up as well as one
3368
+ released mid-wait. When the wait runs out, STIM_DEVICE_BUSY names every
3369
+ holder and its expiry. No candidate at all is the existing STIM_NO_DEVICE,
3370
+ with the resolver's own message. \`--no-wait\` takes the first candidate
3371
+ anyway and proceeds with no lease, as it does for one named device.
3372
+
3373
+ The chosen device is on the phase line and in \`--json\` (\`udid\` or
3374
+ \`serial\`, plus \`deviceName\`), so an agent can hand the same id to its
3375
+ device tool.`
3376
+ },
3377
+ release: {
3378
+ summary: "Release configurations and ...Release variants: Metro skipped, process-proven launch, the JS swap",
3379
+ body: () => ` A VARIANT WHOSE NAME ENDS IN "Release" IS A RELEASE BUILD (\`release\`,
3380
+ \`productionRelease\`), and that is the whole opt-in -- there is no second
3381
+ flag. It is the Android half of \`ios --configuration Release\` and behaves
3382
+ the same way: AGP's bundle task embeds the JS, so Metro is skipped ENTIRELY
3383
+ (no gate, no \`adb reverse\`, no debug_http_host, no dev-client deep link --
3384
+ a plain \`am start\` of the launcher activity), the payload says
3385
+ \`metroPort: null\`, and \`launched\` is proven by the app PROCESS being
3386
+ alive on the device rather than by a bundle fetch. Device logs are still
3387
+ collected, so \`logs --errors\` answers "does it repro in release/Hermes
3388
+ bytecode".
3389
+
3390
+ On a release CACHE HIT the cached APK carries its BUILDER's baked-in JS, so
3391
+ it is never installed as-is. Stim copies it aside, regenerates this
3392
+ workspace's bundle with the project's own tools (\`expo export:embed\` /
3393
+ \`react-native bundle\`, then the project's own hermesc when
3394
+ \`hermesEnabled\` is not false in android/gradle.properties), re-packs it
3395
+ into the copy with plain zip surgery (stored, not deflated -- the runtime
3396
+ mmaps it), then zipaligns and re-signs with apksigner. The keystore defaults
3397
+ to android/app/debug.keystore with the standard password; android.keystore /
3398
+ android.keystorePassword override it (see \`guide settings\`). The cache
3399
+ entry itself is never modified.
3400
+
3401
+ Before re-packing, THE ASSET GATE compares CONTENT HASHES of the assets
3402
+ React Native emits: what this workspace just emitted under --assets-dest
3403
+ against a manifest of what the cached build emitted, recorded as
3404
+ assets-manifest.json inside the cache entry at build time. Same producer on
3405
+ both sides, so the comparison is exact -- an added, a removed OR A REPLACED
3406
+ asset (a different image under an unchanged filename) all mean NO SWAP, and
3407
+ the run falls back to a full gradle build with a note naming an example. An
3408
+ Android drawable is not just a file in the zip -- it has a row in
3409
+ resources.arsc only AAPT can write -- so an APK cannot be made to carry an
3410
+ asset it was not built with, and Stim will not install one whose JS
3411
+ references an asset it lacks. The APK's own res/ table is never read: a
3412
+ release build shortens every resource path (AGP's
3413
+ optimizeReleaseResources), so those entries are \`res/-B.png\`, not the names
3414
+ anything emitted.
3415
+
3416
+ AN ENTRY WITH NO MANIFEST NEVER SWAPS. One stored before asset tracking, or
3417
+ downloaded from an Expo build-cache provider, has nothing to compare
3418
+ against, so the run says so and builds fresh -- and that build REPLACES the
3419
+ entry, manifest included, so the next run on the same fingerprint swaps
3420
+ normally. The same replacement happens after any gate refusal or swap
3421
+ failure, which is what stops a bad entry from refusing every run forever.
3422
+
3423
+ Local re-signing also
3424
+ means an APK signed by CI cannot be updated over: on
3425
+ INSTALL_FAILED_UPDATE_INCOMPATIBLE (or a version downgrade) a release run
3426
+ uninstalls the package once and retries, printing a note -- the app's data
3427
+ goes with it, which is why only release runs do this.
3428
+
3429
+ Local installs only, onto an owned emulator or, with
3430
+ \`android --device\`, a connected physical device. Store signing and
3431
+ distribution stay out of scope.
3432
+
3433
+ \`ios --configuration <name>\` selects the Xcode configuration --
3434
+ \`--configuration Release\` builds a SIMULATOR Release app with the JS
3435
+ bundle embedded. It overrides the ios.configuration setting (the app-level
3436
+ default); unset, the Debug flow is unchanged. A non-Debug configuration
3437
+ skips Metro ENTIRELY: no gate, no port wiring, no dev-client deep link (a
3438
+ plain \`simctl launch\`), and the payload says \`metroPort: null\` --
3439
+ \`launched\` is verified by the app PROCESS staying alive, not by a bundle
3440
+ fetch. The build cache keys on the configuration
3441
+ (\`<fingerprint>-release-sim\`), and because a cached Release .app carries
3442
+ its builder's baked-in JS, a cache hit regenerates THIS workspace's bundle
3443
+ (the project's own \`expo export:embed\` / \`react-native bundle\`, plus
3444
+ its own hermesc when Hermes is enabled) into a copy of the artifact,
3445
+ re-signs it and installs that; any swap failure falls back to a full build
3446
+ rather than ever installing stale JS. Device logs are still collected, so
3447
+ \`logs --errors\` answers "does it repro in release/Hermes bytecode".
3448
+ A run with no \`--device\` installs on the simulator only. \`ios --device\`
3449
+ builds the \`iphoneos\` slice for a cabled iPhone and keys its cache
3450
+ \`-device\` instead of \`-sim\`, but does not install it yet. Archives,
3451
+ \`.ipa\` export, store signing and distribution stay out of scope.`
3452
+ },
3453
+ simslim: {
3454
+ summary: "installing SimSlim and what ios.simslimProfile does to an owned simulator",
3455
+ body: () => `OPTIONAL SIMSLIM PROFILE
3456
+ Install SimSlim once on each Mac:
3457
+
3458
+ brew install mobai-app/tap/simslim
3459
+
3460
+ Then commit a profile and select it in .stim.json:
3461
+
3462
+ { "ios": { "simslimProfile": ".simslim/dev.json" } }
3463
+
3464
+ SimSlim requires an iOS 18 or newer simulator. On each local \`stim ios\`,
3465
+ Stim reconciles that profile on the owned simulator before the app build.
3466
+ The first change can update services and reboot the simulator. A matching
3467
+ profile is a fast no-op on later launches. The settings persist across normal
3468
+ shutdowns and reboots. Removing the setting restores stock services when
3469
+ Stim applied the profile. Stim never changes an unowned or remote simulator.`
3470
+ }
3471
+ }
3472
+ },
3473
+ cleanup: {
3474
+ summary: "Where simulators come from, and how they get reclaimed",
3475
+ preamble: () => `CLEANUP AND DISK
3476
+
3477
+ WHAT RECLAIMS AN OWNED DEVICE
3478
+ stim worktree remove parks eligible owned simulators and emulators
3479
+ (\`guide lifecycle pool\`); deletes them when
3480
+ parking is disabled or their setup cannot be verified
3481
+ stim gc --delete sweeps stim-* devices no project references, and
3482
+ clears verified parked simulators and emulators
3483
+ stim gc --delete --older-than <days>
3484
+ also reaps the device of a project nothing has
3485
+ touched in that long, even though the project is
3486
+ still on disk
3487
+
3488
+ Those are the only two commands that delete. \`stim stop\` shuts a device
3489
+ DOWN and leaves it assigned, which is what makes returning to a branch cost a
3490
+ boot rather than a create, a provision and a reinstall.
3491
+
3492
+ Neither touches $STIM_HOME/stats.json: \`gc\` never reports or trims the run
3493
+ counters \`stats\` prints, and there is no reset flag. Delete that one file to
3494
+ start the counters over. A file this version cannot read -- unparseable, or
3495
+ written by a newer Stim -- costs one dim line on stderr and is otherwise left
3496
+ alone; only the next \`ios\` or \`android\` run moves an unparseable one aside
3497
+ to stats.json.corrupt-<unix ms> and starts a new one.`,
3498
+ sections: {
3499
+ gc: {
3500
+ summary: "what gc and worktree remove delete, keep and refuse: orphans, stale records, locks, leases, EAS sessions",
3501
+ body: () => `LINKED WORKTREES
3502
+ \`stim worktree remove\` works with any linked worktree, warmed or not.
3503
+ Git registration identifies the worktree; a Stim registry entry is not
3504
+ required. The command reclaims any owned resources it finds, checks for
3505
+ uncommitted and unpushed work, and removes the linked checkout. Git-created
3506
+ branches stay. A branch with an existing Stim ownership record is deleted
3507
+ only when it has no unique commits.
3508
+
3509
+ ON THE SOURCE CHECKOUT
3510
+ git cannot remove a repository's main working tree, and deleting the source
3511
+ checkout is not what anyone meant -- so there, and only there,
3512
+ \`worktree remove\` reclaims the ENVIRONMENT and nothing else: the owned
3513
+ devices are parked or deleted, the Metro port freed, the registry entries
3514
+ (including nested monorepo app dirs) dropped, and the global workspace
3515
+ directory deleted. The tree itself is never touched, which is also why the
3516
+ dirty-tree and unpushed guards do not apply on that path.
3517
+ It ends with:
3518
+ Reclaimed the environment; the working tree stays (it is the source checkout).
3519
+ A registered project directory that is not a git repo at all gets the same
3520
+ environment reclaim -- there is nothing else remove could mean there.
3521
+
3522
+ The delete paths and \`stop\` do not check simulator occupancy. An explicit
3523
+ \`stim stop\` shuts down this workspace's Stim-owned simulator, including a
3524
+ simulator used by a UI-test runner. It never shuts down an unowned simulator.
3525
+
3526
+ If a delete fails, the device's config record is KEPT and the command reports
3527
+ it. A record is what makes the device findable again, so it outlives a failed
3528
+ teardown rather than turning it into an orphan.
3529
+
3530
+ BUILD LOCKS
3531
+ \`gc\` also reports the single-flight build locks (above): the ones whose
3532
+ builder is no longer running are debris a reboot or a kill left behind, and
3533
+ \`gc --delete\` clears them. A lock whose builder IS running is a build in
3534
+ progress -- it is named in the report and touched by nothing, because
3535
+ removing it would put a second workspace on the same compile.
3536
+
3537
+ DEVICE LEASES
3538
+ A workspace can hold a timed lease on a physical device. The lease is one
3539
+ file under ~/.stim/device-locks, and it expires on its own. \`gc\` reports
3540
+ the lease files whose expiry has passed; \`gc --delete\` removes those
3541
+ files, re-reading each one under its own lock first, so a lease renewed in
3542
+ the meantime survives. Two kinds are reported and KEPT: a file that does
3543
+ not parse, which no run may take the device around, and an unexpired lease
3544
+ whose holder directory is gone. \`stim status\` lists every lease file with
3545
+ its holder and expiry, including holders no config knows. \`stop\` and
3546
+ \`worktree remove\` release the leases of the workspace they act on, and
3547
+ nothing else deletes a lease file: never remove another workspace's.
3548
+
3549
+ A device leaks when a project is abandoned WITHOUT either delete path -- the
3550
+ sim survives with nothing pointing at it. \`stim gc\` (no flag, writes
3551
+ nothing, always safe) reports those; \`gc --delete\` reaps them, and in the same
3552
+ run drops the dead config ENTRIES those projects left behind and frees their
3553
+ Metro ports.
3554
+
3555
+ REMOTE EAS SESSIONS
3556
+ Plain \`stim gc\` is a dry run. \`gc --delete\` can stop active stim-* EAS
3557
+ sessions after workspace state is missing. The stop needs verified
3558
+ project, name, platform, and status ownership. The same run also cleans the
3559
+ local state that it can prove is stale.
3560
+
3561
+ A fixed ownership record and lock live under ~/.stim/machine/eas,
3562
+ independent of STIM_HOME. Unclaimed sessions are never stopped.
3563
+ Missing config.json does not authorize cleanup.
3564
+ The exact recorded workspace state path must prove that the session ID is
3565
+ absent.
3566
+ If claim removal fails after a verified stop, the session is stopped, but the
3567
+ workspace record is kept for reconciliation.
3568
+
3569
+ If a registered root is missing or unreadable, the EAS sweep fails closed and
3570
+ leaves the remote EAS session running. Independent local cleanup continues
3571
+ for entries it proves stale.
3572
+
3573
+ THE MIRROR IMAGE: A STALE DEVICE RECORD
3574
+ A device deleted out from under a LIVE project (by hand, or by Xcode) leaves
3575
+ the opposite problem: the record points at a sim that is not on the machine,
3576
+ and \`stim status\` warns about it on every run. \`gc\` reports these under
3577
+ "Stale device records", and \`gc --delete\` clears the RECORD -- only the
3578
+ record. There is no device left to shut down or delete, so nothing is issued
3579
+ at simctl or avdmanager, and the project keeps its entry, its label and its
3580
+ Metro port. The next \`ios\` / \`android\` creates a fresh owned device.
3581
+
3582
+ THE ONE CASE GC WILL NOT REAP
3583
+ If the config is gone entirely (deleted ~/.stim, or a throwaway
3584
+ STIM_HOME), gc cannot tell your stale devices from another config's LIVE
3585
+ ones, so it refuses to delete anything. It still NAMES the stim-* devices
3586
+ it found, so you can judge. Delete them yourself:
3587
+ xcrun simctl delete <udid>
3588
+ avdmanager delete avd -n <name>`
3589
+ },
3590
+ collector: {
3591
+ summary: "log collector reaping: an unproven collector pid, and why the app on a phone closed",
3592
+ body: () => `WHAT ELSE STOP REAPS
3593
+ The device-log collectors (\`simctl log stream\` / \`adb logcat\`) that
3594
+ \`ios\` / \`android\` attach after launch. They are recorded in
3595
+ the global workspace state.json, and nothing outside this workspace can name them,
3596
+ so \`stop\` is what stands between a teardown and a log stream that outlives
3597
+ the device it was reading. A fresh \`ios\` / \`android\` run also kills the
3598
+ previous collector for that platform before starting its own.
3599
+
3600
+ A PHYSICAL IPHONE'S COLLECTOR IS THE SAME PROCESS with one difference: on
3601
+ hardware the collector IS the launch. \`devicectl\` connects an app's
3602
+ streams only when it is the process that starts the app, so the collector
3603
+ runs \`devicectl device process launch --console\` itself rather than
3604
+ attaching after the fact. It registers under the same \`ios\` key, carries
3605
+ the same --root in its title, is proven and replaced by the same pid rules,
3606
+ and is reaped by the same \`stop\`.
3607
+
3608
+ THE APP'S LIFETIME IS BOUND TO THAT COLLECTOR, and this is the one place a
3609
+ phone behaves worse than a simulator. \`devicectl device process launch
3610
+ --console\` keeps the app attached to the launching process, so anything that
3611
+ ends the collector ends the APP ON THE PHONE: \`stop\`, \`gc --delete\`,
3612
+ \`worktree remove\`, a fresh \`ios --device\` run stopping its predecessor,
3613
+ a crash, the host sleeping, or the cable coming out. Measured: SIGTERM to the
3614
+ collector alone terminates the app. The phone has no owned-device registry
3615
+ entry. \`stop\` closes the app and releases this workspace's leases.
3616
+ Nothing is uninstalled, and the next \`ios --device\` starts it again.
3617
+
3618
+ Unplugging the phone ends devicectl, which ends the collector: it unregisters
3619
+ itself and exits either way. A separately held \`device lock\` lease survives
3620
+ collector exit until released or expired; \`gc --delete\` can remove its
3621
+ expired lease file.
3622
+ WHICH record it writes on the way out depends on devicectl's exit code, and
3623
+ that code is unverified until someone pulls a cable: a zero exit is
3624
+ collector_stopped, a non-zero one is collector_failed, because on hardware
3625
+ a non-zero devicectl exit is the only evidence a launch or console failed.
3626
+ See \`guide logs\` for what it can and cannot carry.
3627
+
3628
+ Before signalling a recorded collector pid, \`stop\`, \`gc --delete\`,
3629
+ \`worktree remove\`, and a fresh \`ios\` / \`android\` run each read that
3630
+ persisted process identity and require it to match the exact process
3631
+ registered for this workspace and platform. A pid that cannot be proven is
3632
+ reported and left alone: the
3633
+ kernel reuses pids, and an unreaped record is a smaller problem than a
3634
+ signal delivered to someone else's process. A fresh \`ios\` / \`android\`
3635
+ run starts its replacement anyway, leaving the unproven pid to clear on its
3636
+ own. A collector started by an older Stim has no process identity token, so
3637
+ it reports as unverified until its record clears -- which happens when its
3638
+ own device's log stream ends and it unregisters itself, or when the next
3639
+ \`ios\` / \`android\` run overwrites the record with its own, whichever
3640
+ comes first; the old process itself keeps running until it exits on its own.
3641
+
3642
+ A different exact OS start identity proves PID reuse: the recorded collector
3643
+ is gone, and the unrelated process is never signalled. A missing, malformed,
3644
+ or unreadable identity leaves the record unverified and kept for a retry.
3645
+ Wall-clock timestamps and command names are not ownership proof.`
3646
+ },
3647
+ disk: {
3648
+ summary: "disk usage, AVD and build-log sizes, the data partition, trimming the shared caches",
3649
+ body: () => `DISK
3650
+ Logs, state, pidfiles and Xcode DerivedData are under the global workspace
3651
+ directory, and \`worktree remove\` reclaims them. Gradle retains its normal
3652
+ project build directories while sharing task outputs through its build cache.
3653
+
3654
+ Android AVDs normally live under ~/.android/avd, and a booted owned AVD can
3655
+ use several GB. \`worktree remove\` deletes the workspace's owned AVD; plain
3656
+ \`stop\` only shuts it down for reuse. Stim uses Android's default Quick Boot
3657
+ unless displayless Linux requires software rendering, where snapshots are
3658
+ disabled. The first boot and a boot after the emulator, system image, or AVD
3659
+ settings change are cold, while later supported boots load the one automatic
3660
+ snapshot saved on exit. \`stop\` waits for the emulator process and, when
3661
+ enabled, the snapshot save to finish.
3662
+ New owned AVDs default to an 8 GiB data partition, though project settings can
3663
+ change it. When enabled, Quick Boot keeps one automatic snapshot, and \`worktree remove\`
3664
+ deletes the whole AVD.
3665
+ \`gc\` prints the on-disk size beside an orphaned or stale owned Android AVD
3666
+ when its content directory can be read.
3667
+
3668
+ So are the logs, and one of them is not small: build-ios.ndjson /
3669
+ build-android.ndjson hold the whole xcodebuild or gradle transcript at debug
3670
+ level, which for a cold build is tens of megabytes (74 MB measured on one
3671
+ first iOS build of a real app). They are worth that -- a build that fails at
3672
+ minute nine is unreadable any other way -- and they are per workspace, not
3673
+ global, so \`worktree remove\` reclaims them along with everything else in
3674
+ the global workspace directory. Each build starts its transcript file over, so the log
3675
+ holds one run and a workspace you keep building in does not accumulate them.
3676
+
3677
+ Simulators are large and live in the CoreSimulator device set, not in your
3678
+ project. If the disk is filling up, Stim's own devices are usually not the
3679
+ bulk of it -- Apple's default simulators and old runtimes are. Useful:
3680
+ xcrun simctl delete unavailable # sims for runtimes you removed
3681
+ xcrun simctl list devices # see everything
3682
+ stim gc # report dead entries, orphans, caches
3683
+ Xcode recreates default simulators on demand, so deleting them is safe.
3684
+
3685
+ New owned Android AVDs use an 8 GiB data partition by default. This leaves room
3686
+ for repeated app installs while capping userdata growth below the 10 GiB
3687
+ setting measured on the selected API 36 profile. Set
3688
+ \`android.dataPartitionSizeGb\` to a whole number from 6 through 16384 when a
3689
+ project needs another size. Android userdata grows but does not shrink, so the
3690
+ setting applies only to a newly created AVD; recreate the environment to adopt
3691
+ a changed value.
3692
+
3693
+ SHARED BUILD CACHES
3694
+ The caches that make a second workspace fast are alive by design and never
3695
+ included in a plain \`gc --delete\`. Every \`gc\` run reports them anyway,
3696
+ each row tagged (registered) or (detected), with its size:
3697
+ stim gc # report, caches included
3698
+ stim gc --delete --older-than 30 # trim entries nothing has used
3699
+ stim gc --delete --cache all # empty them whole, index-backed ones
3700
+ # (the Xcode CAS) included
3701
+ $STIM_HOME/ccache (default ~/.stim/ccache) holds the Android C++ objects
3702
+ \`stim android\` compiles through ccache. ccache keeps it under CCACHE_MAXSIZE
3703
+ on its own, so \`gc\` reports its size and leaves it alone; --older-than
3704
+ skips it, and \`--cache all\` empties it whole like the Xcode CAS. That
3705
+ bound is Stim's: it sets CCACHE_MAXSIZE on the Gradle run, which wins over
3706
+ a max_size written into the cache directory's own ccache.conf.
3707
+
3708
+ The Gradle build cache under GRADLE_USER_HOME (default ~/.gradle) is
3709
+ report-only because every Gradle build shares it. Stim reports its size
3710
+ but never prunes or empties it, including with --older-than or --cache all.
3711
+ Trim rather than empty. Emptying costs the next build in every project the
3712
+ time the cache was saving.`
3713
+ }
3714
+ }
3715
+ },
3716
+ settings: {
3717
+ summary: "Settings Stim reads, and where they can live",
3718
+ body: () => `SETTINGS
3719
+
3720
+ There is no \`stim config\` command. Settings are JSON files, edited by
3721
+ hand or committed; command-line selectors override their matching settings.
3722
+
3723
+ Resolution order, first match wins:
3724
+ 1. project layer ~/.stim/config.json, under this project's entry
3725
+ 2. repo layer ~/.stim/config.json, under this repo's git common dir
3726
+ 3. committed .stim.json beside the app's package.json
3727
+ 4. machine defaults ~/.stim/config.json, top-level optimizations only
3728
+ 5. Stim default
3729
+
3730
+ The committed file is plain JSON and travels with the app. Each monorepo app
3731
+ reads its own file, never an ancestor's runtime settings. A single-app repository
3732
+ still uses its root file. Existing machine project/repository overrides keep
3733
+ their precedence. Move root runtime settings into each relevant app when
3734
+ upgrading; relative profile/config/provider paths resolve from the app directory.
3735
+
3736
+ Worktree copying is repository-wide: worktree warm reads worktree.exclude and
3737
+ worktree.defaultBranch from the source checkout's root .stim.json, not from
3738
+ individual apps. Keep those rules at the repository root; runtime files and
3739
+ worktree-copy policy are separate scopes.
3740
+
3741
+ An app's .stim.json can contain:
3742
+
3743
+ {
3744
+ "ios": {
3745
+ "deviceType": "iPhone 17 Pro",
3746
+ "runtime": "26.2",
3747
+ "simslimProfile": ".simslim/dev.json"
3748
+ },
3749
+ "android": { "variant": "productionDebug" },
3750
+ "caches": ["~/.myapp-metro-cache"]
3751
+ }
3752
+
3753
+ KEYS STIM READS
3754
+ ios.deviceType e.g. "iPhone 17 Pro" -- the simulator model this
3755
+ workspace's owned sim is created as, spelled exactly as
3756
+ \`xcrun simctl list devicetypes\` names it, and one an
3757
+ installed runtime can create. The \`--device-type\`
3758
+ flag overrides this per invocation. A name no installed
3759
+ runtime offers is STIM_BAD_ARG and the creatable names
3760
+ are printed
3761
+ ios.runtime e.g. "26.2" -- the iOS runtime that sim is created on,
3762
+ as a version ("26.2") or a runtime's full name
3763
+ ("iOS 26.2"); nothing else matches. The \`--runtime\`
3764
+ flag overrides this per invocation, and an uninstalled
3765
+ version refuses the same way
3766
+ ios.configuration e.g. "Release" -- the Xcode configuration to build
3767
+ (simulator only). Committing
3768
+ { "ios": { "configuration": "Release" } } makes every
3769
+ \`stim ios\` in the app a release-shaped build:
3770
+ embedded JS, no Metro, cache keyed -release-sim, and
3771
+ a JS-bundle swap on cache hits. The \`--configuration\`
3772
+ flag overrides this per invocation. Unset means Debug.
3773
+ ios.remote "proxy" or "eas" to use that remote backend, the same
3774
+ as passing \`--remote proxy\` or \`--remote eas\`. The
3775
+ build still runs here; only the device is elsewhere.
3776
+ ios.simslimProfile a SimSlim JSON profile under the app directory,
3777
+ at most 64 KiB. Install the
3778
+ external tool once with
3779
+ \`brew install mobai-app/tap/simslim\`. SimSlim requires
3780
+ an iOS 18 or newer simulator. Each local \`stim ios\`
3781
+ reconciles the profile on its Stim-owned simulator.
3782
+ The first change can reboot it; a matching profile is a
3783
+ fast no-op. Removing the setting restores stock services
3784
+ when Stim applied the profile. Absolute paths, root or
3785
+ symlink escapes, and missing files are refused before
3786
+ simulator creation. Remote and unowned simulators are
3787
+ never changed.
3788
+ ios.signingIdentity e.g. "Apple Development: Jane (TEAMID5678)" -- the
3789
+ keychain identity to re-seal a \`--device\` build with,
3790
+ overriding the one Stim derives from the artifact's own
3791
+ embedded.mobileprovision. Discovery is zero-config, so
3792
+ this exists only for the case discovery cannot cover.
3793
+ The name must be one \`security find-identity -v -p
3794
+ codesigning\` prints.
3795
+ ios.signingIdentitySha1
3796
+ the 40-character hex SHA-1 hash printed beside that
3797
+ name. Set it when two certificates share one common
3798
+ name: Stim is non-interactive, so it refuses an
3799
+ ambiguous identity rather than picking one. It wins
3800
+ over ios.signingIdentity.
3801
+ ios.lanHost e.g. "192.168.1.42" -- the address a phone uses to
3802
+ reach this workspace's Metro on an \`ios --device\`
3803
+ Debug run, pinning the interface on a multi-NIC Mac
3804
+ whose en0 is not the one the phone shares. A bare
3805
+ address or hostname ONLY: never a scheme, a port, or a
3806
+ URL, because the channels that carry it to the phone
3807
+ (the dev-client deep link and the bundle's ip.txt)
3808
+ compose the URL themselves. Unset means Stim orders the
3809
+ host's non-internal IPv4 interfaces en0 first, then the
3810
+ remaining en* by index -- react-native-xcode.sh's own
3811
+ heuristic, so Stim and a plain Xcode run pick the same
3812
+ interface.
3813
+ android.systemImage e.g. "system-images;android-36;google_apis;arm64-v8a"
3814
+ -- the sdkmanager package id the owned AVD is created
3815
+ from. The \`--system-image\` flag overrides this per
3816
+ invocation, and an id this SDK has not installed is
3817
+ STIM_BAD_ARG with the installed ids printed
3818
+ android.dataPartitionSizeGb
3819
+ whole GiB for a newly created owned AVD's data
3820
+ partition. Defaults to 8; accepts 6 through 16384.
3821
+ Existing AVDs are never resized because Android
3822
+ userdata grows but does not shrink. Recreate the
3823
+ environment to adopt a changed value.
3824
+ android.avdConfigFile
3825
+ path under the app directory to a flat native
3826
+ key=value INI fragment,
3827
+ at most 64 KiB. Stim parses it and
3828
+ merges supported values into avdmanager's generated
3829
+ config.ini before first boot; it is never used as a
3830
+ replacement file. Absolute paths, app-directory or
3831
+ symlink escapes, malformed or duplicate lines, and
3832
+ unsupported keys are refused before AVD creation.
3833
+ android.avdConfig flat object of the same native keys. It merges key by
3834
+ key across settings layers and overrides the selected
3835
+ avdConfigFile fragment. Boolean values accept true,
3836
+ false, "yes", or "no"; numbers and enums are checked.
3837
+ Supported keys and values:
3838
+ ${ANDROID_AVD_CONFIG_HELP.map((line) => ` ${line}`).join("\n")}
3839
+ Identity, architecture, host path, storage, image,
3840
+ kernel, camera, snapshot, boot-lifecycle, and unknown
3841
+ keys are protected. The emulator may normalize a valid
3842
+ value. These overrides apply only to a newly created
3843
+ AVD; existing and recovered AVDs are never rewritten.
3844
+ On displayless Linux, Stim launches with
3845
+ -gpu swiftshader_indirect -noaudio; those arguments
3846
+ override hw.gpu.enabled, hw.gpu.mode, hw.audioInput,
3847
+ and hw.audioOutput for that headless launch.
3848
+ android.variant e.g. "productionDebug" -- the gradle variant to
3849
+ assemble and install on a project with product
3850
+ flavors. A repo like tlon-mobile with
3851
+ flavorDimensions "profile" and production/preview
3852
+ flavors has NO plain assembleDebug output: commit
3853
+ { "android": { "variant": "productionDebug" } } and
3854
+ \`stim android\` runs assembleProductionDebug,
3855
+ finds the APK in apk/production/debug/ and keys the
3856
+ build cache on the variant. The \`--variant\` flag
3857
+ overrides this per invocation. Unset means plain
3858
+ assembleDebug. A variant whose name ENDS IN Release
3859
+ (\`release\`, \`productionRelease\`) is a release
3860
+ build: embedded JS, no Metro, cache keyed on the
3861
+ variant, and an APK re-pack on cache hits. See
3862
+ \`guide lifecycle release\`.
3863
+ android.keystore the keystore a RE-PACKED release APK is signed with,
3864
+ absolute or relative to the project root. Unset means
3865
+ android/app/debug.keystore, which every RN and Expo
3866
+ android project carries -- the right default, because
3867
+ what this signs is a local emulator install and never
3868
+ anything distributed. Set it only when the release
3869
+ variant must be signed with the repo's own key.
3870
+ android.keystorePassword
3871
+ the password for it. apksigner's SCHEMED form is
3872
+ passed through unchanged (\`env:MY_KS_PASS\`,
3873
+ \`file:/keys/pw.txt\`, \`stdin\`), which is how a
3874
+ committed .stim.json avoids carrying a secret; a
3875
+ bare string is used as the literal password. Unset
3876
+ means the debug keystore's fixed "android".
3877
+ android.remote "proxy" or "eas"; the Android half of ios.remote
3878
+ metro.tunnel selects how a remote device reaches this workspace's
3879
+ Metro after remote intent exists. Plain \`start\` stays
3880
+ local. For Expo and bare React Native, "auto" (default)
3881
+ first tries an authenticated and working ngrok.
3882
+ After an auth refusal,
3883
+ or any failure before ngrok returns a URL, it falls back
3884
+ to cloudflared. "off" asserts the device
3885
+ shares this machine and is the only mode that needs no
3886
+ tunnel. "expo" lets the Expo dev server tunnel itself.
3887
+ "cloudflared" and "ngrok" name a managed provider
3888
+ explicitly. Any other value is refused as invalid.
3889
+ metro.ngrokUrl the stable managed ngrok URL. It requires metro.tunnel
3890
+ "ngrok" and passes --url to ngrok http. Stim owns
3891
+ this process.
3892
+ metro.publicUrl an existing tunnel's URL. Takes precedence over
3893
+ starting one, whatever metro.tunnel says -- Stim
3894
+ did not create it, so a Metro request through it is
3895
+ still gated the same way a managed tunnel's is. Set it
3896
+ before Expo start so the manifest advertises it.
3897
+ worktree.exclude ignored-path skip list for worktree warm. Settings
3898
+ come from the source checkout's repository-root
3899
+ .stim.json. A nonempty .worktreeexclude in the source
3900
+ checkout replaces this setting. Registered nested Git
3901
+ worktrees are always skipped.
3902
+ worktree.defaultBranch
3903
+ the branch the source checkout is expected to sit on,
3904
+ read only by \`worktree warm --refresh\`, which WARNS
3905
+ (and continues) when the source checkout is on another
3906
+ branch, because the copy then carries that branch's
3907
+ dependencies. Unset, the branch
3908
+ \`git symbolic-ref --short refs/remotes/origin/HEAD\`
3909
+ names is used; set it when origin/HEAD is missing or
3910
+ wrong. Neither answering means no warning, not an error.
3911
+ cache.provider one optional SECOND-TIER cache provider: a module
3912
+ path relative to the settings file that names it, or a
3913
+ package name. It implements the @stim-cli/cache
3914
+ contract and can serve Metro transforms, native build
3915
+ artifacts, or both. The local filesystem stays tier
3916
+ one; a provider is read only after a local miss and
3917
+ written after the local write. Failures and timeouts
3918
+ are cache misses, never build or bundle failures.
3919
+ Stim ships no provider and never configures one.
3920
+ This module is EXECUTABLE CODE that every worktree of
3921
+ this app runs; review a committed value the way
3922
+ you review a build script.
3923
+ \`stim ios\` and \`stim android\` use it unless
3924
+ artifact or remote artifact caching is disabled. Metro
3925
+ uses it only when the project's own metro.config.js
3926
+ calls \`sharedCacheStores()\` from @stim-cli/metro: the
3927
+ store Stim injects for you (bare in-process, or the
3928
+ Expo config override) stays local-only.
3929
+ cache.options free-form object handed to that module's factory. It
3930
+ merges key by key across settings layers. Keep secrets
3931
+ out of the committed file: read them from the
3932
+ environment or the machine layers.
3933
+ caches extra shared-cache paths for \`gc\` to report. A JSON
3934
+ array; every path is treated as a flat store.
3935
+
3936
+ Each setting takes its documented type: string, array of strings, number,
3937
+ boolean, or object. A value of the wrong type is
3938
+ refused by name on every command that resolves settings, so a wrong shape never
3939
+ falls back to a default silently. \`stim doctor\` reports it as a finding
3940
+ instead of refusing. The exception is \`optimizations.android.casToolchain\`:
3941
+ an invalid value warns and falls back to ccache, or no compiler cache when
3942
+ \`compilerCache\` is \`none\`. \`doctor\` also reports the invalid setting.
3943
+
3944
+ Anything else is IGNORED, and Stim warns about it by name on every run that
3945
+ resolves settings. If you see such a warning, the key was either renamed or
3946
+ removed -- check this list rather than assuming it still applies.
3947
+
3948
+ \`stim doctor\` checks the settings themselves on every run, whatever
3949
+ --platform says, because a rotted machine setting is not a native-platform
3950
+ problem. It reports, as notes naming the key, the file it came from, and the
3951
+ line that clears it: a setting whose path no longer exists, a setting that needs
3952
+ a companion the config does not supply, a key Stim no longer reads, and a
3953
+ config file that is not valid JSON. It skips the \`projects\` registry, where an
3954
+ entry for a deleted checkout is normal and \`gc\` owns the cleanup.
3955
+
3956
+ CONCURRENCY LIMITS ARE MACHINE-LEVEL, NOT A PER-PROJECT SETTING
3957
+ The caps above are not in the layered settings -- they are not per-project,
3958
+ because the resource they share (cores, RAM, booted simulators) is the whole
3959
+ machine's. They live under a top-level \`concurrency\` key in
3960
+ ~/.stim/config.json, edited by hand:
3961
+
3962
+ {
3963
+ "concurrency": { "maxBuilds": 2, "maxDevices": 3 }
3964
+ }
3965
+
3966
+ or via the environment, which overrides the file:
3967
+
3968
+ STIM_MAX_BUILDS=2 STIM_MAX_DEVICES=3 stim ios
3969
+
3970
+ Unset, 0, or any non-positive value means NO enforcement -- the default, where
3971
+ Stim limits nothing. See \`guide lifecycle concurrency\` for what each cap
3972
+ does.
3973
+
3974
+ THE DEVICE POOL BOUNDS ARE MACHINE-LEVEL TOO
3975
+ \`pool.iosParkedMax\` caps how many parked simulators \`worktree remove\` may
3976
+ leave behind for a later workspace to adopt. It is machine-level for the same
3977
+ reason: the disk they sit on is the whole machine's, about 2.5 GB each.
3978
+
3979
+ {
3980
+ "pool": { "iosParkedMax": 3 }
3981
+ }
3982
+
3983
+ in ~/.stim/config.json, or STIM_POOL_IOS_PARKED_MAX in the environment, which
3984
+ overrides the file. Absent means 3. \`0\` turns parking and adoption off:
3985
+ \`worktree remove\` deletes the simulator, \`ios\` never adopts, and a pool
3986
+ that already exists stays where it is until \`gc --delete\`. A value that is
3987
+ not a whole number 0 or more is refused by name on \`worktree remove\` and
3988
+ \`ios\`, and warned about by \`status\`, \`gc\` and \`doctor\`.
3989
+
3990
+ Android uses \`pool.androidParkedMax\` or STIM_POOL_ANDROID_PARKED_MAX with
3991
+ these same defaults and validation rules. \`android\` validates that bound;
3992
+ \`worktree remove\`, \`status\`, \`gc\` and \`doctor\` check the applicable
3993
+ platform bounds. Android adoption matches the system image and AVD creation
3994
+ settings, preserves the APK, and clears app data before launch. See
3995
+ \`guide lifecycle pool\` for cleanup and the system state that remains.
3996
+
3997
+ When STIM_HOME is set, parking and adoption are OFF unless
3998
+ the corresponding STIM_POOL_IOS_PARKED_MAX or STIM_POOL_ANDROID_PARKED_MAX
3999
+ is set too. A redirected home is a scoped config --
4000
+ test suites and the end-to-end harness use one -- and a scoped config must not
4001
+ leave simulators on the machine it cannot account for. A redirected home that
4002
+ wants a pool says so with the variable.
4003
+
4004
+ STIM NEEDS NO PROJECT CHANGES TO RUN
4005
+ Nothing above is required to use Stim. The performance caches that used to
4006
+ be setup steps are supplied by Stim on the command lines it composes itself:
4007
+
4008
+ xcodebuild COMPILATION_CACHE_ENABLE_CACHING / COMPILATION_CACHE_CAS_PATH /
4009
+ SWIFT_ENABLE_COMPILE_CACHE / CLANG_ENABLE_PREFIX_MAPPING /
4010
+ CLANG_OTHER_PREFIX_MAPPINGS -- so no Podfile post_install block
4011
+ (Xcode 26+ only, and skipped when the project configured ccache,
4012
+ which defeats it)
4013
+ gradlew --build-cache -- so no org.gradle.caching=true in a committed
4014
+ gradle.properties. Debug builds add
4015
+ -PreactNativeArchitectures=<target ABI> when the owned
4016
+ emulator system image or physical device proves the ABI;
4017
+ unknown targets and Release builds stay universal. The same run
4018
+ carries the ccache launcher and CCACHE_BASEDIR /
4019
+ CCACHE_NOHASHDIR when ccache is on PATH -- so no
4020
+ externalNativeBuild cmake arguments in a committed
4021
+ build.gradle.
4022
+ start a shared Metro FileStore, APPENDED to whatever the project
4023
+ configured -- so no metro.config.js. On a bare project Stim
4024
+ hosts Metro itself and adds it to the config it loaded; on Expo
4025
+ SDK 54+ the child loads Stim's config adapter through
4026
+ EXPO_OVERRIDE_METRO_CONFIG. Expo SDK 53 and older run with
4027
+ their normal Metro cache.
4028
+
4029
+ Each of those prints one dim line saying it happened. There is no setup skill
4030
+ and no init command; \`stim doctor\` reports the project-side settings as
4031
+ things you need only if you ALSO build outside Stim.
4032
+
4033
+ OPTIMIZATION SWITCHES
4034
+ Put an optimizations object at the TOP LEVEL of $STIM_HOME/config.json
4035
+ (default ~/.stim/config.json) to set machine defaults. Merge it into the
4036
+ existing file; preserve the project and device records. No project changes
4037
+ are required. The same object in .stim.json, or the existing repository/project
4038
+ settings layers, overrides individual values. Explicit false wins; removing a
4039
+ key inherits the next layer. Changes apply on the next build or Metro restart.
4040
+
4041
+ {
4042
+ "optimizations": {
4043
+ "buildCache": true,
4044
+ "remoteBuildCache": true,
4045
+ "releaseBundleSwap": true,
4046
+ "metroSharedCache": true,
4047
+ "ios": {
4048
+ "compilationCache": true,
4049
+ "swiftCompilationCache": false,
4050
+ "prefixMapping": true
4051
+ },
4052
+ "android": {
4053
+ "compilerCache": "auto",
4054
+ "pch": "auto",
4055
+ "gradleBuildCache": true,
4056
+ "targetAbiOnly": true
4057
+ }
4058
+ }
4059
+ }
4060
+
4061
+ The example shows the defaults. Full setting names and behavior:
4062
+ optimizations.buildCache
4063
+ false skips native artifact reads AND writes, including remote providers.
4064
+ Compiler caches remain independent. The existing --no-build-cache flag
4065
+ only bypasses reads and still stores the fresh build.
4066
+ optimizations.remoteBuildCache
4067
+ false skips both optional remote artifact providers, including loading
4068
+ their modules and authentication. Local artifact caching stays enabled.
4069
+ Stim ships no network provider or hosted cache. Remote artifact reuse
4070
+ requires a provider configured through cache.provider or Expo.
4071
+ optimizations.releaseBundleSwap
4072
+ false always builds Release from source, including the current JS;
4073
+ it never installs an old embedded bundle. Fresh artifacts can still store.
4074
+ optimizations.metroSharedCache
4075
+ false stops Stim appending its shared Metro store on both dev servers.
4076
+ Project-configured stores remain the project's choice. Replace the removed
4077
+ machine setting caches.injectMetroStore=false with this setting set false.
4078
+ optimizations.ios.compilationCache
4079
+ controls Xcode compilation caching (Xcode 26+).
4080
+ optimizations.ios.swiftCompilationCache
4081
+ opts into experimental Swift caching, requiring compilationCache=true.
4082
+ optimizations.ios.prefixMapping
4083
+ controls Clang source/DerivedData prefix mapping. false clears Stim's
4084
+ mappings. The existing Xcode version and project-ccache guards still apply.
4085
+ optimizations.android.compilerCache
4086
+ auto uses a CAS manifest if supplied, otherwise the normal ccache setup.
4087
+ ccache explicitly selects that setup; none stops Stim injecting it and
4088
+ disables inherited ccache. Project-defined compiler integrations can still
4089
+ override CMake settings. cas asks for the manifest below; with no usable
4090
+ manifest the build warns once and uses ccache rather than refusing. A value
4091
+ outside auto|ccache|cas|none is still refused by name.
4092
+ optimizations.android.casToolchain
4093
+ absolute path to the experimental Apple Clang toolchain JSON manifest.
4094
+ STIM_ANDROID_CAS_TOOLCHAIN overrides this path. An explicit ccache or none
4095
+ selection overrides automatic CAS selection even with that environment
4096
+ variable set. Any value that is not an absolute path, whatever its type,
4097
+ and a manifest that is missing, unreadable, or does not name an executable
4098
+ clang, clangxx, lld, ar and ranlib plus an existing resourceDir, degrade to
4099
+ the compiler cache the selection leaves -- ccache, or none when
4100
+ compilerCache is none -- in one warning naming this key and the file it came
4101
+ from. \`stim doctor\` resolves the same manifest and reports what the build
4102
+ would warn about as a note: a path that is not there, from this key or from
4103
+ that environment variable, or a manifest that is there and cannot be used.
4104
+ For prerequisites, see:
4105
+ https://stim.appandflow.com/docs/android-cas
4106
+ optimizations.android.pch
4107
+ auto keeps library/project policy, with PCH off by default when Stim supplies
4108
+ ccache. on/off overrides Gradle CMAKE_DISABLE_PRECOMPILE_HEADERS arguments;
4109
+ CMake target-level overrides still win. on does not fix stock ccache's
4110
+ cross-worktree PCH limitations.
4111
+ optimizations.android.gradleBuildCache
4112
+ false passes --no-build-cache to Gradle, overriding org.gradle.caching=true.
4113
+ optimizations.android.targetAbiOnly
4114
+ false stops narrowing Debug builds to the device ABI. Release is always
4115
+ universal; project ABI filters still apply.
4116
+
4117
+ Android CAS, explicit PCH modes, and changed iOS compiler options use separate
4118
+ native artifact keys. Android ccache and none share an artifact key when their
4119
+ PCH mode matches; disable artifact caching too to force native compilation. Legacy Expo providers
4120
+ cannot key these compiler profiles, so Stim skips that tier for custom profiles
4121
+ and Android CAS. Providers implementing the Stim key contract remain usable.
4122
+ Android compiler/PCH profiles also get separate CMake staging directories under
4123
+ <module>/.cxx/stim-<profile>, or under the project's custom staging root. Switching
4124
+ backends in Stim selects the matching directory without deleting previous builds.
4125
+ These directories accumulate across profile changes and shim upgrades. Stim
4126
+ does not prune them; remove an obsolete generated profile only with all native
4127
+ builds stopped. Worktree removal reclaims profiles with the rest of the tree.
4128
+ Direct Gradle runs keep their own configuration. These switches control Stim's
4129
+ build invocations; they do not edit Xcode, Gradle, CMake, or Metro project files.
4130
+
4131
+ Reading the timeline for it: on Expo, \`cache_store_requested\` is Stim saying
4132
+ it asked (it set EXPO_OVERRIDE_METRO_CONFIG on a process it does not run, which
4133
+ is all this side can know), and \`cache_store_added\` is the adapter reporting
4134
+ from inside that process that the store is in the config Metro loaded. Only the
4135
+ second one means transforms are being shared. A bare project writes
4136
+ \`cache_store_added\` directly, because there Stim adds the store itself.
4137
+
4138
+ TEMPORARY STORAGE
4139
+ Large temporary copies for iOS app preparation, release JS/APK
4140
+ swaps, and the doctor fingerprint checkout select a writable directory on the
4141
+ relevant filesystem. App/APK preparation uses the artifact volume. Worktree
4142
+ warm copies directly to the destination and does not use temporary storage.
4143
+ A system temporary directory on that
4144
+ volume is preferred, then a writable ancestor of the relevant path. Staging
4145
+ is private and outside Git working trees, so ignored secrets cannot enter
4146
+ Git status or git add. If no safe location exists, the operation refuses.
4147
+
4148
+ Set STIM_TMPDIR or top-level tempDir in $STIM_HOME/config.json (default
4149
+ ~/.stim/config.json) to override placement. STIM_TMPDIR takes precedence.
4150
+ The value must be an absolute directory outside Git working trees; missing
4151
+ directories are created privately. An override is used even on another volume.
4152
+ Doctor reports cross-volume copy costs and invalid temporary settings; it
4153
+ creates no directories for this placement check. Unset the override to restore
4154
+ automatic selection. An example machine setting:
4155
+
4156
+ { "tempDir": "/Volumes/SSD/stim-tmp" }
4157
+
4158
+ Small tool-response and entitlement files still use the system temporary
4159
+ directory. Build-cache storage stages beside its destination independently of
4160
+ tempDir. Keeping build output and its cache on different volumes still requires
4161
+ a full copy. iOS build output lives under STIM_HOME/workspaces; Android APKs
4162
+ live under the project's android/app/build/outputs/apk. Doctor compares these
4163
+ locations with the cache, using resolved symlinks and filesystem device IDs.
4164
+ It checks the current layout; arbitrary provider-returned paths and future
4165
+ mount changes cannot be predicted. Same-volume placement permits cloning when
4166
+ the filesystem supports it; it does not prove cloning occurred. On macOS,
4167
+ cp -c can silently fall back to copying and exit successfully.
4168
+
4169
+ CACHE LOCATIONS ARE MACHINE-LEVEL TOO
4170
+ The shared build cache and Metro transform cache default to living under
4171
+ ~/.stim. To relocate them (say, to an external disk), set a top-level
4172
+ \`caches\` key in ~/.stim/config.json, edited by hand -- absolute paths:
4173
+
4174
+ {
4175
+ "caches": { "buildCache": "/Volumes/SSD/stim/build-cache",
4176
+ "metroCache": "/Volumes/SSD/stim/metro-cache" }
4177
+ }
4178
+
4179
+ STIM_BUILD_CACHE / STIM_METRO_CACHE in the environment override the file.
4180
+ The CLI and both cache packages resolve these identically, so every process
4181
+ finds the same store regardless of shell profile. A relative path is ignored.
4182
+ The Metro value is a PARENT root. The sanitized package name is appended below
4183
+ it, so apps remain separately reportable and prunable. Earlier releases used an
4184
+ overridden Metro root as one flat store. A new registration replaces that legacy
4185
+ parent entry and marks the named layout. If an older package registers it again,
4186
+ current gc ignores the exact unmarked legacy parent while a marked child exists.
4187
+ A marked store that later becomes another override parent remains visible but is
4188
+ report-only while its marked child exists. Root-level legacy files remain
4189
+ untouched for manual cleanup.
4190
+
4191
+ PREFER SELF-REGISTRATION OVER THE 'caches' SETTING
4192
+ There is no 'cache' command. A cache registers itself from code instead, once,
4193
+ and every 'gc' report shows it from then on, tagged (registered):
4194
+
4195
+ import { register } from 'stim/cache-manifest';
4196
+ register({ dir: '<dir>', name: '<what to call it>', entriesDepth: 2 });
4197
+
4198
+ entriesDepth is how far below dir one entry sits (default 1, a flat store).
4199
+ Pass 2 for a root with a layer of grouping above the entries -- a Metro
4200
+ FileStore shards across 256 directories, a build cache is keyed
4201
+ <platform>/<key> -- or 'gc --delete --older-than N' removes a whole shard or
4202
+ platform instead of one entry. Pass prune: 'atomic' for a cache whose index
4203
+ references its own data (an LLVM CAS): it is then left alone by --older-than
4204
+ and emptied whole only by 'gc --delete --cache all'.
4205
+ Registration is idempotent and keyed on the directory.`
4206
+ }
4207
+ };
4208
+ //#endregion
4209
+ //#region src/commands/guide.ts
4210
+ function topicNames() {
4211
+ return Object.keys(TOPICS);
4212
+ }
4213
+ function topicByName(name) {
4214
+ return Object.hasOwn(TOPICS, name) ? TOPICS[name] ?? null : null;
4215
+ }
4216
+ const LOOKUPS = /* @__PURE__ */ new Map();
4217
+ function sectionLookup(name) {
4218
+ const cached = LOOKUPS.get(name);
4219
+ if (cached) return cached;
4220
+ const lookup = Object.create(null);
4221
+ for (const [sectionName, section] of Object.entries(topicByName(name)?.sections ?? {})) {
4222
+ lookup[sectionName] = section;
4223
+ for (const alias of section.aliases ?? []) lookup[alias] = section;
4224
+ }
4225
+ LOOKUPS.set(name, lookup);
4226
+ return lookup;
4227
+ }
4228
+ function sectionNames(name) {
4229
+ return Object.keys(topicByName(name)?.sections ?? {});
4230
+ }
4231
+ const COMMAND_NOTATION = "Commands use `stim`. If it is not installed globally, replace `stim` with `npx stim`.";
4232
+ function wordCount(text) {
4233
+ return text.trim().split(/\s+/).filter(Boolean).length;
4234
+ }
4235
+ function renderSectionIndex(name) {
4236
+ const topic = topicByName(name);
4237
+ if (!topic?.sections) return null;
4238
+ const entries = Object.entries(topic.sections);
4239
+ const width = Math.max(...entries.map(([sectionName]) => sectionName.length));
4240
+ const lines = ["SECTIONS"];
4241
+ for (const [sectionName, section] of entries) {
4242
+ if (section.separator) {
4243
+ lines.push("", section.separator, "");
4244
+ if (section.context) lines.push(section.context, "");
4245
+ }
4246
+ lines.push(` ${sectionName.padEnd(width)} ${`${wordCount(section.body())}w`.padStart(6)} ${section.summary}`);
4247
+ for (const alias of section.aliases ?? []) lines.push(` = ${alias}`);
4248
+ }
4249
+ lines.push("", `Read one with: stim guide ${name} ${topic.sectionHint ?? "<section>"}`);
4250
+ return lines.join("\n");
4251
+ }
4252
+ function renderTopic(name) {
4253
+ const topic = topicByName(name);
4254
+ if (!topic) return null;
4255
+ if (topic.body) return `${COMMAND_NOTATION}\n\n${topic.body()}`;
4256
+ return `${COMMAND_NOTATION}\n\n${topic.preamble?.() ?? ""}\n\n${renderSectionIndex(name) ?? ""}`;
4257
+ }
4258
+ function renderSection(name, section) {
4259
+ const lookup = sectionLookup(name);
4260
+ const found = Object.hasOwn(lookup, section) ? lookup[section] : void 0;
4261
+ if (!found) return null;
4262
+ const context = found.context ? `${found.context}\n\n` : "";
4263
+ return `${COMMAND_NOTATION}\n\n${context}${found.body()}`;
4264
+ }
4265
+ function topicOwning(section) {
4266
+ for (const name of topicNames()) if (Object.hasOwn(sectionLookup(name), section)) return name;
4267
+ return null;
4268
+ }
4269
+ function renderIndex(version) {
4270
+ const lines = [
4271
+ `stim ${version} -- reference for the binary you are running.`,
4272
+ "",
4273
+ COMMAND_NOTATION,
4274
+ "",
4275
+ "This output is generated by the CLI, so it always matches this version.",
4276
+ "The bundled skill routes coding agents to the agent topic. Other topics",
4277
+ "carry the detailed command contracts and remedies.",
4278
+ "",
4279
+ "TOPICS"
4280
+ ];
4281
+ const width = Math.max(...topicNames().map((n) => n.length));
4282
+ for (const name of topicNames()) lines.push(` ${name.padEnd(width)} ${TOPICS[name]?.summary ?? ""}`);
4283
+ lines.push("", "Read one with: stim guide <topic> (sectioned topics: stim guide <topic> <section>)");
4284
+ return lines.join("\n");
4285
+ }
4286
+ function guideCommand(program, version) {
4287
+ program.command("guide [topic] [section]").description("Print reference documentation for THIS version of Stim (topics: " + topicNames().join(", ") + "). A topic with sections prints its section index; name a section to print only it, e.g. `stim guide errors STIM_NO_METRO` or `stim guide lifecycle builds`. Generated by the binary, so it cannot drift from the installed CLI.").action((topic, section) => {
4288
+ if (!topic) {
4289
+ console.log(renderIndex(version));
4290
+ return;
4291
+ }
4292
+ const body = renderTopic(topic);
4293
+ if (!body) {
4294
+ console.error(chalk.red(`Unknown topic "${topic}".`));
4295
+ console.error(chalk.dim(`Available: ${topicNames().join(", ")}`));
4296
+ const owner = topicOwning(topic);
4297
+ if (owner) console.error(chalk.dim(`Did you mean: stim guide ${owner} ${topic}`));
4298
+ process.exit(1);
4299
+ }
4300
+ if (!section) {
4301
+ console.log(body);
4302
+ return;
4303
+ }
4304
+ const index = renderSectionIndex(topic);
4305
+ if (!index) {
4306
+ console.error(chalk.red(`Topic "${topic}" has no sections.`));
4307
+ process.exit(1);
4308
+ }
4309
+ const sectionBody = renderSection(topic, section);
4310
+ if (!sectionBody) {
4311
+ console.error(chalk.red(`Unknown section "${section}" in topic "${topic}".`));
4312
+ console.error(index);
4313
+ process.exit(1);
4314
+ }
4315
+ console.log(sectionBody);
4316
+ });
4317
+ }
4318
+ //#endregion
4319
+ export { guideCommand as default, renderIndex, renderSection, renderSectionIndex, renderTopic, sectionLookup, sectionNames, topicNames };