Commit Graph

73 Commits

Author SHA1 Message Date
Michał Pierzchała 1826b2e68b refactor(ios): integrate runner with snapshot engine (#2214)
* refactor(ios): integrate runner with snapshot engine

* fix(ios): preserve macOS runner snapshots

* refactor(ios): keep runner presentation device-aware

* fix(ios): validate runner scroll presentation

* fix(ios): close presenter package boundaries

* fix(ios): preserve snapshot source lineage

* test(ios): colocate snapshot engine coverage

* fix(ios): settle post-merge audit checks

* test(ios): fix manifest parity lint

* refactor(ios): simplify runner source walk

* test(ios): cover shared package source fixture

* fix(ios): close post-merge audit gaps

* perf(ios): avoid bundling acquired snapshot path
2026-09-01 18:36:09 +02:00
Michał Pierzchała a8ee397168 test(ios): add snapshot engine conformance gates (#2213)
* test(ios): add snapshot engine conformance gates

* test(ios): align differential acquisition inputs

* fix(ios): gate Swift differential on macOS

* test(ios): keep differential coverage host-aware

* test(ios): own snapshot differential on macOS
2026-09-01 15:59:31 +02:00
Michał Pierzchała 868f8f90ee refactor(ios): extract snapshot presenter (#2206)
* refactor(ios): extract snapshot presenter

* refactor(ios): consume shared snapshot presenter

* fix: unblock snapshot presenter CI
2026-09-01 15:59:30 +02:00
Michał Pierzchała b7e4b5aa7b fix: improve ref-based interaction recovery (#2167) 2026-08-31 13:36:48 +02:00
Michał Pierzchała e832325e87 refactor(substrate): split host mechanics into @agent-device/host-kit capability ports (#2088)
* refactor: split generic host mechanics into @agent-device/host-kit (#2082 W1)

The shared src/utils closure that blocked the platform-family moves lands
on declared owners: generic host mechanics form a new private
@agent-device/host-kit package between kernel and capture-kit, and
capture-kit keeps capture, snapshot, and recording behavior, depending on
host-kit for the mechanics it needs. tar-stream and yauzl move with the
archive code.

Every seam's exported subpaths are pinned in package-boundaries.test.ts,
the layering model ranks the new zone, R13's allow-list names it, and each
seam carries an exact eager-closure row. ADR-0019's substrate amendment
describes the layout.

Tests that mocked two of the moved modules separately became duplicate
same-seam vi.mock factories, where the second silently replaced the first;
those are merged, and the mocks that production code reaches past are
pinned at their injection points instead.

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

* refactor(host-kit): one narrow capability port per export

The four technical barrels (exec/fs/values/request) grouped by category
rather than by capability, so a consumer needing one mechanic evaluated
unrelated ones. Each export is now a single capability over the host
machine: command, process, diagnostics, retry, archive, file, request,
version. A port re-exports only what a consumer of that capability uses,
and every port carries its own eager-closure row.

Most of the old values barrel was never host mechanics. Pure record
readers, config-source values, result text, memoization, async scoping,
coordinate validation, and device-scope parsing touch no process, file, or
environment, so they join kernel's other primitives instead.

Closures fall accordingly: capture-kit's png-worker-client from 20 to 10,
png-resize from 28 to 18, session-teardown from 79 to 68, and the CLI from
386 to 380.

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

* chore: drop the migration inventories and trim the touched comments

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

* docs: trim the touched host-kit and mutation-lane comments

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

* docs: keep tool directives only in the touched files

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

* docs: keep tool directives only across the touched tree

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

* fix: point the Swift parity comment at the real TS twin and test

The W1 move rewrote this citation to packages/contracts/src/mobile-snapshot-semantics.ts,
which does not exist: the module went to capture-kit while isTapPointInsideViewport itself
went to packages/contracts/src/snapshot-visibility.ts. The TS test line was left pointing at
the pre-move path. Both now resolve.

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

* fix: repoint comment citations at the homes this refactor moved them to

The W1 move left ~20 comment citations pointing at src/utils/*.ts and
src/request/*.ts paths that no longer exist. Each now names the capability
port that owns the symbol, which survives further file moves:

  exec -> host-kit/command          host-process, owner-identity -> host-kit/process
  diagnostics -> host-kit/diagnostics   atomic-file, process-lock -> host-kit/file
  retry -> host-kit/retry           request progress/cancel -> host-kit/request
  version -> host-kit/version       ttl-memo, source-value, parsing, device-isolation,
                                    keyed-lock, success-text -> kernel subpaths

Comment-only; no closure, budget, or behavior change. ADR citations are left
as written, being dated records of the decision rather than live references.

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:48 +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 7db5ad73dd fix(ios): grant the text-entry commit wait time against progress (#2035)
* fix(ios): grant the text-entry commit wait time against progress

The synthesized commit wait used a flat 3s deadline, which cannot tell a
throttled simulator input pipeline (characters keep landing, slowly) from a
wedged one (nothing lands) — it condemned both at the same instant and reported
TEXT_INPUT_COMMIT_NOT_OBSERVED over a `type`/`fill` that was still working, on
branches touching no iOS code.

SynthesizedCommitBudget grants time against progress instead: while the observed
value's expected-prefix grows — the same length-only evidence logCommitCadence
already emits — the wait continues, up to a 10s ceiling. A pipeline making no
progress expires at exactly the 3s the flat deadline used, so a wedge is
condemned no later than before. It is a reference type, and the observe/expire
coupling carries a structural guard, because as a struct that coupling would
rest on Swift boxing one captured var and could revert to the flat deadline
silently.

Text-entry readiness' hardware-keyboard fallback also stops returning a
possibly-unfocused element after 0.35s of "no software keyboard seen"; it now
returns only on confirmed focus of the target and re-arms otherwise. And the
keyboard-hidden precondition of
testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden skips rather than fails,
so an environment flip cannot read as a product regression.

The issue's remaining ask — pinning the simulator keyboard preference — is
deliberately not done: measured on a dedicated simulator, per-device
ConnectHardwareKeyboard makes no difference to a headless `simctl boot`, which
always shows the software keyboard. See the PR body for the A/B.

Refs #1874 — not a closing keyword on purpose. This is a mitigation; the
unidentified simulator input-throttle mechanism that issue tracks is untouched
here, so it stays open.

* refactor(ios): move the commit-wait budget into the wait itself

Review follow-up. The budget was a detached object tested in isolation, with a
TypeScript parser asserting that two escaping Swift closures happened to share
it — a guard that only existed because the seam was in the wrong place.

The budget is now a local `var` inside `awaitSynthesizedCommitOutcome` and its
replacement counterpart, advanced from the same observation the progress check
already reads, with the clock injected alongside the existing observation and
pacing seams. Recording progress and asking whether time is up are two
statements in one loop, so there is no coupling left to guard.

The detached tests and the TypeScript wiring guard are deleted. In their place,
four sequence tests drive the shipped waits through a hand-driven clock: a
prefix that keeps growing outlives the flat 3s deadline, a frozen prefix is
condemned at exactly 3s, an indefinitely throttled pipeline stops at the 10s
ceiling, and a value churning between two lengths buys no time. Verified red
first — the two progress tests fail against a no-op `record`, and the two
unchanged-behavior tests stay green.

* fix(ios): grant the text-entry commit wait time against progress

The synthesized commit wait started its clock before reading the field's
placeholder, and that read is an AX round-trip which takes seconds on exactly
the loaded host this budget exists for. Slow setup therefore spent the budget:
with a 3.5s placeholder read the first observation already exceeded the 3s
stall budget, so `type` reported TEXT_INPUT_COMMIT_NOT_OBSERVED after a single
poll — sooner than the flat deadline this replaced, in the one condition it was
written for.

The budget is now two durations, and only the poll loop starts it, from its own
first `now()`. Passing a pre-loop timestamp is no longer expressible. The poll
also takes one clock sample instead of two, so the instant an observation is
recorded at is the instant it is judged against.

testCommitWaitBudgetStartsAtTheLoopRatherThanBeforeIt pins it: 60s of setup
before the wait must still leave the full stall budget. Verified red against a
deadline started outside the loop.

* fix(test-app): stop the form fixture placing its own placeholder in every fill

The `smoke:form-input` half of #1874 is not the commit deadline. This PR's own
iOS lane reproduced it (run 32889322172) and the trace settles it: `wait start
expectedLen=12`, then zero `[DEBUG-1874] poll` lines, then `wait
outcome=notObserved elapsedMs=3608`. The wait never polled — it returned from
the `textMatchesPlaceholder` guard, which refuses before polling because an
empty text field renders its placeholder AS its accessibility value, so a match
cannot prove a commit.

`field-name`'s placeholder was "Ada Lovelace" and every checkout-form suite
fills exactly "Ada Lovelace"; `field-email` had the same collision with
"ada@example.com". Twelve fills across eight files, so `fill` into those fields
is unverifiable by contract. It looked intermittent only because the
synthesized-replacement route is gated on `xCTestChannelPenalized` — it fires
when the host is loaded — which is also why re-running a failed job on the same
commit reproduced it identically.

The collision also made the read-back assertions vacuous: `assertJsonContains(
name, 'Ada Lovelace')` is satisfied by an empty field rendering the placeholder.

Fixed in the fixture rather than in the values, because frozen replay-compat
corpora carry the same fills and must not be edited.
fixture-fill-placeholder-collision.test.ts guards the class: it fails on any
repository fill whose value equals the target field's placeholder.

* refactor(ios): drop the fill/placeholder source guard and flatten the commit deadline

Review: the 83-line guard was a source-reconstruction test, not a fixture
invariant. It regex-parsed JSX and two literal fill spellings and duplicated the
Swift trim/equality rule in TypeScript, so it could stay green while its "every
fill" claim was false — expressions, variables, typed clients and unlisted roots
are all outside what a regex can enumerate. The owning evidence already exists:
the Swift tests prove a placeholder-equal AX value is unobservable, and live
smoke:form-input failed on the prior head for exactly this collision. Deleted;
the two placeholder changes stay.

Same pass over the rest of the change, for the same reason. The commit deadline
was a budget value type, a nested Deadline type and a factory method; it is now
one flat struct the poll loop constructs, with the two durations as defaulted
parameters. Production call sites name no budget at all, tests name one only
when they are asking about time, and SynthesizedCommitBudget.standard and the
tests' unboundedCommitBudget both disappear.

* refactor(ios): split the text-entry readiness and commit-wait seams

Review: the change grew three files past their budgets. Splitting them along the
seams they already had, no behavior change.

RunnerTests+TextEntry.swift (607) keeps the vocabulary, field clearing and value
reading at 259; everything that decides "which element is about to receive text,
and has it taken focus" moves to RunnerTests+TextEntryReadiness.swift at 354.

RunnerTests+SynthesizedTextEntry.swift (503) keeps the private-XCTest synthesis
boundary, the replacement route and the route policies at 356. The commit wait
moves next to the deadline that bounds it: the two waits, the observation and
pacing they poll through, and the value-free cadence line that path may log now
sit together in RunnerTests+SynthesizedCommitDeadline.swift at 206. That also
puts every line touching the polled field value in one file, so
apple-runner-log-redaction.test.ts guards a single surface — its path constant
moves with it.

The deadline's clock and sequence tests leave the policy tests (641 -> 494) for a
sibling RunnerTests+SynthesizedCommitDeadlineTests.swift, which gains the
replacement-route case the review asked for: a growing prefix carries the wait
past the 3s stall budget and the 10s ceiling is what ends it. The injected clock
is now defaulted, so only a test actually asking about time names it.

* refactor(ios): split text-entry target acquisition from readiness

Review residual: the readiness extraction was 354 lines and still owned two
questions. Acquisition — the one-shot tap witness, post-tap stabilization, both
focusTextInputForTextEntry entry points and the refresh point — moves to
RunnerTests+TextEntryFocus.swift (206). Readiness keeps the waits, the keyboard
signals they read and the focus corroboration (158).

The dependency is one-way: acquisition asks readiness, never the reverse, so
waitForTextEntryReadiness and keyboardBecameVisible lose file-private scope and
nothing else does.
2026-08-26 16:50:37 +02:00
Michał Pierzchała 72cae2bc72 refactor(apple): colocate the XCUITest runner client into packages/platform-apple (#2040) (#2050)
* refactor(apple): colocate the XCUITest runner client into packages/platform-apple (#2040)

Moves src/platforms/apple/core/runner/ (34 modules + apple-runner-platform.ts and
the 30 runner test suites) into packages/platform-apple/src/runner/ — Apple
mechanics live in the Apple package. Host capabilities (exec, diagnostics,
retry, process probes, locks, Apple tooling, physical-device control) enter
through the package-owned AppleRunnerHost port; the root composition module
src/platforms/apple/core/runner-client.ts constructs the client exactly once
and re-exposes the bound operations under their historical names.

R13 admits the transitional state deliberately: the family exports its root
façade plus exactly the enumerated ./runner, ./runner/client, and
./runner/test-host subpaths; the ./runner façade subpath is the recorded #1983
seam for unmigrated root consumers; ./runner/client has one composition root
and ./runner/test-host one vitest installer; the runner subtree may own its
cache files and sockets while raw process primitives stay banned. When #1983
completes, the subpaths and every subtree exemption are deleted and the family
returns to a single implementation-lazy façade export.

* docs(adr): model the runner subtree as a durable platform-owned facet

Review correction on #2050: the sunset story attributed the runner-consumer
migration to #1983, which owns snapshot/presentation vocabulary — not the
runner's daemon/root consumers — so that event cannot delete the ./runner
subpaths or the subtree exemptions. Reword ADR-0019, R13, and the gate
comments: the facet is the intended ownership model, its seam is enumerated
and pinned (exact export list, one client composition root, one test-host
installer, raw-process ban, eager-closure pins), and the seam narrows only
if a real runner-consumer migration retires the direct consumers. The
declaration mechanism stays apple-specific until another family needs a
mechanics facet. No behavior change; identifiers and comments only.
2026-08-26 15:53:01 +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 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 3e06884fcd fix(ios): verify fill's synthesized-replacement route before reporting success (#1995)
* fix(ios): verify fill's synthesized-replacement route before reporting success

The channel-penalized fill route (runSynthesizedReplacementRoute, taken when
the XCTest accessibility channel is already penalized under load) posted the
synthesized keystrokes and returned ok:true without ever reading the field
back, so a dropped or still-in-flight character was indistinguishable from
success. This is the same class of bug already fixed for bare `type`
(#1676/#1924), but that fix never covered this fill-only route.

Reusing type's append-mode commit-wait verbatim would have been wrong: its
"observed value isn't a prefix of expected -> trust the app" rule exists to
tolerate legitimate transforms (autocomplete, formatters) during append, but
it also waves through a dropped-middle-character corruption, since a value
with a hole in it is neither a matching prefix nor an exact match. Verified
against the real corruption strings ("Ada Lovelace" -> "Avelace", "ada@example"
-> "aexample") that the old rule would classify both as "trust it" and never
fail. Added a separate replacement-mode outcome function with no such
escape hatch, consistent with how isRepairableTextEntryMismatch already
treats every .replacement-mode mismatch as failing/repairable unconditionally.

Verified on a real iOS Simulator via xcodebuild test-without-building, not
just a build: the new regression test proves the old model accepts both
corruption strings while the new one correctly reports commit-not-observed,
and the full pre-existing append/type test suite passes unchanged.

* fix(ios): restore labeled observe-closure call sites for the redaction guard

The previous shared-plumbing refactor passed each route's outcome function
as a stored closure parameter, which erases Swift argument labels at the
call site. That broke the CI "Coverage" check's static content-redaction
test (apple-runner-log-redaction.test.ts), which locates the commit wait's
observe closure by its literal `observe: {` label to verify it only logs
polled field content through the value-free logCommitCadence boundary,
never a raw NSLog.

Restructured so the shared placeholder/deadline/observe/pacing ingredients
are still factored into one place, but each of the two public entry points
(append/type, replacement/fill) now calls its own named outcome function
directly with real argument labels, restoring the labeled closure shape the
guard depends on. Verified locally: the TS redaction test passes, and the
full on-device unit test set (19 tests, iOS Simulator) still passes with
zero regressions.
2026-08-24 14:35:28 +02:00
Michał Pierzchała e0cd06e567 test(snapshot): add cross-runtime presentation conformance (#1973)
* test(snapshot): add cross-runtime presentation conformance

* fix(ios): preserve acquired snapshot actionability

* chore(ios): retain existing XCTest selection name

* test(snapshot): cover nested cumulative clipping
2026-08-23 17:29:38 +02:00
Michał Pierzchała 7f3e355426 fix(ios): preserve regular snapshot depth through structural wrappers (#1947)
* fix(ios): complete regular snapshot depth frontier

* fix(ios): align depth frontier with visibility fold

* fix(ios): exercise regular depth frontier in CI

* fix(ios): cover visible-depth frontier through public snapshot

* fix(ios): tolerate absent deep-link confirmation

* test(ios): expose visible-depth fixture hierarchy

* test(ios): wait for visible-depth fixture subtree

* fix(ios): keep visible-depth fixture minimal

* fix(ios): update snapshot hint fixtures

* test(ios): avoid fixture label aggregation

* test(ios): match fixture raw hierarchy

* test(ios): prove visible-depth raw ancestry

* test(ios): align depth smoke with AX hierarchy
2026-08-22 13:53:39 +02:00
Michał Pierzchała e5bfde3d13 diagnose(1874): instrument the synthesized commit wait and add a dispatchable stall loop (#1941)
* diagnose(1874): instrument synthesized commit wait and add stall loop workflow

* diagnose(1874): fix empty-array expansion under set -u; raise default iterations

* diagnose(1874): add arm64 matrix leg to isolate the Rosetta factor

* ci: build the iOS runner for the native arm64 slice

A generic simulator destination leaves the active arch undefined; Xcode 26.6
defaults it to x86_64, running the whole runner under Rosetta on arm64 hosts.
Pin ARCHS=arm64 across every lane that builds the iOS runner and bump the
derived-data cache suffixes. Measured ~30% faster commits on identical CI
hardware; delivery-throttle episodes still occur but start from a lower base.

* diagnose(1874): keep commit-wait cadence evidence value-free

The per-poll trace logged the observed field's contents (prefix(40)) on the
shipped type path; that value is user content and runner.log persists. Log
lengths and the expected-prefix walk instead, allowlist every
string-interpolating NSLog format in the module behind a source-scan guard,
and pin commonPrefixLength in the host-lane policy tests.

* diagnose(1874): narrow the log-format match for typecheck

* diagnose(1874): route cadence evidence through a typed value-free boundary

logCommitCadence accepts Int lengths and a timestamp only, so observed field
contents are unrepresentable at the poll call site; its emitted line is pinned
by a sentinel-secret test in the host-lane policy tests. The source guard
becomes structural — boundary present, poll path logs through it, no raw NSLog
in the observe closure — instead of parsing Swift format strings. #1874 is
reopened as the removal-tracking thread for this temporary instrumentation.
2026-08-22 13:39:09 +02:00
Michał Pierzchała 991c08561b fix(ios): enforce regular snapshot clip invariant (#1946)
* fix(ios): enforce regular snapshot clip invariant

* fix(ios): restore typed snapshot failure construction

* fix(ios): linearize snapshot clip validation

* fix(ios): propagate snapshot presentation errors

* fix(snapshot): clarify presentation failure recovery
2026-08-22 12:15:40 +02:00
Michał Pierzchała 3e584d1d5d fix(ios): isolate snapshot acquisition timing from presentation (#1948)
* fix(ios): isolate snapshot acquisition timing

* fix(ios): pin snapshot phase timing fixture

* fix(ios): avoid starving synthesized text commits
2026-08-21 22:10:47 +02:00
Michał Pierzchała 34c14a55dc refactor(recording): share the AVFoundation export pipeline between overlay and trim (#1943)
* wip: shared recording export support

* refactor(recording): share the AVFoundation export pipeline between overlay and trim

Extracts RecordingExportSupport.swift (error vocabulary, flag-value reading,
composition assembly, bounded export wait) so recording-overlay and
recording-trim stop carrying three near-identical copies of the same
mechanics. Entry points move to @main because multi-file swiftc reserves
top-level statements for main.swift.

compileSwiftSourceFile gains extraSourcePaths: extra units join the cache key
and reach swiftc, and overlay.ts passes the shared support file for both
scripts. Error messages and per-script stderr prefixes are unchanged; trim's
error-precedence order (missing video track before invalid range) is
preserved by resolving the track before the range guards.
2026-08-21 18:42:35 +02:00
Michał Pierzchała 580bb5946a fix(ios): make snapshot presentation construction private (#1937) 2026-08-21 16:51:07 +02:00
Michał Pierzchała 5676d5ff8a refactor(apple): drop dead runner code and collapse duplicated helpers (#1936)
* refactor(apple): drop dead runner code and collapse duplicated helpers

Removes declarations with no consumers (findScopeElement, interactiveTypes,
two unused PresentedNode convenience inits) and collapses copy-pasted logic:
DataPayload now relies on the synthesized memberwise init, TvRemoteButton is
String-raw-valued, point-hit sorting shares smallestElementFirst, command-id
trim-or-nil lives once on RunnerCommandJournal, scroll/desktopScroll share
direction and durationMs validators, and the seven inline NSError refusals use
unsupportedOperationError. elementTypeName reads a table pinned by the
visibility-fold parity test.

The packager now skips files whose unit-test blocks were their whole body, so
10 test-only files stop shipping (and stop compiling on user machines) as
empty translation units.

Packaged Swift: 432.3 kB -> 427.8 kB; 56 files instead of 66. Net -144 lines.

* test(apple): pin skeleton-file exclusion in the packaging guard

The strip fixture always kept runtime content, so reverting the
skeleton-skip branch left every guard green. The new fixture's whole body is
unit-test blocks; the packaged path must be absent while a non-skeleton
sibling still ships. Observed red with the skip branch disabled before
re-enabling it.

* refactor(apple): tighten runner cleanup boundaries
2026-08-21 16:42:17 +02:00
Michał Pierzchała 766d42e124 fix(ios): report an unobserved text commit instead of a partial success (#1924)
* fix(ios): report an unobserved text commit instead of a partial success

awaitSynthesizedFirstResponderCommit returned Void, so its three exits were
indistinguishable to the caller: the expected text committed, the app
transformed the input, or the 3s deadline expired with a strict prefix still
outstanding. The caller returned dispatched-with-no-failure in all three, and
`type` answered ok with textEntryRoute synthesized-first-responder over a field
holding part of the requested text.

The wait now returns a SynthesizedTextCommitOutcome and an expired deadline
becomes TEXT_INPUT_COMMIT_NOT_OBSERVED, whose hint points at fill rather than a
type retry — type appends, so retrying it concatenates onto whatever committed.

The tail is still not re-synthesized: #1676 rejected that because a stalled
prefix cannot be told apart from a suffix still queued, so repair double-posts.
Reporting is what the runner does instead.

typeIntoCurrentTarget loses its `dispatched` flag, which was exactly
`failure == nil` and could not express the new state — characters posted, commit
unconfirmed, command must refuse. Failure is now the single discriminator.

The decision moves behind an injected clock/observer so the deadline branch runs
in the macOS host lane on every PR instead of needing a simulator.

Refs #1874, #1844

* fix(ios): close false-failure windows in the commit wait

Adversarial review found two deterministic false failures in the wait added by
the previous commit, plus a message that asserted a field state never read.

The deadline was checked before observing, so a commit landing during the final
poll sleep was condemned as never observed — under exactly the loaded-host
timing the wait exists for. The check now runs after an observation, so the last
thing before condemning is a read.

`treatingPlaceholderAsEmpty` maps a value equal to the field's placeholder to
"", a prefix of every expected value. `type "0.00"` into a field placeheld
"0.00" committed instantly, read as pending for the full 3s, and failed. The
observation now settles on an exact raw match; the normalized read still drives
the prefix walk.

The outcome-to-failure mapping moves to textEntryFailure(forCommitOutcome:) so
the branch the command refuses on is pinned by a test rather than living only in
a ternary. `.unobservable` staying a success is what keeps `type "...\n"`
working, and it now has an assertion.

Message and hint no longer claim the field holds a partial value: under both
fixed windows it may hold all of it. The docs sentence no longer implies every
text-entry route verifies its result — the replacement and keyboard-visible
routes have no resolvable element to observe and are unchanged.

Refs #1874, #1844

* test(ios): pin the placeholder fix at the boundary it actually lives on

Review [P1]: testValueEqualToThePlaceholder… injected an observe closure that
already returned "0.00", so it never supplied the normalized "" that causes the
failure. The raw-value short-circuit lived in the production observe closure,
which that test bypassed entirely — reverting the fix left it green.

The raw-exact/normalized-prefix choice moves into commitObservation, and the
test drives it with (raw: "0.00", normalized: "", expected: "0.00"). Reverting
commitObservation to always return the normalized reading now fails the
exact-match assertion.

normalizedValue is a closure rather than a value so an exact match still costs
one accessibility read instead of two, on a path that polls every 20ms for up to
three seconds; a second test pins that laziness.

The old test is deleted rather than kept: its remaining assertion (an exact
match settles without polling) is already covered by
testSynthesizedCommitStopsAtTheFirstSettledObservation.

Refs #1874

* fix(ios): never treat placeholder equality as commit evidence

Review [P1]: an empty text field renders its placeholder AS its accessibility
value, which is why editableTextValue(treatingPlaceholderAsEmpty:) classifies
that value as empty. The previous revision's raw-exact short-circuit therefore
matched BEFORE anything committed whenever the requested text was the
placeholder: `type "0.00"` into a field placeheld "0.00" settled on the first
read and returned ok with zero characters delivered — reintroducing the
success-misdescribes-the-device failure this PR exists to remove.

The state is structurally indeterminate. element.value is identical whether the
placeholder is rendering or the committed text happens to equal it, and
placeholderValue does not disambiguate, so no read resolves it and waiting the
deadline out discovers nothing. placeholderMakesCommitUnobservable detects it up
front and reports the commit unobserved, which the caller refuses on.

commitObservation is deleted rather than narrowed: the raw match was only ever
consulted in this exact case, and in this exact case it is not evidence.

The failure message drops its deadline reference — this refusal never waits.

Refs #1874

* fix(ios): scope the placeholder refusal to an empty baseline

Review [P1]: the guard took only the placeholder and the expected text, so it
refused any append whose result happened to equal the placeholder. Value "0" +
`type ".00"` against placeholder "0.00" was refused before a single read, even
though the non-empty pre-dispatch value proves the placeholder is not what is
rendering and a later "0.00" is genuine commit evidence.

The baseline is what decides it, so it is now an input. placeholderCommitEvidence
returns three states rather than a boolean:

  normalRead         expected differs from the placeholder; the placeholder never
                     enters into the observation
  indistinguishable  expected IS the placeholder and the field was empty, so the
                     placeholder was what rendered and no read can resolve it
  rawValueIsEvidence expected IS the placeholder but the field held content, so a
                     raw match is real

Only .indistinguishable refuses, and it still refuses before the wait, since no
read resolves it. .rawValueIsEvidence reaches the observation and settles on the
raw match, which the normalized read would otherwise hide.

commitObservation returns for that third state, now scoped by evidence rather
than applied unconditionally as in the revision that made raw equality a
false success. Both readings stay closures, so normalRead — the ordinary case —
never pays for the raw read.

Refs #1874

* fix(ios): keep placeholder-equal commits conservative
2026-08-21 15:12:43 +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 30de1597d3 ci: attribute native package size and trim Apple runner (#1934)
* ci: attribute npm package size by shipped component

* refactor: modularize size reporting and trim Apple runner

* ci: preserve size reporter modules across base checkout
2026-08-21 13:46:53 +02:00
Michał Pierzchała 07023eb202 fix(ios): separate snapshot actionability from occlusion (#1933) 2026-08-21 12:47:25 +02:00
Michał Pierzchała af96c6608d feat(ios): publish effective snapshot geometry (#1931) 2026-08-21 11:27:04 +02:00
Michał Pierzchała 73db7be2ff feat(ios): move the regular-projection clip fold into snapshot presentation (#1797) (#1929)
* feat(ios): move the regular-projection clip fold into snapshot presentation

Both iOS snapshot backends carried their own copy of the visibility fold: the
tree walker and the private-AX serializer each computed viewport-and-scroll-clip
intersection, ancestor projection, hidden-content hints, and collapsed depth
during acquisition. Hand-synchronized copies of that interpretation are what
produced the scroll-overflow leak class (#1784), and C1 (fact-availability
neutrality) could not hold while acquisition decided what a screen shows.

Acquisition backends are now fact serializers: every traversed node is emitted
at raw traversal depth with its reported frame, and SnapshotAcquisition carries
the viewport. presentRegular runs the one clip fold for every backend --
viewport ∩ scroll clip, the ancestor cursor (an out-of-clip Cell or scroll
container hides its clamped descendants), the sub-pixel decoration rule,
scroll hints booked onto anchors, reparenting with collapsed depth -- and
narrows the emitted hittable to the clip: nothing outside its clip, and nothing
without geometry, is ever hittable, whatever the backend reported. Platform
differences are a SnapshotFoldPolicy input to the shared algorithm (iOS
cursor-projected; macOS/tvOS plain viewport), never a backend exception.

The private-AX backend collapses to ONE serializer for both projections, and
the flat filter-decision family dies with the acquisition gates it fed.

Three intentional edge deltas, each toward one backend-neutral rule: sub-pixel
content-free decorations now drop on every backend (was private-AX only);
labeled offscreen Application/Window carriers survive on every backend (was
tree only), never hittable; query-sweep regular without -i is viewport-folded.
Declared acquisition residues: the traversal-depth budget cut, the sweep's
frameless-element drop, the private-AX bridge's device-side cap.

Refs #1797 (migration step 3, clip-fold delta).

* refactor(ios): isolate snapshot visibility fold
2026-08-21 11:27:04 +02:00
Michał Pierzchała 4137e4275e feat(ios): split raw and regular snapshot projections behind one capture hint (#1926)
The private-AX backend interpreted `--raw` as the regular projection: it folded
the viewport and scroll clips and dropped sub-pixel decorations before returning,
so a raw capture that recovered onto it answered with viewport-pruned nodes
labeled raw (#1797 D4). Nothing related the two copies of that decision.

Presentation now exposes the two projections it always implied. `presentRegular`
folds visibility, eligibility, scope and scroll hints; `presentRaw` is the
acquired tree, normalized, narrowed only by a scope or depth the request asked
for. Acquisition reads one derived `CaptureHint` instead of the request itself,
so what a capture may skip is stated once, beside the reason skipping it keeps
the projection complete: scope and its relative depth never narrow, raw depth
does (raw depth is traversal depth), and the raw projection never carries
`interactiveOnly` — `--raw -i` is the acquired tree.

Two structural rules replace the hand-synchronized ones. The raw plan is derived
from `SnapshotBackendKind.supportsRawProjection`, so the query sweep — an
interactive element query with no hierarchy to return — cannot be planned for a
raw request. And presentation compares the requested projection with the hint
the acquisition was captured under, dropping that tier with a structured
`IOS_SNAPSHOT_PROJECTION_MISMATCH` failure rather than presenting it under the
requested label.

Declared residue: a regular `--depth` request still cuts the traversal at that
depth while regular presentation emits collapsed depth, so a node that would
present within the limit can be dropped. The cut is what keeps `--depth 1`
probes cheap; making it complete is the open visible-depth frontier obligation.

Refs #1797 (migration step 3, raw-projection delta).
2026-08-20 21:44:48 +02:00
Michał Pierzchała be51870118 fix: make iOS scroll release controlled (#1906)
* fix: reduce iOS scroll overshoot

* fix: make iOS scroll release controlled

* fix: make controlled iOS scrolls deterministic

* fix: preserve continuous drag sampling
2026-08-20 19:49:39 +02:00
Michał Pierzchała 762e0d49b9 test: cover host runner tv remote and recording paths (#1903)
* test: cover host runner tv remote and recording paths

* fix: make recorder timestamp test deterministic

* fix: remove uptime dependence from recorder test
2026-08-20 16:01:54 +02:00
Michał Pierzchała fed7251336 fix: calibrate iOS scroll execution (#1905) 2026-08-20 15:19:08 +02:00
Michał Pierzchała 9ce1ef7587 feat(snapshot): move scope into presentation (#1855)
* feat(snapshot): move scope into presentation

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

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

* fix(snapshot): select scopes with presented content

* docs(snapshot): describe presentation-owned scope

* docs(snapshot): record contribution-aware scope

* refactor(snapshot): drop unrelated provider churn

* fix(snapshot): accept healthy empty scoped capture

* refactor(snapshot): isolate empty-scope admission

* fix(snapshot): align scope ownership across runtimes

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

* test(snapshot): retain find test shrink
2026-08-20 10:30:18 +02:00
Michał Pierzchała d07b837621 test: classify the runner XCTests — pure decisions to a macOS host lane, simulator semantics gated os(iOS) (#1781 A7) (#1861)
Every declared AgentDeviceRunnerUITests method now belongs to a lane, and
the #if guard is the classification: AGENT_DEVICE_RUNNER_UNIT_TESTS alone
means a pure runner decision (runs on the macOS host on every PR — ci.yml's
existing compile job now executes the bundle it builds), '&& os(iOS)' means
runner/XCTest semantics (simulator lanes only). check:xctest-selection
evaluates the guards per platform, derives each lane's reach, and fails on
a flagged identifier that is undeclared or uncompiled on that lane, on a
declared test no lane reaches (found the two tvOS-only tests, dark since
birth — widened to os(tvOS) || os(macOS)), and on testCommand reaching any
lane. The host and nightly lanes assert executed == derived reach, so a
missing -D flag or a guard that compiles a file out reads red, not as a
smaller green. One duplicate test deleted (sparse-verdict assertions folded
into its twin).
2026-08-19 13:59:45 +02:00
Michał Pierzchała ee13203a16 feat(ios): unify snapshot eligibility (#1850)
Make iOS regular snapshot eligibility one backend-neutral presentation rule.

Acquire tree nodes conservatively, preserve interactive scroll containers, normalize surviving hierarchy, and keep raw membership plus daemon publication policy unchanged. Part of #1797.

- iOS and macOS unit-enabled runner builds
- 2 focused XCTest cases
- 3 production-path publication tests
- live Settings snapshots: 73 regular nodes and 167 raw nodes, both healthy tree captures
2026-08-18 18:57:29 +02:00
Michał Pierzchała a70cdee360 refactor(ios): route snapshot backends through presentation (#1848)
## Summary

Route every iOS capture-plan backend through one SnapshotPresentation boundary while preserving each backend's current output semantics.

SnapshotAcquisition now carries nodes and attempt-level facts, PresentationOptions is the stable policy input, and only the presentation module assembles wire-facing nodes. Part of #1797.

Touches 10 files within the existing iOS snapshot module and its architecture vocabulary; scope did not expand beyond the planned command family.

## Validation

- Unit-enabled iOS runner build and focused presentation XCTest passed.
- Removing the custom-action handoff made the focused test fail with exactly two assertions, proving the routing check is non-vacuous; restoring it returned to 1/1 green.
- macOS runner build passed for the shared Swift path.
- XCTest selection and repository formatting checks passed.
2026-08-18 18:24:44 +02:00
Michał Pierzchała f03c0309a1 fix: derive iOS transition snapshots from visible presentation (#1831)
* fix: project iOS transition semantics

* fix: derive iOS transition semantics from visible state

* fix: preserve iOS presentation context for scoped snapshots

* fix: confirm broad iOS transition settlement

* ci: run coordinate input regression on pull requests

* test: mock migrated snapshot capture seam

* fix: confirm transitions across snapshot backends

* fix: arm transition confirmation after first capture

* fix: settle against immutable action baseline
2026-08-18 17:53:23 +02:00
Michał Pierzchała 8300fa131e refactor(ios): establish snapshot presentation seam (#1845)
Introduce RawAXNode and PresentedNode so acquisition backends can no longer construct the wire-facing snapshot shape directly. Preserve current output while #1797 moves semantics behind the seam.

Non-vacuity: setting PresentedNode.label to nil made testSnapshotPresentationPreservesCurrentWireShape execute once and fail on the missing label field; restoring the production mapping made the same focused XCTest pass.
2026-08-18 17:39:26 +02:00
Michał Pierzchała f843dc2df1 fix(scroll): keep saturated scroll gestures out of the status bar; gate Android replays from android/emulator (#1781 A1) (#1820)
* fix(scroll): keep saturated scroll gestures out of the status bar; gate Android replays from android/emulator (#1781 A1)

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

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

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

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

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

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

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

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

Follow-up for content-safe viewport bounds + discovery order: #1821.
2026-08-18 14:31:54 +02:00
Michał Pierzchała 1234590a69 fix(ios-runner): reject CGRect.infinite in navigation frame guards (#1816)
topLeadingNavigationFallbackPoint and isTopNavigationControlFrame only
validated width/height, never the origin. CGRect.infinite has a finite
(if absurd, ~-9e307) origin, so it slipped past the isFinite/>0 guard:
the fallback point collapsed to roughly (-9e307, -9e307), and an
unresolved element frame was classified as sitting in the nav header
band.

Extract isUsableNavigationFrame(_:), which adds the isInfinite check
TapPointPolicy.isAllowed already uses for the same rect, and share it
between both helpers.

Fixes #1812
2026-08-18 12:32:04 +02:00
Michał Pierzchała 8b0560a51d fix: hide clamped descendants of offscreen iOS rows (#1811) 2026-08-18 11:00:19 +02:00
Michał Pierzchała 0d3b7413c5 fix: prevent private AX subtree leaks at source (#1807)
* fix: prevent private AX subtree leaks at source

* fix: preserve values in settle signals

* fix: normalize settle signal semantics
2026-08-18 09:23:39 +02:00
Michał Pierzchała ed1d44fa62 fix: unify private AX scroll visibility (#1798)
* fix: unify iOS scroll snapshot visibility

* fix: preserve composed scroll hints
2026-08-18 00:47:25 +02:00
Michał Pierzchała 8b698e8efc fix: stabilize private AX settle snapshots (#1784)
* fix: stabilize private AX settle snapshots

* fix: harden private AX settling
2026-08-17 18:58:12 +02:00
Michał Pierzchała 85aef7af75 fix(ios): make custom-action reads atomically single-flight (#1749)
* fix(ios): relax custom-action refusal timing assertion

* fix(ios): make custom-action admission atomic
2026-08-12 12:53:49 +02:00
Michał Pierzchała 1b2e786128 refactor: move screen recording onto platform runtime (#1724) 2026-08-11 10:24:57 +02:00
Michał Pierzchała cdc754e6ed perf: speed up iOS agent recovery and streamline CLI guidance (#1700)
* Avoid interactive children in parent taps

* docs: streamline no-skill CLI help

* perf: recover faster from sparse iOS trees

* fix: preserve selector context for blocked parent taps

* fix: preserve coordinate text-entry focus

* fix: preserve thin parent touch targets

* fix: fail closed for unscoped iOS typing

* test: isolate replay lock fixture

* test: share node integration process
2026-08-10 20:43:01 +02:00
Michał Pierzchała 6c0fcb64a1 fix: reject distinct ambiguous mutation targets (#1667)
* fix: reject distinct ambiguous mutation targets

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

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

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

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

* test(ios): execute selector read ambiguity regression

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-08 08:57:32 +02:00
Michał Pierzchała 04c33f9e1e feat(ios): expose AX custom actions on merged accessibility elements (#1665)
* feat(ios): expose AX custom actions on merged accessibility elements

Apps that merge a card into one accessibility element for VoiceOver (React
Native's `accessible` prop) publish the card's real affordances as
UIAccessibilityCustomActions rather than as child elements. Our snapshot showed
only the merged node, so an agent looking for a feed card's options control had
nothing to aim at and fell back to coordinate guessing.

`snapshot --actions` now names them:

    @e8 [link] "feedItem-by-whiskers.test" actions: ["Reply", "Repost", "Open post options menu"]

Opt-in, because the AX server cannot serve custom actions in a bulk tree
request: adding the attribute makes testmanagerd's reply decoder reject the
nested arrays a custom action serializes into, drop the reply, and time the
request out (~65s vs ~110ms). Only per-element reads answer, at ~100ms each, so
the runner reads at most 12 labelled childless nodes and stops at the
capture-plan deadline. The request pins the private-AX backend, since no other
backend can read the attribute, and reports that as its own `requested-backend`
verdict so a deliberate pin never renders as a degradation warning.

Invocation is not shipped: the actions are readable but not invocable from the
runner. RunnerAXSnapshotBridge.h records the five APIs that were tried.

* fix(ios): disclose a capped custom-action pass, and read on-screen elements first

Two gaps in the first cut.

An element the bounded pass never reached rendered identically to one with no
custom actions, so a capped capture silently taught the reader that later feed
cards have no affordances — the exact mis-inference this feature exists to
prevent. The runner already counted reads against candidates; it now carries
both to the response, and the verdict renders one response-level line when the
pass was incomplete. A complete pass stays silent, and "never asked" stays
distinguishable from "read none" (the key is absent, not (0, 0)).

The obvious remedy for a capped pass would be a scoped re-run, but scope is
applied when the Swift walk builds nodes, long after the read pass, so it does
not redirect the budget at all — measured: reads=12 candidates=18 with and
without --scope. Rather than print a remedy that does nothing, the read pass now
orders candidates on-screen first. That makes the budget land on elements an
agent can act on, and makes the disclosed remedy true: scrolling changes the
on-screen set, so a re-run reads elements the previous pass could not.

Also states plainly, in the flag help and the tool/SDK field description, that
the names are for planning: nothing invokes them, so the affordance is reached
through the element's detail screen, the same control exposed elsewhere, or
coordinates.

* test(snapshot): pin the custom-action coverage pair in the verdict shape assertion

* chore(scripts): classify the --actions flag in the integration progress model

The completeness gate flagged snapshotCustomActions as unclassified, which is
what it is for. It gets its own bucket rather than joining the provider-scenario
table: the values come from the private AX client inside the runner process, and
the fake runner derives its behavior from fixture tables that cannot fabricate
custom actions, so there is no provider-backed scenario to claim. The owning
coverage is named instead — runner XCTest unit, snapshot-lines, snapshot-quality.

* fix(ios): fail closed on unserviceable --actions, bound each read, cap output, and count actions in identity

Four review findings.

1. `--actions` with `--raw`, or on any target that is not an iOS simulator, used
to succeed and return nodes with no actions — a requested capability silently
no-opped, indistinguishable from "this screen has none". Both now fail closed.
The raw pairing is rejected at the shared request seam (INVALID_ARGS) so CLI,
Node client and MCP answer alike before any device work; the platform case is
rejected once the session device is resolved (UNSUPPORTED_OPERATION), naming the
resolved target. `diff --actions` was already rejected as an unsupported flag.

2. The per-element AX read had no timeout, so one wedged element could consume
the whole capture budget. Each read now runs off-thread behind a 1s wait. A
timed-out element counts as unread, never as "read, and it has no actions", so
the existing partial-pass disclosure already covers it.

3. The element budget bounded element count only; one element could still return
an unbounded list of unbounded names. Capped at 8 names of 80 characters, and
clipped elements are counted into the coverage so a truncated list is disclosed
rather than silently presented as complete.

4. Action names were rendered unescaped, and no comparison key read them. Names
now get the same escaping as text previews plus control-character folding, so an
app-authored name cannot split or corrupt a line. `actions` joins the diff
comparable key, the unchanged-comparison projection, and — the sharper bug —
the snapshot presentation key, without which `snapshot` followed by
`snapshot --actions` on a still screen answered "unchanged" and never delivered
the actions that were explicitly requested.

* fix(ios): contain a hung custom-action read instead of accumulating orphans

The 1s read deadline frees the caller, but the underlying AX call is a
synchronous XPC round trip that cannot be cancelled — it keeps running. On a
global concurrent queue that meant repeated `snapshot --actions` against a
wedged element piled up orphaned reads, all using the shared XCAXClient
concurrently. The deadline was containment for the capture, not for the runner.

Since the call cannot be cancelled, contain it instead:

- every read runs on one dedicated serial queue, so a wedged call can never be
  joined by a second concurrent user of the shared client;
- a single-flight guard refuses to dispatch at all while an abandoned read is
  still outstanding, so a repeated capture adds no work — the dispatch counter
  stands still;
- the read pass stops at that point rather than paying a deadline per element
  on reads that would all be refused, and reports `blocked` so the capture stays
  honest. That gets its own line, because the partial-pass remedy (scroll and
  re-run) cannot clear a hang and would send the reader in circles.

Recovery needs no reset: when the hung call finally returns, in-flight drops to
zero and reads resume.

The regression drives a fake AX client that never returns, and asserts the three
things the fix exists for — exactly one in-flight read with no further
dispatches across repeated captures, immediate returns with the skip disclosed
instead of the scroll remedy, and reads working again once the wedge clears.

* ci(ios): execute the custom-action runner regressions instead of only compiling them

The iOS workflow runs a targeted -only-testing list, so a runner test that is
not named there is compiled by the build step and then never executed. All seven
custom-action tests were in that gap — including the containment regression,
which is the only executable proof that a hung AX read cannot accumulate
orphaned in-flight reads.

Red/green against the containment regression, with the fix reverted to its
pre-fix concurrency behavior (global concurrent queue, no single-flight guard,
no blocked exit):

  RED   in-flight 6 (want 1), dispatches 6 (want 1), each repeat paid the full
        1.004s deadline (want <0.2s), blocked=false (want true), and the
        in-flight drain never completed — "Exceeded timeout of 5 seconds".
  GREEN 7/7 pass, containment regression in 1.02s.
2026-08-07 17:35:48 +02:00
Michał Pierzchała 5f90d908e2 fix(ios): wait for hidden-keyboard synthesized text to commit before responding (#1676)
* fix(test): wait for typed text to settle in the hidden-keyboard runner test

testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden read textField.value
in one shot right after executeTypeCommand returned. The simulator commits
synthesized keystrokes after the command responds, so on a loaded CI machine
the read landed mid-word — observed failures reported ("h") and
("hardware-ke"). It failed on 3 of 5 runs of a branch carrying zero Swift
changes and passed on re-run.

Poll the value until it holds the expected text (10s ceiling) and assert on
the last value read, so a real regression still fails with what the field
actually held. Both reads in the test use the same helper; the assertions are
unchanged.

* Revert "fix(test): wait for typed text to settle in the hidden-keyboard runner test"

This reverts commit caecdc6d5b.

* fix(ios): wait for hidden-keyboard synthesized text to commit before responding

The synthesized-first-responder bare-type route returned ok as soon as the
private XCTest event record was posted, while the target app was still
committing characters. On slow CI simulators the trailing characters landed
after the response, so agents (and the smoke test) observed a truncated
field value through the public type path.

After dispatch, poll the tapped element until its value reaches
textBefore + typedText, exit immediately when the app transforms the input
(formatter, mid-text caret, autocomplete), and if progress stalls as a
strict prefix, re-synthesize the missing tail once. Submit-suffixed text
keeps the old immediate return so a repair can never double-submit.

Validated on a booted iPhone 17 Pro simulator: 5/5 passes of
testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden under full-core CPU
load, with the commit wait absorbing up to ~390ms of post-dispatch lag that
the previous code ignored (uniform ~494ms dispatch-only before); the tail
repair never had to fire, consistent with commit lag rather than true drops.

* fix(ios): make the synthesized commit wait observation-only

Addresses the P1 on #1673: a 600 ms quiet prefix cannot distinguish delayed
app-side commit from a genuinely dropped suffix, so re-synthesizing the tail
could post it while the original was still queued and commit the text twice
after the command had already reported ok.

Drop the repair path entirely — the tail builder, the quiet-window constant,
the stall tracking, and the synthesizer dependency the wait only needed in
order to repair. What remains is a bounded observation: poll the tapped
element until the value commits, the app transforms it, the value becomes
unreadable, or the 3s ceiling expires. A dropped suffix still reports ok,
exactly as before this change; only the false truncation from commit lag is
removed.

The submit-key skip stays, for its own reason: the app may clear or rewrite
the field on submit, so textBefore + typedText is not the value to wait for.

Also wire testSynthesizedTextCommitProgressWalksExpectedPrefixOnly into the
iOS smoke lane — that workflow enumerates its tests by hand with
-only-testing, so a new test that is not listed never runs.
2026-08-07 17:34:09 +02:00
Michał Pierzchała f4ebd6f14f fix(ios): synthesize hidden-keyboard text through responder (#1657)
* fix(ios): synthesize hidden-keyboard text through responder

* docs: document iOS text synthesis failure
2026-08-06 22:16:55 +02:00
Szymon Dziedzic 3e4828d68d feat: add scale-only screenshot sizing (#1617)
* feat: add scale-only screenshot sizing

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

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

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

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

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

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

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

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-08-06 15:11:00 +02:00