63 Commits

Author SHA1 Message Date
Michał Pierzchała 6db1a4270f fix(android): report the clip an Android recording really captured (#2566)
* fix(android): report the clip an Android recording really captured

Android `screenrecord` encodes a frame only when the screen changes, so a window that ends on an
unchanged screen returns a video far shorter than the requested duration, and `record stop` had
nothing to say about it: the reported `durationMs` is host wall clock from `record start` until the
export finished, which is not the length of the file that was just pulled.

`record stop` now measures the pulled MP4 timelines and reports them as `capturedDurationMs`, and
warns with the clip length against the window when the video is two or more seconds short. The
window is measured on the device's own elapsed clock, read before the stop signal and at launch,
because host wall clock drifts against the clock the encoder timestamps frames with; an unreadable
clock or a chunk that answers no duration costs the caller the claim, never the recording.

Measuring the timeline needed an ISO-BMFF box walk, which now lives in
`@agent-device/capture-kit/recording-mp4-duration` and replaces the private top-level atom scan
that MP4 container detection was doing. Stop replay through daemon recovery carries the field too,
so a completion read back from the session resource reports the same numbers it did live.

* chore(gates): enumerate the capture-kit MP4 subpaths the layering scan holds

`@agent-device/capture-kit/recording-mp4-duration` and its fixture sibling are new declared package
subpaths, so the boundary enumeration that holds every exported workspace subpath has to name them
for the layering scan to accept the Android recorder's read of a pulled clip's timeline.

* fix(capture-kit): evaluate the MP4 box scan only when a file is validated

The Coverage job's ADR-0019 eager-closure probe failed: `recording/video.ts` evaluated 25 modules on
import where the merge-base evaluated 24, because the MP4 container gate statically imported the box
walk it now shares with the clip-duration read, and `recording/overlay.ts` grew by the same module.
An entry the merge-base already carries gets no growth budget, so the edge moves behind a
function-scoped `await import`: the scan is something recording completion asks for, and importing
this module for `waitForStableFile` or WebM detection should not evaluate a box walker.

The alternative the probe offered -- hosting the walker in a module both growing entries already
evaluate -- would have put an ISO-BMFF walk in `swift-cache.ts` or `video-webm.ts`, or made the
duration read import the Swift validator machinery that sits behind `video.ts`.

* refactor(android): bracket the recording window with the host clock

Human review of #2566: the device-clock read defended against host-vs-encoder drift that does not
matter at this threshold. Quartz drifts by tens of ppm, so a 30-minute chunked recording moves the
window under 100 ms against a 2s warning threshold, while the read cost a transport operation, its
own probe budget, and two adb round trips per recording. The window is now the host elapsed time
between `Date.now()` immediately before the recorder launches and `Date.now()` immediately before the
stop signal, so the contract change and the extra device I/O are gone, and the one case where the
clocks genuinely diverge -- a host that sleeps mid-recording -- reports a shorter window and misses
the warning rather than inventing one.

The surviving clock arithmetic is one subtraction, so it lives in the window module that already owns
that concern instead of a module of its own. A stop recovered through daemon recovery now passes the
manifest's own start instant, which is the first host timestamp the recording ever had, so a recovered
stop gets the same comparison a live stop gets.
2026-09-14 13:54:30 +02:00
Michał Pierzchała 04052fdcd2 fix(android): retire recording evidence stranded by a re-adopted device id (#2564)
* fix(android): retire recording evidence stranded by a re-adopted device id

Android `record start` refused forever with "native recovery evidence already
exists" once an emulator was re-adopted under a new serial: the device-side
marker names the device identity that wrote it, reconciliation retired evidence
only when that identity matched, and the leftover classified as neither
recoverable nor retireable — an `UNKNOWN` internal error whose hint asked for a
bug report, while `record stop` owned nothing to clear.

Start reconciliation now retires evidence whose recording is terminal or whose
device identity the transport can no longer address, and only after every
artifact it names is proven released: a committed recorder through process
inspect, an uncommitted pending artifact against the recorders running on the
device. A recorder still writing is never deleted; the refusal names the writer
in `details.writer`. Remaining refusals — unreadable evidence, the other
transport mode, and an open recording this identity owns — are typed errors
carrying the marker path and the command that clears it.

Closes #2550

* fix(android): keep an unreadable recorder off the delete path

The writer probe filtered candidate processes down to the ones it could prove
were recorders, so a caller that read an empty list as "nothing writes this
path" removed an artifact from under a recorder whose /proc entries could not
be read, along with the marker that named it. A process table it could not read
at all threw a bare Error, which reached callers as an unclassified failure.

The transport answers a writer search with `clear`, `found`, or `uncertain`.
Only `clear` proves an artifact is free: start retirement refuses with
`native_recording_recorder_unproven` and owned cleanup keeps the evidence
pending, so both wait for a conclusive answer instead of deleting on doubt.

* fix(android): keep a mixed writer scan inconclusive

The writer search answered `found` as soon as it identified one recorder, which
hid the candidates it could not read. Start reconciliation already refuses a
found writer, but owned cleanup stops only the recorders it was handed and then
removes the artifact and marker — deleting under an unreadable recorder still
writing that same path.

A search now reports the writers it identified together with whether every
candidate was read. Cleanup requires both halves: an identified recorder does
not prove the others are gone, so an inconclusive scan retains the evidence
before anything is signalled or removed.

* test(android): record retirement side effects through one evidence rig

21 reconciliation scenarios each rebuilt the same marker reader and the same
recording transport stubs. One rig holds the marker and the ordered side-effect
log, so a test names only the probe outcome it is about.

* test(android): keep legacy reconciliation scenarios as they were

The scenarios that already covered retirement were rewritten into a shared rig,
which spent most of this PR's churn budget on moving lines around. They read
from the device marker again as before; only the recorder-state table names the
outcome each state now produces, and the stale row that expected evidence naming
another device identity to be kept is gone, since retiring it is this fix.
New scenarios use the rig.

* test(android): table-drive the inconclusive writer scans

Each inconclusive writer-scan scenario rebuilt the same scoped adb fake and the
same cleanup transport. The transport cases now differ only in which candidate
reads fail, and the owned-cleanup cases differ only in the scan they return, so
both run from one table against the same assertions.
2026-09-14 11:52:56 +02:00
Michał Pierzchała 15644b6f0d feat(devices): answer when a device's current boot began (#2575) 2026-09-14 11:30:53 +02:00
Michał Pierzchała 3394d5b89c fix(android): keep a scroll's swipe out of the IME window (#2514)
* fix(android): keep a scroll's swipe out of the IME window

Android is the case the clip exists for beyond iOS: an `adjustPan` or `adjustNothing` activity keeps a
window whose recorded bounds already run under the IME, so a plan built from them aims at the keys.
The helper now reports the largest input method window beside the application window, in absolute
screen pixels like the window next to it, and `scroll` clips its band with the shared rule or refuses
when the keyboard owns the surface.

An older helper reports no keyboard keys, and a provider-supplied viewport has no IME channel at all.
Both read as "nothing to avoid", which is what the shared rule already does with a missing frame;
neither turns into a refusal.

`UiAutomation.getWindows()` answers with an empty list until the service asks for interactive windows,
so the read applies the seam the tree capture already uses rather than depending on a snapshot capture
having run first in the same instrumentation; the one-shot fallback below it has no such neighbour.
Measured on a Pixel 7 emulator with an `adjust=pan` contact editor, the application window keeps its
full 2400px height while the IME window reports `[0,1517][1080,2400]`, and `scroll down` answers with
`referenceHeight: 1505`, `keyboardMinY: 1517`, `keyboardAvoided: true` and a swipe ending at 301
instead of starting at 1920 under the keys.

* fix(android): clear the composer, not just the key plane, before a scroll swipes

The helper kept the largest `TYPE_INPUT_METHOD` rectangle as the keyboard. A composer bar and its key
plane can arrive as separate windows and the key plane is the larger one, so the earlier top edge was
discarded and the clipped band still ended inside the composer: the swipe landed on keys the rule
exists to keep it off.

The read now copies every input method window and unions the ones the swipe's centre line crosses,
which is the same line the shared clip rule tests. A candidate strip at the edge of the screen that the
swipe can never reach no longer shortens the band either. The selection runs on plain window edges,
because `Rect` is a device type whose constructors throw off-device, so the two-window case is a unit
test rather than a simulator-only path.

* refactor(android): pass the measured occlusion explicitly and drop the duplicate rect check

The clip rule already fails open on a keyboard frame it cannot measure,
so the helper reader no longer needs its own copy of the check, and the
gesture-viewport validator goes back to its original body. The refusal
names its three numbers instead of spreading the clip variant, so the
discriminator never leaks into error details.
2026-09-13 13:55:38 +02:00
Michał Pierzchała 37d67de776 fix(android): carry accessibility selected state into snapshots (#2515)
The snapshot helper never serialized `selected`, and the host reads only the
helper's XML, so no later layer could recover it: `get attrs` had no `selected`
field, no snapshot node was marked selected, `is selected` could not match, and
a Maestro `assertVisible {id, selected: true}` failed with "Maestro visible
condition did not match" for a visible element while `selected: false` matched
every Android node (#2462).

The helper now emits both answers, like `enabled` and `password`, so an
unselected control answers `false` and a helper older than the attribute answers
nothing. The parser, the Android hierarchy node, and the published snapshot node
carry it to `get attrs` and the `[selected]` marker.

Snapshot lines render that marker on the default formatter path too: `--settle`
and `diff` already compared selection, and a line that weighs a fact it cannot
print turns a tab tap into a changed pair whose two lines look identical.
2026-09-12 20:18:35 +02:00
Michał Pierzchała fd4cee83f8 fix(android): retire completed recording evidence after pid reuse (#2487)
* fix(android): retire completed recording evidence after pid reuse (#2476)

A reused emulator can reassign a recorded screenrecord pid to an unrelated
process. The transport proves that replacement with `ownership-lost`, but
completed-evidence retirement and reattach accepted only `missing`, so they
treated proven termination like an uncertain live recorder: `record start`
refused forever on the retained marker, and `record stop` could not return
the already-finalized completion.

Classify the declared ownership observations once, in the contract that
declares them, and ask that question instead of comparing to `missing`.
Retirement still refuses a live or unreadable recorder and never signals a
pid it proved is not its own.

* fix(android): read proven termination in the recovery warning too

Review follow-up. Classify the recovered chunk's recorder with the owning
observation predicate, so a recorder proven gone through pid reuse also
discloses that the MP4 may be truncated instead of only a pid directory that
went absent. Attribute each `ownership-lost` producer — reassigned executable,
foreign remote path, exited task with no command line — in the transport test,
and state them in the contract comment the classification rests on.

* fix(android): retain completed recording evidence while a replacement recorder writes its path

Review follow-up. A reused pid that runs screenrecord on the recorded remote
path with a different start time proved the old recorder gone, and retirement
read that as permission to remove the artifact — deleting the replacement
recording's active MP4. Classify that observation as foreign-writer in the
contract: it still proves termination, so recovery and the truncation warning
keep reading it, but it never proves the path unclaimed, so retirement retains
the marker and artifact until the replacement ends and never signals it.
Stop-wait refuses it like ownership-lost.
2026-09-11 15:39:02 +02:00
Brad Anderson f57b42166a fix(network): report iOS requests that reused a keep-alive connection (#2433)
* fix(network): report iOS requests that reused a keep-alive connection

CFNetwork logs a request URL only on the `com.apple.network:connection`
line that opens a connection. A request that reuses a keep-alive
connection emits a task summary carrying status, timing, and byte counts
but no URL anywhere in the log, so a URL-keyed reader dropped it and the
dump silently omitted a request that did happen. An "assert this endpoint
was called on startup" check therefore read as a definite fail.

Correlate a reused task summary with the connection it names and report
it against that connection's origin, with `pathUnavailable` set, its
status, and its timing. The request path is not in the log at all, so the
dump also notes how many requests it could not name — a gap in
observation now reads as a gap rather than as a negative observation.

Also stop a URL parsed out of a log line from carrying the punctuation
that follows it, so an entry's `url` compares equal to the endpoint under
test instead of failing on a trailing comma.

The correlation lives in the reader rather than a sibling module because
`packages/capture-kit/src/index.ts` may not grow its eager import closure.

Refs callstack/agent-device#2430

* fix(network): count keep-alive requests the reader cannot name at all

Review of the parent commit found the same definite-negative it fixes,
one level down: a reused task summary whose connection was opened before
the scanned window resolves to no origin, so it produced no entry and no
signal — an empty dump reporting "No HTTP(s) entries were found" for a
window that demonstrably carried traffic. Count those in the dump's
`unnamedRequests` and say so in the notes, so an unnameable request is
still a reported observation.

Also order the Apple note builders so the keep-alive note no longer trips
the `notes.length === 0` guard that suppresses lifecycle guidance, and
give the android-backend test a fixture an Apple dump would actually
resolve, so the backend gate it names is the thing it proves.

* fix(network): scope connection correlation to the process that opened it

Review findings on the parent commits: three ways the reader still answers
with something other than what it observed.

A connection number is only meaningful within one process, but the index
keyed on the number alone, so an app that relaunched and reopened the same
number inherited the origin its predecessor had contacted — a request
attributed to a host it never reached, which is worse than dropping it.
Key the index by the compact log's `name[pid]` and the connection number
together; a line whose process cannot be read correlates to nothing and its
traffic stays unnamed.

The simulator recovery pass merged its dump only when it carried entries,
so a recovery window holding nothing but unnameable reused-task summaries
discarded that count and the response still reported an empty window. Merge
whenever the pass observed traffic in either form, and reserve the "none
looked like HTTP traffic" note for a pass that found neither.

The trailing-separator strip was global, so a valid URL ending in
punctuation became a different endpoint. Take the URL from the delimited
`url:` field where the format establishes the separator, and leave a bare
URL exactly as matched.

Regressions cover each: the same connection number under a different pid,
an unreadable process identity, recovery-only unnamed traffic, and a path
that legitimately ends in a period.

* fix(network): reconcile unnamed keep-alive requests across scan windows

The app log and the simulator recovery pass cover different, sometimes
overlapping windows, so taking the larger of their two unnamed counts was
wrong in both directions: two unnameable requests in one window and three
in the other reported three rather than five, and a request the recovery
pass resolved stayed counted as unnamed from the app log.

Carry the identities instead of a count. Every CFNetwork line names its
request as `Task <UUID>.<seq>`, scoped here to the emitting process, so the
same request seen in two windows is recognisable as one. A merge unions the
unnamed identities and subtracts anything either window managed to name, and
a resolved reused request carries its identity as `packetId` so that
subtraction has something to key on.

`NetworkDump.unnamedRequests` becomes `unnamedRequestIds`, since a list of
identities is what makes the reconciliation exact rather than a lower bound.

Regressions cover disjoint windows, overlapping windows, and a request one
window named while the other could not.

* fix(network): keep unnamed-request identities out of the response

`unnamedRequestIds` collected every unresolved task in the scan window and
was spread straight into the response, so `network dump 1` could answer
with thousands of task ids: an output whose size tracked the log rather
than the requested entry limit.

The identities exist to reconcile two scan windows, which is a step that
finishes before a dump is returned. Keep them there. `NetworkDump` carries
`unnamedRequests` as a count again, bounded by construction; the identities
ride `ScannedNetworkDump`, the internal widening that the reader and the
merge speak, and the Apple runtime projects them away with
`withoutScanIdentities` on the way out.

Reconciliation is unchanged: overlapping windows still collapse to one
request and disjoint windows still sum, because the merge still sees the
identities and recomputes the count from them.

Regression: five unnameable tasks against `maxEntries: 1` reports all five
and exposes no identity list.

* fix(network): return scan identities beside the dump, not on it

The Apple route stopped leaking task identities into its response, but
Limrun and WebDriver return the scanner result directly and both serve
Apple sessions, so an iOS `network dump 1` through either still answered
with every unresolved task id in the scan window. Projecting at one
producer was never going to hold: `ScannedNetworkDump` was assignable to
`NetworkDump`, so returning the scanner result compiled everywhere and
each producer had to remember not to.

Take the shape away instead. `readRecentNetworkTrafficFromText` returns a
`NetworkScan` — `{ dump, unnamedRequestIds }` — so identities sit beside
the public dump rather than on it, and `mergeNetworkScans` reconciles the
pair. A route returning `scan.dump` cannot carry them out, and a route that
forgets does not compile. All four producers are updated; the response
shape is unchanged.

Regressions cover the Apple, Limrun and WebDriver routes: five unnameable
tasks against `maxEntries: 1` report the count and expose no identity list.
All three fail if the identities are put back on the dump.
2026-09-10 20:44:16 +02:00
Michał Pierzchała 2d109e5cc5 refactor(contracts): additive capability facts on the clipboard family (#2464)
* refactor(contracts): make clipboard capability facts additive

Additive capability facts on the clipboard family (#2443, family 3).

clipboardRuntimeOperationFacts took exactly two cells, read and write, and both are
required. The halves stay separate claims — a WebDriver provider whose Appium clipboard
extension exposes only a getter is a real owner with one half and not the other, and
`clipboard read` must not be refused because the write half is missing — so both are
optional and `unsupported` names the denial an unnamed half reports. A call naming no
denial is still refused: omission is a classified refusal, never an unclassified half
and never an implied success.

Six of the eight owners drive both halves from one shell command set or one leaf gate,
so they now state that once: web, HarmonyOS, and Vega name a single denial, and the
shared unavailable record carries one clipboard cell where it carried two. The owners
that serve clipboard keep naming both halves, and the one install-source double that
enumerated the two keys by hand now goes through the family builder like every other
construction site.

Fact values are unchanged: every owner's clipboard cells are byte-identical, and the
family's own test pins an unnamed half reporting the stated denial verbatim, an owner
naming nothing answering with the exhaustive shape, and the freeze.

* fix(contracts): derive clipboard family denials from the served cell

Review follow-up on the clipboard family.

Apple restated its leaf branches to choose a clipboard denial, so the leaf split lived
twice in one file. It now reads the leaf's own refusal, keeping a leaf-scoped
placeholder only where the leaf serves clipboard and therefore has no clipboard
refusal to state.

Android named a build-level shell verdict as its family denial, which is the claim its
own probe refuses to make when the probe did not complete; the denial now follows the
probed cell, so an unnamed half reports the unknown rather than a verdict.

Pin the shared unavailable record's clipboard fan-out the way the keyboard commit
pinned its own: one input cell, two operations, each with its reason.
2026-09-10 17:02:32 +02:00
Michał Pierzchała 1131fb3118 refactor(contracts): additive capability facts on the gesture family (#2460)
* refactor(contracts): make gesture capability facts additive

Additive capability facts on the gesture family (#2443, family 2).

gestureRuntimeOperationFacts took one fact per tier, and it had five: plan,
directionalFling, multiTouch, targetAuthoredDrag, viewport. The tiers split exactly
where an owner's mechanics split — no tier is one every owner serves, and several
owners serve none — so every tier is optional now and `unsupported` names the denial
an omitted tier reports. The type still refuses a call that names no denial at all:
omission is a classified refusal, never an unclassified tier and never an implied
success, and an owner cannot leave the family blank.

An owner states a tier only to say something the family denial does not. Web refuses
every tier with one cell and names only the drag tier whose wording came from a
retired closure. Vega names the two tiers whose refusals came from separate closures.
Limrun's session-less and iOS branches name one denial each, where each wrote five.
The owners that serve gestures keep naming what they serve: the win is the next tier,
which an owner can now ignore entirely.

Fact values are unchanged: every owner's gesture cells are byte-identical, and the
family's own test pins an omitted tier reporting the stated denial verbatim, an
owner that names nothing answering with the exhaustive shape, and the freeze.

* fix(platform-apple): name the gesture family denial by leaf

Review follow-up on the gesture family.

Apple refuses gestures for two reasons depending on the leaf: watchOS and visionOS
have no gesture surface at all, while every other leaf without a touch kind is the
wrong device kind. One constant cannot be truthful for both, and the family denial
is what the first unnamed tier will report, so it now follows the leaf.

The Limrun gesture call collapsed to one source for the session-less branch, so pin
the reason and hint it reports there rather than availability alone, and cover the
directional-fling tier the loop had skipped.
2026-09-10 17:02:32 +02:00
Michał Pierzchała d80b021fa5 refactor(contracts): make keyboard capability facts additive (#2459)
The keyboard family's facts builder took one required cell per operation, so an
operation only one owner implements still cost a hand-written denial in every
other owner. The builder now takes the family's denial once, as `unsupported`,
and every operation cell is optional: an owner names what it serves and omission
reports that denial verbatim, with the reason and hint the owner would otherwise
have repeated per cell.

Omission stays a classified refusal, never an unclassified cell and never an
implied success: `unsupported` is required, so a call that leaves the family
blank does not compile. An owner that names every operation still states the
family refusal, and it must refuse the family rather than one operation of it —
whatever the owner leaves unnamed reports that cell verbatim.

The shared unavailable-facts input collapses `keyboardStatus`/`keyboardDismiss`/
`keyboardEnter` into one required `keyboard` cell, so the owners that answered
the family with raw keys (webdriver, vega, linux, limrun's no-session binding)
now answer it through the builder, which is the family's one entry point.

Facts are unchanged for every owner, so the per-platform admission assertions
hold untouched. Two owner tests now assert the keyboard reason as well as the
availability, because one family cell reports one reason for all three
operations: the WebDriver inactive session and the stale Limrun identity.

Refs #2443
2026-09-10 17:02:31 +02:00
Michał Pierzchała 41e2633f10 fix(wait): retire native selector bypass and recover text observations (#2440)
* fix(apple): resolve selector waits through canonical capture

* fix(wait): retire dead selector observations and recover native text failures
2026-09-10 12:03:58 +02:00
Michał Pierzchała 6ca66c9fad fix(android): ease controlled scrolls within the requested duration (#2393) 2026-09-10 10:15:34 +02:00
Michał Pierzchała edbd96ea30 refactor(contracts): additive capability facts pilot on touch family (#2420)
* refactor(contracts): make touch capability facts additive

The touch family's facts builder took one required cell per operation, so an
operation only one owner implements still cost a hand-written denial in every
other owner. The builder now takes the family's denial once, as `unsupported`,
and every operation cell is optional: an owner names what it implements and
omission reports that denial verbatim, with the reason and hint the owner would
otherwise have repeated per cell.

Omission stays a classified refusal, never an unclassified cell and never an
implied success: `unsupported` is required, so a call that leaves the family
blank does not compile. Facts are unchanged for every owner, so the per-platform
admission assertions hold untouched.

Refs #2412

* fix(contracts): keep tap/longPress/fill required in touch facts

Making every touch cell optional dropped the compile-time guard on the
three operations every owner implements, with nothing replacing it: an
owner could silently drop `tap` and nothing would fail. Only the truly
per-owner cells (tapRef, hover, hoverRef, fillRef, tapElementSelector)
need `unsupported` as their default.

Restoring the required fields caught a real regression this refactor
introduced: platform-web's touch facts had silently lost their explicit
`longPress: readinessUnavailable` cell, so longPress reported the
family's `unsupported` fallback instead of the owner's own readiness
reason. Restored it, and fixed the other now-required-field call sites
(the fully-unavailable owner fixtures, and the screenshot-runtime-fixture
test double, which had also silently lost its longPress/fill distinction).

Refs #2412

* fix(coverage): repoint hover evidence off the retired runtime.ts source line

The touch family's additive-facts refactor dropped every owner's explicit
hover: unavailable cell in favor of the family-level unsupported fallback,
so the coverage manifests' literal-substring checks against
packages/platform-apple/src/runtime.ts and packages/platform-linux/src/runtime.ts
broke: that source line no longer exists.

Point the macOS, iOS, tvOS, and Linux hover rows at new tests that assert the
typed unavailable denial directly against each owner's bound facts, instead of
re-adding a hand-written denial line the refactor was meant to retire.

Refs #2412

* test(coverage): point hover evidence at the typed-denial tests in the declaration table
2026-09-09 22:16:45 +02:00
Michał Pierzchała 342e98cff9 refactor(daemon): separate open-target policy from platform mechanics (#2416)
* refactor(daemon): separate open-target policy from Android mechanics

Move resolveAndroidPackageForOpen/inferAndroidPackageAfterOpen behind
the Android owning seam in packages/platform-android. resolveSessionAppBundleIdForTarget
now lazily reaches Android mechanics itself instead of taking an
injected resolver function, so open-prepare and selector-dispatch
import only the neutral open plan/result surface from
platform-runtime-open-target.ts. Reclassifies the two R74 inventory
edges to daemon-policy-essential and updates ADR 0022.

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

* refactor: address adversarial review findings on open-target seam

Restore try/catch around the Android-mechanics lazy load so a module
load failure still resolves to undefined instead of throwing. Rename
the unrelated private resolveAndroidPackageForOpen in app-lifecycle.ts
to requireAndroidPackageForOpen to remove the naming collision with
the new exported function. Add a planted-violation regression test
for reintroducing Android mechanics on the selector-dispatch edge.
Tighten ADR/inventory wording that overstated which files consume the
neutral resolver.

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

* fix(platform-android): keep the mechanics facade lazy for the new open-target exports

resolveAndroidPackageForOpen/inferAndroidPackageAfterOpen were re-exported
statically from mechanics.ts, which eagerly evaluates open-target-resolution.ts
on import and tripped the eager-closure-budgets gate (177 -> 178 modules).
Wrap them as lazy async functions, matching the existing pattern used for
listAndroidAppsWithAdb/captureAndroidLogcatWithAdb in the same file.

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

* fix(android-tools): keep inferOpenedAppBundleId best-effort on a mechanics load failure

Loading Android mechanics moved from the near-infallible root
platform-runtime-open-target.ts to the real adb-backed mechanics
module, but the wrapper call stayed unguarded. A loader failure now
throws instead of leaving the app-bundle identity unset, even for a
targetless open that never needed the loaded module. Wrap the load
and delegate in try/catch so it degrades to the current bundle id,
matching the pre-refactor behavior, and add a regression test with
the loader rejecting on a targetless open.

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

* perf(android-tools): skip loading Android mechanics when app-bundle identity is known

inferOpenedAppBundleId always loaded Android mechanics before
delegating, even when currentAppBundleId already made the delegate's
own fast-return a no-op. Check it first so the load is skipped
entirely once the identity is already known, and add a regression
test asserting the loader is never called in that case.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-09 17:35:12 +02:00
Michał Pierzchała 06773095c6 fix(android): honor scroll releaseBehavior to stop the fling overshoot (#2372)
* fix(android): honor scroll releaseBehavior to stop the fling overshoot

scrollAndroid ignored ScrollReleaseBehavior and always released the pan
like a fling, so a controlled `scroll` overshot its requested distance
by ~60% on both a plain RecyclerView and an RN ScrollView. Android's
VelocityTracker treats a truly stationary release as a resampled
duplicate and ignores it, so a hold alone can't stop the fling.

For the default 'controlled' release, append a short tail after the
pan's endpoint that holds the scroll axis exactly fixed (zero velocity
there) while nudging the orthogonal axis every frame so no two
consecutive samples repeat. 'inertial' (the scroll top/bottom edge
passes) is unchanged. Live-measured on a Pixel 9 Pro XL emulator:
displacement lands within touch-slop of the requested drag instead of
overshooting it.

Fixes #2371

* fix(android): cap the controlled-release tail at the gesture duration ceiling

The release tail was appended after buildGesturePlan's own duration
validation, so a maximum-duration (10000ms) scroll silently produced a
10160ms plan beyond GESTURE_DURATION_MAX_MS. Cap the tail so the
dispatched plan never exceeds that shared ceiling, shrinking (and, at
the exact maximum, dropping) the tail near the boundary instead of
truncating the requested move. Add regression coverage for the exact
maximum and near-maximum cases.

Also fix the stale android-lifecycle provider-integration expectation:
a plain scroll resolves to 'controlled' release by default, so its
dispatched plan is now 510ms (350ms move + 160ms tail), not 350ms.

* fix(android): reject a controlled scroll that leaves the release tail no room, instead of shrinking it

The previous fix capped the tail near GESTURE_DURATION_MAX_MS, which
silently dropped the fling-suppressing braking step for scrolls close
to the ceiling -- defeating the point of the fix exactly where a long
scroll needs it. A controlled scroll's own accepted duration range now
stops CONTROLLED_RELEASE_TAIL_MS short of that shared ceiling
(9840ms), rejecting anything past it with a clear INVALID_ARGS error
instead of truncating the move or the tail. Every accepted controlled
scroll now runs the full, unshortened tail; 'inertial' releases are
unaffected and keep the full 10000ms range.

Also drop the multi-line explanatory comment on the corrected
android-lifecycle.test.ts expectation (a same-line comment instead)
so the fix doesn't grow that file past its line-count tripwire.

* docs(scroll): describe reduced momentum and the Android controlled-scroll limit

The scroll docs only carried the generic app-physics caveat, not the
release-behavior contract itself. State plainly that scroll releases
with reduced momentum rather than an exact stop, and document
Android's controlled-release durationMs ceiling (9840ms, reserved
headroom for the braking tail added in #2371) and that a longer
request is rejected rather than silently truncated.
2026-09-08 21:52:20 +02:00
Michał Pierzchała 64b7cc45d4 fix(android): return from orientation once the display reports the rotation (#2356)
`orientation` wrote accelerometer_rotation and user_rotation and returned
at once, while the display rotated some time later. On the loaded CI
emulator that takes seconds, and accessibility reads hang meanwhile: the
Android smoke's `wait text landscape` right after `orientation
landscape-left` got a helper request timeout and then no readable
capture for its whole 10s budget, with the failed-step snapshot taken
afterwards already in landscape (PR #2344, run 34025424834).

The command now polls `dumpsys display` for mCurrentOrientation to
match the requested rotation before returning, each probe bounded by
what is left of the 15s settle budget so a stuck probe ends the settle
as a failure. A display that never gets there fails the command with the
observed rotation instead of reporting success; a display that reports
no rotation at all is left to the setting as before. The provider
scenario scripts the display read against the last user_rotation write.
2026-09-07 10:14:09 +02:00
Michał Pierzchała d26b0786fb perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner (#2329)
* perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner

Local Simulator opens now decide how much the XCTest runner is needed from the
runtime operations declared by the steps still ahead in the same batch: an
observation-only plan starts no runner, an unknown plan keeps the speculative
prewarm without ever awaiting it, and a plan with an interaction prepares
readiness for that step. open --relaunch no longer waits for runner readiness
on a Simulator and resets the runner target only when a session is already
alive. The Apple find ports report not-proven instead of starting a runner on
a Simulator without a live session, so wait and read-only find observe through
the canonical AX-bridge tree. Physical devices keep their lifecycle unchanged.

The plan travels through the server-private internal request channel, never
the wire; the Apple owner maps declared operations to a runner demand through a
record complete over the runtime operation union.

Refs #2198

* test(fixtures): share one inert audio-probe host across the platform runtime fixtures

The Apple and Android runtime fixtures carried identical audio-probe doubles;
host-kit now owns the one copy and both fixtures import it. Also folds the two
Apple native-find ports onto one admission helper and lifts the Simulator
runner prewarm policy out of the open sequence, keeping both under the
complexity gate.

* fix(ios): answer runner liveness through the runner provider seam

The find ports and the relaunch target reset asked the local session registry
whether a runner was alive, which misreads scripted and request-scoped runner
providers as absent. Liveness is now a provider question: the local provider
consults its session registry, a provider without startup cost counts as live,
and an awaited prewarm proves liveness without asking.

* perf(ios): select plan uses from step input and give young Simulator targets a bounded bridge grace

A snapshot, diff, or find step now selects the runtime uses its structured input
reaches, the way its handler does, so a plain snapshot no longer counts the
custom-actions alternative and an observation-only batch resolves runner demand
none. The descriptor declares the selector next to its alternatives; the daemon
plan derivation honors it and keeps the union for every other command.

Without the runner wait, the first snapshot after an open reached the AX bridge
while the app was still becoming the primary foreground owner or registering
its accessibility server, and the typed fallback then started the runner the
plan had just avoided. A target younger than ten seconds is re-read for a
bounded grace measured from the first such failure: five seconds for a missing
AX server, one second for an ownership miss so a launch-time system dialog
still reaches the fallback quickly. Established targets get no grace.

* fix(ios): a registered runner session counts as live only once it has answered

A session record exists while xcodebuild is still connecting, so an alive
child pid is not a runner that can answer. Treating it as live sent the
relaunch target reset into a starting runner, queued behind its connection
retries, and the failed reset invalidated the very session the prewarm was
building. Liveness now also requires the session's readiness flag, which the
first successful runner response sets.

* refactor(ios): lift the bridge launch grace out of the snapshot route capture

* test(descriptors): pin the snapshot, diff, and find step-use selectors

* test: stub runner operations in the replay test-runner suite and keep runner-session tests within the size ratchet

A Simulator open schedules a best-effort runner prewarm that outlives its
request. The replay test-runner suite opened a Simulator with the real Apple
tools, so the prewarm's deferred import resolved after the file finished and
spawned into whichever file the worker ran next, where the hermetic signal
guard failed an unrelated test.

* fix(plan): count only required operations and read find and snapshot steps the way their handlers do

Runner demand now counts a command's required operations only: a preferred or
conditional operation is a measured fast path the command succeeds without, so
get, wait, and read-only find stay observation-only. The step selectors for
snapshot, diff, and find live next to the registry and read the daemon step
exactly as the handlers do: the daemon flag for custom actions, and find's
positionals through the same parser, where a missing action is a click and an
unparseable step keeps every declared alternative. The handler and the selector
share one action-to-intent map. The batch runner hands each step its remaining
steps in handler shape, and the derived operations reach the platform as a
typed list on the lifecycle execution instead of an untyped plan on every open.

* perf(ios): let open wait for the launched app to become observable, and make runner liveness explicit

The snapshot route no longer infers a launch from process start text and
retries inside its own capture. Open owns launch timing instead: a local
Simulator open asks the AX bridge whether the launched app is observable,
bounded by per-code windows measured from the first typed launch-transition
failure and never extended, so an ownership miss seen after an AX-server miss
shrinks the deadline to the ownership window and a launch-time system dialog
still reaches the typed fallback quickly. Any other device, or a bridge that
cannot answer, keeps the fixed settle. The open response reports what it
learned.

Every runner provider now states whether it can answer without a startup wait;
a bare executor answers directly by construction and scripted providers say so.
The runner prewarm policy and the observation settle move out of the open
sequence into their own module, and the native find admission is named for what
it admits.

* docs(context): keep the runner-demand vocabulary within the guidance budget

The enumeration and the no-public-flag rule live on the contract type that
owns them; CONTEXT.md keeps the term itself, and two neighbouring entries lose
words that carried no meaning.

* refactor(contracts): name the runtime operation vocabulary below the operations union

The lifecycle execution carries the operations a plan requires, but typing
that list with the operations union closed a 36-file type cycle: the
operations types depend on the lifecycle types. The vocabulary now lives as a
const list below both, proven equal to the union by a type test, so the plan
is typed end to end, the Apple host table indexes it without casts, and the
daemon narrows descriptor names through a guard instead of a cast.

* fix(apple): reach runner liveness through the memoized operations loader

Every Apple tool port loads the runner operations through the one memoized
loader (#2314): a port that opens its own dynamic import can resolve the
unmocked module while a test's mock factory is still loading and let a real
local runner escape. The liveness port now uses the loader like its siblings;
the facade members consumed only through the loader are declared to fallow,
and the plan resolver reads one step per helper to stay under the complexity
threshold.

* fix(ios): keep bridge-only behavior to iOS Simulators

The launch observation, the runner-free find admission, and the relaunch
policy apply only where the host AX bridge exists: iOS Simulators. A tvOS
Simulator keeps its awaited prewarm and asks for no observation, which the
tvOS provider scenario now pins.

* bench(ios): add a first-interaction cell to the snapshot convergence harness

An open that defers runner readiness moves its cost to the first
runner-dependent command. The cell starts each sample like cold, opens the
fixture untimed, then times the first press that follows (the deep-link
confirmation when the launch URL raises it, otherwise the screen anchor).

* bench(ios): read the deep-link confirmation from a snapshot and by node type

The open response carries no tree and regular snapshots publish the node type,
so the confirmation iOS raises for a launch URL was never seen on this runtime
and every deep-linked cell failed its anchor check.

* refactor(plan): keep the step-use selectors inside the registry

The eager-closure ratchet counts every module the registry loads; the
selectors need nothing the registry does not already import, so they live
beside find's recording-effect reader instead of adding a module to every
entry that loads the registry.

* feat(apple): release a speculative runner when the plan is proven observation-only

#2198 requires a `none` runner demand to retain no runner, not only to start none. A runner a
prewarm started that no command has used yet is speculative: the session records that mark at
creation, the first command that is not a readiness probe clears it, and a Simulator open whose
plan is proven observation-only asks the runner owner to release a speculative session in the
background, so the observation path never waits for a runner to stop either. A runner that has
served a command is the session's working runner and stays under the existing idle-stop policy,
so a mixed workload does not pay a cold runner start at every observation-only open.

The release goes through the runner provider seam: the local provider stops its own speculative
session; a provider that never starts speculative work omits the operation and releases nothing.

* bench(ios): press an unambiguous target on the catalog and Settings screens

The first-interaction cell pressed the screen's anchor text, which on the catalog and iOS
Settings screens names two actionable elements (the native tab and the screen title); the CLI
refuses that as AMBIGUOUS_MATCH by design, so those two cells could never measure anything.
Each such screen now names the element the cell presses.

* fix(ios): keep observation on the bridge while app discovery is pending and no runner is live

#2331 bounds one capture's wait for the Simulator app discovery and takes the XCTest fallback
past it; #2198 stops a Simulator open from awaiting the runner. Together, a `wait` right after a
relaunch on a loaded host fell back to XCTest while the runner was still starting, spent its poll
budget on that start, and timed out (the iOS smoke lane after the main merge). A capture with no
live runner now stays on the single-flight discovery, one wait slice at a time, until the
discovery's own deadline or the request signal ends it; a runner that is already live still takes
the fallback at once, the cheaper route #2331 chose.

* fix(apple): queue a speculative-runner release behind a start that is still in flight

A `possible` open's prewarm registers its session only when the start completes, so a `none` open
that released in that window found nothing and the runner it meant to release survived as a
retained speculative session. The release now takes the runner session lock: it queues behind the
in-flight start, sees the registered speculative session, and stops it; a start a command asked for
is left alone. Two deferred-start regressions pin both outcomes.
2026-09-07 10:12:58 +02:00
Michał Pierzchała bd08e6e0f2 refactor(contracts): move single-owner modules out of @agent-device/contracts (#2357)
* refactor(daemon): move root-only contracts vocabulary into its owning zone

Six @agent-device/contracts modules had no consumer outside the root zones,
so the shared vocabulary package carried types only the daemon and root
composition ever read. Each one moves to the zone that owns it and every
consumer switches to the owning module; no re-export stays behind at the old
contracts path.

- perf-runtime-plan, snapshot-timeout-evidence, platform-resource-cleanup ->
  src/daemon
- daemon-owner-cleanup -> src/
- interaction-error -> src/core

wait-runtime-plan stays in contracts: @agent-device/command-registry consumes
it, so it is not root-only after the registry package landed.

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

* refactor(platform): move single-consumer contracts modules into their platform package

Four modules in @agent-device/contracts had exactly one consuming package, so
the shared vocabulary carried Android- and Apple-specific shapes no other zone
could use. Each moves into the package that owns it, with every consumer
switched to the owning module and no re-export left at the old contracts path.

- android-helper-artifacts -> platform-android/src/helper-artifacts.ts
- android-touch-plan -> platform-android/src/touch-plan-lowering.ts, which
  also retires the package-local touch-plan.ts re-export barrel that existed
  only to give the contracts module a local name
- snapshot-presentation -> platform-android/src/snapshot-presentation-node.ts
  (renamed to keep the package's existing Android-specific
  snapshot-presentation.ts distinct)
- apple-multitouch-support -> platform-apple/src/multitouch-support.ts

APPLE_OS_DISPLAY_NAMES folds into gesture-admission.ts, its one remaining
contracts caller, so both gesture refusals still share one copy of the wording
without a new contracts subpath for a table its own doc calls non-public.

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

* refactor(core): move the replay divergence implementation into src/core

replay-divergence.ts mixed the wire vocabulary every zone reads with the
sanitizing, bounding and reporting implementation only root zones call. The
ten value consumers are all root (daemon replay, the session replay
coordinator, the daemon client lifecycle, the replay-test reporter, the
command error projection, and the MCP tool error), so the implementation moves
to src/core/replay-divergence.ts and carries its test unchanged.

The types stay in contracts and keep the @agent-device/contracts/divergence
subpath, which packages/ad-replay and packages/selectors type-import.
ReplayVarScrubEntry follows the implementation: it is the sanitizer's own
parameter shape, not part of the divergence wire report.

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

* chore(gates): shrink the contracts export surface by the moved subpaths

The nine relocated modules no longer live in @agent-device/contracts, so its
exports map drops their subpaths (118 -> 109) and
scripts/layering/contracts-exports.snapshot.json is regenerated from the
manifest, which is what R11 package-boundaries diffs the live surface against.

The two resolution assertions naming the retired snapshot-presentation and
snapshot-timeout-evidence subpaths go with them; interaction, snapshot and
react-native-overlay still cover both the direct-module and facade shapes the
assertions were there to prove.

The property tests that needed fast-check left with snapshot-presentation and
replay-divergence, so the dependency moves too: contracts drops it and
platform-android declares it, as fallow's unused-devDependency check reports.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 12:55:14 +02:00
Michał Pierzchała ebdaa7617e feat: delegate reviewed managed automation (#2312)
* feat: delegate reviewed automation through managed lease authority

* fix: preserve lazy simulator readiness through scoped authority

* fix: admit managed operations at their dispatch boundary

* test: move managed automation scenarios to integration lane

* chore(gates): declare the private managed readiness scope export
2026-09-06 08:10:59 +02:00
Michał Pierzchała 07e25087e1 fix: contain managed ADB transport to its lease target (#2311)
* fix: contain managed ADB transport to its lease target

* fix: block managed ADB server administration and inherited sockets
2026-09-06 08:10:58 +02:00
AdzeB f30328d086 fix(android): preserve editable-field metadata in snapshots (#2290)
* fix(android): preserve editable-field observation metadata

* fix(android): carry field facts through the attrs digest and selection offsets past editability

- `get attrs --level digest` kept only the pre-#2288 semantic fields, so
  `editable`/`password`/`hintShowing`/`selectionStart`/`selectionEnd`
  vanished on the token-cheap route. The digest now keeps them, with a
  regression covering explicit false/zero/empty and omission when unavailable.
- The helper emitted selection offsets only inside `isEditable()`, but
  read-only selectable text exposes a selection too. Each nonnegative offset
  is now emitted independently; -1 stays absent. Parser-to-snapshot regression
  for a non-editable selectable node.
- Docs: the field-metadata notes get their own section instead of leading the
  efficiency tips.
- Dropped the test-isolation commit: main already mocks
  notifyIosRunnerAppRelaunched, and the lifecycle test passes without it.

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-09-06 07:32:43 +02:00
Michał Pierzchała 80997b6bf1 fix: stop stamping recovered iOS captures truncated; confirm Android alert dismissal (#2315)
* fix: stop stamping recovered iOS captures truncated; confirm Android alert dismissal

Two CI flake families on main and PRs since 2026-09-03.

iOS Smoke, `is absent ... capture was truncated` (7 of 13 failures): the
runner's stampedSnapshotPayload set `truncated: true` on every non-healthy
capture, so a complete private-AX tree taken while the XCTest channel was
penalized as slow (the normal state on a loaded CI host) was reported as
truncated. Nothing consumed that until the strict absence assertion (#2245)
refused truncated captures. `truncated` now tracks completeness only:
payload truncation, a depth-limited capture, or a sparse terminal payload.
The E2E conformance helper asserted the old conflation and now asserts
`truncated === false`; a runner unit test pins the new contract and joins
the targeted list in ios.yml.

Android Smoke, `get text id="automation-alert-result"` selector miss (5 of
5 failures): #2260 replaced a polling wait with a one-shot read right after
`alert dismiss`, and Android's `alert accept|dismiss` returned as soon as
the button was pressed, while the dialog window was still the only thing in
the accessibility tree. They now poll until the same dialog is gone (a
different alert taking its place counts as dismissed), bounded by the
existing action budget, else fail with "did not dismiss the visible alert"
like the iOS runner already does.

* test(provider): model Android dialogs that leave the tree after the alert action

The scripted Android alert scenarios served the same dialog to every
capture, which encoded the old return-after-press behavior; alert
accept/dismiss now confirm the dialog is gone, so a dialog that never
leaves is the failure it should be (covered by a new scenario). The
fixtures now hide the dialog once its button is tapped or Back is sent,
the way the ANR recovery scenario already did.

* test(e2e): wait for the alert outcome before reading it; dump evidence for any failed step

The Android smoke still missed `id="automation-alert-result"` on CI right
after a confirmed dismissal: the daemon opened a fresh helper session for
that read and its 2s capture had no such node, while the same one-shot
read passes locally in 150ms. The fixture's re-render after the button
callback is app timing, so the scenario waits for the outcome text (the
polling landmark #2260 removed) and then pins it to the canary element.

The harness kept only a screenshot, and only for wait timeouts, so the
tree that produced a selector miss was never in the artifacts. Every
unexpected step failure now writes failed-step-N.png and
failed-step-N-snapshot.json next to failed-step.txt.

* test(provider): move the Android alert scenarios and dialog fixtures out of android-lifecycle

The test-file size ratchet rejects growth in android-lifecycle.test.ts
(1,597 lines at the merge-base), and the dialog re-check work added a
scenario there. The alert scenarios now live in android-alert.test.ts
and the scripted dialog surfaces they share with the ANR scenarios in
android-dialog-fixtures.ts; the lifecycle file drops to 1,260 lines.
2026-09-05 23:15:21 +02:00
Michał Pierzchała 0c8227e9b7 refactor(runtime): let platform runtimes list apps and read app state directly (#2295)
* refactor(runtime): let platform runtimes list apps and read app state directly

The root host carried two adapters, appInventory and appState, that only
forwarded a platform call back into that platform's own package. Each platform
runtime now performs its own listApps and appState call through a lazy import
inside its package, keeping the deferred load, the AbortSignal threading, and
the package/bundleId -> id rename. PlatformRuntimeHost loses both keys, so
Android, Apple and Harmony fixtures no longer stub the two platforms they do
not own.

Android is the one platform runtime whose package now reaches adb directly.
The adb host that adb mechanics require is bound by a module side effect that
only the root can perform, so the Android runtime-module registration binds it
before the module loads. loadAndroidMechanics keeps its own binding import for
the root host ports that reach mechanics without binding a runtime; neither
binder subsumes the other.

Android appstate now runs one foreground-focus loop instead of two. The host
shaped readAndroidAppState/AndroidAppStateHost pair is gone: limrun's adapter
already closes over its own adb executor, so it calls the executor variant
directly, and that variant took the per-attempt abort check the host variant
had. AppStateRuntimeCommand and AppStateRuntimeCommandResult described the
deleted host port and go with it.

Tests: the new ordering test in
src/platform-runtime-android-adb-binding.test.ts was seen red by deleting the
binding import from that registration (order came back
["android-runtime", "adb-host"]); the composed-gateway listApps test in the
same file was seen red by reverting the Android runtime's inlined listApps to a
host.appInventory lookup (TypeError reading 'android'); the new abort test in
packages/platform-android/src/app-state.test.ts was seen red by removing both
signal?.throwIfAborted() calls from readAndroidFocusWithExecutor (the second
dumpsys was issued and the call resolved). All green after.

* chore(gates): drop the retired app-inventory/app-state host allowances

The two PLATFORM_RUNTIME_HOST_FILES rows point at host files this change
deletes, and the ./platform-runtime-app-state-host.ts composition allowance has
no importer left.

* refactor(runtime): construct the Android runtime module with its adb host binding

The Android runtime now calls adb from inside its package for listApps and
appState, which needs the process-wide adb host port bound. That dependency
was hidden in a registry wrapper doing a side-effect import, with a paragraph
explaining why it and loadAndroidMechanics did not subsume each other and an
import-order test pinning the ordering. The package now declares the
dependency: createAndroidRuntimeModule({ bindAdbHost }) awaits the binding
before the runtime loads, and the composition root supplies the one binding
implementation (evaluating its adb host module). The wrapper, the paragraph
and the import-order test are gone; the routed listApps test stays and a
routed appState test joins it.
2026-09-05 22:40:42 +02:00
Michał Pierzchała 54df6f3f4b feat(runtime): route managed leases through contained transports (#2285) 2026-09-05 19:10:51 +02:00
Michał Pierzchała 3726f027ce test(platform): extract Apple perf and Android runtime fixtures (#2253)
* test(platform): extract shared fixtures from the Apple perf and Android runtime suites

Move the duplicated host-command routing, app-bundle writers, and ps
handlers behind packages/platform-apple/src/core/__tests__/perf.fixtures.ts,
and the Android runtime host, device, and ordinary-bind builders behind
packages/platform-android/src/runtime.fixtures.ts. Every test keeps its
title and its own assertions; test and assertion counts are unchanged.

* chore(gates): exclude .fixtures.ts modules from changed-line coverage

runtime.fixtures.ts (packages/platform-android/src) sits outside any
__tests__ dir, so vitest's coverage.include picked it up as production
source and the changed-line gate scored its 20 unreachable construction
lines directly, failing at 39.39% against the 70% threshold.

Add '**/*.fixtures.ts' to coverage.exclude (repo-wide convention:
40+ fixture modules, all test support, never production). The file
disappears from lcov and scripts/coverage-changed/model.ts's existing
excluded-path fallback reports it non-gating instead, with no
duplicate classifier needed there.

Planted red (targeted run against the real diff/model, not committed):
BEFORE (file present in lcov, all added lines uncovered): totalLines=110
coveredLines=0 pct=0 passed=false. AFTER (file absent from lcov post-fix):
totalLines=0 excludedTotal=99 excludedReason=excluded-path passed=true.
Confirmed against a real `vitest run --coverage` + `check:coverage-changed`
pass: runtime.fixtures.ts absent from coverage/lcov.info, gate PASS,
99 lines reported under excluded/excluded-path.
2026-09-03 15:58:03 +02:00
Michał Pierzchała 79391375c6 test(android-e2e): make an alert-dismiss failure self-explaining (#2260)
* test(android-e2e): make an alert-dismiss failure self-explaining

The Android smoke scenario asserted the post-alert canary text with a
screen-wide `wait text`, whose timeout report is a top-6-label surface
dump that may not include the element in question at all. Assert the
canary through the specific automation-alert-result element instead, so
a failure states its actual current value directly.

Also record the tapped alert button's coordinates alongside its label
(already recorded) in the Android alert-handled result, and capture a
screenshot artifact when an e2e `wait` step times out, so a flake has
more to go on than the surface dump.

* review: extract shared alert-test fixtures, trim narrated docblock

- Extract node/text/button RawSnapshotNode builders (near-duplicated
  between alert.test.ts and alert-detection.test.ts) into a sibling
  alert-fixtures.ts, reconciling the two button() signatures by keeping
  the optional `permission` param. Both suites now import from it.
  Planted red: bumped the shared button() rect width and reran both
  suites — 2 of 7 tests failed on the changed tap coordinates
  (alert.test.ts's dismiss/accept cases), confirming the fixtures are
  live-wired; reverted, suite back to 7/7 green.
- Trim captureWaitTimeoutScreenshot's docblock in runtime.ts to the
  caller-facing contract only ("never throws, returns undefined on a
  failed capture"); the surface-dump rationale already lives in the PR
  body's Summary.
2026-09-03 14:30:40 +02:00
Michał Pierzchała 2c7fb93cfc fix(android): apply settings airplane through the connectivity service (#2234)
* fix(android): apply settings airplane through the connectivity service

settings airplane wrote airplane_mode_on and then broadcast
android.intent.action.AIRPLANE_MODE, which Android refuses for non-system
callers. The write landed, the broadcast failed, and the device reported
airplane mode with the radios still up.

The connectivity service now owns the change: it is read to prove the build
supports airplane mode before anything is written, driven with
cmd connectivity airplane-mode enable|disable, and read again so the response
reports the mode connectivity holds rather than the one requested. Builds
without that command are refused unmutated with UNSUPPORTED_OPERATION.

Closes #2223

* test(android): pin the mechanics eager closure at 178 modules

Splitting the airplane owner out of settings.ts adds one module to the
mechanics facet, which is implementation-eager by design. The row moves to the
measured number in the PR that grows it.

* fix(android): report only capability absence as unsupported airplane mode

An unrecognized nonzero probe — a permission denial, a connectivity-service
error — was answered with "requires Android 11; use a newer device". Only the
prose adb prints when a build ships no shell implementation for the command
now selects UNSUPPORTED_OPERATION; every other failed read stays
COMMAND_FAILED with its classified hint, and the write is unreachable from
both.

The predicate that reads that prose already existed for the clipboard service
and is now named for the question it answers, so airplane mode reuses it
instead of adding a second message sniff.
2026-09-02 16:54:19 +02:00
Michał Pierzchała db08548026 refactor: enforce src/utils retirement (#2149) (#2229) 2026-09-02 07:59:22 +02:00
Michał Pierzchała 42dc9adb5d fix: address security scanner findings (#2182)
* fix: address security scanner findings

* fix: close image-size parser review gap

* test: prove zero-length image box regressions

* fix: keep fixture fingerprint output machine-readable

* test: align fixture fallback with fingerprint owner
2026-08-31 20:38:44 +02:00
Michał Pierzchała a6232e51cf refactor: prune platform split residue (#2123) 2026-08-29 13:10:47 +02:00
Michał Pierzchała c7f42ccedc refactor: move Android family behind package exports (#2117)
* refactor: move Android family behind package exports

* fix: address Android W5 review feedback

* fix: update relocated routing fixture assertion
2026-08-28 13:02:28 +02:00
Michał Pierzchała af6f12e391 chore: adopt shared oxlint config (#2115)
* chore: adopt shared oxlint config

* fix: preserve project lint boundaries

* fix: remove redundant oxlint config
2026-08-28 11:42:58 +02:00
Michał Pierzchała 7a6284bb36 refactor(platforms): break the four upward edges out of src/platforms (#2090)
* refactor(platforms): break the four upward edges out of src/platforms (#2082 W2)

src/platforms carried four imports that point up into root src, each of
which would become an R11 violation the moment its family moves into a
platform package:

- android/app-helpers.ts reached the composition root for the foreground
  parser. The parser is pure dumpsys vocabulary, so it moves to
  @agent-device/contracts/android-observation; the platform-android
  app-state module and app-helpers both consume it from there, and the
  composition wrapper plus the package facade's lazy re-export retire
  (R13 allows only the composition root to import platform packages, so
  vocabulary relocation is the inversion that stays legal).
- web/provider.ts and web/agent-browser-network.ts type-imported the
  backend diagnostics/network-dump vocabulary from src/backend.ts. Those
  six types move to @agent-device/contracts/backend-diagnostics;
  backend.ts re-exports them for its SDK consumers.
- snapshot/snapshot-desktop-surface.ts split three ways: the pure
  projection (scope/interactive/depth) moves to
  @agent-device/contracts/snapshot-desktop-projection, the per-family
  captures move to platforms/linux/surface-snapshot.ts and
  platforms/apple/os/macos/surface-snapshot.ts beside the code they
  dispatch to, and the root file keeps only the device-dispatching
  runtime host behind R3-tolerated dynamic imports. apple/interactor
  now reaches macOS surface capture family-internally instead of
  through root.

src/platforms -> root src is now zero edges. Contracts grows two entries
(pinned, budgeted); the android foreground-parser tests move beside the
parser. Left for a later pass: app-parsers.ts shares the marker-walk loop
shape with the contracts parser but parameterizes it for blocking-dialog
parsing - generalizing that is a design change, not a move.

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

* refactor(platforms): keep platform parsing with its family; give snapshot shaping its capture owner

ADR-0019's amendment forbids satisfying R13 by moving implementation
into contracts, so this wave's two relocations invert instead of sink:

- The Android dumpsys foreground parser returns to
  @agent-device/platform-android with its owning test, and
  contracts/android-observation goes back to observation vocabulary
  only. src/platforms/android/app-helpers exposes
  createAndroidAppStateReader(parseForegroundApp) and never imports
  upward; the composition seam in src/sdk/android-adb.ts injects the
  root-composed parser, keeping the published
  getAndroidAppStateWithAdb(adb) signature intact.
- The desktop snapshot projection moves to
  @agent-device/capture-kit/snapshot-desktop-projection beside the rest
  of the capture-side snapshot behavior; contracts exports the
  snapshot-scope vocabulary it consumes.

The #1832 history narration in the projection test is gone; the test
name and golden fixture carry the invariant.

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

* refactor(platforms): the adb app-state read lives whole behind the platform-android seam

The injected-parser loop retires: platform-android's app-state module
owns the complete adb-executor read/parse (readAndroidAppStateWithExecutor,
beside its host-based twin), the façade exposes it lazily, the
composition root wraps it, and src/sdk/android-adb.ts reaches it through
that root in one hop. app-helpers keeps only the app-list helpers, and
the SDK-route tests live in SDK topology as src/sdk/android-adb.test.ts.

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

* docs: drop the seam wrapper explainer comments

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

* docs: drop the projection docblocks the test names already carry

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-28 07:46:49 +02:00
Michał Pierzchała 3378d9c05a refactor: move Android system observation out of daemon (#2071) 2026-08-27 13:11:01 +02:00
Michał Pierzchała 9f16fc885c refactor: migrate perf to device runtime (#2061) 2026-08-26 20:41:19 +02:00
Michał Pierzchała fe1e7c0909 refactor(android): one injectable host-adb transport for the remaining raw adb sites (#2052)
* refactor(android): one injectable host-adb transport

* refactor(android): enforce host-adb transport failure contract

* style(android): format relocated limrun transport test

* refactor(android): colocate host adb transport

* refactor(android): route host adb through composition

* fix(android): keep unbound adb host loud
2026-08-26 17:42:46 +02:00
Michał Pierzchała 8af2660332 refactor(android): extract the adb executor and IME cluster into packages/platform-android (#2041) (#2044)
* refactor(android): extract adb executor and IME cluster into packages/platform-android (#2041)

Implementation moves behind an injected adb host port (R13-clean); shared
vocabulary moves to contracts (android-touch-plan, android-helper-artifacts)
and kernel (keyed-lock); root keeps thin re-export shims plus the composition
wiring that binds the port. session.ts/session-observability.ts no longer
import platform transport types (opaque unknown, R62 pattern).

* refactor(android): trim shims to consumed surface, break helper-install cycle, add gate coverage

Fallow-clean: shim re-exports carry only names root still consumes;
helper-package-install imports the package subpath directly (no cycle) and
reuses the contracts decision types; transitional R13 table gets planted-red
tests; ADR-0019 records the transitional exception; eager-closure rows added
for the new entry surfaces.

* review: restore maxBuffer on spawn options, scope the transitional test allowance

Adversarial review round 1: AndroidAdbSpawnOptions keeps maxBuffer for spawn-
signature width parity with the pre-move ExecBackgroundOptions; the R13 test
allowance narrows from any test file to the cluster's own src/platforms/android
__tests__ directory, with planted-red coverage for a foreign test file; ADR
text records the scoped allowance.

* review: keep an unbound adb host port loud in listAndroidAdbSerialsQuick

* style: format policy test

* refactor(android): split the extracted adb and IME modules into focused owners

Review: the moved adb-executor.ts (652 lines) and ime-lifecycle.ts (487 lines)
carried three concerns each across the package boundary. The entry subpaths are
now thin surfaces over focused modules — adb: transport vocabulary, failure
classification+enrichment, provider normalization, provider scope/routing,
port-reverse ownership, pull/install transfers; IME: ownership state, on-device
settings record, activation transaction, restore+orphan recovery — every module
under 250 lines, each with colocated tests over an in-memory host-port stub and
a shared fake IME device. R31's fence invariant repoints to ime-activation.ts;
eager-closure pins move to the split shape.

* rebase: reconcile the transitional android table with the #2050 mechanics-facet model

The R13 declaration check now composes both enumerated subpath sets on the
exact-list model #2050 introduced; the policy test fixture declares android's
transitional subpaths the same way it declares apple's runner facet.
2026-08-26 16:40:32 +02:00
Michał Pierzchała aeb2ff8402 refactor(contracts): retire the platform/interaction compatibility façades (#2048)
#1959 granularized the contracts entry surface but left the wide
platform/interaction façades in place as a compatibility surface for
~490 type-only importers. This mechanically moves every importer
(~350 files) onto the granular subpath each symbol actually lives on,
deletes the two façade files, and removes the fallow ignoreExports
entries and eager-closure-budget rows that existed only to cover them.

Eight previously-unexported source files needed new package.json
subpaths (clipboard, keyboard, network-traffic, platform-plugin,
platform-providers, runner-lease-context, screen-recording-runtime-host),
and ./interaction now points directly at src/interaction.ts instead of
the deleted barrel. .oxlintrc.json's no-restricted-imports rules for
the two façades are removed since there's no wide facade left to warn
against value-importing.
2026-08-26 13:13:25 +02:00
Michał Pierzchała 494f1c5ad0 Migrate audio probe to platform runtime with durable resource lifecycle (#2038)
* refactor(daemon): migrate audio onto the request-bound device runtime (R60)

Wave 6 closure unit 1 of 2 for #1739. The audio probe leaves legacy execution
for exact-owner runtime facts with the logs/record durable treatment:

- contracts: audio-probe-runtime operations (audioProbeStart/Reattach/Cleanup
  for the durable host capture, audioProbeQuery for the stateless web page
  probe), audio-runtime-plan action-selected uses + shared positional grammar,
  audio-probe-runtime-host seams; two new required unavailable cells.
- capture-kit: one shared host-capture implementation (descriptor codec v1
  with cleanup-only recovery, start pipeline waiting on the sampler's first
  status publication, live handle, exact-identity recovery operations) used by
  both darwin-hosted owners.
- owners: apple and android state their exact capture cells (macOS host only;
  the legacy bucket's physical-iOS over-claim becomes a stated refusal), web
  owns the page probe, all other families and both providers state refusals.
- daemon: audio-probe.resource.json envelope via the DurableCaptureResource
  coordinator, fence-minting admission ledger, session slot becomes the
  neutral handle+envelope pair (store-owned under R7), teardown/close finish
  through the coordinator, startup recovery registered in the device-claim
  reconciler; the handler admits by facts inspection and binds once per plan.
- deleted: the capability bucket, the WEB_QUERY_COMMANDS graft and its
  matrix pass, both supportsByDefault closures, src/daemon/audio-probe.ts,
  src/platforms/audio-probe-backend.ts, and the macOS backend shim.
- gates: cutover row R60 (durable tier, lifecycle proof on the session slot),
  R7/R11 baselines moved for the slot reclassification and new subpaths.

Known parity delta, itemized: status/stop with no active probe now answers
without backend-specific notes (the daemon no longer knows a backend before a
capture starts); the wire shape is otherwise unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(daemon): doctor becomes host-scoped execution behind the host-diagnostics surface (R62)

Wave 6 closure unit 2 of 2 for #1739, carrying the ADR-level discriminator
decision the pre-unit record names: CommandPlatformExecution gains
{ kind: 'host' } — host-scoped diagnostics contributed by platform families
through a neutral surface, binding no device runtime of its own. 'none'
remains barred as a migration target; doctor's device legs already ride the
migrated inventory gateway, facts inspection, and the apps unit's use.

- contracts: host-diagnostics facet (toolchain/device/ambient/warmup methods
  over the existing DoctorCheck vocabulary, a per-call context carrying the
  neutral inputs, and an opaque provider transport override the one owning
  family narrows back); the discriminator assert, entry gate (host is held to
  the same no-bucket rule as none), and error text extend to the new kind.
- probes move to their owning families: apple (xcodebuild/xcode-select +
  runner-cache warmup), android (adb/SDK/license toolchain, Metro reverse,
  orphaned test-IME), harmonyos (hdc), vega (tool provider + VVD inventory
  read via the context), web (managed browser census); one shared
  first-line probe helper. Wire shapes and check ids are byte-identical.
- composition: createHostDiagnostics with per-family lazy loading, injected
  from daemon-runtime through router/chain/session params — the daemon never
  imports the composition root, so no transitive platform edge returns.
- daemon: session-doctor.ts keeps orchestration only; its six platform
  imports and the three probe-owning modules
  (session-doctor-{toolchain,android,web}.ts) are deleted.
- gates: HostCutover row variant with a gateway-identity proof (R17's shape);
  R62 row; the descriptor-row completeness test now mandates rows for host
  descriptors; ADR 0019 rules-at-a-glance amended.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(audio): fold the per-family probe factories into one capture-kit operation set

Post-migration fallow pass over R60/R62: apple and android carried
byte-identical audio-probe operation factories, so the shared
createHostAudioProbeCaptureOperations now lives in capture-kit and both
bind arms call it directly; the family files keep only their stated
capture facts. Also un-exports the plan/recovery symbols nothing
consumes anymore, splits the durable-descriptor field validation out of
the decoder, names the Linux audio refusal by its file's convention, and
drops a stale session-doctor-web mock from the relocated doctor test.

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

* fix(coverage): repoint platform audio/doctor coverage claims at the migrated evidence

The R60/R62 rewrite renamed the daemon audio contract tests and moved the
web doctor suite, which broke the macos-coverage smoke gate on CI and five
sibling coverage claims that only fail on non-darwin hosts. Repoints the
macOS, web, and iOS-simulator manifests at the surviving tests (adding an
iOS-simulator start contract the manifest already named), converts the
Linux audio row from capability-denial to a fact-owned contract backed by
new stated-refusal assertions in the Linux runtime denominator test, and
retires the darwin-dependent capability special-cases: audio admission is
owned by the exact-owner runtime fact now, not the catalog.

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

* fix(daemon): forward hostDiagnostics through the session command route

Adversarial review of the R62 cutover found the wiring gap that broke
every doctor request served through the daemon: handleSessionCommands
re-composed its handler params without the hostDiagnostics gateway, so
requireHostDiagnostics always threw. Forwards it, composes
createHostDiagnostics() in the provider-scenario harness the same way the
daemon runtime does (all nine doctor integration tests pass again), merges
a duplicate test import that failed lint, applies oxfmt to the files the
branch left unformatted, and trims blank-line residue from a deleted
capabilities test.

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

* fix(audio): refuse to publish a capture without an exact process identity

Review P1 on the R60 unit: start permitted resolveManagedProcessIdentity
to yield no marker, recovery then mapped the markerless descriptor to
missing, read a possibly in-progress status file as completed, and
terminalized the durable resource without proving or terminating the
child. The marker is now required end to end: start terminates the helper
and fails when the process exposes no identity, the descriptor codec
rejects markerless bodies so a foreign or corrupted record routes to
manual recovery instead of a guessed outcome, and a planted-red
regression pins that a markerless record with a live status file is never
read as completed or missing.

Also splits the capture-kit audio-probe module along its concerns
(descriptor codec / status reads / cleanup-only recovery / live-process
owner) per the same review, moving the eager-closure budget rows the
three new files cost.

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

* fix(audio): let timed probes complete and never adopt a stale status file

Live exact-head evidence on macOS surfaced two lifecycle defects:

- The helper's runAudioProbe parked the CLI's main thread in a semaphore
  while an unstructured Task ran the capture loop; with no run loop ever
  spinning in the one-shot process, the loop stalled at its first
  Task.sleep suspension, so every timed probe froze after its first
  bucket and never completed on its own. The probe is now fully
  synchronous: ScreenCaptureKit's completion-handler APIs bridged
  through semaphores (the pattern the helper's screenshot path already
  uses in production) and a plain Thread.sleep cadence loop.
- startHostAudioProbe accepted a pre-existing audio-probe.json, so
  restarting in a session that had already run a probe returned the
  previous run's snapshot as the new probe's first status. The status
  path is cleared before the spawn — the previous handle's finish() has
  already terminated and awaited its process, so any file observed after
  the spawn was written by the new helper. A planted-red regression pins
  that a stale file is neither adopted nor left behind.

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

* test(audio): complete runtime host fixtures

* fix(audio): surface helper death after a running checkpoint instead of completing it

Review P1 on the live-evidence pass: the live handle discarded the
helper's terminal result, so a sampler that died after publishing a
running checkpoint read as running forever and stop fabricated a normal
stopped completion; marker-missing recovery compounded it by finalizing
any persisted status — a running checkpoint included — as completed.

The handle now observes process.wait: a non-terminal status file plus an
observed exit fails status and stop with the helper's exit detail, so
the durable coordinator records the failed terminal transition and the
record resolves through descriptor cleanup rather than a fabricated
result. Recovery treats only a terminal stopped publication as a
completion; a running checkpoint with the exact PID gone reports
missing, which terminalizes the envelope as already-missing with no
completion metadata. Planted-red regressions cover both: the live handle
with a running checkpoint plus child exit, and a daemon-restart recovery
over an orphaned running checkpoint.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-26 12:36:46 +02:00
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 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 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 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 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 56f2671a66 perf: reduce cold iOS runner startup latency (#1927) 2026-08-20 21:47:29 +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 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