Dev server and logs
Commands use stim. If it is not installed globally, replace stim with
npx stim.
stim start reserves a port for the workspace and starts its React Native or
Expo dev server under a detached supervisor. The command exits only after the
server answers and Stim verifies its project identity.
A Debug stim ios or stim android run starts the dev server the same way
when it is not running, so stim start is optional. Running it first lets
Metro warm up while you do other work.
Bare React Native runs Metro in the supervisor. Expo runs the project's Expo CLI as a supervised child. A healthy server that another process started for the same project can be reused, but Stim cannot capture its full output.
Start Metro with your own command
Some apps only start Metro through their own command, for example an app
inside a monorepo whose packages need the repository's Babel setup. Set
metro.command in the app's .stim.json to the argv that starts it, and pass
{port} where the port goes:
{
"metro": {
"command": ["node", "../react-native/cli.js", "start", "--port", "{port}"]
}
}
Stim runs it from the app directory without a shell, in its own process group,
and replaces {port} with the workspace's reserved port. Metro must keep
running from inside the app directory, which is how Stim proves it belongs to
the app. Set it in the workspace layer or the app's .stim.json. stim start --reset-cache refuses for this server: put the
command's own reset flag in metro.command instead. Windows is not supported.
The output is captured as text, with levels inferred from each line, under
command_stdout and command_stderr events. Stim adds no reporter to this
server, so stim ios and stim android report the launch as unverified;
check stim logs --source metro for the bundle lines.
Try it with an agent:
This app only starts Metro through its own command. Add a metro.command entry
to its .stim.json that runs that command with {port}, then run stim start and
show me stim logs --source metro.
Choose the port
Stim picks a free Metro port from 8082 up. It finds listeners in the native
TCP table (netstat on macOS and Windows, /proc/net/tcp* on Linux). When
that table is denied, empty or unreadable, as in some sandboxes, it checks each
port with lsof and connects to 127.0.0.1 and ::1 instead. If none of
these can answer, stim start refuses with STIM_PORT_INSPECTION_FAILED.
When a workspace's own tools already expect a port, set it with the
metro.port setting or STIM_METRO_PORT, and Stim reserves that number
instead. A committed value
suits a single checkout. Give each worktree its own, in the workspace layer or
the environment: stim start refuses with STIM_BAD_ARG when another
workspace reserves the port or another process holds it.
Another process, such as a second Stim home, can take the port between
Stim's check and the dev server's bind. Without a pin, stim start then
releases the reservation, reserves another free port and starts again, up to
three attempts. A pinned port and --remote starts are not retried.
- Global
- npx
stim settings set metro.port 25062 --scope workspace
npx stim settings set metro.port 25062 --scope workspace
Changing the pin while this workspace's dev server runs is refused until
stim stop; an unverified supervisor also blocks the change until you stop it
with the tool that started it. You can also unset or restore the pin. A foreign process on the pin is refused before
anything starts or the port is reserved. This project's own Metro already
on the pin is attached to. stim web follows the same rules when its page
uses Metro.
For stim ios and stim android, --no-metro-check uses the pin, then the
recorded port, then 8081, without probing, reserving or writing the registry.
An invalid pin still refuses with STIM_BAD_ARG. Release builds skip Metro.
Idle stop
A dev server holds about 450 MB. The supervisor stops it after
metro.idleStopMinutes minutes, 60 by
default, with no bundle request, no client log record and no Stim command
(start, ios, android, reload or worktree warm) in the workspace. For
Expo, every line the Expo CLI prints on stdout counts as a client log. The
supervisor never stops the server while a bundle is still being served, while
a build or another stim ios, stim android or stim stop runs in the
workspace, while the workspace holds a device lock from stim device lock or
a --device run, physical devices included, while one of the workspace's
devices is driven, for example by agent-device, or its activity cannot be
read, or while stim start is running. It checks all of this again just
before it stops the server. On Windows, which has no ps, Stim does not look
for host processes driving an Android device; the on-device check for UI
Automator and instrumentation still applies. Set the value to 0 to keep the
server running. A value that is not a whole number of 0 or more is refused, as
stim settings set refuses it.
A Fast Refresh update is not a bundle request, and an app that is only
connected does not count either. An app you edit with Fast Refresh for an hour
without reloading and without console output therefore counts as idle. Raise
metro.idleStopMinutes for that workflow.
The stop is recorded in the timeline as supervisor_idle_stopped, and
stim status shows the port as stopped (idle) instead of a crash. The devices
stay booted, except owned ones idle for devices.idleShutdownMinutes (30 by
default; 0 never), which the stop shuts down first. The next stim start starts the server again, or reuses one that
another process started, and clears that state. A Debug stim ios or
stim android run starts it again too, and its JSON result reports
devServer: { "started": true, "reason": "stopped (idle)" }. The setting is
read when the supervisor is launched.
- Global
- npx
stim settings set metro.idleStopMinutes 120 --scope workspace
npx stim settings set metro.idleStopMinutes 120 --scope workspace
Why the dev server stopped
When the supervisor stops its dev server, it records the cause in the
supervisor_stopped line of the timeline (supervisor_idle_stopped for an
idle stop) and in stim status, which prints it after not running while
the workspace keeps its port. stim stop releases the port, so its own stops
show only in the timeline. In --json, the environment's metro.lastStop
carries it:
reason | Meaning |
|---|---|
requested | A Stim command stopped it. by names the command: stim stop, stim worktree remove, stim gc --delete, stim start --reset-cache, or budget reclaim from another workspace's run. |
signal | A SIGTERM or SIGINT with no Stim stop request recorded, so it likely came from outside Stim. |
server-exited | The dev server exited on its own. Expo CLI also exits with code 0 on SIGTERM, so exit code 0 with no request usually means something outside Stim signalled the Expo process. |
idle | Idle stop. |
vanished | The supervisor is gone and recorded no cause, usually because of SIGKILL, a crash or a machine restart. The next supervisor logs supervisor_vanished. |
Budget reclaim stops idle dev servers of other workspaces when a
stim ios, stim android or stim start run finds the machine over its
disk or memory budget. See machine settings.
Watchman roots
A Metro that uses watchman registers a
watch root in the shared watchman daemon and never removes it. Bare React
Native Metro uses watchman whenever it is installed. Expo SDK 56 and later use
it only when metro.config.js sets resolver.useWatchman to true.
When the supervisor stops its dev server, it runs watchman watch-del on the
root its Metro registered, once no other client subscribes to it or has a
trigger on it. It only
removes a root that appeared after the supervisor started, contains the
workspace and carried this Metro's subscription. It never starts the watchman
daemon and leaves other roots alone, including those jest registers. The
timeline records watchman_root_removed, or watchman_root_kept with the
reason. A
supervisor that dies without stopping leaves its root; remove it with
watchman watch-del <root>.
Removing a root stops watchman from recrawling it. The daemon's memory footprint shrinks only when the daemon restarts, which also drops every other client's watches.
Launch readiness
stim ios and stim android open the installed app, then check launch
evidence. Debug runs observe Metro; when bundling finishes within the wait
window, Stim also observes up to three seconds of launch logs, fewer once the
app's JavaScript reports Running "<name>". The summary can report
that bundling is still in progress or that launch is unverified. If the wait
window closes and the app process is gone, the run fails with the app process exited and the device log's errors instead. Release runs check process
liveness without Metro.
The wait for the app's bundle request is 20 seconds. An Expo development
client asks the dev server for its manifest before it asks for the bundle, and
Expo recomputes the project fingerprint for every manifest request when the
app uses the fingerprint runtime version policy, so a loaded machine can spend
most of the window there. Stim records each manifest request
from the launched platform in the metro log, as manifest_request_started,
manifest_request_finished or manifest_request_failed at debug level, and
counts it as progress: the wait stays open while a manifest request is in
flight and for 15 seconds after one was served, up to 60 seconds after the
launch, whatever the target; a new or adopted emulator already waits 60 seconds. A manifest is not launch evidence. launched is true or "bundling"
only after the app's bundle request, and a launch that never makes one still
ends unverified. Bare React Native has no manifest request and keeps the
20-second window.
When the app is already running, from an earlier run or because the install
was skipped, Stim restarts it the way Xcode's Run does: simctl launch --terminate-running-process on a simulator (a separate simctl terminate when a
dev client opens through a URL), am force-stop then a fresh launch on an
emulator or an Android phone. The launch line reads restarted running app (was pid <n>), and state
held in the running app is lost. An iPhone run already restarts the app through
devicectl, and remote targets relaunch it through Agent Device. If the running
app cannot be stopped, the run fails instead of reusing the old process.
For an unverified debug launch, follow the printed remedy. An Expo development client may need its server picker; a bare app on a local simulator or emulator gets a process restart command.
Stim reports bundle, red-screen, and fatal launch errors when those signals are available. These checks do not prove that a screen rendered correctly.
A nonfatal error still appears as launch evidence. The agent can read the error and decide whether the change caused it.
Only the human ios / android launch summary shows up to 10 frames per error,
component, or native stack. App-source frames take priority over dependencies;
selected frames remain in captured order, followed by the omitted-frame count.
Error stacks describe the call path; component stacks describe the React parent
tree. They are labeled separately. Every stim logs command, including
stim logs --errors, shows full captured stacks without frame or message-length
limits. The separate default limit of 20 error records never shortens a stack.
Use --source all for all log sources, or --json for raw records. Text
truncated by the runtime before capture cannot be restored.
A run that builds or reuses an app and then fails before the app is running adds one
build error record with event run_failed, the error code and the phase it failed
in, so stim logs --errors lists the failure that stim status shows.
Symbolication is best effort. In development, Stim asks the verified workspace's
Metro /symbolicate endpoint to resolve captured JavaScript coordinates, with a
short timeout and the original coordinates as fallback. Resolved launch context
is saved separately so it remains readable after Metro stops. Expo's printed source
excerpt and stack lines are included when available, even when Stim's own bundle
response records land between them. With --errors, this holds in human, --json,
and --follow output. --json keeps the error's msg as captured and adds those
lines as a context array of strings on the error record. --follow waits for
one quiet settle check, about 500 ms, before it prints an Expo error, so its lines arrive
with it. Uncorrelated bare React Native
symbolication events remain explicitly separate context. Shortened bundle locations
are labeled unsymbolicated, and a component stack is never used
to invent a missing error stack. OS logging can truncate text before Stim captures
it; “full” means the records actually captured, not a recovered original stack.
Copies from different sources are combined only when their error title, stack
location, platform compatibility, and timing match. Raw copies remain available
with --json; repeated errors from the same source are not suppressed.
Human queries can add a correlated device component stack to a selected Metro
error; unrelated device errors are never added as context.
Native crashes
On iOS simulators, Stim attaches stdout/stderr on a cold launch and passes Expo's
initial project URL directly, so
an early Swift fatal error or uncaught exception can appear in the launch result.
Console redirection uses the app's writable cache, including when STIM_HOME is
on an external volume; captured crash evidence is retained in the workspace logs.
OS crash reports can take about a minute or longer to arrive: rerunning
stim logs --errors collects them before you stop or release the simulator.
Reports are matched to the app, simulator, and launch time. Existing source locations
and symbol names are retained, and app addresses are resolved with
atos only when the local binary's UUID matches the report. Android reads the
crash logcat buffer, including reports emitted outside the dead app's PID. Java
exceptions retain their stack; C/C++ frames use matching local ELF build IDs and
NDK tools where available. Unavailable symbols remain explicitly unresolved.
Android can keep a crashed Java process alive behind its system crash dialog.
Stim treats the crash report as failure even when the PID exists. Fix the error,
then rerun stim android, which force-stops that process before launching.
Confirmed app-crash reports are included in default stim logs --errors without
including general OS error noise. Use stim logs --source device --json for full
captured reports. A missing report is not proof that the app did not crash: OS
reports can be delayed, physical iPhone capture is console-only, and stripped or
remote builds may not have matching local debug symbols. This does not download
symbols or replace Xcode/Android Studio's full crash-analysis tooling.
For delayed reports, rerun stim logs --errors before stopping or releasing the
device. Collection requires the workspace's current launch and device ownership
or lease; after release, already-captured reports remain available without
collecting another workspace's crashes.
Non-follow human queries enrich errors; --follow streams captured records and
does not continuously poll OS crash reports. --json preserves raw evidence; its
only addition is the Expo context field described above.
Stim does not resymbolicate an older error after a recorded Metro rebuild.
Historical unsymbolicated JS coordinates require
the matching bundle/source maps, not an unrelated rebuilt Metro bundle.
Recommended app-declared readiness
No package or monitoring SDK is required. In a debug app, emit this once at startup, before initialization that can block the first screen:
if (__DEV__) console.info('[stim:readiness] pending');
Emit the second line when essential initialization succeeds, usable content is rendered, and the splash screen is hidden:
if (__DEV__) console.info('[stim:readiness] ready');
For example, in an Expo root layout with an existing isReady state:
import { useEffect } from 'react';
import * as SplashScreen from 'expo-splash-screen';
if (__DEV__) console.info('[stim:readiness] pending');
// Inside the root component:
useEffect(() => {
if (!isReady) return;
let active = true;
SplashScreen.hideAsync()
.then(() => {
if (active && __DEV__) console.info('[stim:readiness] ready');
})
.catch(console.error);
return () => {
active = false;
};
}, [isReady]);
Use the app's real readiness condition, not an arbitrary timer or merely a
mounted root. Cover login, onboarding, and deep-link destinations. Never emit
ready from a failure handler or a finally block.
Static imports run before module-scope statements. To cover initialization in
imported modules, emit pending from an earlier app entry module before loading
that work. An error before pending still uses the default check.
Without an observed pending, Stim keeps its default three-second stability
window after bundle delivery. The window ends early, half a second after the
app's React Native runtime logs Running "<name>" for this launch on either
platform. A fatal record or an exited process before the window ends still
fails the launch. An error after it, such as a first render that takes longer
than that half second to throw, appears in stim logs --errors. Managed Metro
servers report when the native bundle response finishes; build-complete output alone does not close an observed
request. Without response capture, Stim falls back to the build-complete marker.
When Android reports queued JavaScript loading, Stim waits for device JavaScript
activity before starting stability, bounded by the bundle timeout.
Debug Android launches on an owned emulator that the run creates, or whose
adoption from the pool it finishes, get a 60-second bundle timeout, reported in
the verify phase. Existing emulators,
remote targets, and physical devices get 20 seconds, extended up to 60 while an
Expo development client's manifest request is pending or was just served. Fatal errors
still end verification immediately.
A pending observed before that window closes, early end included, opts into waiting for ready, up
to 30 seconds after the same completion signal.
Repeated pending logs do not extend the deadline. A matching ready can end
the wait early; an app error or process exit interrupts it. A missing ready
prints readiness not confirmed, not success or an inferred crash.
Stim reads exact standalone messages from this app's platform-specific device
log, after the current launch. Stale, other-platform, error-level, and embedded
example messages are ignored. Unlabelled shared Metro output cannot identify
which platform is ready. If device-log capture is unavailable or pending is
not captured in time, the default check applies. On a simulator or emulator
hosted on another Mac (--remote <machine>), the device log is the copy Stim
pulls from that Mac during the launch check, so each window waits up to 10
seconds for the copy to cover it. Release runs and --no-metro-check do not use
this debug-only integration.
This is the app declaring readiness, not visual verification. It does not alter
the launched JSON field: bundle/process evidence remains separate. Continue
checking the expected UI and stim logs --errors. For agent implementation
instructions, run stim guide lifecycle readiness.
Ask your agent to add it
Copy this prompt into your coding agent:
Add the recommended Stim app-readiness logs to this app. Read `stim guide agent`,
then follow `stim guide lifecycle readiness` from the installed CLI. If Stim
is not installed globally, use `npx stim` instead of `stim`.
Use the app's existing initialization and splash-screen lifecycle without
adding a package or monitoring SDK. Cover its real startup destinations,
including login, onboarding, and deep links; never report ready after failure.
Verify a normal launch, a slow successful launch, a missing ready signal, and
a startup error on the supported platforms. Remove temporary test delays and
errors afterward. Report the changes, observed readiness output, and any
validation you could not complete.
Query the timeline
Local follow uses filesystem change hints, with a ten-second repair scan for missed events or directory replacement. While its directory watch is unavailable, it retries and scans every 500 ms. Expo context settling uses a timer only while an error is pending. Local agent actions follow state and claim directory changes with the same repair cadence; active hosted log queries retain their pull cadence. Attachment schedules one 500 ms recovery scan. A moved log directory on Windows can require the ten-second repair scan.
- Global
- npx
stim logs --errors
stim logs --source client --since 5m
stim logs --follow --level warn
stim logs --source agent --follow
stim logs --errors --json
npx stim logs --errors
npx stim logs --source client --since 5m
npx stim logs --follow --level warn
npx stim logs --source agent --follow
npx stim logs --errors --json
The merged timeline includes Metro, client, device, and build records, and the
actions agent-device takes on
the workspace's owned simulators, emulators and native Mac app. Logs live
in the global workspace directory under $STIM_HOME/workspaces, not in the
project checkout. The Metro, client, and device files are capped at about 8 MiB each.
Stim keeps one previous generation of each file, and queries read both. The oldest
records are dropped first. A file written before Stim capped it can be much
larger. stim gc reports each workspace's log size, and stim gc --delete trims
a file over 16 MiB to its newest 8 MiB in workspaces that are not in use. See
gc.
Exit code 0 means the query succeeded, including when it prints errors. A clean
stim logs --errors check requires exit code 0 and no matching errors in the
captured logs. Human mode prints No matching log records on stderr for zero
matches; JSON mode writes NDJSON and writes zero bytes for zero matches. A
workspace that has never produced a timeline refuses with STIM_NO_PROJECT
and, in a monorepo, names the nearest registered descendant app with logs.
Placement decisions
A stim ios, stim android or stim macos run that decides where to build or host records where it put the
build and the device. Read the records with:
- Global
- npx
stim logs --source placement
stim logs --source placement --json
npx stim logs --source placement
npx stim logs --source placement --json
A record has the settings that applied (remote.build for a build,
ios.remote or android.remote for a device) with the layer each came from (flag, env,
setting or default), each remote machine that was checked with a reason code
(accepted, unreachable, busy, disk, load, version-mismatch,
no-matching-device, declined, memory, no-capacity) and the human
message, the choice (local when the run stays on this machine), and a fallback
with its reason when a remote machine that was meant to take the run did not.
Records stay in the local logs and include remote machine names. A new run replaces the
previous run's records, because they sit in its build log.
Try this with your agent:
My last stim ios run built on this machine. Read stim logs --source placement --json
and tell me which remote machines were checked, why each was skipped, and which
setting decided it.
Agent-device actions
When an agent drives an owned simulator or emulator with agent-device, stim logs --source agent shows each action as an info record with source agent: taps
with their coordinates, typing (agent-device redacts the text), app opens, and
screenshots. A failed
agent-device command is an error record. Stim reads agent-device's own session
records under ~/.agent-device (or AGENT_DEVICE_STATE_DIR) plus existing
workspace agent-device directories, and never writes there. An iOS session is matched to a simulator through its runner log, including
a session that moved between simulators. An Android session is matched only
while agent-device holds its claim on the emulator, so its actions drop out of
later queries once agent-device releases the claim. Records from a newer
agent-device format that Stim does not recognize are replaced by one warn
record saying the session's actions are not shown.
Native Mac sessions match the workspace's isolated bundle ID and current app
launch, using a recorded open with explicit --surface app. App switches and
unrecorded lifecycle attempts clear the match, as do event-log rotation and
unknown formats. Native screenshot events are omitted because their surface
override is absent from the event metadata. See native Mac agent actions
for the app-scoped command and limits.
For agent-device you run yourself, use the agentDevice.stateDir reported by
ios, android, macos, or each status --json environment. Read it once
from status, run from the app root, and prefix each agent-device command:
STATE_DIR="$(stim status --json | jq -r '.environments[] | select(.path=="'"$(pwd -P)"'") | .agentDevice.stateDir')"
AGENT_DEVICE_STATE_DIR="$STATE_DIR" agent-device open <bundle id> --platform ios --session <name>
AGENT_DEVICE_STATE_DIR="$STATE_DIR" agent-device snapshot --session <name>
Sessions, logs, metro-sessions, allocations and daemon files live under
$STIM_HOME/workspaces/<name>/agent-device/. The Apple runner build cache and
device claims stay shared: leave AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH and
AGENT_DEVICE_CLAIMS_DIR unset. Each active workspace adds one daemon, which
exits after five idle minutes with no open session. This is opt-in per command; forgetting the
variable uses ~/.agent-device.
stim stop closes owned-device sessions there; stim worktree remove stops its
daemon and removes the directory. Hosted/stim-server sessions already use their
own state directory and need no export.
Read stim guide logs, set AGENT_DEVICE_STATE_DIR from this workspace's
agentDevice.stateDir, then use agent-device to inspect the app. Keep the runner
build cache and device claims shared, and check stim logs --source agent.
stim stop ends the supervisor and log collectors. It also frees the reserved
port and shuts down the owned local device.
Named server ports
Allocate a port in the workspace whose server you want to isolate:
- Global
- npx
stim ports get web
pnpm exec vite --port "$(stim ports get web)" --strictPort
# In another terminal in this workspace:
stim ports
stim ports stop web --dry-run
stim ports stop web
npx stim ports get web
pnpm exec vite --port "$(npx stim ports get web)" --strictPort
# In another terminal in this workspace:
npx stim ports
npx stim ports stop web --dry-run
npx stim ports stop web
Stim keeps allocations in its machine registry and never starts or supervises these
servers. It checks new allocations for existing listeners and skips busy
ports in the 8900–8999 band, announcing retries on stderr. Allocation checks
listeners the same way as Metro's, falling back to lsof and loopback
connects when the TCP table cannot be read, and refuses only when none of them
can answer.
A repeated get returns the same number even while the server is listening.
The reservation does not hold a socket open: another process can bind it
between allocation and launch. Use the server's strict-port option when
available, and check that it actually bound the supplied port.
ports stop kills the TCP listener regardless of its working directory and
prints its PID and command. Stopping listeners requires lsof on macOS and
Linux, or netstat on Windows. Reserve a port only for a service this workspace
may stop. Use ports release to forget the allocation without killing the
server. Both leave Metro alone. worktree remove, gc --delete and automatic
maintenance release named allocations without stopping their listeners.
Allocations belong to the nearest directory with a package.json, resolved
through symlinks. In a monorepo whose web app lives in its own package, a
package that depends on neither react-native nor expo uses the one Stim app
registered in the same Git worktree, and stderr names it. web, settings,
logs, reload, stop and status follow the same rule. Register the app
first by running stim ports get web, stim start, stim ios or
stim android from the app directory, then run ports from the web package:
- Global
- npx
cd apps/web
pnpm exec vite --port "$(stim ports get web)" --strictPort
cd apps/web
pnpm exec vite --port "$(npx stim ports get web)" --strictPort
Every ports command there acts on the app's ports. A package that already
holds ports keeps them until you release them. With no registered app in the
worktree, or more than one, the nearest package keeps the allocation; run
ports from the app directory instead.
Shared services are not modeled: pass a shared service's port through the environment instead of allocating one per worktree.
Copy this prompt into your agent:
Read stim guide ports. Allocate a web port for this workspace, start the web
server using that exact port, and verify it is serving this checkout. When
finished, stop its named port with stim ports stop web.
Metro for hosted iOS
stim ios --remote <machine> keeps Metro here and needs no stim start --remote.
Hosted Debug runs require a supervisor with private gateway support before
reserving the simulator. --no-metro-check refuses; restart a missing or older
supervisor with stim stop; stim start.
The supervisor binds a private gateway only to this machine's own Tailscale address,
pins the hosting peer and requires a per-session secret. The host's bridge listens
only on loopback. Gateway data stays in private workspace state; status and logs
do not print it. metro.tunnel and metro.publicUrl are ignored. Non-Debug runs
skip Metro. A recorded hosted session prevents idle stop; stop or a stopped host
session closes its gateway. Native device records are pulled by stim logs and
stim logs --errors from adaptive, bounded host queries. Followers share a
throttled collection without blocking app delivery, view or control. Stop limits each log drain
to 30 seconds with progress on stderr and a no-progress guard. The host
also collects a bounded final tail before deletion on stop, revocation or close;
stop copies it back afterwards. Native windows overlap by five seconds and
de-duplicate, but read persisted entries only, so info-level or entries persisted
later than the overlap may be unavailable. Copied records remain readable afterwards. An older host prints an update note
and shows logs already copied here. See hosted iOS.