Skip to content

Environment Variables

Most users can use the defaults. Set these variables when you need a different state directory, logs, tool set, or device behavior.

Tables list every public environment read in src/, including supported legacy aliases. In a cell with two names, the first spelling takes precedence when set (even if empty); exceptions are explained in that row. Defaults are effective behavior with the variable unset. Boolean parsing varies by setting: use the exact accepted values shown rather than assuming every flag accepts true. Unknown numeric values usually fall back unless the row states otherwise. Internal test/acceptance hooks, benchmark harness settings, and manager-injected launch metadata are intentionally excluded.

State and logs

Variable Use and accepted values Default
AUTOMOBILE_DATA_DIR, AUTO_MOBILE_DATA_DIR Base directory for observe, accessibility, navigation, CtrlProxy builds, screen streaming, WebRTC, tool outputs, daemon failure artifacts and downloaded libwebp tools; filesystem path, relative to daemon launch directory. ~/.auto-mobile (OS temp fallback if home is unavailable)
AUTOMOBILE_LOG_DIR, AUTO_MOBILE_LOG_DIR Daemon and client log directory; filesystem path, relative to daemon launch directory. $AUTOMOBILE_DATA_DIR/logs
AUTOMOBILE_PLAN_HEALTH_DIR Opt-in: each executePlan run writes a JSON health summary (per-tool step latency, failures, skips) here and attaches it as healthSummary in the result; filesystem path. unset (no summary)
AUTOMOBILE_LOG_FORMAT, AUTO_MOBILE_LOG_FORMAT Log serialization: text or newline-delimited json; case-insensitive, trimmed. text
AUTOMOBILE_LOG_SINK, AUTO_MOBILE_LOG_SINK Log destination: file, stderr, both; case-insensitive, trimmed. file
AUTOMOBILE_LOG_LEVEL, AUTO_MOBILE_LOG_LEVEL Initial logging threshold: debug, info, warn/warning, error, none/silent; case-insensitive, trimmed. info
AUTOMOBILE_COORDINATION_DIR, AUTO_MOBILE_COORDINATION_DIR Shared cross-process CtrlProxy forwarding lease root; must be an absolute path shared by cooperating agents. OS account home .auto-mobile
AUTOMOBILE_ADB_SERVER_COORDINATION_DIR, AUTO_MOBILE_ADB_SERVER_COORDINATION_DIR Root of per-ADB-server device allocation claims and Android boot admissions, and of the managed-slot registry; all ignore AUTOMOBILE_COORDINATION_DIR. Must be an absolute path shared by every daemon on one ADB server. OS account home .auto-mobile/adb-servers
AUTOMOBILE_TOOL_OUTPUTS_DIR, AUTO_MOBILE_TOOL_OUTPUTS_DIR Directory for large CLI tool-output artifacts; filesystem path. CLI --tool-outputs-dir wins. resolved data directory tool_outputs
AUTOMOBILE_DEBUG Enable CLI debug logging; exact 1 enables. off
AUTOMOBILE_DEBUG_PERF Enable UI performance debug tracking; exact 1 enables (also --debug-perf/--ui-perf-debug). off
AUTOMOBILE_DOCTOR_TIMEOUT_MS Doctor command execution timeout; positive base-10 integer milliseconds; anything else (zero, 1e3, 5s) is ignored with a warning and falls back to 5000. 5000 ms

For container log collection:

export AUTOMOBILE_DATA_DIR=/var/lib/automobile
export AUTOMOBILE_LOG_FORMAT=json
export AUTOMOBILE_LOG_SINK=stderr
# The container's only daemon uses this data dir; see "Shared-namespace guard" below.
export AUTOMOBILE_ALLOW_SHARED_DAEMON_NAMESPACE=1

Device snapshots, video archives and downloaded libwebp tools default to stores under AUTOMOBILE_DATA_DIR. This setting does not move the SQLite database (AUTOMOBILE_DB_DIR / AUTOMOBILE_DB_PATH), auxiliary daemon sockets (AUTOMOBILE_AUX_SOCKET_DIR), or coordination directories (AUTOMOBILE_COORDINATION_DIR / AUTOMOBILE_ADB_SERVER_COORDINATION_DIR).

Set AUTOMOBILE_COORDINATION_DIR when the OS account home is read-only or when AutoMobile agents that share an ADB server need an explicit shared lease root. All cooperating agents must use the same absolute path. The legacy AUTO_MOBILE_COORDINATION_DIR alias is accepted when the preferred name is unset.

Daemon namespace

Variable Use and accepted values Default
AUTOMOBILE_DAEMON_SOCKET_PATH, AUTO_MOBILE_DAEMON_SOCKET_PATH Daemon control socket; filesystem path. /tmp/auto-mobile-daemon-<uid>.sock
AUTOMOBILE_DAEMON_PID_FILE_PATH, AUTO_MOBILE_DAEMON_PID_FILE_PATH Daemon PID record; filesystem path. /tmp/auto-mobile-daemon-<uid>.pid
AUTOMOBILE_DAEMON_LOCK_FILE_PATH, AUTO_MOBILE_DAEMON_LOCK_FILE_PATH Daemon start lock; filesystem path. /tmp/auto-mobile-daemon-<uid>.lock
AUTOMOBILE_AUX_SOCKET_DIR Directory for auxiliary stream/push sockets; filesystem path. When set, the daemon control socket, PID and lock files also move to /tmp/auto-mobile-daemon-<uid>-<hash of dir>.* so a private daemon never touches the resident daemon’s files (an explicit AUTOMOBILE_DAEMON_*_PATH still wins). The JUnit runner, desktop app and XCTestRunner apply the same rule, so exporting the var to them reaches the same private daemon. ~/.auto-mobile
AUTOMOBILE_WEBRTC_STREAM_SOCKET_PATH, AUTO_MOBILE_WEBRTC_STREAM_SOCKET_PATH Override WebRTC stream socket; filesystem path. auxiliary socket directory webrtc-stream.sock
AUTOMOBILE_DAEMON_TIMEOUT_MS, AUTO_MOBILE_DAEMON_TIMEOUT_MS Connection/request timeout; positive base-10 integer milliseconds. 120000 ms
AUTOMOBILE_DAEMON_STARTUP_TIMEOUT_MS, AUTO_MOBILE_DAEMON_STARTUP_TIMEOUT_MS Cold startup budget; positive base-10 integer prefix in milliseconds, capped at 2147483647. 30000 ms
AUTOMOBILE_DAEMON_EXISTING_REACHABILITY_TIMEOUT_MS, AUTO_MOBILE_DAEMON_EXISTING_REACHABILITY_TIMEOUT_MS Reachability wait for an already-live daemon; positive base-10 integer milliseconds, capped at two-thirds of the startup budget (minimum 1 ms). 10000 ms, subject to cap
AUTOMOBILE_DAEMON_DISABLE_HANDSHAKE, AUTO_MOBILE_DAEMON_DISABLE_HANDSHAKE Disable version/build identity handshake with 1, true, yes (case-insensitive, trimmed). off; handshake enabled
AUTOMOBILE_DAEMON_STREAM_AUTH Require session authentication on video/WebRTC sockets; 0, false, no, off disable (case-insensitive, trimmed). on
AUTOMOBILE_OPEN_LINK_MCP_TIMEOUT_MS, AUTO_MOBILE_OPEN_LINK_MCP_TIMEOUT_MS MCP timeout floor for openLink; positive base-10 integer milliseconds. 90000 ms
AUTOMOBILE_OBSERVE_MCP_TIMEOUT_MS, AUTO_MOBILE_OBSERVE_MCP_TIMEOUT_MS MCP timeout floor for observe; positive base-10 integer milliseconds. 90000 ms
AUTOMOBILE_RUNNER_READINESS_TIMEOUT_MS, AUTO_MOBILE_RUNNER_READINESS_TIMEOUT_MS Steady-state CtrlProxy readiness budget; integer milliseconds in 1000..120000; CLI flag wins. 30000 ms
AUTOMOBILE_CTRL_PROXY_LEASE_IDLE_MS Idle period after which a daemon closes a device’s CtrlProxy connection and gives up its forwarding lease (no session, stream, tool call or CtrlProxy request); also how recent its own use must be for it to refuse another process’s request to give the lease up; positive base-10 integer milliseconds; anything else is ignored with a warning and the default is used. 60000 ms
AUTOMOBILE_PRIVATE_DAEMON_ORPHAN_IDLE_MS Idle timeout after which a harness private daemon (AUTOMOBILE_HARNESS_PRIVATE_DAEMON=1, or an explicit non-default AUTOMOBILE_DAEMON_SOCKET_PATH with AUTOMOBILE_AUX_SOCKET_DIR set) whose launching parent exited, with no clients or sessions, shuts itself down; non-negative milliseconds, 0 disables. 900000 ms
AUTOMOBILE_ALLOW_SHARED_DAEMON_NAMESPACE Lets daemon lifecycle and release commands, and automatic client restarts, act on the shared daemon while a state directory is relocated without a private namespace (see “Shared-namespace guard”); exact 1 enables. unset
AUTOMOBILE_HARNESS_PRIVATE_DAEMON Marks a test/lane private daemon for the orphan watchdog; 1 arms it, 0 exempts a daemon that would otherwise qualify. unset
AUTOMOBILE_DAEMON_LAUNCHER_PID Pid of the process that launched a private daemon, for the orphan watchdog; defaults to the parent pid read at process entry. Values that are not a pid above 1 are ignored. unset

