warden dev
Run the app in a worktree on a leased simulator with its own Metro port, installing the cached dev build instead of rebuilding.
warden dev replaces expo run:ios in a worktree: it claims a device, installs the dev build cached for your native fingerprint, starts Metro on a leased port and opens the dev client. Use it whenever you or your agent need the app running.
warden dev # ios by default
warden dev android # an emulator
warden dev ios -- --clear # extra args go to `expo start`
warden dev ios --json # one line { udid, port, metroUrl, leaseIds } once Metro is readyWhat it does
- Finds the app and checks what a dev-client run needs (see Before it starts).
- Claims a device and leases a Metro port (default range
8081:100). - Shows the simulator (iOS, macOS): brings its window to the front, launching Device Hub (Xcode 27) or Simulator.app (Xcode ≤ 26) when no viewer is running.
--headlessskips this. Android emulators stay windowless. - Installs the build for this fingerprint: installed, cache, EAS, then local build.
- Starts Metro on the leased port.
- Opens the dev client once the port is verified to serve this checkout.
warden dev owns its Metro. A Metro left running by another worktree is refused, never attached to. It holds the device and the port until Metro exits; q or Ctrl-C in the dashboard (Ctrl-C in plain output), a kill or the terminal closing (SIGHUP) stops Metro and releases both. In the dashboard's interact mode Ctrl-C goes to Expo, which exits and ends the session. The simulator dev booted is shut down with the session and its Device Hub / Simulator.app window is closed, so nothing is left running or showing; one that was already booted when dev leased it, or one you passed with --udid / --lease, is left on. --no-shutdown keeps dev's own sim booted for the next run (a boot is 10-30 s). A hard kill (kill -9, a crashed terminal app) skips all of this; warden gc shuts the sim down once its lease has gone stale. The --json line is printed first, so an agent can read the udid and metroUrl and pass them to warden ui open <bundleId> --metro <metroUrl>.
Metro gets WARDEN_WORKTREE (the checkout directory name) and WARDEN_BRANCH (absent on a detached HEAD) in its environment, so app.config.ts can name the dev server after the worktree. Native builds never see them, so the fingerprint is unchanged.
Before it starts
Everything that can be wrong with the request or the app is found on the plain terminal, before a device is leased, anything is built or the dashboard takes the screen. A failure there is one line with the fix, never a dashboard that flashes and closes.
- No config needed.
warden devruns with or without awarden.config.ts. Started at a monorepo root (nothing found upwards), it looks below it: the package.jsonworkspaces, elseapps/*andpackages/*. One app there is used and named (app apps/mobile (wallet)). With several, a person picks one, and a pipe gets the list and--project <dir>. - Expo or bare React Native. Read from the app's package.json:
expomeansexpo start --dev-clientand the dev client. Onlyreact-nativemeans a bare app: the bundle ids come fromios/*.xcodeproj(PRODUCT_BUNDLE_IDENTIFIER) andandroid/app/build.gradle(applicationId), Metro isreact-native starton the leased port, the default local build isreact-native build-ios/build-android, and the app itself is relaunched against Metro (iOS:-RCT_jsLocation 127.0.0.1:<port>; Android: the device's 8081 reversed to the port).projects[].frameworkin awarden.config.tsoverrides the guess.warden e2estays Expo-only. - The project. The nearest
warden.config.ts, else the app'sapp.json. An app whose config is code (app.config.ts/.js/.mjs/.cjs) with no bundle id inapp.jsonis resolved the way Expo does it:warden devrunsexpo config --json --type publicin the app directory once and takes the bundle id, slug and scheme from the result.--bundle-idstill overrides it, and awarden.config.tsskips the call. expo-dev-client. Metro is started with--dev-client, so an Expo app has to list it. A bare app needs none.
With a person at the terminal each missing piece is offered as a fix:
- a package that is not in
package.json: Add and install it now? runsexpo install <package>through the package manager the checkout uses (yarn,pnpm,bunxornpx, from the nearest lockfile) in the app directory, then checks again; - an app read through
expo config: Write warden.config.ts? writes awarden.config.tsnext to the app with the project name and the resolved bundle id, so later starts skip the call (warden initwrites the same file plus a first e2e spec). In a checkout that formats with prettier the file is run through the checkout's prettier, so its format check stays green.
Declining a blocking fix ends the run with the command to run yourself. Without a terminal (--json, a pipe, CI) nothing is asked: the blocking issues are the error, each with its command, and the optional ones a note.
When a failure happens after the dashboard opened (the claim, the build, Metro dying), its lines are printed to the terminal once the dashboard has closed, followed by the session log directory, so the reason is never lost with the screen.
Dashboard
On an interactive terminal warden dev draws a dashboard instead of printing lines. Tasks are listed on the left: claim device, show simulator, dev build, metro, dev client, session. Every key you press adds a row of its own (a screenshot, a reinstall). The right side shows the live output of the selected row. The metro row shows Metro's real terminal, so Expo's QR code and key menu look the same as outside warden. When the terminal is at least 100 columns wide a HUD box sits at the top right.
warden dev # dashboard on a terminal
warden dev --no-tui # plain output, Metro on this terminalTwo modes
- List mode is the default. Warden's keys apply.
- Interact mode starts with
Enterorion the metro row. Every key, Ctrl-C included, goes to Expo until you pressEsc. Expo's own menu keeps its exact bindings (a,i,w,j,r,m,shift+m,o,?). Exiting Expo ends the session the normal way. Esc Escin quick succession sends a literal Esc to Expo.Shift+PgUp/Shift+PgDnscroll Metro's history.
Keys
List mode:
| Key | What it does |
|---|---|
↑ ↓ j k | select a task |
Enter i | interact with Metro (see above) |
Tab | focus the log pane |
r | reload the app: sends r to Metro |
d | show the simulator window (iOS; a note on Android, which has none) |
o | open the dev client again |
s | screenshot, saved under the session's shots/ directory |
R | reinstall the dev build, then reopen the client. Metro keeps running |
x | restart the simulator and reopen the client (iOS only) |
c | restart Metro with --clear on the same port |
l | start or stop streaming device logs into a row |
p | start a perf session on this device, or stop the one recording |
P | start a perf session recording every capture |
m | add a perf mark: asks for a label in the footer (Enter adds, Esc cancels) |
f | frame stats (Android; the iOS simulator has no frame API) |
g | show the report of the last perf session |
h | show or hide the HUD |
? | key help |
q Ctrl-C | quit: stops Metro and releases the device and port |
Ctrl-L | redraw |
Log pane (after Tab): ↑ ↓ scroll a line, PgUp PgDn a page, g G jump to the top or bottom, Tab or Esc go back to the list, q quits. Help overlay: Esc, ?, q or Enter close it.
Guard rails:
- Keys wait for the dev client: before it opens they report
not readyin their row. R,xandcrun one at a time, and a key already running reportsstill running.- One perf session records per device.
Randxare refused while one records, so the app is not killed under the recording;pstops it first.pwhile another process holds a session on the device reports that and nameswarden perf stop. gis refused while the session still records. A session that reaches its cap stops itself andgshows its report.mis refused when nothing records.xis iOS only.
HUD
The HUD box samples the dev client every second:
| iOS simulator | Android emulator | |
|---|---|---|
| CPU % with a short history | yes | yes |
| Memory | RSS | PSS |
| Frames per second and jank % | no frame API | yes |
It also shows Metro's last bundle time and module count, and a recording clock with the mark count while a perf session records. h toggles it, --no-hud leaves it off, and --hud-interval <duration> sets the sampling interval (default 1s, minimum 200ms; a smaller value is an error). The box is hidden below 100 columns.
Session logs
Everything the dashboard shows is also written to $WARDEN_HOME/dev/<leaseId>/:
| File | Holds |
|---|---|
warden.log | every task row's lines, one per line with a timestamp and the row name |
metro.log | Metro's raw terminal output |
shots/ | screenshots from s |
warden gc removes a session directory once its lease is gone and its newest file is older than 24 hours.
When the dashboard is off
warden dev prints plain lines, with Metro on your terminal, when any of these holds:
--json, or the session is not a plain interactive terminal (pipes, CI).--no-tuiorWARDEN_TUI=0.
A terminal under 60 columns or 16 rows shows terminal too small until it is resized; the session keeps running. Agents should keep running warden dev ios --json: the dashboard is for people and never changes the --json output.
Why not expo run:*
expo run:ios rebuilds natively in every worktree: Xcode's DerivedData is keyed by the checkout's path, so a fresh worktree compiles every pod again even when the native code is identical. warden caches the built app by native fingerprint, shared by every worktree of the repo.
- Same fingerprint as any earlier build (any worktree, EAS or local): no prebuild, no Xcode, seconds to a running app.
- A native change: one local build, cached for every other worktree.
- Two worktrees missing the same hash at once share one build (build lock).
Options
| Flag | Effect |
|---|---|
--profile <slug> · --runtime <runtime> | device profile (iphone-17, pixel-10) and runtime (latest, 26.5) |
--udid <udid> · --lease <id> | use this device instead of claiming one (also the default when this session already holds one device lease). A held sim that is shut down is booted and waited for first, but only under this session's own lease; a foreign one is never booted |
--port <from:span> | Metro port range to lease from (default 8081:100) |
--project <dir> · --bundle-id <id> | project directory (default cwd); override the bundle id / package |
--variant <name> | projects[].variants build (default dev when the project has one) |
--scheme <scheme> | dev-client URL scheme (default exp+<slug>) |
--ready-timeout <duration> | wait this long for Metro (default 2m) |
--no-eas · --no-build | skip EAS downloads; do not build locally on a cache miss |
--install-mode clean|reinstall | clean: fresh app data each run without reinstalling an unchanged build; reinstall: uninstall + install each run. --reset-keychain also resets the simulator keychain |
--no-open | start Metro and take the leases only |
--no-tui | plain output: Metro on this terminal, no dashboard (also WARDEN_TUI=0; --json and pipes are always plain) |
--no-hud · --hud-interval <duration> | dashboard only: no perf HUD; HUD sampling interval (default 1s, minimum 200ms) |
--no-shutdown | leave the simulator booted when dev ends (default: shut down the one dev booted) |
--wait <duration> · --no-wait · --priority <p> | boot-queue behaviour (default --wait 30m) |
--headless · --no-headless | --headless: don't show the simulator and close its Simulator.app windows; the default shows it |
--count <n> · --max <n> · --ttl <duration> · --label <label> · --adopt | as for warden claim |
Variants
A dev client and an e2e build can sit side by side in warden.config.ts:
projects: [{
name: "app",
bundleId: { ios: "com.example.app" },
build: { configuration: "Release" }, // e2e: JS embedded
fingerprint: { include: "native+js", jsInputs: ["src/**"] },
variants: {
dev: { build: { configuration: "Debug" }, fingerprint: { include: "native" } }, // dev client: JS from Metro
},
}]warden dev picks dev when it exists. A variant's keys replace the project's own wholesale. Compiler caches, prebuilt React Native and the cache order are in App builds.
warden ui
Look at and drive a leased iOS simulator or Android emulator from the command line: snapshot, refs, selectors, actions, waits, devtools, exit codes and lease rules.
Debug the running app
Metro bundler output, the app's console and exceptions, network, eval, logpoints and profiles of a warden dev session, from the CLI or over MCP.