Commit Graph

120 Commits

Author SHA1 Message Date
Michał Pierzchała 7672454304 refactor: move interaction handlers behind daemon facade 2026-09-01 21:48:10 +02:00
Michał Pierzchała 7646a73b1b perf: avoid redundant physical iOS runner health check (#2215)
* perf: avoid redundant physical iOS runner health check

* test: isolate iOS runner prewarm coverage
2026-09-01 15:30:00 +02:00
Michał Pierzchała fd4ab84166 refactor(ios): define snapshot acquisition and presentation contracts (#2203)
* refactor(ios): define snapshot acquisition and presentation contracts

* refactor(ios): isolate snapshot planning exports
2026-09-01 11:33:18 +02:00
Michał Pierzchała f513b1d4ae refactor: extract daemon replay behind one application facade (#2166)
* refactor: extract daemon replay behind application facade

* fix: address replay facade review findings

* test: close replay ownership import scan gap

* fix: tighten replay capability boundaries
2026-08-31 15:43:33 +02:00
Michał Pierzchała 34e8cbb7a2 docs+ux: make device ownership discoverable end to end (#2165)
* docs+ux: make device ownership discoverable end to end

Complete the #1320 agent experience so 'busy? -> inspect -> choose or
release' is discoverable from every surface an agent actually reads:

- devices now projects the blocking claim owner per row (claimedBy with
  session and workspace, observe-policy projection; provably dead owners are
  excluded because the next open replaces them automatically), so an agent
  told a device is busy can pick a free one from the same listing.
- help debugging gains a 'Device busy and ownership' section separating the
  two DEVICE_IN_USE flavors and their exact recoveries.
- AGENTS.md documents both flavors; docs/agents/device-verification.md
  retires the last ps/kill recovery guidance in favor of device status,
  daemon stop --state-dir, and device release --stale (Stage 5 of #1320).
- ADR-0010 no longer calls DEVICE_IN_USE 'the only retriable code' without
  naming the claim path's non-retriable override.
- The rendered cross-worktree claim error gains a help-conformance quiz case
  binding (sample-output-device-claim-inspects-owner).
- README points at device status / device release --stale.

Part of #1320.

* fix: key ownership projection by canonical device identity end to end

Review findings on #2165:

- blockingClaimOwnersByDevice keyed claims and inventory rows by bare
  device.id, so a live Android claim could project claimedBy onto an
  unrelated same-id Apple/Harmony/Vega row, with scan order picking the
  displayed owner. Both sides now use the canonical local device key
  (claim.deviceKey against canonicalLocalDeviceKey of the row's claim
  identity). The cross-family same-id regression was observed red against
  the bare-id keying.
- The projection is now asserted across every hop the PR promises: client
  normalization preserves well-formed claimedBy and drops malformed ones,
  and the devices CLI formatter carries it through JSON data and renders
  the text line (MCP shares the same serialization).
2026-08-31 14:32:29 +02:00
Michał Pierzchała 70fb783729 fix: reuse canonical Maestro visibility context (#2156)
* fix: reuse canonical Maestro visibility context

* perf: skip empty Maestro visibility filtering

* refactor: centralize snapshot visibility context
2026-08-31 13:41:15 +02:00
Michał Pierzchała 48eb1b06e3 feat: open Limrun uploaded apps (#2110)
* feat: open Limrun uploaded apps

* refactor: generalize deferred provider app selection

* refactor: fold limrun preinstall into allocation

* refactor: keep app selection provider independent

* refactor: enforce provider-independent commands

* refactor: restrict provider imports from commands

* refactor: keep provider guard lightweight

* fix: enforce provider-independent commands

* fix: enforce provider catalog lease admission

* refactor: deepen provider app catalog admission

* refactor: simplify provider capability projection

* fix: restrict Limrun uploaded apps on public daemon

* refactor: separate provider app catalog lookup

* fix: simplify uploaded app provider boundaries
2026-08-31 13:01:18 +02:00
Szymon Dziedzic d9677301f4 feat: add human takeover controls (#2078)
* feat: add human takeover controls

* fix: harden human takeover controls

* fix: align host XCTest selection count

* fix: address takeover readiness feedback

* fix: handle macos runner permission prompt in smoke tests

* fix: detect background macos permission dialog

* fix: dismiss inaccessible macos privacy sheet visually

* fix: map macos privacy prompt without accessibility

* refactor: own human-control holds in lease registry

* fix: cancel pending human takeover on disconnect
2026-08-29 13:09:54 +02:00
Michał Pierzchała 9abcd7fe03 refactor: move Apple platform family into package (#2118)
* refactor: move Apple platform family into package

* fix: preserve Apple facade sync contracts

* fix: complete Apple W4 rebase review fixes
2026-08-28 15:25:44 +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 ddb415a2c7 refactor: sink package-closed src modules into existing packages (#2106)
* refactor: sink package-closed src modules into existing packages

Move closed modules into contracts, kernel, capture-kit, and ad-script,
and declare DaemonCommandDescriptor in core so R6/R9 can pin the remaining
provider-webdriver type cycle.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: keep contracts and capture-kit off generic sinks

Move interaction-outcome, snapshot warning rendering, and inventory ALS
behind focused owners, and plant R18/R70 domain-shape gates so they
cannot return as package export-map growth.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: drop moved implementation comments from owner modules

Names, types, and tests already carry those invariants; the relocated
files should not keep review-history or control-flow narration.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: drop the empty snapshot-quality layering zone

W1 moved the verdict into capture-kit and this PR moved warning rendering
into snapshot-presentation, so the ranked zone no longer has production files.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 07:56:18 +02:00
Huangshuo Kuang 2e87347bee fix(wait): surface runner restart timeout evidence (#2113) 2026-08-28 07:48:10 +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 ea1d6b8c55 refactor: remove retired capability matrix (#2089) 2026-08-27 16:12:44 +02:00
Michał Pierzchała 7b48531d3b refactor: retire ADR-0019 cutover scaffolding (#2081) 2026-08-27 15:11:14 +02:00
Michał Pierzchała c794c11d7e refactor: close the daemon platform boundary (#2072)
* refactor: move runtime resource mechanics out of daemon

* refactor: move Apple resource access out of daemon

* chore: enforce the terminal daemon platform boundary
2026-08-27 13:11:01 +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 03f0f408c2 refactor: move platform provider composition out of daemon (#2070) 2026-08-27 13:11:00 +02:00
Nicolas Bataille b44f882c83 docs(batch): name the step shape and the accepted commands in help batch and in its refusals (#2067)
* docs(batch): name the step shape and the accepted commands in help and refusals

`batch` accepts one step shape and `help batch` documented none of it: the
usage line, one sentence, and the flags. Every refusal named only what was
wrong. A caller reaching for `press` through `batch` therefore saw
"Invalid batch step 1." for `["press @e12"]` and "unknown field(s): args" for
`{"command":"press","args":[...]}`, and reasonably concluded the mutating verbs
were excluded (#2062).

They are not, and never were: `press`, `click`, `fill`, `longpress`, `scroll`
and `back` all carry `batchable: true` in the command-descriptor registry,
including at 0.20.10. The exclusions are `batch`/`replay` (which never nest) and
the session/daemon/connection/host-tooling commands. Nothing about the
allowlist changes here; what changes is that it is stated.

- `help batch` documents the step shape, serial semantics, and RENDERS the
  accepted commands from the registry's `batchable` trait, so the listing cannot
  drift from the runtime allowlist.
- The step-shape refusals (non-object step, unknown field, non-object input)
  share one hint naming `{"command":"<name>","input":{...}}`, owned by
  `batch-contract.ts` next to the checks that raise them.
- The non-batchable-command refusal points at that listing and says which
  families are excluded and why.
- `assertAllowedKeys` takes an optional hint so the batch call sites can attach
  theirs without a second unknown-key check.

Closes #2062

* docs(batch): ground the step-shape guidance in the structured schema and keep contracts surface-neutral

`help batch` now prints runnable snapshot/press/fill steps carrying the real
structured field names (`target: {kind, ref}`, `text`, `interactiveOnly`), which
no `help <command>` text states, and says so instead of pointing at command help
for them. `cli-help-examples.test.ts` reads those steps back out of the rendered
help and runs each `input` through its own command's `readInput`, so a renamed
field fails there rather than shipping guidance nobody can run.

Fixes the stale batch guidance the audit missed: `help workflow` named
`batch ./steps.json`, which positional input rejects, and `help scripting` still
weighed the removed positionals/flags shape against the accepted one.

`BATCH_STEP_SHAPE_HINT` in `@agent-device/contracts` describes the shape only;
`readBatchStepRecord`/`readBatchStepInputObject` take the hint as a parameter so
the CLI attaches its own `agent-device help batch` recovery step while the Node
client and MCP tools keep the surface-neutral one.

* test(batch): pin every advertised step key, and hint the removed-shape refusal

The example-validation test accepted a step whose optional key the
reader silently dropped — readInput ignores unknown keys, so a renamed
settle or interactiveOnly kept the test green while help advertised a
step that does less than it claims. Every printed key must now survive
into the parsed input.

The removed positionals/flags refusal carries the CLI shape hint like
its three sibling refusals.

* fix(batch): keep the availability refusal surface-neutral; CLI attaches its recovery

readStructuredBatchCommandName emitted 'Run agent-device help batch'
unconditionally, and the same reader backs the MCP/Node batch metadata
— an MCP caller got a terminal-only, unrunnable recovery step. The
shared default now states the exclusion boundary itself with no
terminal vocabulary (MCP/Node read the accepted commands off the step
schema's command enum), and the CLI admission appends the help pointer
via the same optional-hint parameter the shape hint uses.

Found while fixing it: hint strings are redaction-capped at 400
characters, so enumerating the derived roster inline truncates the
hint — recovery pointer and all. The regression pins neutrality AND
that the hint survives the cap whole.

Addresses the P2 review on #2067.

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-08-27 13:10:18 +02:00
Nicolas Bataille ed8d5efb00 fix(daemon): address cwd-scoped sessions by their store key, not their public name (#2068)
* fix(daemon): address cwd-scoped sessions by their store key, not their public name

An implicitly cwd-scoped session is NAMED `default` and STORED under
`cwd:<hash>:default`. Three surfaces built caller-facing text from the name, so
each pointed at something that does not exist:

- `DEVICE_IN_USE` reported `session "default"` and its recovery hint said
  `agent-device close --session default`. `--session` marks the session
  explicit, which disables cwd scoping, so following the hint addressed a
  different, absent session: `SESSION_NOT_FOUND`, and the device stayed held —
  the residual half of #2031 that #2057's superseded-daemon reconciliation does
  not cover.
- `session list` reported `sessionStateDir` and `runnerLogPath` under
  `<state>/sessions/default`, a directory that is never created; the session's
  real artifacts sit in `<state>/sessions/cwd_<hash>_default`, which is what
  `open` already answers with.

`SessionStore.entries()` exposes the key alongside the record, and
`buildSessionRecoveryHint` takes the session's address explicitly. Call sites
that only hold the session the current request named pass its name — the address
there — so their text is unchanged, as are explicitly named sessions everywhere.

Live before/after on an iOS simulator: `open` (cwd-scoped default), then
`open --session qa` on the same device. Before, the hint's own `close --session
default` answered `SESSION_NOT_FOUND` and the retry failed again; after, the
hint names `cwd:8bea844ab16aa9b3:default`, that close releases the device, and
the retry opens.

Refs #2031, #1394

* fix(daemon): thread the resolved session address through every recovery producer

The store key a request resolves — `cwd:<hash>:default` for an implicit
session — was known upstream but dropped before the selector-conflict and
lock-conflict producers, which then named `SessionState.name`. Both emitted
`close --session default`, the unreachable recovery this PR fixes for
DEVICE_IN_USE: `--session` marks the session explicit, so that command
addresses a different session.

`SessionRef` ({ address, session }) now carries the pair, so a recovery
producer cannot be handed a record whose address was never resolved.
`buildSessionRecoveryHint`, `assertSessionSelectorMatches` and
`applyRequestLockPolicy` take it; `prepareLockedRequestBinding` and the
Maestro replay route build it from the store key they already hold.

Replaces the broad `SessionStore.entries()` with the store's own narrow
lookups — `lookup`, `findByDevice`, `listRefs` — so callers receive the
session with its address instead of enumerating raw key/record tuples.

`session list` reports `address` alongside `name`, through the client
contract and serializer, so its discovery output names a value `--session`
accepts.

Regressions run the production routes, not the helpers: router-level
device-in-use, selector-conflict, lock-policy-conflict and `session list`
cases open an implicit session and read the address back off the store, plus
a typed-Maestro selector-conflict case. Each was observed red against the
pre-fix behavior it pins.

Refs #2031, #1394.

* fix(daemon): doctor names same-device sessions by address; green the fallow gate

- sessionChecks enumerated same-device sessions by SessionState.name:
  the printed close --session default was the exact unreachable
  recovery this PR removes elsewhere, the name-based dedupe hid two
  cwd-scoped default sessions from each other, and the evidence
  sessionStateDir pointed at <state>/sessions/default, which never
  exists. Candidates now come from listRefs() and report addresses.
- findByDevice joins values/delete in .fallowrc.json usedClassMembers
  (same resolution false positive on this class; the call site is
  session-open-execution.ts).
- the scoped-paths test narrows its response once instead of nine
  optional-chaining hops, which tripped the CRAP gate.

* style: format .fallowrc.json

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-08-27 11:06:32 +02:00
Nicolas Bataille 4b8bcaca60 feat(interaction): accept fill <target> "" as the clear-field primitive (#2066)
* feat(interaction): accept fill <target> "" as the clear-field primitive

Emptying an input was not expressible: `fill` refused the empty string
("Expected text to be a non-empty string"), `type` only appends, and `keyboard`
has no delete verb. Clearing a field before typing is a routine QA step, so the
only route was the app's own clear button or N locale-dependent keyboard delete
presses read out of a snapshot.

`fill <target> ""` now means "replace with nothing". Both platforms already own
the clear half of replace, so this is the validation and reporting that stood in
front of it, not a new interaction:

- `stringField` takes an opt-in `allowEmpty`, used only by `fill`'s `text`.
  `requiredField` still refuses a MISSING text, so `fill @e57` stays an error
  rather than silently erasing the field — `readFillTargetFromPositionals` now
  reports `undefined` for "no text argument" instead of collapsing it to `''`.
  `type` keeps refusing an empty text: appending nothing is not a clear.
- The Apple runner's empty-text early return skipped the clear while reporting
  "typed". For a replacement it now runs `clearTextInput` and verifies the field
  came back empty (secure fields stay unverifiable, as elsewhere).
- Android already clears before typing and skips an empty shell/IME write, but
  its verifier read a cleared field's absent `text` attribute as a mismatch
  against `''`. An empty expectation now accepts null or "".

Whitespace-only text keeps its established per-shape rules; only `''` is new.

Closes #2063

* fix(interaction): fail the empty-fill clear closed on every backend

Addresses the P1 review on #2066, then closes the same fail-open class
on the backends the PR did not reach:

- Android: an empty expectation no longer matches when the verification
  scan observed NO input node at all — actual is null both for a cleared
  field and for a wrong point/lost focus, and three empty samples of
  nothing were a stable success for a clear that never touched a field.
- Apple runner: when the empty-replacement path cannot resolve a clear
  target (including the synthesized first-responder route, whose target
  carries no element), it returns the typed TEXT_INPUT_NOT_FOCUSED
  failure instead of falling through to the vacuous-typing
  verified-success return. Regression runs in the ios.yml XCTest lane.
- webdriver: fill is tap + sendKeys and owns no clear mechanism, so an
  empty fill refuses as UNSUPPORTED_OPERATION before touching the
  device, instead of reporting a clear it cannot perform.
- linux + web coordinate fill: typing zero characters over the
  select-all selection left the old value intact; the empty fill now
  deletes the selection.
- recording: an empty --record-as literal matches inside every string;
  it now parameterizes only the fill's own text field instead of
  rewriting every empty field and empty evidence label in the entry.
  (The session-wide echo registry already excluded empty literals.)
- help: the text-entry topic taught agents that fill "" is not a
  clear-field command; it now states the new contract.

Each new test was observed red against the pre-fix code.

* fix(android): read hint-showing from the helper so a cleared field verifies

Live Pixel 9 emulator, adb-shell channel: clearing the Settings search
field succeeded on the device but reported 'Android fill verification
failed', because a cleared EditText dumps its HINT as text — getText()
returns the hint for an empty field on modern Android, so 'Search
settings' read back as a residual value. This is the same
placeholder-as-value trap the Apple runner already handles with
treatingPlaceholderAsEmpty.

The helper now emits hint-showing (isShowingHintText, API 26+), the
hierarchy parser carries it, and fill verification matches against the
field's VALUE — hint-only text is an empty value, for empty and
non-empty expectations alike. A field whose real value equals its hint
string keeps failing the clear check: only the authoritative flag, never
the text, says it is a hint. Raw uiautomator dumps carry no such fact
and keep the fail-closed behavior.

Live evidence, both admission channels, after this fix: test-ime and
adb-shell clears both report Filled 0 chars with the field back on its
placeholder; the pre-fix adb-shell run failed closed (never a false
success).

* fix(interaction): close the adversarial-review findings on the empty-fill clear

- android adb-shell: the delete burst is sized from the value being
  REMOVED (pre-mutation read; the attempt's cap when unreadable), not
  from the empty incoming text, which sent the 12/24-delete minimums and
  could never empty a field longer than 36 characters.
- android: the unconfirmed soft-success no longer applies to an empty
  expectation — nothing app-formats the empty value, so residue after a
  clear is a failed clear, and the soft-success also skipped the second,
  bigger delete burst.
- android masked fields: an empty expectation accepts an observed masked
  node with no dump text (a masked field WITH content dumps its bullet
  run), so clearing a password field no longer fails after the clear
  worked — matching iOS, where a secure-field clear succeeds unverified.
- find: 'find <q> fill ""' now reaches the fill leaf as the clear
  request on both the CLI reader and the daemon positional parse; a
  MISSING value keeps its refusal at each producer, so the typed
  value: string contract is unchanged.
- maestro export: a recorded clear exports as tapOn + eraseText instead
  of a vacuous inputText: "" (with the 50-character-default warning).
- the missing-text refusals teach the clear form: (use "" to clear
  the field).

Full unit suite green (1061 files); each behavioral fix carries a test
observed red against the prior code.

* refactor(interaction,android): extract the fill parse and shell-attempt branches

The review commits pushed parseFillTarget and fillAndroid over the
complexity gate (13 cyclomatic each). Each fill target shape parses in
its own function sharing one missing-text response, and the adb-shell
attempt (clear sizing + clear + type + verify) moves out of the fill
loop. Behavior-preserving; the existing tests cover every branch.

* refactor(interaction,android): one owner per empty-fill fact

Design pass after review: the missing-vs-empty rule and the observed-
value rule each had several owners; now each has one.

- parseFillTarget decodes ONCE through readFillTargetFromPositionals —
  which already owns shape detection and documents the undefined-vs-''
  contract on DecodedFillTarget — and keeps only what the wire owns:
  versioned-ref admission, the selector whitespace rule, and the daemon
  responses. This deletes the point branch's duplicated slicing, the
  hasFillText guard, and the three per-shape parse functions.
- observedAndroidValue() is the single statement of Android's value
  rule (absent attribute and hint-only text are the empty value); the
  text branch, the match rule, and the masked branch all consume it.
  The masked branch thereby gains the hint-showing collapse it was
  missing, and isAcceptableAndroidFillMatch narrows to plain strings.
- The empty-text-is-clear contract is stated once, on Interactor.fill
  in contracts, instead of implied per backend.

Behavior-preserving except the masked+hint gain; the existing tests
cover every branch (494 Android, 15 fill-target).

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-08-27 10:59:30 +02:00
Michał Pierzchała 71214e11da refactor(runtime): close residue execution units (#2054)
* refactor(runtime): close residue execution units

* refactor(runtime): address residue ownership review

* fix(runtime): preserve viewport diagnostic log path
2026-08-27 07:46:25 +02:00
Michał Pierzchała 9f16fc885c refactor: migrate perf to device runtime (#2061) 2026-08-26 20:41:19 +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 74a70f1764 refactor: remove next-major compatibility surfaces (#2046) 2026-08-26 14:14:15 +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 214d3b2799 perf(daemon): revalidate the source code-signature cache by stat instead of rereading the graph (#2004)
* perf(daemon): revalidate the source daemon code signature by stat

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

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

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

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

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

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

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

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

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

Addresses the findings in REVIEW.md against this branch.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test: adapt open selection harnesses

* chore: keep context glossary within budget

* fix: separate device identity from selection filters

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No production behavior change.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

* docs: keep context glossary within budget

* fix(snapshot): address presentation boundary review

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

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

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

Closes #1970


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

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

* fix(ci): harden nightly failure classification

* fix(ci): stabilize macOS replay cleanup

* fix(ci): close nightly review gaps

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

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

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

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

* fix(android): preserve exact occlusion evidence

* fix(android): restore collective occlusion coverage

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

* fix: require direct selector touch binding

* fix: address touch runtime review

* fix: classify maestro direct click guarantee

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor: parameterize runSessionOrSelectorDispatch with an execute strategy

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(android): bound presentation footprint work

* fix(android): account for presentation scan work

* fix(android): budget scoped snapshot presentation

* fix(android): admit bounded snapshot presentation

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

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

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

Refs #1690

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

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

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

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

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

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

Refs #1690

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

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

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

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

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

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

Three gate adjustments the split forces:

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

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

Closes #1959

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

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

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

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

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

Wave 4 is complete: layering recognizes 26 migrated commands.

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

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

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

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

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

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

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

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

Review follow-ups on #1935.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(client): isolate snapshot forwarding regression

* refactor(snapshot): keep backend capability metadata internal

* fix(test): merge backend conformance imports

* fix(snapshot): keep backend forcing internal

* refactor(snapshot): isolate backend capability fixtures

* refactor(snapshot): keep capability governance internal

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

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

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

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

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

Review follow-ups on #1925.

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

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

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

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

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

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

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

* test: cover Android Maestro clickable-first path

* fix: keep Maestro fixture Android-only

* fix: reveal Android Maestro targets in smoke scenario

* fix: quote Maestro smoke assertion text

* fix: retain Android Maestro clickability evidence
2026-08-21 08:48:36 +02:00