Set AUTOMOBILE_DAEMON_SOCKET_PATH, AUTOMOBILE_DAEMON_PID_FILE_PATH, and AUTOMOBILE_DAEMON_LOCK_FILE_PATH to distinct absolute paths for each concurrent instance. Relative paths resolve from the daemon launch directory. The legacy AUTO_MOBILE_ aliases are also accepted.

Shared-namespace guard

AUTOMOBILE_DATA_DIR, AUTOMOBILE_DB_DIR and AUTOMOBILE_DB_PATH (and their AUTO_MOBILE_ aliases) relocate a daemon’s state, but not its namespace. With any of them set and neither AUTOMOBILE_AUX_SOCKET_DIR nor an AUTOMOBILE_DAEMON_SOCKET_PATH that differs from the default, --daemon start, stop, restart, restart-admitted, restart-acceptance-session, release-session and release-liveness-ownership refuse with [shared_daemon_namespace]: they would act on the shared daemon, which runs on different state. MCP and --cli clients refuse an automatic version, build or startup-option restart for the same reason; they still auto-start a daemon when none is running. AUTOMOBILE_LOG_DIR does not count (it only moves log files), nor do the coordination directories, which cooperating daemons share.

Set AUTOMOBILE_AUX_SOCKET_DIR to reach a private daemon instead. To act on the shared daemon deliberately, pass --allow-shared-namespace after the daemon command, or set AUTOMOBILE_ALLOW_SHARED_DAEMON_NAMESPACE=1 on hosts whose only daemon uses the relocated state, such as a container.

Start, stop, and restart operate only on that namespace. A live process in another namespace is ignored, even when it uses the default TCP port. Lost PID records can still be recovered when the daemon answers on this socket or carries this socket’s launch marker. An unmarked process that does not answer on this socket is left alone. Restart verifies and stops the namespace’s current process generation before launching its replacement; it reuses the recorded bound port unless explicitly overridden, and fails if shutdown or identity cannot be verified.

Database

Variable Use and accepted values Default
AUTOMOBILE_DB_PATH, AUTO_MOBILE_DB_PATH Exact SQLite database file; filesystem path, takes precedence over DB directory. :memory: is test-only. ~/.auto-mobile/auto-mobile.db
AUTOMOBILE_DB_DIR, AUTO_MOBILE_DB_DIR Directory containing auto-mobile.db; filesystem path. unset; ~/.auto-mobile
AUTOMOBILE_MIGRATIONS_DIR, AUTO_MOBILE_MIGRATIONS_DIR Directory containing SQL migrations; filesystem path. migrations directory beside the database module (or its db/ child)
AUTOMOBILE_MIGRATION_RECOVERY, AUTO_MOBILE_MIGRATION_RECOVERY Safe migration-history recovery is enabled unless 0, false, no, off (case-insensitive, trimmed). Only exact trimmed 1 also permits destructive table reset. safe recovery on; destructive reset off
AUTOMOBILE_MIGRATION_LOCK_TIMEOUT_MS, AUTO_MOBILE_MIGRATION_LOCK_TIMEOUT_MS File-scoped migration lock wait; positive base-10 integer milliseconds. 60000 ms

AUTOMOBILE_DB_PATH takes precedence over AUTOMOBILE_DB_DIR. Relative paths are resolved from the daemon’s launch directory. Use a path unique to each concurrent instance:

export AUTOMOBILE_DB_PATH="$PWD/.auto-mobile/auto-mobile.db"

Do not use AUTOMOBILE_DB_PATH=:memory: in production. It is allowed only for tests that also set AUTOMOBILE_ALLOW_IN_MEMORY_DB=1.

Tool defaults

Variable Use and accepted values Default
AUTOMOBILE_ENABLED_TOOLS Enable comma-separated exact, case-sensitive tool names; unknown names and conflicts fail startup. CLI flags win; persisted choices override startup defaults. unset; no override
AUTOMOBILE_DISABLED_TOOLS Disable comma-separated exact, case-sensitive tool names; same validation and precedence as enabled tools. unset; no override
AUTOMOBILE_ALWAYS_LOAD_TOOLS Register optional tool definitions eagerly; exact true enables. off
AUTOMOBILE_OBSERVE_RESULT_INCLUDE_ELEMENTS Include flattened elements in observations; exact 1 enables, or corresponding CLI flag. off
AUTOMOBILE_TOOL_RESULTS_NO_STRUCTURED_CONTENT Omit structured tool content; exact 1 enables, or corresponding CLI flag. off
AUTOMOBILE_ACTIONS_DIFF_OBSERVE Use differential action observations; exact 1 enables, or corresponding CLI flag. off
AUTOMOBILE_ACTIONS_NO_OBSERVE Skip post-action observation; exact 1 enables, or corresponding CLI flag. off
AUTOMOBILE_ACTIONS_COMPACT_METADATA Omit unchanged metadata per session/device and duplicate matches. Exact 0 opts out, 1 opts in. CLI negative wins over positive, then env, persisted state, default on. Unset/other values relay no preference or restart request. actions-compact-metadata=false persists an opt-out. First/changed/new-session/device-switch blocks remain full; diff/no-observe behavior is unchanged. on
AUTOMOBILE_EVENT_ALL_MARKERS Comma-separated, trimmed event markers that promote matching events to all-event capture; --event-all-markers wins. empty list
export AUTOMOBILE_ENABLED_TOOLS=clipboard,sqlQuery
export AUTOMOBILE_DISABLED_TOOLS=observe

Tool names are exact and case-sensitive. Unknown names and same-layer enable/disable conflicts fail startup. Repeatable --enable-tool and --disable-tool flags override these environment values; persisted setToolEnabled choices override startup defaults.

Session heartbeat timeout

Variable Use and accepted values Default
AUTOMOBILE_SESSION_HEARTBEAT_TIMEOUT_MS, AUTO_MOBILE_SESSION_HEARTBEAT_TIMEOUT_MS Heartbeat leash; positive base-10 integer milliseconds. 4000 ms
AUTOMOBILE_SESSION_HEARTBEAT_CHECK_INTERVAL_MS, AUTO_MOBILE_SESSION_HEARTBEAT_CHECK_INTERVAL_MS Heartbeat expiry sweep cadence; positive base-10 integer milliseconds. 2000 ms
AUTOMOBILE_SESSION_IDLE_TIMEOUT_MS, AUTO_MOBILE_SESSION_IDLE_TIMEOUT_MS Idle window after the last tool call ends; positive base-10 integer milliseconds. 120000 ms
AUTOMOBILE_SESSION_HEARTBEAT_INITIAL_GRACE_MS, AUTO_MOBILE_SESSION_HEARTBEAT_INITIAL_GRACE_MS Initial grace for custom-heartbeat sessions before first heartbeat; positive base-10 integer milliseconds. 20000 ms
AUTOMOBILE_SESSION_PRE_FIRST_HEARTBEAT_GRACE_MS, AUTO_MOBILE_SESSION_PRE_FIRST_HEARTBEAT_GRACE_MS Grace for default-policy sessions that never heartbeat; positive base-10 integer milliseconds. 5000 ms

AUTOMOBILE_SESSION_HEARTBEAT_TIMEOUT_MS (alias AUTO_MOBILE_SESSION_HEARTBEAT_TIMEOUT_MS) defaults to 4000 ms. It controls the heartbeat leash for heartbeat-policy sessions, not cli-idle sessions. A device session is released about 10 s after its owner’s last heartbeat: the 4 s lease, then a 4 s suspect grace, then at most one 2 s expiry sweep. The proxy heartbeats every 2 s, so one late or lost beat never lapses the lease and an owner must miss four beats before its session can be released. When the timeout is set explicitly, the proxy’s cadence is half of it.

export AUTOMOBILE_SESSION_HEARTBEAT_TIMEOUT_MS=20000

Liveness ownership has a single token per session, and the daemon protects it. A claim from a different token is rejected while the current owner’s lease is live. The lease is the session’s heartbeat timeout measured from the owner’s last heartbeat (or its recorded claim), the same deadline the heartbeat monitor reaps on, so a claim succeeds exactly when the session would otherwise be released. Only the owner’s own heartbeats extend the owner’s lease: a tool call that names the session, from any caller, keeps the session in use but does not keep a dead owner’s lease alive, so a restarted proxy with a new token can claim once the lease plus the 4 s grace window have passed even while it is already working on the session. A healthy single proxy is unaffected, since its own heartbeats keep the lease live. Two claimants racing for a lapsed session are serialised: the first records the takeover and its lease in the same step, and the second is refused. A claim succeeds when the session is unowned, the owner’s lease has expired, or the token matches the current owner (a restarted owner resuming). A session on the CLI idle policy never has a live lease for this purpose: its one-shot CLI owners exit between invocations, so the next invocation’s new token can always claim it.

