* fix(android): retire recording evidence stranded by a re-adopted device id Android `record start` refused forever with "native recovery evidence already exists" once an emulator was re-adopted under a new serial: the device-side marker names the device identity that wrote it, reconciliation retired evidence only when that identity matched, and the leftover classified as neither recoverable nor retireable — an `UNKNOWN` internal error whose hint asked for a bug report, while `record stop` owned nothing to clear. Start reconciliation now retires evidence whose recording is terminal or whose device identity the transport can no longer address, and only after every artifact it names is proven released: a committed recorder through process inspect, an uncommitted pending artifact against the recorders running on the device. A recorder still writing is never deleted; the refusal names the writer in `details.writer`. Remaining refusals — unreadable evidence, the other transport mode, and an open recording this identity owns — are typed errors carrying the marker path and the command that clears it. Closes #2550 * fix(android): keep an unreadable recorder off the delete path The writer probe filtered candidate processes down to the ones it could prove were recorders, so a caller that read an empty list as "nothing writes this path" removed an artifact from under a recorder whose /proc entries could not be read, along with the marker that named it. A process table it could not read at all threw a bare Error, which reached callers as an unclassified failure. The transport answers a writer search with `clear`, `found`, or `uncertain`. Only `clear` proves an artifact is free: start retirement refuses with `native_recording_recorder_unproven` and owned cleanup keeps the evidence pending, so both wait for a conclusive answer instead of deleting on doubt. * fix(android): keep a mixed writer scan inconclusive The writer search answered `found` as soon as it identified one recorder, which hid the candidates it could not read. Start reconciliation already refuses a found writer, but owned cleanup stops only the recorders it was handed and then removes the artifact and marker — deleting under an unreadable recorder still writing that same path. A search now reports the writers it identified together with whether every candidate was read. Cleanup requires both halves: an identified recorder does not prove the others are gone, so an inconclusive scan retains the evidence before anything is signalled or removed. * test(android): record retirement side effects through one evidence rig 21 reconciliation scenarios each rebuilt the same marker reader and the same recording transport stubs. One rig holds the marker and the ordered side-effect log, so a test names only the probe outcome it is about. * test(android): keep legacy reconciliation scenarios as they were The scenarios that already covered retirement were rewritten into a shared rig, which spent most of this PR's churn budget on moving lines around. They read from the device marker again as before; only the recorder-state table names the outcome each state now produces, and the stale row that expected evidence naming another device identity to be kept is gone, since retiring it is this fix. New scenarios use the rig. * test(android): table-drive the inconclusive writer scans Each inconclusive writer-scan scenario rebuilt the same scoped adb fake and the same cleanup transport. The transport cases now differ only in which candidate reads fail, and the owned-cleanup cases differ only in the scan they return, so both run from one table against the same assertions.
51 KiB
Changelog
Unreleased
-
Fixed: an iOS capture that no backend could read now says which backend was asked and what was on screen, instead of failing on the internal
regular iOS snapshot presentation requires a valid viewportinvariant alone (#2560). The runner declares such a payload sparse — backend, reason code, and its own reason — and the daemon read that verdict, then discarded it while reconstructing a viewport from a synthetic root that has none. Asnapshot --actionscapture of a web sign-in sheet therefore reported a viewport problem and named neither the private-AX backend that cannot read an out-of-process surface nor the surface presented over the app. The verdict now travels with the refusal aserror.details.snapshotQuality, and the hint composes the shared sparse-capture advice with the bundle id of the surface host when one is presented. Onreplayandtestthe verdict now survives into theREPLAY_DIVERGENCEdetails, which carried only four cause keys before. Captures that used to succeed are untouched: a sparse payload whose tree presentation can still serve presents exactly as before, and a payload the runner did not declare sparse keeps the plain invariant byte for byte. -
Changed: a capture that a backend cut at one of its limits now says so in the snapshot's warnings, on every platform, instead of only setting
truncated: truein JSON. The text path had no disclosure at all, so an agent read a screen missing its footer, tab bar, or the items after a long list as complete — the backends walk the tree in document order, so what falls off is what comes last, on screen or not. One shared warning renders from the shared flag; the limit and dimension stay backend-side. The depth-cap warning no longer suggests--scopeas a way to read deeper: on iOS, scope narrows the presented view and acquisition stays scope-blind. -
Changed: the iOS Simulator AX bridge caps a capture at 5000 nodes, up from 1500, the Android helper's bound. Measured on a synthetic 600-row screen, acquisition time did not move with the cap (the native read fetches the whole tree; the cap only stops conversion) while the 1500 cut dropped the screen's on-screen footer.
-
Fixed: Android snapshots carry the accessibility
selectedstate an app sets on a control, sois selected, aselected=trueselector, and a Maestroselected:qualifier work on Android (#2462). The helper never serialized the attribute, and the host reads only the helper's XML, so no later layer could recover it:get attrshad noselectedfield, no snapshot node was marked selected, and the same assertion that passed on iOS failed on Android with "Maestro visible condition did not match" for an element that is visible — whileselected: falsematched every Android node. The helper now emits both answers, likeenabledandpassword, so an unselected control answersfalseand a helper older than the attribute answers nothing; the host parser, the Android hierarchy node, and the published snapshot node carry it through toget attrsand the[selected]marker in snapshot text. Snapshot lines now render that marker whenever selection is rendered, not only when text surfaces are summarized:--settleanddiffalready compared selection, and a line that compares a fact it cannot display turns a tab tap into a changed pair whose two lines look identical. -
Fixed: Replay test artifacts with colliding filenames retain distinct copies without overwriting other diagnostics, replay sources, timing traces, or attempt manifests.
-
Fixed: Custom test reporters reject invalid exit codes, including values such as
256that could wrap to success and hide a failing suite.getExitCodeaccepts integers from0to255orundefined; JSON output reports an invalid code as oneINVALID_ARGSerror. -
Fixed: Android
record startno longer refuses to begin after a reused emulator reassigned the previous recorder's pid. A completed recording's native marker is retired only once its recorder is proven gone, but only an absent pid counted as proof — a pid that now names an unrelated process, or a recorder that exited and waits to be reaped, did not.record startthen failed every later attempt withAndroid screenrecord completed evidence cannot be safely retired, andrecord stopcould not return an already-finalized recording. Proven termination now retires the marker and returns the stored completion; a live or unreadable recorder still blocks, and the unrelated process is never signalled. A reused pid that runs a replacementscreenrecordon the same remote path proves the old recorder gone but not that the path is free, so that marker and artifact are retained until the replacement ends, and neither is signalled (#2476). -
Fixed: Android
record startno longer refuses forever on an emulator re-adopted under a new serial. The device-side marker records the device identity that wrote it and a later start retired it only when that identity matched, so a marker left by an earlier session on the same AVD blocked every recording on the re-adopted device withAndroid screenrecord native recovery evidence already exists— reported as an internal error whose hint asked for a bug report — whilerecord stopanswered that no recording was active and no session could reach the marker, because recovery always binds the device identity its own record names. A marker now retires when its recording is terminal or when it names a device identity this transport can no longer address, and only once every recorder it names has provably released its artifact; an interrupted launch commits no recorder identity, so its artifact is checked against the recorders running on the device first. A recorder that is still writing is left alone:record startrefuses withDEVICE_IN_USEanddetails.writernaming whether the marker's own recorder or another one holds the path, and only the unmanaged one ends on its own at Android's 180 second limit. A device that cannot answer that question — an unreadable process table, or a candidate process whose identity cannot be read — is refused rather than assumed free, even when other recorders writing that path were identified, so an artifact is never removed under a recorder the probe failed to see. What still refuses — unreadable or undecodable evidence, a marker the other transport mode wrote, and an open recording this device identity still owns — is now a typed error naming the marker path and the command that clears it,record stop --session <name>or removing the marker once no session owns it, instead ofUNKNOWN(#2550). -
Fixed: a polling
waitno longer surrenders its whole budget the first time the iOS runner answersRUNNER_BUSY. That code means an earlier command exceeded the runner's execution watchdog and its abandoned main-thread work is still draining, which clears on its own, so await text ... 20000could fail in under a second with a failure the runner itself asked the caller to retry. A poll refused with a failure its producer marked retriable is now ridden out like an unreadable capture: the wait keeps polling to its deadline, records the poll asretriablein its timeout evidence, and surfaces the refusal only if no readable capture ever completed. That surfaced refusal keeps the producer's code, message and retry details and now also carries the wait's owncaptures,readableCaptures,waitedMsandpolls, so a budget spent entirely on refusals is distinguishable from one immediate refusal. A wedged runner (RUNNER_WEDGED) is not retriable and still ends the wait at once. -
Fixed: a runner failure recovered from the lifecycle journal after its transport response was lost is now classified exactly like the same failure on a live response.
RUNNER_BUSYreached callers as a bareRUNNER_BUSYwire code without theretriableflag on that path, while the live path published it asCOMMAND_FAILEDwithdetails.runnerErrorCodeandretriable: true; both paths now read the runner's code through one classifier. -
Fixed: iOS Simulator snapshots of Safari and of apps with a
WKWebViewstopped showing the page in 0.21.0 — chrome plus empty[webview]nodes, no links, text, or form fields, so no ref could reach the page (#2484). The host AX bridge that 0.21.0 made the Simulator's snapshot source reads one process, and WebKit content lives in another; the bridge delivered the boundary as anAXRemoteElementleaf and the tree was published as if that were the screen. The source now refuses a tree whose on-screen web view ends at that leaf (remote-content-boundary) and the route serves XCTest, which resolves remote elements, for the rest of that app generation — the same path 0.20.x used. The snapshot discloses the switch through its warning, and a relaunch re-enables the bridge. -
Fixed:
testexpands relative globs from the caller's literal working directory, so directory names containing glob characters no longer cause missing suites or select a different directory. Missing non-glob inputs also retain their not-found error in these directories. -
Fixed: JUnit reports remain readable when replay results contain characters forbidden by XML 1.0, replacing them with U+FFFD while preserving legal Unicode and whitespace. Original suite values remain available in JSON and other reporters.
-
Fixed:
replay exportpreserves deep links without//, includingtel:andmailto:, as MaestroopenLinkcommands in both standalone and app-plus-linkopenactions. -
Changed: a command whose synopsis is generated names each option with the label its declaration carries, so
snapshotnow shows--depth, -d <depth>and--scope, -s <scope>where it used to show the short aliases, and--recordis documented underCommand flags:instead of inside thesnapshotandissynopsis lines.snapshot,proxy,daemon,device,doctor,prepareandtv-remoteno longer restate their option list in a hand-written usage string, so adding an option to those commands updates--helpon its own (#2444). -
Fixed: iOS
--depthonsnapshot,is,wait,get, andfindno longer fails withregular iOS snapshot presentation requires a valid viewportwhen the runner plan is pinned or deferred to the private AX backend (custom actions, a private AX verdict on the session, or the XCTest channel penalty). The runner refused a regular depth-capped request on every backend but the recursive tree, fell through to its synthetic sparse root, and the daemon rejected that root as a missing viewport. Presentation applies the presented-depth cut to whatever hierarchy a backend acquired, so every backend serves the request; the private AX declaration is nowregular-depth=presentation-cutand an acquisition that stopped short of the cut keeps disclosing that throughtruncated/effectiveDepthas it does unscoped. -
Fixed: repeated unfiltered Android snapshots stay compact when identical element bounds arrive with a different property order. Changes to the bounds still re-emit the tree.
-
Fixed: iOS
network dumpno longer omits requests that reused a keep-alive connection. CFNetwork logs a request URL only on the line that opens a connection, so a second request to the same host produced nourl:line and was dropped from the dump entirely — an "this endpoint was called" check read as a definite fail. Such a request is now reported against the origin its connection was opened for, withpathUnavailableset, its status, and its timing. A reused request whose connection was opened before the scanned window cannot be named at all; those are counted in the dump'sunnamedRequests, so an empty result still reports that traffic was observed. The identities behind that count reconcile the app-log and recovery windows internally — so overlapping traffic is not double-counted and disjoint traffic is not under-reported — but the response carries only the count, which stays bounded however large the scan window was. The notes say absence of an endpoint does not prove it was not called. -
Fixed: a URL logged as a delimited
url: <value>,field no longer keeps the separator the log format put after it, so an entry'surlcompares equal to the endpoint under test. A bare URL elsewhere is left alone, since nothing there establishes that trailing punctuation is not part of the path. -
Added:
replay exportsupports flows that switch apps and return, preserving eachopen <appId>target as an explicit MaestrolaunchApp.appId. -
Added:
replay exportconverts recordedhomeactions to MaestropressKey: Home, allowing app-to-home-to-app journeys to be exported. -
Added: polling
waittimeouts (wait <selector>,wait text,wait @ref, andwait absentafter a readable capture) carry a per-poll timeline inerror.details(captures,polls[]withstartedMs,durationMs, and a typedoutcome: readable, unreadable, deadline, runner-restart) next to the unchangedreason, so a failure says where its budget went without opening the request log. Long waits keep the first five and last twenty-five polls. The replay landmark-mismatch refusal carries the same poll evidence next to its mismatch details;wait --stabletimeouts and a never-readable strict absence keep their existing diagnostics. -
Fixed: Android
orientationnow returns once the display reports the requested rotation (pollingdumpsys display, up to 15s) instead of right after writing the settings. On a loaded emulator the rotation takes seconds, during which accessibility reads hang, so the next command paid for the transition; awaitissued right afterorientationcould spend its whole budget there. A display that never reaches the requested rotation now fails the command with the observed rotation instead of reporting success; a display that reports no rotation is left to the setting as before. -
Fixed: the iOS Simulator AX snapshot route bounds how long a capture waits for app discovery and stops starting a discovery per capture. Discovery (
simctl launchctl listthrough xcrun) takes seconds on a loaded host; a capture now waits at most 1.5s for the one in-flight discovery, takes the XCTest fallback, and the discovery keeps running under its own 15s deadline for the captures that follow. Previously each capture ran its own probe with a 3s timeout on its critical path, so awaitissued right afteropencould spend its budget on probe timeouts and reportwait_capture_stalledwith the app already on screen. -
Fixed: iOS snapshots no longer report
truncated: truemerely because a later backend produced them. The runner stamped every recovered capture as truncated — including a complete private-AX tree taken while the XCTest channel was penalized as slow — so a strictis absent/wait absentrefused it with "capture was truncated" on loaded CI hosts.truncatednow tracks completeness only: payload truncation, a depth-limited capture, or a sparse terminal payload. -
Fixed: Android
alert accept/alert dismissreturn only once the dialog has left the accessibility tree (a different alert taking its place counts as dismissed), matching the iOS runner's re-check. Previously they returned right after the button press, so the next read could still see only the dialog window. A dialog that stays visible past the action budget now fails withalert <action> did not dismiss the visible alert. -
Added strict
wait absent <selector> [timeoutMs]polling for zero selector matches. Incomplete, sparse, truncated, scoped, depth-limited, and Android unreadable captures cannot prove absence; deadline diagnostics retain typed capture evidence and stable first-match details (#2236). -
Added: the device-claim store can hold an allocator-held claim (schema v3) for a device an allocator-managed pool owns. It has no owning process, so
device statuslists it in the normal view (never as stale),device release --stalerefuses it withallocator-held-owner, the daemon-startup sweep and session close leave it alone, and a command that binds the device ordinarily is refusedDEVICE_IN_USE/DEVICE_CLAIM_ALLOCATOR_HELD. Process-owned claim files are unchanged at schema v2. Two notes for mixed installations: a daemon older than this release reads a v3 file as an unreadable claim record and fails closed rather than clearing it, anddevicesreports noclaimedByfor such a device until the managed-inventory filter lands. -
Added the
harmonyos-instancelease contract and CLI/runtime plumbing as a prerequisite for HarmonyOS proxy support; provider/daemon allocation remains gated until its end-to-end lifecycle is implemented and validated (#2266). -
Fixed:
settings airplane on|offnow takes an Android device offline. It is applied through the connectivity service (cmd connectivity airplane-mode), which drives the radios, instead of writingairplane_mode_onand broadcastingACTION_AIRPLANE_MODE_CHANGED— a broadcast Android refuses for non-system callers, so the old path failed after writing the setting and left the device reporting airplane mode with the network still up (#2223). The response now reports theairplaneModethe connectivity service holds after the change, and an Android build that does not expose that command is refused withUNSUPPORTED_OPERATIONbefore anything is written. -
Fixed: the MCP registry entry (
server.json) now declares the fixedmcpsubcommand viapackageArguments, so a registry-format launcher — the MCP Registry or the website's/.well-known/mcp.jsondiscovery manifest — starts the stdio MCP server. Previously it ranagent-devicewith no subcommand, i.e. the bare CLI (#2275). -
Breaking (0.21): iOS Appium/WebDriver snapshots now expose engine-owned acquisition facts and typed fidelity warnings. The SDK snapshot
truncatedfield is optional when Appium cannot report hierarchy completeness; regular snapshots fail closed without valid viewport evidence, whilesnapshot --rawremains available for diagnostics (#2195). -
Security (daemon, remote/proxy HTTP only): when
AGENT_DEVICE_HTTP_AUTH_HOOKis configured and a request's hook result does not attest atenantId, the request is now refused (401) outright — the daemon no longer runs it as whichever tenant the client declared (RPC bodymeta.tenantIdorflags.tenant, or thex-agent-device-tenantheader on the upload/artifact-download/diagnostics routes) and no longer admits it unscoped when the client declares nothing either. This closes both a shared-token impersonation path and an unscoped-access path to tenant-owned sessions/artifacts in multi-tenant deployments. Deployments with no hook configured (the local loopback CLI) are unaffected. A hook must attesttenantIdon every request it wants the daemon to admit. -
Breaking (0.21): removed aggregate performance compatibility (
perf,perf sample,perf metrics, themetricsalias, optionlessclient.observability.perf(), and SDKarea: 'metrics'). Useperf frames,perf memory sample,perf cpu profile start|stop|report, orperf trace start|stop; removed CLI and raw daemon forms fail with this migration guidance. -
Breaking (0.21): removed legacy batch JSON steps with
positionals/flags. Use{"command":"...","input":{...}}; rejected steps now include a concrete structured example. -
Breaking (0.21): removed the deprecated Node client
command.rotatewrapper and itsRotateCommandOptions/RotateCommandResultexports. Usecommand.orientation; the already-removed CLIrotateform keeps its targeted migration error. -
Security (MCP/AI-SDK tool surface): the operator-owned endpoint and path inputs —
daemonBaseUrl, the MetroproxyBaseUrl,stateDir,cwd,iosSimulatorDeviceSet,iosXctestrunFile,iosXctestDerivedDataPath,iosXctestEnvDir— follow the credential inputs off the model-writable tool surface: no longer advertised, refused as explicit input with guidance, and resolved from env/config only (a model-writabledaemonBaseUrl/proxyBaseUrlwould redirect the env-resolved token to an arbitrary server). Dropping these plus the credential fields shrinkstools/listby roughly half. CLI flags and the SDK client options are unchanged. -
MCP tool descriptions now declare their enforced client timeout envelope (90s default, 180s install, 300s+ lease allocation, unbounded only for the streaming
testrunner), sourced from the descriptor registry's timeout policy so the declared number cannot drift from the enforced one. -
Security (MCP/AI-SDK tool surface): the shared command-tool executor now enforces the advertised tool schema as an admission boundary — every raw
tools/callargument must appear in the tool's advertised (additionalProperties: false) schema, or it is refused before config/env resolution. Hiding a key fromtools/listalone was insufficient: the router forwards raw arguments verbatim and the MCP config resolver readconfig/remoteConfigas CLI flags, so a model-supplied config file could loaddaemonBaseUrl/daemonAuthTokenand redirect the operator's token to an arbitrary endpoint. Deny-by-default closes that, the operator keys, and any unknown key at once; operator env/config defaults still resolve (they never arrive as tool input). Retired keys are still admitted so their migration guidance answers. -
Security (MCP/AI-SDK tool surface):
daemonAuthTokenand the MetrobearerTokenare no longer advertised as tool input properties, and an explicit value is refused with guidance instead of being forwarded. Credentials are operator-owned: setAGENT_DEVICE_DAEMON_AUTH_TOKEN(ordaemonAuthTokenin~/.agent-device/config.json) andAGENT_DEVICE_METRO_BEARER_TOKENon the process serving the tools. The model both reads untrusted app UI text and picks tool arguments, so a model-writable credential parameter was a prompt-injection exfiltration path. CLI flags (--daemon-auth-token,--bearer-token) and env/config resolution are unchanged. -
Release hygiene: after
npm publish,release:mark-devmovesmainto the next patch with a-devprerelease marker so the version onmainnever equals a published version (registry scanners diff the tool surface per version string, and a moving surface under a released number reads as a republish).release:preparerefuses to publish while the-devmarker is in place. -
Windows
--platform webworks again.agent-device web setupno longer fails withnpm not found in PATH, and every web command — includingweb doctor— no longer fails withspawn EINVAL. The managedagent-browserbackend is now launched asnode <js-entry>on every platform instead of through itsnode_modules/.binconsole shim, which is a.cmdfile on Windows thatchild_process.spawnrefuses without a shell (CVE-2024-27980 hardening);shell: truewould only trade that for argument-quoting hazards and aDEP0190warning on every command. Setup spawnsnpmfrom PATH unchanged on macOS and Linux, and runs npm's ownnpm-cli.jsunder the current Node only on Windows, where a barenpmis not spawnable. A managed install now counts as present only when the backend package itself is, andweb setup --json/web doctor --jsongainentryScriptandpackageDir; the publishedbinaryPathis unchanged and still names npm's console shim, now informational rather than the spawned command (#2022). -
Parameterized
fill --record-asprotection is now recording-session-scoped instead of fill-step-scoped (ADR 0017 amendment): a later, unrelated recorded action (wait,is,get) can no longer re-serialize an app-rendered echo of an already-parameterized value into its own result ortarget-v1identity evidence. An echoingwaitlandmark no longer qualifies as an ADR 0016 destination guard, sosession save-scriptrefuses it and directs the author to a stable landmark instead of silently publishing the secret. The protection uses one small, explicit, ephemeral, never-serialized per-session map populated only from values the author already opted to parameterize; ordinary non-parameterized recordings are unaffected (#1398). -
Android covered-state publication now has one owner: same-window surfaces remain visible for diagnosis, while the daemon marks exactly ordered covered controls non-actionable with
interactionBlocked: "covered". Helper-onlydrawing-orderstays private rather than entering the snapshot contract, sparse floating overlays remain usable, and API 23 fails conservative with the existingandroidSnapshot.occlusionScanUnavailabledisclosure (#1832). -
iOS regular
snapshot --depthnow measures depth after structural accessibility wrappers collapse. The recursive-tree backend follows a bounded presented-depth frontier, so controls that fit the requested regular depth are no longer lost behind raw wrappers; raw--depthremains a traversal-depth limit. Flat query recovery is limited to one presented level, and private AX does not claim deeper regular-depth completeness until it has a hierarchy-aware frontier (#1797). -
iOS regular snapshot nodes now publish presentation-owned effective geometry through the existing
rectfield: backend-reported frames remain available to acquisition, while regular output uses the viewport and declared scroll-clip intersection. Raw snapshots and direct element reads retain reported geometry (#1797). -
Android recording-session commands now warn when blocking-dialog readiness inspection fails open. The requested command still runs, but the successful response discloses that readiness could not be inspected and preserves the inspection error's actionable hint, including
pnpm build:androidwhen the snapshot helper is unavailable (#1895). -
Breaking (device selection): when a command needs one concrete device, carries no
--device/--udid/--serial, and more than one candidate is equally preferred, agent-device now refuses withAMBIGUOUS_MATCHand the candidate list instead of quietly picking one. Established preferences are unchanged — virtual over physical, booted over offline, and the Apple kind/target ranking — so a single booted emulator beside offline ones still resolves, an existing session binding still resolves (its identity is already fixed), and explicit selectors still resolve. What no longer happens is choosing between two equally booted devices by discovery order or alphabetically: that produced a successful response describing a device the caller never selected, and reads are no safer than writes there.devicesand other genuinely multi-device commands never enter singular resolution and are unaffected. The error carries the bounded candidate list in the declareddevicesdetails domain, so CLI and MCP print it, with a hint naming the right selector for the platform (--serialfor Android/HarmonyOS,--udidfor Apple, or--device "<name>"). -
--udidwith--platform android(and--serialwith an Apple platform) now fails as the flag mistake it is —INVALID_ARGSnaming the right flag — instead of reaching device resolution and answeringNo Apple device with UDID emulator-5580for an explicitly Android request.--udidaddresses Apple devices,--serialaddresses Android and HarmonyOS; matching pairs and requests that name no platform are unchanged. -
Breaking (
--session-lock strip): a device selector that names a different device than the bound session is no longer silently discarded.stripexists to drop redundant platform/scope selectors; when it also dropped--udid/--serial/--device, the command kept running against the bound device instead of the one the caller named — a wrong-device action that looks like a success. Such a request now fails withINVALID_ARGSunder bothrejectandstrip, and the error carries the two identities structurally (requestedDevice,boundDevice) plus a hint offering the two real recoveries: close the bound session if the requested device is intended, or remove the selector if the bound device is. The hint no longer suggests--session-lock stripfor an identity conflict, since following that advice is what produced the wrong-device run. Scope-only stripping (--platform,--target,--ios-simulator-device-set,--android-device-allowlist) is unchanged. -
iOS regular snapshots now apply one backend-neutral eligibility rule after every capture backend: a node survives when its accessibility type is interactive or it carries a non-empty label, identifier, or value. This removes the tree backend's extra "hittable non-Other" membership path and drops unlabeled decorative nodes consistently; labeled images, identifier-only nodes, and value-only nodes still survive. Raw snapshot membership is unchanged.
-
iOS regular snapshots now run one shared clip fold inside presentation for every capture backend (#1797). Backends serialize reported facts -- every traversed node, at raw traversal depth -- and presentation alone decides what the viewport and scroll clips hide, books the scroll hints, and collapses depth; no backend carries its own copy of that interpretation anymore (the copies are what produced the scroll-overflow leak class, #1784). Three intentional edge deltas ride along, all in the direction of one backend-neutral rule: sub-pixel content-free decorations are now dropped by every backend (previously private-AX only); labeled offscreen Application/Window carriers now survive on every backend (previously tree only), still never hittable; and a query-sweep recovery snapshot without
-ino longer lists offscreen elements. Nothing outside its clip, and nothing without geometry, is everhittablein a regular snapshot, whatever the backend reported. -
iOS
snapshot --rawis now the acquired accessibility tree on every backend that can serve it (#1797). A raw request that recovered onto the private-AX backend — the route an app whose XCTest tree capture fails takes — returned the regular projection's viewport-pruned nodes labeled raw: everything scrolled out of the viewport, and every sub-pixel decoration, was missing from the one view whose purpose is showing what the pruned view hid. Raw now keeps every node the backend serialized, at traversal depth, and--depthstill narrows it (for raw, presented depth is traversal depth). Two structural rules replace the hand-synchronized ones: the raw capture plan is derived from each backend's declared ability to serve raw, so the interactive query sweep — which has no hierarchy to return — cannot be planned for a raw request; and presentation refuses an acquisition captured for the other projection instead of relabeling it, dropping that tier with a structured failure. Breaking in the same direction:snapshot --raw -inow returns the acquired tree instead of an interactive-filtered one —-inarrows the regular projection, and the pair used to produce a third membership rule that differed per backend. Regular and-ioutput is unchanged. Backends now read one derived capture hint rather than the request itself, so what a capture is allowed to skip is stated once, next to the proof that skipping it keeps the projection complete. -
Android
snapshot --rawis now the acquired accessibility tree (#1832 C3): the regular-projection classifiers for nodes Android marks invisible and stale application windows no longer run at parse time, so--rawkeeps everything the helper serialized (normalization only). Covered same-window surfaces are publication annotations rather than membership pruning. Also: Android blocking-dialog recovery now reads the same daemon presentation an agent'ssnapshotsees instead of a hand-rolled subset, and acts on its occlusion result — a stale "App isn't responding" surface left under the foreground one no longer triggers recovery, and a covered "Close app" is never tapped ahead of the visible one; the Android freshness route signature no longer keys onrole, a field the Android backend never carries; and the Android helper's declared fidelity residues (nochecked/checkable/long-clickable, 5000-node cap before scoping, API-level cache-reset divergence) are recorded inCONTEXT.md. -
agent-device mcpnow carries its own usage guidance, so MCP-only clients (Codex CLI, Cursor, custom agents) no longer depend on a separately installed skill (#1833). The handshakeinstructions— returned by bothserver/discoverand, newly, the legacyinitialize— is a compact (< 2 KB, the Claude Code truncation limit) workflow card: start withopen {app, foreground: true}instead of probing, act withsettle: trueand continue from the diff, verify withwait/is/get/find, copy@refsbyte-for-byte, recover from sparse/AX-unavailable, follow error hints,close. A new MCP-onlyhelptool serves the full guides on demand: notopicreturns the CLI's decision card;topicreturnsagent-device help <topic|command>verbatim (workflow, gestures, scripting, tv, macos, web, remote, debugging, …, or any tool name for its complete flag reference), prefixed with the one-line CLI→tool-property mapping.helpis router-owned rather than a command descriptor, so it appears intools/listonly — not in the CLI, Node client, orbatch— and its description tells the model it is not a startup step. Legacyinitializegains the optionalinstructionsfield; no other legacy field changes. -
Android
snapshot --scope(and every selector command's--scope, e.g.press "Save" --scope Panel) now resolves scope exactly once, inside the Android projection, under the shared scope specification: the scope root is the first node in document order whose label, value, or identifier contains the scope text (case-insensitive) and whose subtree still has content in the projection you asked for, the result is that subtree re-rooted at depth 0, and no match returns an empty snapshot (#1832). That second clause is what makessnapshot -i --scope panelreturn the button inside a structural container-idrops, and stops a decorative heading that happens to match from emptying the snapshot. Before, Android ran two passes with contradictory rules — a breadth-first platform match that fell back to the full tree on a miss, then the daemon's document-order pass — so a shallower later container could win over an earlier match, and an interaction capture whose scope reached only the daemon layer was silently unscoped.--depthunder--scopecounts from the scope root, filtering the depths the response prints (a node shown at depth 0 is never hidden by--depth 0), and ancestor context above the scope root (a clickable row, a list) still shapes-imembership inside it. The rule is pinned bycontracts/fixtures/snapshot-scope-policy.json, the same golden table the iOS runner consumes (#1797). -
New
hover <x y|@ref|selector>command for--platform web(#1783). It moves the pointer over the target without pressing, so hover-gated UI — a message row's...toolbar, a menu that opens on pointer enter — becomes reachable through agent-device the way it already was through the underlyingagent-browserbackend (mouse move). It is a member of the targeted-touch family: same@ref/selector/coordinate targeting, occlusion and off-screen guards, and--settle(the settled diff carries the revealed controls with fresh refs, e.g.+ @e4 [button] "Delete"), but no--verify, since hover reveals rather than activates.hover @refpublishes as a portable selector line in recorded scripts, and the Node client exposesinteractions.hover. Hover is a pointer state that touch platforms do not have, socapabilitiesadvertises it on web only and iOS/Android/Linux reject it during admission withUNSUPPORTED_OPERATIONand a hint naming--platform web;longpressremains the mobile hold-gesture verb. -
Internal: session recording is now derived from the script-publication lifecycle instead of being stored beside it.
SessionState.recordSessionis removed;isRecordingPublicationanswers the question from the aggregate (ordinary authoring records only while ARMED, a repair transaction records for its whole lifetime). The stored flag was a second source of truth that handler surfaces set directly, which is how #1533's aborted-recording drift arose — no surface can now arm recording without moving the lifecycle that authorizes it, and the script writer's publication gate is answered entirely by the aggregate. Behavior-preserving: the derivation reproduces what the flag held at every transition. -
Fixed: a script recording aborted by a second
openis no longer published by a later bareclose(#1533).open <app> --save-scriptfollowed by a second successfulopenterminates the recording and warns "Script publication was aborted…", andclose --save-scriptcorrectly refuses it with "Retry with plain close; it will tear down the session without writing." But when that secondopenitself carried--save-script, the flag re-armed recording behind the terminal status, and a bareclosethen wrote the full session log to disk — publishing a recording the caller had been told was aborted, and breaking the promise the refusal makes. An aborted authoring lifecycle is now terminal by construction:--save-scriptarms nothing on any surface that handles it — the re-open builder, the close finalizer, and the recorded-action ingress — and the script writer refuses the lifecycle from every path that reaches it (bareclose, teardown, idle-reap, active publication). This also stops an aborted session from paying recording-time costs it can never publish: a re-opened aborted recording no longer keeps the direct iOS selector fast paths forclickandgetdisabled. Armed recordings, published recordings, and every repair transaction are unaffected. -
agent-device mcpnow serves the stateless MCP2026-07-28revision alongside the handshake-based revisions it already spoke, as the spec's "dual-era server". Modern clients probeserver/discover, which advertises the supported revisions, the tools capability, and server identity; their requests declare a protocol version in_meta, and their results carryresultType: "complete"plus_meta["io.modelcontextprotocol/serverInfo"].tools/listandserver/discovernow return thettlMs/cacheScopecache hints, so a client can cache the 55-tool, ~223KB tool list for an hour instead of re-fetching it on every start; the list was already emitted in a deterministic (sorted) order, which is the other half of what makes it cacheable. Each revision is answered on its own wire contract: a request declaring2025-11-25or2025-06-18through modern framing still gets the legacy result shape, andinitializenever agrees to2026-07-28, which has no handshake to establish. A declared revision this server does not implement is rejected withUnsupportedProtocolVersionError(-32022) naming the ones it does, rather than being served under a version the client did not ask for, and modern framing that omits its requiredprotocolVersion/clientCapabilitiesmetadata — or supplies aclientInfothat is not a validImplementation— is rejected as invalid params.initializeandpingwere removed in2026-07-28, so a modern-framed call to either is answered-32601rather than served inside aresultType: "complete"envelope. Responses to legacy clients are unchanged byte-for-byte —initializeandpingare still served, and no cache,resultType, or_metafield is added to their results. Nothing here affects the CLI, Node, or daemon surfaces: the stdio transport, the tool set, and every tool's input/output schema are untouched. -
Fixed: the MCP
initializehandshake now answers with the protocol revision the client requested when it is one this server implements, instead of always answering2025-11-25. A client pinned to2025-06-18was told to speak a revision it had not asked for, which the lifecycle contract answers by disconnecting. -
agent-device help workflowis now a compact ~8KB card instead of a ~41KB dump; the same depth still exists, split intohelp scripting(save-script, secret-safe fills, batch JSON, replay divergence/repair, recording) andhelp gestures(multi-touch shapes and platform quirks), plus a few paragraphs folded into the topics that already owned the subject (help debugging,help physical-device,help validate). Everyhelp <topic>first line is nowagent-device <version> — <topic>so an agent can read the installed version from its mandatory first help read instead of a separateagent-device --versioncall. -
Cloud iOS (BrowserStack, AWS Device Farm):
snapshotanddiffno longer fail withSESSION_NOT_FOUNDon a live provider session (#1658). The app-session guard they ran belongs to the local XCUITest runner, which must attach to a target app; a cloud capture reads the provider's own driver session and needs no app identity, so the guard now applies to local Apple targets only. Relatedly, a cloud iOSopen com.example.appnow records that bundle id on the session — the provider path skips local app resolution (no simctl/devicectl reaches a hosted device), and used to drop an explicitly spelled bundle id along with it, leaving the session with no app identity at all. Opening a second bundle id replaces the first, matching the local path, where an explicitly spelled target always wins over the session's current app; deep links, display names, and bareopenstill keep the app already tracked. -
Cloud
fill(BrowserStack, AWS Device Farm) now witnesses that the field it tapped actually holds text-entry focus before sending its keys, instead of dispatching tap and keys in back-to-back requests (#1658). A WebView input — an OAuth/SSO page in a Safari view controller, for example — takes first responder asynchronously, so the keys used to land with nothing focused whilefillstill answered "Filled N chars"; tapping and filling as two separate commands worked only because the round trip between them gave the field time to focus. The witness is the focused element's own geometry:fillpolls the active element and proceeds only once it contains the point it tapped, which is the one signal that identifies which field took focus. Keyboard visibility cannot — it reads the same before and after a second fill into an already-open form, so it could not tell a focused password field from the email field the previous fill left focused. The response disclosestextEntryReadiness:focused-element, orkeyboard-shownwhen the driver has no active-element route but the keyboard rose from hidden after the tap. Both describe a fill that witnessed focus before typing; there is deliberately no value for typing without evidence, because nothing renders this field and such a value would reach a caller as an ordinary success. Breaking: when focus cannot be witnessed, cloudfillnow FAILS withCOMMAND_FAILED/text_entry_focus_not_observedand sends no keys, instead of typing into whatever holds first responder and answering "Filled N chars" — a fill with no witness must not read as a filled field. That covers a tap that focused nothing, a keyboard already up on a driver that cannot name the focused field, and a driver that reports neither (text_entry_focus_unobservable, which points atpress+typeas the deliberate way to enter text unwitnessed). Only a positively classified unimplemented route counts as unsupported, so a dead session, an auth rejection, or a grid outage surfaces instead of degrading into a blind text entry. -
Changed mutating selector ambiguity semantics (press/click/fill/longpress): duplicate accessibility wrappers collapse only when every match forms one ancestor-descendant chain resolving to the same actionable node. Matches in distinct subtrees now fail fast with
AMBIGUOUS_MATCHand a bounded, immediately reusable candidate-ref frame; visible/depth/area geometry no longer silently picks a mutation target. The direct iOS XCTest path now counts raw exact matches before hittability and delegates ambiguity to the same runtime rule. AppControlBench provenance: element-14 ran on 0.20.5; this change is intended for 0.20.7+, and comparative benchmark reports should note that it can replace a wrong-success recovery loop with one candidate-pick turn while occasionally adding that turn for genuinely distinct duplicates. -
scrollandbacknow accept--settle(with--settle-quietand--timeout), collapsing scroll-then-observe and back-then-observe into one call (#1638). The response carries the same settled payload the touch commands return — verdict, changed-lines diff with fresh refs on added lines, the unchanged-interactive tail, andrefsGenerationwhen the settled tree was stored — and is best-effort: it never fails the action. One difference is deliberate:scroll/backresolve no element, so the diff baseline is the session's stored pre-action tree ("the last tree you observed") rather than a freshly resolved pre-action capture. Both commands now also preserve the daemon on timeout, like the other settle-capable commands. -
Security: repository
./agent-device.jsonnow accepts only project-safe automation defaults. It rejects daemon endpoint/auth/transport/server settings, tenant/run/lease selectors, provider/cloud and Metro connection fields, headers, executable reporter modules, local write destinations, and other operator-controlled values before local module loading or any daemon health/RPC request. Put remote endpoint and token together in protected CI environment variables, user config, an explicit--configfile, or the existingconnect/--remote-configworkflow. Daemon auth tokens no longer travel in serialized command flags. -
viewportis now rejected during capability admission on Apple targets instead of reaching the device and failing inside dispatch. No Apple backend can resize a screen — simulator and device geometry is fixed by the selected device type — soviewporton iOS/iPadOS/tvOS/macOS now fails withUNSUPPORTED_OPERATION,viewport is not supported on this device, and a hint pointing at--platform weband at picking a different simulator.capabilitiesno longer advertisesviewporton Apple targets. Web viewport resizing (agent-device viewport 1280 900 --platform web) is unchanged, and Android was already denied. -
--save-scriptis now accepted only by the commands that declare it —open,close, andreplay. A hand-built daemon request (or abatchstep) that setsaveScripton any other command, such asrecordortrace, used to arm script publication and could write a.adartifact; it is now rejected withINVALID_ARGSbefore the request reaches admission, the device, or any handler. CLI, Node, and MCP usage of--save-scripton its documented commands is unchanged. -
diff screenshotno longer runs the retired best-effort OCR and non-text analyzers. Their optionalocrandnonTextDeltasfields remain in the result type for source compatibility but are no longer emitted; use the baseline/current images and diff artifact with vision for qualitative interpretation. -
Breaking: removed the deprecated
--session-lockedand--session-lock-conflictsflags. Use--session-lock reject|stripinstead; passing either old flag now fails withUnknown flag: ... Use --session-lock reject|strip instead. -
Breaking: removed the
replay export --formatflag.replay exportalways writes Maestro YAML. -
Breaking: removed the unused
LeaseAllocatePayload,LeaseHeartbeatPayload, andLeaseReleasePayloadtype exports fromagent-device/contracts. Lease request metadata is fully described byDaemonRequestMeta. -
Maestro compat:
assertVisibleandassertNotVisiblenow acceptchildOffor ancestor scoping, matchingtapOn(#1294). -
Breaking: removed deprecated gesture duration and rotate velocity inputs (#1218).
swipe x1 y1 x2 y2no longer accepts a trailingdurationMspositional; usegesture pan x1 y1 (x2-x1) (y2-y1) durationMsfor deliberate timed drags.- Maestro
swipeoperations with a duration continue to normalize togesture panwith theendpoint-holdexecution profile, preserving the Maestro-compatible fast-swipe-then-hold behavior on iOS. gesture fling direction x yno longer accepts a trailingdurationMspositional; usegesture panfor timed movement.gesture swipe presetno longer accepts a trailingdurationMspositional; usegesture panfor timed movement.gesture rotate degrees [x] [y]no longer accepts a trailingvelocitypositional; rotation pacing is derived fromdegrees.- MCP/Node schemas no longer advertise
velocityordurationMsonswipe/fling/gesture swipe;durationMsremains ongesture panandgesture transform. - A
.adscript that still carries a removed positional now fails when the script is parsed, before the replay executes any device action, naming the line and its rewrite (for exampleswipe accepts 4 arguments: x1 y1 x2 y2 (line 6). The trailing durationMs positional was removed: use "gesture pan 197 650 0 -350 300" ...). Previously the script ran up to that step and then failed as a replay divergence. - Published the gesture migration guide covering CLI, Node.js, MCP, and saved
.adrecordings, plus the deprecation policy the next such removal follows (#1216). replay exportnow writes an explicitduration: 100forswipe— the canonical fling duration — instead of omitting it and letting Maestro apply its own 400ms default. Maestro flows replayed byagent-deviceare unaffected; a timed Maestroswipestill normalizes togesture panwith theendpoint-holdprofile.
-
Breaking: the deprecated
rotateCLI command alias has been removed. Useorientationinstead; invokingrotatenow fails withrotate was renamed to orientation; for the two-finger gesture use: gesture rotate. -
Breaking (ADR 0014, session ref-frame lifetime): a mutation through an
@refnow expires the session's ref frame, so a later ref mutation without a fresh observation fails closed with a typeddetails.reason(ref_frame_expired,ref_generation_mismatch,plain_ref_requires_complete_frame, orref_not_issued) instead of acting on a possibly-navigated screen. A ref-oriented sequence that performs several mutations must re-snapshotbetween them, consume an honestly issued--settleref in pinned@eN~s<gen>form, or use selectors. Enforcement applies on every platform, not just iOS. Legacy hand-written.adscripts that reuse several bare refs from one snapshot must capture between mutations or use selectors. -
Ref reads resolve against the authorized ref frame's source tree, so an internal read-only capture (including Android freshness) can no longer retarget an admitted
@refby positional coincidence. Read-only ref consumers keep the structured staleness warning while the frame retains the ref's evidence.
0.15.0
- Breaking:
appsdiscovery and public app-list helpers now default to user-installed apps. Use--allorfilter: 'all'to include system/OEM apps. - Breaking: removed the
agent-device/android-appspublic subpath. Use the Android app helpers fromagent-device/android-adb. - Breaking: removed the
agent-device/daemonpublic subpath. Useagent-device/contractsfor daemon request/response types. - Breaking: removed public local ADB bypass/selection helpers such as
spawnAndroidAdbBySerialandresolveAndroidAdbProvider; usecreateLocalAndroidAdbProvider(device)or pass providers directly to the helpers fromagent-device/android-adb. - Added Android ADB provider helpers for exec, stream, clipboard, keyboard, app lifecycle, logcat, and port reverse workflows.