Read this for Apple runner changes or manual agent-device runs on simulators, emulators, or
physical devices. Live verification steps apply when exercising a device-facing path.
- After changing runtime code reached through
bin/agent-device.mjsor the daemon:pnpm build, thenpnpm clean:daemon— the daemon does not self-reload. - Before any Android verification from source:
pnpm build,pnpm build:android,pnpm clean:daemon.build:androidrefreshes and verifies both bundled Android helper artifacts for the current package version. - Graceful
shutdownhands off a healthy runner that already answered a command, on the simulator and physical iOS lanes alike; a new daemon may adopt the old binary. After Swift runner changes, runpnpm build:xcuitestbefore verification. Use the session cleanup procedure below if ownership is stuck. The physical handoff's device steps aredocs/evidence/ios-physical-runner-handoff-2026-09-19.md; nothing in it is proven until someone with a cabled device checks a box.
- Android: capture
snapshot -i --jsonand requireandroidSnapshot.backendto beandroid-helperwithhelperVersionequal topackage.json's version. A stock UIAutomator fallback is not valid verification unless the fallback itself is the behavior under test. - For repo-owned
Agent Device Testerwork,examples/test-app/README.mdis the source of truth for simulator, physical-device, Metro/dev-client, and app-surface steps. An already-installedcom.callstack.agentdevicelabis not sufficient — the README's Metro/dev-build andsnapshot -ichecks must prove the expected app surface is running. - For Android RN/Expo/dev-client apps that use local Metro, configure
adb reverse tcp:<port> tcp:<port>for the app's Metro port before opening the app or URL.
- Source-checkout daemon state is worktree-scoped, but devices are not. Use
pnpm daemon:state-dirto inspect it and different devices for concurrent worktrees. - The first Node process after a newly signed Apple runner launches may block during Gatekeeper
verification. Warm it with a throwaway
node -e 0before measuring. DEVICE_IN_USEhas two flavors. "already in use by session X" is this daemon — follow itsclose --sessionhint. "owned by session X in workspace Y" is another worktree's device claim — non-retriable; run the error'sdevice status/device release --stalerecovery, never PID hunting. One claim settles itself: if that device rebooted after the lastopenits owner made, its app, runner, and accessibility session were destroyed, soopenreconciles the owner's resources, takes the claim, and says so in its warnings. A reboot you caused yourself during verification looks exactly like that to the nextopen— until the owner reopens, which stamps the boot it is now running on and makes the claim live again.
The OS-neutral Apple runner lives under packages/platform-apple/src/runner/. For connection errors,
start at runner-startup-transport.ts; for retry policy, at runner-error-classification.ts; for
command typing, at runner-contract.ts. Transport stays below session/client
behavior, and xctestrun build/cache logic stays outside request execution.
- Close manually opened sessions, including failed verification attempts, using their original
--session,--platform,--udid, and--state-dirvalues. - Use a purpose-specific session name for experiments, and an isolated
--state-dirunder/private/tmpwhen you need cleanup isolation beyond the current worktree's default daemon. - If
closeis blocked or ownership looks stuck, inspect it withagent-device device status --stale(daemonless), stop the owning daemon withagent-device daemon stop --state-dir <dir>(add--cleanto remove retained runners), and release provably dead owners withagent-device device release --stale. Do not hunt PIDs withps/kill. - If cleanup cannot be completed, report the remaining session name, state dir, and the
device status --staleoutput as a blocker.
Read ADR 0025 before changing capture behavior on a multi-panel device. The iOS 27.1 runtime ships
only with the Xcode that carries it, and xcode-select may point at an older one, so pin the
toolchain per command:
DEVELOPER_DIR=<Xcode-27.1>/Contents/Developer xcrun devicectl device info displays --device <udid>.
- Panels: that command lists each integrated panel with
backlightState. Only the lit panel is capturable — a capture of the dark panel exits 0 and writes an all-black PNG. - Input routing: verify a fresh control after each pose change. A successful synthesis acknowledgement
does not prove a hit. Inspect the runner and simulator
testmanagerd/BackBoard logs for display identity and delivery; the resolved app window owns gesture coordinates and its target screen. - Pose:
agent-device fold closed|half-open|opensends private HID inside the simulator and reads the hinge back throughdevicectl device motion hinge-angle. Device Hub and host Accessibility permission are not required. Re-snapshot afterwards: refs and coordinates do not survive folds. Verify locally with Device Hub stopped and the simulator booted throughsimctl boot; test all three poses, active panel capture, and an app interaction. Duo coverage remains local until GHA supports the runtime. To inspect the angle independently:xcrun devicectl device motion hinge-angle --device <udid> --session-timeout 1 --timeout 5. - Touch overlays export at the captured track size again (#2707). The burn-in used to re-encode
through a fixed 480px preset, so a recording with touches collapsed to
220x480(landscape480x220) on any panel — not just therot90inner one — and went all-black on long clips, always with exit 0. That preset is gone: both quality tiers export through the one geometry-preserving preset, and the compositor now checks its own output (size and non-black) against the raw before publishing it — on failure it drops the overlay, keeps the raw capture, and reports it asoverlayWarningonrecord stoprather than returning a broken file. Only reach forrecord start --hide-toucheswhen you want the fastest raw capture, not to dodge the defect. See ADR 0025 and #2707. - An app built with the iOS 27 SDK must adopt the UIScene lifecycle to launch on an iOS 27.0 or
27.1 simulator at all: a legacy
UIApplicationDelegateapp traps at launch inside___UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption, which reads like a broken device but is not one. - The 27.1 runtime in this beta accepts only the
iPhone Duodevice type, so a second non-foldable 27.1 simulator cannot be created as a control.
The daemon binds localhost. If the sandbox rejects the listener with listen EPERM, rerun with
host access when permitted. Generic Failed to start daemon or cleanup errors alone do not prove a
sandbox cause; inspect the underlying failure. Run other checks in the sandbox unless their tools
require host access.