Heartbeats prove liveness, not use (owner decision 2026-10-08, #10656). A heartbeat-policy session also has a 2-minute idle window measured from the end of its last control call, and heartbeats never extend it: they renew only the owner lease and its grace window. Only control calls extend the idle deadline, so a heartbeat from an idle but live owner does not. No read counts as activity, not even the owner’s own (owner decision 2026-10-09, #10964): observe and the other read-only tools never extend the idle window, so an agent that only observes its device for longer than the window loses it. Read-only access never requires a session (#10965); see device ownership for which tools and argument forms are reads. Only a control call in flight holds the idle release, and the idle window restarts when that control call ends (#10694). That hold is bounded: it lasts until the latest in-flight control call’s request deadline plus 10 s, or the 30 min ceiling when a control call has no deadline, so a call that never settles cannot hold a device forever. A read in flight never holds or extends an idle or owner-disconnect release; the release cuts it with the typed terminal refusal session_ownership_lost (#11381). An agent that acquires a device and then makes no tool calls for 2 minutes loses the session and its device even while its proxy stays open and keeps heartbeating; the release reason is cleanup-expired whichever path discovers it, not heartbeat-timeout. Watching is not use, and desktop input is (owner decisions 2026-10-08, #10693,

10730). A desktop or IDE window that only watches a device holds no session on it: it

