Build speed and caches
Commands use stim. If it is not installed globally, replace stim with
npx stim.
Stim shares four types of work across projects and git worktrees:
| Layer | What it avoids |
|---|---|
| Native artifact cache | A complete iOS or Android build when native inputs match |
| Xcode compilation cache | Recompiling unchanged native units on an artifact miss |
| Gradle caches and output | Repeating Android dependency and task work |
| Metro transform cache | Transforming the same JavaScript modules in each worktree |
See build optimizations for switches, defaults, and tradeoffs for each layer, including Android ccache, PCH, and experimental CAS.
Native artifact cache
Native Gradle artifact inputs
Native Android projects can opt into APK reuse in .stim.json:
{
"android": {
"artifactInputs": {
"complete": true,
"ignored": [],
"outputs": ["build", "app/build"],
"localFiles": ["../signing/release.keystore"],
"environment": ["SIGNING_PASSWORD"]
}
}
}
complete is the project's promise that Git-visible source, listed ignored files,
local files and environment variables fully determine a repeatable build with
pinned dependencies. Stim does not infer this from arbitrary Gradle code.
Declare every signing file, external configuration and environment input. Mutable
dependencies, undeclared files, network responses, time and other changing inputs
do not meet this contract. Leave the declaration unset to build without Stim
artifact caching; Gradle's own caches remain available.
ignored lists exact repository-relative files and outputs lists generated
repository-relative directories. Source cannot overlap outputs. localFiles
lists exact regular files, absolute or relative to the project; environment
lists variable names. Missing files and unset variables contribute an absence
marker. Private bytes and values contribute only hashes and are not persisted in
cache receipts. This declaration adds no files or values to worker transfers;
the independent android.offloadInputs contract still governs Git-visible and
explicitly authorized ignored source. Never authorize private signing files for
worker transfer.
The identity also includes the wrapper JDK release, installed SDK version
metadata, variant, ABI, build options, local.properties, user
gradle.properties, and ordinary debug keystores, including their absence.
Declare alternate JDK/toolchain release files and custom JVM user.home or
signing locations explicitly. For publication, AGP's SDK must match
ANDROID_HOME; the SDK directory location alone does not change the key.
local.properties needs plain unescaped keys and single-line values; Java
Properties escapes in sdk.dir values are supported. Unsupported syntax leaves
the build uncached. Other properties still contribute to the identity.
Changed inputs during compilation prevent storage. Native
Release hits reuse the original signed APK; signature, package, ABI and receipt
bytes are checked before reuse.
A configured cache provider with a files capability,
including the S3-compatible provider, can share the completed APK and its
verification receipt. Stim checks the local cache first, then downloads and
verifies a shared entry before filling the local cache. Invalid entries are
cache misses. Providers without files are skipped for native Gradle artifacts.
Only the APK and receipt are shared; private input files, signing keys and
environment values stay local. A build worker can reuse verified APKs when the
transferred project declares complete android.artifactInputs. It keys them from
its actual worker inputs, including the worker's toolchain, signing files and
environment; it does not use the caller's key. Without that declaration, worker
builds remain uncached. After a local and configured-provider miss, Stim can ask
enabled paired workers for a completed APK matching the declared source and
resolved variant, ABI and build options. Each worker rechecks its current private
inputs against the saved build receipt, again before download. A hit skips source
upload and compilation even while that worker is busy. A miss uses normal build
placement. This requires native-gradle-peer-cache; older workers still build
normally. Off mode, an explicit local build and disabled remote artifact reads
skip peer lookup. The returned offload fingerprint remains null; a worker key
is never published under the caller's identity.
optimizations.buildCache=false disables artifact reads and writes.
--no-build-cache skips reads and shared-build waits but stores the fresh build. --plan performs the same read-only local check.
React Native and Expo inputs
Stim uses @expo/fingerprint to identify native inputs in both Expo and bare
React Native projects. The cache key also includes the platform, target, and
build configuration or variant. A build for one CPU architecture adds it: an
Android Debug build for a known ABI ends in that ABI (-arm64-v8a), and an iOS
Debug simulator build or a --remote build ends in its architecture (-arm64
or -x86-64). A local iOS Release simulator build compiles every architecture
the project lists and has no suffix.
Stim runs @expo/fingerprint with its default balanced preset, the one the
project's own Expo uses:
- An installed dependency counts by its name and version, not by its files. So
does a config plugin loaded from
node_modules. - A package linked from outside
node_modulescounts by its files: a workspace package, alink:dependency, or a local Expo module. Afile:dependency that the package manager copies intonode_modulescounts by version. patches/, a committedios/orandroid/, the evaluated app config, its local config plugins and the files it names count by their contents. Stim also hashes thepatches/and.yarn/patches/directories next to a monorepo's lockfile, and the patch hashespnpm-lock.yamlrecords, wherever the patch files live.- The app's
version,ios.buildNumberandandroid.versionCode,eas.json,.easignoreand the path overrides inreact-native.config.jsare left out. A Release cache hit keeps the version it was built with.
To hash the files of every native dependency, set preset: 'strict' in
fingerprint.config.js. That costs about twice the fingerprint's CPU time.
Stim does not see an edit you make directly to a dependency's files, such as
node_modules/<package>/ios/*.m, a podspec or build.gradle. The key stays the
same, so the next run installs the cached app without your change. Make the
change a patch with patch-package or pnpm patch; Stim hashes patches. To try
an edit before you make it a patch, build it fresh on this machine:
- Global
- npx
stim ios --no-build-cache --remote-build local
stim android --no-build-cache --remote-build local
npx stim ios --no-build-cache --remote-build local
npx stim android --no-build-cache --remote-build local
--no-build-cache alone can place the build on a paired build machine. That
machine installs dependencies from the lockfile, so its build does not include
your edit. The fresh build is stored under the same key in the machine-wide
cache, and handed to a configured cache provider, so every worktree reuses it,
edit included, until the dependency's version or patches change. Undo the edit
with another --no-build-cache build.
stim ios and stim android first check the machine-wide artifact cache. A hit
installs the saved .app or .apk. A miss runs the native build and stores the
result. Two matching misses use one build through a single-flight lock.
Computing the fingerprint evaluates the app config and runs autolinking. After a
full fingerprint, Stim records it under a static digest of the tree in
$STIM_HOME/fingerprint-memo (~/.stim by default), shared by every worktree.
The digest covers several inputs:
- the git blob ids of the tracked native inputs, and the contents of untracked, modified and ignored ones;
- the app config's sources and the files and directories they name;
- each installed package's path and version, and the lockfile;
- the environment variables the config and its
.envfiles read.
A later run whose tree has the same digest, in any worktree, uses the record and
prints matches a recorded fingerprint of this tree. A full fingerprint then
runs in the background at low priority. If it disagrees, Stim drops the record
and prints a warning at the end of the run; run the command again to use the
current tree.
A config that the digest cannot follow always gets a full fingerprint. That
includes dynamic imports, process.env used other than by literal name, file,
process or network access while the config is evaluated, and dotenv. Local
config plugins count as evaluated, except that the files a plugin reads are not
tracked when it applies mods; Stim assumes it reads them at prebuild, and the
background check catches one that puts them in the config. The
record is never used after expo prebuild or pod install, on a build machine,
in stim doctor, or with --no-build-cache.
After local and configured-provider misses, development builds can reuse an
exact completed archive from an enabled paired build worker with
react-native-peer-cache. The query includes the post-prebuild/pod-install
fingerprint, native options and compatible toolchain. The worker retains the
completed archive even after changing or removing its checkout. A hit needs no
source upload or compilation slot. Missing or corrupt archives use normal build
placement. Remote mode off, an explicit local build, and disabled remote
artifact reads skip this lookup. Release and physical-device builds keep their
existing paths.
To find out before a run whether it will hit, and how long it should take, run
stim ios --plan or stim android --plan. See
Predict the next build. Stim Desktop
and the phone app run the same check when a workspace opens and show the
result on its build row.
A bare app whose Xcode project is not in ios/ sets ios.projectPath to its
directory, relative to the app. A subdirectory is hashed the way ios/ is. When
the project sits in the app directory itself ("."), Stim cannot tell the
native sources from the JavaScript beside them, so it hashes every top-level
entry except node_modules, Pods, build, android, .stim.json and
anything git ignores: a JavaScript edit
then misses the cache and rebuilds incrementally rather than reusing a build
that might be stale.
{ "ios": { "projectPath": "." } }
In an Expo project that gitignores ios/ or android/ (Continuous Native
Generation), the fingerprint hashes the app config instead of the native
directory. Stim records the fingerprint of each prebuild it runs. When a native
build is needed and the existing directory was not generated from the current
fingerprint, for example after a config plugin change, Stim updates it in place
with expo prebuild --no-clean before building (plain expo prebuild before
Expo SDK 57, whose CLI keeps the directory by default). On iOS it then runs
pod install against the existing ios/Pods, so only changed pods are
reinstalled. A build offloaded to another machine still regenerates that
machine's copy with --clean, because deleting your local directory does not
reach it. A committed native directory is part of the key and is never
regenerated.
An in-place prebuild reapplies the current app config over the existing
directory but does not undo what earlier config wrote. Delete the gitignored
ios/ or android/ before the next stim ios or stim android after a
change that has to remove something or needs a fresh template:
- removing a config plugin, or an app config entry that a plugin turned into a native setting such as a permission, entitlement, URL scheme or extra target
- upgrading the Expo SDK or React Native, because Expo copies its native template only into a missing directory
- renaming the app, when the Xcode project and target names should follow
- a config plugin that fails or duplicates its edit when it runs twice
Stim then generates the directory from scratch and runs a full pod install.
If you already built after such a change, that build is cached under the
current fingerprint, which deleting the directory does not change. Pass
--no-build-cache on the next build so it compiles from the fresh directory.
Hand edits in a gitignored directory survive an in-place update unless a plugin
rewrites them, and are lost when it is deleted, so keep native settings such as
the signing team in the app config or a config plugin.
Stim only knows about prebuilds it ran. If you run expo prebuild yourself with
a different config, delete the native directory before the next stim ios or
stim android.
Inputs edited during a build
Stim fingerprints the project again after the compile, so it never stores an
artifact under a key its inputs no longer match. Changes the build makes to
files under node_modules/ or the native directory, such as a Gradle plugin
rewriting a library manifest, move the key and the artifact is stored under
the new one. Stim cannot tell your own edits in those directories during the
compile apart from the build's. An edit to the app config or a config plugin
at any point during the run, or to any other input during the compile, means
the artifact may not match. The only config change Stim accepts is the
ios.bundleIdentifier or android.package that expo prebuild adds to a
static app.json that lacks one. Otherwise Stim installs the artifact for the
current run, and skips the local cache, the cache provider, and remote uploads:
fingerprint expoConfig changed while the build ran, so the artifact may not match its key; the build will be installed but not cached
The next run regenerates a gitignored native directory from the edited config and builds it.
Release configurations use separate keys. On a cache hit for an iOS simulator
or Android target, Stim regenerates the current workspace's JavaScript and
assets in a copy of the artifact. In an app with expo-updates, the copy's
embedded update also gets a new id and the current time, as a full build
would, so an update the app downloaded earlier cannot launch instead of your
JavaScript. If that swap fails, it builds fresh. iOS physical-device Release
runs always build fresh.
Generated dependency output and cache misses
A build that misses the cache says why before it compiles:
cache miss: native dependency added: expo-clipboard (+2 more)
Stim compares the build's fingerprint sources with the last build of the same
platform in this workspace, or else with the newest build of the same project
in another worktree. stim status --json reports the reason as
build.missReason while the build runs (as soon as the first lookup misses,
with build.missProvisional until a prebuild or pod install has been followed
by a second lookup) and keeps it under
lastBuilds.<platform>.missReason, and Stim Desktop and the phone app show it
on the workspace's build row. builds.<platform> keeps the same facts for the
last 10 runs, so you can see which run turned a hit into a miss. stim ios --plan and stim android --plan give
the same reason before a build, and the apps show it under "Next build".
If identical worktrees build instead of reusing an artifact, compare their
fingerprint sources before adding exclusions. A project-level
.fingerprintignore can exclude generated output that does not affect native
inputs. Never ignore an entire native dependency, its build scripts, or source
files to force a cache hit.
React Native Test App can put checkout-specific generated Android files under
node_modules/react-native-test-app/android/support/build. When the differences
are confined to that directory, add these entries to the app root's
.fingerprintignore:
node_modules/react-native-test-app/android/support/build
node_modules/react-native-test-app/android/support/build/**/*
Use the dependency path shown in the fingerprint sources; hoisted or linked
layouts may differ. Keep android/support/build.gradle and native sources
included. Verify that built worktrees agree and that changing a real native
input still changes the fingerprint. Stim does not add these exclusions by
default.
A prompt to investigate a miss:
Investigate why these two worktrees do not share Stim's Android artifact cache. Compare their fingerprint sources. If only React Native Test App's generated support/build output differs, add a narrowly scoped .fingerprintignore entry at the app root. Verify fingerprint parity and that a real native input change still invalidates the cache. Do not ignore the package, build scripts, or native sources. Report the evidence.
EAS development builds
With --eas-profile <name>, Stim downloads a compatible EAS development build
and uses EAS CLI's artifact cache directly. It does not add another Stim cache
copy or compile locally on a miss. See EAS development builds
for profile selection, device support, and cache behavior.
Optional artifact providers
@stim-cli/cache-s3 is a separate provider for S3-compatible storage,
including Cloudflare R2. Without a configured provider, artifacts stay on the
local machine. Its AWS SDK and archive dependencies are installed only when
you install that provider. Stim does not run a hosted cache service.
Projects can supply a module through cache.provider using the
@stim-cli/cache contract,
or use a configured Expo buildCacheProvider, such as eas.
Custom Expo provider modules run in isolated workers with the workspace environment.
Inspection and lookup have 30-second request budgets; upload has a 60-second
budget, followed by verified worker cleanup. Plugin output appears in build logs.
Each request loads a fresh module; module globals are not shared between phases.
Legacy uploads finish before Stim installs or exports the built app.
optimizations.remoteBuildCache controls whether Stim uses those providers.
Provider and build-profile restrictions still apply; see
build optimizations.
This caches app artifacts; ccache and Clang CAS use separate compiler caches.
Expo's eas tier needs eas-build-cache-provider in the app and an installed
eas-cli in the app or on PATH. Stim runs fixed EAS CLI commands without
loading the provider SDK into the caller. Fingerprint generation uploads
fingerprint metadata to EAS. Fingerprint and transfer commands share one deadline
within each resolve/upload phase; timeout or cancellation stops the CLI process
before returning. Stim never
installs eas-cli during cache lookup. Custom Windows batch launchers need a
project eas-cli installation so Stim can stop the Node process directly.
Install @stim-cli/cache-s3 in the app and select the S3 provider in .stim.json:
{
"cache": {
"provider": "@stim-cli/cache-s3",
"options": {
"bucket": "stim-cache",
"endpoint": "https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com",
"region": "auto",
"prefix": "team/cache-v1"
}
}
}
Use a private R2 bucket and an R2 token scoped to Object Read & Write on that
bucket. Save the credentials through the AWS SDK's standard credential chain;
for a named profile, set cache.options.profile to stim-r2 or run
AWS_PROFILE=stim-r2 stim ios. An explicit cache profile overrides the ambient
AWS profile. Keep secrets out of
committed settings. See Cloudflare's setup guide.
For AWS S3, omit endpoint and use the bucket's region. Other compatible
services may require forcePathStyle: true.
The same transport shares Metro values and complete native artifacts, with local caches checked first, digest verification and cancellable transfers. Use matching native toolchains for the initial test: the provider uses Stim's existing keys and does not add a toolchain partition. It does not synchronize dependency directories or compiler caches. Bucket lifecycle rules own remote retention; local GC never deletes shared objects. Single-object PUT uploads are limited to 5 GiB per entry. Connection resets retry the completed file with a fresh stream, up to three PUT attempts with cancellable delays. Conditional writes preserve an existing entry even if an upload committed but its reply was lost.
Keep the source checkout warm
Run stim doctor before native worktree work. It checks whether the source
checkout has current dependencies and CocoaPods state. On a checkout without
installed dependencies, it also checks whether a fresh worktree produces the
same native fingerprint.
When several native tasks are coming, build the source checkout once:
- Global
- npx
stim start
stim ios # or: stim android
stim stop
npx stim start
npx stim ios # or: npx stim android
npx stim stop
Later worktrees can reuse that cache entry. In an existing linked worktree,
stim worktree warm copies missing ignored state from the source checkout
without replacing existing entries. This includes installed dependencies, Pods,
and native output. See worktree isolation for its full scope.
Inspect and clean caches
- Global
- npx
stim gc
stim gc --delete --older-than 30
stim gc --delete --cache all
npx stim gc
npx stim gc --delete --older-than 30
npx stim gc --delete --cache all
The first command only reports sizes. Age-based cleanup removes unused entries.
--cache all empties managed caches and makes future builds cold; it reaps
nothing, so gc --delete on its own remains the way to prune stale entries.
--cache "compilation cache" empties one cache instead of every one.
A full disk or a killed build can leave a damaged object in the Xcode
compilation cache. When an iOS build that used it fails with the compiler's
own corruption error, such as OnDiskCAS: corrupt internal reference or
CAS operation failed, Stim empties the cache the same way and fails with
STIM_COMPILATION_CACHE_CORRUPT. Run the command again; that build is a cold
one. Stim leaves the cache alone while another build is compiling with it, and
the error says so. The emptying is recorded in the maintenance log. A build
worker does the same for its own cache. A build sent there with
--remote-build <machine> fails with that code; one placed there
automatically builds here instead.
A cached iOS simulator app can be damaged without failing to build. If a
cache-hit app is killed at launch because dyld cannot bind a symbol that one
part of the app needs from another part of the same app, Stim removes that
entry from the shared cache and fails with STIM_LAUNCH_FAILED naming it. Run
the command again to build the app fresh. A crash that the app's own bytes did
not cause, such as a missing system library, leaves the entry alone, and so
does an app built by the same run. A cache provider keeps its own copy of an
entry; add --no-build-cache to skip it.
Each iOS build holds a shared claim on the compilation cache while xcodebuild
runs, in a compilation-cache.claims directory beside it. gc --delete --cache, automatic maintenance and the corruption check empty the cache only
after taking that claim exclusively, so none of them can empty it under a
running xcodebuild. Xcode builds you run outside Stim take no claim.
Workspace build outputs
Each workspace keeps its own derived-data/, gradle-build/, android-cas/,
cache-provider/, SwiftPM scratch directory macos/build, staged
macos/<Product>.app and interrupted-build macos/staging-* directories under
$STIM_HOME/workspaces/<name>/. They usually take most of the disk Stim uses. gc reports them as one cache, "Workspace build
outputs", with the size, last use and verdict of each workspace.
- Global
- npx
stim gc --delete
stim gc --delete --older-than 7
stim gc --delete --cache workspaces
npx stim gc --delete
npx stim gc --delete --older-than 7
npx stim gc --delete --cache workspaces
Plain gc --delete lightens every workspace that is not building and did not
run stim ios, stim android or stim macos in the last 15 minutes, and
prints the bytes it freed for each. A workspace whose worktree is locked with
git worktree lock is never lightened, at any disk pressure level, and nor is
a workspace whose worktree git cannot list. A running dev server, a booted or
hosted simulator or emulator, or a tunnel no longer keeps the outputs. They
stay while a stim ios or stim android run holds the workspace, a build names
it, or its macOS app or build runs or cannot be verified. Lightening also clears the
project's own android/app/build, android/build, android/.gradle and
.build when git ignores every file in the directory and tracks none.
ios/build stays, because React Native's pod install writes files there that
only pod install recreates. The shared build cache, the Xcode compilation cache, the Gradle caches
and the Metro cache are never touched. --older-than <days> clears only
workspaces no Stim command has used for that many days. --cache workspaces
clears the outputs and nothing else, and --cache all includes them.
--cache parked is the one reserved name that all leaves out: it erases
parked simulators and emulators (see owned devices). The
workspace keeps its workspace.json, state.json, logs, devices, ports and
running dev server, along with macos/ itself, macos/runtime.lock, its claim
set and other macOS files. Automatic maintenance at disk pressure level 2
clears them by the same rule; level 1 keeps its own rules.
The next build of an unchanged app installs from the shared build cache. After
a native change, the Xcode compilation cache speeds up the rebuild. On React
Native 0.86, Swift does not use that cache because explicit modules are off,
so the first iOS build after a native change recompiles Swift. The next
stim macos recreates its scratch directory with a full Swift build and
restages the app.
Worktree dependencies
Plain gc --delete also lightens linked worktrees that have a Stim workspace:
it deletes their node_modules and ios/Pods, never the source checkout's.
A worktree locked with git worktree lock always keeps them, so a rollback
checkout stays runnable. Otherwise a worktree keeps them while its dev server
runs or a native run or build holds it, for 15 minutes after its last git, Stim or dependency activity, while git
tracks a file in them, and while a stim-server service, the stim on your PATH
or a running process (by command line or working directory) uses the worktree.
It also keeps them when stim worktree warm could not restore the same
versions: the source checkout lacks the directory, or the worktree's lockfile
differs from the source checkout's. gc records what it deleted, and the next
stim start, stim ios or stim android in that worktree runs
stim worktree warm first to restore it. If that warm fails or leaves a
directory missing, the command refuses with STIM_DEPS_FAILED. Other commands,
such as a test run, find the directory missing until then. .pnpm-store
stays, because it is all clones and deleting it frees nothing.
Device recordings
stim-server records owned simulators, emulators and the Stim-owned Chrome
page while an agent or automation tool drives them, or while the phone app
watches them. The phone app uses these recordings to scrub back through what
happened. They live in recordings/ in each workspace directory. stim-server
keeps the last 15 minutes of footage per device and at most 1 GiB across the
Stim home, deleting the oldest footage first. Idle time and time after
stim stop do not age footage out, so a stopped workspace stays replayable.
- Global
- npx
stim gc
stim gc --delete --cache recordings
stim gc --delete --older-than 7
npx stim gc
npx stim gc --delete --cache recordings
npx stim gc --delete --older-than 7
gc lists each workspace's recordings and their size. Plain gc --delete
deletes only the recordings of dead or orphaned workspaces, with their
workspace directory, and keeps them when it cannot prove the project is gone.
--cache recordings deletes every workspace's recordings and nothing else, and
--cache all includes them. --older-than <days> deletes footage recorded
before then. stim worktree remove deletes the workspace's recordings with its
directory. To stop recording altogether, see
Device recordings.
Set STIM_BUILD_CACHE or STIM_METRO_CACHE to an absolute path to place the
shared caches on a different volume. The same values can live in the machine config under
caches.buildCache and caches.metroCache.
Automatic maintenance
CLI commands, status --watch and every running dev server supervisor start a
detached maintenance pass when a check is due, so the disk check runs each
minute while a dev server or Stim Desktop is running even when no command does. In the default on mode a pass removes what stim gc --delete
would remove, under the caps and floors in
settings. In report mode it only
measures, plans and logs.
Disk and memory pressure are checked every minute, idle devices every
maintenance.idleCheckMinutes (5), Stim-owned directory sizes every hour,
finished worktrees every 15 minutes and the age sweep once a day.
On macOS the memory signal is the sysctl pressure level, with no signal when
sysctl fails. Only other platforms use os.freemem(). Memory pressure is
recorded; below disk pressure level 3 a pass never stops dev servers or
helpers. The idle check shuts down owned simulators and emulators idle for
their workspace's devices.idleShutdownMinutes, also when no dev server runs;
see idle shutdown.
Removing a finished worktree or orphaned directory does tear down that
workspace's own dev server, owned devices and Chrome profile, as gc --delete does.
Size, idle, worktree and sweep checks defer under high host load. The size scan covers
workspace build outputs, native build and Metro caches, ccache, Swift
compilation cache, and other registered Stim caches. It also records the disk of
each owned and parked simulator and emulator, which stim status reports as
owned devices. Other tools' caches are not managed by this feature.
In on mode a pass works through these steps, cheapest to rebuild first, and
stops the disk-driven ones once free space is back above the floor:
- Orphaned workspace directories whose project is gone, and registered caches whose directory no longer exists.
- Build outputs of idle workspaces: over
maintenance.workspaceOutputsMaxGb, below the free-disk floor, or unused formaintenance.olderThanDays. - Native build and Metro cache entries, least recently used first, down to
maintenance.capTargetPercentof their caps, plus entries unused formaintenance.olderThanDays. - The Swift compilation cache, emptied whole, only below
budget.hardFloorDiskGb. It is kept while an iOS build holds its claim on the cache, or while that claim is unresolved. The cache is LLVM's on-disk content store, whose entries live inside shared database files, so Stim cannot remove only the entries a running build does not need. - Linked worktrees whose branch or pull request finished, by the same rules as
stim gc --deleteandgc.worktreeGraceMinutes, a dirty finished one after archiving its uncommitted files, and clean ones with no commits of their own once idle (worktrees). Withmaintenance.removeIdleWorktrees true(off by default), the daily sweep also removes clean, pushed linked worktrees unused formaintenance.olderThanDays, asstim gc --delete --worktrees --older-than <days>removes them. An unfinished dirty worktree, one with unpushed commits, a locked one, the source checkout, one holding the running or PATHstim, and one whose last use is unknown are never removed.maintenance.removeFinishedWorktrees falseturns off all of them. - Parked simulators and emulators unused for
maintenance.olderThanDaysin the daily sweep, deleted through centralized teardown after their ownership is checked again. - Owned simulators and emulators of a finished worktree that is kept, such as
a dirty one, once idle for
gc.worktreeGraceMinutes, and of any workspace idle formaintenance.deviceIdleDays(3 days) in the daily sweep. Idle means no dev server, native run, build, lease, managed lock, driven or busy device, and no recorded use. The next run recreates them.
The daily sweep runs in the background of Stim commands every maintenance.sweepHours (24), never at a
set hour, and waits while the host load is over maintenance.maxLoadPerCore.
maintenance.olderThanDays (7) is the age for steps 2, 3, 5 and 6; set it to 0
and the sweep removes nothing in them by age. Step 1, step 7 (which
maintenance.deviceIdleDays controls), finished worktrees, and the cap and
low-disk parts of steps 2 and 3 still run.
A pass keeps a workspace that is in use, holds a lock, was used within
maintenance.protectRecentHours, or is the workspace of the command that
started the pass. It keeps cache entries used within that window, entries named
by a project's last builds, a parked device or a running build, and Metro
entries of a project with a running dev server. Anything it cannot verify is
kept. Pin a workspace against all of this with maintenance.keep. ccache
limits itself through caches.ccacheMaxGb. The first build after a clear
rebuilds what was removed.
Disk pressure levels
When free disk keeps falling, maintenance escalates:
| Level | When | What a pass adds |
|---|---|---|
| 1 | Under budget.minFreeDiskGb (20 GB) | The steps above. |
| 2 | Under maintenance.urgentFreeDiskGb (12 GB), or level 1 could not get back above the floor | Build outputs of workspaces that did not run stim ios, stim android or stim macos in the last 15 minutes, as stim gc --delete lightens them (a running dev server does not keep them, a build does); parked simulators and emulators; build directories of those worktrees (android/app/build, android/build, android/.gradle, SwiftPM .build, at the worktree root, a project root, or apps/* and packages/*; ios/build stays, because pod install writes files there); owned simulators and emulators of workspaces idle for the short window; Stim temp directories, xctrace recordings and SwiftPM temp directories in TMPDIR unmodified for 24 hours, with the checks of stim gc --delete. |
| 3 | Under maintenance.criticalFreeDiskGb (6 GB) or budget.hardFloorDiskGb, where builds refuse | The Swift compilation cache; finished worktrees, and clean, pushed worktrees with no commits of their own beyond the default branch, with the short window as their grace period, unless maintenance.removeFinishedWorktrees is false; workspaces whose dev server runs but that are otherwise inactive for the short window, whose dev server stops and whose owned simulators and emulators are deleted; node_modules and ios/Pods of idle linked worktrees, never the source checkout; idle Gradle and Kotlin daemons, as gc --delete --cache gradle-daemons stops them, only when they are all that keeps an unpinned Gradle version, and then Gradle version caches and wrapper distributions no registered project pins, once unmodified for maintenance.gradleVersionIdleDays (7) days. |
Each step re-measures free disk first and stops once it is back above its
level's floor: the budget floor for levels 1 and 2, the level 3 floor for level 3. A directory is cleared only when it is a real directory inside its worktree,
git tracks nothing in it, its worktree is not in use, locked, building or
pinned, and, for the node_modules and ios/Pods of level 3, nothing under it, nor the worktree's git index, HEAD or Stim
state, changed within the short window. A level 2 build directory also stays in a worktree where no Stim workspace has recorded any state. A dirty or unpushed worktree can still be
stopped and have these directories cleared; only its removal is refused. Caches
owned by other tools, such as the pnpm and npm stores, CocoaPods, SwiftPM and
the shared Gradle caches, are never deleted; at level 3 only the version
caches and wrapper distributions of Gradle versions no registered project pins
go, with the checks of stim gc --cache gradle-versions.
stim status shows the current level. At level 3 the last pass names the
shortfall in stim status. When a build keeps the Swift compilation cache, it
also names the cache's size and that build. While the last check, at most 10
minutes old, measured level 3, every Stim command prints one warning on stderr.
stim status shows the last plan and any running pass. stim gc previews the
next pass using live pressure and cached sizes; it does not rescan sizes for
that preview. stim gc --json carries the preview in a top-level maintenance
key; sections remains arrays of entries. With --cache, the preview excludes
device actions. stim logs --source maintenance shows this workspace's records;
add --errors for failures after the latest launch marker.
The machine log is $STIM_HOME/maintenance/maintenance.ndjson; stim doctor
prints its path. It rotates at 1 MiB by default, retains the rotated generation
for 30 days, and logs debug checks only with maintenance.logChecks enabled.
In report mode actions and explaining skips are logged only when newly planned.
In on mode every action taken, failure and explaining skip is logged with its
size, and pass records carry the bytes freed. Removed worktrees and orphaned
directories appear in the machine log only.
Every run that deletes or changes something also appends one record per item
to the machine gc log, $STIM_HOME/gc/gc.ndjson: stim gc --delete, --idle
and --cache, each maintenance action taken, and what the budget gate reclaims
before a build. A dry run writes nothing. Each record has the run, the trigger
(manual, desktop, maintenance or budget), the item's kind, label, path,
bytes freed and status (done, kept or failed), and the reason a kept or
failed item was left. It rotates and expires with the maintenance log. Read it
from any directory with stim logs --source gc, add --json for raw records
or --errors for failures.
The hook skips every stim gc invocation, and live or unresolved claims.
stim gc without --delete prints the maintenance plan itself. Failed attempts
back off for at least one minute; child crash logs are truncated above 64 KiB.
status and doctor report unresolved claims with a removal command to run
only after confirming the holder is gone. Invalid maintenance settings appear
in invalid and disable passes; an invalid cache cap falls back to its own
default and is reported there.
A pass and gc --delete share an exclusive claim; a held claim makes gc refuse
with the holder and recovery guidance.
Each pass keeps a 1 MiB file, maintenance/disk-reserve. When taking its claim
fails because the disk or quota is full, the pass deletes that file and tries
once more, so it starts at zero free disk as long as an earlier pass left the
reserve. It writes the file again when it ends.
The default mode is on, or off under STIM_HOME or CI unless
STIM_MAINTENANCE is explicit. Passes delete by default. Turn them off, or
keep only the plan and the log, with:
- Global
- npx
stim settings set maintenance.mode off
npx stim settings set maintenance.mode off
- Global
- npx
stim settings set maintenance.mode report
npx stim settings set maintenance.mode report
Pin one workspace with stim settings set maintenance.keep true in its project.
Copy this agent prompt:
Run stim status and stim gc. Show me the automatic maintenance plan and any
blocked targets, then read stim logs --source maintenance in this workspace.