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