# CLI (/docs/reference/cli)



This is the exhaustive list of `warden` commands and flags; use it to look up an option, and `warden <command> --help` for the version you have installed.

## Quick reference [#quick-reference]

| Need                                         | Command                                                                                           |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| one sim / emulator                           | `warden claim ios --json` · `warden claim android --json`                                         |
| N devices + ports for a script               | `warden run ios --count N --port 8091:20 -- <cmd>`                                                |
| One job per device, queued                   | `warden batch ios --count N --jobs-from jobs.txt -- <cmd {job} {udid}>`                           |
| a saved batch from `warden.config.ts`        | `warden batch <preset> [flags] [-- <cmd>]`                                                        |
| which e2e specs a change needs               | `warden affected [suite] --base main --explain`                                                   |
| run them and gate on the required ones       | `warden e2e [suite] --base main --count N`                                                        |
| a free port                                  | `warden port claim --json`                                                                        |
| dev build installed on the device            | `warden app ensure ios --udid <udid> --json`                                                      |
| duplicate a shut-down sim                    | `warden clone <udid\|name> [--name x]`                                                            |
| pre-build the golden image                   | `warden golden ensure --profile iphone-17`                                                        |
| who holds what                               | `warden ls`                                                                                       |
| every sim / emulator + who leases it         | `warden devices` (alias `warden list`)                                                            |
| lighten a booted sim (fewer daemons)         | `warden sim slim <udid>\|--booted [--dry-run] [--restore]`                                        |
| tidy Simulator.app windows                   | `warden arrange`                                                                                  |
| look at / drive a leased device              | `warden ui snapshot -i --udid <udid>` · `warden ui press @e3 --settle` ([guide](/docs/agents/ui)) |
| record a session as a spec                   | `warden ui record start <id>` → drive → `warden ui record stop` ([guide](/docs/e2e/author-specs)) |
| is this device free / mine?                  | `warden check --udid <udid>` (exit 2 = someone else's)                                            |
| keep a long lease alive                      | `warden heartbeat --mine`                                                                         |
| reclaim dead leases                          | `warden gc`                                                                                       |
| sim + runtime disk usage, what can go        | `warden sims` · `warden sims prune --dry-run`                                                     |
| pick sims to delete (any owner)              | `warden sims delete` (`--suggested -y` off a terminal)                                            |
| is the agent loop fast enough?               | `warden ui bench [--runs n] [--json]`                                                             |
| what profiling needs on this host / device   | `warden perf doctor`                                                                              |
| app CPU / memory now, or live                | `warden perf metrics [--watch]`                                                                   |
| JS hotspots (source-mapped)                  | `warden perf js --duration 10s`                                                                   |
| cold start time                              | `warden perf startup --cold --runs 5 --claim`                                                     |
| why does this screen re-render?              | `warden perf react --duration 10s`                                                                |
| native CPU / hangs / leaks                   | `warden perf native --duration 10s`                                                               |
| the boot queue and host budget               | `warden capacity` · `warden queue`                                                                |
| a dev client per worktree                    | `warden dev` ([guide](/docs/agents/dev))                                                          |
| set an Expo app up for warden                | `warden init`                                                                                     |
| install warden + agent hooks, tab completion | `warden install` · `warden completion zsh`                                                        |
| the agent skill                              | `warden skill show` · `warden skill install`                                                      |
| setup problems                               | `warden doctor`                                                                                   |
| update warden                                | `warden update` (`--check` to only look)                                                          |

## Devices [#devices]

### `warden claim [platform]` [#warden-claim-platform]

Lease iOS sims / Android emulators — reuse, boot or create — booted and ready. `platform` is `ios | android`, asked for when omitted in a terminal.

| Flag                                             | Default                                |                                                                                                                                                                 |
| ------------------------------------------------ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--profile <slug>`                               | `iphone-17` / first AVD                | device profile, e.g. `iphone-17`, `pixel-10`                                                                                                                    |
| `--runtime <runtime>`                            | `latest`                               | `latest`, `iOS-26-5`, `26.5` …                                                                                                                                  |
| `--count <n>`                                    | `1`                                    | how many devices                                                                                                                                                |
| `--max <n>`                                      | what the machine's capacity allows     | max warden devices of this profile (pool size; machine-wide boots are capped by [capacity](/docs/devices/capacity))                                             |
| `--wait <duration>`                              | `30m`                                  | how long to wait for a free device or a slot in the boot queue, e.g. `10m`                                                                                      |
| `--no-wait`                                      |                                        | fail fast instead of queueing                                                                                                                                   |
| `--priority <interactive\|high\|normal\|low\|N>` | `WARDEN_PRIORITY`, else per command    | boot queue priority; see [Capacity & queue](/docs/devices/capacity)                                                                                             |
| `--ttl <duration>`                               | `30m` (bare `claim` by an agent: `5m`) | lease TTL without heartbeat                                                                                                                                     |
| `--label <label>`                                |                                        | shown in `warden ls`                                                                                                                                            |
| `--adopt`                                        |                                        | allow allocating foreign (non-warden) devices                                                                                                                   |
| `--headless` / `--no-headless`                   | `WARDEN_HEADLESS`, else off            | iOS: close the claimed sims' Simulator.app windows instead of tiling them; `--no-headless` overrides config and env. See [Headless e2e](/docs/devices/headless) |

### `warden capacity` / `warden queue` [#warden-capacity--warden-queue]

`warden capacity [--json]` prints the host budget (RAM, performance cores), what is booted (warden and foreign), per-device cost, the binding dimension and memory pressure. `warden queue [--json]` lists claims waiting for a boot slot: position, effective priority, age, owner. See [Capacity & queue](/docs/devices/capacity).

### `warden release [leaseIds...]` [#warden-release-leaseids]

Release leases, selected by id, `--udid <udid...>`, `--mine` or `--session <id>`. `--label <label>` narrows `--mine` to leases with that label (what subagents should use). `--shutdown` also shuts down devices warden created (or the lease owner booted).

### `warden run [platform] -- <cmd...>` [#warden-run-platform----cmd]

Claim devices, run a command with `WARDEN_UDIDS` set, release on exit. Takes every `claim` flag, plus:

| Flag                                |                                                                                                                                                                                                                           |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--port <from:span>`                | lease a port for the child (`WARDEN_PORT_<i>`); repeatable                                                                                                                                                                |
| `--app`                             | install the project's app on each device first                                                                                                                                                                            |
| `--project <dir>`                   | `--app`: project directory (default: cwd)                                                                                                                                                                                 |
| `--variant <name>`                  | `--app`: install this `projects[].variants` build (`dev`, `e2e`…)                                                                                                                                                         |
| `--bundle-id <id>`                  | `--app`: override the bundle id / package                                                                                                                                                                                 |
| `--variant <name>`                  | `--app`: install this `projects[].variants` build (`dev`, `e2e`…)                                                                                                                                                         |
| `--no-eas` / `--no-build`           | `--app`: skip EAS downloads / local builds                                                                                                                                                                                |
| `--install-mode <clean\|reinstall>` | `--app`: `clean` = fresh app data each run without reinstalling an unchanged build (falls back to reinstall if the reset fails); `reinstall` = uninstall + install each run. Default: install only when the build differs |
| `--reset-keychain`                  | `--install-mode`: also reset the iOS simulator's keychain (not cleared by uninstall either)                                                                                                                               |
| `--clean`                           | deprecated alias of `--install-mode reinstall`                                                                                                                                                                            |

See [e2e scripts](/docs/devices/scripts).

### `warden batch [platform|preset] [-- <cmd...>]` [#warden-batch-platformpreset----cmd]

Claim devices and fan a job queue out over them, one worker per device; the command runs once per job with `{job}`, `{udid}`, `{worker}`, `{seq}` and `{port}` (the first leased port) substituted. Takes every `run` flag, plus:

| Flag                                      |                                                                                                           |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `--jobs <list>` / `--jobs-from <file\|->` | the jobs: comma-separated, or one per line                                                                |
| `--retry <n>`                             | re-run a failed job up to N times on the same device                                                      |
| `--passes <n>`                            | a job passes only after N consecutive green runs on its device (`WARDEN_PASS`)                            |
| `--serve <sh-cmd>`                        | a long-lived process (e.g. Metro) started before the jobs, killed at the end                              |
| `--serve-ready <probe>`                   | wait for `http://…`, `tcp:PORT` or `file:PATH` first; `{port}` works here and in `--serve` (`tcp:{port}`) |
| `--serve-timeout <duration>`              | give up waiting for `--serve-ready` (default `10m`)                                                       |
| `--job-timeout <duration>`                | a job still running after this is screenshotted, killed and failed (exit 124); off by default             |
| `--record <dir>`                          | iOS: record every simulator and the TUI (`batch.json`, `tui.cast`, `dev-<i>.mp4`)                         |
| `--logs <dir>`                            | per-job logs (default: `<record dir>/logs`, else `$WARDEN_HOME/batches/<id>/logs`)                        |
| `--no-tui`                                | plain log lines instead of the live grid                                                                  |

With `--headless` (or the preset's `headless`), the leased sims' windows are closed at claim and again before the first job, serve and jobs get `WARDEN_HEADLESS=1`, and `batch.json` has `"headless": true`. `--label` also labels the port leases (default `warden batch`). With fewer jobs than `--count`, only one device per job is claimed.

Each `batch.json` job entry (one per attempt) may carry `logDrainMs`: how long warden waited, after the job exited, for its log pipes to drain, summed over its `--passes` runs.

If the first operand is the name of a preset in [`warden.config.ts`](/docs/reference/configuration#batches) `batches` (found by walking up from the cwd to the git root), that preset supplies the platform and defaults:

* Flags you actually pass override the preset; commander defaults (`--count 1`, `--retry 0`, `--serve-timeout 10m`…) do not.
* `--jobs` / `--jobs-from` replace the preset's `jobs` / `jobsFrom`.
* `-- <cmd>` replaces the preset's `cmd`; without it the preset's `cmd` runs.
* Relative paths in the preset resolve against the preset's cwd (its `project` root, else the config file's directory); paths passed on the command line resolve against your cwd.
* Serve, jobs and a `jobsFrom.command` run in the preset's cwd.
* The label defaults to the preset name.

```bash
warden batch salient-e2e                # everything from the preset
warden batch salient-e2e --count 2 --jobs qa-login -- bun e2e {job} --device {udid}
```

An operand that is neither `ios`, `android` nor a preset fails with the list of presets.

Exits 0 only when every job passed. See [Batches](/docs/devices/scripts#batches-one-job-per-device).

### `warden affected [suite]` [#warden-affected-suite]

List the specs of `e2e.<suite>` (optional with one suite) that the changes since `merge-base(--base, HEAD)` need: one id per line.

| Flag                    |                                                                                                                                       |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `--base <ref>`          | compare against this ref (default: the suite's `base`, `main`)                                                                        |
| `--platform <platform>` | `ios \| android` (default: the suite's `platform`, else both)                                                                         |
| `--files <list>`        | comma-separated changed files (relative to cwd) instead of git                                                                        |
| `--required-only`       | only `required` specs                                                                                                                 |
| `--explain`             | why each spec was picked, with the import chain                                                                                       |
| `--strict`              | exit 3 when a changed file reaches no spec                                                                                            |
| `--json`                | `{ suite, base?, changes, platforms: [{ platform, selected (each spec with its reasons), skipped, unreached, warnings }], warnings }` |

Following `entries` through the import graph needs TypeScript 5.0 or later: the compiled binary uses the project's own, the nearest `node_modules/typescript` from the config's directory upward (Yarn Plug'n'Play is not supported); without one the run exits 1 naming that path. See [Affected e2e](/docs/e2e/affected).

### `warden e2e [suite]` [#warden-e2e-suite]

Select like `warden affected`, then run the specs like `warden batch` — each spec as a `bun test` spec process (`@delacour/warden-e2e`, configured by the suite's `spec`) — and gate: exit 0 when every required spec passed, 1 otherwise, 130 when interrupted. Takes every `batch` flag except the job flags (`--jobs`, `--jobs-from`), plus the `affected` selection flags (`--base`, `--platform`, `--files`, `--required-only`, `--explain`) and:

With no `e2e` suite configured (and none named), the Expo app around the cwd gets a default suite: every `*.e2e.ts` spec under `e2e/`, one `@delacour/warden-e2e` spec process per device (`isolate: "device"`) over a pool of 2 devices (`--count`), on iOS unless you pass `--platform android`, the dev client (the `dev` variant when the project has one) on a Metro warden starts, opened with the Expo dev menu off. No git diff is involved. `warden init` writes this suite into `warden.config.ts`.

`--install-mode`, `--reset-keychain` and `--clean` act on the installed app, so a suite that installs none (no `app` in its config) refuses them up front unless you also pass `--app`.

| Flag                       |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--dry-run`                | select and explain, run nothing                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `--all`                    | run every spec of the suite (after `include` / `exclude`), no git diff                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `--specs <list>`           | run exactly these spec ids / globs (comma-separated), no git diff; a list matching nothing fails                                                                                                                                                                                                                                                                                                                                                                                                          |
| `--report <file>`          | also write `e2e-report.json` here                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `--devices <legs>`         | which `devices` legs run: `default` (the default), `all`, or profile names (comma-separated)                                                                                                                                                                                                                                                                                                                                                                                                              |
| `--job-timeout <duration>` | overrides the suite's `jobTimeoutMs` (default 10 min)                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `--no-shutdown`            | keep the devices running after the gate passes (the default locally; under CI they are shut down. Suite `shutdown` sets it, the flag wins)                                                                                                                                                                                                                                                                                                                                                                |
| `--perf[=captures]`        | profile every attempt: a [perf session](/docs/devices/perf) wraps each test and its report is copied to the attempt's `perf/` dir (`report.json`, `report.md`, `session.json`). Bare `--perf` records metrics, frames and logs; `--perf=react,js` picks captures (`js`, `react`, `native`, `metrics`, `frames`, `logs`, or `all`). `perf.budgets` apply, and a breach fails the test with a `PERF_BUDGET` error (kind `perf_budget` in `e2e-report.json`); `e2e-report.json` links each test's `perf` dir |
| `--no-warm`                | skip the iOS driver-helper warm-up (the suite's `warmDrivers: false`)                                                                                                                                                                                                                                                                                                                                                                                                                                     |

The suite's `count`, `profile`, `retry`, `passes`, `app`, `ports`, `env`, `serve`, `serveReady` and `serveTimeout` apply unless the flag is passed; `project` sets the cwd. With [`metro`](/docs/reference/configuration#e2e) warden starts and verifies its own Metro before any spec and stops it last. With `devices`, every picked leg claims its own pool and runs the specs concurrently (`--profile` / `--count` replace the legs with one claim); the report then has one verdict per spec and leg. By default (`isolate: "device"`) one spec process per device runs its share of the specs (round-robin by spec id) and every spec still gets its own verdict; `--retry` reruns only the specs that did not pass, and `--job-timeout` / `jobTimeoutMs` is per spec. With `isolate: "spec"` every spec is its own process.

While it runs, the grid (or, with `--no-tui`, one line per finished test, `✓ todos › adds a todo (3.21s)` / `✗ …`) ticks specs and tests off as the spec processes append to their `results.jsonl`. The grid's header bar has one cell per spec (one per job with `isolate: "spec"`): green passed, red failed, cyan running, dim pending; each device row has its own bar of the jobs it ran. With `isolate: "device"` every leased device gets a row, cyan while its spec process works; when the run ends warden reads each process's `results.jsonl` and fills the device's bar with the specs it ran there. Specs run in id order (sorted file path), so `count: 1` is a stable serial run. `headless` (suite, else top-level) closes the leased sims' windows at claim and again before the first spec, unless `--no-headless`; `e2e-report.json` records `"headless"` and `"isolate"`.

Nothing affected → exit 0 without claiming. Every failed spec is named at the end with its device, spec log and failure screenshot (also `device`, `log`, `screenshot` and a `tests[]` entry per test on the spec in `e2e-report.json`), then each failing test with the first line of its error and its artifact directory.

Before the specs, an iOS suite warms the leased devices' driver helpers (`warmDrivers`, on by default) so the first spec does not pay the cold start. Leases are always released at the end.

Under CI (`CI` set to anything but empty, `0` or `false`), or with `shutdown: "always"`, a passed gate shuts down the devices warden created (or booted for the run) before releasing them; locally (`shutdown: "idle"`, the default) they stay booted for the next run and `warden gc` shuts them down once idle. A failed or interrupted run leaves them up to inspect, and a sim that was already running when leased is never touched.

### `warden ls` [#warden-ls]

List leases: resource, state, owner, repo/worktree, age, heartbeat.

### `warden devices [platform]` (alias `list`) [#warden-devices-platform-alias-list]

Every simulator / emulator, booted or not, plus Android AVDs — with warden ownership and leases.

### Device health: `release --bad`, `sim unquarantine` [#device-health-release---bad-sim-unquarantine]

After booting or reusing an iOS sim, `warden claim` checks that `simctl spawn <udid> launchctl print system` answers within 5 s and that SpringBoard is running. A sim this claim just booted (`bootstatus -b` saw the boot finish) only needs SpringBoard running. A device that fails is quarantined (recorded in the store with a reason starting `claim:`, shut down if warden created it) and the claim picks another one; after 2 retries it fails with a clear error. A probe that times out while the 1-min load average is at or above the core count is asked once more after 5 s, and a second timeout only skips the device for the next round: a busy host is not a hung sim. It is not quarantined, and if this claim booted it, it is shut down again. With `--wait` it is asked again on a later round; when it was the only candidate the claim fails with `host overloaded (load X ≥ N cores); retry later`. A claim's quarantine expires after 24 h, so the device gets another chance, but only while the pool has room under `--max` (a replacement may have taken its slot). A foreign device that fails is skipped, never touched. Android has no probe yet. warden switches Play Protect's adb-install verification off (and pre-answers its consent prompt) on every emulator it boots; the change lives in the emulator's throwaway overlay.

* `warden release <leaseId…> --bad [reason]` releases and quarantines the devices by hand (warden devices only). Quarantined devices are never allocated and free their pool slot; a quarantine set by hand does not expire.
* `warden sim unquarantine <udid|serial>` clears it. `warden devices` shows `quarantined` and `slim` next to the warden column.
* A claim that has to build the golden image prints `warden ... prewarm with: warden golden ensure --profile <profile>` first.
* `warden app ensure` no longer trusts its install record alone: on iOS it also compares the installed app's `CFBundleVersion`, executable size and `main.jsbundle` size with the cached build, and reinstalls on a difference.

### `warden sim slim [udid]` [#warden-sim-slim-udid]

iOS. Switches off the launchd jobs inside a booted simulator that UI specs never need — Siri, Apple Intelligence, Health, News, Mail, Photos analysis, Game Center, Wallet and friends (a conservative denylist; SpringBoard, installd, XCTest, the accessibility tree and the keyboard are never touched). A booted simulator runs 200+ such processes; with several simulators side by side, that is the RAM and CPU a spec suite is starved of.

| Flag        |                                                   |
| ----------- | ------------------------------------------------- |
| `--booted`  | every booted simulator instead of one `<udid>`    |
| `--dry-run` | list the jobs, change nothing                     |
| `--restore` | re-enable them (fully effective on the next boot) |

Each job is `launchctl disable`d inside the simulator (remembered across its reboots) and booted out so it stops now. Idempotent. Siri's `assistantd` and `corespeechd` are deliberately left running: the software keyboard asks them about dictation, and with them down an app that shows the keyboard spins its main thread until XCTest can no longer read it. A simulator slimmed by an older warden gets them re-enabled on the next `slim` or `--restore` (effective after its next boot). The disabled state is not copied by `warden clone`, so slim every device you use. A simulator leased by another session is refused. `slim` and `--restore` record the state per device; a plain `warden claim` re-enables a device left slimmed (jobs already booted out stay down until its next reboot). In an e2e suite, `"slim": true` does this on every leased device before `setup` — see [Affected e2e](/docs/e2e/affected#per-device-setup).

### `warden sims audit | prune | delete` [#warden-sims-audit--prune--delete]

`audit`, `prune` and `delete` share one listing (`simctl list devices -j`: every platform, unavailable runtimes included) and one set of rules, so they always agree about a sim. The only difference is which sims each one acts on.

**Owner.** `warden` (in warden's store, or named `warden-<profile>-N`), `golden` (`warden-golden-…`), or `foreign` (anything else).

**Blockers.** A sim is `leased` (any lease, live or stale; a stale one shows `run warden gc`), `booted`, or `golden`.

**Reasons** (the same text appears in the audit's VERDICT column and in the delete menu hint):

| Reason                         | Owner   | When                                                                                                         |
| ------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------ |
| runtime removed                | any     | the sim's runtime is no longer installed (`isAvailable: false`)                                              |
| warden-named, no warden record | warden  | `warden-<profile>-N` that the store has no record of (`orphan`)                                              |
| warden sim unused N            | warden  | not claimed or booted (the later of the store's `lastUsedAt` and `lastBootedAt`) for `--idle` (default `7d`) |
| over --max-size                | warden  | with `--max-size 40G`, the least-recently-used unblocked warden sims until all sims fit (`budget`)           |
| not booted in N                | foreign | last booted more than `--stale` ago (default `30d`). Never-booted sims are left alone.                       |
| older runtime                  | foreign | older than the newest runtime installed for the same device type and platform                                |
| duplicate                      | foreign | same name and runtime as a more recently booted sim                                                          |

Warden sims never get `older runtime` or `duplicate`: warden manages its own pool. Goldens never get a reason; they belong to `warden golden prune`.

| Subcommand            | Acts on                                                                                                                                       |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `audit` (the default) | nothing; read-only. VERDICT is `keep (leased\|booted\|golden\|recent)`, `delete: <reasons>` (prune will delete it), or `foreign[: <reasons>]` |
| `prune`               | warden sims with a reason and no blockers                                                                                                     |
| `delete`              | any sim you pick except leased ones and goldens. Unblocked sims with a reason start ticked.                                                   |

Sizes are simctl's `dataPathSize` (the device's `data/` dir, nearly all of it), with `du` as a fallback.

**Deleting.** `prune` and `delete` use the same delete path. Each sim is leased to the caller under the store lock, and skipped if anyone holds a lease on it, even a stale one. It is then shut down (if its runtime still exists), `simctl delete`d, forgotten from the store, and released. A racing `warden claim` either wins (the sim is skipped) or waits.

#### `warden sims audit` [#warden-sims-audit]

Lists every sim with its size, owner, lease, last use and verdict, then every downloaded runtime (`xcrun simctl runtime list`, Xcode 15+). For each runtime it shows the size (7–9 GB each), last use, how many sims use it, and a verdict: `in use`, `unused`, `unused after prune` (only sims prune would delete use it) or `unused (not deletable)`. Runtimes are machine-wide, so warden only reports them; free one with `xcrun simctl runtime delete <identifier>`.

Flags: `--idle`, `--stale`, `--max-size`, `--owner warden|golden|foreign|all` and `--json`. In the JSON, each entry has `blockers`, `reasons` and `verdict`, and runtimes sit under `runtimes` (`null` plus `runtimesError` when simctl can't list them). The audit also says if you'd still be over `--max-size` after pruning.

#### `warden sims prune` [#warden-sims-prune]

Deletes the sims `audit` marks `delete` (`--idle`, `--max-size`). Use `--dry-run` to preview. Off a terminal it needs `--yes`. It never deletes foreign sims or goldens.

#### `warden sims delete [udids...]` (alias `sims rm`) [#warden-sims-delete-udids-alias-sims-rm]

Permanently deletes simulators. With no udids it opens a multi-select menu (space toggles, enter submits) of every sim. Suggested sims come first and start ticked; the hint shows their reasons, state, runtime and size. Leased sims and goldens are shown but can't be picked. It lists what will go and asks before deleting unless you pass `-y`.

| Flag                                       | Meaning                                                                                                      |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| `--suggested`                              | delete every suggestion instead of showing the menu                                                          |
| `--stale <duration>` / `--idle <duration>` | rule thresholds (same as `audit`)                                                                            |
| `--dry-run`                                | only show what would be deleted, with reasons (works off a terminal)                                         |
| `-y, --yes`                                | don't ask; required off a terminal (with udids or `--suggested`)                                             |
| `--json`                                   | `{ deleted, failed }`, or `{ dryRun, wouldDelete: [{ id, name, reasons }] }`; exits 1 if any sim was skipped |

### `warden arrange` [#warden-arrange]

macOS only. Lines up the open Simulator.app (Xcode 27: Device Hub) windows of warden sims in name order (`warden-iphone-17-2` before `-10`), left to right from the top-left of the main screen. Simulator and Device Hub windows can't be resized, so a row wider than the screen overlaps evenly, with every title bar left visible; windows wrap to a second row only if the screen is tall enough. Other simulators are left where they are, and neither viewer is ever launched. Every iOS claim (`claim`, `run`, `batch`, `e2e`) runs this automatically unless it is [headless](/docs/devices/headless); set `WARDEN_ARRANGE=0` to turn that off. Moving windows uses System Events, so your terminal needs Accessibility access (System Settings → Privacy & Security → Accessibility). Xcode 27 replaced Simulator.app with Device Hub, which opens no window when a sim boots: a non-headless claim asks for one per sim it booted (only while Device Hub already runs), then tiles them. `--open <udid…>` does that for those sims on demand (the e2e session uses it after a HID-loss reboot). `--json`: `"viewer"` is `device-hub` / `simulator` / `none`, or `{ "opened": [...] }` with `--open`. See [Device Hub](/docs/devices/headless#xcode-27-device-hub).

### `warden clone <source>` [#warden-clone-source]

Duplicate a shut-down iOS simulator (udid or name) into warden's pool. `--name` defaults to the next free `warden-<profile>-N`.

### `warden golden ensure | ls | prune` [#warden-golden-ensure--ls--prune]

Golden iOS images / Android snapshots new devices start from. All three take `--platform ios|android` (default `ios`). `ensure [--profile] [--runtime]` builds once or reuses (profile default `iphone-17`; Android: first AVD); `ls` lists them; `prune` deletes stale goldens (`--all` every golden, `-y` skips the prompt before `--all`). See [Golden images](/docs/devices/golden-images).

### `warden check` [#warden-check]

Exit 0 if a device (`--udid`) is free or yours, **2** if another owner holds it. `--session <id>` checks against that agent session instead of the caller.

### `warden heartbeat [leaseIds...]` [#warden-heartbeat-leaseids]

Refresh heartbeats so leases don't go stale. Same selectors as `release`.

### `warden gc` [#warden-gc]

Reclaim stale leases and shut down unleased warden devices idle for `--idle` (default `20m`). Never deletes devices — use `warden sims prune` to free sim disk. Then prunes the build cache, least-recently-used first, down to `--max-cache-size` (else `WARDEN_MAX_CACHE_SIZE`, default `20G`; the same cap as `warden builds prune --max-size`); builds used in the last 30 min and builds being fetched or built stay, and the notes list what was pruned. A bad `WARDEN_MAX_CACHE_SIZE` only skips the prune, with a note; a bad `--max-cache-size` exits 1. Background auto-gc also runs when the cache is over that cap. `--quiet` prints nothing (used by background auto-gc).

## Ports [#ports]

### `warden port claim` [#warden-port-claim]

`--from` (8091), `--span` (20), `--count` (1), `--ttl` (30m), `--label`. Prints one port per line. See [Ports](/docs/devices/ports).

### `warden port ls` [#warden-port-ls]

List port leases.

### `warden port release [ports...]` [#warden-port-release-ports]

By port, `--lease <id...>` or `--mine`. `--force` releases leases held by someone else.

## Builds [#builds]

### `warden app fingerprint [platform]` [#warden-app-fingerprint-platform]

Print the project's native fingerprint (both platforms by default). `--project`, `--bundle-id`, `--variant`.

### `warden app ensure [platform]` [#warden-app-ensure-platform]

Install the build matching the fingerprint on a device (`--lease <id>` or `--udid`). `--no-install` only resolves into the cache; `--install-mode clean|reinstall` resets the installed app first (`clean`: fresh data, no reinstall of an unchanged build; `reinstall`: uninstall + install), `--reset-keychain` also resets the iOS sim keychain, `--clean` is a deprecated alias of `--install-mode reinstall`; `--no-eas`, `--no-build`, `--project`, `--bundle-id`, `--variant <name>`. See [App builds](/docs/devices/app-builds).

### `warden dev [platform] [-- <expo start args>]` [#warden-dev-platform----expo-start-args]

The worktree-friendly `expo run:*`; the platform defaults to `ios`. Before leasing anything it checks that the project has a bundle id for the platform and lists `expo-dev-client`, and names the fix. One device only: `--count` other than 1 fails with `warden dev runs on one device (drop --count)`. Uses the device of `--udid` / `--lease` / your single lease, else claims one (claim options apply). Ensures the `dev` variant's build (or the project's own when it has no `dev`; `--variant` to pick, which must exist), leases a Metro port (`--port`, default `8081:100`) and runs `bunx expo start --dev-client --port <p>` in the project root.

Metro counts as ready (`--ready-timeout`, default `2m`) only once `/status` on that port is proven to serve **this** project root (the `X-React-Native-Project-Root` header, else the listening process's cwd). Another checkout's Metro on the port fails the command with `port <p> is served by <dir>, not this worktree` instead of being attached to.

Then the dev client is opened on the device: iOS relaunches the app with `--initialUrl <metroUrl>` (no "Open in …?" alert); Android runs `adb reverse` and opens `exp+<slug>://expo-development-client/?url=…` (`--scheme` to override). `--no-open` skips this. `--json` prints one line `{ udid, port, metroUrl, leaseIds }` on stdout once Metro is ready (Metro's own output goes to stderr). The device and port stay leased (heartbeat every 30 s) until Metro exits; Ctrl-C stops Metro and releases what it leased. `--no-eas`, `--no-build`, `--install-mode`, `--reset-keychain`, `--project`, `--bundle-id`. `--headless` closes the claimed sim's window and gives Metro `WARDEN_HEADLESS=1`; dev is headless only with the flag (config `headless` and `WARDEN_HEADLESS` don't apply).

### `warden init` [#warden-init]

Set an Expo app up for `warden dev` / `warden e2e`, in the app's root (`--project <dir>`). Writes a starter `warden.config.ts` (the project and a dev-client e2e suite) and an `@delacour/warden-e2e` spec `e2e/smoke.e2e.ts` when the app has no specs. `--bundle-id <id>` fills in `expo.ios.bundleIdentifier` / `expo.android.package` in `app.json` when they are missing (required for an app with no id). Existing files are kept unless `--force`; a warden config in another format is never replaced. Missing packages (`expo-dev-client`, `@delacour/warden-e2e` pinned to this warden's version) are named, not installed. `--dry-run`, `--json` (`{ root, written, skipped, install }`).

### `warden builds ls | prune | import` [#warden-builds-ls--prune--import]

* `prune [--max-size 20G] [--dry-run] [--yes]` — remove least-recently-used builds until the cache fits; build-locked ones stay. `--max-size` defaults to `WARDEN_MAX_CACHE_SIZE`, else `20G` (the cap `warden gc --max-cache-size` uses).
* `import <path.app|path.apk> --hash <h> [--platform] [--project] [--profile] [--bundle-id]` — copy a local build into the cache.

## Drive a device [#drive-a-device]

### `warden ui <subcommand>` [#warden-ui-subcommand]

The agent CLI over the e2e driver: read the screen, act on it, wait. Full guide: [Drive a device](/docs/agents/ui). Every subcommand takes the options below, and every call checks the device lease (exit 2 on a foreign or missing lease).

| Option                                                 | Effect                                                                                                                                      |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `--udid <udid>` · `--serial <serial>` · `--lease <id>` | the device (else `WARDEN_UDID`, a single `WARDEN_UDIDS`, your only device lease)                                                            |
| `--claim`                                              | take a device when none is leased, or take over a stale lease; `--platform ios\|android`, `--profile <slug>`, `--runtime <runtime>` pick it |
| `--timeout <ms>`                                       | per-command deadline (default 5000)                                                                                                         |

\| `--force` | drive the device even while a live warden process (`warden e2e`, `batch`, `run`) holds your lease on it; without it the call exits 2 (`device_busy`) naming the owner, pid and label. Your own `warden dev` device needs no `--force`: the call shares it (`"attachedTo": "<leaseId>"` in `--json`; `close --release` keeps that lease). Another owner's live lease still refuses |
\| `--no-record` | while a `record` is active, do not append this call to it |
\| `--json` | one JSON document on stdout: `{ok, command, device, generation?, durationMs, result, warnings?}` or `{ok:false, command, error:{code, message, hint?}}` |

Exit codes: 0 ok · 1 failed or usage (also the `record` errors `no_recording`, `recording_active`, `target_exists`, `empty_recording`, `verify_failed`, `verify_unavailable`) · 2 lease · 3 engine unavailable (`engine_unavailable`, `devtools_unavailable`) · 4 stale ref · 130 interrupted.

| Subcommand                                                                   | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status`                                                                     | device, your lease and the helper                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `open <bundleId\|url>`                                                       | `--relaunch`, `--metro <url>`, `--args <args...>`, `--settle [ms]`. Without `--relaunch` an app that is already running is attached to, not restarted: the output says `attached to <id>` and `--json` has `attached: true`                                                                                                                                                                                                                                                                                                                                                                    |
| `close`                                                                      | stops this device's helper; `--app [bundleId]` quits the app, `--release` frees the lease                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `snapshot`                                                                   | `-i`/`--interactive` interactive only, `--all` no line cap, `--depth <n>`, `--settle [ms]` quiet-screen diff                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `find <selector...>`                                                         | elements matching `id=… label=… text=… role=… value=… editable=… nth=…`, with refs                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `press <target>` (alias `tap`)                                               | target = `@eN` \| selector \| `x y`; `--verify`, `--settle [ms]`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `fill <target> <text>` · `type <text>` · `key <name>`                        | `fill`: `--secure`, `--no-verify`; `type`: `--submit`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `scroll <dir> [amount]`                                                      | `--until <selector> --in <target> --max <n>`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `wait` · `is` · `get`                                                        | poll the live screen: `wait <selector>\|absent <selector>\|text "…"\|stable [ms]`, `is <selector> visible\|hittable\|enabled\|checked\|selected\|focused` (exit 0/1), `get <selector> text\|value\|label\|id\|role\|frame\|count`                                                                                                                                                                                                                                                                                                                                                              |
| `alert accept\|dismiss\|get` · `home` · `back`                               | `back` is Android only. `alert get` with no alert shows an empty button list (exit 0); `alert accept\|dismiss` then fail with `no_alert` (exit 1)                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `screen rotate [orientation]` · `screen appearance [light\|dark]`            | the device's orientation (`portrait`, `portrait-upside-down`, `landscape-left`, `landscape-right`) and light / dark mode; no argument reads the current value                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `screen screenshot [path]` · `screen video start [path]\|stop`               | default under `$WARDEN_HOME/ui/<udid>/`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `gesture swipe <dir\|x1 y1 x2 y2>` · `gesture longpress <target> [ms]`       | `swipe --from <target>`; `longpress`: `--verify`, `--settle [ms]`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `gesture pinch [target] <scale>` · `gesture rotate [target] <degrees>`       | two fingers around the target or the screen centre; `--duration <ms>` (default 500), `--radius <px>`, `--verify`, `--settle [ms]`; `scale` above 1 zooms in (0.1 to 10, not 1), positive `degrees` is clockwise (negative works; not 0, at most 720)                                                                                                                                                                                                                                                                                                                                           |
| `gesture path --path 'x,y x,y …' [--path …]`                                 | 1-2 fingers (each path at least 2 points) that go down and lift together; `--duration <ms>` (default 600), `--settle [ms]`; coordinates in points (iOS) or px (Android). Two fingers on Android need `warden-uiad`                                                                                                                                                                                                                                                                                                                                                                             |
| `record start <name>`                                                        | `--to <spec>`, `--helper <module#fn>` `--prerequisite <text>`, `--ids <module>`, `--options <module#name>` (shared `test.use` options, or `none`), `--title <title>`, `--suite <name>`, `--overwrite`. Journals every later action; `stop` writes `<specsDir>/<name>.e2e.ts` (or the helper). [Guide](/docs/e2e/author-specs)                                                                                                                                                                                                                                                                  |
| `record use <module#fn>`                                                     | `--suite <name>`. Runs the exported helper once on this device (a generated one-test spec through the spec process `stop` verifies with); on a pass appends a `use` step, which the spec calls as `await fn(fixtures)` imported relative to its path. Exit 1 `helper_missing` (module or export not found), `use_failed` (helper failed; `details.evidence`), `verify_unavailable` (no suite), `no_recording`; nothing is recorded on failure                                                                                                                                                  |
| `record note <text>` · `record undo [n]` · `record status` · `record cancel` | `note`: a comment above the next action; `undo`: drop the last n steps (notes count); `status`: target, step count, last 5 steps, lints; `cancel`: drop the journal (a written spec stays)                                                                                                                                                                                                                                                                                                                                                                                                     |
| `record stop`                                                                | `--no-verify`, `--suite <name>`, `--overwrite`. Writes the spec or helper, merges ids, prints lints and the `specs.<id>` entry for `warden.config.ts` (`--json`: `result.config` `{ id, suite?, snippet, entries, routes, unresolved, captured, hints }`, `null` for a helper; routes are read on dev clients only, need the suite's `routerRoot`, and capture stops after 3 failed reads), then runs the spec once on this device (helpers are not run). Exit 1 (`verify_failed`, `verify_unavailable`, `target_exists`) keeps the journal; `--force` is the busy-device option, not this one |
| `eval <expr>` · `tree` · `logs` · `network` · `reload`                       | dev client only (`--metro <url>`): `eval --await`; `tree --all --max <n> --screen <name>`; `logs [n]\|start [--bodies]\|stop\|mark <label>\|clear`; `network [n]` (default 50) `--include headers,body --id <id>`; `eval`, `tree`, `logs` and `reload` take `--metro <url>` (default: `WARDEN_METRO_URL`, `state.json`, then this owner's leased ports)                                                                                                                                                                                                                                        |

The old top-level `rotate`, `appearance`, `screenshot`, `video`, `swipe` and `longpress` are removed (unknown command, no aliases).

The old top-level `rotate`, `appearance`, `screenshot`, `video`, `swipe` and `longpress` are removed (unknown command, no aliases).

### `warden ui bench` [#warden-ui-bench]

`warden ui bench [--runs <n>] [--metro <url>] [--udid|--serial|--lease] [--json]` times the agent-loop primitives on a leased device and compares each with its target. `--runs` (default 20) is the sample count; the slow metrics are capped (cold start 3, settle and driver open 5). Run it on a device nobody else is using: the cold-start sample stops the device's helper first, and the press samples tap the middle of the screen.

| Metric                 | What is timed                                                                                | Target (p50)                                       |
| ---------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `helperColdToSnapshot` | helper stopped, then connect (spawn) to the first `snapshot -i` (iOS)                        | \< 1500 ms                                         |
| `snapshotWarm`         | `snapshot -i` against the resident helper                                                    | iOS p50 \< 60 ms, p95 \< 120 ms; Android \< 800 ms |
| `pressHid`             | a tap at the screen centre, HID only                                                         | iOS \< 30 ms; Android \< 120 ms                    |
| `pressSettle`          | tap, then `--settle` (wait for a quiet screen, diff) on a static screen (iOS)                | \< 600 ms                                          |
| `tree`                 | `warden ui tree` against the dev client (iOS); `skipped: "devtools_unavailable"` without one | \< 250 ms                                          |
| `openDriver`           | a second driver client opened on the resident helper socket, as a spec does (iOS)            | \< 500 ms                                          |

A metric passes up to its target times 1.5 (`ok` in the JSON; a loaded machine shows up as `slow`, past the tolerance as `FAIL`). `--json` prints the usual `warden ui` envelope with `result.metrics` as `{ name: { p50, p95, samples, targetMs?, targetP95Ms?, ok? } }` in milliseconds (or `{ skipped, samples: 0 }`), plus `result.passed`. `warden ui bench` itself exits 0; `bun run smoke:ui-bench` (`scripts/smoke/ui-bench.ts`) claims a device, opens the example app, runs it and exits 1 when anything is over target x1.5.

## Profile an app [#profile-an-app]

### `warden perf` [#warden-perf]

Profile an app on a warden simulator / emulator. Sessions live in `$WARDEN_HOME/perf/<id>/` (`session.json` plus what each capturer writes); `id` is `<yyyymmdd-hhmmss>-<hash6>` in UTC. Simulator and emulator numbers run on host CPU and GPU: compare relative, not absolute. Dev builds (`__DEV__`) inflate JS and React costs. One-shot and start/stop subcommands (`metrics`, `memory`, `frames`, `startup`, `js`, `heap`, `react`, `native`, `logs`, `crashes`, `tools`) work on their own; `start`, `stop` and `record` run several of them as one session whose `report` merges them, and `query` and `diff` read what it left behind.

Device-bound subcommands take the `warden ui` options (`--udid`, `--serial`, `--lease`, `--claim`, `--platform`, `--timeout`, `--json`) plus:

| Option                   | Effect                                                                                                                                                     |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--app <id>`             | bundle id (iOS) / package (Android). Default: the foreground app (iOS: the one running non-Apple app), then the app in `warden.config.ts`                  |
| `--allow-mutate-foreign` | let a mutating step act on a device someone else holds. Refused (`device_busy`) while a live warden process (`e2e`, `batch`, `run`, `dev`) holds the lease |

Which device: `--udid\|--serial\|--lease`, `WARDEN_UDID`, a single `WARDEN_UDIDS`, your only device lease, then the only booted device warden knows (created by warden, or leased). Several candidates exit 2 with `ambiguous_device` and list them (udid, name, holder, foreground app). A device warden does not know exits 2 with `device_unknown`: perf never attaches to foreign devices.

Observe-only work (metrics, attach-only traces, Metro / Hermes profiling, logs, crashes, frame reads) runs on any warden device and never takes or heartbeats a lease. Mutating work (kill / launch, heap dumps, `gfxinfo reset`) needs your own lease (`--claim` takes a free device) or `--allow-mutate-foreign`; otherwise it exits 2 with `device_foreign` / `device_not_leased`.

One active session per device, whoever holds the lease (`session_active` names the first). A session stops itself after 30 s (`--max-duration`, hard max 5 m, longer is clamped with a warning); a dead collector's or overdue session is swept the next time a session starts.

| Subcommand        | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ls`              | recorded sessions, newest first (id, device, app, status `running\|stopped\|aborted\|overdue`, age, cap, marks); `--device <udid>` filters, `--devices` lists the warden devices a session could attach to; `--json`                                                                                                                                                                                                                  |
| `mark <label...>` | annotate the device's running session (`session_not_found`, exit 1, when none): commits, hangs and jank are tagged with the last mark before them                                                                                                                                                                                                                                                                                     |
| `doctor`          | read-only checks, one line each: `xctrace` (macOS; fails when missing), `xcode` (warns on 26.4+, where `xctrace --device` deadlocks), `adb`, `device-tools` (`perfetto` and `simpleperf` on an Android device), `trace-processor` (cached under `$WARDEN_HOME/tools`), `metro`, `app` (Android: debuggable or `<profileable android:shell="true"/>`). Exit 1 only on a `fail`; with no resolvable device the device checks are `skip` |

Errors carry `{code, message, hint?}`; the codes added for perf are `device_unknown`, `session_active`, `session_not_found`, `app_not_found`, `unsupported`, `not_profileable` (exit 1) and `tool_missing` (exit 3) (plus the `warden ui` lease codes). `--json` prints the `warden ui` envelope (`command` is `perf <sub>`); `ls` and `doctor` print their own document (`{sessions}` / `{devices}` / `{ok, device?, checks}`).

#### `warden perf metrics` [#warden-perf-metrics]

CPU % and memory of the app over time. Observe-only: works on any warden device and never takes a lease.

```bash
warden perf metrics [--interval 1s] [--duration 3s] [--watch] [--app <id>]
```

* iOS simulator: `ps` matched on the app's executable; CPU % is the delta of cumulative CPU time between samples (not the decaying `%cpu`), memory is RSS.
* Android: `top -n 1 -b -p <pid>` for CPU and `dumpsys meminfo <package>` for PSS and RSS.
* The first sample is the CPU baseline. Output is min/avg/p95/max per metric plus the memory trend; a steadily rising series (at least 6 samples over 5s, +1 MB, +50 KB/s) adds a `memory-growth` leak hint.
* Samples go to the running session's `metrics/samples.jsonl` (timed from the session start), else a one-shot directory under `$WARDEN_HOME/perf`.
* `--watch` redraws CPU, memory (and fps when available) as unicode sparklines with current/avg/max on stderr. Piped, or with `--json`, it prints one NDJSON line per sample on stdout, then the result envelope. Ctrl-C prints the summary and exits 130. Without `--duration` it stops after 5 minutes.
* `--interval` is at least 200ms. Numbers from simulators and emulators are for comparing relative changes, not absolute values.

#### `warden perf memory` [#warden-perf-memory]

Where the app's memory sits right now, or a heap snapshot.

```bash
warden perf memory [--snapshot] [--app <id>]
```

* Without `--snapshot` (observe-only): Android prints TOTAL PSS/RSS and the top 5 `dumpsys meminfo` categories; iOS prints the physical footprint (and peak) and top regions from `vmmap --summary`.
* With `--snapshot` (pauses the app; needs your own lease, so pass `--claim`):
  * iOS: `leaks --outputGraph` writes a `.memgraph` (open it in Xcode's Memory Graph debugger) and the command reports the leak count, bytes and leaked types.
  * Android: `am dumpheap` writes an `.hprof`, which is pulled and removed from the device. It is raw only; open it in Android Studio (Profiler, Load). The app must be debuggable or profileable.
* Artifacts go to the running session's `native/` directory, else a one-shot directory under `$WARDEN_HOME/perf`.

#### `warden perf frames` [#warden-perf-frames]

fps, janky %, dropped and frozen frames and the worst jank windows for the app on an Android device,
read from `dumpsys gfxinfo <pkg> framestats`.

```bash
warden perf frames [--app <pkg>] [--reset] [--json]
```

* Reports `fps` and `refreshHz`. The frame deadline is the median vsync gap, clamped to 4-50ms.
* Reports `jankPct` (the OS "Janky frames" summary since the last reset), `droppedFrames`, `frozenFrames`
  (frames over 700ms) and `worstFrameMs`.
* `worstWindows`: the 3 worst jank clusters. Clusters split on gaps over 500ms. Each window has
  `startOffsetMs`, `endOffsetMs`, `missedDeadlineFrameCount` and `worstFrameMs`.
* With a running session (`warden perf start`), offsets are measured from the session start and each window
  carries the last `warden perf mark` before it. The report is also written to the session's `frames.json`.
* A `jank` finding is emitted at 2% or more janky frames, or any frozen frame.
* A plain read is observe-only. It takes no lease and changes nothing on the device.
* `--reset` clears the OS frame counters after reading, so the next read is a fresh window. This mutates
  the device and needs your own lease (`--claim` or a lease you hold).
* Window offsets come from the OS's last \~120 frames (about 2s at 60Hz). The jank % and totals cover
  everything since the last reset. For a long scenario, run `perf start` with marks and read at the end of the interaction.
* iOS simulator: returns `status: "unsupported"` with a hint. The simulator has no frame API. Use hangs from
  `warden perf start --native` instead.

#### `warden perf startup` [#warden-perf-startup]

Cold or warm app start time, median and p90 over several launches.

```bash
warden perf startup [--cold] [--warm] [--runs <n>] [--method approx|xctrace] [--app <id>] [--claim]
```

Kills and relaunches the app, so it needs your own device lease (or `--claim` on a free device). Without a lease it fails with `device_foreign` and never touches the app.

| Flag           | Meaning                                                                                |
| -------------- | -------------------------------------------------------------------------------------- |
| `--cold`       | Stop the app before each start (default).                                              |
| `--warm`       | Send the app to the background first, then start it. With both flags, cold runs first. |
| `--runs <n>`   | Launches to time, 1-20 (default 5).                                                    |
| `--method <m>` | iOS only: `approx` (default) or `xctrace` (App Launch template, cold only, spike).     |

* **Android:** `am start -W` (`-S` for cold). Time is `TotalTime`, the time to the first frame. If the app calls `reportFullyDrawn()`, the fully-drawn time is reported too.
* **iOS simulator:** `approx` is the wall time of `simctl launch --terminate-running-process`. It is a lower bound (time to process spawn); compare relative, not absolute. `xctrace` records the App Launch template and reads the launch periods from the trace.
* **React Native:** when Metro is reachable, the app's `performance` marks are read after the last launch and listed. A missing Metro is noted in `startup.json`, not an error.
* **Output:** median, p90, min and max per kind. A `startup` finding is raised when the cold median is over 2s (warn) or 5s (critical). The full result is written to `metrics/startup.json` in the active session, or in a one-shot dir under `$WARDEN_HOME/perf/`.

#### `warden perf js` [#warden-perf-js]

Hermes JS CPU profile of the running dev client, source-mapped to `file:line` hotspots. Observe only: it never leases the device.

```bash
warden perf js [--duration 10s] [--sampling-interval 100] [--metro <url>] [--app <id>] [--json]
```

* Samples for `--duration` (default 10s, max 5m) at `--sampling-interval` microseconds (default 100).
* Writes `js/cpu.cpuprofile` (opens in Chrome DevTools or speedscope) and `js/hotspots.json` into the running session, otherwise a one-shot directory under `$WARDEN_HOME/perf`.
* Source maps are fetched from Metro only (64MiB cap). The report lists top self and total time per function with `file:line`, groups `node_modules` frames by package, reports idle share separately, and raises `js-hotspot` findings for functions above 10% of active time.
* Dev builds (`__DEV__`) get a banner: unminified JS and the DevTools hook inflate costs, so confirm on a release or profileable build.
* A JS reload during the capture loses the profile. Release builds have no Metro, so the command exits 3 (`devtools_unavailable`).
* If a running session already profiles JS, the command fails with `session_active`.

#### `warden perf heap` [#warden-perf-heap]

Hermes heap snapshots of the running dev client.

```bash
warden perf heap snapshot [--metro <url>] [--app <id>] [--claim] [--json]
warden perf heap diff <before> <after> [--json]
```

* `snapshot` freezes the JS thread while it runs, so it needs your lease on the device (or `--claim`). It streams `js/heap-<ts>.heapsnapshot` (opens in Chrome DevTools, Memory tab) and prints the top 15 constructors by self size and by count. Retained sizes are not computed; open the file for them.
* `diff` compares two snapshot files: per-constructor count and self-size deltas, largest change first. It only reads files.
* If the runtime lacks the needed Profiler or HeapProfiler method, the command exits 3 with `unsupported: ...` and a hint.

#### `warden perf logs` [#warden-perf-logs]

The app's device logs, secrets redacted. An observe op: needs no lease, and works on any warden device.

```bash
warden perf logs --app com.acme.shop --level W --since 5m
warden perf logs --follow --json --grep "api|cart"
```

| Flag                         | Meaning                                                                              |
| ---------------------------- | ------------------------------------------------------------------------------------ |
| `-f, --follow`               | Stream live: text lines, or one JSON entry per line with `--json`. Ctrl-C exits 130. |
| `--level <V\|D\|I\|W\|E\|F>` | Minimum level (words like `error`, `warn` work too).                                 |
| `--grep <regex>`             | Keep lines whose tag or message matches (case-insensitive).                          |
| `--since <duration>`         | Only lines from the last `30s`, `5m`, `1h`.                                          |
| `-n, --lines <n>`            | Lines to show, newest last (default 200).                                            |

* **Android:** `logcat -v threadtime --pid <pid>`. While following, the pid is re-resolved every 2s and the stream restarts when the app does. `--since` is measured against the device clock.
* **iOS simulator:** `log stream|show --style compact` with a predicate matching the app's subsystem, sub-subsystems and executable. A one-shot read looks back 5m unless `--since` is given.
* **Redaction:** `Authorization`/`Bearer` tokens, `api_key=`, JWTs, `password=` and emails become `[redacted]` before anything is printed or written.
* **In a session:** the collector writes the same redacted stream to `logs/device.log`.

#### `warden perf crashes` [#warden-perf-crashes]

Crashes and ANRs of the app, newest first (up to 10), with `crash` / `anr` findings. An observe op.

```bash
warden perf crashes --since 1h
warden perf crashes --symbolicate --dsym build/ShopApp.app.dSYM
```

| Flag                 | Meaning                                                       |
| -------------------- | ------------------------------------------------------------- |
| `--since <duration>` | Only crashes from the last `30s`, `5m`, `1h`.                 |
| `--symbolicate`      | iOS only: resolve app frames through a dSYM (needs `--dsym`). |
| `--dsym <path>`      | The `.dSYM` (or DWARF file) of the build that crashed.        |

* **iOS:** reads `~/Library/Logs/DiagnosticReports/*.ips` for this app on this simulator. Each crash shows exception type, signal, crashed thread, top 10 frames and the first app frame. `--symbolicate` matches the dSYM by `dwarfdump --uuid`, then runs `atos` with the image's load address; a dSYM from another build is reported as a warning and the crash is still listed.
* **Android:** `logcat -b crash -d` plus `dumpsys dropbox --print data_app_crash` and `data_app_anr`, de-duplicated. Native tombstones are not read.
* **In a session:** raw reports are copied into the session's `crashes/` directory.

#### `warden perf react` [#warden-perf-react]

React render profile of the running dev client (observe-only; never takes the lease): which commits were hot, which components cost the most, and ranked findings with a fix.

```bash
warden perf react [--duration 10s]      # record, then analyse
warden perf react start                 # begin and return (profile lives in the app)
warden perf react stop                  # end, analyse, write
```

| Flag                | Meaning                                                       |
| ------------------- | ------------------------------------------------------------- |
| `--duration <time>` | how long to record (default 10s, max 5m)                      |
| `--metro <url>`     | Metro to attach through (default: the one serving the device) |
| `--take-over`       | take over (or stop) a React profile another agent started     |

Output goes to `react/commits.json` (every commit: durations, per-fiber actual/self time, change descriptions) and `react/findings.json` (hot commits, ranked components, findings) in the running session, else a one-shot dir under `$WARDEN_HOME/perf`.

* **Hot commits:** under 16ms is fine, 16-50ms warm, over 50ms hot; heat is the cube root of duration over the slowest commit.
* **Findings** (`kind`): `slow-commit`, `slow-render`, `memo-defeated` (memoized but props change identity), `context-cascade`, `parent-cascade`, `render-storm`, `eager-mount`, `effect-chain`. Each has `costMs`, `evidence`, `fix`, `next`. Components under 2ms or 3% of render time are not reported; animation and transition components are tagged and not blamed.
* **Where:** components are located by the runtime's `_debugSource` when it exposes one, else by grepping the project for `function Name` / `const Name =` / `class Name`.
* **JS correlation:** with a `js/cpu.cpuprofile` from the same session (`perf start --js --react`), each hot commit lists the JS functions that ran inside it.
* **Errors:** `rn_too_old` (React Native below 0.74), `release_build` (no DevTools hook or `__DEV__` false), `devtools_backend_missing` (no `react-devtools-core` in the bundle), `session_active` (another agent holds the profile; `--take-over`).

Dev builds inflate React costs: confirm on a profileable build. The simulator banner applies.

#### `warden perf native` [#warden-perf-native]

```bash
warden perf native [start|stop] [--duration 10s] [--leaks] [--analyze <trace>] [--skip-analysis] [--flavor perfetto|simpleperf] [--yes]
```

Native CPU, hangs and (iOS) leaks from the platform profiler. iOS simulators use `xcrun xctrace record` with the Time Profiler template plus the `Hangs` instrument; `--leaks` adds `Leaks` and `Allocations`. The trace lands in `native/ios.trace` of the device's running perf session, or of a one-shot dir under `$WARDEN_HOME/perf/native-<id>/`. With no action it records for `--duration` (default 10s, max 5m), stops and analyzes; `start` begins a detached recording, `stop` ends it (SIGINT, waits for the trace to be finalised, SIGKILL only on timeout) and exports it.

* Attach-only recording is observe-only: it works on any warden device and never takes or heartbeats a lease. On Xcode 26.4+ (where `xctrace record --device` deadlocks) it records `--all-processes` and filters the rows by the app's pid.

* `--leaks` is a mutation: it terminates the app and cold-launches it with `MallocStackLogging=1` under the profiler, so it needs your own lease (`--claim`).

* The analysis names the dominant frame of each sample (first app or third-party frame, else a React Native frame such as `RCT*`, Yoga, Hermes, JSI; system-only samples are skipped), groups threads (Main Thread, JS Thread, Shadow Thread) and reports frames over 3% of samples (over 15% is critical). Each hang lists its duration, the top 5 functions and top 3 call chains on the main thread during it. Leaks are grouped by type and responsible frame with bytes and counts.

* `--analyze <trace>` re-analyzes an existing `.trace` without a device. `--skip-analysis` on `stop` leaves the export for later. `--yes` (Android) downloads `trace_processor` without asking.

* Simulator numbers are for comparing runs, not absolutes. Open the trace in Instruments for the full picture.
  Android:

* Default is a Perfetto trace of the app: 100 Hz callstack sampling scoped to the package, scheduler/binder ftrace, atrace (`view gfx wm am input dalvik` + the app), SurfaceFlinger frame timeline and 1 s process stats. The config goes to `perfetto` on stdin; the trace is pulled to `native/android.perfetto-trace` and removed from the device. The trace stops at `--max-duration` at the latest.

* `--flavor simpleperf` (in a session: `perf start --native=simpleperf`) samples the running app pid with `simpleperf record -e cpu-clock:u` and reports the top functions from the on-device `simpleperf report` (`native/simpleperf.data`, `native/simpleperf.data.report.txt`). It needs no trace\_processor.

* Analysis runs PerfettoSQL through a pinned `trace_processor_shell` (`$WARDEN_HOME/tools/trace_processor_shell-<version>`, or `WARDEN_TRACE_PROCESSOR`): CPU hotspots (critical over 15% of samples, warn 3-15%), missed-deadline frames with the main-thread state split (running / runnable / sleeping / io) and GC overlap, per-thread time, RSS growth. Raw SQL is written to `native/queries/`.

* The trace is always kept: if trace\_processor is missing the analysis is skipped with a warning naming the install command.

* Zero samples fail with `not_profileable`: the app must be debuggable or `<profileable android:shell="true"/>` in its manifest.

* Observe-only: attaching never leases or restarts the device or app.

#### `warden perf start | stop | record` [#warden-perf-start--stop--record]

```bash
warden perf start [--js] [--react] [--native [flavor]] [--metrics] [--frames] [--logs] [--all]
                  [--relaunch] [--reset] [--max-duration 30s] [--yes] [--join]
warden perf stop [--no-analyze] [--yes]
warden perf record [--duration 10s] [same capture flags as start] [--no-analyze]
```

One combined session. `start` sweeps overdue sessions, opens the session, writes `plan.json`, records a clock anchor (`{wallMs, deviceWallMs}`; Android reads `adb shell date +%s%N` and halves the round trip) and spawns one detached `warden perf-collect --session <id>` that hosts metrics, logs, the JS and React capture (one Hermes socket) and the auto-stop timer. With no capture flag you get metrics, frames and logs; `--all` adds JS, React and native.

* `--relaunch` terminates the app, arms what can be armed before launch (Android perfetto), launches, waits for the Metro target and then arms JS and React. iOS native is armed right after launch (it needs the pid). `--reset` clears app data first and implies `--relaunch`. Both mutate the device, so they need your lease; a free warden device gets a short `warden perf` lease that `stop` releases. A live foreign lease fails with `device_foreign`.
* `--native [flavor]` is `perfetto` (default) or `simpleperf` on Android; give the flavor attached, `--native=simpleperf`, so it is not read as a positional argument.
* `--max-duration` is capped; at the cap the collector runs the same stop path as `perf stop`.
* `--join` attaches read-only to a running session.
* `stop` ends the capturers in reverse order, gives back a lease `start` took and writes `report.md` + `report.json` under the session dir. `--no-analyze` keeps native traces unanalyzed.
* `record` is `start`, wait `--duration` (default 10s, max 5m), `stop`. `--no-analyze` stops without building the report. Ctrl-C stops early, still reports and exits 130.
* `warden release` and `warden ui close --release` stop a running session on the released device.

#### `warden perf report` [#warden-perf-report]

```bash
warden perf report [id|latest] [--full] [--rebuild] [--json]
```

Prints the merged report: Summary, Startup, Frames, JS CPU, React, Native CPU, Hangs, Memory, Logs/Crashes and `next:` hints. Findings are sorted by severity then cost; overlaps are correlated (`hang 412ms @ 3.2s overlaps commit #14 (FlatList, 180ms) <- mark 'scroll feed'`). `--json` is `report.json` (`PerfReportJson`). Output is capped; `--full` lifts the cap, `--rebuild` regenerates from the session files.

#### `warden perf query` [#warden-perf-query]

```bash
warden perf query <id> commits [--by component|window|index|cascade] [--size 1s] [--index N] [--limit N]
warden perf query <id> commits --by index <n>
warden perf query <id> cpu [--top | --tree | --window 2s-5s] [--limit N]
warden perf query <id> stacks hang | callers <fn> | thread | leaks [--rerun --yes] [--limit N]
warden perf query <id> frames [--worst]
```

`<id>` is a session id, a unique prefix or `latest`. Reads the session's files; no device needed.

| Topic     | Options                                                                                                                                                                                                             |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `commits` | `--by component` (default) \| `window` \| `index` \| `cascade`. `--by window` takes `--size <time>` (bucket, default 1s). `--by index` takes the commit as a positional `<n>` or `--index <n>`: one commit in depth |
| `cpu`     | `--top` (the hottest functions, default), `--tree` (call tree), `--window <a-b>` (self time between two points on the session timeline, e.g. `2s-5s`)                                                               |
| `stacks`  | `hang`, `callers <fn>`, `thread`, `leaks`; `--rerun` re-runs the Perfetto SQL (Android), `--yes` downloads `trace_processor` for it                                                                                 |
| `frames`  | `--worst`: the worst jank windows and hangs                                                                                                                                                                         |

`--limit <n>` caps the rows of `commits` and `cpu` (`cpu` defaults to 15). `--json` prints the `warden ui` envelope.

#### `warden perf diff` [#warden-perf-diff]

```bash
warden perf diff <a> <b> [--fail-on regress] [--threshold 10%] [--json]
```

Reads two sessions' reports, so it takes no device options.

Compares two reports metric by metric (lower is better, except fps) with a relative threshold plus absolute floors, and lists function self-time deltas. With `--fail-on regress` exits 1 when any metric regressed.

#### `warden perf tools` [#warden-perf-tools]

```bash
warden perf tools install trace-processor [--yes] [--json]
```

Downloads the pinned Perfetto `trace_processor_shell` (about 14 MB; sha256-verified, installed atomically) into `$WARDEN_HOME/tools/`. On a terminal it asks first; without a terminal it fails with `tool_missing` (exit 3) unless `--yes` is given. A progress bar (bytes, percent, MB/s) is drawn on stderr only, so `--json` stdout stays one document. Re-running reports `already installed`.

## Setup [#setup]

### `warden install` [#warden-install]

Install warden to `~/.local/bin` plus hooks for detected agents (default: every detected agent). `--claude`, `--codex`, `-y/--yes` (apply agent-config changes without asking), `--dry-run` (show diffs, write nothing), `--shim` (running from source: link `~/.local/bin/warden` to this checkout), `--no-completion` (skip tab completion), `--json` (diffs go to stderr).

### `warden completion [zsh|bash|fish]` [#warden-completion-zshbashfish]

Print the tab-completion script for a shell (default: `$SHELL`). `warden install` sets it up for `$SHELL`: zsh / bash get `$WARDEN_HOME/completion/warden.<shell>` sourced from `.zshrc` / `.bashrc` (one line, diff + confirm), fish gets `~/.config/fish/completions/warden.fish`. By hand: `source <(warden completion zsh)` after `compinit`.

Every tab asks `warden __complete -- <words…>`, which walks the same command tree as `--help`: subcommands at every depth, options after `-`, `.choices()` and `a | b` help hints as values (`warden claim <tab>` → `android ios`), files for free-form arguments. zsh: tab once lists with descriptions, tab twice picks the first, further tabs cycle (menu select, unless you set your own `menu` style). fish cycles by default. bash cycles only with readline's `bind 'TAB:menu-complete'`, `bind 'set show-all-if-ambiguous on'` and `bind 'set menu-complete-display-prefix on'`, which warden leaves to you.

### `warden skill show | install` [#warden-skill-show--install]

`show [--file <name>] [--json]` prints SKILL.md, or one file of the skill folder such as `references/ui.md`. `warden install --claude` stages the embedded skill in `$WARDEN_HOME/skill/warden/` and installs it with the same `skills add` command.

`install` runs `bunx skills add` (npx fallback): `--from local|github` (default `local`: this binary's embedded copy; `github` is the updatable release repo), `--project` (default: global), `-a/--agent <agent...>`, `--copy` (copy instead of symlink), `-y`, `--dry-run` (print the command, run nothing), `--json`.

### `warden hook session-end` [#warden-hook-session-end]

The SessionEnd hook entrypoint: shuts down what the session booted and releases its leases. Called by Claude Code / Codex, not by hand. There is no PreToolUse hook any more: `warden install` removes the `PreToolUse` hook an older warden wrote, and a hidden `warden hook pretool` no-op keeps a PreToolUse hook written by an older warden from erroring.

### `warden doctor` [#warden-doctor]

Check home, db, device tools, the iOS simulator helper, install method (and its upgrade command), `PATH`, Claude/Codex hooks + skill, rule, and stale leases. `--fetch-helper` first downloads `warden-simd` for this version into `$WARDEN_HOME/simd/<version>/`. See [iOS driver helper](/docs/reference/e2e-driver).

### `warden update` [#warden-update]

`--check`, `--release`, `--force`, `--to <path>`. npm / bun installs print their package manager's upgrade command instead. See [Updating](/docs/updating).

### `warden version` [#warden-version]

Print the version and build channel (`dev` / `local` / `release`).
