Devices and cleanup
Commands use stim. If it is not installed globally, replace stim with
npx stim.
Stim creates and records its local simulators and emulators. Their names start
with stim-. It never creates, boots, or deletes a simulator or emulator that
another tool made.
The Stim phone viewer's control toolbar offers Shake and Slow Animations for owned iOS simulators when the paired server supports them. Slow animations shows the confirmed simulator state. These options require control access and are hidden on older servers or devices that do not support them. They use Stim's viewer and never open Device Hub.
A stim- name alone does not make a device Stim's. Stim lists every device it
creates in ~/.stim/created-devices.json, or in $STIM_HOME/created-devices.json
when STIM_HOME points somewhere else. For devices created before that ledger,
it also accepts a device its config records in the device pool or in a
workspace as owned. stim gc --delete removes only those devices when no
workspace references them. It lists any other stim-* device, including one
another STIM_HOME created, with the command that deletes it (rm -rf <dir>
for AVD data with no registration), and leaves it for you to run.
When a simulator in the ledger is deleted outside Stim, for example from Xcode,
stim gc reports its UDID as a stale ledger entry and stim gc --delete removes
it from the ledger. Stim does this only when simctl lists every simulator,
unavailable ones included, and that UDID is not among them.
The Android ledger records AVD names, and another STIM_HOME can create an AVD
with the same name later. stim gc reports an AVD name as stale when
emulator -list-avds answered, the AVD home exists, and no AVD root has a
registration or data directory for that name. It does not report a name that an
unfinished stim android setup has reserved. stim gc --delete checks again
before it forgets the name. After you delete a Stim AVD by hand, run stim gc --delete so a later AVD with that name is not treated as this home's.
stim android --device [serial] and stim ios --device [udid] install, launch,
and read available logs on connected physical devices. An iPhone can be cabled
or paired over Wi-Fi; with no UDID, Stim picks a cabled iPhone first. Stim leases the device
for the run, then releases the lease. Use stim device lock to hold it across
runs. Hardware never enters the owned-device registry and is never booted,
shut down, or deleted by Stim.
A new iPhone needs a Development profile that lists it before stim ios --device can install a build. Builds never create one.
stim device provision ios registers the
phone with the project's team and creates the profile after you confirm the
listed account changes; building once from Xcode does the same.
While a workspace holds the lease, stim status lists the device under that
workspace with its name, model, whether the machine reaches it, and when the lease
ends, and Stim Desktop and the Stim phone app show it as a tile with a
Physical badge. To keep a phone on screen while you work, lock it:
stim device lock ios --for 30m
stim status
Or ask an agent:
Lease my connected iPhone to this workspace with stim device lock for 30 minutes, then run stim status and tell me whether it shows as connected.
A leased iPhone cabled over USB also streams its screen to the phone app
through stim-server, view only: Stim sends a physical iPhone no taps, text
or buttons. Over Wi-Fi it shows no screen. The iPhone must be unlocked and
trust the Mac. macOS
counts the iPhone's screen as a camera, so the first stream asks for Camera
access for Stim, the app that runs stim-server.
While a workspace holds an Android phone's lease, the phone app and Stim Desktop
also show the phone's screen live through stim-server. Control in Stim
Desktop, or a phone paired with control, can tap, swipe, type and press Home,
Back, Apps and Lock on it. Stim Desktop also shows a cabled iPhone's screen,
view only. The stream runs over
adb with the scrcpy server, which
stim-server pushes to /data/local/tmp and deletes when the stream stops. It
installs nothing, changes no setting, and cannot rotate the phone. Control ends
when the workspace releases the lease or it expires. Some Android 15 and 16
phones send no picture until their screen changes. With the screen off, you see
what the phone's display shows, such as an always-on display, and watching never
wakes it.
Each workspace keeps its owned-device assignments for later runs.
After boot, Stim opens its owned iOS simulator in Device Hub on Xcode 27, or
Simulator on older Xcode. Stim passes the workspace's simulator ID to Device Hub
so it can open that device's window.
Opening the window is best effort; a window failure does not undo a successful boot.
On Xcode 27 (confirmed on 27A266a), quitting Device Hub by default shuts down
every booted simulator on the machine, including ones it never opened a
window for and ones other workspaces or agents are using. Never quit Device
Hub to free memory or clean up; use stim stop on workspaces you own
instead. Siniulator's setup notes
describe a macOS preference that stops this; it is undocumented whether that
preference also covers simulators Device Hub never displayed. See
Keep simulators running when Device Hub quits.
To display owned simulators in Siniulator, set "iosSimulatorApp": "siniulator"
at the top level of ~/.stim/config.json after installing it. For one launch,
use stim ios --simulator-app siniulator (or --simulator-app xcode to use
Apple's viewer). This also opens an already running owned simulator without
rebooting it or saving the override. Siniulator
shuts down a simulator when its window closes by default. In Siniulator Settings,
enable Leave simulator running after window is closed to keep Stim's device
running.
When Stim Desktop is installed, it is the default viewer for
both platforms: iosSimulatorApp and androidEmulatorApp default to
"stim-desktop". Stim looks the app up by its bundle id
(dev.stim.desktop) in Launch Services. Without Stim Desktop the defaults
stay "xcode" and "emulator", and a value you set always wins.
stim settings get iosSimulatorApp prints the effective value and, on stderr,
(default: Stim Desktop installed) when the default comes from the app.
For owned simulators, Stim Desktop keeps the current page and shows a launch
card with a Show button. Clicking Show opens the workspace that owns the
simulator and focuses that device, and no simulator window opens. It only displays the simulator; it never boots or shuts it
down. Pass
--simulator-app stim-desktop to use it for one launch.
When Stim Desktop is not running, Stim starts it without the command's
STIM_HOME. Stim Desktop then reads the same Stim home, and serves the same
paired phones, as when you open it yourself. It shows only devices from that
home, so a simulator booted under another STIM_HOME does not appear in it.
For owned Android emulators, Stim starts newly booted emulators on macOS with
-no-window -gpu host, which keeps GPU acceleration, and opens
stim-desktop://open?serial=<serial> in the background so Stim Desktop
shows a launch card. Clicking Show opens that emulator. An emulator that is already running keeps its current display
until it next boots, and physical devices are unaffected.
To keep the simulator and emulator windows with Stim Desktop installed:
stim settings set iosSimulatorApp xcode
stim settings set androidEmulatorApp emulator
An iPhone Duo simulator shows its active screen, with the frame fitted to that screen. Both screens appear side by side until the active screen is known. While Control is on, its tile has a Fold / Unfold button that sweeps the simulated hinge to the other posture. The button needs the bundled app, and because it uses private iOS interfaces, a new iOS runtime can break it. Rotate Left and Rotate Right work in folded, half-open and unfolded postures. Apps keep their supported orientations, and the Duo home screen stays portrait. When the Desktop viewer is open and the installed devicectl supports hinge observation, its preset selection follows changes made by other controllers. Arbitrary angles leave all presets unselected. Older tools retain the posture Desktop last requested. Hinge Angle opens a slider from 0 to 180 degrees while Control is on; release it to apply the selected angle. Selecting the current preset does nothing. When its observed angle is below 180 degrees, the Desktop viewer projects the active inner display around its hinge. Taps and drags follow the projected screen. The cover and fully open display stay flat; without angle observation, the inner display also stays flat.
Multiple devices with slots
Use --slot <name> to keep multiple targets in one workspace: phone and tablet
simulators, several devices of the same model, Android emulators, and connected
hardware. There is no fixed number of slots; available host resources and any
configured device caps still apply. Omitting the flag uses default, including
assignments created before slots were supported.
Test a change on multiple devices
Use Stim to test this change on an iPhone simulator, an iPad simulator, and my connected iPhone in this workspace. Use slots named phone, tablet, and hardware. Reuse those slots on later runs. Check the UI and errors on each target, report what you verified, and leave the devices running for me.
The agent should select installed simulator models and identify the connected phone before running. A physical iPhone needs a signed development build that covers its UDID. If several phones are connected, name the one you want.
Run and reuse targets
For example, with the named iPad model installed:
- Global
- npx
stim start
stim ios --slot phone
stim ios --slot tablet --device-type "iPad Pro 13-inch (M5)"
stim ios --slot hardware --device
stim status
npx stim start
npx stim ios --slot phone
npx stim ios --slot tablet --device-type "iPad Pro 13-inch (M5)"
npx stim ios --slot hardware --device
npx stim status
Repeat the same slot and device selectors to reuse an assignment. Choose an
installed model reported by xcrun simctl list devicetypes;
replace the iPad model above when needed. Use --device <udid> when selecting
among connected iPhones. Android supports the same pattern with
stim android --slot phone, stim android --slot fold --device-profile pixel_fold
or stim android --slot hardware --device <serial>.
Slot names are case-sensitive, 1–64 letters, digits, underscores or hyphens,
and must begin with a letter or digit; prototype-related reserved names and
web, which stim stop --slot web uses for the owned Chrome, are rejected. A name is scoped to its platform within the workspace.
Slots share one Metro server and compatible native build caches. Native runs
serialize changes to shared build output; the devices can remain running
together afterward. A Debug launch counts a Metro bundle delivery only when it
can tell that its own device requested it. An iOS simulator slot proves its own
launch: on macOS, Stim looks up which simulator app process opened the bundle
request. Android and physical-device requests carry no device identity, nor
does a simulator request whose process lookup failed, so they prove a launch
only while no other slot of the same platform is running (a booted owned
device, a log collector or a device lease; stim stop --slot <name> ends all
three).
Otherwise the launch reports unverified, and you should inspect the intended
device and its logs before claiming success. reload remains platform-wide, and named
slots are not supported for remote sessions. Local runs using an
EAS development build can use slots.
Inspect and stop one slot
- Global
- npx
stim logs --slot tablet --errors
stim stop --slot tablet
npx stim logs --slot tablet --errors
npx stim stop --slot tablet
The slot filter selects that target's attributed records plus the shared Metro
and app client output, windowed by that slot's own launch, so another slot's
relaunch does not hide the tablet's JS errors there. status lists the named assignments and leases.
stop --slot tablet stops that slot's owned devices and collectors and releases
its leases, while keeping Metro and sibling slots running. The assignment stays
available for another run. Plain stim stop handles every slot in the workspace.
stop --slot default stops only the workspace's default device the same way,
leaving named slots and Metro up.
Physical-device leases are separate per slot, but two slots cannot hold the
same physical device at once. device lock --slot hardware can retain a lease
across runs; use the platform and device selector shown in the
command reference.
All slots participate in workspace removal and garbage collection. Parked models share each platform's pool limit: an infrequently reused iPad can be evicted as the oldest parked simulator when that pool fills. Upgrades preserve existing ownership state; do not erase it. Use a slot-aware CLI consistently while named assignments exist, because older versions cannot manage them.
Keep simulators running when Device Hub quits
Stim never changes Apple preferences. The commands below are optional, you run them yourself, and Stim does not check or depend on them.
Device Hub quit. Siniulator's setup notes document this private, machine-wide Xcode preference, and report testing it with Xcode 27.0. Apple does not document it, and its effect can change between Xcode releases.
defaults write com.apple.dt.Devices shutdownStartedDevicesOnQuit -bool false
It is meant to stop Device Hub from shutting down the simulators it started when
you quit it. Whether it covers simulators Device Hub never displayed, and
whether it holds on every Xcode build, is not confirmed. On one Mac running
macOS 27 with Xcode 27.1 beta the write failed with Could not write domain.
On a Mac with Xcode 27.0 the write and readback succeeded, but no Device Hub
quit was tested, so the command may not work everywhere. Keep treating a Device Hub quit as
unsafe, and stop only your own workspaces with stim stop.
To undo it, delete the key:
defaults delete com.apple.dt.Devices shutdownStartedDevicesOnQuit
Xcode Run and Stop for native iOS development. Xcode may shut down an unbooted simulator target when you press Stop. This Xcode preference may help; its effect has not been verified with Stim:
defaults write com.apple.dt.Xcode DVTiPhoneSimulatorAlwaysLaunchInCoreSimulatorSession -bool true
Quit Xcode before you set it, and boot the exact target with Stim before you press Run in Xcode. It applies to iOS simulators only, not to native macOS development. To undo it, quit Xcode and delete the key:
defaults delete com.apple.dt.Xcode DVTiPhoneSimulatorAlwaysLaunchInCoreSimulatorSession
Neither preference changes which devices Stim owns or stops. stim stop still
shuts down only the owned devices of the workspace you stop.
Local devices
stim ios selects the newest installed runtime that offers a regular numbered
iPhone model (for example "iPhone 18 Pro"), and the newest such model on it, by
default. It falls back to a special model (no generation number, such as
"iPhone Duo") only when no installed runtime offers a numbered one. stim android selects the newest installed system image matching the host
architecture: arm64-v8a on ARM64 or x86_64 on x64. Set .stim.json
defaults or use ios --device-type, ios --runtime, android --system-image,
and android --device-profile for a specific target. These flags choose the
device Stim creates. When the workspace already owns a device of another model
or version, stim ios and stim android refuse rather than boot it. The one
exception is --system-image, which replaces an emulator that never finished
booting. Pass --slot <name> to create the requested device beside the current
one. Android AVDs use the
pixel_6 hardware profile unless android.deviceProfile or
--device-profile names another one, such as pixel_tablet or pixel_fold.
stim stop shuts down an owned local device but does not delete it. The command
assumes that the caller finished all device automation for that Stim session.
It does not block on other processes attached to the owned device.
Android creation records an owned reservation before running native tools.
Other workspaces can update Stim config during creation, while GC and teardown
keep that reservation. An interruption leaves an incomplete record; retry
stim android to reconcile it through owned-device cleanup. If a per-AVD claim
under ~/.stim/avd-locks is live or unresolved, cleanup refuses. Wait for live
work to finish. Before removing an unresolved claim, verify that neither Stim
nor its native child is still using the AVD, and remove only the named claim.
Keep the AVD data and follow stim guide errors STIM_CLAIM_REFUSED.
Memory pressure and SimSlim
SimSlim is recommended as an optional way to reduce background services and
memory use when running several iOS workspaces. Review the profile against
your app's needs: disabled services can affect tests. Current SimSlim 0.8
requires iOS 18.5 or newer. Run stim guide lifecycle simslim for installation
and profile setup; doctor recommends it without applying it automatically.
To slim every Stim-owned simulator on a Mac without a profile per app, run
stim settings set ios.simslim on --scope machine. A project profile still wins,
and ios.simslim set to off in a project opts that app out. The first slim of
a simulator reboots it once; an already slim simulator is left alone, and a
rerun on the same boot with an unchanged profile skips SimSlim entirely. A manual
simslim off on a running simulator is not noticed until it reboots or the
profile changes. A running
simulator is slimmed on the next stim ios, without a boot first, and Stim mutes
it again after that reboot. A parked simulator is slimmed when Stim adopts it.
Hosted simulators on a named Mac are never changed. If simslim is not
installed, doctor reports it and stim ios boots without it.
Each boot of an owned iOS simulator writes ios_boot records to the workspace's
device log: boot requested (adopted, created, existing or restart), bootstatus
done, SpringBoard started with its pid, viewer opened and SimSlim reboots. When
SpringBoard is missing after boot, Stim also copies the simulator's recent
SpringBoard-*.ips crash reports from ~/Library/Logs/DiagnosticReports into
the workspace logs directory before it restarts the simulator once, because macOS
deletes them after about two days. Read the records with
stim logs --source device --grep "ios boot" and --grep "SpringBoard crash report".
A simulator can report Booted and show SpringBoard while process startup is stalled. Stim checks a bounded process spawn before installation and bounds local launch calls. When macOS reports elevated memory pressure, diagnostics recommend freeing memory before retrying. A timeout or existing swap usage alone does not establish OOM. Stop only workspaces you own and have finished using; ask before closing other agents' simulators or heavy apps. SimSlim can reduce future resource use but is not a guaranteed fix for a stalled host.
During a slow local iOS boot, Stim reports the simulator name, elapsed time, last boot
output, current memory pressure, and highest observed pressure roughly every
15 seconds. Recovery advice appears when pressure first reaches warning or
critical, and again only when the level changes. Boot failures retain the highest pressure and the number of
unavailable readings. Unknown readings are not treated as normal, and pressure
does not prove the cause of a timeout. The boot deadline remains ten minutes
and also covers SpringBoard finishing its startup after simctl bootstatus
returns, so a slow host spends that time in the boot step, where the last boot
output reads waiting for SpringBoard, rather than in the app launch.
Synchronous CLI work can delay observations, progress, and timeout handling.
Diagnostics report observation gaps over 30 seconds; pressure during those gaps
is unobserved, even when surrounding readings are normal.
An agent should stop unused slots in workspaces it owns, reduce concurrent builds, and retry after pressure falls. Ask before closing another agent's simulator or a user's app. Repeated reboots under unchanged pressure can repeat the stall.
Local Android emulator boots also print a memory phase line when macOS reports warning or critical pressure. If boot times out under that pressure, Stim extends the wait once by up to 240 seconds while the process it started is still alive. It waits on the same emulator and never launches a duplicate. New-device preparation waits 120 seconds initially; the later boot check uses 240 seconds. Normal or unavailable pressure, an exited process, or unavailable process liveness does not trigger the extra wait. adb probes are bounded.
Timeout remedies include observed pressure and the running Stim-owned device
count when available. Device count alone does not prove memory pressure or
trigger the extra wait. Specific emulator log errors keep their own remedies.
Stop an unneeded device with stim stop only in a workspace you own, then rerun
stim android with the same build options. Read
stim guide errors STIM_NO_DEVICE for recovery details.
Abnormally large processes
Memory pressure often comes from one process that leaked, not from the
simulators and emulators. stim doctor, and the iOS and Android boot messages
under warning or critical pressure, name any process whose physical footprint is
abnormal. Stim reads it from macOS top, which counts compressed and swapped
memory (ps RSS leaves them out) and can read root-owned daemons. Abnormal means
at least a quarter of physical memory and at least 8 GiB, or 2 GiB for
fseventsd and Watchman, which are normally far smaller.
For a process that is safe to restart, Stim prints the command. It never runs the command itself.
| Process | Command | Effect |
|---|---|---|
fseventsd | sudo killall fseventsd | launchd restarts it; file watchers rescan once |
| Watchman | stim gc --delete --cache watchman | stops it once no workspace uses it |
| Gradle or Kotlin daemon | stim gc --delete --cache gradle-daemons | stops idle daemons; the next build starts one |
Any other abnormal process is named with its size only; ask before quitting it.
Boot does not wait for the top read: the pressure message prints at once and the
named processes follow on a second memory line when top finishes.
Doctor skips this check when STIM_HOME is set, because processes are
machine-global.
While pressure stays elevated, the automatic maintenance check also records the
processes in stim status --json under maintenance.pressure.memory.culprits,
and Stim Desktop shows them under Machines > This Mac > Resource Usage with
a copy button for each command.
Idle shutdown
A stalled agent can leave its simulator or emulator booted for hours. Set
devices.idleShutdownMinutes to have the workspace's dev server supervisor
shut its owned devices down after that many idle minutes. It is on by default
at 30 minutes. Set it for the machine or for one project; 0 turns it off:
stim settings set devices.idleShutdownMinutes 60 --scope machine
A device counts as idle when it is booted, no tool drives it, no stim device lock or agent-device session holds it, no build runs in the workspace, and it
has shown no activity (app logs, device logs, bundle requests, Stim commands, agent actions)
for that long. A phone app viewing the device through stim-server keeps it
up. Stim Desktop's own simulator view does not.
The device is shut down, never deleted, through the same path as stim stop.
Physical devices are never touched. stim status shows
shut down after 30m idle on the device, and the next stim ios or
stim android boots it again.
The supervisor checks once a minute and reads the setting when it starts, so
restart the dev server with stim stop and stim start after changing it.
When metro.idleStopMinutes is shorter, the dev server's idle stop shuts down
the devices idle that long first.
With maintenance.mode on, the default,
automatic maintenance applies the
same setting to devices with no supervisor, such as those of release runs or of
a dev server that exited. Every maintenance.idleCheckMinutes (5) a Stim command
starts a background check that shuts down each owned device idle for its
workspace's devices.idleShutdownMinutes, read fresh each time. It uses the same
idle rules, rechecks under the workspace's native-run lock, records the same
shut down after 30m idle status and device_idle_shutdown log record, and
never touches the workspace of the command that started the check. It covers
what Stim Desktop's Shut down idle devices option does.
Queue reclaim is a separate, faster path. With concurrency.maxDevices set,
the waiting run uses its own effective devices.reclaimIdleMinutes, default 10, to shut down the
longest-idle eligible owned device across this Stim home's other workspaces.
Only the FIFO head reclaims, one device at a time, at most once every 15
seconds, then checks the queue and capacity before another. 0 disables
reclaim; the supervisor's own idle shutdown defaults to 30 minutes.
The same idle check applies: no driver, Stim or agent-device lock, build,
device lock or viewer, and no Stim command, app log, device log, Metro bundle
or agent action within the interval. Reclaim rechecks idleness under the
target workspace's native-run lock and re-resolves ownership through
centralized teardown. It shuts down, never deletes, and excludes the waiting
workspace, physical, hosted, remote, parked and other homes' devices. Lock or
teardown failures are reported once per device and skipped for the rest of
the wait. It
can reclaim the device of any workspace of this Stim home that is idle for
devices.reclaimIdleMinutes, even when the target has no supervisor and even
when its own devices.idleShutdownMinutes is 0. Stim Desktop's simulator
view and manual input in Simulator.app do not count as activity.
The waiting run prints the reclaimed device, workspace and idle minutes on
stderr. The reclaimed workspace's status and device_idle_shutdown log say
reclaimed for a waiting run; wait stats include a positive reclaimed count.
Opt out machine-wide with the command below; the waiter's effective setting decides.
stim settings set devices.reclaimIdleMinutes 0 --scope machine
Agent prompt:
Change Stim's idle device shutdown for this project to 60 minutes with
`stim settings set devices.idleShutdownMinutes 60 --scope workspace`, restart
the dev server with `stim stop` and `stim start`, and confirm with
`stim settings get devices.idleShutdownMinutes`.
Remote devices
Stim supports two optional remote backends:
proxyconnects through an Agent Device daemon that already owns a session.eascreates and owns an EAS simulator session.
A named --remote-build refuses physical and --remote cache misses with
STIM_OFFLOAD_REFUSED. Use auto or local for those builds; an existing
cache hit with a listed paired name needs no worker. Unlisted or unpaired
names refuse at setup before checking the cache. See machine settings.
The app builds locally by default; --eas-profile can instead download an
EAS development build. stim start --remote creates the
Metro route required by the remote device. Remote EAS sessions can incur cost
independently of the build source. Stim has EAS stop every session it creates
after remote.easMaxDurationMinutes (machine setting, default 30), which ends a
session whose workspace was deleted by hand; the next run replaces an expired
session. EAS rejects a value above the account plan's session limit. Stim prepares the app before it creates or
reconnects the session, so a failed build starts no session and the install
follows the connection directly.
An iOS remote build compiles a single simulator architecture. With proxy, Stim
reads the host architecture from the daemon's /health response (hostArch).
It uses arm64 when the daemon does not report one or does not answer within
3 seconds, and always with eas, whose hosts are Apple silicon.
Workspaces can hold EAS sessions at the same time, but only one session starts
at a time on a machine. A --remote eas run that finishes its build while
another workspace is starting a session waits for that start. It prints a
lock waiting for EAS remote start (pid …, in <workspace>, running for …)
line right away and a still waiting line every 30 seconds. If one holder
keeps the lock longer than the slowest EAS session start (39 minutes), the run
refuses with STIM_LOCK_TIMEOUT, names that process, and installs nothing.
From Windows or Linux
--remote eas is the supported way to run iOS from a host without Xcode.
stim doctor --platform ios on such a host points at it instead of at
CocoaPods and simulators. Install eas-cli 21.6.0 or later and agent-device, plus ngrok or
cloudflared for the Metro tunnel, then:
stim start --remote
stim ios --remote eas --eas-profile ios-simulator
stim logs --errors
stim stop
The profile is the simulator profile from EAS builds:
the EAS Simulator runs simulator binaries, so it must set ios.simulator: true.
The build must already exist on EAS; a miss prints the eas build command and
never starts one. stim status lists the running session with its browser
preview URL, and Stim Desktop shows that page as a device tile. stim stop
ends the session and the tunnel.
An install or launch failure leaves the session running and billed. The remedy
names the session: rerun the command to reuse it, or run stim stop to end it.
Metro on your tailnet
For a remote device or an agent-device proxy on the same tailnet, install Tailscale and sign in on both hosts, then:
stim settings set metro.tunnel tailscale --scope workspace
stim start --remote
stim status
tailscale is an explicit metro.tunnel choice; auto never selects it.
Stim runs a foreground tailscale serve --https=<metro port> http://127.0.0.1:<metro port> process. It never enables Funnel. The printed
URL is https://<tailnet-name>:<metro port>, and status reports the recorded
provider and URL. The device must be on the same tailnet, so the eas backend
refuses it; the host may not
reach its own tailnet name, so Stim skips the public bundle probe for its
recorded Tailscale tunnel.
stim stop and stim worktree remove verify and stop only the process Stim
recorded. Ending the foreground process removes its route; a route Stim did
not create is never touched. If the Metro port already has a serve route,
Tailscale refuses and Stim prints Tailscale's error message.
tailscale serve status --json lists foreground sessions under the top-level
Foreground key; plain tailscale serve status does not list them.
Expose Metro on your tailnet
Set metro.tunnel to tailscale for this workspace and start Metro for my remote agent-device proxy on the same tailnet. Show me its URL with stim status, then use stim stop when finished.
Control from the phone viewer
A phone granted control can use the compact bottom toolbar for Keyboard, Home on iOS or Back on Android and web, and both rotation directions. More (the ellipsis button) opens a native menu for Lock, Android Home and Apps, supported foldable postures, and simulator options. Device Frame appears in More when matching artwork is available and remains usable without a control lease. The bar stays below the screen in portrait and landscape and fits its common controls within the available width. The app in front still needs to support the requested orientation. Physical devices rotate only in hand.
Tapping Control explicitly takes over from the displayed driver immediately. If the server reports a driver conflict after that tap, the Take over action also starts control immediately. The server keeps its exclusive control lease and records the takeover. Viewing replay remains read-only.
Replay device screens
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. It keeps the last 15 minutes of footage per device, so you can
see what an agent did while you were away.
In Stim Desktop the device viewer shows a replay bar under the screen; in the phone app the replay controls sit over the bottom of the screen and fade out until you tap it:
- Scrub. Drag to show the frame at that time.
- Markers. Tap one to land just before an agent action or an app error.
- Play. Play at 1x or 2x, then tap Live to return.
- Step. In the phone app, jump to the previous or next agent action, and see the device's last agent actions under the screen.
Control is off while you look at the past. Time when nothing was recorded, such
as after stim stop, shows as a gap, and a stopped device's last footage stays
replayable.
Recordings stay on your machine and are served only to paired phones and Stim Desktop. Turn them off for the whole machine in the phone's Settings or in Stim Desktop's Phones settings, or on the Mac, where a repository or workspace can also be set on its own:
stim settings set recording.enabled false --scope machine
See Device recordings for scopes and
STIM_RECORDING, and
Inspect and clean caches for cleanup.
Try it with an agent:
Run this app with stim ios, then drive it with agent-device for a minute:
open a few screens and fill a form. Tell me when you are done so I can scrub
back through it in the Stim phone app.
Cleanup behavior
stim stopreleases the active environment and device leases. It ends an owned remote session. On a physical iPhone, stopping the log collector also closes the app; it does not shut down the phone or uninstall anything.stim worktree removereleases leases, parks eligible owned iOS simulators and Android emulators for another workspace. Each platform keeps up to three parked devices by default and deletes the oldest when full. Disabling parking makes removal delete the device; see settings. Under disk pressure level 2 or 3 removal deletes the devices instead of parking them, and says so in its output.- Automatic maintenance treats owned simulators and emulators as disposable:
the next run recreates them. It deletes the owned devices of a workspace
that is idle, meaning no dev server, native run, build, lease, managed lock,
driven or busy device, and no recorded use, for a threshold: 10 minutes
(
maintenance.pressureProtectMinutes) under disk pressure level 2 or 3, 3 days (maintenance.deviceIdleDays) otherwise, andgc.worktreeGraceMinutesfor a workspace whose worktree is finished but kept. A workspace pinned withmaintenance.keepkeeps its devices.stim statusshows the disk they use. Its daily age sweep also deletes parked simulators and emulators parked formaintenance.olderThanDays(7 days; 0 never), through centralized teardown. stim gcreports stale and orphaned resources.stim gc --jsonprints the same report as one JSON object.stim gc --deleteremoves verified resources from the report, including parked simulators, emulators, and expired device lease files. With--older-than <days>, it removes only the devices parked at least that long. It also deletes the owned devices of a linked worktree whose branch is merged or whose pull request ended but that it keeps, for example because it is dirty, under the same idle rules as automatic maintenance.stim gcprints the disk owned devices use asOwned device disk.stim gc --cache parkedlists only the parked devices, with the app each one holds and its size. With--deleteit erases each verified parked simulator (simctl erase) and wipes each parked emulator's user data and snapshots, and keeps them parked for the next workspace.
Before shutting down, parking or deleting an owned simulator or emulator, Stim
attempts to close local agent-device sessions on that exact iOS UDID or live
Android serial. An Android session must also name the owned AVD, because the
next emulator on a console port reuses the serial. For an emulator that is
already shut down, Stim closes the sessions that name its AVD on any serial
that no connected device now holds. stim stop (including
stop --slot) closes a session only when agent-device's claim on the device
names that session and was taken inside the workspace being stopped. A session
claimed from the workspace's git root, such as the worktree root when the app
lives in a subdirectory, is closed too, but only after stop has shut its
device down and Stim has checked that the device is still stopped and that no
other device holds its serial. Stim
rechecks ownership and asks agent-device to reject a close if the session now
targets another device. Sessions from another workspace or claim, including
sibling directories under the git root, sessions on other devices, and physical
devices stay open. The integration is optional: a
missing binary skips cleanup; failures print a warning and device teardown
continues. Agent-device calls have
a combined 15-second budget per device and pass (the pass stop makes after
shutdown has its own), and require local socket transport and
support for --session-lock reject. Remote daemons are never used.
An owned emulator counts as stopped only when no emulator process launched for
its AVD is left. Stim asks it to quit with adb emu kill. A hung emulator that
ignores that for 60 seconds, or that adb cannot reach, gets SIGTERM and then
SIGKILL, but only after Stim verifies that the process runs that AVD and is the
same process it saw before shutdown. Windows has no such check, so Stim signals
nothing there. If the emulator still runs, stop, worktree remove and
gc report teardown failed and keep the device record instead of reporting it
stopped or parked.
If deletion fails, Stim keeps the ownership record and exits with an error. A later cleanup can then retry without losing track of the resource.
Parked-device adoption and deletion share a process-identity claim. The next
attempt recovers a proven dead or different owner, while a live owner keeps
the device protected. An opaque deletion marker also protects the device from
older Stim versions. When that marker outlives its owner, adoption treats the
device as erased and drops its recorded app, cache key and scheme approvals.
An erase by gc --cache parked --delete that fails after the erase command
started keeps the device parked without them for the same reason.
If Stim dies during a device-tool call, its native child
may still be running: inspect both before following the claim's removal remedy.
Older inline deletionClaim fields require manual inspection and removal of
only that field; keep the device and pool record. Run stim guide lifecycle pool
for the recovery steps.
Runtime state lives under $STIM_HOME, which defaults to ~/.stim. Each
workspace stores state and logs in a directory derived from its absolute path.
Parking keeps the device as it is: the app stays installed and the pool
record names it. A parked device can therefore hold gigabytes until
stim gc --cache parked --delete erases it. Stim parks a simulator only after
it reports Shutdown: it waits up to 15 seconds, retries the shutdown once,
and waits up to 15 seconds more. A simulator that stays booted is deleted
instead, and stim worktree remove prints could not park <name>: ... -- deleted it instead.
Adoption gives the new workspace a clean app state without reinstalling. It
resets privacy grants and the keychain, uninstalls every other user app, and
clears the workspace app's data: on iOS it empties the app's data container
and resets its preferences, and on Android it runs pm clear. It does this on
every adoption, whichever workspace parked the device. The reset preferences
of an iOS expo-dev-client app hold only the two that hide the Expo dev menu and
its Tools button, so the menu stays off when an agent or a crash relaunches
the app. Stim then compares the installed app with the requested build and
skips the install when they match, so a new worktree running the
same build as the parked device boots it warm and launches without copying the
app. On iOS it hashes both bundles. On Android it compares the installed APK's
size and the SHA-256 of its signing block, central directory and end record
with values recorded when the build was cached, so the check is one adb shell
call instead of reading the whole APK. App-group
containers, photos, the pasteboard and Simulator settings are not cleared.
Android adoption requires the same system image, disk size and AVD creation
settings. It keeps the AVD name. An emulator's /data partition is fixed, so
before installing, adoption checks its free space. When there is less than
twice the APK size plus 512 MB, Stim trims app caches, and if that is not
enough it wipes the emulator's user data, boots it again and says so in the
output. An install that fails with INSTALL_FAILED_INSUFFICIENT_STORAGE gets
the same cleanup and retries. Simulators use the Mac's disk, so iOS has no such
check. AVDs created before Stim recorded their creation configuration are
deleted when removed.
Run iOS on another Mac
If Stim is not installed globally, replace stim with npx stim.
Use the hosting approval setup, then name the Mac:
- Global
- npx
stim ios --remote janics-mac-mini --device-type "iPhone 17 Pro" --runtime "iOS 27.0"
stim status
stim reload ios
stim stop
npx stim ios --remote janics-mac-mini --device-type "iPhone 17 Pro" --runtime "iOS 27.0"
npx stim status
npx stim reload ios
npx stim stop
Set ios.remote to that name for a default: --scope machine for every app on this machine, or a workspace, repo or committed scope for one app. eas and proxy keep
those backend meanings. No flag or setting runs here. auto places iOS or
Android on an approved Mac when this machine is full or busy; see
automatic device placement. --device and --simulator-app cannot target a hosting Mac; a --device run ignores ios.remote.
The host boots headless; this machine's viewer setting is ignored.
--slot, --scheme, --configuration and --eas-profile work as usual.
Model and runtime selectors use the host's installed choices. The build targets
its simulator architecture. --remote-build independently selects a compatible
Debug build worker. A named host never falls back: STIM_HOSTING_REFUSED names
unreachable, declined or uncertain placement and its reason.
Metro stays on this machine. Its supervisor runs a private tailnet gateway to the
host's loopback bridge; no stim start --remote is needed. For an Expo dev
client the bridge listens on this machine's Metro port, because the dev server
writes that port into the manifest's bundle URL. When the iOS run starts Metro,
it asks the host first and picks a port free on both Macs, moving the workspace's
recorded port if the host uses it. A Metro that is already running on a port the
host uses, or a metro.port pin the host uses, refuses before the build: run
stim stop, then stim ios --remote <mac> without stim start, or pin a port
free on both Macs. metro.tunnel and
metro.publicUrl are ignored, and non-Debug runs skip Metro. Hosted Debug runs
require a running supervisor with private gateway support before reserving a
simulator; --no-metro-check refuses with STIM_BAD_ARG. If Metro is missing or
uses an older supervisor, run stim stop; stim start, then retry. Fast Refresh and
stim reload ios use this workspace's Metro. launched stays unverified
until bundle evidence, or positive native process evidence for a release run.
Reruns reattach to the recorded session and upload a new app attempt. A stopped
or missing session is replaced. An unreachable or unknown session refuses
replacement. Switching between this machine and a hosting Mac, or to a different
hosting Mac, requires stim stop first for that slot.
status --json adds ios.host with machine, session, selected, the device's
name and runtime, agent, and the probed state. A shutdown local simulator
is replaced in status without a warning. A booted or unknown local simulator
stays visible alongside ios.host with a warning; plain status shows both. The
default slot appears only in ios, never in slots[]. The host UDID and private gateway never appear
in local device fields. Stop and worktree removal wait for the host, park its
eligible owned simulator within the host's pool.iosParkedMax limit (delete
otherwise), then clear placement. stop --json reports
each hosted slot under outcomes.device["ios:host:<slot>"], alongside local
outcomes; one failure does not hide siblings that stopped. An unreachable host
keeps the placement and fails cleanup; rerun stim stop when it answers. Missing
or unapproved local credentials require stim doctor to restore access first.
Unreadable machine or session state stays recorded: status warns, and stop names
the workspace-state key to inspect. Only that slot's iOS run refuses; restore
its recorded machine and session from the host, then run stim stop for that
slot. Reload reports the failure and remedy in JSON when no other app can
reload, and logs remain readable. A hello refusal after this machine loses hosting
access clears its placement during stop or worktree removal; other refusals
retain it.
A clean host server restart stops its sessions and preserves eligible parked
devices for adoption by the same client. A running session left by a crash stays
unknown until explicit stop; booted devices are never re-attached after restart.
On the hosting Mac, stim gc lists parked hosted devices and stim gc --delete
deletes them through their owning session homes under a session claim. A held
claim or unverified ledger ownership keeps the device. Android deletion requires
the AVD to be visible from this shell; otherwise run gc with the server's
ANDROID_AVD_HOME/HOME. Records no longer listed in their session ledger are
already removed and skipped. The server reconciles
its parked marker after deletion empties the ledger.
See hosted parking for cleanup,
compatibility and eviction.
Reruns upload only missing manifest and file digests from a session-scoped store; the host verifies bytes before reuse. When an offloaded iOS simulator build ran on the hosting Mac's own stim-server, approved for the same client key, the host takes files that match the client's manifest from that build. A refused or timed-out handoff falls back to upload, retrying a still-busy host for at most one minute.
stim logs and stim logs --errors pull native device records through bounded
host queries. Concurrent followers share a collection, throttled per session,
without blocking app delivery, viewing or control. Stop limits each log drain to
30 seconds with progress on stderr and stops if neither cursor nor collection
checkpoint advances. The host captures a bounded final tail before parking or deletion,
also on revocation or server close, and stop copies it back afterwards. If that
collection drops a backlog interval and eventually succeeds, a device warning
record names the interval.
A damaged collection checkpoint is ignored and rebuilt; collected records remain
readable. The client waits up to 280 seconds for stop. The final collection,
parking, the stop that follows a failed park and worker termination paths fit
within that wait; an in-flight handoff copy and
closing Metro or view transports are outside those worker bounds. Collected
records remain in the session home; app blobs and materialized bundles are removed.
Native queries read persisted entries, overlap by five seconds and de-duplicate;
info-level or later-persisted entries may be unavailable. JavaScript logs reach Metro.
An older host prints an update note, uses uploads and shows previously copied logs.
Stim Desktop shows the simulator with the machine's name and the phone app with an on <machine> label. Turn on Serve to phones in Desktop and pair the phone with the client machine. Both view and control it through that machine's local stim-server relay; neither connects directly to the hosting Mac. Named slots work independently. Touch and text work in Desktop. Local Simulator.app, rotation, hardware buttons and simulator options are hidden there. The phone also sends supported buttons, rotation and the postures reported by the host. Hosted viewing has no replay, Duo frame rendering or physical-device target. A stopped session shows a rerun command; an unavailable host reports the connection failure.
devices.idleShutdownMinutes does not stop hosted
simulators in this phase; their recorded sessions prevent Metro idle stop.
For agent control, set hosting.agentDriver to agent-device on the hosting Mac.
Both machines need agent-device 0.21.20 or later, with the daemon policy and remote-config contract.
The host starts one loopback daemon per installed session, under an ownership claim
whose child is that daemon. Its policy allows exactly the session's simulator UDID and
refuses shutdown. Stop, close, revocation, reinstall and graceful server close stop
the daemon and invalidate its grant. A server crash can leave detached daemon
processes and claims; restart alone does not prove they stopped. An unexpected exit restarts only that session; rerun
stim ios to refresh the rotated grant.
An unresolved proxy or daemon retains its claim and blocks reinstall and native
teardown. Clear a named claim only after proving its owner and child are gone.
ios.host.agent reports driver, remoteConfig and command. Plain status prints
agent: <command>. Each slot has its own 0600 config under the workspace's Stim
state directory; tokens never appear in placement or status. Use that file:
agent-device open <bundleId> --remote-config <file>
agent-device snapshot --remote-config <file>
agent-device click <ref> --remote-config <file>
agent-device screenshot --remote-config <file>
The command shape is agent-device <command> --remote-config <file>. open
automatically allocates an ios-instance lease through the proxy provider in
this session's tenant. The config contains daemonBaseUrl, daemonAuthToken,
tenant, sessionIsolation, runId, clientId, deviceKey, leaseBackend,
leaseProvider and platform, with no preallocated leaseId. close releases
the lease. stim stop closes the matching connection and removes that slot's
config once the host confirms stop or revocation. An unreachable host keeps the
config and placement for retry.
The host stops the session daemon and identity-checked agent-device runners
targeting its exact simulator UDID before deleting the simulator, even without close.
Allowed commands are devices, open, close, snapshot, diff, wait, find, get,
is, click, fill, press, type, focus, scroll, screenshot,
longpress, swipe, back, home, orientation, appstate, alert and batch.
They inspect and interact with the simulator and its installed apps. devices
lists only the session's simulator, filtered by the daemon policy. Its device
claims are session-local and record / as the workspace; a host-side agent-device
using the same simulator is not visible to the hosted daemon. Client device
selectors are stripped and requests are pinned to iOS and that UDID; an explicitly
named foreign UDID is refused. Selecting another device, boot/shutdown/erase,
installs, uninstall, uploads, push, record, logs, network, perf, trace,
clipboard and settings are refused. Stim installs the app; agents cannot
enumerate other host devices or write host files. Ambient client fields (cwd,
stateDir, config and remoteConfig) are stripped, including within batch steps.
Host paths and launch inputs (--out, baseline, launchConsole, developerDir,
installSource and similar fields) are refused, including within batch steps.
Runtime hints are dropped and only reporting/artifact metadata is forwarded. Screenshots accept agent-device's generated remote temp artifact,
which the client downloads, rather than arbitrary host destinations.
If the setting is none, the daemon cannot enforce the policy, or the host
lacks the hosted-ios-agent hello feature, agent access stays
{ driver: 'none', setting: 'hosting.agentDriver' }. Update the host and check
its agent-device installation and setting. This never grants another device.
Client hello carries only name and version, not features, so the host cannot gate
daemon startup or grant issuance on client support. The client checks the host's
hosted-ios-agent feature before using a grant.
Copy this prompt into your coding agent:
Run this workspace's iOS app on janics-mac-mini with stim ios --remote janics-mac-mini.
Use the host's offered simulator architecture, keep Metro here, report launch evidence
and status. Read ios.host.agent from stim status --json, open the app with
agent-device open <bundleId> --remote-config <file>, then snapshot and click through
that config. Run stim stop when finished. If the host refuses, report its reason.
Paired Mac hosting approval
Device-host approval is separate from build offloading and phone control. On the
client machine, set remote.machines to the expected tailnet names, then run
stim doctor --fix in an app directory to request approval. Doctor stores a
separate private, node-pinned credential and reports its state under
deviceHosts in JSON. iOS and macOS can run on a named approved Mac.
See machine settings.
A person approves device-host access by running the stim-server setup
command that Stim Desktop's Add Machine flow or stim doctor --fix on the
requesting machine shows, with that machine's setup ticket. Inspect approvals
with stim-server devices and remove one with stim-server devices revoke <id>.
Hosting approval permits that client's native app code to run in its own
hosted simulators, emulators or macOS app sessions. Approve only an expected client.
Before reserving, an approved client can query the hosted availability offer protocol for an installed iOS or Android SDK choice or the macOS host version, host architecture, current hosted reservation capacity, memory pressure, load and free disk on the Stim home volume. An unavailable or unknown choice carries a decline reason. This query creates no device or session. Offers are advisory snapshots; reserve still admits atomically and rechecks native conditions. Ordinary local device producers do not participate in hosted capacity, and Android AVD storage can be on a different volume. Named iOS placement uses these offers before building or reserving.
An approved client can reserve, boot, reconnect to and stop its own iOS simulator
through the hosted session protocol.
It can also transfer a digest-verified simulator .app bundle and install and
launch it through the app delivery protocol.
The worker checks the expected bundle identity, simulator platform, executable
architecture and minimum OS. Bundle links must stay inside the app. Uploads
resume at recorded byte offsets; replaying a completed app attempt returns its
result without launching twice. Stop and revocation cancel installation before
deleting that exact owned simulator.
Replaying an attempt resolves the same session after a lost reply. Unknown
creation or deletion outcomes retain the reservation until exact reconciliation;
explicit stop and approval revocation delete only that session's exact
ledger-owned simulator. At start, stim-server retires leftover stopped iOS and
Android devices while retaining session homes and records.
Hosted reservations are atomic with each other; ordinary local device producers
do not participate, so this is not a machine-wide hard capacity guarantee.
The worker also accepts an Android reservation with optional installed
systemImage and deviceProfile selectors. It gives that opaque session a
unique owned AVD and a journal-reserved console port, verifies the exact running
AVD and ABI, and reconnects to that same record. Explicit stop and revocation
delete only that exact ledger-owned AVD and retain its session home and record. Ports are coordinated
among hosted sessions; ordinary local producers can still race a reservation.
Android Metro and screen/input routing remain follow-ups in #2266;
Android sessions refuse the iOS Metro, view and input routes. The
protocol details are in the
hosted Android session protocol.
Android app delivery uses the same resumable transfer methods with a single
App.apk file and the expected package identity. The worker checks byte digests,
minimum SDK, native ABI and its exact ledger-owned running AVD before install
and launch. An installed app attempt replays without launching twice. An install
or signature conflict refuses without uninstalling an existing app. See the
Android app delivery protocol.
The host also accepts platform: "macos" without selectors. It reserves one
of 64 app slots and accepts a prebuilt release .app with a digest-verified
Contents/Info.plist and executable under Contents/MacOS/. The worker checks
bundle identity, architecture and minimum macOS version, refuses Apple bundle
identities, URL registrations and update feeds, then stamps
<bundleId>.hosted<appSlot> and ad hoc signs the app. The owned macOS supervisor
runs it with a private home and temporary directory; this is not an OS sandbox.
Stop and revocation stop only the recorded processes and remove the last hosted
identity's preferences domain and plist, the private app home and delivered bytes,
keeping logs and receipts. Unresolved sessions retain their app slots.
Until a driver runs, the host reports agent: { driver: 'none' } for installed macOS apps, and stim doctor on the host names hosting.agentDriver.
macOS hosting supports viewing and control of the running app on the host. Metro
is refused; see macOS placement.
See the macOS session protocol.
Development launches remain unverified until a Metro bridge proves a bundle
request. Release launches report success only after positive native process
evidence; absent evidence remains unverified.
The private Metro protocol routes the hosted iOS app through worker loopback to its client's verified Metro over the tailnet. The client gateway accepts only the pinned worker and a session secret. Reconnect keeps the worker port while its session owner lives; stop and revocation close the streams. No public tunnel or Funnel is needed. Expo development clients need the forwarded-origin manifest contract described in that protocol; older client/CLI versions are not covered by this slice.
Automatic placement and Android Metro and viewing remain in #2266.
This protocol slice does not change where stim ios or stim android runs.
Hosting approval grants no access to unrelated
workspaces, phone control or build offloading.
The hosted view and input protocol
streams and controls only the approved client's ready, owned iOS simulator or
running macOS app. macOS frames show only the hosted app's window. Viewing is
refused before launch and after exit. On the host, grant Screen & System Audio Recording and
Device Control and Data Access (Accessibility on macOS 26 and earlier) once to the
app that runs stim-server: Stim Host for a server run by
stim-server service install, or Stim Desktop. The worker derives
the exact simulator or app identity from its private records and keeps the
capture helper under the session's ownership claim. Reconnect preserves the
native session; subscribe and begin control again. Installation, stop and
revocation close capture and input before native work. An uncertain journal
retains native ownership while ending known capture. Surviving or unresolved
posture input blocks native replacement and teardown after a server crash.
Automatic CLI placement remains under #2266.