Commit Graph

92 Commits

Author SHA1 Message Date
Michał Pierzchała c77bc40d48 refactor(daemon): Wave 6 — migrate clipboard, app-switcher, trigger-app-event, settings, alert, react-native and capabilities onto request-bound runtimes (R55–R63) (#2021)
* refactor(daemon): migrate clipboard onto request-bound runtimes (R55)

Wave 6 unit 1 of the ADR 0019 platform-free daemon migration (#1739).
`clipboard` leaves the legacy dispatch projection: admission is now the
action-selected `readClipboard`/`writeClipboard` fact the parsed subcommand
names, and the only execution is that one bound operation.

- new `@agent-device/contracts/clipboard-runtime` facet, riding the existing
  `Interactor` seam through `interactor-operation-binding.ts`; read and write
  are separate cells because a provider can genuinely expose one half only.
- every owner states its own cells: Apple gains a `system/` facts module
  (simulator or the macOS host, matching the retired
  `supportsHostOrSimulatorSurface` closure), Android admits every real kind,
  Linux the desktop device, and HarmonyOS/Vega/web refuse -- none ever carried
  a bucket. Limrun reuses the local Android interactor and refuses on iOS;
  WebDriver rides interactor reachability like `back`/`home`.
- retires the `core/dispatch.ts` clipboard arm and handler, the descriptor's
  capability bucket and `dispatch` leaf, and the Apple plugin's clipboard
  admission closure. `handlers/session.ts` loses its inline handler (and its
  last `dispatchCommand`/`requireCommandSupported` imports) to the new
  `handlers/session-clipboard.ts`.
- `bindLocalInteractorOperationSet` collapses the byte-identical local
  interaction bind list Android and Linux each held a copy of.

Cutover row R55 with its retirement, admission-member and single-bind claims.

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

* refactor(daemon): migrate app-switcher onto request-bound runtimes (R56)

Wave 6 unit 2 of the ADR 0019 platform-free daemon migration (#1739).
`app-switcher` leaves the legacy dispatch projection: admission is the owner's
`appSwitcher` fact and the only execution is that one bound operation, resolved
by the generic route alongside back/home/orientation/tv-remote.

- new `@agent-device/contracts/app-switcher-runtime` facet on the shared
  `Interactor` seam, bound through the interactor catalog.
- Apple states one springboard reading for `home` and `app-switcher` (parity:
  the retired `supportsAppAndDeviceLifecycle` closure gated both off the same
  per-AppleOS row, so macOS and watchOS refuse); Android admits every real kind;
  HarmonyOS admits both kinds, restating the retired overlay membership;
  Linux/Vega/web refuse. Limrun reuses the local Android interactor and refuses
  on iOS; WebDriver rides interactor reachability.
- retires the `core/dispatch.ts` arm, the capability bucket, the `dispatch`
  leaf, `HARMONYOS_SUPPORTED_COMMANDS` membership, the Apple plugin closure, and
  the now-readerless `appAndDeviceLifecycle` row in the per-AppleOS table.
- router tests that used `app-switcher` as their legacy-dispatch stand-in move
  onto bound operations; the typed-error `supportedOn` test moves to `perf`, the
  one command that keeps a capability-matrix row after this wave.

Cutover row R56 with its retirement, admission-member and single-bind claims.

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

* refactor(daemon): migrate trigger-app-event onto request-bound runtimes (R57)

Wave 6 unit 3 of the ADR 0019 platform-free daemon migration (#1739).
`trigger-app-event` leaves the legacy dispatch projection: admission is the
owner's `triggerAppEvent` fact and the only execution is that one bound
operation.

The split follows ADR 0019 §2 — a facet input names no command, request, or CLI
flag. The event name pattern, the payload size limit, and the per-platform
`AGENT_DEVICE_*_APP_EVENT_URL_TEMPLATE` are daemon policy and stay in
`core/app-events.ts`; what reaches the owner is a resolved URL to open. They
also stay downstream of admission, where the retired `dispatchCommand` ran them,
so an unsupported device still reports its unsupported cell rather than an
argument error.

- new `@agent-device/contracts/app-event-runtime` facet on the shared
  `Interactor` seam, bound through the interactor catalog.
- Apple admits every leaf with a constructible interactor (no closure ever gated
  this command beyond its bucket), Android every real kind, and
  Linux/HarmonyOS/Vega/web refuse. It is the one system leaf both Limrun legs
  serve, since each implements `open`; WebDriver rides interactor reachability.
- retires the `core/dispatch.ts` arm and handler, the capability bucket, the
  `dispatch` leaf, and the session route's last
  capability-gate-then-`dispatchCommand` thunk: every leaf on that route now
  supplies a bind-and-execute thunk.
- the end-to-end delivery tests keep their shell-level assertions and move onto
  the migrated composition.

Cutover row R57 with its retirement and single-bind claims.

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

* refactor(daemon): migrate settings onto request-bound runtimes and retire the legacy dispatcher (R58)

Wave 6 unit 4 of the ADR 0019 platform-free daemon migration (#1739). `settings`
was the last `DISPATCH_HANDLERS` arm, so this change closes the command and
retires the legacy command dispatcher whole.

- new `@agent-device/contracts/settings-runtime` facet on the shared
  `Interactor` seam, bound through the interactor catalog. What reaches the
  owner is its own settings vocabulary (setting, state, resolved app id, typed
  coordinates); the CLI parse, the macOS setting-name gate, the clear-app-state
  app-id check and the coordinate typing are daemon policy and stay daemon-side,
  downstream of admission where the retired leaf ran them.
- Apple shares clipboard's exact host-or-simulator reading (the retired
  admission intersected the `settings` bucket with the same
  `supportsHostOrSimulatorSurface` closure); Android admits every real kind;
  HarmonyOS matches its retired overlay membership; Linux/Vega/web refuse.
  Limrun splits Android-reuse / iOS-refusal like `app-switcher`; WebDriver
  refuses unconditionally, since its interactor declares settings unsupported.
- retires `dispatchCommand`, `dispatchWithInteractor`, `dispatchKnownCommand`,
  `DISPATCH_HANDLERS`, `listRegisteredDispatchCommandNames`, and the request
  router's `executeGenericPlatformCommand` fallback. `core/dispatch.ts` keeps
  only `dispatchGestureViewport`, whose last consumers are replay/test.

Retiring the dispatcher surfaced two callers broken since Wave 5 moved `press`
onto a bound runtime: react-native overlay dismissal and the opt-in interaction
no-change retry both called `dispatchCommand(device, 'press', …)`, which has
thrown `INVALID_ARGS: Unknown command: press` on main since R48. Both now run
the same bound `tapPoint` every other touch leaf uses. The retry declares its
own callback seam rather than importing runtime admission, so the policy stays
readable without the binding stack — and that inversion, plus the dispatcher's
retirement, drops the largest type-level import cycle from 25 files to 21.

Cutover row R58 with its retirement and single-bind claims.

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

* refactor(daemon): migrate alert, react-native and capabilities onto facts (R59/R61/R63)

Wave 6 units 5, 7 and 9 of the ADR 0019 platform-free daemon migration (#1739),
plus the residue reclassification the tracker asks for as an analysis task.

R59 `alert` — new `@agent-device/contracts/alert-runtime` facet with four
action-selected legs (`readAlert`, `awaitAlert`, `acceptAlert`, `dismissAlert`)
on the shared `Interactor` seam. The daemon route admits and binds exactly the
leg the parsed subcommand names, and the poll and retry windows move to the
owners with it: how long a transient sheet takes to appear, and how many times
to re-ask a runner that says it is not there yet, are family mechanics, not
request policy. `src/platforms/apple/alert.ts` now holds the Apple windows
verbatim (with the macOS-helper / XCTest-runner split), and Android's legs read
the same presented tree `snapshot` publishes, which is why their occlusion
reading still holds.

Apple's cell is the retired `supportsAlertSurface` closure restated as facts —
the host-or-simulator reading widened by physical iOS — and that closure was the
per-AppleOS capability table's last reader, so `src/platforms/apple/capabilities.ts`
goes with it.

R61 `react-native` — the command's device work moved onto a bound `tapPoint`
with R48; this retires the capability gate that still stood in front of it and
moves admission ahead of the observing capture, so an owner that cannot dismiss
an overlay refuses without first spending a snapshot on it. That exposed a real
defect: the request handler chain never forwarded the request's runtime bindings
to this route, so the dismissal leg had been reaching a missing gateway ever
since R48 — only the no-overlay-detected path returned early enough to hide it.
Fixed, with a chain-level regression test.

R63 `capabilities` — the projection now reads each command's own declared
`platformExecution` uses instead of a hand-written map plus a "no capability
bucket means supported everywhere" fallback. That fallback is what let a stopped
Android AVD advertise `snapshot press fill` it cannot run, and a Vega VVD
advertise every migrated command; both collapse to the fact-derived set here.
The command itself executes nothing on a device, so it declares `none`.

Residue: `batch`, `debug` and `events` reclassify to `none` — each reaches no
device and delegates nothing that does. `replay`/`test` keep their gesture
viewport and boot-diagnostics edges, `daemon`/`web` hold platform imports in
their own CLI modules, and `react-devtools` still injects device-runtime
`runtime`, so all five stay `legacy`.

Cutover rows R59 and R61 with their retirement and single-bind claims.
Descriptors: 32 legacy at the wave checkpoint, 9 now.

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

* fix(daemon): restore two settings/alert sequences the migration had shifted

Self-review of the Wave 6 diff against `origin/main` found two places where the
migrated routes were faithful in what they did but not in when:

- `settings` typed its location coordinates before expiring the ref frame, so a
  request that failed on a bad coordinate no longer expired it. The retired route
  expired the frame first, then emitted its diagnostic, then typed the
  coordinates inside the leaf. Same order again.
- `alert` narrowed a frontmost-app session to "no bundle" in the daemon, which
  also stripped the bundle from the XCTest runner leg. That narrowing was only
  ever the macOS helper's, and it already lives in `platforms/apple/alert.ts`;
  the runner leg gets `session.appBundleId` unconditionally again, pinned by a
  test.

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

* fix(daemon): address adversarial review of the Wave 6 cutovers

Three independent reviews (behavior parity, correctness, ADR 0019 conformance)
ran against the branch. What they found, and what changed:

Correctness

- The R48 retry seam was unreachable. `captureSnapshot` builds it from the
  request's runtime bindings, but no caller forwarded them, so every retry
  resolved to a skip. The `snapshot` route now threads `inspectFacts`/
  `bindDevice` through `createSnapshotRuntime` and the daemon snapshot backend
  down to the capture.
- A retry tap that rejected escaped the capture it was decorating and turned a
  plain `snapshot` into an error. It is caught and reported as a skip, matching
  what the seam's own contract already claimed.
- The attempt is spent before the device work again, as the retired route did,
  so an owner that fails mid-flight cannot be re-attempted from a full budget.
- `react-native dismiss-overlay` reached its required `tapPoint` through `?.`
  and answered `dismissed: true` when the operation was absent. It refuses.
- `factOwnedCapabilityAvailable` indexed the facts map unguarded, and treated an
  empty `required` as proof (`[].every` is vacuously true). Both fail closed.

ADR 0019 conformance

- §6 forbids a `none` descriptor from binding a device, and `capabilities` bound
  three times to answer `logs`/`network`/`record`. Every owner composes a
  binding's facts with the same function `inspectFacts` calls, so those probes
  read back values the single inspection already carries — at the cost of a
  device claim on a read-only query. They are gone, and with them the last three
  empty-`required` admission uses.
- §9 is one admission per handler; the retry tap re-admitted on every retry
  round. It memoizes per device.
- `installFamilyCapabilityAvailable` was scaffolding this wave was scheduled to
  retire: the general projection returns the same verdict for all four
  install-family commands. Deleted.

Leftovers the cutovers created

- `requireCommandSupported` lost its last production caller when R56 migrated
  `app-switcher`: every generic-route command is admitted from owner facts
  before the dispatcher runs. The dead arm, the function, and
  `commandUsesDeviceRuntimeExecution` are removed.
- `CommandDispatchFacet`, `descriptor.dispatch`, and `explain`'s `dispatch=`
  field described a dispatcher R58 deleted.
- `request-router-android-modal.test.ts` asserted on a `dispatchCommand` mock
  whose module export no longer exists, so three assertions were vacuous.
- `generic-route-runtime-completeness.test.ts` now exists — a comment claimed it
  did. It pins the routing table as total over the generic route.
- Comments and test names describing the retired dispatcher, the deleted AppleOS
  capability table, and a react-native regression that never shipped.

Also records two deliberate provider cell changes the migration made (physical
Apple `clipboard` admitted, provider `alert` refused) and the react-native
widening to Linux, web and HarmonyOS, and drops a scratch probe file that was
committed by accident.

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

* fix(apple): type the alert-absence retry instead of matching error prose

Review blocker 1 on #2021. The Apple alert legs decided retry and hint
eligibility by substring-matching error messages for "alert not found" / "no
alert", and `alert wait` swallowed *every* read failure. A dead runner, an
unreachable macOS helper or a canceled request was therefore spent as poll
budget and finally reported as `alert wait timed out`, hiding the real cause.

Both backends now state absence as typed evidence:

- The XCTest runner answers `ErrorPayload(code: "ALERT_NOT_FOUND", ...)`. It is
  diagnostic-only, so it stays `COMMAND_FAILED` on the wire and surfaces as
  `details.runnerErrorCode` — the same shape `RUNNER_BUSY` already used.
- The macOS helper adds `reason: "alert-not-found"` to its JSON error details,
  which the helper client already forwards verbatim.

`isAlertNotFoundError` reads only those two fields. `awaitAppleAlert` re-throws
anything that is not a typed absence instead of polling through it, and the
scoped-snapshot fallback hint attaches to typed absence alone.

The three tests the review asked for, plus coverage the daemon-altitude copies
could not express: a non-absence failure propagates immediately from `wait`; an
action does not retry a failure whose message merely reads like an absence; the
macOS helper's typed reason is retried like the runner's. The daemon-level
non-absence test moved to the family suite that owns this policy since R59,
lowering that file's size pin.

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

* fix(runtime): admit clipboard and provider operations from what execution checks

Review blocker on #2021, reproduced on a Pixel 9 Pro XL / Android 36 emulator:
`capabilities` advertised `clipboard`, then `clipboard read` failed with
`UNSUPPORTED_OPERATION: Android shell clipboard read is not supported on this
device.` Admission and execution were consulting different authorities, which
ADR 0019 §2 forbids — a bound operation must already be admitted.

Android. `cmd clipboard` has no shell implementation on every build, and the
retired bucket admitted both halves on every real Android kind, leaving the leaf
to discover the refusal after the fact. Support is now a fact: the owner probes
once per device (cached for its lifetime — a build's shell command set cannot
change while the device is up) and states `owner-capability-missing` when adb
names the condition. The probe is definitive in one direction only: adb saying
so means unsupported, a probe that cannot run means unknown, and reporting
unknown as unsupported would hide a working clipboard behind a transport
hiccup. The predicate moves to `@agent-device/contracts/android-clipboard-support`
so admission and the leaf's own defense-in-depth check cannot drift apart.

Cost, stated plainly: the first facts inspection per device now spends one adb
round trip, including for requests that never touch the clipboard.

WebDriver. `webdriver-interactor.ts` refuses through `capabilitySupported`,
while fact generation admitted from interactor reachability alone — so a
provider configured with `capabilityOverrides: { 'clipboard.read': 'unsupported' }`
was admitted and then thrown out of. The declared capability map is now an input
to fact generation, and the refusal carries the map author's own note. Applied to
every operation with an unambiguous capability key, not just the two named in
review: the mechanism is identical and a half-applied fix would leave the same
defect for `back`/`home`/`orientation`/`tap`/`fill`/`type`/`scroll`. Behavior is
unchanged by default — every one of those is `supported` or `partial` in the base
map — so only an explicit override bites. `focus`, the gesture tiers and
`trigger-app-event` keep reachability: no capability key maps to them 1:1.

Also collapses the eight identical `*RetiredDispatchProjectionProof` wrappers in
the cutover table into one parameterized factory (second review point).

Parity tests: an Android build reporting either unsupported-shell phrasing, the
probe cache, an adb failure staying admitted, and a WebDriver override refused at
admission for each keyed operation.

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

* refactor(layering): split the Wave 6 cutover rows into a sibling module

Second review P2 on #2021. `runtime-command-cutover-table.ts` had reached 1,325
lines, past the point where one read covers it.

Wave 6's eight rows move to `runtime-command-cutover-table-wave6.ts` and are
spread back in, leaving the table at 1,095 lines. The split is by wave because
that is how these rows are retired: a wave's rows are deleted together once the
ADR declares its commands' migrations closed, and deleting a whole file is a
cleaner end than excising a run of literals from the middle of a larger one.

`retiredDispatchProjectionProof` moves to the shared extensions module, since
both tables now use it — the main table for `snapshot`/`diff`, the sibling for
its own eight.

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

* fix(android): never fabricate clipboard availability from a failed probe

Review blocker on #2021. The probe I added had a `catch { return true }`, then
cached that result by device id for the runtime owner's lifetime. A transient
adb offline or timeout therefore made `capabilities` advertise the clipboard on
a build with no clipboard shell — recreating the exact lie the fix was for, and
pinning it for the rest of the session. A test locked the behavior in.

Support is now a typed verdict with three states, because "we could not ask" is
not "it works": `supported | unsupported | probe-failed`. Only a definitive
answer is cached; `probe-failed` refuses conservatively with a hint saying
support could not be determined, and is deliberately not remembered, so the next
inspection asks again.

The same change repairs the ownership boundary. Turning raw adb stdout/stderr
into a verdict is Android tool knowledge, so it belongs to the Android owner, not
to shared vocabulary — `@agent-device/contracts/android-clipboard-support` now
carries the typed union alone. The parser returns to `src/platforms/android/adb.ts`
and runs in exactly one place, behind a new `AndroidToolHost.probeClipboardShellSupport`
that hands owners the verdict. That also settles which Android home owns it:
R13 lets only `src/platform-runtime.ts` import `@agent-device/platform-android`,
so a parser shared between the package and the root leaf cannot live in the
package either.

Tests now cover the failure path the previous ones locked the wrong way: a failed
probe refuses instead of admitting, its refusal says it could not determine
support rather than claiming the build lacks it, and it is not cached — a second
inspection re-probes and admits once the device answers.

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

* refactor(contracts): declare each interactor operation once

Second review P1 on #2021. `interactor-operation-catalog.ts` declared the same
operation set three times — a name tuple, a complete local binder map, and a
complete provider binder map — and each facet carried a mirrored
`bindLocal…Interactor`/`bindProvider…Interactor` pair whose only difference was
which interactor source to use and which label a refusal names.

There is now one row per operation, carrying its facts key, its provider refusal
label, and the facet's own executor. The local/provider split lives in the two
adapters, which differ by exactly the thing that differs: the interactor source.
Adding an operation is adding one row.

Deleted: the parallel tuple, both binder maps, 32 mirrored wrappers across ten
facet modules, and the per-facet `Local…`/`Provider…InteractorResolver` aliases
that existed only to be re-exported. Kept: every facet's typed executor, now
exported as its binding surface.

Net −563 production lines in `packages/contracts`.

Two consumers moved onto the catalog's public entry point rather than keeping a
private path to a single operation: the app-event delivery test and the provider
scenario fixture, whose two hand-bound keyboard legs are now whichever legs its
facts admit. Each facet's tests spell out the composition the retired wrappers
performed, so every assertion still exercises one executor reached through one
source.

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

* fix(android): let only a clean adb exit prove clipboard support

Third review P1 on #2021. The typed verdict landed one layer too high. The
adapter probe runs `adb shell cmd clipboard get text` with `allowFailure`, so a
non-zero exit comes back as an ordinary result rather than a throw — and the
only thing standing between that result and `supported` was the missing-shell
prose check. A device that had gone offline, was unauthorized, timed out, or
failed for any other reason produced none of that prose, so it fell through to
`supported` and was then cached by device id for the runtime owner's lifetime.
The `catch` I added guarded the one path adb almost never takes.

Each adb outcome now proves only what it can:

- `exitCode === 0` is the sole evidence of support, because it is the only
  result that shows the command ran.
- The recognized missing-shell prose is the sole evidence of absence, and is
  read before the exit code — adb reports that condition non-zero, so checking
  the code first would turn every honest `unsupported` into a refusal.
- Everything else — non-zero without that prose, and the transport throw — is
  `probe-failed`, which admission refuses and the cache does not remember.

The package tests mocked the typed verdict, so they sat downstream of the bug
and could not see it. The regression is therefore at the adapter, over the raw
adb result: four planted reds (offline, unauthorized, device-not-found, generic
failure) that all returned `supported` before this change, plus the two
definitive verdicts and the ordering case that keeps `unsupported` reachable.

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

* fix(android): never read adb's refusal prose out of the clipboard's contents

Fourth review P1 on #2021, and a second instance of the same bug it names.

The previous fix read `isClipboardShellUnsupported(stdout, stderr)` before the
exit code. On a *successful* `cmd clipboard get text`, stdout is the clipboard's
contents — arbitrary user text. Anyone who had copied "unknown command" or "no
shell command implementation" (from a terminal, a bug report, this repo) had
their own working clipboard classified `unsupported`, and the runtime owner
cached that for its lifetime. Ordering prose ahead of the exit code to keep
`unsupported` reachable traded one wrong admission for another.

The exit code is decisive on its own when it is zero, so it goes first. Only a
call that failed can carry prose about the call itself, which makes the missing-
shell phrases meaningful on non-zero exits alone:

    if (result.exitCode === 0) return 'supported';
    return isClipboardShellUnsupported(...) ? 'unsupported' : 'probe-failed';

`isClipboardShellUnsupported` now states that precondition, because reading it
on a successful call is exactly the mistake to prevent.

The same defect was already shipped in the helper's other caller.
`runAndroidClipboardShellCommand` in `src/platforms/android/device-input-state.ts`
has checked the prose before the exit code since #1950, so `clipboard read` on a
clipboard holding either phrase threw `UNSUPPORTED_OPERATION` — telling the user
their device does not support a clipboard it had just read correctly. It is not
this wave's code and not reachable from the migration, but it is the same helper
misused the same way, and documenting a precondition while leaving a caller that
violates it invites the next regression. Repaired here, with the failure ordering
otherwise unchanged: a non-zero exit still reports missing-shell as
`UNSUPPORTED_OPERATION` and anything else as the adb result error.

Both repairs are pinned by regressions that fail against the code they replace:
four exit-0 cases at the adapter (verified red against the ordering this commit
removes), and three at `readAndroidClipboardWithAdb` (verified red against
`origin/main`) covering contents that look like a refusal, a genuine missing
command, and an unrelated non-zero failure.

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

* fix(cli): bring the workflow help card back under its size budget

`Coverage (2)` has been red on `main` and on every PR branched from it since
#2020, which replaced three short Bootstrap lines with one longer line carrying
the new selection semantics. It updated the content matcher for that line but
not the size assertion beside it, so the card went to 9003 bytes against the
`< 9000` both `cli-help.test.ts` and `cli-help-topics.test.ts` enforce.

Nothing #2020 added is removed here — all of it is pinned by the matcher it
shipped, and it is the sentence agents most need. The bytes come back from a
clumsy repetition elsewhere in the card, where "settle" named itself twice in
one clause:

  ... only when you did not settle, settle reported not settled, or ...
  ... only when you did not settle, it reported not settled, or ...

which reads better short and puts the card at 8999.

That is one byte inside the budget, which is the real finding: the card has no
slack left, and the next sentence anyone adds re-opens this. The durable fix is
a base-owner call between raising the budget and moving a block down into its
sub-topic — the mechanism the card already uses, and which its own test
documents. Flagged on #2021 rather than decided here.

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

* test(cli): raise the workflow help-card budget to 9100

The card is a curated agent-facing reference, and #2020 grew it for a good
reason: the selection semantics it added are what an agent needs to predict
which device a bare `open` picks. Holding that content to a limit set before it
existed just moves the cost onto whoever writes the next sentence.

9100 is headroom, not a target. The previous commit left the card at 8999 of
9000 -- one byte -- which is not a state anyone should have to work in, and I
had already established there is no slack left to reclaim: no trailing
whitespace, and the only repeated runs are the deliberate column alignment in
the Escalate footer. Trimming further would have meant deleting content the
tests pin as load-bearing.

This is explicitly interim. The card is ~9KB of dense prose in one string, and
the real answer is to move a block down into its owning sub-topic -- the
mechanism the card already uses and its own test documents ("Deep content moved
out of the compact card, not deleted"). Raising the ceiling buys room to do that
deliberately instead of under a red CI.

Both enforcement sites move together, since they measure the same card through
different surfaces: `cli-help.test.ts` reads it through the CLI, and
`cli-help-topics.test.ts` through `usageForCommand`.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 17:47:35 +02:00
Michał Pierzchała 214d3b2799 perf(daemon): revalidate the source code-signature cache by stat instead of rereading the graph (#2004)
* perf(daemon): revalidate the source daemon code signature by stat

Every CLI invocation from a source checkout fingerprinted the daemon's
import graph to decide whether the running daemon still matches this code
(isReusableDaemonInfo). Rediscovering the graph's edges meant reading all
~800 modules: 3,915 statSync + 803 readFileSync (4.6MB) per invocation,
~30ms of a ~245ms command.

The walk is now cached under os.tmpdir() and revalidated by statSync alone.
That is sound against the walk it replaces rather than merely close to it:
the signature already treats a file's size:mtime as the stand-in for its
contents, so if every previously visited file still carries its recorded
pair, no file's contents changed, therefore no import specifier changed,
therefore the graph and its signature are unchanged. Any mismatched,
vanished, or non-file entry, and any unreadable or malformed document, falls
back to the full walk and republishes. code-signature.ts is itself inside the
graph it walks, so changing the walk invalidates every stored document.

The dist entry keeps the direct walk: it is a ~120-chunk bundle at ~5ms, and
a bundled install has no source graph to amortize.

Warm source invocation: 803 statSync, 1 read, ~1.5ms for the signature
(was ~30ms); the command drops from ~245ms to ~205ms idle, and from ~342ms
to ~251ms under a loaded host.

resolveDaemonLaunchSpec (~8 existence probes) and readVersion are memoized
per process alongside it; both are immutable for the life of a process. The
signature deliberately is not, so a long-lived client still notices a daemon
rebuilt underneath it.

* refactor(daemon): read the divergence resume through its contract type

The client's keep-alive check and the daemon's repair-liveness stamp each
reconstructed `details.divergence.resume` inline: one through ad-hoc
Record<string, unknown> casts, the other through a private structural guard
next to the stamp. ReplayDivergenceResume already owns that shape, so the
narrowing moves next to the type in @agent-device/contracts and both callers
read through it.

The client is now as strict as the daemon, which is what it always meant:
repairSessionHeld is only ever written to a record this reader accepted
(markSessionHeldIfArmed), so no payload that carries the R7 signal can fail
it.

* fix(daemon): refuse a code-signature cache document that is not this graph's

Addresses the findings in REVIEW.md against this branch.

F1 (high): a `{"version":1,"files":[]}` document validated forever — every
stamp in an empty list matches vacuously — so the client answered
`graph:0:da39a3ee...`, disagreed with the daemon's own walk on every
invocation, and killed a healthy daemon each time, permanently. A stored
stamp list is now refused unless it carries the entry's own label, and
publishing is gated on the same predicate, so a document the reader would
refuse is never written. The label comes from `buildDaemonCodeFileLabel`, the
function the walk stamps with, so the two derivations cannot drift.

F2 (medium): `os.tmpdir()` is the shared `/tmp` on Linux. The cache directory
is now per-user (`agent-device-code-signature-<uid>`, 0700, documents 0600),
so no uid silently turns the cache off for every other one; and a document is
read through one descriptor and refused unless `fstat` says this user wrote
it, so a planted document cannot choose the signature the client compares a
running daemon against.

F4 (low): `readReplayDivergenceResume` now checks the two optional fields it
types — `repairSessionHeld` (present means exactly `true`) and
`alternateFrom` (an integer) — so an accepted payload cannot type as the R7
liveness signal while carrying something else.

F5 (low): a launch-spec test stubs `--experimental-strip-types` to route the
source arm of `resolveLocalDaemonCodeSignature`, the branch the cache exists
for and the one Vitest never reached in a built checkout.

Also: the cache key hashes realpaths, as `buildSourceCheckoutStateDirName`
already does, so symlinked paths to one checkout share one document; the
module comment now states the real bound (as strong as the walk for edits to
files already in the graph, weaker for a module that joins it while every
recorded stamp still matches) instead of claiming exact equivalence; and the
cache fixture redirects `os.tmpdir()` into its own root so it no longer
clobbers documents in the run's shared TMPDIR.

Every new test was observed red against the code it guards.

* fix(daemon): keep the code-signature cache out of the startup closure

PR #2004 CI: the coverage lane's eager-closure gate failed on two rows.

`packages/contracts/src/facades/divergence.ts` (3 -> 4): the new
`readReplayDivergenceResume` reached for `isRecord`, and `./json.ts` was not
otherwise in that facade's closure. It now narrows structurally and locally,
the way this module's other wire readers (`divergenceStepLine`,
`divergenceScreenLine`, `divergenceOverflowLine`) already do; the accepted
payloads are unchanged, and the rejection table proves it.

`src/cli.ts` (362 -> 365): three modules, only one of which had a lazy seam.
`src/daemon/code-signature-cache.ts` is reached only by a source checkout, so
an installed client statically evaluated it -- and `src/utils/atomic-file.ts`
behind it -- on every invocation without ever being able to use it. It now
loads through a function-scoped `await import` in the source arm of
`resolveLocalDaemonCodeSignature`, which is therefore async.

Propagating that await collapsed the duplicated reuse ladder: the same three
checks were written twice, once as `isReusableDaemonInfo` and once as
`resolveDaemonTakeoverReason`, whose `'not reusable'` fallback was already
dead. One function now answers both questions -- the reason, or `undefined`
when the daemon is reusable -- so the verdict and the notice explaining it
cannot disagree, and the signature is still resolved only after the version
check passes.

The remaining two are deliberate and stay eager, so `src/cli.ts` is pinned at
364 with the reason recorded next to the row: `src/utils/ttl-memo.ts` (the
per-process version/project-root memo this PR added to cut re-reads of
package.json) and `src/daemon/client/daemon-launch-spec.ts` (the launch-entry
probe split out of daemon-client-lifecycle.ts). Both sit on the path every
local command already takes; deferring either would move the same load, not
avoid it.

* fix(daemon): make the code-signature cache sound across resolution changes

Extensionless specifiers resolve .ts before .js, so adding dep.ts to a graph
whose './dep' resolved to dep.js changed a fresh walk while leaving every
cached stamp untouched — the stale signature was silently reused. The walk now
records every candidate probed-and-missed ahead of a winner (and every
candidate of an unresolved specifier) as absent paths; the cache revalidates
that each is still missing and re-walks when any appears. Red-first:
cached-vs-fresh dep.js -> newly added dep.ts regression, plus lower-precedence
appearance staying warm and a nothing-resolving specifier gaining a target.
2026-08-25 13:42:34 +00:00
Michał Pierzchała 17cfd8ca8a feat: add deterministic device selection resolver (#2020)
* feat: add deterministic device selection resolver

* test: adapt open selection harnesses

* chore: keep context glossary within budget

* fix: separate device identity from selection filters

* refactor: make the selection resolver the sole owner of selection provenance

Simplifies the deterministic device-selection resolver (net -114 lines vs the
previous head) while fixing the outstanding app-aware provenance finding:

- Move the booted-simulator app-affinity narrowing into the resolver behind an
  appleSimulatorAppTarget param, with its own typed reason
  'single-app-installed-local' (candidateCount 1). This removes the
  selectedDevice escape hatch that reported 'preferred-local' with
  candidateCount 2 for the app-narrowed pick, and gives the app-match errors
  the same platform-aware retry selectors as every other selection failure.
- Delete dead code: the allowBootableLocal param (no caller ever passed it, so
  the eligibleDevices filter was unreachable), the hasExplicitProviderIdentity
  alias, the duplicated deviceCandidateDetails in dispatch-resolve, and the
  double candidate computation.
- Shrink the public selection contract to what #1777 specifies: drop `booted`
  (it contradicted its own doc comment once markSelectionBootedAfterPreparation
  flipped it; bootOccurred plus the reason codes carry the same information)
  and drop `retrySelectors` from success metadata (the daemon only ever emits
  retry selectors inside error details). DeviceSelectionRetrySelector leaves
  the contracts facade.
- Replace the typeof-import lazy seam and resolver threading through four
  context objects with one lazy forwarding wrapper; the dispatch eager closure
  stays at 83.
- Collapse the Apple path to resolve -> optional simulator fallback; the
  provider branch goes through the generic resolver call directly.
- Consolidate the five copy-pasted resolveTargetDeviceSelection test mocks
  into one shared stub (selectionFromResolveTargetDevice).
- Add the requested regression: two booted simulators with the app on one now
  assert typed selection metadata through resolveTargetDeviceSelection, plus a
  resolver-level app-affinity provenance test.

Validation: typecheck, oxlint, oxfmt, layering (184-check guard OK), DI seams,
fallow changed-files, eager-closure 235/235, daemon suite 323 files / 2287
tests, core/commands/mcp/client suites 255 files / 2133 tests.
2026-08-25 15:13:41 +02:00
Michał Pierzchała 777c7af8cc fix: persist daemon-owned child process records (#2019)
* fix: record daemon-owned child processes (#1882)

* fix: harden owned child cleanup identities
2026-08-25 09:23:21 +02:00
Michał Pierzchała 5b6feafe92 Extract snapshot policy from daemon to host-side facet (#1983) (#2014)
* refactor(snapshot): give the Wave 4 policies neutral host seams (#1983)

#2005 established the presentation ownership boundary and moved the iOS
presentation policies out of `src/daemon/`. It left the three remaining Wave 4
policies behind their existing daemon adapters. This closes that gap, so
`src/snapshot/` owns host-side snapshot policy generally rather than
presentation alone.

Freshness recovery: the window vocabulary, the Android staleness classification
and its thresholds, and the retry loop move to `src/snapshot/snapshot-freshness/`.
The loop is parameterized by a classifier and a retry schedule, so how long a
backend may lag behind a real transition is a policy input rather than a
constant the loop owns. `src/daemon/session-snapshot-freshness.ts` keeps only
what needs a session — reading and retiring the window on store-owned
`SessionState`, and choosing the comparison baseline from snapshot lineage — and
remains the declared R7 owner of `androidSnapshotFreshness`. The two call sites
#1739 named as the Wave 5 blockers, `selector-capture-runtime.ts` and
`deferred-interaction-outcome.ts`, now reach freshness through the seam.

Timeout evidence: whether a failure is the accessibility-timeout shape becomes a
policy in `src/snapshot/snapshot-timeout-policy.ts`. The published
`details.androidSnapshotTimeoutScreenshot` payload becomes vocabulary in
`@agent-device/contracts/snapshot-timeout-evidence`, built through constructors
so an assembly site cannot publish a fifth, undeclared arm. It gets its own
subpath rather than riding the shared capture facade, which keeps it out of the
CLI cold-start closure. Typed details, diagnostics and screenshot evidence are
unchanged.

Screenshot-overlay policy: which Android nodes earn an overlay ref, and what
rectangle an overlay covers, move to `src/snapshot/screenshot-overlay/`. The
daemon keeps approved artifact and ref assembly only — ranking, projection to
screenshot pixels, drawing and PNG IO.

The boundary test generalizes from the presentation subtree to the whole facet:
nothing under `src/snapshot/` may import `src/daemon/`. It gains a positive
control, because a filter that stopped matching would look identical to a
boundary being obeyed.

The residual call sites #1983 also named are audited and deliberately left in
place. `direct-ios-selector.ts` carries no presentation policy; its two pure
exports are selector derivation and ADR 0011 delegation-on-error, whose owner
would be the selector pipeline governed by R19, not this facet. ADR 0004 records
the finding so it does not have to be re-derived.

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

* refactor(snapshot): address adversarial review of the Wave 4 seams

Three findings from an adversarial pass over bc95d7f, all in the new seams.

`SnapshotFreshnessRetrySchedule.deadlineMs` was an absolute epoch instant named
almost identically to the duration constant `ANDROID_FRESHNESS_RETRY_DEADLINE_MS`
that feeds it. A backend binding the loop with the duration instead of
`markedAt + duration` type-checked, drove `remainingMs` hugely negative, and
silently ran zero retries with no annotation. Renamed to `retryUntilMs` — the
pre-refactor local's name — and the doc now says which one it is. The recovery
loop also gains direct tests it never had: the trustworthy, recovered and
still-suspicious paths, plus an already-expired deadline that pins the budget to
the action rather than to whenever the first capture returned, which is the shape
the mis-binding would have taken.

Two stale doc references from earlier drafts of the same commit: the timeout
assembly claimed its evidence shape lives in `@agent-device/contracts/capture`,
which is where it deliberately does NOT live — following that comment would
re-home the type into the shared facade and reintroduce the cold-start closure
cost the dedicated subpath exists to avoid. And the freshness window doc cited
`SnapshotFreshnessPolicy`, a type removed before commit for being unused; the
real seam is the loop's `classify` callback.

No production behavior change.

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

* refactor(snapshot): key timeout evidence on a typed reason, budget retries by duration

Addresses the three review findings on the Wave 4 facet work.

1. The recovery loop accepted an absolute `retryUntilMs`, and its own comment
   admitted that passing a duration type-checks and silently disables retries.
   Documenting a footgun is not removing one. The schedule is now a duration
   budget and the loop derives the deadline from the window's `markedAt`
   itself, so there is no absolute instant a caller can get wrong. Two tests
   pin the invariant: a budget already spent before the loop starts runs the
   capture once, and the same budget retries or not depending only on how old
   the window is — a loop measuring from its own start would return the same
   count for both.

2. The timeout policy recognized failures from hint prose and helper message
   text. That is a message shape standing in for a decision, and extracting it
   into a named facet made it worse by promoting the sniffing to declared
   policy. The Android platform boundary now decides once and publishes the
   typed reason `accessibility-timeout`, joining the existing
   `ANDROID_CONTENT_RECOVERY_REASONS` taxonomy in the contract that already
   exists to stop producers and consumers growing separate ones. The facet
   reads that reason. The hint is derived from it rather than decided
   alongside it, so rewording prose can no longer change what a reader
   concludes. Coverage now runs producer to consumer: the platform tests assert
   that both timeout shapes publish the reason, that an ordinary helper failure
   does not, and that the real policy recognizes exactly what the real producer
   emits — the message-sniffing approvals are gone.

3. `SnapshotTimeoutEvidence` still permitted `annotated: true` with zero refs.
   The annotated arm now carries a non-empty tuple, so the contradiction is
   unconstructible rather than merely unconstructed, with a `@ts-expect-error`
   guard that fails the build if it ever becomes valid again.

The timeout tests moved out of `snapshot.test.ts` into a cohesive
`snapshot-capture-failure-reason.test.ts` rather than growing a file already
over the size tripwire; its pin ratchets down 1495 -> 1445.

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

* refactor(snapshot): decide the capture-failure reason from machine values only

Addresses the two remaining typed-policy blockers on #2014.

P1. The previous commit moved the message sniff rather than removing it:
`androidCaptureFailureReasonOf` still ran `/timed out/i` over helper and
wrapper prose, and a regex over the wrapper message for exit 137. A producer
sniffing prose is the same defect as a consumer sniffing prose, one layer down.

The decision now happens at the deepest boundary that holds the evidence, from
machine-defined values only. `snapshot-capture-failure-reason.ts` maps the
helper's structured `errorType` field by exact equality against
`java.util.concurrent.TimeoutException` — the same constant
`isUiAutomationConnectionTimeoutResponse` already compares — and the SIGKILL
exit code 137, which the fallback constructor knows structurally instead of
re-deriving from the message it just wrote. The helper-result,
session-protocol, and killed-instrumentation constructors attach the reason;
every layer above rewraps it. Both regexes are deleted, and the only
`TimeoutException` string left on the path is that constant.

This tightens behavior deliberately: a helper reporting ok=false with
timeout-looking prose but some other `errorType` is no longer classified as a
timeout. Both directions are proved end to end against the real producer —
four rewordings of the helper message (including empty) keep the typed value,
and three timeout-looking messages under non-timeout error types produce no
value and are not recognized by the real policy.

P2. The evidence union stored `overlayRefCount` beside the refs, so
`{annotated: true, count: 0, refs: [ref]}` and arbitrary mismatches stayed
assignable. No arm stores a count now — it is derived from `overlayRefs`, the
one source of truth — and the arms that carry no refs have nothing to count,
which `overlayRefsAnnotated: false` already states. Two type regressions guard
it: the empty-annotated contradiction, and the reintroduction of a stored
count, both as `@ts-expect-error` so the build fails if either becomes valid.

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

* refactor(snapshot): retire the duplicate timeout classifier on the session path

`isUiAutomationConnectionTimeoutResponse` compared `helper.errorType` to
`java.util.concurrent.TimeoutException` on its own, so the session fallback
diagnostic decided "was this a UiAutomation timeout" a second time. I cited it
as precedent for the constant in the previous round without noticing that
leaving it standing is the drift it was cited against: one taxonomy, two
deciders. The session protocol already publishes the typed reason on exactly
these errors, so the diagnostic now reads it.

The regression is proved rather than assumed: with the protocol's
`androidCaptureFailureReason` attachment removed, the new session-path test
fails; with it restored, it passes. It rides the existing
`ui-automation-timeout` fixture, so it exercises the real socket response
shape rather than a hand-built error.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 07:57:09 +02:00
Michał Pierzchała 50f460cce4 refactor(snapshot): establish presentation ownership boundary (#2005)
* refactor(snapshot): establish presentation ownership boundary

* docs: keep context glossary within budget

* fix(snapshot): address presentation boundary review

* test(snapshot): ratchet eager closure budgets
2026-08-24 20:36:33 +02:00
Michał Pierzchała 942a310fb3 perf(selectors): collapse the double tree scan for disambiguate/fail-closed rows (#2009)
resolveSelectorChainWithPolicy ran listSelectorChainMatches (one full scan
per alternative) and then resolveSelectorChain (another full scan per
alternative) for the disambiguate/fail-closed rows readText/readUnique use —
the same defect class #1690 removed from replay's resolveRecordedTarget.

resolveSelectorChainDomain now tracks the first alternative that matched
anything (`firstMatch`) unconditionally, in the same pass that already
decides the winning resolution, so resolveSelectorChainWithPolicy needs only
one call for these rows. matchedNodes keeps naming the first alternative
that matched (not the winner) when they differ, preserving the existing
contract wait's landmark check and the ambiguous outcome rely on.

Also fixes analyzeSelectorMatches's lazy isVisible: it now builds the
viewport-root rect list once per alternative (via the newly extracted
collectViewportRects) alongside the existing lazily-built byIndex map,
instead of isNodeVisibleOnScreen re-deriving it on every ambiguous
candidate.

Closes #1970


Claude-Session: https://claude.ai/code/session_01YJoiggu7utUNDBmdzSBK2h

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-24 20:13:37 +02:00
Michał Pierzchała 893ce4b866 fix(ci): repair nightly XCTest and conformance lanes (#1989)
* fix(ci): repair nightly XCTest and conformance lanes

* fix(ci): harden nightly failure classification

* fix(ci): stabilize macOS replay cleanup

* fix(ci): close nightly review gaps

* fix(ci): classify device claims as infrastructure
2026-08-24 19:29:33 +02:00
Michał Pierzchała 054dcd4ea4 refactor(snapshot): type the acquisition producer beside the platform channel (#2000)
* refactor(snapshot): type the acquisition producer beside the platform channel

Three producers with different guarantees share the backend: 'xctest'
stamp (Apple runner, Appium page-source, limrun element trees), and the
'android' channel conflates the local uiautomator path with Appium
trees the same way. Add SnapshotProducer as a required field on
SnapshotResult so the compiler enumerates every producer, and carry it
into SnapshotState. Types-before-semantics step for #1983; no consumer
behavior changes.

* refactor(snapshot): make provenance one kernel-owned pair table

Review follow-up on #2000: backend and producer were independent unions,
so cross-channel pairs type-checked. SnapshotProvenance now owns the
legal channel<->producer pairs; SnapshotBackend is its projection,
SnapshotResult embeds the strict pair, SnapshotState embeds the
optional-producer variant, and buildSnapshotState carries the pair
through a narrowing helper so the fields never decorrelate. Negative
type-level regression pins that invalid pairs cannot compile.
2026-08-24 19:11:11 +02:00
Michał Pierzchała 957a6727f8 fix(android): publish covered state from exact order evidence (#1981)
* fix(android): unify snapshot occlusion across API levels

* fix(android): preserve exact occlusion evidence

* fix(android): restore collective occlusion coverage

* fix(android): preserve snapshot evidence across consumers
2026-08-24 18:09:05 +02:00
Michał Pierzchała bcca714a07 refactor: move gesture family to platform runtime (#1952) 2026-08-24 15:54:41 +02:00
Michał Pierzchała 759f175332 refactor: move Wave 5 touch commands to platform runtime (#1987)
* refactor: move touch commands to platform runtime

* fix: require direct selector touch binding

* fix: address touch runtime review

* fix: classify maestro direct click guarantee

* fix: distinguish Maestro direct selector dispatch
2026-08-24 14:54:44 +02:00
Michał Pierzchała 296447707e refactor: migrate back/home/orientation/tv-remote/keyboard to the request-bound device runtime (#1955)
* refactor: migrate back/home/orientation/tv-remote/keyboard to the request-bound device runtime

Continues the ADR 0019 platform-runtime migration (Wave 5 generic leaves):
five generic-route commands move off dispatchKnownCommand/Interactor legacy
dispatch onto fact-owned admission, one bind per handler. keyboard uses the
R35 action-selected single-bind pattern (status/dismiss/enter each admit and
bind independently). All 8 owner runtime packages gained fact-cell tests for
the new operations; six smoke-coverage integration oracles and nine
daemon/capability unit test files were updated for the retired capability-
catalog admission these commands no longer carry.

* refactor: extract shared interactor-resolution prelude in keyboard-runtime

bindKeyboardStatus/Dismiss/Enter repeated the same signal-check +
resolveInteractor call; factor it into resolveKeyboardInteractor so each
binder is a two-line call instead of a six-line copy. No behavior change —
the three contract-module mutants planted earlier in review still kill on
this shape.

* fix: refuse watchOS admission for back/home/orientation/keyboard; pin tv-remote non-TV parity

P1: watchOS has no constructible Apple interactor (XCUITest cannot drive its UI, ADR-0009),
matching the existing captureScreenshot/captureSnapshot/readTextAtPoint/findSelector pattern
in this same file. appleBackFact/appleHomeFact/appleMobileInputEligible admitted every
Apple OS but tvOS/macOS, wrongly including watchOS. Facts now refuse watchOS explicitly for
back, home, orientation, and keyboard dismiss/enter, with a fact-cell test asserting no
binding for every one of them.

P2: verified the daemon's generic-route capability gate already reproduced the retired
per-platform tv-remote hint text (message stays the generic "<command> is not supported on
this device", hint carries the owner-specific text) for every device that could reach
dispatch in the old system -- the retired handleTvRemoteCommand's own "supported only on TV
targets" check was unreachable there and only exercised by a test calling dispatchCommand
directly. Added a daemon-level test pinning the exact iOS and Android-mobile hint strings to
make that parity explicit instead of implicit.

Also fixes a fallow complexity finding the P1 test edit introduced by splitting the fact-cell
assertions into five small named helpers instead of one large function.

* fix: stop orientation-runtime.test.ts's router-join test from hitting real adb

Root-caused the CI-only Coverage failure (unreproducible locally in isolation,
reproducible 2/2 in the full CI run): every generic-route leaf this migration
touches carries `androidBlockingDialogGuard: true`, and `dispatchGenericCommand`
calls `ensureNoAndroidBlockingDialogReady` unconditionally for any
`platform: 'android'` session reaching the real request router -- regardless of
whether admission is fact-based or capability-based. That check calls
`getAndroidBlockingDialogFocus`, which shells out to the real `adb` binary.

orientation-runtime.test.ts's "request router joins..." test used a synthetic
`platform: 'android'` device through `createRequestHandler` (the real router),
without stubbing the platform ADB layer -- only the runtime gateway was mocked.
On a host with a real `adb` binary (my machine) the subprocess fails fast and
`allowFailure` tolerates it, costing ~800ms-1.1s but still succeeding. On a host
with no `adb` binary at all (CI's Coverage job, a plain unit-test lane with no
Android SDK) the spawn itself throws, which isn't something `allowFailure`
catches, producing exactly the observed `ok: false` unsupported-operation
response.

back/home/tv-remote's equivalent router-join tests already use Apple/Vega
devices, so they never reached this path. Switched orientation's fixture to
match -- Apple, since the fixture's facts/execution are fully synthetic and
platform-agnostic regardless.

Also: renamed the widely-shared 'emulator-5554'/'ios-simulator' device-id
literals in back/orientation/tv-remote/keyboard-runtime.test.ts to file-scoped
ids. Device claims for a `local-family` owner binding hit the real on-disk
`require-owner` claim file (keyed only by canonical device id), and 27+
pre-existing test files already share 'emulator-5554'; this migration added
three more consumers of it under a `require-owner` policy that reaches real
admission, which was worth eliminating as a source of doubt even though it
wasn't the actual root cause here.

* refactor: extract navigation/keyboard concepts into sibling modules; test the real Android dialog-guard path

packages/platform-apple/src/runtime.ts and packages/provider-limrun/src/app-log-runtime.ts
grew past the repo's 500-line extraction threshold. Move the new back/home/orientation/
tv-remote/keyboard facts and bindings into packages/platform-apple/src/navigation/runtime.ts
(new sibling module, matching deployment/runtime.ts's existing pattern), and the new keyboard
facts/bindings for limrun into the existing packages/provider-limrun/src/interaction-operations.ts
(which already held the sibling navigation logic).

Also fix orientation-runtime.test.ts's router-join test: it previously swapped its device
fixture from Android to Apple to dodge the real adb-backed blocking-dialog guard, which masked
the Android route that was actually failing in CI. Keep the Android fixture and stub
getAndroidBlockingDialogFocus instead, the same seam request-router-android-modal.test.ts
already uses.

* refactor: adopt granular contracts subpaths for back/home/orientation/tv-remote/keyboard

Following main's #1969 (facade granularization), give each of this branch's five
new contract modules their own package.json entry subpath and move every
value-importer (owner runtime packages, the daemon binders, and their tests) off
the wide @agent-device/contracts/platform facade onto the specific module that
owns the symbol — the same convention #1969 established for the rest of the
vocabulary. Keeps this migration's files out of the contracts-entry-closure gate
and out of the eager-evaluation cost #1969 measured for the daemon's permanent
hubs (registry.ts, dispatch.ts).

* refactor: shared navigation/keyboard binder table; dedupe keyboard admission; drop restated types

Addresses the review's finding 1 (seven per-owner copies of the same
"fact-keyed table of interactor binders" pattern) by extracting
bindAdmittedLocalInteractorOperations/bindAdmittedProviderInteractorOperations
into packages/contracts/src/interactor-operation-catalog.ts. Each owner now
requests the subset of back/home/setOrientation/tvRemote/keyboard{Status,
Dismiss,Enter} it admits, instead of hand-writing
`facts.operations.<key>.available ? bind…(resolver) : {}` per operation.
Applied across all seven call sites (apple, android, harmonyos, vega, linux,
webdriver, limrun) and collapsed limrun's two separate bind functions
(navigation, keyboard) into one shared call.

Finding 3 (resolveBoundKeyboardRuntime copy-pastes admit-then-wrap three
times): extracted a local admitKeyboardAction<...> helper mirroring
resolveBoundGenericRuntime's admit-then-defer shape, so the three action
branches (status/dismiss/enter) share one admission path.

Finding 6 (execute* helpers hand-restate a contract that can drift): back/
home/orientation/tv-remote/keyboard's execute functions are now typed off
`BoundDeviceRuntime<typeof xRuntimeUse>` (derived from the actual bind-use
value) instead of a hand-written `Readonly<{ operations: Readonly<{...}> }>`
shape. Also fixed provider-limrun's `RuntimeOperationUnavailability |
{ available: true }` restating RuntimeOperationFact by hand — folded away
entirely once the bind functions it typed were removed.

Finding 7 (naming/placement): platform-apple/runtime.ts's misleadingly-named
`captureOperations` bucket (held deployment/network/recording/find, not just
capture) collapsed into one flat `operations` object now that the navigation
bucket is a single function call instead of six ternaries.

Exported RuntimeAdmissionRequest from runtime-admission.ts (needed by the new
keyboard admission helper). Added packages/contracts/src/
interactor-operation-catalog.test.ts for the new shared binder table.

pnpm typecheck, check:fallow, check:layering, and the full unit-core suite
(1010 files / 7513 tests, one known contention-flake excluded) are green.

* refactor: split generic-mutating command traits from the legacy dispatch pair

Addresses the review's finding 5: GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS
bundled two orthogonal things (daemon/recording traits, and the legacy
capability+dispatch pair migration strips), forcing every migrated
descriptor to hand-expand the constant minus two fields plus an explanatory
comment.

Split into GENERIC_MUTATING_COMMAND_TRAITS (the shared daemon/recording
traits) and LEGACY_LINUX_DEVICE_EXECUTION (the dispatch/capability pair).
back/home/orientation/tv-remote (this migration) and focus (an earlier one,
same pattern, previously a stale reference to the retired constant name)
now spread the trait constant directly instead of hand-expanding it; the
still-legacy `scroll` descriptor spreads both pieces, equivalent to the
retired constant.

pnpm typecheck, check:fallow, check:layering, and the registry/daemon test
suites are green.

* refactor: table-ify packages/contracts/src/keyboard-runtime.ts's three-way duplication

Finding 3's second half: the three bindKeyboardX functions and six
bindLocal/ProviderKeyboardXInteractor entry points differed only by method
name and label string. Replaced with one generic bindKeyboardAction<Key>
dispatching off the operation key (interactor[key], resolved from a small
label table) plus two shared local/provider dispatch helpers the six named
exports each call with their own key — collapsing three copies of the bind
logic into one and six near-duplicate entry-point bodies into one line each,
while keeping every exported name and type signature unchanged.

pnpm typecheck, check:fallow, check:layering, and pnpm check:affected --run
are green.

* refactor: parameterize runSessionOrSelectorDispatch with an execute strategy

Addresses the review's finding 2: handleKeyboardCommand re-implemented
runSessionOrSelectorDispatch's orchestration step for step (session/selector
guard, device resolve, ref-frame expiry, record) instead of reusing it,
because the shared function had no seam for keyboard's bind-and-execute
admission — only the legacy requireCommandSupported + dispatchCommand path.
That left the shared orchestrator with one caller instead of two, and set a
precedent that would fork a new copy for each of the 28 remaining
session-route migrations.

Gave runSessionOrSelectorDispatch an `execute` parameter: the orchestration
(guard, resolve device, admit-then-execute, expire ref frame if mutating,
derive and record next session) stays in one place, and callers supply their
own admission/execution strategy. Extracted `legacySessionDispatchExecute`
for the still-legacy capability-gate-then-dispatchCommand shape
`handleTriggerAppEventCommand` (the remaining legacy caller) now passes
explicitly, and `keyboardSessionExecute` for keyboard's bind-and-execute
shape. Deleted the now-fully-redundant `executeBoundKeyboardCommand` — its
result recording duplicated what the shared orchestrator's tail already
does.

pnpm typecheck, check:fallow, check:layering, the full daemon test suite
(321 files / 2271 tests), and pnpm check:affected --run are green.

* refactor: extract limrun facts-runtime.ts; discriminate KeyboardDismissResult by owner

app-log-runtime.ts was still 589 lines after the shared-abstraction fixes; moves fact
assembly (limrunAppLogFacts/limrunAppLogRecoveryFacts/limrunLifecycleFacts/deploymentOptions)
to a new facts-runtime.ts and the shared device-identity predicate to device.ts, the leaf
both files already depend on. app-log-runtime.ts is now 336 lines.

KeyboardDismissResult was an 11-field optional bag with executeKeyboardDismiss separately
re-deriving platform from the device and projecting subsets by hand. Each owner (android,
apple, harmonyos) now tags its own result with a `kind` discriminant, so an owner can only
ever produce its own shape, and the daemon derives the wire `platform` label from `kind`
instead of guessing from the device a second time. Wire output is unchanged.

* fix: expire ref frame before the mutating call, not after; extract session/selector dispatch; derive catalog operations from facts

runSessionOrSelectorDispatch awaited execute(device, session) — which bundled admission
and the mutating invocation together — before expiring the ref frame, so a rejecting or
timed-out invocation left a stale frame active (ADR 0014 requires expiry immediately
before the mutating call, with no success-only rollback). Split the execute thunk into
`prepare` (admission only) + a deferred `execute` invocation, so the orchestrator can
expire between them regardless of how the invocation resolves. Added a regression test
proving the frame still expires when the invocation rejects.

Extracted runSessionOrSelectorDispatch and its keyboard/trigger-app-event callers into a
new session-selector-dispatch.ts, matching this file's own convention of one file per
command-group (session.ts shrinks from 571 to well under its 500-line budget).

bindAdmittedLocalInteractorOperations/bindAdmittedProviderInteractorOperations accepted
both a facts object and a separately hand-maintained `operations` array naming the same
keys — a second source of truth that could drift from what the facts actually admit.
Removed the array; the binder now walks the fixed set of navigation operations and lets
each owner's own facts decide what binds, exactly as before but with one source of truth.

* style: reformat legacySessionDispatchExecute call in session-selector-dispatch.ts

* refactor: derive catalog operation list from one canonical tuple; move keyboard orchestration tests

NAVIGATION_INTERACTOR_OPERATIONS was declared as a plain readonly array independently
of the NavigationInteractorOperation union it walked, so a future union member could
compile without ever being added to the walk list, silently preventing an admitted
fact from binding. Made the tuple the single canonical value: the union type is now
derived from it via `(typeof TUPLE)[number]`, so LOCAL_BINDERS/PROVIDER_BINDERS'
Record<NavigationInteractorOperation, ...> completeness is checked against the same
tuple, not a separately hand-kept list. Added a regression test binding all seven
operations at once to pin the runtime walk, independent of the type-level guarantee.

Moved the four keyboard-orchestration tests (the two ADR 0014 ref-frame seam tests
plus the two session/selector-guard tests) out of the mixed appstate/perf test file
into a new session-selector-dispatch.test.ts, colocated with the file they exercise.
Strengthened the rejection regression test to assert the frame is already expired
from inside the rejecting keyboardDismiss callback itself, pinning the exact
pre-invocation seam rather than only checking the end state after the dispatch settles.

* fix: restore back/home/orientation/tv-remote/keyboard-runtime exports lost in rebase

Rebasing onto origin/main dropped these five package.json export entries during
conflict resolution (the granular-subpath commit's package.json changes silently
lost during merge). Restored, confirmed by pnpm typecheck across all 17 workspace
packages and the full unit-core suite (1023 files / 7581 tests).

* test: pin the exact point the live iOS email field value goes missing

Two prior CI runs on this PR saw the seeded email field ("ada@example") end up
containing only a typed suffix (".test") by the time the flow reads it back at
the end — after fill, keyboard dismiss, coordinate refocus, and type. Since this
PR touches executeKeyboardDismiss's response shaping, the reviewer asked to
disprove keyboard dismiss as the cause rather than assume the pre-existing
dropped-keystroke flake pattern applies.

Added two read-back checkpoints: right after seeding (before dismiss runs at
all) and right after dismiss (before the coordinate refocus + type steps that
follow). If both hold "ada@example", the loss happens during refocus/type, not
dismiss — matching the documented flake, not a regression in this PR's diff.

* refactor: make keyboard status/enter owner-discriminated too; trim review-round prose

KeyboardStatusResult and KeyboardEnterResult were bare objects; executeKeyboardStatus
and executeKeyboardEnter derived the wire platform label from device.platform via
keyboardPlatformLabel, the same re-derivation already fixed for dismiss. Each owner
now tags its own result with a kind (android's status/enter as 'ime-probe' and
'android-acknowledged', harmonyos's enter as 'harmonyos-acknowledged', apple's enter
as 'visibility-echo'), and the daemon derives platform from a kind-keyed lookup table
for all three actions. keyboardPlatformLabel and its isIosFamily import are gone —
nothing derives platform from the device anymore. Android and HarmonyOS's enter
acknowledgments are structurally identical (empty besides kind), so the discriminant
alone — not result shape — is what tells the daemon which owner actually ran.

Added a harmonyos enter test alongside the existing ios/android ones so all three
owners are covered for both dismiss and enter's kind-to-platform mapping.

Also trimmed several comments that narrated which PR review round motivated them
down to just the durable invariant or rationale — the type shape, test names, and
assertions already carry the proof.
2026-08-24 10:40:27 +02:00
Michał Pierzchała 6eb74d08ce feat(android): bound snapshot presentation quality (#1972)
* feat(android): bound snapshot presentation quality

* fix(android): bound presentation footprint work

* fix(android): account for presentation scan work

* fix(android): budget scoped snapshot presentation

* fix(android): admit bounded snapshot presentation

* fix(android): validate presentation per window
2026-08-23 17:29:37 +02:00
Michał Pierzchała 1d1367311a perf(find): reuse snapshot index once per ranking pass (#1971)
* refactor(find): isolate match ranking policy

Move find's candidate ordering — on-screen preference, actionability
scoring, and the area/input-order tie-break — out of find-match-resolution.ts
into a focused sibling. Pure move: no score input, ranked order, ambiguity
refusal, or --first/--last behavior changes.

The extraction establishes the seam the follow-up indexing change needs,
so the ranking pass has one production entry point to build a topology in.

Refs #1690

* perf(find): index snapshot topology once per match ranking pass

Ranking a mutating find's candidates asked the whole tree the same three
questions once per candidate: same-rect descendants filtered every node,
the nearest hittable ancestor rebuilt a full index map, and the
overly-broad-ancestor check re-filtered every node for viewport roots.
With m matches over an n-node capture that is O(m x n) full-tree work
before find can act or refuse.

buildActionableTouchTopology reads those three collections in one pass
(nodes by index, children by parent index, normalized viewport-root
rects). preferOnscreenMatches builds exactly one per multi-match pass and
threads it through every score; resolveActionableTouchResolution takes it
as an optional argument so one-off interaction callers keep the cheap
two-argument shape. findNearestAncestor gained the same optional prebuilt
map its snapshot-presentation sibling already accepted, and
classifyActionableTouchCandidates now reuses one topology instead of
building a bare index map and re-resolving per candidate.

Score inputs, ranked order, area and input-order tie-breaks, ambiguity
refusal, and --first/--last are unchanged; only the derivation is shared.

Observed red first: with the wiring hunk removed, the new ranking
regression reports builder calls 0 (expected 1) and 64 filter + 32 map
whole-tree scans (expected 0) over 32 candidates.

The topology docstring records two seams the reviewer asked for. #1690
names src/snapshot/snapshot-processing.ts as findNearestAncestor's home;
that path is gone and packages/contracts/src/snapshot-tree.ts is the seam
that replaced it, so the issue's file list is drifted rather than a second
site to change. And viewportRootRects is not interchangeable with
snapshot-visibility's precomputedViewportRects: normalizeRect drops
negative width/height where hasValidRect keeps them, which changes which
rect wins pickLargestRect.

Refs #1690

* refactor: hide actionable touch indexing
2026-08-23 11:00:22 +02:00
Michał Pierzchała 03c3984066 perf(contracts): granularize entry surfaces so hub importers stop evaluating the facade clump (#1969)
* perf(contracts): granularize entry surfaces so hub importers stop evaluating the facade clump

`@agent-device/contracts/platform` unions 32 vocabulary modules and
`/interaction` another 18. A file that value-imports either evaluates the whole
union to reach one function, and because permanent hubs sat behind them —
`command-descriptor/registry.ts`, `core/capabilities.ts`,
`interactors/register-builtins.ts`, `command-descriptor/platform-execution-entry.ts` —
that union rode into roughly half the unit suite's test graphs.

Give every vocabulary module its own entry subpath and move all value-importers
onto the module that owns the symbol. Type-only importers are left alone: `import
type` is erased, so it already evaluated nothing.

Measured with the #1950 eager-import-closure walker over all 974 unit-core test
files, against base e5bfde3d1:

  aggregate eager module evaluations  143,248 -> 129,738  (-9.4%)
  facades/platform.ts   carried by    466 -> 1 test graphs
  facades/interaction.ts carried by   451 -> 0 test graphs

  registry.ts                 105 -> 66
  capabilities.ts             113 -> 76
  register-builtins.ts        111 -> 73
  platform-execution-entry.ts  43 -> 3
  dispatch.ts                 134 -> 100

Three gate adjustments the split forces:

- R11's pinned contracts subpath list grows to the new entries, and the resolver
  test's "must not resolve" example moves to `./clipboard`, since `./gesture-plan`
  is now a real entry.
- R16 anchored the record-runtime join on the literal `contracts/platform`
  specifier. It now accepts any contracts entry — the assertion's provenance is
  what the rule pins, not which subpath carried it.
- `gesture-plan.ts` became an entry target, and the no-bare-star rule rejects the
  `export * from './gesture-plan-types.ts'` it carried. Its one internal consumer
  now imports the owning module directly.

Both facades keep their type re-exports for the ~490 type-only importers, so
every symbol on them now reads as value-unused; one fallow entry records that
and names retiring them as the follow-up.

Closes #1959

* test(contracts): text-filter the facade scan before parsing

The repo-wide scan parsed all ~3000 sources, which the coverage lane's
instrumentation pushed past both the 5s test timeout and the 2.5s slow-test
budget. A file that never names the specifier cannot import it, so filter on the
text first and parse only the ~490 candidates.

Non-vacuity moves with it: instead of counting narrow imports across every file,
require that the surviving type-only importers were seen and classified as
erased — which an empty scan cannot satisfy.
2026-08-22 15:35:38 +02:00
Michał Pierzchała 1f80c92a27 refactor: derive every --settle surface from the descriptor trait (#1652) (#1945) 2026-08-21 19:50:57 +02:00
Michał Pierzchała d0547dcb97 refactor: complete the find cutover onto the request-bound runtime (#1944)
* refactor: complete the find cutover onto the request-bound runtime

The deferred Wave 4 unit for #1739 (R35), unblocked by focus (R40) and type
(R41). Find's read-only legs, focus leg, and type leg already ran bound; the
one remaining direct platform execution was the mutating-target capture, which
built createSelectorCaptureRuntime without a bound capture and fell through
the legacy dispatch branch reserved for "the last one to migrate".

- The mutating path now enters resolveBoundSelectorCapture — the selector
  family's shared admit-then-bind entry, which already named find in its
  intent table — and threads the bound capture into the target capture.
- Find was that last one: `capture` on SelectorCaptureRuntimeParams is now
  required and the legacy fallback branch is deleted. The backend's `bound`
  becomes required-to-state, with the observation-free duration wait
  (`wait 400`) as the one declared absence — its runtime now carries no
  capture backend at all, so an accidental capture fails loudly instead of
  falling anywhere.
- The descriptor flips to device-runtime with findRuntimePlanUses (the
  selector-text plans shared with get, plus focusRuntimeUse and
  typeTextRuntimeUse); the capability bucket and both overlay memberships
  (HARMONYOS_SUPPORTED_COMMANDS, WEB_QUERY_COMMANDS) are deleted.
- R35 lands with the selector family's shared operation owners; the capture
  and backend tests move off the dispatch mock onto the bound seam, which is
  where the poll-deadline and private-ax-pin assertions actually live now.

Wave 4 is complete: layering recognizes 26 migrated commands.

* fix(find): one action-selected bind per mutating handler (ADR 0019 §9)

Review P1 on #1944: a mutating find performed up to three separate
facts/admit/bind projections — capture, then focus, then type re-admitted
per leg. The request handler now resolves ONE action-selected plan and binds
once:

- New selector intents find-focus / find-type carry combined uses
  (capture + focusPoint, capture + focusPoint + typeText) through the same
  admit-then-bind path and plan machinery every selector capture uses; the
  new bind arms reuse the existing capture selectors, so the shared operation
  owners stay single. Delegated click/fill resolve targets on the plain
  capture pair.
- The handler threads the one bind's operations to the shared executors:
  executeFocusPoint is extracted as the single lexical owner of the focusPoint
  call (R40's owner claim follows it), and executeBoundTypeText's runtime
  param narrows to the operations it actually uses so find can pass its own
  broader bind through it.
- findRuntimePlanUses becomes the full action-selected set (eight uses), and
  the descriptor test pins each use's exact requirement list.
- Regression: find focus and find type each assert exactly one facts
  inspection and one bindDevice call — the pre-fix handler fails both (two
  and three binds respectively).

Live re-verified on iPhone 17 Pro at this head: find focus and find type both
execute through the single bind, route synthesized-first-responder, typed text
visible in the captured tree.
2026-08-21 18:43:09 +02:00
Michał Pierzchała 81409f1a7c refactor: migrate type to the request-bound device runtime (#1935)
* refactor: migrate type to the request-bound device runtime

Wave 5 unit 2 for #1739 (ADR 0019), find's last blocker. `type "text"` and
`find <q> type "text"` now reach the device through one admitted, request-bound
`typeText` operation instead of the `handleTypeCommand` interactor leaf and its
dispatch-table arm.

- New `TypeTextRuntimeOperations` contract riding the same `Interactor` seam as
  focus/screenshot/element-text; the operation returns the interactor's own
  closed `TypeTextBackendResult`, so Apple route evidence passes through and
  every other owner types blind, exactly as before. The iOS synthesized-type
  commit wait (#1676) is Apple-interactor-internal and moves nowhere.
- The interaction backend's `typeText` member exists only when the `type`
  handler admitted and bound a runtime — no caller can fall back to legacy
  dispatch, so the command keeps exactly one execution path (R41).
- `executeBoundTypeText` reproduces the retired leaf byte-for-byte: leading-ref
  rejection with the same hint, space-joined positionals, the 0-10000 delay
  bound, and only textEntryRoute surviving from the owner's result. Its parse
  pins moved from the dispatch-level tests into the daemon runtime test.
- Exact-owner facts replace the capability bucket (apple sim+device, android
  all-but-simulator-row, harmonyos emulator+device, linux device, web device,
  vega unavailable, providers wherever their interactor is reachable) and
  `type` leaves HARMONYOS_SUPPORTED_COMMANDS / WEB_INTERACTION_COMMANDS.
- The Linux desktop replay types a digit on real hardware; the coverage
  manifest promotes `type` contract -> live with the two-sided count pins.
- Android/webdriver facts helpers extracted (androidTouchFact, interactorCell)
  to keep inspectFacts under the complexity gate.

`find` stays legacy: both of its direct execution legs now share bound
runtimes, so the atomic R35 cutover is next.

* refactor(type): single-pass daemon routing, shared binder source, owner cell tests

Review follow-ups on #1935.

- The type handler now calls the bound executor directly: the interaction-
  runtime hop validated and formatted what executeBoundTypeText validates and
  formats again, so it is gone — no boundTypeText backend member, no second
  result rebuild. The ADR 0014 frame expiry moves to the handler.
- New contracts/interactor-operation-binding.ts: one local resolver and one
  fail-closed provider resolver shared by the screenshot, focus, and type
  binders — three private copies retired, provider error text preserved.
- provider-limrun/interaction-operations.ts: the interactor-backed interaction
  cells move out of the app-log owner (586 -> 563 lines, below its pre-unit
  size); text interaction is composed by that owner, not defined in it.
- Every owner runtime test now pins the focusPoint/typeText fact cells and
  bound-operation presence for its exact kinds: apple, android (incl. the
  synthetic-simulator refusal), harmonyos, linux, web, vega (refusal + hint),
  webdriver (reachability-gated, incl. inactive session), limrun (live +
  recovery). The webdriver unsupported-capability row documents that
  interaction gates on interactor reachability, not capture declarations.
- The Linux replay assertion is now change-sensitive: type "555" then wait for
  a 555 node — no calculator button carries that label, so the wait passes only
  if the keystrokes landed in the display; deleting the type step turns it red.

* fix(replay): give the calculator focus before the Linux type assertion

The change-sensitive wait exposed what the review predicted: the typed digits
never landed, because `focus 100 100` clicks the DESKTOP and takes keyboard
focus away from the calculator. The old broad assertion masked exactly this.

The retries then wedged on a latent quirk: attempt-1 leaves the pointer at
(100,100), and the next attempt's `xdotool mousemove --sync` to the same point
waits for a motion event that never comes, so every retry dies at the focus
step with a 10s timeout — which is why the lane reported step 7, not the
failing wait.

New tail: `focus 100 100` (R40 evidence + survival assert), then
`click "label=1"` — a resolved press inside the window that restores keyboard
focus, proves pointer input lands in the app, and moves the pointer off
(100,100) so retries cannot trip the mousemove no-op hang — then `type "55"`
and `wait "label=155 || text=155 || value=155"`. No button is labelled 155, so
the wait passes only if the typed keystrokes reached the display.

* refactor(type): delete the fallow SDK typeText surface, drop dead surface fields

Thermo-nuclear review follow-ups (reviewed at 82fb8c2dc; the three-hop relay
it names was already deleted in 3d44b6dea — these are the residuals).

- typeTextCommand had zero production callers after the direct-executor
  routing: the daemon was its only consumer, and the released SDK types over
  the wire (executeCommand('type')), not through the embedded runtime catalog.
  Deleted: the command, its Options/Result types, the catalog registrations,
  the AgentDeviceBackend.typeText member, and every fixture/pin that kept the
  dead surface alive. R41's retirement claim now names typeTextCommand, so a
  revival fails the gate. One parse/compose owner remains: executeBoundTypeText.
- TypeTextInput.options.surface and FocusPointInput.options.surface were dead
  clones — never set by a projector, never read by a binder. Removed both.
- The Linux replay click disambiguates with role=button: the calculator tree
  carries [text] digit nodes beside the buttons, so a bare label=1 was an
  AMBIGUOUS_MATCH rejection on attempt-1 — which parked the pointer at
  (100,100) and made every retry hang in the xdotool no-op move, reporting as
  the step-7 focus timeout.

Live-verified on iPhone 17 Pro at this head: coordinate focus -> bare type ->
route synthesized-first-responder -> text observable in the captured tree.

* fix(ci): lower the settle.test.ts pin, add pixel evidence to the Linux type wait

- settle.test.ts shrank to 2359 lines when its dead typeText fixture member
  left; the ratchet pin follows it down (the history-backed gate caught the
  gap on CI while the local affected run had not re-selected the ratchet).
- The Linux replay now screenshots the calculator right after the type step.
  The lane uploads test/screenshots/replays/*.png on pass and fail, so a
  wait-155 timeout becomes diagnosable from the artifact: display showing 155
  means a tree-exposure gap; an empty display means the input never landed.
  The 20-ref divergence dump cannot show the entry node either way.

* fix(replay): assert the Linux type through the computed result

Run 32485981780's typed-state artifact settled both open questions with one
image: the display shows "55" — the bound typeText keystrokes LAND on Linux
CI — while the wait for that value timed out, so the calculator entry does not
expose its text to selectors; and the display shows no leading "1", so the
resolved click on button 1 missed its target entirely.

Both discoveries leave the replay: the click dependency goes (a pre-existing
click-coordinate defect is not this unit's evidence chain), and the assertion
moves to where the tree can answer — the typed string is now a full
calculation ("100+55=") whose `=` creates a history row, and the wait matches
the computed 155. No button carries that label and the typed string never
contains it, so the wait passes only if the keystrokes executed; deleting the
type step turns it red. The typed-state screenshot stays as per-run pixel
evidence either way.

* fix(linux): guard the no-op --sync mousemove; assert typed digits via value=

Two artifact PNGs decided this. Run 32485981780 shows "55" in the entry while
the wait for it timed out on a mismatched target; run 32487868346 shows
"100+55" — digits and + land, the = keystroke does not, and the retries died
in the focus step again because the previous commit removed the pointer-moving
click.

- moveTo now probes `xdotool getmouselocation --shell` and skips the --sync
  move when the pointer already sits on the target: a no-op move emits no
  motion event and hangs until the action timeout, which is what reported
  every failed replay retry as its first coordinate step. A failed probe never
  blocks the move. The provider test pins both sequences, including the
  skip case.
- The replay types digits only ("155") and waits on `value=155`: the AT-SPI
  dumper reads the entry's Text interface into the node's value and the
  selector engine matches it — the earlier "exposure gap" conclusion came
  from a pair that never tested the matching value. No button carries 155, so
  the wait passes only if the keystrokes executed.

* fix(replay): keep Linux type at contract tier — GTK4 exposes no entry text

Run 32490373693 closed the investigation: the typed-state artifact shows
"155" in the calculator entry while the wait for value=155 timed out with no
interactive filtering in play and a matcher that does compare node.value. The
AT-SPI dumper's get_text_iface() route returns nothing for GTK4
gnome-calculator, so no tree-level assertion on typed text can hold on this
lane today.

The replay keeps the type step and uploads the typed-entry screenshot every
run — live pixel evidence that the migrated typeText path lands keystrokes on
real Linux hardware — and closes with the survival assertion. The manifest
claim returns to the contract tier with the reason written at the entry, and
the count pins follow. The GTK4 exposure defect joins the Linux input-defect
chip; fixing PyGObject-vs-GTK4 blind through CI rounds is not a sane loop.

The moveTo no-op guard from the previous commit stays: it is why this run
finally reported the true failing step on every attempt instead of the
step-7 hang.
2026-08-21 17:15:35 +02:00
Michał Pierzchała 17da776350 feat(ios): add snapshot backend conformance (#1930)
* feat(ios): add snapshot backend conformance

* fix(ios): load built SDK at live runtime

* test(client): isolate snapshot forwarding regression

* refactor(snapshot): keep backend capability metadata internal

* fix(test): merge backend conformance imports

* fix(snapshot): keep backend forcing internal

* refactor(snapshot): isolate backend capability fixtures

* refactor(snapshot): keep capability governance internal

* fix(ios): align snapshot actionability contract
2026-08-21 15:01:10 +02:00
Michał Pierzchała 46eff36f85 refactor: migrate focus to the request-bound device runtime (#1925)
* refactor: migrate focus to the request-bound device runtime

Wave 5's first unit (#1739, ADR 0019). `focus x y` and `find <q> focus` now
reach the device through one admitted, request-bound `focusPoint` operation
instead of the `handleFocusCommand` interactor leaf and its dispatch-table arm.

- New `FocusRuntimeOperations` contract with local and provider interactor
  binders, mirroring the screenshot/element-text seam rather than inventing a
  second way for one operation class to reach its mechanics.
- Exact-owner facts replace the capability bucket: apple simulator/device,
  android emulator/device/unknown, harmonyos emulator/device, linux device,
  web device, vega none, providers wherever their interactor is reachable.
  That is the retired bucket's cell table, restated as facts.
- `focus` leaves BASE_COMMAND_CAPABILITY_MATRIX and both hand-maintained
  overlays (HARMONYOS_SUPPORTED_COMMANDS, WEB_INTERACTION_COMMANDS).
- R40 is the new parametrized cutover row; `focusPoint` has exactly one owner.
- The `x y` positional parse moves to utils and is shared with the still-legacy
  touch siblings, so a migrated command cannot drift from them.

`find` stays legacy: this unit owns its focus leg only, its `type` leg still
dispatches, and R35 waits on the Wave 5 `type` unit.

* test(focus): cover the owning interactor binders, lower the find ratchet

Review follow-ups on #1925.

P1: focus-runtime.test.ts bound a fake focusPoint, so deleting the interactor
call inside bindLocalFocusInteractor left focus a successful no-op with every
test green. Adds packages/contracts/src/focus-runtime.test.ts, which executes
both binders and asserts resolver context, positional (x, y) forwarding, the
structured missing-provider failure, and that an already-cancelled request
never resolves an interactor at all.

Two planted mutants confirm it bites: removing
`await interactor.focus(input.point.x, input.point.y)` and transposing its two
arguments each fail exactly the two forwarding tests, while the daemon-level
focus and find suites stay green — which is the gap the reviewer named.

Coverage: find.test.ts shrank to 1204 lines when its focus assertion moved off
the dispatch mock; the ratchet pin follows it down.

* test(focus): add live Linux focus coverage to the desktop replay

The Linux `focus` claim rested on the provider scenario at command-contract
level. The desktop replay runs on real Linux hardware in the Smoke lane, so it
now runs a coordinate focus and re-asserts the session survived it.

Coordinate, not selector: the step exists to prove the migrated `focusPoint`
path executes on real hardware, so it must not be able to fail on match
ambiguity or CI layout drift.

Reclassifies focus contract -> live in the Linux coverage manifest and updates
the two pinned counts. The manifest gate is two-sided — a live claim must name
a command the replay actually invokes — so the claim cannot drift from the file.
2026-08-21 11:34:22 +02:00
Michał Pierzchała 1f8fdd0b5d fix: preserve Maestro clickable-first ordering (#1917)
* fix: preserve Maestro clickable-first ordering

* test: cover Android Maestro clickable-first path

* fix: keep Maestro fixture Android-only

* fix: reveal Android Maestro targets in smoke scenario

* fix: quote Maestro smoke assertion text

* fix: retain Android Maestro clickability evidence
2026-08-21 08:48:36 +02:00
Michał Pierzchała 56f2671a66 perf: reduce cold iOS runner startup latency (#1927) 2026-08-20 21:47:29 +02:00
Michał Pierzchała be51870118 fix: make iOS scroll release controlled (#1906)
* fix: reduce iOS scroll overshoot

* fix: make iOS scroll release controlled

* fix: make controlled iOS scrolls deterministic

* fix: preserve continuous drag sampling
2026-08-20 19:49:39 +02:00
Michał Pierzchała 17bdca76cc refactor: migrate wait to request-bound runtime (#1875)
* refactor: migrate wait to request-bound runtime

* fix: preserve native selector wait observation

* fix: classify wait observations as conditional

* refactor: compact conditional runtime declarations

* fix: isolate selector runtime intents
2026-08-20 15:59:38 +02:00
Michał Pierzchała 494eb52f66 refactor: migrate get to the request-bound device runtime (#1877)
* refactor: migrate get to the request-bound device runtime

`get` declares `elementReadRuntimeUse` (required `captureSnapshot`, preferred
`readTextAtPoint`), admits once from exact owner facts, refuses before binding,
and binds exactly once. Its capability bucket, the static HarmonyOS/Web command
sets that augmented it, and `requireCommandSupported` admission for `get` are
gone; `'get'` leaves the `createSelectorRuntime` capability union.

The neutral `readTextAtPoint` operation replaces the branch-per-family legacy
`read` dispatch on the `get` path. Every local family and both providers now
classify it exhaustively — Web, HarmonyOS, Vega and every provider row report it
unavailable, which is behaviour-preserving because the legacy dispatch had no arm
for them and threw on every call before falling back.

R36 is the new parametrized cutover row.

* fix(get): admit before the direct-iOS fast path; close the element-read outcome

Review blockers on #1877.

1. `dispatchGetViaRuntime` could complete the direct-iOS selector query before
   `resolveBoundGetRuntime`. Once `get` declares `device-runtime`, ADR 0019
   requires resolve -> admit -> bind before anything in the request path
   operates, so admission now runs first for every target shape and the fast
   path is a fast path *within* an admitted request. Regression: an eligible
   direct selector cannot operate when facts refuse admission.

2. `readTextAtPoint` returned `Promise<string>` and `readTextForNode` caught
   any throw and fell back, assigning a typed diagnostic after an untyped
   failure. It now returns a closed `ElementTextReadOutcome`; fallback happens
   only for the contract's classified reasons; unexpected errors propagate.
   The reason union is derived from its runtime list so the two cannot drift,
   and an unhandled reason is a compile error at the consumer.

This retires the generic catch the start record promised.

* feat(daemon): land the selector capture seam with get as its first consumer

Takes ownership of the request-bound selector capture seam from #1876, which
cannot ship standalone: with find's cutover deferred it had no consuming
command (ADR 0019 §10) and was not dead-code clean (check:production-exports
19 -> 20). `get` is its first consumer, so it lands here.

Adopts find's handoff as given. The one shape change, approved by the
coordinator: the selector family gets its own capture uses carrying a PREFERRED
`readTextAtPoint`, declared ALONGSIDE the snapshot uses so `snapshot`/`diff`
keep binding exactly what they bind today. The read is surfaced through the
existing arms of `bindSnapshotCaptureRuntime`, reusing the same
selectActiveAppSnapshot / selectSnapshotWithoutActiveApp selectors — no second
plan-to-operation dispatch.

`get` now runs through `createBoundSelectorRuntime`; `resolveBoundGetRuntime`
and its test are deleted as superseded, and `'get'` leaves the
`createSelectorRuntime` capability union.

The legacy read adapter survives for `find <q> get text` and is selected by
which command constructed the runtime — never by failure, family, environment,
or flag — so `get` cannot reach it. It retires in find's cutover, where the
last consumer moves.

* refactor: retire the read dispatch alias across both selector consumers

Read-only `find` now constructs a BOUND selector backend, so `get text` and
`find <q> get text` execute the same bound `readTextAtPoint` instead of one
binding it and the other dispatching the legacy `read`. This moves find's READ
LEG only: find's descriptor stays LEGACY_PLATFORM_EXECUTION and it claims no
cutover row.

With no consumer left, the whole chain goes: the `read` registry entry and its
`dispatch: {}` projection, `DISPATCH_HANDLERS.read`, `handleReadCommand`,
`interaction-read-legacy-dispatch.ts`, and the duplicate platform reader
branches it carried. `read` was the only `dispatch-alias` descriptor, so that
catalog group goes too.

Deleting the registry entry drops 'read' from DescriptorDispatchCommandName,
which makes a surviving DISPATCH_HANDLERS.read a compile error rather than
something R36 has to police. R36 now claims the retirement it can prove.

`find.test.ts` is over the size tripwire, so its handler invocation is
extracted to find-handler-fixture.ts and the pin lowered 1237 -> 1221.

* refactor(daemon): apply the seam addendum after #1876 was re-scoped

Two edits, per find's ADDENDUM.md:

1. `includeRects` returns to `buildRuntimeCaptureInput`. It was removed from
   #1876 as unconsumed; the selector capture path is genuinely its first
   consumer (a Web rect capture requests bounds explicitly), so it lands here
   under the same rule that moved the seam. `snapshot`/`diff` pass nothing.

2. The per-capture `signal` is dropped, not restored. `CaptureSnapshotInput`
   has no such field on this stack — it moved to `wait` (#1875) with the
   regression that proves per-poll abort and quiescence. `get` captures once
   per resolution and never polls, so nothing here needs it. The seam test and
   fixture coverage for it moves with the contract rather than being kept
   against a field that no longer exists.

* refactor(get): retire the direct-iOS selector shortcut

`get` declares device-runtime, so its request path must reach the platform only
through operations R36 declares. `dispatchDirectIosSelectorGet` reached
`runAppleRunnerCommand` through a path the row declares no operation for;
admitting before a bypass is not executing through the seam, so the bypass is
removed rather than ordered after admission. Every target shape — including the
simple iOS `id=` selector — now resolves through the bound capture.

`queryDirectIosSelector` itself stays: `offscreen-target-probe.ts` still
consumes it and it remains single-copy. `dispatchDirectIosSelectorIs` belongs to
`is` (#1883). Two get-only helpers (`readDirectIosGetSelector`,
`buildDirectIosGetResult`) became unreachable and are deleted with the caller.

Declaring `querySelector` as a fact-admitted preferred operation was rejected on
duplication, not correctness: the offscreen probe takes a plain session and
cannot consume a bound operation, so it would ship the query twice until Wave 5
moves the probe — the deferred-duplication shape this PR was already overruled
for on the `read` alias. It returns as a declared, §9-measured operation in a
later unit that also moves the probe.

Cost, stated plainly: `get text id=…` loses its tree-capture skip on iOS. No
fallback was added and the latency is not recovered elsewhere. R36's
singularExecution claim is now what the code does rather than aspirational.

* refactor: ride the Interactor seam for the element read; drop the bespoke host

Two operations of the same class were reaching their mechanics two different
ways: `findText` rides `Interactor` via `localInteractors.resolve`, while
`readTextAtPoint` had its own host port. That is duplication of MECHANISM, so
the read now rides the same seam.

`Interactor` gains `readTextAtPoint?`, implemented on the Apple, Android and
Linux interactors where those mechanics already live.
`src/platform-runtime-element-text-host.ts` and its `elementText` host wiring
are deleted; the contract binds through the resolver exactly as the snapshot
runtime does.

Size honesty: this removes an 89-line module but the four readers still have to
exist, so they moved into the interactors rather than vanishing. Net production
change is ~4 lines, not ~89. The duplication of mechanism is what is actually
fixed; Wave 5/6 retires the seam for both operations together.

Also from the size investigation:
- `ElementTextRuntimeExecution` was byte-identical to `SnapshotRuntimeExecution`;
  removed and reused, as `find-text-runtime.ts` does.
- Removed a stranded, stale comment in `selector-capture-binding.ts` that still
  claimed a duplication this branch had already retired.
- `FrozenUnavailablePlatformRuntimeFacts` is derived from its input type rather
  than restated, removing a 14-line clone group my new cell had pushed over the
  detector threshold.

* refactor: migrate is to the request-bound device runtime (#1883)

* refactor: migrate is to the request-bound device runtime

`is` declares the shared selector capture use, admits once from exact owner
facts, refuses before binding, and binds exactly once. Its capability bucket,
the static HarmonyOS/Web command sets that augmented it, and
`requireCommandSupported` admission for `is` are gone; `'is'` leaves the
`createSelectorRuntime` capability union.

Admission now runs BEFORE the direct-iOS selector fast path. ADR 0019 requires
resolve -> admit -> bind before anything in a `device-runtime` command's request
path reaches the device, so that query becomes a fast path *within* an admitted
request rather than a way around exact-owner facts. The rule is documented once,
on `createBoundSelectorRuntime`, replacing the two duplicated call-site comments
`get` and `is` were each carrying.

Declared behaviour change: `is` takes the active-app plan split, so the facts
decide per family. On iOS `appBundleId` is the XCUITest attach target — with no
tracked app the runner's own process comes to the foreground, displaces the app
under test, and the capture then answers confidently about the runner's own
blank screen. An iOS `is` on a session with no tracked app is now a typed
SESSION_NOT_FOUND refusal carrying the `open` hint. Refusing beats
displacing-and-lying. Android captures the real launcher in that state and is
unchanged, which is what the platform facts already encoded.

The two Apple watchOS cells move from capability-admitted-then-runner-failure to
a typed unavailable refusal, the same classification snapshot, diff, and get
already landed.

R37 is the new parametrized cutover row. `find` keeps `createSelectorRuntime`
and its `requireCommandSupported` call, so `captureData` stays optional and
`captureSnapshotWithInteractor` stays: this unit is not the last selector unit.

* fix(is): a failing iOS assertion fails instead of exiting zero

Reverses part of #557, on thymikee's explicit instruction.

`is` is an assertion: the docs state it "exits non-zero on failure". The
direct-iOS fast path broke that contract — it reported a failed predicate as a
completed command, so on device

    $ agent-device is text id=… "Wrong Expected Text"
    Passed: is text          (exit 0)

because `{ok: true, pass: false}` reaches `isCliOutput`, which renders
"Passed: is <predicate>" without reading `pass`. A failing assertion reported as
success lets a replay run on past a broken state. Now:

    Error (COMMAND_FAILED): is text failed for selector id=…:
      expected="Wrong Expected Text" actual="Apple Account, …"   (exit 1)

The renderer needed no patch: a negative can no longer produce a success
envelope, so it is correct by construction.

Direction chosen deliberately. Making the two paths agree could have gone either
way, and "an agent asked a question and got an answer" is a real argument for the
other one. This follows the DOCUMENTED contract rather than merely the incumbent
behaviour, and the alternative is a far larger change: a zero-exit `is` would
alter every platform and path, break scripts that rely on it failing the shell,
and needs its own PR, docs, and probably a major version. It is also already how
`is hidden` and `is exists` behave end to end.

PASSING assertion, and that arm still answers with zero captures (pinned). Only
the negative falls through — what #557's own summary asked for, "preserving
snapshot fallback for misses", refusing fallback only for hard failures like
ambiguity. The fall-through was #557's own design, never armed: the `| null`
return and the caller's `if (!payload) return null;` guard were unreachable.
This makes that dead guard live.

Measured on iPhone 17 (median of 9, warm daemon): predicate holds 0.14s / 0
snapshots, unchanged; predicate fails 0.25s / 1 snapshot. ~+0.11s on failing
assertions only.

Correctness gain beyond the envelope: the fast path evaluates a ONE-NODE tree, so
`visible` cannot see the ancestor geometry a list row inherits and its negative
can be wrong. Falling through re-asks the real tree and can turn a spurious
negative into a pass.

The #557 pin moved with its reasoning at the pin site.

* fix(layering): let a cutover row state a data-only admission retirement

Review blocker on #1883: R37 claimed `legacyRetirement.routeNames:
['WEB_QUERY_COMMANDS_WITH_IS', 'HARMONYOS_IS_SUPPORT']`. Neither identifier has
ever existed. They satisfied the non-empty shape check while proving nothing —
the vacuous registry claim AGENTS.md warns about, and a green gate that would
stay green if the deletion were reverted.

The cause was the model, not the row. Every `LegacyRetirementClaim` form names
something that must NOT exist, which a row can always satisfy by inventing a
name. `is` retired no module, route, or dispatch projection because it had none:
its legacy admission was a capability bucket plus membership in two static
platform command sets, so its real retirement is a DATA deletion the model could
not express. Rather than patch around that with sentinels or a per-command
policy file — both forbidden by the playbook — this generalizes the model.

`staticCommandSets` names the sets themselves and is proven from both sides:
each must still be DECLARED in production source, and must no longer list the
command. A fictional set fails the first half; a skipped deletion fails the
second. That is what an identifier-shaped claim cannot state.

R37 now claims HARMONYOS_SUPPORTED_COMMANDS and WEB_QUERY_COMMANDS, which is the
deletion it actually performed.

Planted red, both halves, against the real gate:

  [R37 is-runtime-cutover] 2 violation(s):
    (is cutover row):1 — claims retired static command set
      'WEB_QUERY_COMMANDS_WITH_IS', which no production source declares
    (is cutover row):1 — claims retired static command set
      'HARMONYOS_IS_SUPPORT', which no production source declares

  [R37 is-runtime-cutover] 2 violation(s):
    src/core/capabilities.ts:59 — static command set WEB_QUERY_COMMANDS still
      admits is

so the exact claim that shipped is now rejected by name, and so is restoring the
membership it claims to have removed. Mechanism cases live with the other
planted-row tests; layering goes 177 -> 181.

* test(is): pin the exit-code guarantee independently of what answers the predicate

Prep for the Blocker 1 retirement, which deletes `buildDirectIosIsResult` — the
function the #557 reversal fixed. The reversal's guarantee must not evaporate
with it, so it gets a case that does not know how the daemon decided.

`is` is documented to "exit non-zero on failure". The reversal proved that at
the JSON envelope; nothing pinned it at the CLI boundary, which is where the
defect was actually visible (`Passed: is text`, exit 0). This asserts the CLI
contract directly: a `predicate_failed` response exits 1 and never renders as
passed.

It survives the retirement untouched, because it asserts the outcome rather than
the path. Planted red with the exact pre-#1739 envelope the shortcut produced
(`{ok: true, data: {pass: false}}`): `exitSpy.calls` is `[]` — no exit call at
all — so the case fails, which is the regression it exists to catch.

Unpushed on purpose: the restack will carry it into the retirement cycle.

* refactor(is): retire the direct-iOS selector shortcut

thymikee's ruling (option b). `is` declares `device-runtime`, so its request path
must reach the device only through the operations R37 declares. It did not: a
simple iOS `id=`/`label=` target was answered by a direct XCUITest querySelector
without any capture, ordered after admission but not executing through the seam.

This is not retired because it was wrong. `wait` hypothesized that the degenerate
one-node evaluation mis-answers `is visible` for off-viewport nodes, traced it
through the code convincingly, then tested it on device and it did not reproduce
— XCUITest's own query is conservative about visibility, so the degenerate
evaluation never gets the chance. It is retired because it was an undeclared,
unmeasured bypass that made R37's singularExecution claim false: the same class
of untruth as the sentinel retirement names fixed in the previous commit.

Declaring querySelector as a real operation instead was rejected for a concrete
reason: offscreen-target-probe.ts consumes queryDirectIosSelector with a plain
session and cannot take a bound operation, so declaring it now would ship it
twice until Wave 5 moves the probe — the deferred-duplication shape that got
get's read deferral overruled. It returns as a declared, fact-admitted,
section 9-measured operation in the unit that also moves the probe.

Retired: dispatchDirectIosSelectorIs, its call site, buildDirectIosIsResult, and
resolveDirectIosSelectorQuery — each had exactly one caller, all on this path —
plus the ResolvedDirectIosSelectorQuery type they orphaned and two imports.
queryDirectIosSelector itself stays: the offscreen probe still consumes it and it
remains single-copy.

Latency cost, stated plainly and not softened: a held predicate on a simple iOS
selector goes from ~0.14s with no capture to ~0.25s with one, measured as the
median of 9 warm runs on iPhone 17. There is no fallback and no fast path.

R37's comment finally describes the code: "every predicate answers from the
resolved tree" was written while the shortcut existed. Its scope is now stated
too, so it is not read as absolute — the Android foreground-blocker diagnostic
still reaches adb on the failure path, where it cannot produce or change a
verdict; that edge is pre-existing, co-owned with wait, and recorded as Wave 6
denominator work with R22's appState as its declared replacement.

Seven tests lost their subject. Those whose only content was the shortcut's own
mechanics are deleted; the outcome-level ones are retargeted and keep asserting
what survives.

---------

Co-authored-by: agent <agent@local>

* fix(contracts): a falsely advertised element read fails as a contract bug

An owner whose facts advertised `readTextAtPoint` but whose interactor cannot
perform it was reported as `{ status: 'unreadable', reason: 'surface-not-readable' }`.
That put a contract violation inside the closed reason set that licenses falling
back to the captured tree, so `get text` answered from potentially stale snapshot
text precisely because the runtime lied about itself. ADR 0019 §2 requires the
mismatch to fail as `runtime-contract-invalid`; it now throws.

Removing the only producer of `surface-not-readable` made that reason dead: no
path can reach it, since an interactor that HAS the read maps a blank or absent
answer to `no-text-at-point` via `elementTextRead`. Dropped from the union, its
consumer switch arm, and both test lists. `classifiedFallbackReason`'s `never`
arm stays — it is what makes adding a reason a compile error rather than a
silent untyped fallback.

Deduplication found while auditing the change:

- `invalidRuntimeContract` was module-private in `platform-runtime.ts`. It now
  owns its own module so both runtime modules share one construction. It is
  deliberately not exported through the platform facade: that facade must stay
  exhaustive over its sources, which would make this a public symbol with no
  external consumer.
- The 8-field runner execution projection was written out three times
  (`snapshot-runtime-capture-input.ts`, `interaction-read.ts`,
  `screenshot-runtime.ts`). One `runtimeExecutionFromContext` now serves all
  three; `screenshotExecutionFromContext` keeps its name and delegates, since
  `ScreenshotRuntimeExecution` and `SnapshotRuntimeExecution` are the same type.
  Dropping a field here silently strips request id, log/trace paths, XCUITest
  overrides, or runner lease context — an operation that still answers but runs
  unconfigured, which is exactly the defect the wait unit hit as a P1.

Red before green: with the old guard restored the new regression fails with
"Missing expected rejection" — the call resolves instead of throwing, which is
the silent degradation it exists to forbid.

---------

Co-authored-by: agent <agent@local>
2026-08-20 15:59:38 +02:00
Michał Pierzchała fed7251336 fix: calibrate iOS scroll execution (#1905) 2026-08-20 15:19:08 +02:00
Michał Pierzchała 2eeb9c644f fix(maestro): refresh target after selector miss (#1886) 2026-08-20 10:31:59 +02:00
Michał Pierzchała 9ce1ef7587 feat(snapshot): move scope into presentation (#1855)
* feat(snapshot): move scope into presentation

Use one preorder label/identifier/value policy across Swift and TypeScript, keep scoped iOS acquisition conservative, and remove the daemon's second scope pass.

Non-vacuity: label-only matching failed identifier/value parity fixtures; Android pass-through failed its boundary test; disconnecting Swift applyScope produced eight scope/depth/projection failures.

* fix(snapshot): select scopes with presented content

* docs(snapshot): describe presentation-owned scope

* docs(snapshot): record contribution-aware scope

* refactor(snapshot): drop unrelated provider churn

* fix(snapshot): accept healthy empty scoped capture

* refactor(snapshot): isolate empty-scope admission

* fix(snapshot): align scope ownership across runtimes

* test(snapshot): pin post-wire scope owner

* test(snapshot): retain find test shrink
2026-08-20 10:30:18 +02:00
Michał Pierzchała d8e03aea9b refactor: migrate screenshot to request-bound runtime (#1878)
Retires the last dispatchCommand edges for screen capture: the generic-route
command, the sparse-snapshot fallback, and the Android snapshot-timeout evidence
capture all admit exact owner facts and bind once (ADR 0019, cutover rule R39).

--overlay-refs becomes part of the declared use, so a target that can capture
pixels but not a tree is refused before anything is written to disk.
2026-08-19 17:33:48 +02:00
Michał Pierzchała 79dddaf781 refactor: tighten viewport runtime facts (#1873) 2026-08-19 11:06:18 +02:00
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 294654a3a5 fix(android): resolve snapshot scope once and disclose the API 23 occlusion-scan gap (#1832 C1/C2) (#1846)
* fix(android): resolve snapshot scope once and disclose the API 23 occlusion-scan gap (#1832 C1/C2)

- Android resolves --scope inside its projection only, under the shared scope specification
  (matchesSnapshotScope in @agent-device/contracts/snapshot: first document-order match over
  label/value/identifier, empty on no match). The daemon post-wire scopeSnapshotNodes pass skips
  the android backend, so scope has one owner and one no-match semantics instead of BFS+fallback
  followed by document-order+empty.
- Golden table contracts/fixtures/snapshot-scope-policy.json is asserted against the predicate,
  the Android projection, and the daemon pass; the Swift runner twin (#1797) consumes the same table.
- androidSnapshot.occlusionScanUnavailable discloses helper trees without drawing-order (API 23),
  where the covered-sibling pruner cannot run. Disclosure only; C1 stays open until occlusion moves
  to the daemon annotator.

* fix(android): resolve scope over the presented tree and stop dropping it on interaction captures

Adversarial review findings on the first commit:

- BLOCKER: captureSnapshotData spread `snapshotScope: undefined` over flags, so an interaction
  capture (press/click/fill/longpress/hover --scope, --settle observation) reached the Android
  platform unscoped while buildSnapshotState still saw the scope. The post-wire pass used to rescue
  it; after skipping android it returned the unscoped tree. One effective scope now feeds both.
- Scope resolves over the PRESENTED nodes of the requested projection, not the acquired tree, so an
  acquired match that membership drops no longer empties the snapshot, and Android matches the
  domain iOS's pass uses.
- Slicing after the walk keeps ancestor context (hittable/collection/chrome) above the scope root,
  so scoped -i is a subset of unscoped -i; --depth stays scope-relative.
- Shared findSnapshotScopeRange/reindexSnapshotNodes so the daemon pass and the Android projection
  run one implementation; scope slice extracted to ui-hierarchy-scope.ts (mirrors its test).
- parseUiHierarchy moved to a test fixture module (it had no production caller left).
- Golden rows sharpened (value row no longer matches via label on Android); the Android leg runs
  raw AND regular. CHANGELOG entry; docs wording corrected for iOS/@ref.

* fix(layering): keep the contracts snapshot façade exhaustive over snapshot-scope

* fix(android): scope to the first match whose subtree still has presented content

Review P1 on #1846: with scope resolved strictly over presented nodes, `snapshot -i --scope panel`
answered "no nodes" whenever the matched container was a structural view membership drops — even
though the button inside it was exactly what was asked for — and `--depth 0` then hid a node the
response prints at depth 0.

The scope root is now the first document-order match whose subtree contributes at least one node to
the requested projection, and the result is that subtree's presented nodes re-rooted at depth 0.
Both failure modes die: a decorative match membership drops no longer empties the snapshot, and a
dropped container still scopes to its content. `--depth` under scope filters the depths the
response emits, so a node shown at depth 0 survives `--depth 0`.

Tests: the golden legs stay raw+regular (bare TextViews cannot survive -i, so an -i leg would
measure membership, not scope) with the projection interplay pinned by two dedicated tests on
actionable shapes; the 'not re-scoped after the wire' case now runs a real parsed scoped tree
instead of fabricated depth-0 siblings.
2026-08-18 18:44:13 +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 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 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 0d3b7413c5 fix: prevent private AX subtree leaks at source (#1807)
* fix: prevent private AX subtree leaks at source

* fix: preserve values in settle signals

* fix: normalize settle signal semantics
2026-08-18 09:23:39 +02:00
Michał Pierzchała f378050586 feat(snapshot): attach a fallback screenshot to sparse captures (#1764)
A sparse verdict already tells the caller to use a screenshot as visual truth,
which made that screenshot the guaranteed next command on every unreadable
screen — a second round trip to obey advice we authored. The user-facing
`snapshot` dispatch now takes the shot itself and links the path in its
warnings.

The fallback is deliberately hung off `dispatchSnapshotViaRuntime` and skipped
for internal observations: selector resolution, settle, and wait polling reach
`captureSnapshot` directly, so a wait polling an unreadable screen cannot turn
into a screenshot per poll. A failed shot is swallowed — the verdict's own
warning still carries the manual remedy, so the fallback can never fail the
snapshot that was asked for.

Sparse captures also say when the screen is the app's problem. Only the
`sparse-tree` reason code is evidence about the app: every backend reached the
screen and it published no semantic content, which is the same emptiness
assistive tech gets. `ax-rejected`, `budget`, `no-nodes` and `capture-failed`
are limits of this tool and stay unattributed, so readers are not sent to file
bugs against code that is not broken.
2026-08-16 16:06:10 +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 c7565cb1f8 refactor(snapshot): clean snapshot ownership (#1754)
* refactor(snapshot): clean snapshot ownership

* fix(snapshot): address ownership review feedback
2026-08-12 13:44:21 +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 97c87eec4d refactor(registry): exhaustive platformExecution discriminator (ADR 0019 §6) (#1740)
* refactor(registry): make the platform-execution discriminator exhaustive

ADR 0019 §6 (amended): every command descriptor declares its platform-execution
mode explicitly. Adds the `none` mode to `CommandPlatformExecution`, removes the
silent `{ kind: 'legacy' }` default at registry entry, and annotates all 76
descriptors so the migration denominator is machine-readable.

Part of #1739 (wave 0)

* fix(registry): react-devtools executes delegated platform behavior

`react-devtools start` on a Limrun Android instance dispatches internal
`runtime port-reverse`, which reaches a provider device runtime, so ADR 0019 §6
`none` is false for it. Reclassify as `legacy` and add the derived coherence gate
that catches delegated platform execution: if a CLI route for command R
dispatches command D, R may declare `none` only when D is `none`.

Part of #1739 (wave 0)

* fix(registry): attribute CLI dispatches by occurrence, not command name

Subtracting attributed command NAMES let a stray dispatch hide behind a routed
one that names the same command, so the gate's totality claim did not hold.
Dispatch sites now carry their source offset and attribution subtracts
occurrences.

Part of #1739 (wave 0)

* fix(registry): unresolvable CLI daemon-send targets fail the gate

An unknown literal or computed command target resolved to undefined and never
entered the scan, so a dispatch could evade attribution by naming a target the
gate could not read. Daemon-send envelopes are now located by their send call and
an unresolvable target is reported instead of skipped.

Part of #1739 (wave 0)

* refactor(cli): own injected daemon dispatches at a typed construction seam

The syntactic scan recognized only a direct sendToDaemon call whose first
argument was an inline object literal, so a variable envelope or a computed
callee was omitted from every result. Rather than teach the scanner more shapes,
the CLI's injected dispatches now flow through one typed construction point whose
route/command pairs are declared, and the gate reads that declaration instead of
recovering it from syntax.

Part of #1739 (wave 0)

* fix: constrain injected daemon transport handoffs
2026-08-12 10:40:42 +02:00
Michał Pierzchała 74eab2a554 refactor: route selector-resolution structural stages into typed policy (#1744)
* refactor: route selector structural stages into typed policy

#1649 landed the per-caller ambiguity matrix and deliberately left four
structural columns out: occlusion, off-screen, hittable-ancestor promotion,
and the poll budget were per-caller pipeline code, so declaring them would
have been an unverifiable claim (nothing consumed them; flipping one left the
suite green).

This adds the missing half as a table with runners. `SELECTOR_PIPELINE_POLICIES`
(src/core/selector-pipeline-policy.ts) gives each caller ONE row naming its
ambiguity contract plus its four stages, and every stage is reached only
through a runner that reads the row:

- occlusion -> selectorPipelineCandidates (candidacy) and
  resolveSelectorPipelineTarget (refusal). Acting rows exclude covered nodes
  and refuse covered targets; `find` and the diagnosis probe keep them as
  candidates and refuse at the target; reads and `wait` ignore them.
- promotion -> resolveSelectorPipelineTarget. The per-call-site
  `promoteToHittableAncestor: boolean` is gone: click/press/longpress name
  `promotedTarget`, fill/focus/scroll/drag endpoints and the native-ref
  preflight name `resolvedTarget`. `find`'s below-the-root variant is a
  declared value rather than a second local helper.
- off-screen -> throwIfOffscreenInteractionTarget, which now takes the row and
  returns the node untouched (no iOS rescue round trip) for observation rows.
- poll -> selectorPollBudget, which createWaitPolling derives its deadline and
  inter-poll delay from; the two wait loops carry a budget, every other row
  carries none and cannot be polled.

Behavior is byte-identical. The acting refusal keeps its exact node, label and
details in every branch (promotion declines to retarget away from a covered
node, so the "both covered" case names the same node it always did), and
`find` carries the occlusion verdict to the focus/type seam rather than
raising it early, because find click/fill still delegate that refusal to the
interaction leaf's own error shape.

selector-pipeline-policy.test.ts drives EVERY row through EVERY runner,
including the rows whose answer is "skip" — the half that used to be an
absence of code, and an absence cannot fail. Each stage was proven red by
flipping its cell (occlusion, promotion, off-screen, poll, plus the
declare-only-what-is-enforced guard). The ADR 0011 occlusion/nonHittable
`via` pointers for the runtime tree paths now name the runner that makes the
decision, not the predicate it applies.

Closes #1656; prework for #1739 (waves 4-5).

* docs: state constraints instead of narrating the refactor

Comment pass over #1656: drop the "used to be per-caller code" /
"not module constants" / "rather than an omission" narration — a comment
should say what a future edit must respect, not what the previous shape was —
and compress the find occlusion-verdict and poll-budget notes to the
constraint they actually carry.

* refactor: make the selector pipeline the only door to the engine

Review of #1744: the structural rows were declared but bypassable. Read and
wait routes composed `selectorPipelineCandidates(row, nodes)` with the raw
`resolveSelectorChainWithPolicy(..., row.resolution)` and never entered the
promotion or off-screen stages, so flipping a read row's `promotion` or
`offscreen` changed only the policy unit tests — production `get`/`is`/`wait`
were unaffected, which is the unverifiable-column failure #1656 exists to
remove. Callers could also pair one row's candidate set with another row's
ambiguity contract, and `find list` reached the engine directly.

The owning interface (src/core/selector-pipeline.ts) now runs every stage a row
declares, skips included, and the stage functions are private to it:

- `resolveSelectorPipeline` — single-target rows: candidacy, ambiguity, the
  replay-guard hook, promotion, occlusion, off-screen.
- `listSelectorPipelineMatches` — `reject-candidates` rows, returning the
  candidate set AND the tree the row sees, so ranking and equivalence
  classification judge the same nodes candidacy produced.
- `runNodePipelineStages` — the node stages for a target from a non-chain
  matcher (`@ref`, find's fuzzy locator) or a narrowed candidate set.

A row whose off-screen stage refuses must supply a refusal shape, so flipping
an observation row to `refuse` fails on its real route instead of silently
observing. `find list` now names a `readList` row (the new
`reject-candidates`/no-rect ambiguity row) instead of calling the engine.

R17 selector-pipeline-ownership (scripts/layering/) makes the bypass
structurally inexpressible: only the owner may import the engine entry points.
Proven against a planted import in selector-read.ts, which the repo-wide scan
rejects with the entry points that replace it.

Flips now fail through REAL command routes, verified one at a time:
readUnique.occlusion/offscreen/promotion and wait.occlusion via get attrs / is
/ wait; readAny.offscreen via is exists and find; readList.occlusion via find
list; promotedTarget.promotion via runtime click. The wait route test needed an
advancing clock first — with the frozen one a refused wait spun instead of
failing, so the flip hung rather than asserting.

* refactor: drop find's dead candidate binding

The selector branch bound the row's candidate set and never read it: only the
acting classification needs that tree, and find's locator branch brings its own
matcher. Names what actually governs the locator target — the shared node
stages below, not a candidate set it never had.

* refactor: reserve the selector engine behind the pipeline owner

Review of #1744 (three blockers).

**Listing rows no longer claim stages they cannot run.** `find <q> list`
resolves to a candidate SET, so promotion, the off-screen guard and a poll
budget have nothing to apply to — a listing has no single element to retarget,
keep on screen, or wait for. `readList` now declares only the two stages a
listing executes (`SelectorListPolicy`: resolution + occlusion), and the
narrower shape is load-bearing: `runNodePipelineStages` and `selectorPollBudget`
take the full row, so handing them a listing row is a compile error rather than
a silently skipped stage. Pinned with `@ts-expect-error` — widening `readList`
makes the directives unused and fails the typecheck.

**The engine door is a specifier, not a symbol.** R17's regex could not see a
namespace import, a re-export, or a deferred `import()`, none of which mention
the symbol it matched. The two engine entries moved to
`@agent-device/selectors/engine`, and R19 enforces over the resolved import
graph, where every one of those forms is the same edge. Proven on the
repo-wide scan by planting each form into a shipped route: namespace import,
dynamic import, and `export *` laundering all come back red.

`resolveImportEdges` drops an edge whose specifier resolves to nothing, so a
specifier rule goes quiet — not red — if the subpath is ever retired. The gate
now says that out loud instead of scanning clean.

**R19, not R17.** #1750 allocates R17/R18. Verified free against origin/main
and that PR's diff, then validated by real merges in both directions: the
uniqueness gate passes either way and the three ids stay distinct.

The gate itself is new (`scripts/layering/rule-ids.ts`): two branches taking one
free number do not conflict in git, so nothing caught R17 twice. Matching whole
string literals is what separates a declaration from prose that names a rule,
and it is what let the gate see #1750's `const RULE = '…'` shape — the first
version missed it and would have been vacuous. `main`'s two pre-existing
collisions (R11, R13) are listed as known, not pinned by equality, so #1750
lands in either order without breaking this.

Also: the root façade now exposes no resolver at all, and its surface test
pins both doors.

* fix(layering): make each rule-id allowance expire with its collision

Review of #1744: `KNOWN_RULE_ID_COLLISIONS` filtered the exact R11/R13
collision strings, so once #1750 renames those rules apart the entries would
keep waving those very collisions through if anyone reintroduced them. "Inert"
was wrong — a stale allowance fails open, permanently.

`ruleIdCollisionFailures` now checks the transition from both sides: a
collision nobody allowed fails, AND an allowance whose collision is absent
from the scan fails as a stale allowance. The entry therefore has to be deleted
in the same change that removes the collision, and the list burns down to
empty, which admits nothing.

#1750 is still open, so the transitional entries stay for now (option (b)).
Verified against a scratch tree carrying that PR's rename: leaving the list
untouched reports both entries as stale; deleting them is clean; and
reintroducing `R11 names contracts-implementation-authority and
package-boundaries` afterwards is rejected. The last of those is also a unit
regression, so the post-transition guarantee is pinned rather than argued.
2026-08-12 07:57:08 +02:00
Michał Pierzchała f18f8b076f refactor(contracts): consolidate per-domain defineUse wrappers into one neutral defineUse (#1741)
* refactor(contracts): consolidate per-domain defineUse wrappers into one neutral defineUse

ADR 0019 §9: use declarations share one neutral defineUse; per-domain
currying wrappers around runtimeUse<PlatformRuntimeOperations>() add a
module per domain for no information. network-runtime-plan.ts,
logs-runtime-plan.ts, screen-recording-runtime-plan.ts, and
app-log-resource-recovery.ts each re-derived their own curried alias
(defineNetworkUse, appLogUse, defineScreenRecordingUse, and an inline
instantiation) from the same generic factory with the same type
parameter.

Export defineUse = runtimeUse<PlatformRuntimeOperations>() once from
platform-runtime.ts (where runtimeUse lives) and re-export it from the
platform facade. Every runtime-use declaration (networkDumpUse,
networkAdmissionUse, the app-log uses, the screen-recording uses, and
appLogRecoveryUse) now builds through that single export; the
per-domain wrappers are deleted.

Type-level only: no required/preferred keys changed, and no use
declaration was added, removed, or altered. Existing deepEqual
assertions in network-runtime-plan.test.ts, logs-runtime-plan.test.ts,
and screen-recording-runtime-plan.test.ts already pin every produced
use object's exact {required, preferred} shape, so they double as the
before/after regression proof that this refactor is behavior-neutral.

Part of #1739 (wave 0).

* fix(contracts): move defineUse into platform-runtime-operations.ts

Review: defining defineUse in platform-runtime.ts required importing the
concrete PlatformRuntimeOperations catalog into the generic runtimeUse
primitive module, while platform-runtime-operations.ts already imports
generic runtime types from platform-runtime.ts. That's an avoidable reverse
type dependency — the lower generic primitive depended on its concrete
aggregate catalog. The unchanged SCC file count didn't prove this harmless;
it counts cycle members, not newly introduced back-edges.

Move defineUse = runtimeUse<PlatformRuntimeOperations>() into
platform-runtime-operations.ts, alongside PlatformRuntimeOperations.
platform-runtime.ts no longer imports the concrete catalog. Re-export
defineUse through the platform facade from its new source module; every
call site keeps importing it from @agent-device/contracts/platform
unchanged, and the three contracts-internal call sites now import it
directly from platform-runtime-operations.ts.

Validation: tsc (full workspace + examples/sdk), check:layering (136/136,
type-cycle count unchanged at 46), and check:affected --run (473 files /
3939 tests) all clean at the new head.
2026-08-11 18:05:32 +02:00