Commit Graph

1496 Commits

Author SHA1 Message Date
Michał Pierzchała 4b89482a06 fix(daemon): open --foreground P1 hotfix — selector rejection, interactive snapshot, capture-failure masking (#1671)
* fix(daemon): open --foreground P1 hotfix — selector rejection, interactive snapshot, capture-failure masking

Three P1s from the post-merge review of #1670, all at the
session-open-foreground dispatch seam:

1. Explicit device selectors were silently overwritten: the resolved-device
   rewrite pinned --udid/--platform over whatever the caller passed, so
   `open --foreground --udid B` with sim A sole-booted silently opened A.
   Now fails fast with INVALID_ARGS (matching the existing app-positional
   rejection) on --udid/--device, and on --platform other than ios; an
   explicit --platform ios passes through.

2. The promised interactive snapshot was never requested: the composed
   snapshot dispatch forwarded the open request's flags untouched, without
   snapshotInteractiveOnly — so the capture was NOT the `snapshot -i` path
   the doc comment promised and returned no interactive presentation. The
   composed request now sets snapshotInteractiveOnly: true (the exact key
   the CLI maps -i to and the snapshot runtime reads as interactiveOnly).

3. A capture failure masked the successful open: returning the snapshot
   error discarded openResponse even though the session exists, so a retry
   of `open --foreground` failed with "close the current session first".
   Open success + snapshot failure now returns ok with an explicit
   initialSnapshotError {code, message} detail and a rendered warning that
   the session IS open and how to capture manually (snapshot -i).

Regressions added for all three: explicit-selector rejection
(udid/device/both/non-iOS platform + ios pass-through), the composed
dispatch carrying snapshotInteractiveOnly, and the snapshot-failure path
returning ok + warning + usable session.

* fix(cli): render the composed open --foreground snapshot on default stdout and project initialSnapshotError through the public surfaces

Post-merge review on #1671 found the daemon fixes never reached the public
boundaries: openCliOutput ignored the nested snapshot (the one-call promise
held only under --json), and initialSnapshotError was daemon-only — absent
from AppOpenResult, Node normalization, and serializeOpenResult, with the
normalized shape truncated to code+message.

- default open output now renders the composed interactive tree through the
  same snapshotCliOutput path snapshot -i uses
- AppOpenResult carries initialSnapshotError as the FULL daemon error
  (hint/details/diagnosticId/logPath preserved) through normalization and
  serialization

Worker-authored; committed by the coordinating session after the worker
stalled twice mid-push. Tests: output.test.ts + session-open-foreground
(26 pass), typecheck, oxfmt.

* refactor(client): one daemon-error normalizer + client-route regressions for initialSnapshotError

Review follow-ups on #1671: normalizeInitialSnapshotError duplicated the
target-shutdown error normalization and pushed the module over the fallow
complexity threshold; consolidated into a single internal normalizeDaemonError
(table-driven, full shape incl. retriable/supportedOn) used by both result
paths, projected through normalizeOpenForegroundComposition.

Client-route regressions: createAgentDeviceClient().apps.open now proves the
full initialSnapshotError shape (hint/details/diagnosticId/logPath/retriable)
survives normalization, and that a malformed one is dropped — deleting the
boundary normalization fails these tests.

Also rebased onto current main.

* fix(daemon): a thrown initial-snapshot capture failure gets the same successful-open contract

Review P1 on #1671: dispatchSnapshotViaRuntime rethrows ordinary
capture/runner exceptions; the composition only handled a returned
{ ok: false }, so a thrown failure escaped to the router and failed the
whole open after the session was created — retrying then wedged on the
existing session. The catch normalizes the rejection (kernel
normalizeError, same conversion the router applies) into the shared
openWithInitialSnapshotFailure path: ok response, full-shape
initialSnapshotError, session-usable warning. Rejecting-mock regression
added alongside the returned-failure case.
2026-08-08 07:54:33 +02:00
Michał Pierzchała 13bc70f24f refactor(find): resolve a mutating find's target once, not twice (#1654) (#1669)
* fix(find): resolve a mutating find's target once, not twice (#1654)

A mutating `find click`/`find fill` captured the screen, matched by
locator under the `findAct` policy, promoted to a hittable ancestor, minted
`@eN` off the node it chose — and then re-entered the interaction leaf by
bare `@ref`, which looked that ref up AGAIN via resolveSnapshotForRef. The
second lookup reads the SESSION frame tree, not the fresh capture find
matched against, so the two could disagree: it could hand the action a
different node than find picked, or refuse as unresolvable a ref find had
observed a moment earlier.

find now passes the node itself. `internal.findPreresolvedTarget` carries
the resolved node and its tree over the in-process invoke hop, and the ref
branch adopts them instead of resolving the ref a second time.

What is NOT skipped: the guarantees. Occlusion, hittable-ancestor
promotion, and the off-screen guard all still run, on that node, at the
same symbols the ADR 0011 `runtime-ref` cells name. Only the LOOKUP is
replaced. Recording, ref-frame effects, settle/observation, and
deferred-outcome marking are untouched — they live in the dispatch wrapper,
not in resolution, which is why the invoke hop is kept rather than bypassed
the way find focus/type bypass it.

The channel is a second field rather than widening `findResolvedTarget`
because they answer different questions: that flag governs ref-frame
admission and staleness (policy), this governs resolution (lookup), and
focus/type set neither. It is `internal`-only, so it never crosses the
wire and carrying live node references is sound.

ADR 0011 re-check: the `runtime-ref` disclosure cell gains a second
producer, adoptPreresolvedRefTarget, reporting `exact`. Truthful rather
than borrowed — the ref is minted off the node handed over. It cannot
report label-fallback, which is right: label recovery is a property of
looking a stale ref up, and this path performs no lookup.

Tests pin the behavior by tap coordinates, so they name which tree the leaf
resolved against, plus a control proving the ordinary @ref path is
unchanged and a case where the session tree cannot resolve the ref at all —
that one can only pass if no second lookup runs. Verified revert-sensitive:
removing the short-circuit fails 3 of the 4, and the control stays green.

Out of scope, unchanged: the ADR 0011 `native-ref` path (web provider
clickRef/fillRef only), where the ref is the provider's own element handle.

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

* test(find): prove the production route, and correct the divergence claim (#1654 review P2)

The regression tests built `internal.findPreresolvedTarget` by hand and
called handleInteractionCommands directly, so they proved the leaf consumes
the channel but never that find ATTACHES it. Either producer could have
stopped doing so and they would all have stayed green — the exact gap
#1649's review named, reappearing one layer up.

Four tests now drive the real handleFindCommands with invoke wired to the
real handleInteractionCommands. Two assert click and fill each carry the
selected node (separate producers, separate forwarding hops, so asserted
separately); two advance the session tree between find's match and the
leaf's resolution and assert the dispatched point is still find's node.

That last pair also corrects the record. The claim that the leaf resolves
against a different tree than find matched is too strong for the common
path: `omitRefFrameSnapshot` (interaction-runtime.ts) makes find's internal
dispatch skip the authorized frame tree and resolve against
`session.snapshot`, which find's own capture just wrote — so the second
lookup normally AGREES, and the two non-diverged tests above pass with or
without the fix.

The divergence is real but narrower: it needs something to advance
`session.snapshot` between find's match and the leaf's resolution, which
`refreshAndroidRefSnapshotIfFreshnessActive` does on the ref path. Measured
at this head, the old code taps (60,720) "Delete" where find matched
"Save" at (310,510) — a wrong-element mutation, now pinned by both click
and fill.

So the change's value is what #1654 asked for — one resolution end to end —
plus closing that window, not a fix for a divergence occurring on every
find.

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

* fix(find): tighten resolved target provenance

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-07 17:45:43 +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 f5b6e8f28c fix: enforce replay timeout envelope (#1674)
* fix: enforce replay timeout envelope

* fix: preserve replay timeout metadata
2026-08-07 17:33:53 +02:00
Michał Pierzchała 7b4e461220 fix(test): clean Swift toolchain temporary directories (#1664)
* fix(test): clean Swift toolchain temporary directories

* fix(test): wait for Swift toolchain shutdown

* fix(test): terminate Swift toolchain process groups
2026-08-07 16:14:37 +02:00
Michał Pierzchała e14c9d8d7b fix(cloud): unblock iOS snapshot and gate cloud fill on text-entry focus (#1658) (#1666)
* fix(cloud): unblock iOS snapshot and gate cloud fill on text-entry focus (#1658)

Two bugs isolated to the cloud-webdriver iOS path.

`snapshot`/`diff` refused every capture on a live BrowserStack session with
SESSION_NOT_FOUND, instantly and without a driver round trip. The app-session
guard they ran belongs to the local XCUITest runner, which must attach to a
target app; a cloud capture reads the provider's own driver session and needs
no app identity, so it now applies to local Apple targets only. The session
was empty in the first place because the provider open path skips local app
resolution wholesale — no simctl/devicectl reaches a hosted device — and
dropped an explicitly spelled bundle id along with it. A dotted, non-deep-link
target is the bundle id under the same convention resolveIosApp applies
locally, so a cloud `open com.example.app` now records it.

`fill` tapped and sent its keys in back-to-back requests. A WebView input —
an OAuth page in a Safari view controller — takes first responder
asynchronously, so the keys landed with nothing focused while the command
still answered "Filled N chars"; tapping and filling as two separate commands
worked only because the round trip between them gave the field time to focus.
The cloud interactor now waits on the same signal the Apple runner uses, the
software keyboard going from hidden to shown after its tap, and discloses what
it observed as `textEntryReadiness` so a fill with no witness cannot pass for
a filled field. Where keyboard visibility cannot witness the focus move —
back-to-back fills into one form, the shape that failed most often — it spends
the runner's full readiness budget rather than racing the app with a short
settle.

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

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

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

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

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

Review follow-ups on #1658.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two blockers from the review of 3a9aceb9.

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

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

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-07 15:52:24 +02:00
Michał Pierzchała 9b4239b2f3 fix: heal dangling XCTestDevices redirect and prove lock-owner death before reclaim (#1672)
* fix(ios): heal dangling XCTestDevices symlink during device-set reconcile

fs.existsSync follows symlinks, so a redirect symlink whose target set was
deleted read as absent: reconcile skipped the unlink and the backup rename
failed with ENOTDIR, wedging every XCTest command on the machine until manual
cleanup. Detect symlinks with lstat (throwIfNoEntry) in both the reconcile
check and unlinkIfSymlink so the stale link is removed and the backup restored.

* fix: prove owner death before reclaiming locks, and detect zombies

A zombified lock owner passes kill(pid, 0) and still reports its original
lstart, so a killed-but-unreaped daemon held the XCTest device-set lock
forever. Conversely a ps read lost to CPU contention condemned a live owner
and let waiters steal a held lock (the flake pinOwnProcessStartTime papers
over in tests).

Unify both liveness surfaces on classifyOwnerLiveness: condemn zombies via
ps state, treat failed ps reads as no-proof rather than death, and reclaim
null-start-time owners whose pid provably started after the lock was acquired
(ps etime bound). Lock timeout errors now report ownerLiveness.

* fix: keep null-start-time lock owners fail-closed

Review P1 on #1672: the started-after-acquisition bound compared a wall-clock
birth estimate (Date.now() - ps etime) against the persisted wall-clock
acquiredAtMs, so a clock step larger than the fixed 30s slack could condemn a
live null-start owner and let a waiter steal the held lock — the failure class
this PR eliminates. There is no same-clock-domain proof of birth order to be
had here (macOS ps etime is itself wall-derived), so drop the bound: an alive
pid with no recorded start time is never reclaimed. Regression covers the
steal at both the classifier and the lock level.
2026-08-07 14:33:06 +02:00
Michał Pierzchała a158434a9c feat(cli): compact workflow help card + version header (#1663)
* feat(cli): compact workflow help card + version header

Shrinks the per-task agent protocol tax of the help/skill surface.
`agent-device help workflow` drops from 41025 to 8466 bytes (-79%) by
moving depth into new `help scripting` (save-script, secret-safe
fills, batch JSON, replay divergence/repair) and `help gestures`
(multi-touch shapes/quirks) topics, and folding a few paragraphs into
topics that already owned the subject (help debugging,
help physical-device, help validate). Content is moved, not deleted.

Every `help <topic>` first line is now `agent-device <version> —
<topic>`, so the skill router reads the CLI version off the mandatory
first help read instead of a separate `agent-device --version` call.
SKILL.md is updated to do that and stays a thin router otherwise.

The compact card also gains two terse behavioral rules: chain
confident consecutive steps with `&&` (falling back to one command at
a time when uncertain), and confirm the requested end state is
actually visible on screen before declaring a task done.

help-conformance-bench (22 cases x 2 runners) improves after the
change: 29/44 -> 32/44 passing checks.

* fix(cli): review follow-ups on the compact workflow card (#1663)

Three fixes from PR review:

- Extend the help-conformance plan validator to split a command line
  on unquoted && and validate each chained segment independently, so
  a plan that follows the workflow card's "chain confident consecutive
  steps with &&" guidance is accepted instead of rejected as one
  shell-projection violation. && inside a quoted selector value (e.g.
  label="A && B") is not a chain boundary and does not split. Adds
  unit tests for the splitter and a chains-confident-consecutive-
  settle-steps conformance case. batch stays out of this: it is
  deliberately stop-only.

- Replace the literal @ref placeholder the compact card used in its
  own "snapshot -s @ref" example with a concrete ref
  (snapshot -s @e12 (the current concrete ref)), matching the same
  card's rule against placeholder targets. Reverts the test to demand
  the concrete shape.

- Give help scripting and help gestures real conformance cases
  instead of waivers: a secret-safe recorded-fill + publish case, and
  an Android transform-then-verify case whose exact verification text
  only appears in the gestures topic. Removes both waivers.

help-conformance-bench (25 cases x 2 runners, repeat=1) after these
fixes: two full runs landed at 32/50 and 33/50. That is on par with
the pre-change baseline (29/44) once the topic-untouched cases'
run-to-run swings are accounted for (confirmed noise: one case with
zero exposure to any change here flipped 10/10 -> 1/10 on a runner API
error, and another swung across all three post-fix runs). The new
scripting case now passes 8/8 for both runners; the new chaining case
correctly reports the model's choice not to chain as a soft signal,
not a validator failure.

* fix(cli): update session.test.ts help pointer for moved script-authoring content

* fix(cli): reject empty && chain operands in the plan validator (#1663)

splitOnUnquotedAnd() previously trimmed and filtered out empty
segments, so a plan with a leading (`&& press ...`), trailing
(`press ... &&`), or doubled (`a && && b`) operator passed
validPlanCommands even though a real shell rejects all three as a
syntax error. The validator would bless a plan that fails at
execution.

Empty segments are now surfaced as an `empty-chain-operand` issue
instead of being silently dropped. The quoted-&& non-split behavior
(label="A && B") is unchanged, and a normal single command with no
chain still parses identically to before.

Adds regression tests for all three empty-operand shapes plus the
quoted-&& case.
2026-08-07 13:26:48 +02:00
Michał Pierzchała ac52281448 fix(test): deterministic temp-dir cleanup across node --test lanes (#1661)
* fix(test): deterministic temp-dir cleanup across node --test lanes

node --test has no global setup/teardown hook, so unlike Vitest (#1593) every
node --test package.json script (maestro:conformance, mutation:test,
check:affected:test, check:coverage-changed:test, check:layering,
depgraph:test, check:tmpdir-leaks:test, check:contention-retry,
test:fixture-cache, test:smoke(:web), test:integration:node,
test:concurrency-torture) still created scratch directories against the
real, unredirected os.tmpdir(), with cleanup only as reliable as each call
site's own try/finally — which a crash, OOM, or timeout kill bypasses
entirely.

Add scripts/node-test-tmpdir.ts: it wraps the whole `node --test`
invocation as a child process, redirecting TMPDIR to one disposable,
pid-tagged directory (shared root/prefix with the Vitest lane) and removing
it from the process 'exit' event, which fires on normal completion, a
thrown error, or a forwarded SIGINT/SIGTERM alike. Every node --test script
now runs through it. check-tmpdir-leaks.ts already scans by root/prefix, so
it covers both mechanisms with no changes to its detection logic.

Verified: a node --test process that mkdtemp's then gets SIGKILL'd leaves a
directory behind unwrapped; wrapped and SIGTERM'd, TMPDIR is redirected and
the directory is gone with no orphaned processes. All 13 wrapped lanes and
the full Vitest suite (5,591 tests) pass with zero residual
agent-device-test-run-* directories after the run.

Fixes #1595

* test(tmpdir): ratchet every node --test script through the wrapper

The 13 lanes wrapped in package.json were a one-time hand sweep with
nothing enforcing the pattern going forward — a 14th node --test script
added later without scripts/node-test-tmpdir.ts would silently reopen
#1595 for that one lane.

Add a structural check to scripts/node-test-tmpdir.test.ts (now part of
check:tmpdir-leaks:test) that reads package.json and fails if any script
invokes `node ... --test` without routing through the wrapper. Dumb
string matching over the scripts map, no shell parsing, with an explicit
(currently empty) NODE_TEST_WRAPPER_BYPASS_ALLOWLIST for any lane that
must legitimately bypass it. Verified it both passes on the current
package.json and fails when a synthetic unwrapped `node --test` script is
added.

* fix(test): preserve the Swift cache and close the raw node --test bypasses

Review on #1661 found two gaps:

1. The wrapper only overrode TMPDIR, so it discarded and forced a
   recompile of the durable Swift compiler cache every run instead of
   mirroring vitest-tmpdir-global-setup.ts's carve-out for it. Read
   os.tmpdir() before the child's TMPDIR redirect takes effect and set
   AGENT_DEVICE_SWIFT_CACHE_DIR from that (only when unset), same as the
   Vitest lane — the two now share one durable cache instead of each
   discarding and recompiling their own. Added a probe assertion
   (scripts/node-test-tmpdir.test.ts) that fails without the fix and
   passes with it (verified both ways).

2. docs/agents/testing.md documented raw `node --test` commands for the
   iOS smoke files, and the android/ios/conformance-regenerate/nightly
   workflows invoked `node --test` directly outside package.json. Routed
   all of them through scripts/node-test-tmpdir.ts so the documented
   local commands and CI lanes get the same crash/timeout-safe cleanup
   the package.json scripts already have.
2026-08-07 13:26:20 +02:00
Michał Pierzchała cc943400a9 feat(daemon): [RFC] prototype foreground-attach convenience (#1670)
Prototype `open --foreground`: on a fresh session with no app argument,
auto-resolves the target from the sole booted iOS simulator's sole
foreground app (reusing the exact same ambiguity-detection probe that
enriches the SESSION_NOT_FOUND hint), then attaches the initial
interactive snapshot to the response by composing the existing
snapshot-runtime dispatch. Collapses the documented 3-call
snapshot-fails -> read-hint -> open -> snapshot-succeeds dance into a
single call for the unambiguous case, while failing closed
(AMBIGUOUS_MATCH) with no guessing otherwise.

First-pass RFC, not reviewed — see PR body for the design tradeoff
writeup, live before/after evidence, and scoped-out follow-ups.
2026-08-07 13:17:13 +02:00
Michał Pierzchała 4f510ebd18 fix(daemon): SESSION_NOT_FOUND hint names the detected foreground app (#1662)
* fix(daemon): SESSION_NOT_FOUND hint names the detected foreground app

iOS snapshot/diff without an app session always emitted the generic
"Run open first" hint, even when the environment was unambiguous — costing
an agent 2-3 extra turns to discover the bundle id itself (27/30 benchmark
tasks hit this). When exactly one iOS simulator is booted and exactly one
installed app is currently running on it, the hint now names the exact
runnable `open` command instead. Any ambiguity (0/2+ booted simulators, 0/2+
running apps, or a probe failure) keeps the existing generic hint — the
detection never guesses.

- listBootedIosSimulators (devices.ts): cheap, bounded simctl probe for every
  currently booted simulator, used to detect true device-level ambiguity
  independent of what device resolution silently picked.
- detectSoleRunningIosSimulatorApp (app-resolution.ts): cross-references
  `launchctl list` running UIKitApplication jobs against `simctl listapps`
  to find the sole real installed app running, filtering out simulator-
  internal services without a version-fragile bundle-id denylist.
- ios-app-session-hint.ts composes both into the hint text, wired into
  snapshot-runtime.ts's existing SESSION_NOT_FOUND guard.

* fix(daemon): say the detected app is running, not in the foreground

launchctl only confirms the app's process is alive, not that it's the
frontmost scene — a resident-but-backgrounded app (launched, then sent
home) would match the same way. The actionable open command is correct
either way, but "in the foreground" overclaimed what the probe actually
verified. Say "running" instead.

* fix(daemon): bound and catch the hint probe, pin the printed command

Two precision fixes from review:

- The probe was best-effort in spirit but not in fact: allowFailure only
  suppresses a non-zero exit code, not a timeout or spawn-failure rejection
  (exec.ts always rejects on those). listSimulatorApps' listapps call had no
  timeout at all, so it could hang the error path indefinitely; the
  launchctl probe had no catch, so a rejection would propagate and replace
  the deterministic SESSION_NOT_FOUND with a probe failure. Threaded an
  optional timeoutMs through listSimulatorApps/listSimulatorAppMetadata
  (including its plutil fallback) and wrapped both
  detectSoleRunningIosSimulatorApp and buildIosOpenCommandHint in try/catch
  so any throw falls back to the generic hint, never propagates.

- The printed command now always pins --udid (the sole-booted-device check
  only proves uniqueness within the probed device's own simulator set, not
  that a fresh CLI invocation without --ios-simulator-device-set would
  resolve to the same device) and echoes --ios-simulator-device-set when the
  detected device carries one, so the hint is the exact command that was
  live-validated, not a shorter one that happens to work by coincidence.
  Added a length guard: wire-level error details are redacted before send
  and silently truncate any string over 400 chars, so a hint that would
  exceed a safe budget (a long device-set path pushes it there) now falls
  back instead of printing a truncated, non-copy-pasteable command.

Also: "in the foreground" overclaimed what the probe verifies (a resident
but backgrounded app would match the same running-process check) — reworded
to "running".
2026-08-07 10:43:38 +02:00
Michał Pierzchała 35f4cd63d7 perf(cli): lazy-load dedicated client command handlers (#1660)
router.ts statically imported all 10 "dedicated" command handlers
(connect, disconnect, connection, auth, daemon, device, proxy, replay,
screenshot, diff) at module top level, so every real CLI invocation paid
for loading all of them -- including replay.ts's @agent-device/maestro
subtree -- even when dispatching to an unrelated command like snapshot
or press. The generic client-backed commands (snapshot, press, tap,
fill, ...) already lazy-load their handler via a dynamic import('./generic.ts');
this brings the 10 dedicated handlers in line with that existing pattern.

ClientCommandHandlerMap (router-types.ts) is repointed to describe the
lazy-loader shape (Record<CliCommandName, () => Promise<ClientCommandHandler>>)
and the loader map is checked against it via `satisfies`, so a typo'd or
stale command key still fails to compile, matching the safety the old
eager map had.

Measured via git-stash-built before/after dist trees, interleaved 1:1 to
cancel drift, run against a live iOS session (own throwaway simulator):
the CLI-side "pre-daemon" phase (node boot + import graph + arg parse,
before the process contacts the daemon) drops from a ~121-125ms median
to a ~76-80ms median -- about a 37% cut -- consistently across snapshot -i
and press --settle. Total command wall time drops 10-21% depending on
background system load (daemon/device RPC time is unaffected by this
change and dominates more under contention).
2026-08-07 09:29:12 +02:00
Michał Pierzchała f4fc089099 0.20.6 v0.20.6 2026-08-07 08:34:50 +02:00
Michał Pierzchała 628406390b fix(daemon): report the device claim retained by a failed close (#1647)
* fix(daemon): report the device claim retained by a failed close

When close cannot confirm the device was released, it deliberately keeps the
advisory claim (handing an unconfirmed device to the next session would be
worse) but deletes the session record on the next line regardless, leaving a
claim naming a session the daemon no longer tracks with no trace. Emit a warn
diagnostic naming the device key and session, mirroring the open path's
existing rollbackNewSessionClaim handling. Retention policy is unchanged.

* test(daemon): pin claim retention on the cleanup-failure branch too

The retention decision reads `platformCloseError ?? cleanupAggregate`, and the
existing pair only drove the first input. A best-effort cleanup failure — a
wedged perfetto stop, a dead helper — is the branch operators hit more often
and reaches the same retention through a different value, so narrowing the
diagnostic to the platform-close branch left every existing test green.

Verified red against exactly that: gating the emit on `platformCloseError`
fails this test alone, 28 others unaffected.

Live evidence for the device-facing path is still outstanding; this closes the
untested residual the review named, not that requirement.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-07 07:50:37 +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
Michał Pierzchała fd1ed578d5 fix(ios): keep the baseline presentation when a tap corroboration has no request flags (#1646)
* fix(ios): keep the baseline presentation when a tap corroboration has no request flags

matchingCaptureFlags dropped the baseline snapshot's scope/depth/raw whenever
the incoming request carried no flags, so the post-action corroboration
capture ran at the default presentation and could never match a non-default
baseline's presentationKey. The corroboration then silently declined to
engage, leaking the raw XCTEST_RECORDED_FAILURE it exists to eliminate.

* docs(test): correct tap corroboration reachability
2026-08-06 22:16:42 +02:00
Michał Pierzchała b19ee116b5 fix(remote): stop persisting the daemon bearer token, and authenticate forced-reconnect release correctly (#1648)
* fix(remote): stop persisting the daemon bearer token in connection state

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-06 21:32:02 +02:00
Michał Pierzchała 09a0881e6c refactor(ios): runner error classification as data; recovery tested at the transport seam (#1644)
* refactor(ios): runner error classification as data; recovery tested at the transport seam (#1631)

Error-retry classification lived in four places consulted from one catch
dispatch: two overlapping substring chains in runner-contract.ts, a
code-keyed fatality chain in runner-session.ts, and an inline composite in
runner-lifecycle.ts. RUNNER_ERROR_RULES is now the one declaration
(mirroring RUNNER_COMMAND_TRAIT_MANIFEST): each rule names an error shape
and its verdicts across the four axes (retryable, connect-retry,
session-fatal reason, restart-before-send); the predicates keep their
signatures and derive from the table. One deliberate widening, noted at the
declaration: restart-before-send matching is now case-insensitive like the
other axes (the message is our own transport literal).

runner-command-recovery.ts gets its first direct coverage — through the
real stack: a scripted fake iOS runner (local HTTP server) stands in for
the XCTest runner process, executeRunnerCommandWithSession and the
transport fetch run for real, and invalidation is observed through the
module's existing invalidateSession parameter. Seven scenarios (retained
response, runner-reported failure, in-flight, unknown lifecycle, probe
failure, missing command id, read-only completed-without-response) plus an
11-case classification suite. Zero vi.mocks of runner internals.

* test(ios): prove the recovery wiring and the recycled-pid guarantee (#1644 review)

P1 was right, and I verified it before fixing: disabling the shipped
recovery callsite in runner-lifecycle.ts left all seven recovery tests
green. They call handleRunnerTransportErrorAfterCommandSend directly, so
they prove the module, not the wiring.

runner-recovery-wiring.test.ts enters at runAppleRunnerCommand — the
production facade and provider seam — and fakes only session creation (the
xcodebuild spawn). Real: command-id assignment, provider resolution,
executeRunnerCommand's catch/classification, the recovery callsite, the
recovery module, executeRunnerCommandWithSession, the transport fetch, and
response parsing. Disabling the callsite now turns all three red. Entering
one layer lower silently defeats it: the id is assigned by the facade and
recovery declines to probe a command without one.

P2: runner-lease-recycled-pid.test.ts covers #1621 with a REAL spawned
process and real readProcessStartTime — no mock choreography. It asserts
through the injected cleanup adapter rather than by observing a kill, so a
regression reports a pid instead of signalling a live process; removing the
identity guard turns it red. Only the refusal direction, which is
contention-proof: the recorded start time never matches, so a timed-out
verification read yields the same verdict. The opposite direction needs a
second live `ps` read inside production code and flakes under load, so it
stays with the mocked cases in runner-session.test.ts where pinning is the
point.

The fake runner now scripts per command rather than as one queue, because
production sends a readiness `uptime` probe before a mutating command and
`status` during recovery; a rigid queue coupled every test to that order.

* test(ios): route the recycled-pid test through exec.ts and off the real lease tree (#1644 review)

Two fixes to the same test:

- Process execution goes through utils/exec.ts's runCmdBackground, per
  AGENTS.md's hard rule, instead of node:child_process spawn. The
  kill-induced `wait` rejection is owned deliberately.
- The lease root defaults to the developer's REAL ~/.agent-device tree, so
  the test was writing leases there. It now redirects
  AGENT_DEVICE_IOS_RUNNER_LEASE_DIR to a temp dir per test and restores it,
  writing nothing outside the sandbox. (Verified by inspecting and cleaning
  the leases the earlier revision left behind.)
2026-08-06 21:19:48 +02:00
Michał Pierzchała 10ff339d14 refactor: declare selector resolution policy as data (#1649)
* refactor: declare selector resolution policy as data (#1630)

Five native consumers of "resolve a selector against the screen" each
hand-declared their ambiguity contract as inline requireUnique/
disambiguateAmbiguous literals, so the repo's real policy matrix was only
discoverable by reading four files. SELECTOR_RESOLUTION_POLICIES
(packages/selectors) now declares one row per caller — ambiguity kind plus
the structural columns (rect, occlusion, off-screen guard, promotion, poll)
— and selectorResolutionKnobs turns a row into the engine knobs it stands
for. Callers consume rows; zero ambiguity literals remain in src.

Semantics are unchanged by construction: each row was read off its call
site. The matrix names what was previously implicit — act and get text
disambiguate, is/get attrs fail closed, exists/find-reads and wait take the
first match, mutating find rejects candidates unless narrowed (#1625).
`reject-candidates` is declaration-only and rejected by
selectorResolutionKnobs at the type level, because find enforces it through
its own narrowing rather than engine knobs.

resolution-policy-parity.test.ts gate-tests the matrix against the callers
(ADR 0011's declared-plus-gate-tested pattern): knobs must match the named
ambiguity contract, every claimed structural column must appear in the
caller's source, the read/wait pipelines must genuinely lack the machinery
they disclaim, and no caller may reintroduce an inline literal. Verified
revert-sensitive: flipping readUnique to disambiguate and faking wait's
occlusion column each fail it.

Out of scope, unchanged, per the issue: the Maestro engine (ADR 0015) and
the open click-implicit-wait product decision.

* refactor: route wait and mutating find through the policy interface (#1649 review)

P1 was right: the first head declared seven rows but genuinely routed five.
selector-wait.ts never imported its row (it called listSelectorChainMatches
directly), findAct consumed only requireRect while its ambiguity contract
stayed bespoke, and the parity test sniffed marker strings in source files —
so it stayed green across exactly that gap. Asserting about the layer I had
edited instead of the behavior it produces.

resolveSelectorChainWithPolicy is now the one policy-driven entry: it
returns a discriminated outcome (none / resolved / ambiguous) because the
rows genuinely disagree about what several matches mean, which is what
previously forced each caller to re-derive its contract inline. wait and
find's selector branch both route through it; find additionally asserts its
row still says reject-candidates rather than assuming.

The parity test is rebuilt on fixture trees driven through that interface —
no source sniffing. Wiring verified revert-sensitive: flipping the wait row
fails the policy tests, and flipping findAct fails REAL find handler tests
(ambiguous-candidate listing), which is the proof the previous version
could not produce.

One behavior nuance the fixture work surfaced and now pins: disambiguation
declines on genuinely indistinguishable candidates (the tiebreak is
evidence, not a coin flip), so an acting row surfaces ambiguity there rather
than binding one silently.

* fix(test): let fallow see the host-process mock helper's real consumers

Rebase onto main brought #1642's host-process-mock.ts into this PR's
fallow scope, where its export reports as unused. It is not: three suites
consume it, but only through `(await import(...)).pinOwnProcessStartTime`
inside vi.mock factories — vitest hoists those above static imports, so the
dynamic form is required and fallow cannot trace it statically. Documented
suppression rather than a restructure that would break the hoisting
contract.

Latent on main rather than introduced here: the audit gate is
changed-files-only, so main sees the file in scope only from a PR whose
diff contains it.

* fix: keep every candidate when a policy resolves one winner (#1649 review P1)

A real regression I introduced, not a test gap: routing wait through the
policy interface collapsed the candidate set to the winner, and the #1349
landmark check is satisfied when SOME match carries the recorded identity.
A first same-selector impostor therefore hid a later genuine landmark and
timed the wait out.

The resolved outcome now carries `matchedNodes` — the full candidate set of
the alternative the winner came from — so a policy that picks one node no
longer throws the rest away. wait passes that straight to the landmark
check, restoring the original semantics.

Regression test added at the within-one-poll shape the existing suite did
not cover (both candidates in the SAME capture, impostor first); verified
it goes red against the singleton reconstruction it replaces.

* refactor: declare only the policy fields the matrix enforces (#1649 review)

The occlusion / offscreenGuard / promotion / poll columns were never
consumed by resolveSelectorChainWithPolicy or selectorResolutionKnobs:
changing any of them left behavior and the suite green, so they were
unverifiable claims that read as truth. (My earlier source-sniffing test
"verified" them by grepping caller files for marker strings — which is why
it also stayed green when a row was disconnected entirely.)

The matrix now declares exactly what it enforces: the ambiguity contract and
the rect requirement, both consumed by the resolution interface and pinned
behaviorally. A new test asserts every row's field set, so an unenforceable
column cannot reappear without coverage — verified by re-adding one and
watching it fail. Routing the structural stages into typed behavior is
tracked in #1656 with the constraint that each field must be consumed, not
merely declared.

* fix(selectors): flatten the policy outcome at the package boundary

`PolicyResolutionOutcome.resolution` was typed as `AstSelectorResolution` and
the root façade returned it unchanged, so the parser AST #1589 confined to
`@agent-device/selectors/ast` came back through a nested field.
`selector-wait.ts` reading `outcome.resolution.selector.raw` was the runtime
proof. The existing boundary gate reads exported *names*, so it could not see
this.

The public outcome now lives beside `SelectorResolution` in
public-resolution-types.ts with its selector as text; the parser-side shape is
renamed `AstPolicyResolutionOutcome` and stays package-private, and the façade
wrapper flattens on the way out — the same treatment `resolveSelectorChain`
already gave `AstSelectorResolution`.

Two new pins, both verified red against the shape they replace: a behavioral
one asserting the façade returns selector text under every policy row, and a
structural one asserting resolution shapes are re-exported from
public-resolution-types.ts rather than from a parser-side module — which is
what distinguishes the leak from a correct re-export in a name list.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-06 21:10:30 +02:00
Michał Pierzchała 0033002ed5 fix(daemon): say why a no-effect claim was withheld (#1655)
* fix(daemon): say why a no-effect claim was withheld

A vetoed gestureNoEffect claim is invisible from outside: the response
looks exactly like a gesture that worked. #1620 spent weeks unable to
tell a cross-backend pair from capture drift from real movement for
precisely this reason, and its own suggested next step was to log the
corroboration inputs and re-run.

Every veto on an accept-stale verdict now emits
post_gesture_no_effect_vetoed with a reason: baseline_rebased (the pair
is cross-backend, #1569) or surface_divergence carrying
onlyInBaseline/onlyInCurrent/rectMismatched/shared from a new
summarizeDiscriminatingSurfaceDivergence — one-sided keys are membership
drift, rectMismatched is movement.

Measured live against the seeded Bluesky fixture (167 nodes, depth-56,
private-ax): inert scrolls corroborate and the warning fires 2/2,
successful gestures never claim 2/2, zero rebases, and the one veto
observed was rectMismatched:4 shared:158 onlyIn*:0 from ambient feed
motion over an artificial 105s gap. #1620's truncation-drift half does
not reproduce; membership is stable under the remembered depth cap.

Also: marking no longer stores an empty baseline signature. '[]' is
truthy, so an invented baseline was being rebased onto a post-gesture
capture; 'no usable baseline' now has one representation, matching
markPendingInteractionOutcome.

* test(daemon): pin the veto diagnostic's payload, not just its phase

The claim tests counted `post_gesture_no_effect_vetoed` events by phase. The
operator-facing behavior this PR adds is the *reason* and the divergence
counts, and both survive an event tally: emitting the wrong reason, or dropping
the counts entirely, keeps every count at 1.

The four veto tests now assert the whole emitted payload, read back out of a
diagnostics trace file so the assertion pins the serialized NDJSON line rather
than an in-memory event. Backend rebase pins `reason: baseline_rebased` with no
counts riding along (the pair is cross-backend; there is nothing comparable to
count over). Both divergence cases pin `reason: surface_divergence` with exact
onlyInBaseline/onlyInCurrent/rectMismatched/shared — the numbers that separate
scope drift from movement, and the two membership-drift directions from each
other.

Verified red both ways the reviewer named: dropping the counts fails 2 tests,
forcing the reason to surface_divergence fails 2. The old count assertions
stayed green under both.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-06 21:08:33 +02:00
Michał Pierzchała d85072d935 perf(cli): route command aliases through the help fast path (#1641)
* perf(cli): route command aliases through the help fast path

bin.ts's `--help` fast path resolved aliases through a hand-written
two-entry table that had drifted out of sync with the real
CLI_COMMAND_ALIASES registry (five entries). `tap`, `launch`, and
`relaunch` missed the table and silently fell through to a full
runCli() bootstrap just to print static help text (~150-165ms vs
~45-50ms for aliases already in the table).

Delegate to the shared normalizeCliCommandAlias registry instead of
the stale local table, so every alias the registry knows about gets
the fast path automatically.

* test(cli): add R12 layering guard for bin.ts's alias delegation

The unit test added for the alias fast-path fix (cli-help-alias-fast-path.test.ts)
calls normalizeCliCommandAlias directly, so it stays green even if bin.ts
itself reverts to a hand-rolled table — it pins the registry composition,
not bin.ts's own wiring, and bin.ts cannot be safely unit-imported (it runs
unguarded top-level dispatch on import and is deliberately excluded from
coverage).

Add an AST-based structural guard instead, in the style already established
by scripts/layering/session-state.ts, facade-exports.ts, and zero-dep-jobs.ts
(oxc-parser's module/program records, not a line scan, so a fixture's string
literal can't produce a false hit). R12 asserts two facts about src/bin.ts:
it holds a value import of normalizeCliCommandAlias from
commands/cli-command-aliases.ts, and it contains none of the registry's own
alias tokens as string literals. The token list is read out of the
registry's own source (CLI_COMMAND_ALIASES's `alias:` property values), not
hard-coded, so a future sixth alias is covered automatically. Both facts
were false on the pre-fix bin.ts, verified by reverting locally and
capturing the failure before restoring the fix.

Wired into the existing check:layering chain (already part of
check:tooling), next to R7's session-state ownership rule, which pins the
same "delegate to your single owner" shape.

* test(cli): pin the alias-resolver call into buildCommandUsageText (R12 P2)

Maintainer review of R12 (PR #1641): import-presence and literal-absence
alone let bin.ts regress to buildCommandUsageText(helpTarget) while the
normalizeCliCommandAlias import stays in place, used harmlessly elsewhere
(or not at all) — the real-tree gate stayed green through that exact
regression.

Add a third fact: bin.ts's call to buildCommandUsageText must receive, as
its argument, a call to the LOCAL binding the resolver was imported as
(aliasResolverLocalName + usageTextCallsResolver, both AST-based). Binding
by local name rather than the literal export name means a renamed import
(`as resolveAlias`) still verifies, and an unrelated same-named local
cannot be mistaken for it.

Verified by reverting locally to exactly the missed regression — import
left in place, call reverted to buildCommandUsageText(helpTarget) — and
confirming R12 now fails where the two-fact version passed; restored after.
Two negative fixtures pin the scenario going forward: import present but
unused, and import present but used only unrelated to the call.

* test(cli): make R12's delegation fact universal and value-bound

The previous fact 3 asked whether *any* `buildCommandUsageText(resolver(...))`
existed in bin.ts. That quantifier is satisfied by a decoy call while the line
that actually ships resolves nothing:

    void buildCommandUsageText(normalizeCliCommandAlias('open'));
    const commandHelp = buildCommandUsageText(helpTarget);

Fact 3 now requires EVERY `buildCommandUsageText` call to receive the imported
resolver applied to the fast path's own help-target binding, which rejects both
lines above independently. The help-target name is read from bin.ts (the
variable initialized by `resolveSimpleHelpTarget`), so renaming it re-points
the guard instead of disarming it.

Because fact 3 claims binding identity by name, it also now rejects a local
shadow of the resolver and an ambiguous second help-target declaration — a
same-named local would otherwise let the composition read as delegation while
calling something that resolves nothing.

The predicate returns the reason rather than a boolean, so the gate names which
of the several distinct failures happened.

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

record-trace-android-recovery.ts (763 lines) and android-recording.test.ts
(1,764 lines) were both past the 500-LOC extraction tripwire. The
screenrecord liveness/probe concept this PR modified now lives in
record-trace-android-liveness.ts, and the new scenarios moved to test
files mirroring the source modules they cover
(record-trace-android-copy.test.ts, record-trace-android-liveness.test.ts)
with shared scenario plumbing in the android-recording-fixtures.ts
sibling. android-recording.test.ts shrinks to 1,476 lines — below its
pre-PR size.
2026-08-06 17:38:10 +02:00
Michał Pierzchała 58a9d4bf71 refactor(daemon): consolidate surface-evidence helpers, delete the wording heuristic (#1615)
Re-derived onto #1633's split (`post-gesture-stabilization.ts` became
`post-gesture-stability.ts` + `deferred-interaction-outcome.ts` +
`gesture-no-effect.ts`). None of this had been subsumed by that refactor —
it relocated the code and carried every one of these forward untouched.

- Three copies of the four-field rect comparison in
  `interaction-outcome-policy.ts` become one `rectsWithinTolerance`.
- `identifiedContent` returns the entry instead of `{ entry }`, dropping
  the `.entry` indirection at every call site.
- `haveIdenticalDiscriminatingSurfaces` records why it keys on `key` while
  `classifyBaselineSurfaceEvidence` keys on `identity` — opposite choices,
  once, at the function that makes the stricter one.
- `SnapshotCaptureBackend` names the capture-strategy union in
  kernel/snapshot beside `SnapshotBackend`, and
  `PostGestureStabilization.baselineBackend` uses it instead of `string`,
  closing the silent-typo gap on the comparison the field exists for.
  Deliberately NOT applied to `post-gesture-stability.ts`: #1633 made that
  module generic over the surface type, and `backend: string` is right for
  an interface that must not know about iOS capture strategies.
- `formatGestureNoEffectWarning` echoes positionals verbatim. The
  `/^[\d.-]+$/` filter it replaces ate all four coordinates of
  `swipe <x1> <y1> <x2> <y2>` and emitted a contentless bare "swipe"; the
  warning names the gesture the agent issued, and `scroll down 1` is what
  they issued.
- `PostGestureStabilization.positionals` is required — its only writer
  always sets it, so the `?? []` at the read site guarded an impossible
  state. Tightening it caught six test fixtures building the state
  directly.

Red evidence: restoring the numeric filter fails the wording test
("scroll down 1 produced no visible change"); 95 files / 757 tests green
with it deleted.
2026-08-06 17:27:18 +02:00
Michał Pierzchała 0b8c1d7a7f test: pin own-pid start time in device-claim tests to stop load flakes (#1642)
* test: pin own-pid start time in device-claim tests to stop load flakes

device-claim-prune.test.ts and device-claims.test.ts seed "live" claims
from readCurrentOwnerIdentity() — a real `ps -p <pid> -o lstart=` read of
the vitest worker's own start time — and classification later re-reads it
through a second `ps` shell-out with a 1s timeout. Under full-suite CPU
contention that second read can miss its deadline and return null,
mismatching the seeded value and flipping the genuinely-live owner to
'owner-process-dead' (intermittent assertion failures like 2 !== 1).

Apply the same hardening as 4551fb7aa (#1556): mock readProcessStartTime
to read our own pid's start time once for real, then return the cached
value forever; other pids read as null. isProcessAlive stays real, so the
fabricated dead-pid fixtures still classify as dead through the genuine
kill(pid, 0) check.

Verified with 3x full `vitest run src` under 6-process CPU load: all
green.

* test: extract shared pin-own-start-time host-process mock

The cache-own-pid readProcessStartTime vi.mock factory was duplicated
verbatim in three files (cli-device-status from #1556 plus the two
device-claim files), each carrying its own paragraph justifying it. One
helper now owns the factory and the why; call sites are one line.

* test: stop reporter-values retry suite from depending on readdir order

The two runRetrySuite tests fed a two-file directory to `test` and
asserted hook order and per-test indexes assuming 01-untyped.ad is
discovered before 02-retry.ad. Directory inputs deliberately enumerate
in native readdir order (Maestro parity, replay-source-discovery.ts),
which is not lexicographic on every filesystem — reversing discovery
order locally reproduces the exact hook sequence from the Coverage-lane
CI failure. Pass explicit file positionals instead: their ordering is
the contract (argument order), same as session-test-suite.test.ts's
fail-fast case. The Coverage lane's refusal to retry (assertion failure
off the enumerated contention list) worked as designed.
2026-08-06 16:28:56 +02:00
Michał Pierzchała d2f28d790f docs: pin the version-skew invariant and the four legitimate compat axes (#1643)
* docs: pin the version-skew invariant and the four legitimate compat axes

A local client and daemon can never disagree on version — daemon reuse
requires exact version and code-signature equality, otherwise the client
replaces the daemon (both directions). Compat code is therefore justified
only for: the remote daemon boundary, separately versioned runner/helper
binaries, persisted artifacts, and released API consumers. Documented in
CONTEXT.md Terms next to the RPC protocol version entry, and the one
comment that said 'older daemon' without scoping it is now explicit about
the remote boundary.

Swept src/ and packages/ for fallbacks that only tolerate local skew:
none exist — every current compat site names one of the four axes.

* docs: tighten the version-skew invariant to its three load-bearing facts
2026-08-06 16:00:42 +02:00
Michał Pierzchała d5f11f6e2f refactor: import package types directly — no internal re-export laundering (#1640)
* refactor: import package types directly instead of re-exporting from internal modules

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

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

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

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

The one consumer (cli/parser/args.ts, a multi-line import the sweep's
single-line scan missed) now imports CliFlags from contracts/command;
FlagDefinition/FlagKey stay — they are src-declared types, not package
laundering.
2026-08-06 15:22:51 +02:00
Michał Pierzchała 870d12c406 fix: honest find contract — press/tap aliases, read-only list action, selector uniqueness (#1637)
* fix: honest find contract — press/tap aliases, read-only list, selector uniqueness (#1625)

Three defects in find's contract, fixed together because they are one
vocabulary:

press/tap are the same action as click everywhere else in this CLI, yet
find rejected them — agents using the vocabulary the tool itself
established burned a tool call per attempt (four in one bench run).
Both parsers now normalize press/tap to click; longpress/swipe stay
real exclusions.

The #1602 recovery hint told agents to run bare find to 'list matches',
but bare find CLICKS a unique match — inspection guidance pointing at a
mutation (the #1625 report: 'find Dictionary' navigated into
Dictionary). find <q> list is the read-only surface that guidance
needed: every match with its @ref, unique match included, never a tap.
Captured UNSCOPED (the label-scope optimization narrows to the first
match, exactly wrong for listing), published as an ADR 0014 partial
frame authorizing every listed ref.

Selector-shaped queries skipped the ambiguity check and took the first
match silently — the mis-binding path the AMBIGUOUS_MATCH recovery
advice itself pointed agents at, while --first/--last were documented
as explicit opt-ins. Selector and text queries now share one contract:
multiple matches reject with the #1597 candidates listing unless
--first/--last narrows explicitly.

The hint is rewritten around the new contract; docs and the MCP find
output schema follow. Regressions at every layer: both parsers (alias,
list token, unsupported-action hint shape), the daemon handler
(selector ambiguity with candidates, --first opt-out, list returns all
matches with zero action dispatches, unique-match list does not tap).

* refactor: single-home the find read result and flatten parseFindArgs (fallow)

The daemon's DaemonFindResult had drifted into an identical structural
twin of the engine's FindReadCommandResult — the two grew the list
variant in parallel and crossed the clone threshold. The shape now
lives in contracts as FindReadResult (below both zones, per R2's own
remedy) with the engine and daemon both aliasing it.

parseFindArgs collapses the four bare single-token actions into one
membership check and extracts the get sub-action parser, bringing it
back under the complexity threshold instead of waiving it.

* style: merge duplicate contracts import (lint)

* fix: accept list on the MCP input surface and pin every listed ref (review)

- FIND_ACTION_VALUES gains 'list' so field-metadata/MCP input no longer
  rejects the action the CLI parser accepts
- FindCommandResponseData types 'matches' (public client response)
- MCP mergeFindRefPins learns every matches[] ref, so a plain @eN press
  after find-list forwards pinned and the partial frame admits it
- CLI/MCP text renders every listed match as its own pinned line via the
  snapshot-line role/label normalizers
- regressions: daemon partial-frame scope, pin store, CLI output, MCP
  schema + find-list->press chain, typed client list response
2026-08-06 15:11:12 +02:00
Szymon Dziedzic 3e4828d68d feat: add scale-only screenshot sizing (#1617)
* feat: add scale-only screenshot sizing

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

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

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

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

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

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

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

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-08-06 15:11:00 +02:00
Michał Pierzchała f93b259d15 fix: stop the slow-snapshot warning from firing on a single cold start (#1628)
* fix: stop the slow-snapshot warning from firing on a single cold start

The session's first capture folds one-time startup (runner launch,
helper install) into its duration, and nearest-rank p95 over a small
sample set equals its largest one or two values — so one 12s cold start
produced 'snapshots are slow in this run: p95 12417ms over 1 captures'
with hints blaming device load or a stale daemon, inviting exactly the
restart spirals the hints exist to prevent (observed on every
AppControlBench run).

The warning now judges only warm captures (first sample excluded) and
only once at least three exist; the displayed stats still cover every
sample, so the cold start remains visible as maxMs.

* refactor: single-home the slow-snapshot warning policy (review)

Push the warm-judging rule down into summarizeSnapshotTimingSamples so
the interactive session path and all three replay handler paths share
one policy, and summarizeSnapshotDiagnostics returns to a one-line
delegate. Merge no longer judges slowness from lossy order-less
reconstructed samples (a run's cold start comes back as both its p95
and max): it aggregates display stats and carries a warning only when a
constituent run judged one itself. MIN_WARNING_SAMPLE_COUNT renamed to
MIN_WARM_SAMPLE_COUNT — it gates warm samples, not total captures.

Cold-start regression tests move to the shared layer; suite-aggregation
tests now pin that individually-silent runs merge silent.

* style: oxfmt

* fix: quorum-gate the warm warning and make the merged message speak about warned runs (review)

A single warm outlier could still fire the warning (nearest-rank p95 is
the maximum through nineteen samples): chronic now additionally requires
at least two slow warm captures. And the merged warning formatted its
number from the reconstructed aggregate, so one slow run among many fast
ones produced 'slow: p95 <fast number>' — the merged message now reports
how many runs warned and the worst warned run's own p95, never the
aggregate. Regressions for both: one-warm-outlier stays silent; a slow
run merged with many fast ones warns with the slow run's number while
the aggregate p95 sits below the threshold.
2026-08-06 13:52:20 +02:00
Michał Pierzchała 3d2a9a05e8 ci: remove package smoke workflow (#1624)
* ci: remove package smoke workflow

* ci: align affected package check

* ci: verify packaged tarball before publish
2026-08-06 13:31:36 +02:00
Michał Pierzchała a67c72c211 fix(ios): pin tap-outcome corroboration probes to the baseline's backend (#1634)
* fix(ios): pin tap-outcome corroboration probes to the baseline's backend

The recorded-failure screens are exactly where the capture plan flips
between XCTest and private-AX (the penalty boundary), so #1605's
same-backend requirement failed closed right where XCTest tap false
negatives actually happen: the baseline was captured via private-AX
under penalty, the probe came back via tree, and a landed tap surfaced
as XCTEST_RECORDED_FAILURE. In the AppControlBench bsky-16 run this
fired four times, each sending the model into a re-observe/retry spiral.

The comparison stays same-backend by design (backends are not comparable
views of a screen); instead the probe is now CAPTURED the way its
baseline was: a new internal preferredBackend option (never CLI-exposed)
threads daemon -> runner, and a private-AX-preferred capture takes the
exact penalized route — privateAX-first plan, 'deferred' verdict, no
degradation warning, no settle budget reset.

Live-verified on the deterministic repro (Bluesky drawer-menu press
under penalty, seeded bench feed): errored with the backend-mismatch
diagnostic before, corroborates as landed after, with no mismatch phase
in the request diagnostics. Daemon tests cover pinned and unpinned
baselines end to end through the dispatch context; the Swift plan gate
is a pure function with an executed in-bundle test (added to the ios.yml
regression list).

* style: oxfmt

* fix: exclude raw baselines from corroboration and prove the pin end to end (review)

Raw baselines could not be pinned: the raw diagnostic plan keeps
tree-first error propagation by contract and is never rerouted by the
penalty or the preferred backend, so preserving 'raw: true' on the probe
recreated exactly the backend-mismatch false failure this PR removes.
Corroboration now declines raw baselines up front (they are diagnostics,
not evidence baselines) with a regression pinning that no probe capture
is dispatched at all.

The wire is now regression-proven at every hop: a dispatch-level test
drives dispatchCommand with the context flag and asserts the emitted
RunnerCommand carries preferredBackend (red if handleSnapshotCommand or
the interactor stops forwarding); the injected-transport test asserts
the interactor's snapshot payload both ways; and a runner unit test
decodes the wire JSON, projects it through the extracted
snapshotOptions(from:), and composes it with the plan rule — pinned
regular plan defers to privateAX-first, RAW plan stays untouched.
Executed on-simulator; added to the ios.yml regression list.
2026-08-06 13:27:30 +02:00
Michał Pierzchała d5f99bab1c refactor: sink backend.ts's cycle-closing types below both zones (#1632) (#1636)
backend.ts imported RepeatedInput up from commands/command-input.ts and
ScreenshotResultData up from utils/screenshot-result.ts — the interface hub
typed in terms of the zones that depend on it, R6's textbook inversion shape.

- RepeatedInput now lives in @agent-device/contracts/interaction;
  command-input.ts re-exports it for its existing importers.
- ScreenshotResultData already had a byte-identical canonical declaration in
  contracts/snapshot-types.ts (exported via contracts/capture); the utils
  copy is now a re-export of it, deleting the duplicate outright.

Measured member-by-member: the R9 type cycle collapses 76 -> 49 files.
backend.ts, runtime-contract.ts, commands/runtime-types.ts, and
commands/runtime-common.ts all leave the component (27 files stranded out at
once); zone ceilings lowered to the measured values (commands 33 -> 14,
platforms 7 -> 2, root 5 -> 3, daemon-server 20 -> 19) and CONTEXT.md's hub
list recomputed (core/dispatch.ts 8, command-catalog.ts 7, resolution.ts 6,
command-descriptor/registry.ts 6). No TYPE_INVERSION_BASELINE additions.
2026-08-06 13:25:07 +02:00
Michał Pierzchała d919876cb0 refactor(daemon): one interface for the deferred interaction outcome (#1633)
* refactor(daemon): one interface for the deferred interaction outcome (#1629)

The machinery answering "did that mutation actually take effect?" was three
modules coordinated only through raw SessionState fields: two independent
marking sites (finalizeTouchInteraction vs dispatchGenericCommand, plus a
third in session-open), a resolve side buried as private functions in
snapshot-capture.ts with no direct tests, freshness heuristics split across
the seam, and two raw reads of session.postGestureStabilization outside the
owning module.

- src/daemon/deferred-interaction-outcome.ts is now the one interface:
  markDeferredInteractionOutcome (every mutating route, one ordering) and
  resolveDeferredInteractionOutcome (every snapshot capture, parameterized
  over the capture primitive so it is directly testable).
- getAndroidFreshnessReason moves beside its state machine in
  android-snapshot-freshness.ts, with the module's first direct test file.
- isPostGestureStabilizationPending replaces the raw field reads in
  direct-ios-selector.ts and selector-capture-runtime.ts.
- snapshot-capture.ts shrinks 617 -> 359 lines and keeps only capture,
  state building, and scope resolution.
- No behavior change. The R9 type cycle drops 76 -> 74 files; zone ceiling
  lowered accordingly. CONTEXT.md gains the "deferred interaction outcome"
  term.

* style: format android-snapshot-freshness.test.ts

* fix(layering): record the honest R9 delta — the new module joins the cycle (+1 node)

The earlier 76 -> 74 measurement was an artifact: the layering scan reads
tracked files, and deferred-interaction-outcome.ts was still untracked. The
real delta vs main is 76 -> 77 / daemon-server 20 -> 21: the choke point sits
inline on value paths that already ran member-to-member, so the cycle gains
one node and zero new edges. Ceiling raised explicitly with the rationale at
the baseline, per the R9 rule's own escape hatch.

* refactor(daemon): host the deferred-outcome seam in the stabilization owner (#1633 review)

Zero R9 growth, per review: a NEW aggregator file cannot stay out of the
cycle by shedding type imports — its value imports of the two member owners
close the loop regardless. The unique zero-growth host is an existing cycle
node, and the stabilization owner is the only legal one (the policy module
cannot value-import stabilization back, R4; the freshness module would join
as a new member). So deferred-interaction-outcome.ts absorbs the
post-gesture-stabilization implementation and becomes the R7 owner of
postGestureStabilization: the seam lives in a node that was already on the
member-to-member paths it concentrates.

- markPostGestureStabilization is now module-private behind
  markDeferredInteractionOutcome; stabilization marking tests drive the
  public interface.
- The freshness retry loop moves beside its classifier in
  android-snapshot-freshness.ts; gesture-no-effect helpers move to a leaf
  file. Both stay outside the cycle.
- Ratchet reverted to main's exact values (76 files, daemon-server 20) —
  measured member-by-member vs main: the diff is empty.

* refactor(daemon): extract the pure stability loop into a leaf (#1633 review)

The deferred-interaction-outcome owner keeps the seam, the pending-record
ownership, and the R7 clear; the quiet-window polling loop and the
baseline-distrust verdict move to post-gesture-stability.ts, parameterized
by hooks (capture, surface reader, the three signature comparators) so the
leaf imports no cycle owners and no SessionState — verified outside the R9
cycle, which stays at main's exact 76/20. Owner drops 539 -> 374 lines.

The no-effect corroboration keeps comparing against the ORIGINAL pre-gesture
baseline (never a mid-loop rebased one), now stated in the leaf's doc. The
stabilization loop suite drives the unchanged public adapter; the verdict
suite wires the real classifier through the leaf's hook.
2026-08-06 12:26:50 +02:00
Michał Pierzchała 4a3ed1e8c3 feat(ios): extend depth-capped private-AX captures via element-rooted requests (#1627)
* feat(ios): extend depth-capped private-AX captures via element-rooted requests

The AX server's depth limit is per-request (kAXErrorIllegalArgument above
a tree-size-dependent value), so a capture capped at depth 56 on
Bluesky-class React Native trees returned chains of unlabeled [other]
containers and hid every actionable control below the cap — agents fell
back to screenshot-and-coordinate guessing.

After a capped serialization, the bridge now re-issues the same snapshot
request rooted at each deepest-level childless node's live accessibility
element, splicing the returned children in and chaining further while
capped subtrees remain — bounded by a call limit, the shared node
budget, and the capture-plan deadline. The AX server counts node levels,
not edges: a maxDepth=56 request emits nodes to depth 55, so the
frontier is the deepest observed level, kept only when it sits at the
cap; a tree that ends naturally above the cap yields no frontiers and
costs nothing.

A capture whose frontier extension drained every capped node no longer
reports itself depth-limited — the re-run hint it used to trigger could
not add anything. Explicit --depth requests stay exact captures with no
extension.

Live on the seeded Bluesky bench feed: 8 extension calls (~106ms each)
turn the 117-node all-[other] tree into a 182-node tree carrying post
text, testID links, and every feed control (Reply/Repost/Like/options);
steady-state capture 0.52s -> 1.37s. Observation-only captures paying
the extension needlessly is #1626.

* fix: count missed frontiers and tighten deep-extension shape (review)

The completeness verdict inverted in the failure paths: a frontier whose
live element vanished or whose re-rooted request failed was silently
dropped, so an all-miss extension reported pendingFrontiers=0 and the
capture presented as complete while whole subtrees were missing. Missed
frontiers are now counted, logged, and keep the depth-limited verdict —
the pure decision lives in privateAXDepthLimited with in-bundle tests.

The truncation hint no longer advertises --depth (an explicit --depth
capture disables extension, so following it returned strictly less than
the capture that produced the hint); the honest remedy is a plain
re-run with a fresh extension budget.

Shape: candidates collected only at the cap boundary and not at all
when extension is disabled (exact --depth captures pay zero
bookkeeping); the zero-caller 3-arg overload is gone; the response
shape's keys are shared constants; the unsupported-selector diagnostic
is restored; the exact-depth policy is hoisted to one named local.

* test: execute the deep-extension miss-path contract in CI (review)

The depth-limited regression compiled but never ran (absent from
ios.yml's -only-testing list), and the pure Swift consumer test was
vacuous against the Objective-C producer — a literal missedFrontiers
proves nothing about the increments. RunnerAXSnapshotFrontier and
extendSnapshotFrontiers move to the header as the executed contract
seam, and testDeepExtensionCountsMissedFrontiers drives both real miss
paths with fabricated snapshots: a nil accessibilityElement (explicit
nil property — bare NSObject resolves the key through a UIKit category
and takes the call path instead) counts missed without consuming a
call; a resolving element whose client cannot serve the re-rooted
request consumes a call AND counts missed. Both tests join the
executed ios.yml list.

Red-before verified on-simulator: with both increments stripped the
producer test fails at the counter assertion (0 != 2); restored, both
tests pass.
2026-08-06 12:09:42 +02:00
Michał Pierzchała 46abd0f48d chore: Update GitHub Sponsors usernames in FUNDING.yml 2026-08-06 07:58:53 +02:00
Michał Pierzchała 9dd1cddf30 fix: resolve Dependabot security alerts (#1623) 2026-08-05 22:58:38 +02:00
Michał Pierzchała 4269ca6d88 fix: update MCP registry namespace (#1618)
* fix: update MCP registry namespace

* feat: inherit MCP descriptions from CLI help

* refactor: project command guidance per surface

* refactor: make command guidance a single canonical description

The guidance type carried seven fields, but only three were ever set, and all
twenty call sites used it the same way: to hold a second, hand-written MCP
string next to a near-identical CLI one. That is the drift the abstraction was
meant to remove, so the type no longer offers a per-surface description at all.

A command now has one canonical description plus an optional tail per surface:

  guidance: {
    description: 'Shared body.',
    cliDetail: 'Flags, positional syntax, terminal examples.',
    mcpDetail: 'When-to-use and sequencing hints.',
  }

Because a surface can only append, CLI help and MCP tool text cannot diverge —
the guard against CLI syntax in MCP descriptions becomes structural rather than
a review tripwire, since flag vocabulary only lives in cliDetail. All twenty
commands that previously carried two descriptions now share one body.

Also:

- Drop `summary` from the description fallback chain. It is the short list-view
  line, so falling back to it replaced the full description with a fragment on
  both surfaces: artifacts, boot, and shutdown each lost their real description.
- Stop writing the MCP variant back over `metadata.description`. That field
  feeds CLI help, `explain`, and docs; `explain` was printing MCP-only text.
  MCP now reads a separate `mcpDescription`.
- Drop `mcp.parameters`. It restated inputSchema property descriptions inside
  the tool description — 1232 characters duplicated verbatim across six tools,
  and three of sixteen declared hints silently rendered nothing because the
  property had no description. Those properties are documented in the schema
  instead, which serves MCP, --help, and docs at once.
- Drop `cli.flags`. Its one use appended "Relevant flags: --surface,
  --launch-console." to help text that already named both flags inline.

Tests assert the structural property (both surfaces share a canonical prefix)
and the summary-fallback regression, alongside the existing CLI-syntax guard.
CLI help wording assertions follow the new copy.

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

* test: gate undocumented MCP tool inputs

Guidance no longer restates input fields in prose, so a tool's inputSchema is
the only place its inputs are documented — for the model, for --help, and for
the docs site. An undescribed property is a silent gap rather than a cosmetic
one, which is exactly the failure mode the removed `mcp.parameters` selection
had: it dropped hints for properties that carried no description and reported
nothing.

Describe the two trigger-app-event inputs that mechanism used to name, and add
a ratcheting gate over every MCP tool input. A property key that is not already
in the budget fails immediately; the total may never grow, and lowering it is
required once properties gain descriptions, so the 132 remaining stay visible
instead of settling in as permanent debt.

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

* fix: project the canonical description to every surface

Storing only the MCP variant left the shared body unpropagated: `metadata.description`
and the executable definition kept their pre-guidance text, so `explain click` reported
"Click or tap a semantic UI target..." while CLI help and the MCP tool both used the
canonical "Activate a UI target...". 53 commands were affected — the CLI schema base,
`explain`, and docs all read `metadata.description`.

`projectCommandGuidance` now returns the canonical body plus the MCP-only tail, and
`defineCommandFacet` writes the body to both metadata and the definition. Only the tail
is stored apart, as `mcpDetail`, so the body has exactly one home instead of a second
full copy that could drift; `composeMcpDescription` joins them for the tool surface.

The surface gate pins the invariant: definition, metadata, and `explain` must report the
identical body, and neither CLI help nor the MCP description may do anything but extend
it. Both arms verified by breaking them.

Also replace the undocumented-input ratchet's bare-key allowlist plus aggregate budget
with exact `tool.property` identities. The old shape stayed green while a gap migrated:
describing `foo.text` and adding an undescribed `bar.text` left both the allowed-name set
and the total of 132 unchanged, and stale names kept authorizing later gaps. Verified
with that exact scenario — `app` was already an allowed name via push/reinstall/settings
and the total held at 132, yet a newly undescribed `open.app` now fails. Recording a fix
requires deleting its baseline entry.

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

* refactor: drop guidance.description in favour of the command's own

`guidance.description` restated what `metadata.description` already is. Setting it
shadowed the metadata literal rather than replacing it, so every command that used it
shipped two bodies: the canonical one and a terse original that no surface could
observe — 768 bytes of unreachable strings across 20 commands.

Move each canonical body to the metadata literal where it belongs and delete the field.
Guidance is now tails only, `cliDetail` and `mcpDetail`, which also removes the question
of where a body is written: there is one place, and no chain to consult. Three guidance
blocks held nothing else and are gone entirely.

registry.js drops 1117 bytes.

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

* refactor: give every command one text block with a mandatory summary

The CLI carried four prose fields with a fallback chain between them, and one of
them — `helpDescription` — was authored on 45 commands and generated on the rest.
That ambiguity is why surfaces drifted: whichever field a reader looked at, some
other field might be the one actually rendered.

Prose now lives in a single `CommandText`, and `CommandSchema` keeps only grammar:

  summary      what is this command, in a list of ninety?   (mandatory)
  description  what does it do, and when do I reach for it? (mandatory)
  cliDetail    flags, argument shapes, terminal examples
  mcpDetail    sequencing and cross-tool hints

`--help`, the command list, the MCP tool description and `explain` are projections
computed where they render, so nothing derived is stored and no field can be both
input and output. The four-field model was validated against the whole surface
before the migration: all 67 commands reproduce their MCP text exactly and derive
their help body from `description`, so none needed a fifth field.

Making `summary` mandatory fixes a regression this branch introduced. 23 commands
had none, so the command list fell back to the full detail paragraph; lengthening
those descriptions earlier turned `click`'s list entry from 61 characters into 267,
`fill`'s into 214, `devices`' into 159. Every command now states its own line, and
a gate holds them under 72 characters, non-empty, period-free, and distinct from
the description.

Two duplications go with it: the per-command help printed its synopsis twice, once
as a header and again under `Usage:`, and `press` said "use longpress" in both its
body and its tail.

* refactor: tighten the command text plumbing

Self-review follow-ups on the text model, all quality-only:

`command-text.ts` moves from `cli-schema/` to `commands/`. It is a command concept
that MCP reads as much as the CLI does; living under `cli-schema` made the MCP
surface import a CLI module to render its own tool descriptions.

`defineCommandFacet` no longer casts. It took a facet and returned it with the
schema completed, but claimed to return the input type, which needed
`as unknown as` — a double cast is the type system reporting that the signature
was wrong. Splitting `CommandFacetInput` from `CommandFacet` states the completion
in the return type, so both that cast and the registry's `as CommandSchema` go.

`push`'s summary duplicated its description apart from a trailing period, which the
gate missed by comparing exact strings; it now compares normalised text, and the
summary says something the description does not. `install-from-source`'s summary
loses a clause it did not need.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-05 18:51:21 +02:00
Michał Pierzchała d81ac0a092 fix(ios): corroborate recorded tap outcomes (#1605)
* fix(ios): corroborate recorded tap outcomes

* fix(ios): preserve corroborated tap target identity

* fix(ios): suppress corroborated tap retries

* fix: require comparable iOS tap evidence

* fix: bound iOS tap corroboration baseline

* test(ios): deterministic injection seam for recorded-tap-failure corroboration (#1605 merge gate)

The field failure cannot be reproduced on this head: the tap false-failures
were a downstream symptom of XCTest-channel saturation, which the #1587
capture fixes removed. The seam records a real XCTIssue AFTER the real
gesture inside the per-command failure-count window, so
xctestRecordedFailureResponse and target invalidation fire byte-for-byte
like the field failure. Armed via a decrementing /tmp flag file (the daemon
regenerates tampered xctestrun templates, so env plumbing cannot reach a
daemon-spawned runner); compiled only under AGENT_DEVICE_RUNNER_UNIT_TESTS.

Live evidence on a daemon-spawned runner (Bluesky, ad-bsky-repro sim):
- landed case: injected failure on a real Search-tab tap -> success with
  the corroboration warning, screen verifiably on Search, no redispatch,
  runner serving next commands; flag consumed exactly once.
- unchanged case: injected failure on a dead-coordinate tap -> capture
  unchanged -> XCTEST_RECORDED_FAILURE preserved with the new honest hint;
  runner still usable.
- field-shape race (relaunch -> full snapshot -> immediate press, 5
  attempts): no natural recorded failure occurs on this head — the hostile
  tree needed for channel saturation is gone, corroborating the causal
  story.

* fix: reconcile tap corroboration with current interaction semantics
2026-08-05 18:38:07 +02:00
Michał Pierzchała 3b1431c634 fix(ios): never signal a recycled runner pid from a stale lease (#1621)
* fix(ios): never signal a recycled runner pid from a stale lease (#1596)

A runner lease file can outlive its runner by days (SIGKILLed daemon),
and pids get recycled: cleanupLeasedRunnerProcesses killed lease.runnerPid
raw — group kill + direct kill + pkill -P — while only the lease OWNER pid
had identity verification. After a pid-space wrap the tree kill lands on
whatever process now holds the pid.

- record runnerStartTime in the lease at construction (both the fresh-spawn
  and adoption sites go through buildRunnerLease)
- verify the pid before the SIGTERM/SIGKILL tree kills: start-time match,
  or for legacy leases without one, a runner-shaped xcodebuild command line;
  otherwise skip the tree kill and emit ios_runner_lease_recycled_pid_skipped
- the pattern-based xcodebuild pkill still runs unconditionally, so genuinely
  stray runner processes are still collected
- adoption skips a recycled pid too: the adopted session's disposal would
  later signal it

* fix: re-verify lease pid identity before each signal and during legacy adoption

The stale-lease cleanup verified the runner pid once and reused it for
both SIGTERM and SIGKILL; the runner usually dies on the SIGTERM, and
the pid can be recycled while the awaited xcodebuild sweep runs before
the escalation. Each signal now resolves a freshly verified pid.

Adoption skipped identity verification entirely for legacy leases
without a recorded runnerStartTime, then re-stamped the lease with the
live pid's start time — laundering a recycled pid into a strongly
verified lease that disposal would later kill. Adoption now shares the
disposal path's verification, including the runner-shaped command-line
fallback for legacy leases.

* fix: re-verify pid identity after the awaited adoption probe

The uptime probe is the last await before the adopted lease re-stamps
the pid with its live start time; the xcodebuild can exit and its pid
be recycled during that network round-trip while the old port still
answers. Re-verify after the probe — everything from there to the lease
write is synchronous.
2026-08-05 18:37:30 +02:00
Michał Pierzchała 98fa7b9240 build: eliminate tsdown bundle warnings (#1607) 2026-08-05 18:24:21 +02:00
Michał Pierzchała 8c800ae53f refactor(contracts): one viewport-root predicate for the whole repo (#1613)
* refactor(contracts): one viewport-root predicate for the whole repo

"Is this the Application/Window root" was written nine times: three
spellings normalizing `type|role|subrole`, five lowercasing `type` alone,
and one comparing the normalized type for EQUALITY. Two of the nine sat
in `contracts/snapshot-visibility.ts` itself, disagreeing with each other.

Measured before collapsing, using #1592's method — ground the comparison
in what each backend ACTUALLY emits, not in fixture strings. Over the 31
names iOS's `elementTypeName` can return, the 18 fully-qualified class
names Android emits, and the 24 mapped/raw forms the macOS helper
produces, the nine agreed on 71 of 73. The two exceptions are macOS
window subroles, and the only spelling that disagreed is maestro's `===`,
whose platform union is `android | ios` — so it can never see them. The
duplication was textual, not behavioral, which is what made the collapse
safe.

`isViewportRootNode` reads role and subrole because the macOS helper is
the only backend populating them and the only one able to emit a window
whose `type` does not say so: `normalizedSnapshotType` returns the raw
subrole for a non-standard window, so an `AXWindow` with subrole
`AXSystemDialog` or `AXUnknown` reads as neither from `type` alone. Those
two shapes are the whole behavioral delta of this change, at the six
call sites that were type-only, and they are windows by role.

`snapshot-viewport-root.test.ts` pins the predicate over those three
emitted vocabularies. Red evidence: reverting the canonical definition to
the type-only spelling fails 2 of 5 cells, to the equality spelling 4 of 5.

Also drops two kernel re-declarations this made visible: maestro's local
`containsPoint` and `rectsOverlap` were character-identical to
`@agent-device/kernel/rect`'s `containsPoint` and `isRectVisibleInViewport`,
in a file that already imports from that module. And `resolveViewportRect`
loses three `as Rect` casts that only existed because `.filter()` cannot
narrow `node.rect` — one `flatMap` states the same thing honestly.

Deliberately NOT in this change: the three viewport RESOLVERS still
diverge, and on Android that is a live defect rather than duplication.
Filed separately with the measurement.

* test(contracts): enumerate the macOS emitter's real vocabulary

Review found the table claimed to pin "the vocabulary each backend actually
emits" while omitting most of it. `normalizedSnapshotType` has three output
classes and only two were represented:

  1. thirteen roles mapped to fixed short names — six were missing
     (StaticText, TextField, TextArea, MenuBarItem, Menu, MenuItem);
  2. AXWindow, whose output is the SUBROLE unless it is AXStandardWindow;
  3. the `default:` arm, `subrole ?? role`, emitting the raw AX-prefixed
     value for every unmapped role.

All three are now enumerated, and the table asserts its own completeness
against the emitter's fixed-output set — a role added to that switch without
being added here fails, which is the emitter-drift protection the docblock
was promising but not delivering.

Re-measuring over the complete tables also corrected the header's own
numbers. The claim was "71 of 73 agree, 2 disagree"; over 75 names it is 71
agree and FOUR disagree, because AXSystemDialog and AXUnknown were absent
from the old table. Those two are the behavioral delta of this PR — an
AXWindow whose subrole is emitted as the type, invisible to the six
type-only spellings and named exactly by `role` — so the incomplete table
had been hiding the very rows that justify reading role/subrole. The other
two (AXFloatingWindow, AXSystemFloatingWindow) remain inert: only the `===`
spelling misses them and its platform union is `android | ios`.

* test(contracts): derive the macOS fixed-output set from the emitter

Two test-validity defects from review, both real.

The raw-fallback row `{ type: 'AXSearchField', role: 'AXTextField', subrole:
'AXSearchField' }` was unreachable: the `AXTextField` arm returns `TextField`
whatever the subrole, so no emitter run can produce it. Replaced with
`{ type: 'AXSortButton', role: 'AXCell', subrole: 'AXSortButton' }` — a
subrole on a genuinely unmapped role, which is what the `subrole ?? role`
default arm actually emits.

`MACOS_FIXED_OUTPUTS` was a hand-kept twin compared against a hand-kept
table, which is circular: a new mapped Swift role is absent from BOTH, so
they agree and the gate stays green. The "emitter-drift protection" the
docblock promised did not exist. The set is now parsed out of
`normalizedSnapshotType` in SnapshotTraversal.swift, so the comparison is
against the emitter rather than against a copy of the table's own
assumptions. `case "AXWindow"` returns a subrole expression rather than a
literal and is deliberately outside the literal-return set.

Red evidence: adding `case "AXDisclosureTriangle": return "DisclosureTriangle"`
to the Swift switch fails with `expected [ 'DisclosureTriangle' ] to deeply
equal []`; 6 pass once reverted. The parser throws rather than silently
matching nothing if the function is renamed or moved.

* chore: restore maestro conformance corpus to main

45 corpus YAMLs carried an unrelated quote-style churn ("Button" ->
'Button'). They were already modified in the worktree when this branch
started and a `git add -A` swept them into the predicate commit. Nothing
in this PR reads them. Restored verbatim to main.
2026-08-05 18:16:58 +02:00
Michał Pierzchała d8b309c6db refactor(contracts): name façade exports explicitly and retire the pin table (#1614)
* refactor(contracts): name façade exports explicitly and retire the pin table

Thirteen of the fourteen `@agent-device/contracts` façades were bare
`export *` barrels. `facades/snapshot.ts`, added by #1582, was the one
exception — explicit named re-exports — and that is now the rule.

Everything #1574 built to cope with `export *` goes with them:

  scripts/layering/facade-symbols.ts          -980   (816 pinned names)
  scripts/layering/facade-exports.ts          -192   (readFacadeExports)
  scripts/layering/facade-exports.test.ts     -234   (star semantics)
  scripts/layering/package-boundaries.test.ts  -55

`readFacadeExports` re-implemented ESM `GetExportedNames`/`ResolveExport`
— star-chain resolution, ambiguity rejection, diamond binding identity,
cycle guards, spec-accurate `default` filtering at the star rather than
the source. All of it existed to enumerate what `export *` hides. 523 of
the 816 pinned names belonged to contracts, i.e. to those thirteen files.
Once a façade names its exports, the façade file IS the pin, and it is
visible in the diff of the file that widened rather than in a separate
table a reviewer has to cross-check.

`readNamedExports` (20 lines) stays and is enough: it already throws on
bare `export *` and on `export default`. The pin is replaced by one
structural gate — no façade may contain a bare star — which reuses that
rejection rather than adding a regex.

Surface equivalence verified independently, not asserted: main's own
`readFacadeExports` run over the new façades, compared against main's own
`FACADE_SYMBOLS` table — 31 subpaths, 0 added, 0 removed.

Red evidence for the new gate: planting `export * from '../request-progress.ts'`
back into facades/progress.ts fails it with the file named and the reason
quoted; 12 pass / 0 fail once reverted.

Not included: the `lowerAndroidTouchPlan` tuple-assertion drive-by. It
needs `sampleGestureOffsets` to carry a min-arity tuple through `.map()`,
which TypeScript will not infer without a typed helper — a real change to
the gesture-plan contract rather than a drive-by, so it stays out.

* test(layering): assert façades stay exhaustive over their sources

Review on #1614 caught this conversion silently narrowing the public
surface. The explicit lists were generated against the surface at fork
time; #1567 landed 13 exports meanwhile — `DragOptions`, the drag-gesture
vocabulary (`COORDINATE_GESTURE_KINDS`, `CoordinateGesturePayload`, the
three `DEFAULT_DRAG_*` constants, `DragGestureInput`, `DragGesturePayload`,
`GestureCommandInput`, `buildDragGesturePlan`,
`dragGesturePayloadFromPositionals`, `normalizeGestureCommandInput`) and
`MultiTargetAnnotationV1`. The `export *` barrels had been forwarding all
13 automatically; the rebase dropped every one, and only a human diff
caught it.

The star-rejection gate could not: it only proves a façade does not WIDEN
invisibly. Narrowing is the failure an explicit list newly makes possible,
because `export *` could not narrow by construction. So the property the
stars gave for free is now asserted directly — every name a re-exported
source declares must appear in the façade.

Scoped to `packages/*/src/facades/`, the barrels this PR converted. A
hand-curated package `index.ts` is a different thing: `ad-replay`
deliberately publishes two values out of a much larger `internal/`, and
forcing exhaustiveness there would widen a surface its owner narrowed on
purpose (#1555). A source that itself carries a bare `export *` is skipped
— unknowable from that file alone, and reachable because the façade
re-exports the starred module directly too, which IS checked.

Red evidence: dropping `MultiTargetAnnotationV1` from facades/replay.ts —
one of the 13 the old gate was blind to — fails with the file, the source
and the symbol named. 13 pass / 0 fail once restored.

* fix(layering): close the exhaustiveness gate's starred-source hole

Two review findings, plus a third the gate caught on itself.

P1 — the three `DEFAULT_DRAG_*` constants join the existing public-façade
suppression, alongside `COORDINATE_GESTURE_KINDS` and
`normalizePublicGesture` which the same conversion surfaced. All five are
#1567's drag vocabulary, made individually visible to `--production`
analysis for the first time because a bare star used to hide them from
that exact check. Kept rather than narrowed, for the reason the existing
entry already states: the façade's surface stays byte-identical to what
the retired pin table asserted, and narrowing is a follow-up with its own
review.

P2 — the exhaustiveness gate skipped any source carrying a bare
`export *`, which dropped that module's DIRECT exports from the check too.
`gesture-plan.ts` stars `gesture-plan-types.ts`, so removing
`buildDragGesturePlan` from the façade narrowed the public surface and
still passed. `readDirectNamedExports` now reads exactly the names a module
declares or re-exports BY NAME and ignores the star, so direct exports are
checked while the starred set stays covered by the façade's own direct
re-export of that module.

Red evidence: removing `buildDragGesturePlan` from facades/interaction.ts
now fails naming file, source and symbol; 13 pass / 0 fail restored.

Third, and the reason the gate is worth having: rebasing onto main after
#1612 merged silently dropped `TEXT_ENTRY_ROUTES`, `TextEntryRoute` and
`TypeTextBackendResult` from the interaction façade — the same narrowing
class as the #1567 one review caught by hand, one merge later. The gate
failed on it before CI did. Restored.
2026-08-05 15:58:02 +02:00
Michał Pierzchała 959d858334 refactor(ios): share one private-XCTest event bridge between gesture and text synthesis (#1608)
RunnerSynthesizedGesture.m and RunnerSynthesizedTextEntry.m each independently
reflected into XCSynthesizedEventRecord/XCPointerEventPath. Extract the
common resolution (classes, addPointerEventPath:/setTargetProcessID:/
synthesizeWithError:/processID, the RunnerRequireClass/Selector/
ApplicationSelector checks, the shared objc_msgSend typedefs, and the
"name: reason" exception formatter) into RunnerXCTestEventBridge.h/.m.
Each caller resolves its own extras on top of the shared core: gesture
resolves the 2-arg initWithName:interfaceOrientation: plus touch-path
selectors, text entry resolves the 1-arg initWithName: plus text-input
selectors.
2026-08-05 15:57:32 +02:00
Michał Pierzchała ee473b6adc refactor(daemon): give the Maestro fallback and ambiguous-match details real types (#1612)
Three places smuggled structured data through untyped bags and re-read it
with runtime guards. Each gets an explicit typed boundary.

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

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

C. `details.candidates` meant two incompatible things. The device-domain
   resolvers now key their list `devices`, so the shared renderer drops its
   shape-disambiguation guards and the device list actually renders.
2026-08-05 14:32:25 +02:00
Michał Pierzchała 36f44ca2cc docs: clarify iOS drag synthesis profiles (#1616)
* docs: clarify iOS drag synthesis profiles

* refactor: consolidate Apple gesture event lifecycle
2026-08-05 14:21:58 +02:00
Michał Pierzchała 9fc266351f fix: stabilize Replay Nightly fixture boundaries (#1610)
* fix: stabilize replay nightly fixture boundaries

* test: stabilize exit flush integration coverage

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

* fix: address drag gesture review feedback

* fix: satisfy drag review quality gates

* fix(android): lower drag trajectories piecewise

* test(replay): validate drag fixture selectors

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

* test(drag): prove destination on live devices
2026-08-05 12:37:02 +02:00
Michał Pierzchała 4c7a899a05 fix: prevent iOS text entry runner wedge (#1604)
* fix: prevent iOS text entry runner wedge

* fix: preserve iOS hardware-keyboard text entry

* fix: expire iOS text-entry tap witnesses

* fix: fail interrupted iOS text entry

* test: run interrupted iOS typing regression
2026-08-05 10:24:04 +02:00