registers an observer session, which the idle window does not apply to. Its first tap, swipe, key or text input on a free device allocates the device, and each later input restarts the idle window. When the user stops sending input, the daemon releases the device after the window as cleanup-expired, and the window drops back to watching without re-acquiring it; the next input allocates it again. The desktop may watch any device whichever session owns it (owner decision 2026-10-08, #10698, #10731, #8902), as a read-only viewer; control stays with the owner. input/* for a held device is accepted only from a frame that names the holding session, and is refused with device_owned_by_other_session otherwise (#10743). A device nobody holds takes input from anyone. AUTOMOBILE_SESSION_IDLE_TIMEOUT_MS (alias AUTO_MOBILE_SESSION_IDLE_TIMEOUT_MS) changes the window, for example to check idle release live; a value that is not a positive base-10 integer is ignored. Set it in both the daemon’s and the proxy’s environment. The proxy’s replay of a remembered session binding and its eviction of held sessions nothing has used follow the same window: only forwarded tool calls refresh them, including a call that selects the device with platform or deviceId instead of naming the session (#10692). Once the window passes, the proxy stops heartbeating the binding even if it missed the daemon’s release notification, and it also stops when the daemon answers a heartbeat with daemon_session_not_found; a call that names the released session is told to call getAndroid or getApple, which works on the same transport (#10702). A session held from the CLI keeps its own 2-minute default idle window (AUTOMOBILE_CLI_SESSION_IDLE_TIMEOUT_MS); the owner chose not to raise it (2026-10-09, #10982), and --cli help prints the same value. session-info reports lastUsedAt as the last tool activity and expiresAt as the idle deadline. When a session’s device restarts, the daemon waits up to three minutes for it to come back. Tool calls that start, wait on, or fail because of that recovery count as activity, so the idle window only ends the recovery once the client has made no calls for the whole window. A call waiting inside the recovery is bounded by the three minutes alone.

A daemon restart gives a session it rehydrates a fresh idle window: the session is recreated with lastUsedAt set to the restart time and its deadline one idle window later, because the persisted last_used_at_ms is not restored. This is intended, so an idle session that outlives a restart can hold its device for up to one more window. A persisted session whose deadline had already passed when the daemon restarted is not rehydrated.

A rejected claim returns { success: false, code: "liveness_owner_conflict", error: "..." } naming the session and changes nothing: the owner, policy, and every deadline stay as the owner left them. A claim from a token that already claimed and was displaced returns { success: false, code: "liveness_owner_superseded", error: "..." } and records nothing; it cannot take an owned session back. An explicit release opens the session to a new claim, including one from a previously used token. To take a session from a displaced owner, claim with a fresh token after that owner’s lease has expired.

On clean proxy close (stdin EOF, transport close or SIGTERM), ordinary sessions that proxy acquired and heartbeats are explicitly released under its current owner token before the daemon connection closes. Releases run in parallel with a 1.5 s bound; failure leaves the existing disconnect/heartbeat fallback. A configured --initial-session-uuid, a harness-supplied stable owner token and CLI-owned sessions retain their existing resume/idle behavior; managed executions use their separate release path. A predecessor cannot release a session whose liveness ownership was handed off (#11920).

A stdio/HTTP proxy claims and heartbeats only the sessions it acquired itself (from a getAndroid, getApple, startDevice or provisionDevice result, which includes a provisionDevice call that returns an error but keeps its session) or was started with through --initial-session-uuid. A session UUID that the proxy learns from a setActiveDevice result is bound for routing like one named in call args, and is not claimed or heartbeated unless this proxy minted it. A sessionUuid that is only passed to a tool call is forwarded with that call but never claimed or heartbeated, so a proxy cannot inherit a session another owner holds, even after that owner’s lease lapses (#10664). A proxy bound with --initial-session-uuid claims on its first heartbeat and restores the strict heartbeat policy. By default the proxy mints a new owner token per process, so a restarted proxy is a different token and is locked out while the previous process’s lease is live. It keeps claiming for as long as the daemon reports the previous owner’s hold (the daemon’s lease for the session plus its suspect grace, whatever the proxy’s own lease setting) and wins once that hold lapses. Only a previous owner that is still renewing its lease a minute after the conflict began counts as a live owner; the new proxy then stops claiming and its tool calls report the session as lost (#10701). A harness that restarts its proxy passes a stable token with --liveness-owner-token <token> (alongside --initial-session-uuid); the restarted proxy then claims with the same token and resumes the session without a conflict. Use a distinct token per harness. One-shot CLI sessions need no keeper (owner decision 2026-10-09, #11096): every --cli call re-declares the session’s CLI idle policy, and its idle window runs from tool calls, not heartbeats. --daemon heartbeat S on a CLI idle session is a successful no-op whatever token or --claim-liveness-ownership it carries: it claims nothing, changes nothing and says so. It is refused on a proxy-owned session (see “Supported liveness stack”). Ordinary ticks from the current owner renew its lease without changing the policy or the idle deadline.

A displaced token’s non-claiming daemon/heartbeat returns { success: false, code: "liveness_owner_superseded", error: "..." } and changes no activity, heartbeat, expiry or policy. The heartbeat CLI exits non-zero with guidance to re-claim or stop, instead of printing heartbeat recorded; it never does so for a CLI idle session, where the heartbeat is a no-op. There is no co-ownership.

The proxy reports lost ownership once at warn level and continues without fencing, reconnecting or re-claiming the session. This acknowledgement proves transport reachability only; its keeper no longer protects the deadline. Only the current owner’s ticks protect liveness: a proxy stall past the heartbeat timeout can therefore reap the session even while a displaced external keeper ticks. Legacy tokenless heartbeats after a token has claimed ownership remain successful no-ops. Missing or releasing sessions still return daemon_session_not_found. A keeper cannot currently claim through the heartbeat CLI without adopting its CLI policy.

Explicit liveness handoff

A one-shot CLI exits after declaring the CLI idle policy, retaining its token. An independent MCP proxy can immediately adopt that session with --initial-session-uuid S: CLI idle sessions do not block a different token’s claim. That claim switches the session back to heartbeat policy.

A harness that owns liveness through a proxy can deliberately hand the session to another client:

auto-mobile --daemon release-liveness-ownership S --liveness-owner-token T

The equivalent daemon method is daemon/releaseLivenessOwnership with { sessionId: S, livenessOwnerToken: T }. On an owned session, only its current token can release. A foreign or stale token returns liveness_owner_not_owner and changes nothing. A missing session returns daemon_session_not_found. Releasing an already-unowned session is a successful no-op for any valid token. The result is { sessionId: S, alreadyUnowned: false } on the first release and { sessionId: S, alreadyUnowned: true } on a no-op.

Unowned means the existing owner-token column is empty. Release changes only that token: it retains the session UUID, device reservation, liveness policy, activity clocks and existing deadline, including heartbeat suspect grace. It adds no schema state or former-owner proof. On restart the row follows the usual unowned rehydration path. A tokenless keeper tick does not adopt ownership. While the daemon keeps running, former-owner ticks cannot adopt the released session: they return liveness_owner_unowned with guidance to make an explicit claim. After a daemon restart, a released session can be re-adopted by its former owner’s token-bearing keeper tick, because the daemon’s claim history is empty; on a CLI idle row that tick is a no-op like any keeper heartbeat. Legacy recovery of rows without ownership continues to follow the existing rules.

Stop the old keeper after release. The next proxy’s explicit claim succeeds and moves the session to heartbeat policy; any second proxy with another token is refused while the first proxy’s lease or grace window is live. An unclaimed CLI idle session is reaped on its usual idle deadline. Tool activity and daemon-stall forgiveness continue to use the existing policy rules.

Ownership and lease state are separate. A session is token-owned or unowned; startup admission is awaiting-owner until the owner returns. For heartbeat policy, session-info reports all three lease states: live (lease remains), suspect (lease expired but grace remains), and lapsed (lease and grace ended, remaining time is zero). CLI idle policy uses its activity-based idle deadline and has no lease field in session-info. Release adds no session-info fields.

Supported liveness stack

The only supported liveness stack is harness → stdio proxy → daemon. The harness proves liveness to its proxy over stdio; the proxy is the only liveness owner of the sessions it holds, and heartbeats and claims them at the daemon with its owner token. Nothing else should heartbeat a proxy’s session.

--daemon heartbeat refuses a proxy-owned session. A session is proxy-owned when a token has claimed it under the strict heartbeat policy. The command sends a keeper marker, and the daemon answers every claim or tick against such a session with { success: false, code: "liveness_owner_is_proxy", error: "..." } before any ownership logic runs, whatever token the keeper presents and whether or not the proxy’s lease is still live. The refusal changes nothing: owner, policy and every deadline stay as the proxy left them. The command exits non-zero and prints the message, the [liveness_owner_is_proxy] code, and the instruction to stop the keeper. This is distinct from liveness_owner_conflict, which is a different token’s claim on a live lease and can succeed once the lease expires. On a one-shot CLI session --daemon heartbeat is a successful no-op (#11096): the CLI idle timeout counts from the end of the last tool call, so a session with no tool calls is released when it elapses however often anything heartbeats. Do not run a keeper loop for CLI sessions.

A harness checks a session’s state with --daemon session-info <session-id>. It prints the session’s assignedDevice, platform, lastUsedAt, expiresAt and, while the session is being released, releasing: true. A missing session fails with daemon_session_not_found. The harness reads this to decide whether a session survived; it must not heartbeat the session itself to find out. It also prints liveness: { state: "live" | "suspect" | "lapsed", remainingMs }, the time left on the lease or grace window, or zero once lapsed.

To tell an idle holder from an active one, session-info also prints:

Field Meaning
lastToolActivityAt Start or end of the session’s last tool call; heartbeats never move it.
lastOwnerHeartbeatAt The owner’s last heartbeat, or null before the first.
idleReleaseAt When idle release is due; the next monitor scan after it releases the session.
holderKind stdio-proxy, desktop, ide, cli, junit or unknown.
activeExecutions Calls in flight, reads included. Only a control call holds the idle release.

holderKind comes from the session’s liveness policy (cli for a one-shot --cli owner), the client name a client sent to daemon/registerSession, and the owner token (a token-claimed heartbeat session is a stdio-proxy). The desktop app and the IDE plugin register under the same name today, so both report desktop.

--daemon active-sessions prints every held session with the same fields plus sessionId, assignedDevice and platform, so one command answers which client holds which device and when it will be released. scripts/live-idle-release-check.sh uses it to check idle release against a real emulator with a private daemon.

Stalled liveness: daemon_stalled and proxy_stalled

When a lease expires the daemon does not release the session at once: it holds it as suspect for a 4 s grace window with its device still reserved for the owner token. A heartbeat from the owner token inside the window restores the session with the same UUID; no tool call runs against a suspect session until then. The proxy uses that window for its own recovery, so a stall is reported to the harness only when recovery has failed. Recovery always fits inside the lease plus the grace window (8 s at the default 4 s timeout) at the default 2 s heartbeat cadence. The proxy applies this to every session it holds, one session at a time. The daemon does not hold its own stalls against owners: when its heartbeat monitor runs more than 2 s later than scheduled it moves a session’s lease forward by exactly that lateness, so a daemon stall of a few seconds cannot push a heartbeating owner past lease plus grace. Only owners whose lease was still live when the stall began (within one lease of their last heartbeat) are excused (#11080): an owner that had already gone silent is released near the usual 10 s even while every scan runs late. Lease and idle time are measured on the monotonic clock, so stepping the wall clock (an NTP correction) neither extends nor shortens them; on macOS a forward step cannot be told from host sleep and counts as sleep.

Idle time is wall-clock, host sleep included (owner decision 2026-10-08,

10661; classification of sleep versus a daemon stall in #10699). A stall that on its own outlasts a

session’s idle window (its timeout; the suspect grace never extends it), such as a laptop asleep for longer than that, is idleness and is not forgiven, so waking the host may release the session. The verdict does not depend on which timer runs first after the wake: the monitor tick, an owner heartbeat, a tool call and the periodic sweep all apply the same stall check before judging expiry. The owner’s next call fails with bound_session_lost and a message saying the session was released after sitting idle (time asleep counts) and that it must acquire a new device session.

When the lease and grace are already spent by the time recovery starts (a long proxy stall, such as a sleeping laptop), the daemon may have forgiven its own stall and still hold the sessions, so each attempt is given the heartbeat request timeout (half the lease, at most twice the heartbeat interval) instead of a share of the exhausted budget.

Two distinct states, each with exactly three automatic recovery attempts:

  • daemon_stalled: the daemon’s socket is open or reconnectable but heartbeat acknowledgements stop. Each attempt reconnects if needed and heartbeats again with the same owner token. From the second attempt a silent socket may be replaced with a fresh one, but at most once per recovery episode (shared by every session recovering together, because the socket is one connection for all of them) and never while a tool call is in flight on it, unless an attempt has already seen the socket fail at the transport level. A held session the daemon answers “not found” is dropped once rather than retried, and a session that failed recovery is not recovered again before its handover. An attempt that is acknowledged ends the episode and the session stays usable; nothing is surfaced to the harness and the recovery is logged. The proxy also starts this recovery when a tool call is refused with daemon_session_suspect, the daemon’s signal that the session still exists and its owner can restore it.
  • proxy_stalled: the proxy’s own heartbeat tick fired later than the lease allows, which it can only notice after it resumes. It re-heartbeats every session it holds with the same owner token. A session the daemon kept as suspect is restored with the same UUID. A session is lost only when the daemon says so: it answers that the session was released, or that another token has taken it over while this proxy was stalled (the session is fenced and listed in the handover). A daemon that does not answer any of the three attempts is stalled, not the proxy: those sessions are handed over as daemon_stalled.

After the third failed attempt the proxy hands over. It stops heartbeating the affected sessions and returns this structured error on the next tool call that names an affected session (or reaches it implicitly), and it sends the same body as an MCP notifications/message (level: "error", logger: "auto-mobile.liveness", data: <the error body>) so a harness that is idle between calls is told too. The notification honours a level the client set with logging/setLevel: a client that asked for critical or above is not sent it and learns from the tool-call error:

{
  "error": {
    "code": "daemon_stalled",
    "message": "...",
    "sessions": [
      {
        "sessionUuid": "…",
        "deviceId": "emulator-5554",
        "lastAcknowledgedHeartbeatAt": 1760000000000
      }
    ],
    "attempts": 3,
    "maxAttempts": 3,
    "lastAcknowledgedHeartbeatAt": 1760000000000,
    "retryable": true,
    "recovery": { "action": "restart_daemon_then_resume_by_session_uuid" }
  }
}
Field Meaning
code daemon_stalled or proxy_stalled.
sessions[] daemon_stalled: the sessions that could not be reached. proxy_stalled: the sessions that were lost. Each has sessionUuid and deviceId (null if the proxy never learned it).
sessions[].lastAcknowledgedHeartbeatAt When the daemon last acknowledged that session’s heartbeat, in milliseconds on the proxy’s clock.
attempts, maxAttempts Recovery attempts made (most of any listed session) and the limit, always 3.
lastAcknowledgedHeartbeatAt The latest of the listed sessions’ last acknowledgements.
recovery.action daemon_stalled: wait_for_managed_reclaim for a ready managed connection’s slot sessions, otherwise restart_daemon_then_resume_by_session_uuid. proxy_stalled: reacquire_lost_sessions.

For daemon_stalled on a generic connection the harness restarts the daemon, then resumes each session by passing its sessionUuid on a tool call: the first call after the error re-claims it with the same owner token. A ready managed connection’s slot sessions instead receive wait_for_managed_reclaim: the proxy keeps probing every heartbeat interval and reclaims the session itself once a daemon answers again. Do not pass a sessionUuid or try to acquire a device; retry the call later. The proxy never restarts the shared daemon. If no daemon comes back, restarting it or relaunching the execution is the runner’s decision. If the session turns out to be released, calls answer managed_execution_ended. For proxy_stalled the listed sessions and devices are gone; reacquire them with getAndroid or getApple. During a liveness recovery episode or while a session remains handed over, the proxy never starts or restarts the daemon: it is shared, and restarting it is a harness action. Tool calls and resource reads wait for automatic recovery; successful recovery surfaces no error, and exhausted recovery returns the structured handover on calls/reads and by notification. Discovery does not wait for recovery or report the handover: it serves cached/static lists when unreachable, and uncached direct lists may observe the daemon unbound. Discovery never starts or restarts the daemon during an episode or handover. A named resume uses an observation-only connection and ends the handover on a definitive daemon answer: acknowledgement of the same UUID and owner token resumes it; daemon_session_not_found, liveness_owner_superseded, or liveness_owner_conflict confirms it was released or taken by another owner after the ownership grace window. A session-released notification also ends it, regardless of the current binding. A definitive loss delivers proxy_stalled once on the next tool call for that session, naming the sessions and devices to reacquire, then normal lifecycle behavior returns. An unreachable daemon retains the handover and the no-start fence; on a generic connection the harness must restart it. Recovery cannot join a pending connection allowed to start or restart the daemon. Healthy proxies and proxies without device sessions retain normal auto-start and skew reconciliation.

Managed reclaim awaits reasonless not-found and recoverable daemon handoff answers (daemon-shutdown or daemon-restart) for one existing rehydration window per answering daemon process; silence does not reset it, and only an answer naming a different daemon instance opens a new window. A managed execution’s terminal answers, notification and slot resource all name the daemon’s release reason, falling back to session-not-found when none was named.

A claim the daemon refuses with liveness_owner_conflict keeps being retried for the other owner’s lease plus its grace window (plus one heartbeat interval), so a restarted proxy can win a session whose previous owner has gone.

CLI session lifetime

Variable Use and accepted values Default
AUTOMOBILE_CLI_SESSION_IDLE_TIMEOUT_MS, AUTO_MOBILE_CLI_SESSION_IDLE_TIMEOUT_MS CLI idle leash; positive base-10 integer milliseconds (digits only; 2m or 1e5 is ignored with a warning), at least 120000: a smaller value is ignored, never shortens the window. Daemon adoption clamps to one hour. 120000 ms

Each --cli invocation is its own process, so it cannot send the periodic heartbeat a long-running MCP connection does. A session acquired or used by --cli is therefore held on a wall-clock idle timeout instead of the heartbeat lease, measured from the end of the last --cli tool call that touched it:

export AUTOMOBILE_CLI_SESSION_IDLE_TIMEOUT_MS=600000

The default is 2 minutes, the same as the ordinary idle window, and the ceiling is 1 hour. Heartbeats do not refresh it, and --daemon heartbeat on such a session is a no-op, so no keeper loop is needed (#11096). The value is read from the --cli process, not the daemon’s, and travels with the invocation, so changing it takes effect on the very next call — no daemon restart. Sessions owned by a long-lived MCP client (stdio or HTTP) are unaffected and keep the heartbeat contract; if such a client takes over a session a --cli call had held, that session goes back to the heartbeat contract and stops occupying its device for the idle window once the client disconnects.

Automatic observation screenshots

Variable Use and accepted values Default
AUTOMOBILE_ACTION_OBSERVATION_SKIP_SCREENSHOT Skip automatic post-action screenshots; false (case-insensitive, trimmed) or 0 opts into final screenshot. skip
AUTOMOBILE_OBSERVE_WAIT_FOR_SKIP_SCREENSHOT Skip final observe.waitFor screenshot; false (case-insensitive, trimmed) or 0 enables it; intermediate polls still skip. skip
AUTOMOBILE_OBSERVE_SETTLED_SCREENSHOT Override persisted screenshot mode: true/1 selects settled; any other set value selects async (case-insensitive, trimmed). Per-call mode wins. unset; persisted flag, otherwise async
AUTOMOBILE_MAX_OBSERVATION_AGE_MS Hierarchy freshness budget; positive base-10 integer milliseconds. 5000 ms

Explicit observe calls retain their existing screenshot behavior. Automatic observations taken after an action or while resolving observe.waitFor skip screenshots by default. Opt in to one screenshot from the final result by setting either skip flag to false (or 0):

export AUTOMOBILE_ACTION_OBSERVATION_SKIP_SCREENSHOT=false
export AUTOMOBILE_OBSERVE_WAIT_FOR_SKIP_SCREENSHOT=false

observe.waitFor suppresses screenshots for all intermediate polls; enabling its flag captures only once, after the condition resolves or times out.

The default screenshot mode is async. Set AUTOMOBILE_OBSERVE_SETTLED_SCREENSHOT=true (or 1) to opt in to awaiting a fresh, validated screenshot for each final observation. false (or 0) disables settled mode. This setting overrides the persisted observe-settled-screenshot feature flag; when settled mode is enabled it also overrides the two legacy skip flags. A per-call observe({ screenshot: "settled" }) takes precedence over both. The resolver’s code comment documents the full precedence.

In a 30-iteration emulator/simulator benchmark, settled mode added about 170 ms per Android observation and 20–70 ms per iOS observation compared with async mode. Use observe({ screenshot: "settled" }) when a particular call needs a validated screenshot that matches its observation.

export AUTOMOBILE_OBSERVE_SETTLED_SCREENSHOT=true

Device behavior

Variable Use and accepted values Default
AUTOMOBILE_IOS_WARMUP_DEVICES Comma-separated simulator UDIDs allowed passive startup warm-up and observation streams without sessions. empty list
AUTOMOBILE_ANDROID_APPEARANCE_SYNC_DEVICES Comma-separated Android serials allowed appearance sync without sessions. empty list
AUTOMOBILE_ANDROID_OBSERVATION_STREAM_DEVICES Comma-separated Android serials allowed passive observation streams without sessions. empty list
AUTOMOBILE_APPEARANCE_SYNC Enable appearance sync; 0, false, off, no disable (case-insensitive, trimmed). on
AUTOMOBILE_ALLOW_DEVICE_CREATE Permit emulator/simulator creation; 1/true enable (case-insensitive, trimmed). Explicit creation flag wins. off
AUTOMOBILE_DEVICE_RECOVERY_ON_LOSS, AUTO_MOBILE_DEVICE_RECOVERY_ON_LOSS Exact 0 disables session continuity; exact 1 additionally opts eligible Android emulators into active restart. Other values warn and disable active restart. passive continuity on; active restart off
AUTOMOBILE_ANDROID_REBOOT_ON_DEATH, AUTO_MOBILE_ANDROID_REBOOT_ON_DEATH Deprecated fallback for device recovery on loss, after both platform-neutral spellings; same 0/1 semantics. unset
AUTOMOBILE_DEVICE_RECOVERY_MAX_ATTEMPTS, AUTO_MOBILE_DEVICE_RECOVERY_MAX_ATTEMPTS Rolling active restart budget; integer 1..10, invalid values fall back. 2
AUTOMOBILE_DEVICE_RECOVERY_WINDOW_MS, AUTO_MOBILE_DEVICE_RECOVERY_WINDOW_MS Rolling recovery budget window; positive integer milliseconds (digits, no leading zero). 900000 ms
AUTOMOBILE_DEVICE_POOL_MATCHING, AUTO_MOBILE_DEVICE_POOL_MATCHING Device selection: exact LATEST, RANDOM, MINIMUM; invalid values fall back. LATEST
AUTOMOBILE_DEVICE_POOL_AUTOLOCK, AUTO_MOBILE_DEVICE_POOL_AUTOLOCK Require acquired device session UUID and auto-release on idle; exact 1 enables. off
AUTOMOBILE_DEVICE_POOL_TIMEOUT, AUTO_MOBILE_DEVICE_POOL_TIMEOUT Autolock idle timeout in seconds, parsed as a positive base-10 integer prefix. 60 seconds
AUTOMOBILE_EMULATOR_HEADLESS Exact true forces headless, false forces windowed; other values use platform detection. headless on macOS and Linux without a display; otherwise windowed
AUTOMOBILE_EMULATOR_AUDIO Exact false adds -no-audio; other values keep emulator audio. audio on
AUTOMOBILE_EMULATOR_ARGS Extra emulator argv as a JSON array of nonempty strings; malformed JSON/entries fail. empty array
AUTOMOBILE_IOS_HEADLESS On macOS exact true/1 forces headless; any other set value forces windowed. Non-macOS is always headless. detect graphical login session
AUTOMOBILE_BOOT_CAPACITY_GATE Boot admission gate for emulator and simulator cold boots; exact 0 disables it on both platforms. See Boot capacity. on
AUTOMOBILE_IOS_SIM_CAPACITY_GATE iOS-only override of AUTOMOBILE_BOOT_CAPACITY_GATE: exact 0 disables, exact 1 enables; other values defer to the shared switch. unset (follows the shared switch)
AUTOMOBILE_ANDROID_MAX_BOOTED Cap on concurrently booted Android emulators used by the boot admission gate; positive integer. Invalid values are ignored with a warning. min(RAM x 0.5 / measured per-emulator RSS, cores / 2), at least 1
AUTOMOBILE_IOS_SIM_MAX_BOOTED Cap on concurrently booted iOS Simulators used by the fleet capacity report and the boot admission gate; positive integer. Invalid values are ignored with a warning. min(RAM x 0.5 / measured per-simulator RSS, cores / 2), at least 1
AUTOMOBILE_WORK_PROFILE_POLL_INTERVAL_MS Android work-profile package polling interval; base-10 integer milliseconds. Use positive values; this read has no invalid-value fallback. 5000 ms

The Android boot limit is shared by every daemon using the same adb server on one host. Admissions are coordinated through the adb-server directory configured by AUTOMOBILE_ADB_SERVER_COORDINATION_DIR; every daemon sharing a server must use the same directory. AUTOMOBILE_COORDINATION_DIR, AUTOMOBILE_DATA_DIR and AUTOMOBILE_DB_DIR do not move it, so daemons isolated only by those still share one limit. Daemons on one adb server that are given different AUTOMOBILE_ADB_SERVER_COORDINATION_DIR values do not see each other’s admitted boots (or device claims): each counts only the emulators adb or the process table already shows, and two boots started seconds apart can exceed the limit. The default root is under the home directory the runtime reports for the account, which follows HOME under Bun, so daemons started with different HOME values must set the variable to one shared path. To isolate a daemon’s boot limit, give it its own adb server as well. An unreadable coordination directory refuses boots with retryable discovery_incomplete.

The daemon’s passive device work is limited to devices with a session in this daemon. At startup, it warms iOS CtrlProxy only for rehydrated sessions. An explicit acquisition can also start a runner for its device. Previously an unset AUTOMOBILE_IOS_WARMUP_DEVICES warmed every booted simulator; now unset or empty means no extra simulators. Set it to a comma-separated list of simulator UDIDs to allow iOS startup warm-up and observation-stream connections for those devices even without a session. The live-acceptance startup secret suppresses both iOS passive paths, including allowlisted devices.

export AUTOMOBILE_IOS_WARMUP_DEVICES=00000000-0000-0000-0000-000000000001

Android appearance sync also defaults to session-owned devices. It runs when a session acquires a device and on later scheduler ticks. IDE-only use without a session no longer syncs appearance unless the device serial is in AUTOMOBILE_ANDROID_APPEARANCE_SYNC_DEVICES. Set AUTOMOBILE_APPEARANCE_SYNC to 0, false, off, or no (case-insensitive, with surrounding whitespace ignored) to turn off appearance sync in daemon and direct mode. Android observation-stream initial connections and cadence refreshes follow the same session-owned default; use AUTOMOBILE_ANDROID_OBSERVATION_STREAM_DEVICES to opt in unowned serials. These Android settings do not affect iOS warm-up. Each device list is a comma-separated list of IDs; an empty value adds no devices.

export AUTOMOBILE_ANDROID_APPEARANCE_SYNC_DEVICES=emulator-5554
export AUTOMOBILE_ANDROID_OBSERVATION_STREAM_DEVICES=emulator-5554

AutoMobile does not create an emulator or simulator by default. The legacy compatibility path can opt in:

export AUTOMOBILE_ALLOW_DEVICE_CREATE=1
auto-mobile --cli startDevice --platform ios --create-if-missing

An explicit --create-if-missing false disables creation even when the environment variable is set. Created devices can be removed with xcrun simctl delete <udid> or avdmanager delete avd -n <name>.

Session continuity is enabled by default for any session bound to an Android emulator or iOS simulator, regardless of how that binding happened – an explicit startDevice/getAndroid, or a client-supplied or runner-minted session UUID that the daemon allocated from the idle pool. What matters is whether discovery resolved a stable identity for the device (its AVD name, or its simulator UDID), not who launched it. If the runtime connection disappears, AutoMobile retains the session for that stable identity and waits for it to reappear. To disable that continuity explicitly:

export AUTOMOBILE_DEVICE_RECOVERY_ON_LOSS=0
export AUTOMOBILE_DEVICE_RECOVERY_MAX_ATTEMPTS=2

Actively restarting the AVD process on loss is a stricter, separate opt-in: AUTOMOBILE_DEVICE_RECOVERY_ON_LOSS=1 additionally requires the emulator’s configured image to have been recorded, which only happens on a startDevice/getAndroid path with successful image enrichment. A pool- allocated or enrichment-failed emulator still gets passive continuity (reattach when it returns) but is never actively relaunched. Physical devices and externally started Android emulators are not restarted. iOS simulators are never actively restarted by session continuity; their recovery is always passive, waiting for the same simulator UDID to become booted again.

AUTOMOBILE_DEVICE_RECOVERY_MAX_ATTEMPTS is a rolling budget, not a lifetime one: only restarts within the last AUTOMOBILE_DEVICE_RECOVERY_WINDOW_MS (default 15 minutes) count against it, so isolated crashes days apart don’t exhaust the budget of a long-lived daemon. A recovery cancelled before it touches the emulator (an ADB-reset takeover, or an intentional killDevice) does not spend an attempt either way.

Boot capacity

Emulator and simulator cold boots are checked against host capacity by default (#11181). Android and iOS each have their own limit: the smaller of half the host RAM divided by the per-device memory and half the CPU cores, at least 1. Android measures per-emulator memory from the qemu-system-* processes (4 GiB when it cannot); iOS measures each simulator’s process tree (3 GiB when it cannot). Every booted device of the platform counts, including ones started outside AutoMobile: for Android, the larger of the emulators adb lists and the qemu-system-* processes on the host, so emulators still booting or on another adb server count too. On iOS, simulators that are Booting or Shutting Down count as well, and a simulator inventory that cannot be read refuses the boot with the retryable discovery_incomplete rather than counting zero. A failed ps read does not lower the iOS limit: it is still derived from host RAM and cores.

Only the device-count limit refuses a boot: iOS host memory/CPU pressure is reported (hostPressure in the capacity snapshot) but never refuses one.

A boot over the limit is refused at once; it does not queue against the boot budget (#11209). It fails with a retryable error whose retryAfterMs (a fixed 5 seconds: capacity frees when another device shuts down, which takes seconds) says when to try again. When emulators AutoMobile did not start occupy slots, the message lists them and externalDevices carries their serials:

{
  "success": false,
  "error": "Refused to boot: no emulator capacity (code capacity_exhausted, retryable; retry after 5000ms): 2 emulator(s) booted; limit is 2 (derived). 1 of them were not started by AutoMobile but still count toward the limit: emulator-5554. Shut one down, raise the limit with AUTOMOBILE_ANDROID_MAX_BOOTED or disable the gate with AUTOMOBILE_BOOT_CAPACITY_GATE=0.",
  "code": "capacity_exhausted",
  "retryable": true,
  "retryAfterMs": 5000,
  "limit": 2,
  "booted": 2,
  "platform": "android",
  "externalDevices": ["emulator-5554"]
}

Adopting an emulator or simulator that is already running or starting is never refused. An admitted boot holds its slot until adb lists the new emulator (or the simulator boot finishes), the launch fails or is cancelled, or the boot deadline passes. listDevices reports capacity per gated platform as { limit, booted, inFlight }. Android also reports optional processTable diagnostics when the current sample lacks the host process table or this daemon has admitted a boot on the adb-only count: availability, the most recent failure reason, detail and attempt count, plus the number and last time of such admissions. Transient ps failures are retried with exponential backoff within the sample deadline; exhausted retries retain the adb-only fallback. On Linux, deterministic ps failures fall back to a bounded /proc read; after a successful fallback, later samples use /proc directly. Unknown non-zero ps exits stop retrying after three consecutive exhausted samples, until a successful ps read resets that latch. Capacity checks and reports share the 15-second sample bound.

export AUTOMOBILE_ANDROID_MAX_BOOTED=3     # fixed Android limit
export AUTOMOBILE_IOS_SIM_MAX_BOOTED=2     # fixed iOS limit
export AUTOMOBILE_BOOT_CAPACITY_GATE=0     # turn the gate off on both platforms
export AUTOMOBILE_IOS_SIM_CAPACITY_GATE=0  # turn it off for iOS only

Shared ADB server

Variable Use and accepted values Default
AUTOMOBILE_MANAGED_ADB_SERVER, AUTO_MOBILE_MANAGED_ADB_SERVER Declare process ownership of ADB server; 1/true enable (case-insensitive, trimmed), stopping it on clean shutdown. off

By default AutoMobile leaves the local ADB server running. Set AUTOMOBILE_MANAGED_ADB_SERVER=1 only when this process owns the server; a clean shutdown then stops it after active device sessions are released.

Port allocation

Variable Use and accepted values Default
AUTOMOBILE_PORT_RANGE_START, AUTO_MOBILE_PORT_RANGE_START First CtrlProxy host port; positive base-10 integer. 8765
AUTOMOBILE_PORT_RANGE_END, AUTO_MOBILE_PORT_RANGE_END Inclusive last CtrlProxy host port; base-10 integer at least the start. Takes precedence over size; invalid values fall back to 100 ports. unset
AUTOMOBILE_PORT_RANGE_SIZE, AUTO_MOBILE_PORT_RANGE_SIZE Number of CtrlProxy host ports; positive base-10 integer, used only without end. 100

Assets and CtrlProxy

Variable Use and accepted values Default
AUTOMOBILE_VERSION Trimmed concrete asset release version; unset, empty or latest uses newest registry release. Unknown explicit pins fail closed on integrity checks. newest release registry entry
AUTOMOBILE_ASSET_BASE_URL Release asset mirror base; absolute HTTPS URL without query or fragment, trailing slash removed. https://github.com/kaeawc/auto-mobile/releases/download
AUTOMOBILE_ALLOW_INSECURE_ASSET_URL Permit plaintext HTTP asset URLs with 1/true (case-insensitive, no trimming); other non-HTTPS schemes still fail. off
AUTOMOBILE_CTRL_PROXY_APK_PATH Local Android APK override; trimmed filesystem path, resolved from daemon launch directory; bypasses published APK checksum baseline. released/cached APK
AUTOMOBILE_SKIP_ACCESSIBILITY_CHECKSUM, AUTO_MOBILE_ACCESSIBILITY_SERVICE_SHA_SKIP_CHECK Skip Android APK checksum enforcement with 1/true (case-insensitive, no trimming). off
AUTOMOBILE_SKIP_ACCESSIBILITY_DOWNLOAD_IF_INSTALLED Accept an installed APK unchecked outside readiness; readiness still upgrades (see below). 1/true (case-insensitive, no trimming). off
AUTOMOBILE_SKIP_CTRL_PROXY_DOWNLOAD Use local CtrlProxy sources without downloading; 1/true enables (case-insensitive, no trimming); CLI skip flag also enables. off
AUTOMOBILE_IOS_PROTOTYPE_AGENT Local iOS simulator prototype-agent dylib override; filesystem path (not trimmed), must exist or resolution fails; wins over a local build and the release download. local build output, then verified release download
AUTOMOBILE_SKIP_IOS_PROTOTYPE_AGENT_DOWNLOAD Refuse to download the prototype-agent dylib when no verified cached copy exists; 1/true enables (case-insensitive, no trimming). off
AUTOMOBILE_IOS_CTRLPROXY_RECORD_DIR Record every iOS CtrlProxy request/response as JSON into this directory for golden replay; trimmed filesystem path. Password-field text is redacted. Also set CTRL_PROXY_IOS_HIERARCHY_PAIR_DIR to make a simulator runner record merger input pairs. off
AUTOMOBILE_CTRLPROXY_VERBOSE Verbose iOS CtrlProxy output; exact true enables. off
AUTOMOBILE_CTRL_PROXY_HEALTH_MAX_ATTEMPTS, AUTO_MOBILE_CTRL_PROXY_HEALTH_MAX_ATTEMPTS iOS runner health poll attempts; positive base-10 integer. 60
AUTOMOBILE_IPROXY_START_TIMEOUT_MS, AUTO_MOBILE_IPROXY_START_TIMEOUT_MS iOS USB iproxy tunnel startup timeout; positive base-10 integer milliseconds. 5000 ms
AUTOMOBILE_CWEBP_PATH Trusted executable cwebp override; trimmed filesystem path; invalid executable fails. PATH, bundled vendor or downloaded libwebp
AUTOMOBILE_DWEBP_PATH Trusted executable dwebp override; trimmed filesystem path; invalid executable fails. PATH, bundled vendor or downloaded libwebp
AUTOMOBILE_FFMPEG ffmpeg executable override; executable path/name (not trimmed); explicit resolver option wins. ffmpeg on PATH

AUTOMOBILE_SKIP_ACCESSIBILITY_DOWNLOAD_IF_INSTALLED does not stop a CtrlProxy upgrade when the installed APK’s checksum differs from the expected release. It applies only to paths that do not request a download: CtrlProxy auto-setup (including reconnect recovery) and doctor status reads accept an installed APK as skipped without reading its checksum or queuing a background refresh. Runner readiness (startDevice, observing a device by deviceId, and other calls that wait for a ready runner) and the IDE “update service” action request the download explicitly, so they ignore this variable and upgrade a mismatched APK. It is also ignored when AUTOMOBILE_VERSION pins a known release and AUTOMOBILE_SKIP_ACCESSIBILITY_CHECKSUM is unset; a mismatch then fails closed. To keep an installed APK through readiness, set AUTOMOBILE_SKIP_CTRL_PROXY_DOWNLOAD=1 (readiness never downloads and reports a version mismatch as a failure) together with AUTOMOBILE_SKIP_ACCESSIBILITY_CHECKSUM=1 (the installed version is accepted).

iOS builds and signing

Variable Use and accepted values Default
AUTOMOBILE_PROJECT_ROOT Root used by local iOS CtrlProxy source builds; filesystem path. process working directory at module load
AUTOMOBILE_CTRL_PROXY_IOS_DERIVED_DATA Local iOS CtrlProxy derived-data root; filesystem path. data directory derived-data
AUTOMOBILE_CTRL_PROXY_IOS_CACHE_DIR iOS CtrlProxy bundle cache directory; filesystem path. coordination directory ctrl-proxy-ios
AUTOMOBILE_CTRL_PROXY_IOS_BUNDLE_URL Trimmed custom iOS bundle download URL; HTTPS unless insecure asset URL opt-in. release bundle URL
AUTOMOBILE_CTRL_PROXY_IOS_IPA_PATH Trimmed local iOS archive/bundle override; filesystem path; wins over bundle-path override. unset; released bundle
AUTOMOBILE_CTRL_PROXY_IOS_BUNDLE_PATH Trimmed local iOS runner bundle or archive override; filesystem path. unset; released bundle
AUTOMOBILE_CTRL_PROXY_IOS_RUNNER_SHA256 Trimmed 64-character hexadecimal executable SHA256 for local/custom runner; wins over local-build derivation. release registry checksum
AUTOMOBILE_CTRL_PROXY_IOS_RUNNER_SHA256_TARGET Checksum executable: exact trimmed runner or xctest; other nonempty values fail. release registry target
AUTOMOBILE_CTRL_PROXY_IOS_USE_LOCAL_BUILD Derive and pin local runner executable hash after build/extract, then reverify before launch; 1/true enables (case-insensitive, no trimming). off
AUTOMOBILE_IOS_CTRL_PROXY_APP_HASH Trimmed expected device app bundle hash; device-specific override wins. released device app hash
AUTOMOBILE_IOS_CTRL_PROXY_APP_HASH_DEVICE Trimmed expected device app bundle hash; overrides generic/device release hash. generic override or released device app hash
AUTOMOBILE_IOS_CTRL_PROXY_APP_HASH_SIMULATOR Trimmed expected simulator app bundle hash. unset; no simulator app hash verification
AUTOMOBILE_IOS_SKIP_CTRL_PROXY_APP_HASH Skip iOS app bundle hash enforcement; exact true/1 enables. off
AUTOMOBILE_IOS_HELPER_REQUIRE_CODESIGN Make codesign, Gatekeeper or pinned Team ID failures fatal; 1/true enables (case-insensitive, no trimming). off; failures warn
AUTOMOBILE_IOS_HELPER_TEAM_ID Trimmed Apple Team ID to verify on downloaded runner bundle; warn or refuse according to require-codesign. unset
AUTOMOBILE_IOS_TEAM_IDS Comma-separated signing Team IDs, trimmed; wins over single Team ID. auto-detected signing teams
AUTOMOBILE_IOS_TEAM_ID Single signing Team ID, also parsed as a comma-separated list; used without plural setting. auto-detected signing teams
AUTOMOBILE_IOS_PROFILE_UUID Provisioning profile UUID; wins over name and specifier. automatic profile discovery
AUTOMOBILE_IOS_PROFILE_NAME Provisioning profile name; used without UUID, wins over specifier. automatic profile discovery
AUTOMOBILE_IOS_PROFILE_SPECIFIER Provisioning profile specifier; used without UUID or name. automatic profile discovery
AUTOMOBILE_IOS_CODE_SIGN_IDENTITY Trimmed signing identity string. auto-detected identity
AUTOMOBILE_IOS_CODE_SIGN_ENTITLEMENTS_PATH Entitlements file path for signing; filesystem path. runner/app entitlements discovery
AUTOMOBILE_IOS_TAP_STRATEGY Diagnostic tap strategy (only at debug log level): exact legacy, appRelative, appRelativeObserved, displayTargeted, displayTargetedObserved. runner default strategy
AUTOMOBILE_IOS_PROTOTYPE_AGENT Local iOS simulator prototype-agent dylib override; filesystem path that must exist; wins over local build output and release download. unset; local build output, then released dylib
AUTOMOBILE_SKIP_IOS_PROTOTYPE_AGENT_DOWNLOAD Refuse to download the released prototype-agent dylib when no verified cached copy exists; 1/true enables (case-insensitive, no trimming). off; download on first prototype use

Video recording and capture

Variable Use and accepted values Default
AUTOMOBILE_VIDEO_QUALITY_PRESET, AUTO_MOBILE_VIDEO_QUALITY_PRESET Exact low, medium, high recording preset; CLI and per-call options win. low
AUTOMOBILE_VIDEO_TARGET_BITRATE_KBPS, AUTO_MOBILE_VIDEO_TARGET_BITRATE_KBPS Positive base-10 integer prefix via parseInt(value, 10) in kbps (999.9 becomes 999, 29abc becomes 29); invalid/nonpositive values warn and are ignored; empty ignored. Throughput cap may lower it. 1000 kbps
AUTOMOBILE_VIDEO_MAX_THROUGHPUT_MBPS, AUTO_MOBILE_VIDEO_MAX_THROUGHPUT_MBPS Positive finite Number(value) (fractions allowed, trailing junk rejected), recording throughput cap; invalid/nonpositive values warn and are ignored; empty ignored. 5 Mbps
AUTOMOBILE_VIDEO_FPS, AUTO_MOBILE_VIDEO_FPS Positive base-10 integer prefix via parseInt(value, 10) in frames/second (29.9 or 29abc becomes 29); invalid/nonpositive values warn and are ignored; empty ignored. Backend may clamp. 15 fps
AUTOMOBILE_VIDEO_MAX_ARCHIVE_MB, AUTO_MOBILE_VIDEO_MAX_ARCHIVE_MB Positive finite Number(value) (fractions allowed, trailing junk rejected), archive storage limit; invalid/nonpositive values warn and are ignored; empty ignored. 100 MB
AUTOMOBILE_VIDEO_FORMAT, AUTO_MOBILE_VIDEO_FORMAT Exact mp4; other values warn and are ignored. mp4
AUTOMOBILE_VIDEO_RETENTION_DAYS, AUTO_MOBILE_VIDEO_RETENTION_DAYS Finite nonnegative recording TTL in days; 0 disables age expiry. 7 days
AUTOMOBILE_VIDEO_RETENTION_SWEEP_MINUTES, AUTO_MOBILE_VIDEO_RETENTION_SWEEP_MINUTES Positive finite sweep period in minutes; timer minimum 1 second. 60 minutes
AUTOMOBILE_VIDEO_INPROGRESS_CHECK_SECONDS, AUTO_MOBILE_VIDEO_INPROGRESS_CHECK_SECONDS Positive finite active recording size-check period in seconds; timer minimum 1 second. 15 seconds
AUTOMOBILE_ANDROID_VIDEO_USE_FFMPEG_PIPE Android exec-out screenrecord to ffmpeg recording backend; exact 1/true enables. off
AUTOMOBILE_VIDEO_SERVER_JAR Local Android persistent encoder JAR; untrimmed filesystem path, used only if it exists. Surrounding whitespace is significant; nonexistent paths silently fall through. cached/released JAR, then source-built JAR
AUTOMOBILE_REQUIRE_VIDEO_SERVER Make unavailable/invalid video-server JAR fatal instead of degrading; 1/true enables (case-insensitive, no trimming). off
AUTOMOBILE_SKIP_VIDEO_SERVER_DOWNLOAD Resolve video-server JAR locally only; 1/true enables (case-insensitive, no trimming). off
AUTOMOBILE_IOS_SCREEN_CAPTURE_HELPER, AUTO_MOBILE_IOS_SCREEN_CAPTURE_HELPER Local screen-capture-helper executable path; bypasses release prefetch. released helper
AUTOMOBILE_NETWORK_FILTER_APP_PATH Local signed Network Filter .app used by auto-mobile --ios-network-filter install instead of the release download. release download
AUTOMOBILE_NETWORK_FILTER_TEAM_ID Optional Apple Team ID the Network Filter app and its provider must both be signed by. shared Developer ID team required
AUTOMOBILE_IOS_WEBRTC_FFMPEG, AUTO_MOBILE_IOS_WEBRTC_FFMPEG iOS WebRTC ffmpeg executable path/name (not trimmed); first nonempty spelling wins; explicit resolver option wins. ffmpeg on PATH
AUTOMOBILE_IOS_WEBRTC_FORCE_RAW, AUTO_MOBILE_IOS_WEBRTC_FORCE_RAW Force raw iOS capture path instead of native H.264; 1/true enables (case-insensitive, no trimming). off
AUTOMOBILE_IOS_SIMULATOR_WINDOW_TIMEOUT_MS Simulator capture window target resolution budget; positive base-10 integer milliseconds. 2000 ms

WebRTC

Variable Use and accepted values Default
AUTOMOBILE_WEBRTC_WHIP_ENDPOINT WHIP ingest URL; HTTPS, or loopback HTTP; remote HTTP requires insecure-WHIP opt-in. Per-call options win. unset; required to stream
AUTOMOBILE_WEBRTC_WHIP_TOKEN Bearer token string for WHIP ingest. unset
AUTOMOBILE_WEBRTC_ICE_SERVERS JSON array of RTCIceServer objects (urls, optional username/credential) or comma-separated STUN/TURN URLs. stun:stun.l.google.com:19302
AUTOMOBILE_WEBRTC_BITRATE_KBPS Positive finite encoder bitrate, rounded to integer kbps. unset; encoder/source default (iOS uses pixel-rate budget)
AUTOMOBILE_WEBRTC_MAX_SIZE WIDTHxHEIGHT; positive even safe integers within H.264 Level 4.2 frame limit. unset; source dimensions
AUTOMOBILE_WEBRTC_IOS_SIMULATOR_FPS Integer 5..60 capture rate. 15 fps
AUTOMOBILE_WEBRTC_ANDROID_FPS Integer 1..60 capture rate. 30 fps
AUTOMOBILE_WEBRTC_TRICKLE_ICE WHIP PATCH trickle ICE; 1, true, yes, on enable (case-insensitive, trimmed). off
AUTOMOBILE_WEBRTC_AUDIO Add audio track; 1, true, yes, on enable (case-insensitive, trimmed). off
AUTOMOBILE_WEBRTC_ALLOW_INSECURE_WHIP Permit non-loopback HTTP and arbitrary wire WHIP overrides; 1, true, yes, on enable (case-insensitive, trimmed). off
AUTOMOBILE_WEBRTC_WHIP_ALLOWED_ORIGINS Comma-separated origins or bare host[:port] allowed for wire WHIP overrides; loopback and configured WHIP origin are always trusted. empty additional allowlist
Variable Use and accepted values Default
AUTOMOBILE_NAV_RETENTION_SCREENSHOT_TTL_MS Screenshot retention TTL; positive safe integer milliseconds. 604800000 ms (7 days)
AUTOMOBILE_NAV_RETENTION_STRUCTURE_TTL_MS Structure retention TTL; positive safe integer milliseconds. 7776000000 ms (90 days)
AUTOMOBILE_NAV_RETENTION_PER_APP_MAX_OBSERVATIONS Per-app observation cap; positive safe integer. 50000
AUTOMOBILE_NAV_RETENTION_GLOBAL_MAX_OBSERVATIONS Global observation cap; positive safe integer. 500000
AUTOMOBILE_NAV_RETENTION_EVICTION_CHUNK_SIZE Rows per eviction batch; positive safe integer clamped to 10000. 5000
AUTOMOBILE_NAV_RETENTION_INTERVAL_MS Retention sweep cadence; positive safe integer milliseconds at most 2147483647; larger values fall back. 21600000 ms (6 hours)

Performance diagnostics

Variable Use and accepted values Default
AUTOMOBILE_OBSERVE_PERF_SNAPSHOT, AUTO_MOBILE_OBSERVE_PERF_SNAPSHOT Attach rolling performance snapshot to observe results; 1, true, yes enable (case-insensitive, trimmed). off
AUTOMOBILE_OBSERVE_PERF_WINDOW_MS, AUTO_MOBILE_OBSERVE_PERF_WINDOW_MS Positive finite snapshot window milliseconds, rounded and clamped to 1000..30000; invalid values fall back. 5000 ms
AUTOMOBILE_DISABLE_PERF_AUDIT Disable UI performance audit with 1, true, yes (case-insensitive, trimmed); audit otherwise requires UI perf mode and debug-perf. off
AUTOMOBILE_TOUCH_LATENCY_SAMPLING Opt into synthetic tap latency sampling; 1, true, yes enables (case-insensitive, trimmed). off