Skip to main content

Dev server and logs

Command examples

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.

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.

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:

reasonMeaning
requestedA 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.
signalA SIGTERM or SIGINT with no Stim stop request recorded, so it likely came from outside Stim.
server-exitedThe 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.
idleIdle stop.
vanishedThe 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.

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.

stim logs --errors
stim logs --source client --since 5m
stim logs --follow --level warn
stim logs --source agent --follow
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:

stim logs --source placement
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.

Ask your agent
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:

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

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:

cd apps/web
pnpm exec vite --port "$(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.