Commit Graph

312 Commits

Author SHA1 Message Date
Michał Pierzchała 37b1bc8cbd refactor: migrate viewport to request runtime (#1864)
* refactor: migrate viewport to request runtime

* fix: preserve viewport cutover evidence
2026-08-19 10:38:27 +02:00
Michał Pierzchała 3f0f706f0b refactor: migrate diff to request-bound runtime (#1847) 2026-08-18 19:40:14 +02:00
Michał Pierzchała d76e0f94e9 refactor: migrate snapshot to device runtime (#1779)
* refactor: migrate snapshot to device runtime

* refactor: complete snapshot runtime policy cutover

* test: enforce snapshot owner-facts admission

* refactor: consolidate desktop snapshot capture

* fix: scroll to visible iOS smoke targets

* fix: close snapshot cutover alias bypasses

* fix: constrain snapshot admission identity flow

* fix: enforce snapshot admission through owner facts

* fix: adapt replay source tests to snapshot runtime
2026-08-18 15:49:24 +02:00
Michał Pierzchała a853734f0c fix(webdriver): give cloud session creation its own budget and stop leaking billed sessions (#1782)
* fix(webdriver): give cloud session creation its own budget and stop leaking billed sessions

Cloud lease allocation ran under the generic 30s/1-retry request policy, so
BrowserStack iOS real-device session creation (45-90s) aborted client-side at
~60s on most runs. Each timed-out POST /session still completed server-side and,
being non-idempotent, was retried — leaving two billed provider sessions per
failed open with no id to release them.

- POST /session is its own phase: a 180s create budget (default), zero retries,
  and no request-bound abort, so the daemon always learns the session id.
- lease_allocate carries a 300s allocation budget surfaced to providers as
  LeaseLifecycleContext.deadline, and a matching 330s client envelope that
  preserves the daemon on timeout (a reset would SIGKILL mid-create and orphan
  every billed session the daemon held).
- The request's cancellation signal is ownership evidence: a session that
  completes after the requester left is released, not registered; a create that
  the transport gives up on surfaces typed evidence (provider + lease) so an
  operator can find and stop the maybe-orphaned session.

Closes #1774

* refactor: one canceled-request error, and tighten the #1774 shapes

Review pass over the session-create fix:

- The canceled-request error had nine hand-rolled copies (src/request/cancel,
  maestro shared, exec, retry, install-source x2, and the new provider one).
  It now has one definition in @agent-device/kernel/errors:
  createRequestCanceledError(details?, cause?) + isRequestCanceledError +
  REQUEST_CANCELED_REASON. Callers add evidence or a sharper hint; the reason
  itself is not overridable, so nothing can build one the predicate misses.
- lease_allocate's timeout bundle moves beside INSTALL_TIMEOUT_POLICY in the
  registry (same {...DEFAULT, envelopeMs, onTimeout} shape); the request timeout
  constant stays exported from timeout-policy like its siblings.
- Transport: fetch helper returns Response's own ok/status; the timeout reason
  const is private behind isWebDriverRequestTimeout.
- Client: one-use options type inlined; the two deadline helpers share one floor.
- Session-manager tests: shared makeRuntime/jsonResponse/afterEach restore.

Net -29 lines with the feature in.

* chore: keep the canceled-request reason private to the kernel

* fix: typed cancellation everywhere + own the AWS remote-access ARN through startup

Second-order follow-ups the #1774 refactor made cheap:

- markRequestCanceled aborts the request signal WITH the kernel's typed
  canceled error as its reason. Every signal.throwIfAborted(), aborted fetch,
  and 'throw signal.reason' in the daemon (20+ sites) now surfaces a canceled
  request as such instead of a bare DOMException that normalized to UNKNOWN —
  and no site has to know the factory exists.
- AWS Device Farm prepareSession owns the remote-access ARN from the moment
  create-remote-access-session answers: a startup timeout, the allocation
  deadline, or a canceled request now stops it before the failure surfaces
  (previously a timed-out startup left a RUNNING billed session behind — the
  same leak class as the WebDriver session, one phase earlier). The startup
  wait is capped by LeaseLifecycleContext.deadline and wakes on cancellation.
- BrowserStack's pre-session local app upload honors the request signal (an
  upload is not billed, so plain abort is right there).
- lease_heartbeat/lease_release share lease_allocate's preserve-daemon policy:
  the rationale — the daemon owns billed sessions; a reset orphans them all —
  applies verbatim.

Each AWS ownership test proven red without the guard (3/3).

* refactor: dedupe billed-resource cleanup and lease-signal wiring

Shrink pass — same behavior, less duplication:

- releaseOnFailure(primaryError, release) in webdriver-utils replaces the two
  identical 'best-effort stop the billed resource, attach cleanupError to the
  primary AppError' helpers (WebDriver session + AWS remote-access ARN); shared
  errorMessage too.
- The lease handler pulls the request signal from getRequestSignal(requestId)
  like every sibling handler, instead of threading a requestSignal arg through
  LeaseHandlerArgs and the request-handler chain. Drops the field, the wiring,
  and five mechanical test edits; the handler test now proves the request-bound
  signal (abort it, watch the provider's signal flip) rather than arg identity.
- Inlined the one-use requestHeaders back into fetchWebDriver.

Handler-signal test proven red without the wiring.

* fix(lease): the daemon releases a lease allocated for a gone requester; honest release evidence

Review follow-up. The provider was doing the daemon's job: it treated the request
signal as 'ownership evidence, not an interrupt' and needed three paragraphs to
say so. The daemon owns the request, so it now decides — generically, for every
provider — what happens to a lease that finished allocating after its requester
left: release it (provider + registry) and answer with the canceled error.

- lease.ts: after allocate returns, isRequestCanceled(requestId) →
  releaseAllocationForGoneRequester(). Release evidence is claimed ONLY on a
  clean release (no warnings, no throw); a WEBDRIVER_SESSION_DELETE_FAILED
  release is reported released:false with providerSessionId + a stop-by-hand
  hint (thymikee's finding: the previous evidence was success-shaped even when
  DELETE failed).
- WebDriverSessionManager: the createOwnedSession/releaseCanceledSession trio is
  gone; allocate is plain 'create with a budget; on failure clean up' again.
- LeaseLifecycleContext.signal is just cancellation, like everywhere else; the
  ownership-semantics comments on the contract, client, registry, AWS prepare and
  utils shrink to what the code no longer says itself.
- Tests: the two provider-level cancellation tests move to the daemon handler
  (where the logic now lives), plus the failing-DELETE regression; both proven
  red without the post-allocate check.

* fix(aws): the allocation deadline bounds remote-access startup, not the 120s default

Live iOS real-device run: startup needed ~128s and hit the standalone 120s
default while the daemon's 300s allocation budget still had room — the new
ownership guard correctly stopped the ARN, but the open failed for no reason.
When the daemon supplies a deadline it is the bound; the default only applies
standalone. Rerun: open in 112s, snapshot, clean close, session STOPPING.

* test(aws): pin that the allocation deadline outlives the 120s startup default; drop empty import

Review follow-ups on 7f9d1481a: a virtual-clock test (Date.now advanced 10s per
poll, RUNNING at 150s, deadline 300s) that fails on the old min(default,
deadline) logic and passes now; and the empty 'import {} from kernel/errors'
left in maestro/shared.ts is removed.

* refactor: finish the dedupe — one release path, kernel errorMessage, AWS on releaseOnFailure

Code-quality review at 7f9d1481a:
1. aws-device-farm.ts still carried its own copy of releaseOnFailure (the dedupe
   commit's script aborted before reaching it and I mis-verified). Now uses the
   shared helper; private copy deleted.
2. Empty 'import {} from kernel/errors' in maestro/shared.ts removed (273870099).
3. errorMessage() lives in @agent-device/kernel/errors; the two copies this PR
   had added (lease.ts, webdriver-utils.ts) import it. Sweeping the pre-existing
   copies is a follow-up.
4. lease.ts has ONE release path: releaseLease(registry, provider, lease,
   request, ctx) → { released (registry), provider } used by both the
   lease_release case (wire shape unchanged) and the gone-requester branch, which
   folds a throwing provider release into releaseError. 'released' now means the
   same thing in both; the provider verdict is a separate 'providerReleased'
   (warnings-free, no throw) that drives the stop-by-hand hint. -~35 lines.
5. sessionCreateTimeoutMs is Omit-ed at the WebDriverTransportOptions boundary
   instead of Pick-ed back out internally.

* fix(lease): 'released' on a canceled allocation means the billed session is confirmed gone

Re-review at 3665ea06: unifying the release path had made the cancellation
error report released:true from the daemon's registry record while the provider
DELETE had failed — success-shaped again, with the operator verdict demoted to
a second key. Fixed at the source of the ambiguity:

- LeaseReleaseOutcome names its bookkeeping field registryReleased.
- On the canceled error, 'released' is true only when registryReleased AND the
  provider released without warnings AND without throwing; the registry record
  is exposed as 'registryReleased'. The stop-by-hand hint keys on 'released'.
- lease_release keeps its existing wire field ('released' = registry; provider
  cleanup rides in 'provider'), unchanged.
- Regressions: failed DELETE and throwing release both pin released:false /
  registryReleased:true (+ providerSessionId, warnings|releaseError, hint);
  both proven red on registry-only semantics.

* ci: retrigger default-setup CodeQL

Run 32051017472 is wedged on GitHub's side: status=completed with
Analyze (python) still queued and Analyze (java-kotlin) failed only at SARIF
upload (503, 'No server is currently available'). It can be neither cancelled
nor rerun, and default-setup CodeQL has no dispatchable workflow, so a new push
is the only way to get a fresh run. No source change.

* test(webdriver): assert the typed timeout contract on the shared-budget probe

main's #1790 tightened this test to expect the raw TimeoutError DOMException,
which this PR intentionally normalizes into AppError{reason:
webdriver_request_timeout}. On the merge ref the two met and Coverage went red.
The regression now asserts the structured contract and that the second request's
budget is the shared remainder (~118ms of 200 after an 80ms first call).
2026-08-18 15:48:58 +02:00
Michał Pierzchała d0d5c8594c fix: serve remote daemon request diagnostics to the caller (#1801) (#1814) 2026-08-18 15:36:09 +02:00
Michał Pierzchała 4b44c1c53a chore(test): remove the contention retry and shrink the subprocess-stub project (#1781 A4) (#1827)
The enumerated single-retry policy (#1419) has fired zero times since it
landed on 2026-07-29: 0 of 234 sampled Coverage-job lane envelopes
(2026-08-11 to 2026-08-18) have retryCount > 0, and none of 17 recent
failed runs was retried (5 refused "outside the enumerated retry list",
4 refused "unhandled error"). All three trackers its entries pointed at
(#1098, #1414, #1419) are closed. It cost ~1,454 LOC, a per-run secret
marker threaded through a setup file on every Vitest project, and a
standing obligation for every future gate reporter to call the blocker
bus.

Delete the scripts, tests and fixtures, the check:contention-retry
script and gate, the envelope artifact upload, and the runner-timeout
setup file; test:coverage:ci is a plain `vitest run --coverage` again.
lane-envelope.ts stays: the mutation, fuzz and concurrency-torture lanes
build their envelopes from it. run-blocker-bus.ts goes: its only
consumer was the retry's failure sink, and its only publisher already
fails the run by setting process.exitCode.

Keep the subprocess-stub project for the three files that really spawn
(client-metro, fuzz harness, fuzz corpus-replay) and drop the three that
run in 31/212/277ms in CI, which cannot contend for anything. The list
is now a plain array in vitest.config.ts with the reason at each entry.
Membership and the project's kill criterion live in #1823.

Because test:coverage:ci is a bare vitest run, the gate manifest reads
its projects directly, so OPAQUE_RUNNERS no longer needs it and an
unrun Vitest project becomes unrepresentable rather than detected; the
audit test now constructs that state by project-scoping the script.
2026-08-18 15:35:25 +02:00
Michał Pierzchała 60f6356b04 fix: read replay scripts on the caller and ship them with the request (#1810)
* fix: read replay scripts on the caller and ship them with the request

Closes #1802

* test: assert the caller-side replay path as a substring, not a hand-escaped regex

* perf(cli): load the Maestro engine only when a replay entry is a flow

The command registry evaluates every command family on CLI startup, so the replay script-source builder's static @agent-device/maestro import put the YAML parser on the --help path. It now loads on demand behind the format check, and the startup import-closure guard covers the engine the way it already covers node:http.

* refactor: share the replay request field vocabulary across the CLI and client views

The new replay script-source flags appear in both CliFlags and CommandExecutionOptions, which fallow flagged as a clone; ReplayRequestFields declares them once. The test-suite handler's missing-sources rejection now travels the typed-error path its sibling rejections already use, so the fix adds no branch to handleSessionReplayCommands.
2026-08-18 14:56:34 +02:00
Michał Pierzchała f843dc2df1 fix(scroll): keep saturated scroll gestures out of the status bar; gate Android replays from android/emulator (#1781 A1) (#1820)
* fix(scroll): keep saturated scroll gestures out of the status bar; gate Android replays from android/emulator (#1781 A1)

`pnpm gate replay-android` failed 4/8 whenever it ran after the full-tier Android E2E
(replays-nightly run 32107665052, job 95620294899): 05-app-lifecycle, 06-swipe-gestures and
both fixture replays diverged under "A system surface covers the app". The E2E was not the
cause. Reproduced on a pixel_7 / API 36 AVD with the same cutout geometry CI's
`avdmanager --device pixel_7` produces (status bar 136px, not the 63px of a plain 1080x2400
skin):

- `03-scroll-discovery.ad` runs `scroll up 3`. The scroll planner clamps travel to the viewport
  minus a 5% band, so the touch-down landed at y=120 — inside the 136px status bar — and
  pulled the notification shade instead of scrolling. On API 36 the app window is
  edge-to-edge, so the reported viewport starts at y=0 and includes that bar.
- The shade then covered every replay until `04`'s `back` closed it. Native readdir order on
  the runner (03, 05, 06, fixture/02, fixture/01, 04, 01, 02) put four files in that window;
  the last green run (2026-07-30) had 04 right after 03, so the pull was masked.

Fix in the product, not the lane: DEFAULT_EDGE_PADDING_FRACTION 0.05 -> 0.1 in the TS scroll
planner and its Swift port. Every real Pixel has a cutout (5.7% of a Pixel 7's height) and an
iPhone's Dynamic Island status bar is 6.9%, so any saturated `scroll up` opened the shade /
Notification Center for real agents too. Parity vectors updated in both suites plus a Pixel 7
regression vector (1080x2400, amount 3 -> touch-down y=240 > 136).

Second contamination the same order exposed once the shade was gone:
`fixture/02-selector-routes-covered-diagnosis.ad` is a #1715 reproduction recipe that FAILS
BY DESIGN at step 9 (covered-target refusal) and leaves the device in landscape, yet the
gate enumerated `test/integration/replays/android` recursively. iOS keeps gate replays in
`replays/ios/simulator` and fixture recipes in `replays/ios/fixture`; Android now mirrors that:
the six Settings replays move to `replays/android/emulator`, `test:replay:android` points there,
and `fixture/` stays E2E-owned (`full:fixture-replays` already runs 01 by path). android.yml
and the workflow-evidence fixture follow the path; the replay-compat manifest keeps the
historical paths it pins at released tags.

Verified live (Pixel 7 geometry, API 36, --retries 0): control run at main head in CI order
reproduces exactly CI's 4/8; with the fix, `pnpm gate replay-android` 6/6 in both native and
CI order, and `03` leaves Settings on screen (scroll up 3 now touches down at y=240).

* test(scroll): drive the TS and Swift scroll-plan parity vectors from one table (#1820 review)

The two suites hand-mirrored the same vectors and #1820 had to edit both by hand — the drift
class the repo already closes for the tap-point rule via contracts/fixtures/tap-point-policy.json.
The scroll vectors (plus both planner constants, pinned behaviourally on a 1000px axis) now live in
contracts/fixtures/scroll-gesture.json; scroll-gesture.test.ts and RunnerTests+ScrollGesture.swift
iterate it. Verified: vitest 10/10; the four XCTests run on an iOS 26.2 simulator with the unit
flag on (Executed 4 tests, 0 failures).

Also: test/ci/android-workflow-evidence.json says what it guards.

Follow-up for content-safe viewport bounds + discovery order: #1821.
2026-08-18 14:31:54 +02:00
Michał Pierzchała 2b6d04a13e fix: enforce device claims for sessionless device mutations (#1809)
`boot` and `shutdown` never consulted the host-global device claim store, so a
daemon in one state directory could terminate an emulator another daemon held a
verified-live claim on and report success (#1799).

Rather than adding a claim check to those two handlers, this makes the class
unrepresentable: `CommandDescriptor` gains a REQUIRED `deviceClaimPolicy` trait
(#1320's vocabulary), and the request-execution scope enforces it where the
request runtime bindings create a device binding — the one seam through which
any handler can obtain device operations, and already the place per-device
deduplication lives. A `transient-exclusive` command acquires a command-scoped
claim before operations reach the handler, refuses a foreign live claim with the
existing DEVICE_IN_USE/DEVICE_CLAIM_LIVE_OWNER error, and releases in the
scope's finally. Every other policy performs no claim-store I/O, so session-bound
commands keep #1320's non-goal intact.
2026-08-18 14:12:08 +02:00
Michał Pierzchała 801734d433 feat(ai-sdk): add agent-device/ai-sdk tool set and document the MCP zero-code path (#1804)
* feat(ai-sdk): add agent-device/ai-sdk tool set and document the MCP zero-code path

Adds `createAgentDeviceTools()` under a new `agent-device/ai-sdk` subpath,
built from the same command registry the MCP server uses so both stay in
lockstep without a hand-maintained tool list. Introduces a `frameworkTier`
descriptor facet ('core' | 'extended') so the factory can default to a
curated perceive/act loop instead of handing a model dozens of tools.

`ai` is wired as an optional peer dependency, imported lazily inside the
factory rather than at module scope, so importing the subpath itself never
requires `ai` to be installed - only calling it does. The package's own
publishing gate (scripts/lib/shipped-imports.ts) is extended to recognize
peerDependencies as a valid resolution source, since this is the first
optional peer this package has shipped.

Also restructures the AI SDK doc around three tiers (zero-code via
@ai-sdk/mcp, the new typed tool set, hand-written tools) and fixes a stale
`needsApproval` reference in favor of the current `toolApproval` API.

* fix(layering): classify src/ai-sdk as a rank-4 zone

The layering guard requires every src/<folder>/ to be explicitly ranked or
unranked; the new src/ai-sdk/ subpath (added in the prior commit) was left
unclassified, failing CI's Layering Guard job. It sits at the same tier as
client/compat/daemon-server/metro/remote/sdk - a public integration surface
consuming mcp (3) and core (2), imported by nothing else in the tree.

* fix(ci): cover, exempt, and pack the new ai-sdk subpath

Fixes the remaining CI failures on the ai-sdk subpath commit:

- Coverage: src/ai-sdk/index.ts had no dedicated unit test (only manual/
  integration verification), so changed-line coverage sat at 6.9% against
  the 70% gate. Adds src/ai-sdk/__tests__/index.test.ts (core vs 'all' tool
  filtering, session/platform pinning and schema hiding, error
  normalization, toolApproval passthrough) with createCommandToolExecutor
  and createAgentDeviceClient mocked the same way command-tools.test.ts
  does, plus a dedicated missing-peer-dependency.test.ts that mocks `ai`
  itself to throw, isolated to its own file so it doesn't affect the other
  tests' use of the real, installed `ai` package. Changed-line coverage is
  now 29/29 (100%).
- Fallow Code Quality: src/ai-sdk/index.ts and examples/sdk/ai-sdk-tools.ts
  are entry points with no in-repo importer (reached only via package.json
  exports / run directly), and the new subpath's exports are unused
  internally by design - both need the same treatment src/sdk/*.ts and its
  examples already have in .fallowrc.json.
- Integration Tests: test/integration/installed-package-metro.test.ts and
  src/__tests__/package-exports.test.ts each hand-list every published
  subpath and smoke-check it from a real packed install; added ./ai-sdk to
  both so the new subpath is actually exercised, not just silently passing.

* fix(ai-sdk): hide MCP transport/config fields from the model too

createAgentDeviceTools() only removed session and mcpOutputFormat from tool
schemas. stateDir was still model-visible and reached the shared executor
as client configuration, letting a tool call redirect into a different
daemon state directory - defeating the "one pinned session" guarantee the
factory exists to provide. includeCost and responseLevel are MCP
tool-config knobs in the same category, irrelevant to this adapter.

Widens the hidden-field set to session/stateDir/mcpOutputFormat/
includeCost/responseLevel, and now strips them from the runtime input
inside execute() too (not just the schema), so the guarantee holds even if
a caller bypasses schema validation. The schema-properties filter and the
input filter now share one omitHidden() helper instead of two near-
duplicate implementations.

Addresses the P1 review comment on #1804.
2026-08-18 11:57:34 +02:00
Michał Pierzchała 8db36299e4 feat(web): add hover command for hover-gated UI (#1783) (#1786)
* feat(web): add hover command for hover-gated UI (#1783)

Add a first-class `hover <x y|@ref|selector> [--settle]` verb, admitted on
web only, that moves the pointer without pressing via the agent-browser
backend (mouse move). It rides the existing targeted-touch pipeline
(ref/selector/coordinate resolution, occlusion/off-screen guards, settle
observation, response builder, recording) through a new optional
Interactor/backend `hover` op that only the web provider implements.

Touch platforms have no hover state: capabilities advertise it on web
only and iOS/Android/Linux reject it at admission with a --platform web
hint; longpress stays the mobile hold-gesture verb.

Closes #1783

* fix(hover): native hoverRef route for web @ref, android coverage pin, revert skill edit

Review follow-ups on #1786:
- hover @ref on web now dispatches through the provider's own element handle
  (agent-browser `hover <ref>`) via a new backend hoverTarget, mirroring
  click/fill's ADR 0011 native-ref path — web ref frames carry no rects, so
  the coordinate route could never resolve them. The shared preflight +
  exact-ref dispatch is extracted into dispatchNativeRefInteraction and used
  by tap/fill/hover; the guarantee matrix native-ref row now lists hover.
- Daemon regression test is production-faithful: rect-less web ref frame,
  scoped provider, asserts no coordinate dispatch. Selector→coordinate and
  provider hoverRef tests added.
- Android emulator coverage summary pin 2/53 → 3/54.
- skills/agent-device/SKILL.md reverted (out of scope, AGENTS.md rule).
- Docs/help disclose that --settle with @ref on web shares click's existing
  limitation; use a selector or coordinates for the settled diff.

* test: drive hover through the apple output guard; cover direct hover dispatch

The provider-integration apple-leak guard partitions every public command
into driven/skipped; hover was neither, which failed Integration Tests and
took Coverage down with it. Drive it (it reaches the Apple capability
refusal, which is scanned like any other error response). Also cover the
direct-dispatch handleHoverCommand seam.

* test(web): drive hover @ref in the provider-backed web scenario

The integration-progress gate requires every public command to be referenced
by a provider-backed scenario. Add hover @ref to the web desktop flow: it
must reach the provider's hoverRef handle (never a coordinate) and be
recorded on the session without fabricated x/y, like click @ref.
2026-08-18 11:32:48 +02:00
Michał Pierzchała e3f3a2488e test(android): restore full-tier lifecycle and observability scenarios (#1781 A1) (#1793)
* test(android): restore full-tier lifecycle and observability scenarios (#1781 A1)

The nightly Android job has failed on `click id="automation-request-microphone"`
since the full tier landed: `settings permission reset microphone` runs
`pm revoke`, and revoking a *granted* runtime permission kills the app process,
so the round after an accept clicked into an empty launcher surface.

Reproduced on the pinned CI image (android-36 google_apis_playstore, Pixel 7):
pm revoke leaves pid 4259 alive when the permission is denied and kills it when
it is granted, with NexusLauncher resumed afterwards.

Fixing that exposed the rest of a scenario that had never executed end to end:
the post-revoke readback cold-started on the tabs home instead of Automation lab,
the relaunched Automation lab needed its controls revealed, the Form tab does not
exist on the Automation root route, that section needs the system IME back, and
the IME diagnostic sits above the bottom of the form. The observability scenario
then failed the same way (reveal distances tuned for a taller device) plus an
event-timeline walk whose page was smaller than the events each page read appends.

Validated live against a local Pixel_7_CI emulator (API 36, same profile as the
lane): the full tier now runs bootstrap -> inventory -> automation-system ->
form-input -> keyboard-ime -> capture-close -> lifecycle-system ->
observability-artifacts and stops only in full:fixture-replays.

* test(android): repair the drifted fixture replays and pin the catalog canary (#1781 A1)

Review follow-up. The nested batch regression now checks a sibling card instead of the
notice that owns `dismiss-notice`: resolving a child already proves its parent is present,
so the old target could not fail on its own. Confirmed on a Pixel 7 / API 36 emulator that
`gesture-lab-card` and `dismiss-notice` are on screen together at the scenario's existing
0.3 reveal (both present at 0.2-0.4; the card is gone by 0.5).

Getting a full-tier run to complete then required repairing what the lane had never
executed:

- `01-navigation-scroll.ad` clicked `label="Catalog, 0 new notifications"`. #1543 made the
  cart badge conditional, so the live label is `Catalog` — what the iOS twin already used.
- The catalog scroll canary lives inside the scrolling content, and Android accessibility
  snapshots carry on-screen nodes only, so every state except the initial `top` was
  unobservable: `wait "Catalog scroll: down|bottom|up"` could never pass, whatever the
  swipe coordinates were. `stickyHeaderIndices` pins that one line, which makes all four
  states readable at any offset on both platforms rather than tuning the .ad around a
  canary that scrolls away.
- `gesture-lab-android.ad` started its multi-pointer gestures at y=1040, inside the target
  when the file was last repaired but 90px from its top edge after #1567 moved the card
  (targets now span y=949-1525). The second pointer landed outside the view, which reads as
  "the gesture did nothing". Multi-pointer gestures now start at the target centre, and the
  header comment records the geometry they depend on.

Evidence: the lane's own command (`AGENT_DEVICE_ANDROID_E2E_TIER=full` over
smoke-android-emulator.test.ts) passes end to end on a Pixel 7 / API 36 AVD with a
CI-equivalent fixture APK (cached native + head JS through the same repack the workflow
runs): 9/9 scenarios, 153s.
2026-08-18 09:57:24 +02:00
Michał Pierzchała f45228ae71 ci: skip device lanes for root-level docs-only changes (#1781 A9) (#1791)
* ci: skip device lanes for root-level docs-only changes (#1781 A9)

Add AGENTS.md, CHANGELOG.md, CONTEXT.md, CONTRIBUTING.md, LICENSE, and
SECURITY.md to the pull_request paths-ignore block in ios.yml,
android.yml, linux.yml, macos.yml, ci.yml, and size.yml. These
root-level docs files were the only gap left after docs/**,
website/**, and README.md — PRs #1568 (SECURITY.md only), #1697
(CONTEXT.md + docs/adr only), and #1722 (AGENTS.md + docs/) each still
triggered a full 9-15 min macOS iOS run despite touching only prose.

Why each file is safe to ignore for every one of these six workflows:

- None of the four device workflows (ios/android/linux/macos) or their
  composite actions read any of these six files at runtime; the only
  hits from `grep -rln` across scripts/, src/, test/, and
  .github/actions/ are prose comments pointing humans at CONTEXT.md or
  AGENTS.md sections (e.g. scripts/layering/check.ts,
  scripts/wire-compat/run.ts, src/mcp/tool-ref-pins.ts) — never an
  `fs.readFileSync`/`readFile` of the file itself.
- The check-affected selector (scripts/check-affected/model.ts)
  already classifies all six as pure docs: `isDocs()` matches any
  `.md` file plus the literal `LICENSE`, and `docsOwnership()` only
  special-cases `website/docs/docs/commands.md` (unrelated). So these
  files already select zero checks — they only ever produced
  `docsOnlyPaths` entries, never `SelectionReason`s.
- Because they select zero checks, the gate-manifest's path-coverage
  category derivation (`scripts/gate/model.ts` `categories()`, which
  iterates `plan.reasons`) never records a category for them, so
  ci.yml has nothing check-manifest-only that these six files would
  need to keep reachable. `pnpm check:gate-manifest` and
  `pnpm check:gate-manifest:test` both stay green after the change
  (48 checks / 33 lanes, 28/28 gate tests passing).
- size.yml's bundle-size job (scripts/size-report.mjs) measures the
  `pnpm build` dist output and startup timing only — no reference to
  any of these six files. (npm packs LICENSE/README.md into the
  publishable tarball, but that's a `pnpm check:package` node-22.12
  concern in ci.yml's packaged-cli job, which is driven by `dist`
  contents and `package.json`, not by LICENSE/README prose — already
  evidenced by README.md being ignored here since before this change.)

Scope disclosure: `mutation-affected.yml` uses a `paths:` allowlist
(not paths-ignore) so it's structurally unaffected; `test-app-build-cache.yml`
has no path filter at all. Neither was touched.

actionlint and `pnpm check:gate-manifest`/`:test` pass on the changed
workflows.

* test: pin root-doc paths-ignore entries with a regression test

Addresses review feedback on #1791 from thymikee: the docs-only
classifier for AGENTS.md/CHANGELOG.md/CONTEXT.md/CONTRIBUTING.md/
LICENSE/SECURITY.md across ios.yml/android.yml/linux.yml/macos.yml/
ci.yml/size.yml had no regression pin. Neither check:gate-manifest
(only proves a *registered check* is reachable) nor actionlint (only
validates YAML shape) nor generic Markdown coverage would catch a
single dropped entry — e.g. LICENSE reappearing in one workflow's
paths-ignore list but not another's would silently put a full 9-15 min
device run back on prose-only PRs.

test/ci/root-docs-paths-ignore.test.ts parses the six real workflow
files and asserts, using the same matchesGlob the gate-manifest model
uses to decide lane triggering, that each of the six root docs is
ignored by each workflow's pull_request paths-ignore. Registered in
vitest.config.ts's unit-core project next to its sibling
upload-agent-device-artifacts.test.ts (parse-only, no device/subprocess
lane needed).

Verified red on main (all 36 file x doc assertions fail — confirmed via
a throwaway script reading `git show main:.github/workflows/*.yml`)
and green on this branch (6/6). Full unit-core project (873 files /
6641 tests) still passes; check:gate-manifest and
check:gate-manifest:test unchanged (48 checks / 33 lanes, 28/28).
2026-08-18 09:57:09 +02:00
Michał Pierzchała d8a7d03faf refactor: route application lifecycle through runtime facts (#1759)
* refactor: route application lifecycle through runtime facts

Moves the canonical `open`, `prepare`, `close` and internal `runtime` descriptors
behind package-owned lifecycle bindings admitted from device runtime facts, while
daemon request/session policy and public response construction stay put.

Based on main, which already carries the boot unit, the parametrized cutover gate
and the apps unit. Readiness is package-owned there, so the Apple and Android
bindings call ensureAppleReady/ensureAndroidReady rather than a root readiness
bag; ensureAppleReady gained an onColdBootStart hook so open keeps warming the
runner cache in parallel with a cold boot, and a narrow markBooted port publishes
readiness' fresh observation so a flow still makes one simctl listing.

Cutover rows take R24-R27, clear of the accepted catalog and the sibling install
stack, and cutoverTableDefects rejects a duplicate rule id.

Two defects this unit introduced are fixed here rather than shipped:
`open <app> <url>` dropped the URL on a first open, and test-IME activation was
first fatal on an unobtainable helper and then over-caught. Helper unavailability
is a typed non-activation outcome now; fence, lock and post-record failures
propagate.

The duplication the unit had accumulated is gone: one runtime-admission module
instead of five per-command copies, one direct-lifecycle binding factory instead
of six hand-rolled packages, one transport-hint predicate, one session
finalization path, and no identity-wrapper module.

* fix: allocate lifecycle cutover rows after deployment

* chore: preserve lifecycle union reconstruction

* fix: reconcile lifecycle runtime stack

* refactor: tighten lifecycle runtime topology

* refactor: remove superseded runtime adapters

* fix: preserve stacked runtime cutovers

* test: preserve migrated runtime ownership

* test: move Android deployment retry ownership

* test: extract runtime hint fixtures

* fix: preserve lifecycle stack invariants

* fix: complete lifecycle runtime cutover

* fix: remove lifecycle cutover residue
2026-08-16 15:13:10 +02:00
Michał Pierzchała 66cca1a5b8 refactor: route install commands through platform runtime (#1758)
* refactor: route install commands through platform runtime

* fix: preserve stacked runtime facts

* fix: align deployment facts with shutdown runtime

* refactor: simplify capability facts projection

* style: format capability facts projection

* fix: preserve migrated capability ownership

* fix: propagate deployment artifact cancellation

* refactor: move Harmony deployment mechanics into package

* refactor: move Apple deployment tools into package

* refactor: move Android deployment tools into package

* refactor: inject deployment temporary storage

* refactor: remove superseded deployment helpers

* fix: preserve provider deployment transport
2026-08-16 15:13:09 +02:00
Michał Pierzchała 5855dfc2e0 refactor: route shutdown through device runtime (#1757)
* refactor: route shutdown through device runtime

* fix: cover shutdown cutover review gaps

* fix: propagate shutdown cancellation

* fix: move shutdown mechanics to platform owners

* fix: pass device to shutdown fact fixture

* test: cover shutdown facts in session state fixtures

* test: simplify Android shutdown assertions

* fix: preserve Apple shutdown cancellation
2026-08-16 15:13:08 +02:00
Michał Pierzchała 39cd4d346a refactor: route appstate through platform runtime (#1755)
* refactor: route appstate through platform runtime

* test: keep appstate capability fixture below complexity limit

* test: cover appstate required readiness fact

* fix: align appstate facts with boot readiness

* fix: keep appstate use declaration minimal

* fix: close appstate parity and ownership gaps

* docs: record final appstate size accounting

* fix: merge neutral runtime imports

* docs: align final appstate size totals

* fix: remove stale runtime dependency edges

* docs: correct appstate size accounting

* refactor: keep runtime-use factory internal

* docs: itemize runtime-use relocation

* fix: move appstate queries into runtime packages

* refactor: retire root foreground query paths

* refactor: share Android foreground parser ownership

* fix: preserve Android appstate parser precedence

* docs: keep appstate evidence in review artifacts

* fix: keep appstate runtime loading lazy

* fix: fail closed for stale limrun appstate

* fix: preserve limrun recovery and abort appstate

* fix: narrow limrun exact-owner recovery

* fix: allocate appstate cutover rule

* fix: reconcile appstate with merged main

* style: format harmony runtime test

* fix: allocate appstate rule id

* fix: allocate appstate layering rule

* fix: remove stale app command admissions

* fix: close appstate layering regressions

* fix: align Harmony capability parity with runtime facts

* test: cover limrun recovery-only readiness

* fix: keep Limrun recovery binding app-log only

* fix: parse Android app state in linear time
2026-08-12 16:55:50 +02:00
Michał Pierzchała 07d528086b fix: harden iOS alert smoke scenario (#1767)
* fix: harden iOS alert smoke scenario

* fix: address iOS alert smoke review feedback

* fix: present fixture alert after React commit
2026-08-12 12:54:23 +02:00
Michał Pierzchała 15133b587f fix: validate macOS recording finalization (#1732)
* fix: validate macOS recording finalization

* fix: preserve local macOS recording ownership

* fix: resolve recording teardown session keys
2026-08-12 12:51:13 +02:00
Michał Pierzchała eabc936a0f refactor: route apps through request runtime (#1756)
* refactor: route apps through request runtime

* test: remove stale apps adapter mock

* fix: clean apps runtime replay artifacts

* fix: remove stale runtime test exports

* refactor: simplify apps runtime admission

* test: exercise runtime use through facade

* fix: close apps runtime admission gaps

* fix: disambiguate doctor app inventory callback

* fix: keep capability fixture below complexity limit

* fix: keep HarmonyOS app inventory fail-closed

* test: type HarmonyOS app admission fixture

* fix: restore HarmonyOS app inventory parity

* test: align HarmonyOS readiness fixture

* fix: preserve HarmonyOS doctor app parity

* test: type HarmonyOS doctor fixture

* fix: isolate HarmonyOS doctor policy

* fix: align apps cutover with shared rule catalog
2026-08-12 11:55:09 +02:00
Michał Pierzchała b13c06338e refactor: route boot through readiness runtime (#1747)
* refactor: route boot through readiness runtime

* fix: separate boot admission from readiness

* fix: register boot cutover policy

* refactor(runtime): move readiness into platform owners

* fix(test): tolerate provider temp cleanup races
2026-08-12 11:55:09 +02:00
Michał Pierzchała f5d9789764 feat: enforce local device claims and reconcile stale owners (#1735)
* feat: enforce local device claims

* fix: address device claim review feedback

* fix: persist canonical daemon claim state directory
2026-08-11 16:18:45 +02:00
Michał Pierzchała 602b7a2995 refactor: narrow perf API to actionable evidence (#1731)
* refactor: narrow perf API to actionable evidence

* fix: address perf API review feedback

* fix: preserve deprecated Android CPU metrics
2026-08-11 15:28:58 +02:00
Michał Pierzchała b8dd6a5854 refactor: tighten capture ownership boundaries (#1736) 2026-08-11 13:55:28 +02:00
Michał Pierzchała 57fc0f99fa test: fail closed on unknown recording provider commands (#1728) 2026-08-11 11:30:20 +02:00
Michał Pierzchała 1b2e786128 refactor: move screen recording onto platform runtime (#1724) 2026-08-11 10:24:57 +02:00
Michał Pierzchała 338aa2a0d5 refactor: route every native selector resolution through the policy interface (#1715)
* refactor: route every native selector resolution through the policy interface

#1649 declared the per-caller ambiguity matrix; four native call sites still
bypassed it, spreading `selectorResolutionKnobs(row)` into a raw
`resolveSelectorChain` instead of naming the row. That left the "one
interface" claim aspirational: a caller could restate its contract as engine
knobs and nothing would notice.

- `is` non-exists, `get text`/`get attrs`, find's read actions, and the
  covered-selector diagnosis probe now call `resolveSelectorChainWithPolicy`
  with their existing row. Semantics are byte-identical: the knob-backed
  branch of that interface forwards to the same engine call the call sites
  built by hand.
- The façade drops `resolveSelectorChain` and `selectorResolutionKnobs`, so
  no knob-taking resolver is reachable from outside the package and a call
  site cannot re-acquire the knobs even by accident.
  `requireUnique`/`disambiguateAmbiguous` are now named in exactly one
  function, which `resolve-with-policy.ts` and the replay resolver both
  derive through.
- `get` names the two rows it may consume as a type, so pointing it at any
  other ambiguity contract is a compile error.

Tests: selector-read-policy.test.ts pins which row each read command
consumes, end to end, on one ambiguous fixture — the only tree the rows
disagree on. Each assertion was proven red by re-pointing its caller at a
neighbouring row. The knob-consistency check moves into the package beside
the now-private helper. Test call sites that used the raw resolver move to
`resolveRecordedTarget`, the same knobs and the path that actually replays a
recorded chain.

Extracting the failure branch drops `resolveSelectorInteractionTarget` below
the complexity threshold; its `fallow-ignore` waiver is removed (verified
load-bearing before the extraction, unnecessary after).

Closes #1630. Structural stages (occlusion, off-screen, promotion, poll
budget) stay per-caller pipeline code, tracked in #1656.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuKzQWn6WQcMYaAZVvJzdD

* test: observe which node find's row selected, not just that one existed

#1715 review, P2: the find row assertion was only half a pin. `find exists`
returns `found: true` for any resolved node, and the `list` call it leaned on
goes through listFindMatches — a path that consumes no policy row at all. So
repointing findFirstLocatorMatch at `readText` left both assertions green
while selection silently moved from the document-order head to the tiebreak
winner.

Assert through `find get_attrs`, which returns the ref of the node the row
actually selected. Both neighbouring rows are now red: `readText` fails
'@e3' !== '@e2' (the move the old test missed), `readUnique` fails by
refusing the ambiguous screen. `exists` stays as a second, weaker assertion
on the same resolution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuKzQWn6WQcMYaAZVvJzdD

* refactor: route is exists through the matrix, collapse the double match pass

Follow-up tightening on the same seam.

`is exists` reached findSelectorChainMatch directly while the `readAny` row's
own doc claimed to serve "`exists` and find's read-only actions" — true of the
docs, not of the code, which is the unverifiable-claim shape #1656's review
called out. It now names `readAny`, the row it always described. Equivalent by
construction: both take the first alternative with any match under
requireRect: false, and disclose that alternative's count.

That leaves the root façade with no consumer for findSelectorChainMatch, so it
goes the way of resolveSelectorChain — dropped from the string-only façade,
kept on the published ./ast surface. Its façade-twin type SelectorChainMatch
dies with it (fallow caught it).

resolveSelectorChainWithPolicy matched twice on the uniqueness path: once via
resolveSelectorChain, then again to fill matchedNodes. Hoisting the single
list call above the row switch removes that second pass, collapses two
duplicated ambiguous literals into one helper, and drops a `?? [resolution.node]`
fallback that was unreachable — a resolution implies its alternative matched,
so the list is never null there.

While hoisting: the resolved arm's matchedNodes can describe a different
alternative than resolution.selector, because uniqueness skips an ambiguous
alternative to try the next one. Unreachable today (only first-match callers
read it, where both come from one list), and left as-is rather than silently
changed — but the doc claimed "the alternative it came from", so it now says
what is actually true.

Tests: is exists gets a caller-level pin on the shared ambiguous fixture —
passes with matches: 2 where its fail-closed siblings refuse — proven red by
pointing it at readUnique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuKzQWn6WQcMYaAZVvJzdD

* test: discriminate is exists's row by alternative, guard the façade structurally

#1715 review, second regression-validity gap. The `is exists` pin observed
only `pass: true` and `matches: 2` on a fixture whose first alternative was
merely TIEBREAKABLE — so disambiguation succeeded there and reported the same
count first-match would. `readAny`, `readText`, and the pre-migration raw
lookup all produced that, and only the readUnique swap I had checked went
red. One mutation proven is not the same as the row being pinned.

`exists` exposes no node ref, so the row has to be read off WHICH alternative
answered. New fixture: alternative one matches two nodes that are genuinely
indistinguishable (same depth, same area, both on screen) so the tiebreak
declines; alternative two matches exactly one. First-match answers from
alternative one; every uniqueness row skips the undecidable alternative and
answers from alternative two. Asserting the selector now separates them —
readText and readUnique both fail with `id="save-unique"` where
`label="Save"` is expected.

Restoring the raw lookup stays behaviourally invisible, though:
findSelectorChainMatch is equivalent to the readAny row it migrated to, which
is precisely why that migration preserved semantics. No fixture assertion can
catch that revert, so the guard is structural — the façade's export list must
not carry resolveSelectorChain, findSelectorChainMatch, or
selectorResolutionKnobs. Follows the packages/maestro index.test.ts
absence-assertion precedent. Verified red by re-exporting the lookup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuKzQWn6WQcMYaAZVvJzdD

* fix: cover selector routes in device replays

* test: simplify selector replay regression

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-11 07:34:11 +02:00
Michał Pierzchała 05a1d76f2e test: add daemon RPC wire-surface compatibility gate (#1717)
* test: gate daemon RPC wire compatibility against the last released tag (#1432)

ADR 0006 fixes exactly when DAEMON_RPC_PROTOCOL_VERSION must be bumped, and
nothing checked that it was. The runtime guard (readRemoteDaemonHealth) refuses
a mismatched peer, but only fires when someone remembered the bump — a wire
change that skipped it left both sides advertising protocol 2 while parsing
different payloads, which is the failure ADR 0006 exists to prevent.

Local daemons cannot skew (isReusableDaemonInfo takes over on any package
version mismatch). Cross-machine is skewed by design — proxy, cloud/limrun, a
remote macOS host — and ADR 0006 explicitly rules package version out as the
compatibility gate there, so the one boundary where skew is intended was the
one boundary with no gate.

test/wire-compat/surface.ts declares the wire surface grouped by the ADR bullet
each group serves, quoting it, with an `uncovered` note where a bullet is only
partly digestible (the /health and /rpc literals inside http-server.ts stay
reviewer-owned: a moved route 404s at connect time rather than misparsing).
ledger.json records what each declaration hashes to, at which protocol version.

Two gates, split for the same reason the replay-compat corpus splits:
- unit-core holds the ledger to its source and prints the digest to paste;
- Released-Surface Compatibility reads the ledger at the last RELEASED tag and
  requires the drift since then to carry a bump or a compatibleChanges ack.

From one commit a bumped ledger and an unbumped one are both just an edited
file, so only a released baseline can tell them apart. Acks are keyed by the
digest they cover, so one "added an optional field" cannot launder later
changes. Digests ignore comments and formatting; the manifest's closure is
derived from the AST, so a field typed by an unlisted sibling fails rather than
sitting outside the gate.

CI cost: one added job (checkout + toolchain + two node scripts, ~1 min),
mirroring the existing full-history replay-compat job.

* test: close wire-surface overclaim and make the closure fail closed (#1432)

Addresses both review P1s on #1717.

P1 — the manifest materially overclaimed ADR 0006 coverage. It quoted all four
bullets while digesting only the payload TYPES, so the producer and consumer
seams could break a skewed peer without moving a listed digest. Now listed on
both sides of every boundary: JSON-RPC method sets and the projections that
turn each method's params into a DaemonRequest, createRpcError/sendJson/
writeRpcResponseEnvelope, resolveToken and the auth-hook types, upload
preflight/finalize/308 handlers and the resumable ticket shape, artifact route
and download/inventory framing, REST error mapping, and the client's own
payload builder, lease-method mapping, response parser and error projection.
57 -> 117 declarations.

What stays out is now named rather than implied: createDaemonHttpServer's
dispatch wiring and the /health and /rpc literals inside it. Everything it
dispatches WITH is digested individually, and a moved route 404s at connect
time rather than misparsing — the loud failure, not the silent one.

P1 — imported and re-exported payload shapes escaped the closure.
declarationHomes() scanned only the manifest's own files and the walk
continued silently when a name could not be placed, so a listed type could
gain foo?: ImportedShape from a new module and stay green. Resolution is now
explicit and fails closed: relative imports, workspace specifiers (through the
owning package's own exports map, so a re-pointed export cannot drop a type),
and facade re-export chains. Every referenced name must land on a listed
declaration, a waiver with a written reason, a declared external module, or the
TS/Node global set. Fixed two extractor blind spots the walk exposed: a
declaration's own generic parameters and `as const` were being reported as
references.

Planted-red proofs (wire-mutations.test.ts): 13 cases independently mutate
method naming, response serialization, response parsing, auth projection,
upload ticket shape, 308 framing, artifact framing, REST error mapping, and
progress framing, each asserting the digest moves; 3 probes prove the closure
really reaches across a package boundary, a facade re-export, and a plain
relative import. Mutations apply inside the declaration's own span — a
whole-file replace silently hit a sibling sharing the substring, which is how
the first draft of one case passed vacuously.

The largest waiver pair (InternalRequestOptions, CommandFlags) rests on ADR
0006's own additive rule: they reach the peer inside DaemonRequest's untyped
flags/input bags, and the decision says a new flag needs no bump. Digesting
them would fire the gate on every new CLI flag and train reviewers to
rubber-stamp acks.

* test: list the consumer half of the auxiliary HTTP boundaries (#1432)

Addresses the remaining review P1 on #1717. The manifest claimed both sides of
response/upload/artifact framing while listing nothing from upload-client.ts,
daemon-artifacts.ts, or the health consumer in daemon-client-transport.ts, so
those parsers could narrow without moving a listed digest or protocol 2.

Now listed (117 -> 141 declarations):

- /health consumer: RemoteDaemonHealth, readHealthPayload, readDaemonHttpHealth,
  readRemoteDaemonHealth. This is the sharpest of the three — narrowing the
  reader or the comparison disables the very refusal ADR 0006 exists to
  guarantee, and nothing else in the repo would notice.
- /upload consumer: UploadResponse, UploadPreflightResponse, UploadPreflightResult,
  parseUploadPreflightResult, requestUploadPreflight, uploadDirectArtifact,
  tryDirectUploadWithResume, shouldRetryDirectUpload, finalizeDirectUpload,
  uploadLegacyArtifact, ARTIFACT_HASH_ALGORITHM, isStringRecord, and
  PreparedUploadArtifact — whose sha256/sizeBytes/fileName/artifactType/
  contentType fields ARE the preflight body the daemon parses.
- /artifacts/* consumer: DaemonArtifactEndpoint, buildDaemonArtifactUrl,
  isRemoteDaemon, DownloadRemoteArtifactParams, downloadRemoteArtifact,
  materializeRemoteArtifacts, resolveMaterializedArtifactPath.

Running the closure fail-closed over the new files surfaced three more stops,
each decided rather than skipped: PreparedUploadArtifact listed (it is payload),
UploadProgressSink waived (client-local rendering, never leaves the process),
and src/daemon/types.ts#DaemonArtifact waived as a re-export alias of the listed
kernel type, matching its DaemonRequest/DaemonResponse siblings.

10 more planted-red mutations cover the new seams: health version-read and
mismatch-refusal defeated, RemoteDaemonHealth field dropped, preflight parser
narrowed, preflight/legacy response shapes narrowed, finalize body key renamed,
ticket field renamed, artifact tenant header dropped, artifact URL moved. A
fourth closure probe proves the upload-consumer files are genuinely reached by
the walk rather than merely listed. 22 -> 33 tests.

The README now states the coverage as a producer/consumer table per boundary,
so the claim is checkable at a glance instead of asserted in prose.

* test: list the client half of the resumable 308 contract (#1432)

Addresses the third review P1 on #1717. Listing the daemon's
handleResumableUpload proved it still PRODUCES 308; nothing proved the client
still CONSUMES the released one. src/remote/upload-stream.ts owns that half and
was entirely outside the manifest, so a newer client could stop accepting
`upload-offset`, change how it reads `Range: bytes=0-N`, or emit a different
resumed `Content-Range` without moving one of the 141 listed digests.

Now listed (141 -> 151): UploadStreamResponse, streamFileToHttpRequest,
streamFileToHttpRequestAttempt, buildUploadRequestHeaders, isUploadResumeStatus,
isUploadRedirectStatus, parseUploadResumeOffset, parseNonNegativeIntegerHeader,
firstHeaderValue, MAX_UPLOAD_REDIRECTS.

streamFileToHttpRequestAttempt is listed despite its size, unlike
createDaemonHttpServer which stays in `uncovered`. The distinction is stated at
the declaration: the HTTP server only dispatches to handlers that are each
digested, while the attempt loop IS the resume state machine — it decides
whether a 308 continues the upload and what the next request carries, so its
sequencing alone can break a released daemon while every helper keeps its digest.

6 new planted-red mutations prove the client half moves the ledger: a dropped
`upload-offset` fallback, narrowed Range parsing, a changed resumed
Content-Range, 308 no longer treated as continue, a narrowed UploadStreamResponse,
and dropped header-value coercion. 33 -> 39 tests.

Closure fail-closed surfaced two more stops: UploadStreamProgressOptions waived
(local byte-progress rendering) and URL/URLSearchParams added to the global set.

README now carries a `/upload` resume row in the producer/consumer table, and
names the pattern behind three rounds of review: the coverage sentence kept
getting written ahead of the coverage, so the table and the `uncovered` notes
are the claims to trust — they are checkable against surface.ts, prose is not.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-10 20:52:29 +02:00
Michał Pierzchała cdc754e6ed perf: speed up iOS agent recovery and streamline CLI guidance (#1700)
* Avoid interactive children in parent taps

* docs: streamline no-skill CLI help

* perf: recover faster from sparse iOS trees

* fix: preserve selector context for blocked parent taps

* fix: preserve coordinate text-entry focus

* fix: preserve thin parent touch targets

* fix: fail closed for unscoped iOS typing

* test: isolate replay lock fixture

* test: share node integration process
2026-08-10 20:43:01 +02:00
Michał Pierzchała b15c502318 refactor: extract platform network runtime (#1702)
* refactor: extract platform network runtime

* fix: preserve platform network recovery routes

* test: guard network parser placement
2026-08-10 17:58:42 +02:00
Michał Pierzchała b1ed5353d1 refactor: extract platform log runtime (#1701)
* refactor: extract platform log runtime

* fix: clear terminal app log recovery markers

* fix: preserve scoped app log tooling

* fix: preserve app log cancellation

* fix: handle large changed coverage diffs

* fix: harden Limrun runtime identity

* refactor: tighten platform log runtime

* fix: close app log trust gaps

* fix: accept canonical session path aliases

* refactor: extract durable capture kit

* fix: refresh retained log marker admission

* fix: rotate app logs after process relaunch
2026-08-10 17:58:42 +02:00
Michał Pierzchała 057f0e6eb1 fix(android): shell-quote free-text arguments reaching the device shell (#1645)
Text entry (input text) and clipboard write (cmd clipboard set text) now
quote their free-form text argument with the same shellQuoteIfNeeded
helper app-lifecycle.ts already uses for deep-link URLs and launch
arguments, and app-lifecycle.ts's local duplicate of that helper is
retired in favor of the shared one. Multi-word clipboard writes also now
arrive at the device as a single argument instead of being re-tokenized
into separate ones.

Updates the provider-scenario test harness's scripted clipboard-state
simulator to unwrap shell quoting the same way a device shell does, so
it keeps modelling what the device actually receives.
2026-08-10 13:47:30 +02:00
Michał Pierzchała 13cc90ffc6 fix: harden Android snapshot and fill reliability (#1708)
* fix: harden Android automation reliability

* test: isolate CLI flush integration

* test: close Android review gaps

* test: register CLI transport fixture

* test: consolidate CLI subprocess fixture
2026-08-10 13:10:18 +02:00
Michał Pierzchała c06bed9f77 refactor: extract platform device inventory runtime (#1699)
* refactor: extract platform inventory runtime

* fix: preserve scoped Apple inventory tooling

* fix: preserve Apple tool cancellation

* refactor: tighten platform inventory boundaries
2026-08-10 12:51:59 +02:00
Michał Pierzchała e18a183ac9 fix: harden artifact ingestion boundaries (#1692)
* fix: harden artifact ingestion boundaries

* fix: bound archive inspection and upload expiry

* fix: preserve upload preflight expiry
2026-08-09 09:56:34 +02:00
Michał Pierzchała 6c0fcb64a1 fix: reject distinct ambiguous mutation targets (#1667)
* fix: reject distinct ambiguous mutation targets

* fix(ios): scope the raw-match rejection to mutating dispatches

`RunnerTests+Interaction.findElement` applied the new fail-closed
classification to `querySelector` as well as press/type, because the read
call site takes the default `allowNonHittableFallback: false`. With one
visible/hittable match and one non-hittable same-selector duplicate the
query started returning AMBIGUOUS_MATCH where it previously selected the
hittable element, and `queryDirectIosSelectorOrFallback` preserves that
error for read callers — so `get`, `is`, and `wait` surfaced an error
instead of their prior answer.

`classifyDirectSelectorCandidates` now takes a `rawMatchPolicy`. Mutations
keep `.rejectDistinctMatches` (the default, so no mutation call site
changes); `queryElement` passes `.preferHittableMatch`, restoring the
prior read rule: prefer the single hittable match, ambiguous only when
hittable matches compete, and never adopt the Maestro coordinate fallback.
The Maestro expected-point path is untouched.

Covers the one-hittable + one-non-hittable read, competing hittable reads,
and the non-hittable-only read. ADR 0011's amendment now states the scope.

* test(ios): execute selector read ambiguity regression

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-08 08:57:32 +02:00
Michał Pierzchała e6b4fa2810 fix: isolate concurrent remote connections (#1675)
* fix: isolate concurrent remote connections

* refactor: harden remote connection state

* fix(cli): scope every emitted connect command to its own session

`scopeNextSteps` only reached `ConnectReadiness.nextSteps`, so two
command-bearing outputs still shipped unscoped:

- `providerArtifactNotes()` emitted `agent-device artifacts --json` as a
  prose note, which never passes through that helper.
- `buildDeferredRuntimeNotice()` emitted `agent-device metro prepare
  --remote-config <path>` independently in connection.ts.

On the shared-host concurrency path this branch fixes, following either
one resolves against the host-global active connection, so the artifacts
instruction can return another job's provider video and log URLs (#1659).

Both producers now take the connection state and format through one
exported `scopeCommand` helper, which is the single place a suggested
command is bound to its originating session. The metro config path is
shell-quoted alongside the session name.

Coverage: the BrowserStack route asserts human and JSON shapes carry one
`--session` per suggested command and that each emitted session resolves
back through `readRemoteConnectionState` to the connection that printed
it; `connection status` pins the scoped deferred-metro `nextStep`.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-08 07:54:43 +02:00
Michał Pierzchała e14c9d8d7b fix(cloud): unblock iOS snapshot and gate cloud fill on text-entry focus (#1658) (#1666)
* fix(cloud): unblock iOS snapshot and gate cloud fill on text-entry focus (#1658)

Two bugs isolated to the cloud-webdriver iOS path.

`snapshot`/`diff` refused every capture on a live BrowserStack session with
SESSION_NOT_FOUND, instantly and without a driver round trip. 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 it now applies to local Apple targets only. The session
was empty in the first place because the provider open path skips local app
resolution wholesale — no simctl/devicectl reaches a hosted device — and
dropped an explicitly spelled bundle id along with it. A dotted, non-deep-link
target is the bundle id under the same convention resolveIosApp applies
locally, so a cloud `open com.example.app` now records it.

`fill` tapped and sent its keys in back-to-back requests. A WebView input —
an OAuth page in a Safari view controller — takes first responder
asynchronously, so the keys landed with nothing focused while the command
still 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 cloud interactor now waits on the same signal the Apple runner uses, the
software keyboard going from hidden to shown after its tap, and discloses what
it observed as `textEntryReadiness` so a fill with no witness cannot pass for
a filled field. Where keyboard visibility cannot witness the focus move —
back-to-back fills into one form, the shape that failed most often — it spends
the runner's full readiness budget rather than racing the app with a short
settle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Zjbzf7HdziX9SzFpNP7WW

* fix(cloud): let a new bundle-id open replace the tracked cloud iOS app

Adopting an explicitly spelled bundle id on a provider-backed open (the
fix that makes snapshot/diff work at all) also made a previously dead
precedence rule live: the provider branch returned currentAppBundleId
first, so once a first open had populated it, `open com.a` followed by
`open com.b` left the session still reporting com.a to every
appBundleId-gated command.

The local path does the opposite, and is the convention this branch is
meant to mirror: resolveIosApp returns a dotted target unchanged and
never consults the session's current app. Only its deep-link branches
prefer the tracked id. Flip the provider branch to match — an explicit
bundle-id target wins, and everything the branch cannot name (deep
links, display names, bare open) still falls back to the tracked id.

* fix(cloud): fail a witness-less cloud fill instead of reporting it filled

Review follow-ups on #1658.

`not-observed` was still a success: it sent the keys and answered "Filled N
chars", and nothing renders `textEntryReadiness` in default CLI output — so the
exact silent success this branch exists to remove survived whenever focus never
happened. A tap that raises no keyboard now fails with
`text_entry_focus_not_observed` and sends no keys, leaving the field untouched
rather than half-written, and the readiness vocabulary keeps only outcomes that
describe a fill that did type.

The readiness budget was advertised but not enforced at the request boundary:
each keyboard probe inherited the client's 30s default, so one hung probe could
hold a 2s wait for far longer. Probes now carry their own bound, threaded
through the client as a per-request timeout override.

The probe also swallowed every error as "this driver cannot answer", which
degraded a dead session, an auth rejection, or a grid outage into a blind text
entry. Only a positively classified unimplemented route counts as unsupported
now — classified on the W3C error code rather than the status, since `unknown
command` and `invalid session id` share HTTP 404 — and everything else
propagates.

The provider scenario proved request ordering against a stub that always
accepted keys. Its fake now models the device: focus lands a beat after the
tap, and keys arriving while the keyboard is down are accepted and dropped,
exactly as an unfocused field does. The tests assert the field's own value, and
both go red against the pre-fix `fill`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Zjbzf7HdziX9SzFpNP7WW

* fix(cloud): witness the tapped field's focus before a cloud fill types

Review of cc23f2b found two ways a fill could still report success
without evidence that OUR tap focused the field it was aimed at.

P1. `settled-keyboard-up` and `settled-unknown` both typed and returned
normal success. Keyboard visibility can only witness that *a* field took
focus, never *which*: filling a second field in an already-open form
reads the same before and after, so a missed tap left the first field
focused and `POST /keys` — which the driver routes to whatever holds
first responder — appended to it while every request returned 200.

Failing those closed outright would have broken ordinary multi-field
form fills, which do work: a live AWS Device Farm run types both fields
of a WebView login correctly. So witness focus properly instead. W3C
`GET /element/active` answers the question keyboard visibility cannot —
is the thing focused now the thing I tapped — and answers it whether or
not the keyboard was already up. That becomes the primary signal
(`focused-element`); the keyboard transition stays as the fallback for
drivers without the route, and a keyboard already up on such a driver
now refuses rather than typing.

The test is identity, not geometry. Containment of the tap point looks
like the obvious rule and is wrong: focusing a field can re-lay it out.
On a live iPhone 16, tapping Safari's collapsed address bar expands it
into a taller field that no longer covers the tapped point, and a
containment-only rule refused a fill that plainly worked. So a tap that
MOVES focus counts, with containment as the second half of the test —
re-filling the already-focused field moves nothing, and only geometry
tells that from a tap that missed. Both readings are taken before the
tap, since each is evidence only as a change.

P2. The 2s budget bounded the loop but not the calls inside it: every
probe got a fixed 1500ms, so one begun near the deadline finished well
past it. Both the probe timeout and the sleep are now capped by the
remaining budget.

Also fixes a related escape the review did not name: the poll loop had
no catch, so one transient grid error aborted a fill the next poll would
have satisfied. Probe failures are now tolerated within the budget, but
a budget that expires without a single answered probe rethrows, so a
dead session surfaces as itself rather than as "the tap missed".

The provider scenario gains the two-field case the review asked for: it
begins keyboard-up with the email field focused, misses the password
tap, and asserts no keys reach the email field.

Verified on AWS Device Farm iPhone 16 / iOS 18.0 at this exact tree:
address bar (the re-layout case) and both WebView login fields all
report `focused-element`, the second with the keyboard already up, and
the device reads back `tomsmith` and a 20-character password.

* fix(cloud): refuse a cloud fill no focus probe can witness, and bound the composite probe

Two blockers from the review of 3a9aceb9.

P1. `settled-unknown` was the last path that typed without evidence: when
both the active-element and keyboard routes are positively unsupported,
`fill` settled 350ms, typed, and returned ordinary success. Nothing
renders `textEntryReadiness`, so that reached a caller looking exactly
like a fill that worked — the same silent false success #1658 is about,
just narrowed to one branch. It now refuses with a distinct reason,
`text_entry_focus_unobservable`: nothing is wrong with the target, the
driver simply cannot answer, so the caller's next move differs from a
missed tap and the hint names it — `press` then `type` stays the
deliberate way to enter text unwitnessed.

`CLOUD_TEXT_ENTRY_READINESS` is now `focused-element` and
`keyboard-shown` only. Every value describes a fill that witnessed focus
before sending a key; there is deliberately no value for typing blind.

P2. `activeElement(timeoutMs)` bounded each of its two sequential
requests by the full timeout rather than bounding the operation, so a
probe handed the 1.5s left of a 2s readiness deadline could spend ~3s
across `/element/active` and `/element/{id}/rect` and overrun the
deadline it was derived from. It now derives one deadline at entry and
gives the second request only what the first left, floored at zero so an
already-spent budget aborts immediately instead of falling back to the
client default.

The regression pins elapsed transport time across both calls, which is
what the defect is made of: the rect request answers only its own abort,
so the time it was allowed to run IS the budget it was handed. It
measures ~202ms of a shared 200ms budget before the fix and ~120ms
after.

Also updates the generic Cloud WebDriver facade scenario, whose stub
answered `{value: null}` to everything and so read as a driver with
neither route. It now answers the two focus probes, since that scenario
exercises facade wiring rather than text-entry semantics — those live in
cloud-webdriver-ios-text-entry.test.ts, which models focus properly.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-07 15:52:24 +02:00
Michał Pierzchała b19ee116b5 fix(remote): stop persisting the daemon bearer token, and authenticate forced-reconnect release correctly (#1648)
* fix(remote): stop persisting the daemon bearer token in connection state

ADR 0007 requires generated connection profiles to strip daemon and Metro
bearer tokens; only the Metro half was honored. `connect` was writing the
daemon bearer token into the 0600 connection-state file, and every later
command read it back out.

Stop writing `authToken` into `RemoteConnectionState['daemon']` and resolve
it at each reader from the existing flag -> environment
(AGENT_DEVICE_DAEMON_AUTH_TOKEN) -> remote-config-profile chain instead,
matching src/cli/auth-session.ts's precedence.

Behavior change: a user who ran `connect --daemon-auth-token <value>` and
relied on later commands picking the token back up from the state file will
now get an auth failure. They must export AGENT_DEVICE_DAEMON_AUTH_TOKEN,
set daemonAuthToken in their remote config, or pass --daemon-auth-token on
each command. website/docs/docs/remote-proxy.md is updated to show the
supported env-var workflow.

* fix(remote): authenticate forced-reconnect lease release with the previous endpoint's own credential

connect --force released the previous connection's lease using the new
connection's ambient daemonAuthToken instead of the previous endpoint's own
credential, and swallowed the resulting auth failure — silently orphaning the
old lease when replacing a connection with a differently-authenticated one.

Resolve the release token from the previous connection's own remote-config
profile first, fall back to the ambient token only when the two connections
share the same daemon endpoint, and otherwise skip the release and surface an
actionable notice (tenant, run id, lease id, endpoint) through the existing
connect notice channel instead of hiding the failure.

* fix(remote): stop merging ambient env defaults into the previous lease's own token

resolvePreviousOwnDaemonAuthToken read the previous connection's profile
through resolveRemoteConfigProfile, which folds AGENT_DEVICE_DAEMON_AUTH_TOKEN
(and other env defaults) into the result. When the previous config file
declared no token and the new connection's credential came from that same
global env var, it was misclassified as belonging to the previous endpoint
and sent there on forced-reconnect release — recreating the credential leak
the prior fix was meant to close, just via env instead of --daemon-auth-token.

Read the previous profile with the new readRemoteConfigFile (a provenance-
preserving, file-only load with no ambient env/CLI merging), so only a token
the previous config file itself declares can satisfy rule 1. Rules 2 and 3
are unchanged.

* fix(remote): verify the previous config file still speaks for its endpoint

Rule 1 reads the previous connection's own config file to recover a credential
that provably belongs to the previous endpoint. It re-read
`previous.remoteConfigPath` and trusted whatever token that file holds *now* —
but a config path is routinely reused, so "connect to A from ./remote.json,
re-point ./remote.json at B, connect --force" classified B's token as A's own
and sent it to A during lease release. Same cross-endpoint leak the env-merge
fix closed, arriving through the file instead of the environment.

The file must now still vouch for the previous endpoint, by either of two
independent facts: its bytes still hash to the `remoteConfigHash` recorded at
connect time (so it is literally the declaration that stood up the previous
connection), or — if it changed — it still declares the same daemon base URL.
The second is what keeps an ordinary credential rotation releasing its lease
instead of orphaning one; endpoint equality, not the fact of an edit, is what
separates rotation from re-pointing.

Endpoint comparison runs both sides through `buildRemoteConnectionDaemonState`,
the same normalizer that produced the stored `daemon.baseUrl`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Rva4YGtSCAKJqH5PbpcCU

* fix(remote): bind previous config token to its endpoint

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-06 21:32:02 +02:00
Michał Pierzchała 3937036e5e feat: support --settle on scroll and back (#1638) (#1650)
* feat: support --settle on scroll and back (#1638)

Scroll-then-observe and back-then-observe are legitimate agent pairs, but
the post-action observation registry never grew past the touch commands, so
`--settle` on either was rejected with INVALID_ARGS — burning a tool call
each in AppControlBench's bsky-16.

Both commands now carry the `settle` descriptor trait, and every surface
derives from it rather than a hand list: CLI allowed flags, MCP/SDK input
fields, the flag-sourced timeout envelope, and MCP ref-pinning. The CLI
flag/metadata helpers moved out of the interaction family into
post-action-observation-grammar.ts (back is a system command), and
SETTLE_REF_ISSUING_TOOLS became a derivation — a hand list would have
silently stopped pinning the new commands' refs.

settleAfterInteraction and the new settleObservationCommand are two entry
points over one engine: same loop, storage, hints, and diff bounds, with the
target-less path supplying its own baseline and no proximity point. The
daemon reaches that command through the runtime surface, never by importing
`commands/` (R2) — the same seam the touch handlers use for press/fill —
and generic-settle.ts is loaded through a lazy `await import` returning a
closure, so the interaction runtime subgraph stays out of this dispatcher's
static graph (a static edge folded ~18 files into the daemon-server type
cycle; R10 caught it).

Both of generic-settle's orderings are load-bearing and tested: the baseline
is frozen before dispatch (and before the Android dialog preflight), and the
observation runs after markDeferredInteractionOutcome so settle's first
capture folds in the #1542 stabilization rather than racing it. The ADR 0014
"a settled diff publishes refs" rule moved to settle-ref-issuance.ts, shared
by both routes.

One divergence is deliberate: scroll/back resolve no element, so the diff
baseline is the session's STORED pre-action tree — "settled tree vs the
last tree you observed" — not press's freshly resolved pre-action capture.

Both commands also switch to preserve-daemon on timeout, which changes the
non-settle path too: with --settle their dominant hang mode is now a wedged
accessibility bridge, and a timed-out capture must not reset the daemon and
lose every session (#1105). The reviewed-set gate records it.

Live-validated on an iOS 26.2 simulator (Settings): scroll --settle settled
in 1786ms with a +6/-6 diff carrying fresh refs; back --settle in 771ms with
+15/-6. Alternating cost runs, one call vs the pair it replaces:
scroll 2.9-3.0s vs 5.3-5.6s, back 3.1-3.2s vs 4.7-5.1s. Those include the
#1627 deep-capture extension.

* fix: render settled-diff refs paste-ready in CLI output

A settled diff activates a PARTIAL ref frame (ADR 0014), which admits only
the pinned `@eN~s<gen>` form of the refs it issued. The unchanged-interactive
tail already rendered that way, but the diff's own added lines rendered the
bare `@eN` embedded in the snapshot line — so a CLI caller who copied the
ref the diff just handed them got `plain_ref_requires_complete_frame` and had
to append the generation by hand.

Added lines now render pinned when the response carries `refsGeneration`,
exactly like the tail. Removed lines render verbatim: they name elements that
just left the screen, and `SettleDiffLine` never gives them a ref.

This is not new to scroll/back — press/click/fill/longpress had the same gap
since #1101. MCP was never affected: its ref-pin store rewrites plain refs on
the way in, which is why the model never sees a suffix.

Live: `scroll down --settle` now emits `+ @e14~s218078 [cell] "Game Center"`,
and `press @e14~s218078` copied straight out of that line taps successfully.

* test: record the pinned-diff-ref bytes in the output-economy baseline

Rendering added diff-line refs pinned costs 8 bytes in the two settle CLI
text samples (two `~s<gen>` suffixes). The output-economy baseline is the
tripwire for exactly this, so the increase takes an explicit reviewed waiver
rather than a silent baseline bump — the same one the settled TAIL's pins
already carry, for the same ADR 0014 reason.

Only `bytes` moves: lines, refs, hints, and shape are unchanged, which is the
evidence that this is a suffix on existing refs and not a new payload.

Caught by CI, not locally: `pnpm test:unit` runs unit-core and
subprocess-stub only, while the Coverage lane runs every vitest project.

* test: prove the generic settle degrades when its runtime cannot be built

`createGenericSettleRuntime` catches and returns undefined so an observation
that cannot even start does not fail an action that already succeeded. That
was a claim in a docstring with nothing behind it — the one changed line the
coverage gate reported uncovered (95/96).

The test puts the session in the state the catch exists for: the router
handed us a session that is no longer in the store, so building the settle
runtime throws SESSION_NOT_FOUND. The response keeps its scroll result and
simply carries no settle payload. Removing the try/catch fails it.

* build: teach fallow that vi.mock reaches pinOwnProcessStartTime dynamically

Not from this PR: #1642 added `pinOwnProcessStartTime` on main, and its three
consumers reach it the only way a Vitest module mock can —
`vi.mock(path, async (importOriginal) => (await import('...')).pinOwnProcessStartTime(...))`.
Dependency analysis cannot follow that dynamic import to a consumer, so the
export reads as dead the moment any PR pulls that file into its audit scope.
This PR is the one that did.

The entry records the consumers by path and the reason, matching the
daemon route-handler entry directly above it, which exists for the same
dynamic-`import()` limitation.

* refactor: adopt the best of the parallel #1653 implementation

Two sessions independently built #1638 (PR #1650 and PR #1653) and converged
on the same architecture — trait in the registry, one engine with two entry
points, runtime-command seam, lazy import, preserve-daemon, stored-baseline
honesty. #1650 continues; this folds in what #1653 did better:

- The agent-facing help core loop (cli-help.ts) now names scroll and back as
  settle-capable. Without this, the benchmarked closed-grammar help line kept
  instructing agents that --settle is only for press/click/fill/longpress —
  actively steering the AppControlBench models away from what #1638 shipped.
- issueSettleRefs moves into session-snapshot.ts, beside the partial-frame
  primitive it wraps, deleting the single-function settle-ref-issuance module.
- Their seam tests: back reader→writer settle plumbing, back CLI settle
  rendering, and a trait-less generic command (home) ignoring a stray settle
  flag rather than observing or rejecting.

What #1650 had that #1653 lacked, for the record: the SETTLE_REF_ISSUING_TOOLS
registry derivation (without it, MCP never pins a scroll/back settle diff's
refs and the partial frame rejects every follow-up), BackCommandResult.settle
in contracts, back's MCP output schema, paste-ready pinned diff refs, and the
docs/changelog/baseline surfaces.

* bench: help-conformance case for settled scroll-to-find planning

The #1638 extension of the closed --settle grammar to scroll/back is the
feature's entire payoff — collapsing scroll-then-observe into one call — and
the closed command list is an enumerated N whose enumerator is this bench.
The regex over the help text proves the sentence exists; this case checks
whether a model plans differently because of it.

One focused case, deliberately not coached: a pinned visible-first snapshot
(rendered by formatSnapshotText, pinned by the sample-producers gate) whose
wanted row is summarized off-screen with no ref anywhere in the output. The
tempting pre-#1638 plan is `scroll` plus a separate `snapshot -i`; acceptance
is the single settled call. Scoring was verified against eight plan shapes in
both directions before recording.

Model-backed record (claude-haiku-4-5, 3 trials, current help): 0/3 — but the
decomposition is the finding. Settle eligibility GENERALIZED (3/3 trials put
--settle on scroll unprompted; the mutation-suffix framing concern did not
materialize) and the two-call habit is residual (1/3). All three trials failed
on `scroll @e3 down --settle` — the pre-existing #1366 scroll-takes-no-target
confusion, which the live CLI recovers with a dedicated hint but a single-shot
bench cannot. The recorded gap is therefore a first-30 doc gap (nothing
teaches that scroll takes no target), not a settle-eligibility gap; tuning the
case until it passes would just delete the evidence.
2026-08-06 20:04:16 +02:00
Michał Pierzchała 8030fc10d6 fix: make Android record stop survive static screens, slow moov finalization, and dead-pid recovery (#1651)
* fix: make Android record stop survive static screens, slow moov finalization, and dead-pid recovery

Three live-reproduced defects on a loaded Pixel_7_CI emulator shared the
"pulled file is not a playable MP4" / "manifest could not be verified"
symptom family:

- The Swift video validator required duration > 0, permanently rejecting
  valid single-frame recordings of fully static screens (AVFoundation
  reports their duration as 0). Whether a run passed depended on whether
  anything — even the status-bar clock — changed during the window.
- screenrecord finalizes by patching a front-reserved moov in place, so
  the remote file size never changes; the copy path now re-pulls with
  escalating delays (750/1500/3000ms) and detects finalization from the
  pulled bytes via a mandatory ftyp+moov container sniff, which also
  preserves the truncation detection the duration check provided by
  accident. The local waitForStableFile call is gone: a pull is complete
  when adb returns.
- toybox `ps -p <missing-pid>` exits 1 with empty output — the normal
  pid-gone signature — but the recovery liveness probe read every
  non-zero exit as an uncertain adb failure, making a finished recording
  behind a live-status manifest unrecoverable forever. Empty-output
  failures now corroborate via the full process list: healthy listing
  with the pid absent recovers the finished recording; listing failure
  stays conservatively uncertain (a stale verdict deletes the manifest,
  so transport health is proven first). Transport failures keep stderr
  and exec-layer timeouts throw, which is what makes the empty-output
  signature safe to trust.

Provider-scenario coverage: in-place same-size finalization landing past
the first retry, and dead-pid recovery on a responsive device — both
fail on the previous implementation with the live-observed errors.

* refactor: extract Android liveness probe and split recording scenarios per file-shape rule

record-trace-android-recovery.ts (763 lines) and android-recording.test.ts
(1,764 lines) were both past the 500-LOC extraction tripwire. The
screenrecord liveness/probe concept this PR modified now lives in
record-trace-android-liveness.ts, and the new scenarios moved to test
files mirroring the source modules they cover
(record-trace-android-copy.test.ts, record-trace-android-liveness.test.ts)
with shared scenario plumbing in the android-recording-fixtures.ts
sibling. android-recording.test.ts shrinks to 1,476 lines — below its
pre-PR size.
2026-08-06 17:38:10 +02:00
Michał Pierzchała d5f11f6e2f refactor: import package types directly — no internal re-export laundering (#1640)
* refactor: import package types directly instead of re-exporting from internal modules

Post-#1636 review feedback: internal src modules were re-exporting package
types (export type { X } from '@agent-device/...'), giving one declaration
several import paths and hiding its provenance. New rule applied repo-wide:
internal modules import directly from the owning package; only published
entry surfaces (src/sdk/* entries, client-types, finders, metro composition,
remote-config-schema) may re-export.

Eleven internal re-exports removed and ~110 import sites redirected to the
packages, the big two being CommandFlags (core/dispatch chain, 39 sites) and
SessionAction (daemon/types.ts, 19 sites). Two were already dead
(RefFrameEffect via daemon-command-registry, DiffSnapshotCommandResult via
capture/runtime/snapshot). Entry-surface chains now re-export from the
package rather than laundering through a second internal module
(client-types/client-metro MetroBridgeScope).

Side effect: the R9 type cycle shrinks again, 49 -> 47 (daemon-server
19 -> 17); ceilings lowered to match.

* refactor: drop command-schema's CliFlags re-export (#1640 review P2)

The one consumer (cli/parser/args.ts, a multi-line import the sweep's
single-line scan missed) now imports CliFlags from contracts/command;
FlagDefinition/FlagKey stay — they are src-declared types, not package
laundering.
2026-08-06 15:22:51 +02:00
Szymon Dziedzic 3e4828d68d feat: add scale-only screenshot sizing (#1617)
* feat: add scale-only screenshot sizing

* fix: refuse retired --max-size inputs on every released surface

Released sizing inputs must fail closed with migration guidance instead of
silently producing native-size artifacts:

- contracts: RETIRED_SCREENSHOT_MAX_SIZE declaration + SCREENSHOT_SCALE_LIMITS
  as the single source for the scale bounds and migration messages
- .ad parser: released 'screenshot ... --max-size N' and 'record start ...
  --max-size N' lines now refuse at parse time (frozen replay-compat witnesses)
- daemon: screenshot rejects old-client screenshotMaxSize like recording does;
  the recording guard now shares the same contract data
- Node client: screenshot/record daemon writers refuse the removed { maxSize }
  option before transport
- CLI: --max-size unknown-flag error carries the migration guidance
- config/env: stale screenshotMaxSize config keys and the retired
  AGENT_DEVICE_SCREENSHOT_MAX_SIZE env var are refused for sizing commands
  (other commands keep working)

Quality: numberField now reuses the canonical readOptionalNumber contract
helper (AppError bounds instead of plain Error); png-resize inlines one-use
wrappers and restores the worker-thread rationale; docs typo fixed.

* test: drop retired maxSize entries from the MCP undocumented-input allowlist

* fix: refuse retired maxSize at the MCP field-projection seam + release-provenance corpus witnesses

- readFieldInput silently dropped undeclared keys before the daemon writers
  could refuse them, so an MCP call carrying { maxSize } reached transport and
  returned native-size success. New retiredField() combinator declares the
  removed key in the field map: the projection seam refuses it with the
  canonical migration message and the JSON schema no longer advertises it.
  Real-route MCP executor regressions cover screenshot and record.
- replay-compat corpus: derived v0.20.5 witnesses for the released screenshot
  and record --max-size forms (SHA-256 pinned, new retired-capture-size
  coverage surface) so check:replay-compat proves the shipped syntax refuses
  with migration guidance instead of degrading silently.

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-08-06 15:11:00 +02:00
Michał Pierzchała ee473b6adc refactor(daemon): give the Maestro fallback and ambiguous-match details real types (#1612)
Three places smuggled structured data through untyped bags and re-read it
with runtime guards. Each gets an explicit typed boundary.

A. The resolution-suppression rule was encoded twice in
   interaction-touch-response.ts — a spread ternary in the runner-payload
   branch and an unconditional destructure used conditionally in the runtime
   branch, with the ADR 0012 rationale living on only one source variant.
   Both branches now read one `suppressesResolutionDisclosure(source)`
   predicate through one `applyResolutionDisclosurePolicy` helper, where the
   reason is stated once. The union field is renamed
   `maestroCoordinateFallbackDispatched` (the dispatch path that ran) and
   hoisted into a shared base. handleFillCommand's two-arm interactor.fill
   call collapses to one.

B. `Interactor.type` narrows from `Record<string, unknown> | void` to
   `TypeTextBackendResult | void`; the Apple runner boundary is the single
   place the wire payload becomes that type. `maestroFallbackDetails` returns
   a typed `{ used, extra }` instead of a bag both call sites re-read.

C. `details.candidates` meant two incompatible things. The device-domain
   resolvers now key their list `devices`, so the shared renderer drops its
   shape-disambiguation guards and the device list actually renders.
2026-08-05 14:32:25 +02:00
Michał Pierzchała 9fc266351f fix: stabilize Replay Nightly fixture boundaries (#1610)
* fix: stabilize replay nightly fixture boundaries

* test: stabilize exit flush integration coverage

* chore: drop the deleted exit-naive fixture from fallow's entry list (#1610 review P3)
2026-08-05 12:43:55 +02:00
Thiago Brezinski a13a6832ee feat: add selector-targeted drag gestures (#1567)
* feat: add selector-targeted drag gestures

* fix: address drag gesture review feedback

* fix: satisfy drag review quality gates

* fix(android): lower drag trajectories piecewise

* test(replay): validate drag fixture selectors

* fix(ios): ignore full-viewport chrome containers

* test(drag): prove destination on live devices
2026-08-05 12:37:02 +02:00
Michał Pierzchała 611858103e fix(ios): harden Bluesky-class interaction reliability (#1588)
* fix: type into focused iOS inputs without AX

* fix: fill AX-hostile iOS text inputs

* fix: keep scrolling containers from stealing taps

* fix: stop agents after explicit task success

* chore: format benchmark guidance

* fix(ios): preserve fill semantics across fast paths

* test: retire direct selector fill expectations

* test: assert runtime selector fill evidence

* fix(ios): preserve verified and Maestro fill paths

* refactor(ios): isolate synthesized text entry

* fix(client): preserve open diagnostic paths

* fix(ios): expose structured text entry route

* fix(packaging): strip text entry policy tests
2026-08-05 08:02:44 +02:00
Michał Pierzchała 65450dd3a7 fix: flush stdout/stderr before every CLI process.exit() (#1596) (#1603)
* fix: flush stdout/stderr before every CLI process.exit() (#1596)

Node only flushes process.stdout/stderr synchronously to a file or TTY;
on a pipe (the normal condition for this CLI when driven as a
subprocess) a write queued right before process.exit() can be silently
dropped. handleRunCliFailure's --debug daemon-log-tail dump made this
reachable from the exact path that renders a SESSION_NOT_FOUND error
right after a daemon replace, matching field reports of the driving
process going silent immediately after "Replacing daemon ... unreachable"
plus the SESSION_NOT_FOUND error.

Add exitAfterFlush() and route every process.exit() in src/cli.ts and
src/bin.ts through it, so a piped caller always receives the full
structured error (with its "run open first" hint) before the process
terminates. Also bound the --debug log-tail dump to a byte cap instead
of an unbounded 200 lines.

Verified directly against the real CLI (piped subprocess, pre-fix vs
post-fix): a live daemon with a seeded >64KB log truncates its --debug
error output before the fix and delivers it in full after.

* fix: satisfy CI gates on #1596 (format, fallow, coverage)

- oxfmt formatting on the new integration test file.
- Register the two exit-flush regression fixtures (support/exit-naive.ts,
  support/exit-after-flush.ts) as fallow entry points: they're run as real
  subprocesses via a string path (runCmdSync), which fallow's static
  dependency analysis can't follow, same as the existing
  test/contention-retry-fixtures/* entries. exit-payload.ts becomes
  reachable transitively through their static imports. Also switched the
  integration test's local PAYLOAD_MARKER duplicate to import the one
  fallow flagged as unused from exit-payload.ts.
- Added real unit coverage for the new exitAfterFlush code paths, since
  node --test integration files aren't measured by the vitest coverage
  gate: src/utils/__tests__/process-exit.test.ts exercises the
  already-drained, backlogged-then-drains, and never-drains/timeout
  branches directly against a fake stream; src/__tests__/cli-exit-paths.test.ts
  drives runCli() for --version, bare help, no-command, and web to cover
  their exitAfterFlush call sites, plus a --debug case with a >64KB seeded
  daemon.log proving printDaemonLogTailOnError's new byte cap actually
  trims the oldest lines.

Changed-line coverage gate now passes at 92.59% (was 59.26%); the two
remaining uncovered lines are the bottom-of-file `isDirectRun` catch
handler, which only runs when cli.ts is executed as the literal entry
script and is not reachable by importing it as a module in a test (the
same shape as bin.ts's already-excluded top-level fast paths).
2026-08-04 21:57:00 +02:00
Michał Pierzchała 4f8dc3f31e refactor: move selector engine into workspace package (#1589)
* refactor: move selector engine into workspace package

* refactor(selectors): trim the package façade to its real consumers

Follow-up to the selector-package cutover, from a structural review of it.

- Drop 15 façade symbols with no consumer anywhere in the repo:
  selectorUsesKey (added by the cutover, never called), isNodeVisible /
  isNodeEditable (the real helpers are contracts/snapshot's), normalizeText,
  splitIsSelectorArgs, IS_PREDICATE_REQUIRED_MESSAGE, four nested Replay
  types, SelectorDisambiguationDisclosure, and the four kernel type
  re-exports every consumer already imports from kernel directly.
- Delete SelectorCapturePolicyInput.selectorExpression, which
  deriveSelectorCapturePolicy never read; the policy varies only by
  predicate, so it takes one now. Two of the four tests asserted that the
  unread parameter had no effect and could not fail; they go with it.
- Return the Maestro export vocabulary to the maestro package. The cutover
  inlined MAESTRO_TEXT/STATE_SELECTOR_KEYS' values into the CLI call site,
  leaving both constants dead in the package that owns the concept and no
  gate over the two copies. MAESTRO_SELECTOR_PROJECTION is now the one
  statement of it.
- Dedupe SelectorDiagnostics and SelectorDisambiguationDisclosure, declared
  character-for-character twice across the AST/string seam, and name the two
  shared option shapes once instead of five inline copies. The parser-side
  resolution types take an Ast prefix so the twins read as twins.
- Delete three identity wrappers: parsePrivateSelector,
  selectorExpressionToMaestro, and the formatSelectorFailure forwarder —
  nothing passes it a chain any more, so the SelectorChain | string union
  and its branch go too.
- Delete internal/index.ts, an AST barrel whose only consumer was one test
  in the same directory (renamed to engine.test.ts), and the match.ts
  pass-through that existed to feed it.
- ReplaySelectorGrammar had three variants for two behaviors; 'wait' and
  'ordinary' were the same path. It is 'is' | 'positional' now.
- Drop the deleted src/sdk/selectors.ts from .fallowrc.json's entry list.

Behavior unchanged. pnpm check green: 598 unit files / 5278 tests, smoke
35 passed / 3 live skipped, layering 71/71, depgraph 22/22, mutation config
45/45, fallow clean, package smoke sound. Counterfactual: pointing
MAESTRO_SELECTOR_PROJECTION.textKeys at the state keys turns three
replay-maestro-export cells red; restored before commit.

* test(selectors): split the engine aggregation test by source concept

`internal/index.test.ts` (renamed `engine.test.ts` when its barrel went away)
was a 708-line aggregation over the whole engine — past the 500-line tripwire
and mirroring no source module, so it also ran as one serial unit.

It becomes five files that each mirror what they test, plus the parser cells
folded into the existing parse test:

  resolve.test.ts                 alternative fallback, strict uniqueness,
                                  first-match existence
  resolve-disambiguation.test.ts  ADR 0012 ranking: deepest, smallest-area,
                                  winner-vs-challenger disclosure, tie fallback
  resolve-viewport.test.ts        the visibility half: on-screen beats
                                  off-screen, including inside an off-screen
                                  scroll container
  match.test.ts                   per-key matching semantics (text, role,
                                  focused, appname/windowtitle, decoded
                                  newline labels)
  arguments.test.ts               where the selector ends and the command's
                                  positionals begin, both grammars
  parse.test.ts                   +6 grammar/escape cells beside the existing
                                  property tests

The login-form tree shared by resolve.test.ts and match.test.ts moves to
`__tests__/login-form-nodes.ts` rather than being copied into both.

All 27 cells are carried over unchanged and still pass; no file now exceeds
224 lines. pnpm check green: 602 unit files / 5278 tests, layering 71/71,
depgraph 22/22, mutation config 45/45, fallow clean over 127 changed files.

* revert(selectors): keep agent-device/selectors public, behind one AST subpath

The cutover removed the `agent-device/selectors` public subpath as part of
tightening the API. It is in use, so the removal is reverted: the subpath ships
the same ten symbols v0.20.5 shipped, with the same signatures.

That has to coexist with the reason the package façade is string-only, so the
AST leaves through one named door instead of the main one:

  @agent-device/selectors        string-in/string-out; every in-repo consumer
  @agent-device/selectors/ast    the published parser surface; one consumer,
                                 src/sdk/selectors.ts

`packages/selectors/src/ast.ts` re-exports parseSelectorChain,
tryParseSelectorChain, isSelectorToken, the AST-taking findSelectorChainMatch
and resolveSelectorChain, isNodeVisible, isNodeEditable, and types
SelectorChain / SelectorDiagnostics. `formatSelectorFailure` keeps its
published `SelectorChain | string` first parameter as a shim here rather than
widening internal/resolve.ts back to a union — the compatibility obligation
sits at the boundary that owes it.

This is strictly narrower than main, where the AST was reachable from anywhere
in src/ via src/selectors/*. Two gates hold it there: facade-symbols.ts pins
./ast to exactly the v0.20.5 list, and package-boundaries.test.ts asserts
src/sdk/selectors.ts is the only file outside the package that imports it.

Restored alongside: the ./selectors export and tsdown entry/chunk group, the
.fallowrc.json entry, the package-exports supported-subpath list, and both
client-api.md sections. No CHANGELOG entry — nothing is removed any more.

pnpm check green: 602 unit files / 5278 tests, smoke 35 passed / 3 live
skipped, layering 71/71 (10 packages, 32 subpaths), depgraph 22/22, mutation
config 45/45, fallow clean over 129 changed files, package smoke imported all
12 published entry points with publint and attw passing. Verified functionally
against the built dist: the doc's parse -> findSelectorChainMatch example
returns the same shapes as before, resolveSelectorChain still returns an AST
`selector`, and formatSelectorFailure still accepts a chain.

* fix(selectors): correct the two expectations that still assume the removal

Review P1s on a792415a: restoring the public subpath left two gates asserting
it was gone.

- installed-package-metro.test.ts moved `agent-device/selectors` into the
  blocked-specifier list. It goes back to the subpath smoke set, running the
  same `isSelectorToken('||')` + `parseSelectorChain` check it ran before the
  removal, so the file's only remaining delta from main is a formatter reflow.
- owner-files-no-leak.test.ts asserted `dist/src/sdk-selectors.js` was absent.
  It requires the stable named chunk again, and still rejects an auto-numbered
  `selectors2.js` fallback — the pair is what proves the restored tsdown chunk
  group is doing its job, verified against a clean build.

PR body corrected: the removal is no longer described as intentional API
tightening.

* refactor(selectors): satisfy the widened fallow scope after rebase

main's #1591 (the follow-up filed from this review) removed `packages/**` from
.fallowrc.json's ignorePatterns, so the new package is audited for the first
time. Everything below is a finding fallow could not previously see.

Dead surface, all confirmed consumer-free:

- 12 type re-exports from the `.` façade whose shapes consumers only ever
  reach structurally.
- MAESTRO_TEXT_SELECTOR_KEYS / MAESTRO_STATE_SELECTOR_KEYS, orphaned by this
  branch's own MAESTRO_SELECTOR_PROJECTION change, and the test-util
  SELECTOR_VALUE_HAZARDS. All three are module-local now.
- IS_PREDICATE_USAGE_HINT fails --production because its only consumer is the
  is-argument-surface parity test. It gets a commented `ignoreExports` entry
  rather than deletion: the constant is what makes the daemon and CLI raise
  ONE hint instead of two copied strings (ADR 0010), so the test asserting
  that is the point, not an accident.

`fast-check` is now declared by the package that imports it.

Duplication, split by what could be proven:

- `isUsefulVisibilityAnchor` existed character-for-character in both
  packages/selectors and packages/maestro. Moved to
  @agent-device/contracts/snapshot, which both already depend on and which
  already owns this vocabulary. Safe because the `normalizeType` each copy
  called is itself character-identical to the contracts one — checked before
  moving, since a different normalizer would have silently changed which
  nodes anchor.
- maestro additionally reimplemented `normalizeType`, `buildSnapshotNodeMap`
  (as `buildSnapshotNodeByIndex`) and `findSnapshotAncestor`, all
  character-identical to contracts'. Deleted in favour of the shared ones.
- The three scroll-ancestor walks are NOT deduped. They are structurally the
  same walk but each uses a different scrollable predicate, and I have no
  evidence the three agree; collapsing them would be a Maestro-conformance
  change, not a cleanup. Both maestro sites now say so, and the work is filed
  separately.

`projectSelectorExpression` (15 cyclomatic / 22 cognitive, written by the
cutover) splits into a dispatcher plus `readAgreedTextValue` and
`projectSelectorTerms`; all three are under threshold.

Rebase note: the one conflict, in package-boundaries.test.ts, resolved to
NEITHER side — #1591 had already deleted `AdReplayVerifiedTargetGuard` as an
unused export, and this branch deletes the seven ReplaySelectorPort names, so
the conflicting block is empty.

* build: record fast-check for packages/selectors in the lockfile

Declaring the dependency in packages/selectors/package.json without
regenerating pnpm-lock.yaml made every CI job fail in its install step with
ERR_PNPM_OUTDATED_LOCKFILE. My local `pnpm install --frozen-lockfile` printed
"+ 1 dependencies were added: fast-check@^4.9.0" and exited 0, which read as
success but was the same mismatch CI refuses.

Regenerated with the pinned pnpm 11.17.0, not the 11.5.3 on this machine:
11.5.3 rewrites peer-dependency resolution keys repo-wide (dropping
`(supports-color@7.2.0)` suffixes) and produced a 222-line diff. With the
pinned version the diff is the 4 lines this change actually needs, plus
pnpm's alphabetical re-sort of the root selectors entry.
2026-08-04 19:06:39 +02:00
Michał Pierzchała 2e74b789fd feat: verify device cloud connections (#1564)
* feat: verify device cloud connections

* refactor: unify connect provider adapters

* refactor: separate connect verification facts

* fix: tighten connect provider verification

* fix: use neutral cloud connection wording

* perf: deduplicate local affected checks

* refactor: simplify affected check runner

* refactor: derive connect workflow from verification
2026-08-03 16:47:57 +02:00