1333 Commits

Author SHA1 Message Date
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
Michał Pierzchała 23a3016e9b fix: stop the unit suite from leaking temp directories (#1593)
* fix: stop the unit suite from leaking temp directories

~650 test call sites across the unit suite create scratch directories via
fs.mkdtemp(path.join(os.tmpdir(), ...)) or shared factories (makeSessionStore)
with no cleanup, ever. Over time this accumulated 1.16M+ orphaned directories
in the real system tmpdir, slow enough to make tools that enumerate $TMPDIR at
startup (e.g. opencode) take 1-2 minutes to launch.

Rather than migrate every call site, redirect os.tmpdir() itself for the
lifetime of the whole `vitest run` invocation: scripts/vitest-tmpdir-global-setup.ts
wires in as vitest's globalSetup/globalTeardown, points TMPDIR at one
/tmp-rooted directory (verified: env mutations here propagate to every forked
worker, confirmed empirically), and removes it in one recursive rm after every
worker across every project finishes. Since os.tmpdir() reads TMPDIR on every
call, this covers all ~650 call sites without touching any of them.

Rooted at /tmp rather than nested inside the current (already deep, on macOS)
os.tmpdir(): that broke real AF_UNIX socket tests (runner-usbmux.test.ts) by
pushing socket paths past the 104-byte sun_path limit. A per-file afterAll
hook was tried first but proved unreliable — 5 of 7 workers in one run never
ran it before their process was torn down; the global setup/teardown pair
(one process, confirmed single execution) is the mechanism that's actually
guaranteed to run once.

Also adds:
- scripts/check-tmpdir-leaks.ts: CI/local guard asserting no
  agent-device-test-run-* directory survives a run (a leftover one means a
  worker was killed before cleanup could run).
- src/__tests__/test-utils/tmp-dir.ts: documented mkdtempForTest /
  mkdtempForTestSync helpers, the discoverable way to get a scratch dir going
  forward (mirrors the src/utils/exec.ts pattern for node:child_process).
- scripts/check-test-tmpdir-helper.ts: ratchet guard capping raw
  fs.mkdtemp/mkdtempSync call sites in test files at today's count (632); it
  can only shrink as call sites migrate to the helper.

* fix: make check-tmpdir-leaks scan the same root the fix actually uses

check-tmpdir-leaks.ts was scanning os.tmpdir() for leftover run
directories, but vitest-tmpdir-global-setup.ts creates them under a
hard-coded /tmp. On macOS those are different paths (TMPDIR is a deep
per-user /var/folders/.../T/ directory) — the guard could never find a
leak on the exact platform the original leak happened on, only on
Linux CI where os.tmpdir() already is /tmp.

Export TEST_RUN_TMP_ROOT and TEST_RUN_TMP_PREFIX from the global-setup
module and import them in the leak check instead of recomputing a path
that can drift. Switched from a fixed pid-based directory name to
fs.mkdtempSync so a same-named leftover from a prior killed run (or,
on a shared machine, another user) can't collide with a live run.

Also fixes two stale comments (in this file and ci.yml) that still
described the per-file afterAll hook design that was abandoned in
favor of the global setup/teardown pair, and notes the check only
covers vitest runs, not the node --test lanes (test:smoke,
test:integration:node).

Verified live: with the old code the guard reported no leaks even with
a real orphaned /tmp/agent-device-test-run-* directory present (left
by a command that got killed mid-run); with this fix it correctly
found and reported it.

* simplify: drop the tmpdir ratchet guard, keep the leak check local-only

Two guards were more than this needed:

- check:test-tmpdir-helper (ratchet on raw fs.mkdtemp call counts)
  protects nothing a bug could actually trigger — the leak is already
  fixed architecturally regardless of call-site count, so this was
  pure style/discoverability nudging. Dropped the script and its
  check:tooling/CI wiring; kept mkdtempForTest/mkdtempForTestSync in
  tmp-dir.ts as the documented option without enforcing it.

- check:tmpdir-leaks in CI added little: GitHub-hosted runners are
  destroyed after each job, so a leftover directory there is harmless
  by construction, and a worker getting killed mid-run would already
  surface as a job failure some other way. Its real value is local,
  on the long-lived dev machines where the original leak actually
  accumulated — kept it wired into check:unit, dropped the CI step.

* fix: don't flag a concurrent vitest run's tmpdir as a leak

check-tmpdir-leaks.ts reported every agent-device-test-run-* directory
as a leak, but a concurrent vitest run in another worktree legitimately
keeps its own directory present until its own teardown finishes. On a
machine that regularly runs several worktrees at once, that made
check:unit fail on unrelated in-progress work.

Embed the owning process's pid in the directory name (still random-
suffixed via mkdtempSync, so same-pid reuse across separate runs can't
collide) and have the leak check skip any directory whose pid is still
alive (process.kill(pid, 0)) — only directories whose owning process
already exited without running its globalTeardown are real leaks.

Split the pure logic into check-tmpdir-leaks-model.ts (findLeakedRunDirectories,
with an injectable liveness check for testing) so it has a real
regression suite, including the concurrent-run case, instead of only
being exercised by hand.

* refactor: migrate raw fs.mkdtemp call sites to mkdtempForTest(Sync)

Migrates 629 raw fs.mkdtemp(Sync)(path.join(os.tmpdir(), PREFIX)) call
sites across 168 test files to the mkdtempForTest / mkdtempForTestSync
helpers (src/__tests__/test-utils/tmp-dir.ts), so there's one
documented, discoverable way to get a scratch dir in a test — cleanup
already didn't depend on the call-site shape (the global TMPDIR
redirect covers any of them), this is purely for consistency and
discoverability, same reasoning as src/utils/exec.ts for
node:child_process. Existing manual per-test cleanup (fs.rm in
finally/afterEach/onTestFinished blocks) is untouched — the global
teardown is a fallback for killed workers, not a replacement for tests
cleaning up after themselves.

Migrated with a one-off AST-based codemod (oxc-parser, since regex
mismatched multi-line calls and complex prefix expressions like
`options?.tempPrefix ?? 'default-'`) rather than by hand across 168
files. The codemod isn't included — it doesn't need to survive this
commit. Caught and fixed one real bug in it during review: a small
number of files declare a second import statement later in the file,
after some of the matched call sites, which broke a naive "insert
after the textually-last ImportDeclaration" placement; fixed to insert
after the top contiguous import block instead, plus a self-check that
re-parses every generated file before writing it.

Also fixes 5 fallow dead-code findings the branch introduced: the
vitest globalSetup functions (setup/teardown) are only referenced by
the config-string path vitest.config.ts hands to globalSetup, invisible
to static analysis — suppressed with the documented convention.
isProcessAlive didn't need to be exported (nothing outside the module
uses it). And dropped a barrel re-export of the new helpers from
test-utils/index.ts: nothing actually imports through the barrel
(matching the existing makeSessionStore convention, which is imported
directly from store-factory.ts everywhere despite also being
barrel-exported), so the re-export was genuinely dead.

Documents the convention in docs/agents/testing.md.

Verified: full unit suite (5308 tests) passes except the one
pre-existing, unrelated package-exports.test.ts failure; typecheck,
lint, and format all clean; fallow audit clean against the PR base.

* fix: correct fallow suppression token and drop unused barrel re-export

These were meant to be part of 397cdd6d7 (verified locally before that
commit) but didn't actually get staged — caught by CI's Fallow Code
Quality check re-running against the pushed commit, which still had
the plural 'unused-exports' token (fallow expects singular
'unused-export') and the dead barrel re-export.

* fix: migrate the two mkdtemp call sites the rebase silently reintroduced

Rebasing onto main pulled in #1594's two new test cases in this file,
added independently of this branch's migration, still using raw
fs.mkdtempSync(path.join(os.tmpdir(), ...)). Git's line-based merge
found no textual conflict with this branch's removal of the os import
(the changes touch non-overlapping regions), so it silently produced
a file that doesn't typecheck. Migrated both to mkdtempForTestSync for
consistency with the rest of the file, caught by CI's Typecheck,
Fallow Code Quality, and FreeRange checks re-running against the
pushed commit.

* test: pin Vitest tmpdir lifecycle

* fix: preserve the Swift cache across test runs
2026-08-05 08:56:39 +02:00
Michał Pierzchała 611858103e fix(ios): harden Bluesky-class interaction reliability (#1588)
* fix: type into focused iOS inputs without AX

* fix: fill AX-hostile iOS text inputs

* fix: keep scrolling containers from stealing taps

* fix: stop agents after explicit task success

* chore: format benchmark guidance

* fix(ios): preserve fill semantics across fast paths

* test: retire direct selector fill expectations

* test: assert runtime selector fill evidence

* fix(ios): preserve verified and Maestro fill paths

* refactor(ios): isolate synthesized text entry

* fix(client): preserve open diagnostic paths

* fix(ios): expose structured text entry route

* fix(packaging): strip text entry policy tests
2026-08-05 08:02:44 +02:00
Michał Pierzchała dea65cbd58 fix(daemon): judge post-gesture movement by identity, not by the intersection (#1573)
Fixes #1569.

The post-gesture baseline check asked "did anything in the intersection of the
two captures move". On a scroll that is the wrong question: a scroll REPLACES
content rather than sliding shared elements around, so the intersection is
whatever sat still. Measured on a live checkout-form `scroll down 0.6` that
moved the entire form, the baseline and the post-scroll capture had exactly five
identifiers in common and all five were tab-bar icons, which cannot move.

What carried the verdict instead was anonymous layout containers matched by
ordinal position among other anonymous nodes. That real pair had 8 of them on
one side and 10 on the other, and the six "moved" entries reported deltas of
-920, +37 and -7 px for a ~500px scroll. Removing them from the same pair
flipped the verdict to 'unchanged' — for a scroll that replaced every element on
screen. That is the saturation in #1569 finding 1: the oracle was reading
capture composition, not the screen.

Three changes:

- Signature entries carry an `identity` (identifier|label|value|type) alongside
  the existing `key`. The key keeps folding in hittable/enabled/selected and an
  occurrence index, which is right for "did two back-to-back captures agree" and
  wrong across a gesture: scrolling flips `hittable` the moment a node's centre
  leaves the viewport, evicting exactly the elements whose movement was the
  evidence. Anonymous nodes get no identity and leave the comparison.

- The verdict is set membership. Content leaving AND arriving is replacement, so
  'changed'. One-sided difference is scope drift between differently scoped
  captures, so 'unchanged' — the subset tolerance the old rule needed, kept.
  Rect movement of a survivor is still 'changed'.

- The loop records the baseline's snapshot backend and re-baselines rather than
  concluding when it changes. Backends do not return comparable views: on one
  live screen private AX returned 139 nodes including 43 scrolled off-viewport
  where the tree backend returned 48. A plan fallback or the XCTest-channel
  penalty can swap backends mid-poll at any time.

The distrust budget is untouched — with an oracle that can see movement, a real
scroll settles on the first quiet pair instead of running to the cap.

Counterfactuals, each failing the pin it belongs to: putting `hittable` back
into identity fails the hittability-flip test; admitting anonymous nodes fails
the fluctuating-container test; removing the backend guard fails the re-baseline
test.
2026-08-05 08:00:08 +02:00
Michał Pierzchała 20e903c117 fix(maestro): unify the scrollable-ancestor walks and fix Android scroll-container selection (#1592)
* refactor(maestro): collapse the duplicate scrollable-ancestor walk

fallow reported three structurally identical "walk up the parent chain to
the nearest scrollable ancestor" implementations as clone groups
(dup:1b401a24, dup:ce01e1de). Two of the three predicates classify
identically, one does not.

snapshot-policy.ts's isScrollableNode is logically identical to contracts'
isScrollableNodeLike -- same six type patterns, same `=== 'table'`
equality, same role/subrole fallback, and neither normalizes the type
first. The walks match too, so findScrollableAncestorRect collapses onto
findNearestScrollableAncestor with `(n) => Boolean(n.rect)`.

runtime-port-geometry.ts's isScrollableSnapshotType does NOT agree. It
equality-matches the NORMALIZED type, so over 227 node-type strings
harvested from the repo's fixtures and tests it disagrees in both
directions: Android ListView/GridView/RecyclerView, HorizontalScrollView,
AXScrollBar and role-only scrollables clip but are not swipe containers,
while XCUIElementTypeTable and AXTable are swipe containers but do not
clip (the clip predicate compares 'table' against the unnormalized type,
so prefixed forms miss). Only bare `table` satisfies both. It stays
separate, with the divergence and the reason each call site needs its own
answer written down where it can be read.

The new test is load-bearing rather than decorative: replacing
isScrollableSnapshotType with the contracts predicate leaves
`pnpm maestro:conformance` at 46/46 and the pre-existing maestro suite at
206/206 green. The oracle does not cover scroll-container selection, so
nothing else in the repo fails on that collapse.

resolveRootViewport is deliberately left alone -- it resembles contracts'
resolveViewportRect but lacks its third "largest containing rect of any
node" fallback, so it is a real divergence and not the next dedup.

* fix(maestro): recognize Android scroll containers when aiming scrollUntilVisible

The divergence note added in the previous commit was wrong, and it was
covering for a bug rather than describing a design.

Grounding the comparison at the call site instead of in fixture text
changes the answer. `node.type` is never normalized on the way in -- it
carries the raw platform string -- so the domain of each predicate is
exactly what each platform emits:

  iOS    `elementTypeName` returns 31 fixed short names ("Table",
         "ScrollView", "CollectionView", ...), never "XCUIElementType*".
  Android `attrs.className`, fully qualified.
  macOS   role-mapped short names; outside Maestro's platform union.

Over all 31 iOS names the two predicates agree on every single one. The
claimed `XCUIElementTypeTable` / `AXTable` divergence was measured on
strings the runner cannot produce; the real emission is "Table", which
both predicates accept. The role/subrole arm is macOS-helper-only, so it
is inert for Maestro entirely.

What remains is Android, one-directional, and a defect: matching a
normalized type for EQUALITY recognizes bare `android.widget.ScrollView`
and silently misses HorizontalScrollView, NestedScrollView, RecyclerView,
ListView and GridView. `scrollUntilVisible` therefore selected no
container and fell back to a screen-centred swipe inside essentially
every RecyclerView-backed list -- contradicting the function's own
documented intent, and contradicting the existing Android test that
expects `android.widget.ScrollView` to be selected.

So the third walk collapses onto the shared helper too: the substring
predicate is also the better fit for Android's open class-name space,
where an allow-list would keep missing NestedScrollView and every custom
subclass. All three walks now share
`@agent-device/contracts/snapshot`, and the explanatory comment is gone
because there is nothing left to explain.

The test is rewritten to pin the classification over the vocabulary each
platform actually emits, with the Android rows as the regression guard.
2026-08-05 07:58:15 +02:00
Michał Pierzchała 543e9f8c05 fix(ios): give keyboard dismiss a safe-area-tap fallback (#1598) (#1606)
* fix(ios): give keyboard dismiss a safe-area-tap fallback (#1598)

The runner already tapped a keyboard's own Hide/Dismiss/Done key when the
AX tree exposed one, but iPhone's default software keyboard has no such
key, so `keyboard dismiss` returned UNSUPPORTED_OPERATION on the common
case and agents proceeded with the keyboard (and any live QuickType
predictive-text bar) still up.

Live-validated on throwaway simulators before choosing a design: hardware
escape key (no effect without a connected hardware keyboard), swipe-down
starting on the keyboard (does not trigger UIKit's interactive dismissal
on Settings/Safari/Contacts), and a private
`performAction:onElement:value:error:` AX call (hung the runner for 90s on
a guessed action name, force-killed by the daemon timeout) were all ruled
out. The dismiss-key tap remains the primary mechanism (iPad, or any app
with an inputAccessoryView Done/Cancel button); a new snapshot-derived
safe-area tap is added as the disclosed last resort, computed to land
outside both the keyboard and every currently-hittable element so it is a
safe no-op even when it fails to dismiss.

The response now discloses which mechanism actually fired
(`mechanism: 'dismissKey' | 'safeAreaTap'`) across the CLI/daemon dispatch
path, the SDK runtime.backend surface, and session-event summaries, so
callers can tell a real dismiss-key press apart from a best-effort tap.
UNSUPPORTED_OPERATION now says both mechanisms were tried.

* fix: satisfy CI formatting and complexity gates

oxfmt on three touched files; buildKeyboardActionSummary split so the
dismiss wording (incl. the safeAreaTap mechanism disclosure) lives in its
own helper below the complexity threshold.

* fix: any-element obstacle rule for the safe-area dismiss tap (#1606 review P1)

A role allowlist cannot prove a point is AX-empty: an unlabeled RN
Pressable surfaces as a hittable Other, and a tappable parent can cover a
point its static-text child does not. Every known element frame now counts
as an obstacle regardless of role or hittability, with only ~window-sized
structural frames exempt (isStructuralRootFrame, 95% coverage) — exempting
those is what keeps the rule satisfiable, and a genuinely tappable
full-screen backdrop staying exempt is the disclosed, accepted behavior of
this fallback. One .any resolution replaces ten typed queries (single tree
snapshot, no per-element isHittable round trips), so the stricter rule is
also cheaper.

* fix: drop the safe-area tap — background-tap dismissal is unsupported (#1606 review P1, round 2)

No geometry or role query can prove a coordinate is side-effect-free: after
the any-element rule, the structural-root exemption still deliberately
removed full-screen actionable elements (RN Pressable backdrops) from the
obstacle set, so the tap could navigate or submit — and report success
because the mutation hid the keyboard. Per review, generic background-tap
dismissal is now explicitly unsupported: the dismiss key is the only
mechanism the runner vouches for, UNSUPPORTED_OPERATION says so and steers
callers to press-the-next-target / keyboard enter, and the mechanism field
narrows to 'dismissKey'. Unrecognized wire mechanisms degrade to the bare
message and are dropped from event details.
2026-08-04 22:30:04 +02:00
Michał Pierzchała 65450dd3a7 fix: flush stdout/stderr before every CLI process.exit() (#1596) (#1603)
* fix: flush stdout/stderr before every CLI process.exit() (#1596)

Node only flushes process.stdout/stderr synchronously to a file or TTY;
on a pipe (the normal condition for this CLI when driven as a
subprocess) a write queued right before process.exit() can be silently
dropped. handleRunCliFailure's --debug daemon-log-tail dump made this
reachable from the exact path that renders a SESSION_NOT_FOUND error
right after a daemon replace, matching field reports of the driving
process going silent immediately after "Replacing daemon ... unreachable"
plus the SESSION_NOT_FOUND error.

Add exitAfterFlush() and route every process.exit() in src/cli.ts and
src/bin.ts through it, so a piped caller always receives the full
structured error (with its "run open first" hint) before the process
terminates. Also bound the --debug log-tail dump to a byte cap instead
of an unbounded 200 lines.

Verified directly against the real CLI (piped subprocess, pre-fix vs
post-fix): a live daemon with a seeded >64KB log truncates its --debug
error output before the fix and delivers it in full after.

* fix: satisfy CI gates on #1596 (format, fallow, coverage)

- oxfmt formatting on the new integration test file.
- Register the two exit-flush regression fixtures (support/exit-naive.ts,
  support/exit-after-flush.ts) as fallow entry points: they're run as real
  subprocesses via a string path (runCmdSync), which fallow's static
  dependency analysis can't follow, same as the existing
  test/contention-retry-fixtures/* entries. exit-payload.ts becomes
  reachable transitively through their static imports. Also switched the
  integration test's local PAYLOAD_MARKER duplicate to import the one
  fallow flagged as unused from exit-payload.ts.
- Added real unit coverage for the new exitAfterFlush code paths, since
  node --test integration files aren't measured by the vitest coverage
  gate: src/utils/__tests__/process-exit.test.ts exercises the
  already-drained, backlogged-then-drains, and never-drains/timeout
  branches directly against a fake stream; src/__tests__/cli-exit-paths.test.ts
  drives runCli() for --version, bare help, no-command, and web to cover
  their exitAfterFlush call sites, plus a --debug case with a >64KB seeded
  daemon.log proving printDaemonLogTailOnError's new byte cap actually
  trims the oldest lines.

Changed-line coverage gate now passes at 92.59% (was 59.26%); the two
remaining uncovered lines are the bottom-of-file `isDirectRun` catch
handler, which only runs when cli.ts is executed as the literal entry
script and is not reachable by importing it as a module in a test (the
same shape as bin.ts's already-excluded top-level fast paths).
2026-08-04 21:57:00 +02:00
Michał Pierzchała 3a64ee7587 fix(daemon): surface proven no-effect gestures to the agent (#1601)
* fix(daemon): surface proven no-effect gestures to the agent (#1600)

The #1542 post-gesture stabilization loop already PROVES when a scroll,
swipe, or pan moved nothing: its accept-stale verdict fires only when the
quiet post-gesture capture still equals the pre-gesture baseline after the
distrust cap. But the verdict went to the diagnostics stream only — the
agent-facing response reported plain success, and the benchmark showed the
cost: element-18 burned ~40 tool calls re-issuing scrolls the daemon knew
did nothing ("Scrolled up by 1200px" fifteen times over a byte-identical
tree) before stumbling onto raw swipe.

The stabilization loop now returns the verdict alongside the capture, and
the snapshot handler appends a warning through the existing annotations
channel: it names the exact gesture, admits the at-edge ambiguity the
platform cannot resolve, and hands over the raw-drag escape hatch that
moved the stuck list in the field.

Live-verified on the seeded Bluesky feed: scroll up at top warns, scroll
down with real room moves silently, a fling at the bottom edge warns —
all three truthful.

* fix: require full-surface evidence before the no-effect claim (#1601 review P1)

accept-stale alone is subset-tolerant by design: a successful scroll that
replaced every list cell under fixed chrome still classifies 'unchanged' on
the shared chrome alone, and #1573 has live evidence of that shape. The
agent-facing claim now additionally requires every discriminating entry of
the quiet capture to match the baseline exactly, in both directions
(haveIdenticalDiscriminatingSurfaces): any appeared or vanished real
element — including scope drift — vetoes the warning. Silence is the safe
failure mode for a message that steers the agent's next move.

Red evidence: the new fixed-chrome + replaced-list regression test fails on
the previous PR commit (gestureNoEffect wrongly present), passes with the
gate.

* refactor: extract the corroborated-accept result builder

capturePostGestureStabilizedResult crossed the complexity threshold (14
cyclomatic) after the #1601 review gate; the accept/veto construction now
lives in buildAcceptedStabilizedResult, which also keeps the corroboration
rule stated in one place.
2026-08-04 21:22:52 +02:00
Michał Pierzchała 8ba5f9b8de fix: surface AMBIGUOUS_MATCH candidates and name find's supported actions (#1602)
* fix: surface AMBIGUOUS_MATCH candidates and name find's supported actions (#1597)

AMBIGUOUS_MATCH errors now list the matching candidates (ref, role,
label/identifier) rendered the same way as snapshot -i lines, capped at
5 with a "+N more" marker. buildAmbiguousMatchError (the single
producer, src/daemon/handlers/find.ts) reuses formatSnapshotLine to
build the list; formatAmbiguousMatchCandidateLines (src/utils/output.ts)
renders it unconditionally on both text surfaces an agent actually
reads (CLI printHumanError and MCP formatToolErrorText) — previously
the candidates lived only in details, which neither surface printed.

find's "Unsupported find action: X" (e.g. from `find <text> press`)
now attaches a hint naming every action find actually supports and the
two-step recovery shape: run find "<text>" to resolve the ref, then
dispatch the gesture as its own command (press @eNN). The hint is a
single exported constant (UNSUPPORTED_FIND_ACTION_HINT) shared by both
throw sites — packages/selectors' raw-token parser and the CLI's typed
reader (src/commands/interaction/selectors.ts) — so they can't drift.

Matching semantics are unchanged; ambiguous rejection stays by-design.
The help-conformance corpus's AMBIGUOUS_MATCH quiz is updated: its
premise ("candidate refs were not shown") no longer holds, but with 3
identically-labeled candidates the lesson (don't guess a specific ref)
still holds.

* fix: guard the AMBIGUOUS_MATCH candidate renderer against device-domain shapes

Review on #1602 (P2): formatAmbiguousMatchCandidateLines ran for every
normalized error and stringified details.candidates unconditionally,
but device-domain AMBIGUOUS_MATCH/APP_NOT_INSTALLED errors
(findBootedAppleSimulatorWithApp, src/core/dispatch-resolve.ts) reuse
that key for { id, name } device objects with no `matches` field —
CLI and MCP would have printed "Candidates: [object Object]" for
those. The renderer now requires numeric details.matches AND every
candidate to be a string before rendering anything, restricting it to
buildAmbiguousMatchError's element-match shape; unrecognized shapes
render nothing, same as before this feature existed. Added regression
tests against the exact device-error shape on both text surfaces.

Also unexports AMBIGUOUS_MATCH_CANDIDATE_LIMIT (fallow flagged it as
an unused production export) — it has no consumer outside find.ts.
2026-08-04 21:10:05 +02:00
Michał Pierzchała 3835c41d89 fix(ios): stop shipping runner unit tests in the npm package (#1594)
The Apple runner ships as source in dist/apple/runner, and packaging strips
#if AGENT_DEVICE_RUNNER_UNIT_TESTS blocks — but tests outside such blocks
shipped whole and compiled on every user's machine. Two files leaked six
tests this way (RunnerTests+LifecycleCacheTests, RunnerTests+SnapshotTraversalIdentityTests).

Wrap the strays and close the class: packaging now fails if any XCTest-shaped
method (func test*) survives stripping, with testCommand in RunnerTests.swift
as the only allowlisted entrypoint.
2026-08-04 19:30:33 +02:00
Michał Pierzchała 74efcbbd84 fix(snapshot): one-shot recovered warning for internally armed penalties (#1590)
* fix(snapshot): one-shot recovered warning for internally armed penalties

The deferred-capture suppression assumed the capture that armed the
XCTest-channel penalty already rendered the full 'overly complex or slow
accessibility tree' warning. Internal captures (selector resolution,
settle observation loops, system-modal probes) can arm the penalty
without any user-facing render, leaving the next public snapshot with
only the structured verdict and no CLI warning line.

The runner cannot tell user-facing from internal captures, so the daemon
now holds a per-session one-shot latch (snapshot-quality-latch.ts)
applied at the snapshot/diff response seam: a genuine recovered render
sets it silently, the first public 'deferred' verdict without the latch
re-renders the full warning once and sets it, a healthy public verdict
clears it (the penalty window is over), and an app switch supersedes it.
Internal observation responses (observationOnly) neither consume nor
clear the latch.

Follow-up to PR #1587 review (non-blocking hardening).

* chore(layering): declare the deferred-warning latch owner and ratchet baseline

The R7 session-state gate requires every SessionState field to have a
declared writer owner: recoveredSnapshotWarningLatch is owned solely by
snapshot-quality-latch.ts (matching the field's 'managed only through'
contract), and the R10 pressure baseline grows deliberately to
23 writer-owned fields / 29 owner claims. Also oxfmt-formats the new
latch test.

* fix(snapshot): latch on the captured verdict, not the retained session snapshot

Review P2 on #1590: the latch seam read a diff capture's verdict back from
session.snapshot, but an empty ref-scoped capture deliberately retains the
previous stored snapshot (shouldKeepCurrentSnapshot) — so a deferred
capture could consult a retained healthy verdict, clearing the latch and
omitting the one-shot warning.

The daemon snapshot backend now fills a per-request CapturedSnapshotQuality
slot on every capture, and the seam latches on that just-captured verdict
for both snapshot and diff. New production-path regression: an empty
ref-scoped diff over a retained healthy snapshot with a deferred capture
warns once (verified red against the previous seam).

* test(snapshot): pin app-switch latch supersession through the dispatch seam

Cross-vendor review follow-up: the app-switch transition was pinned only at
the pure-function level; a regression in how the seam keys the latch by the
session's appBundleId would not have been caught. Two-dispatch integration
test: latch held for app A, bundle switched, app B's first deferred verdict
warns once and rekeys the latch.
2026-08-04 19:12:17 +02:00
Michał Pierzchała 4f8dc3f31e refactor: move selector engine into workspace package (#1589)
* refactor: move selector engine into workspace package

* refactor(selectors): trim the package façade to its real consumers

Follow-up to the selector-package cutover, from a structural review of it.

- Drop 15 façade symbols with no consumer anywhere in the repo:
  selectorUsesKey (added by the cutover, never called), isNodeVisible /
  isNodeEditable (the real helpers are contracts/snapshot's), normalizeText,
  splitIsSelectorArgs, IS_PREDICATE_REQUIRED_MESSAGE, four nested Replay
  types, SelectorDisambiguationDisclosure, and the four kernel type
  re-exports every consumer already imports from kernel directly.
- Delete SelectorCapturePolicyInput.selectorExpression, which
  deriveSelectorCapturePolicy never read; the policy varies only by
  predicate, so it takes one now. Two of the four tests asserted that the
  unread parameter had no effect and could not fail; they go with it.
- Return the Maestro export vocabulary to the maestro package. The cutover
  inlined MAESTRO_TEXT/STATE_SELECTOR_KEYS' values into the CLI call site,
  leaving both constants dead in the package that owns the concept and no
  gate over the two copies. MAESTRO_SELECTOR_PROJECTION is now the one
  statement of it.
- Dedupe SelectorDiagnostics and SelectorDisambiguationDisclosure, declared
  character-for-character twice across the AST/string seam, and name the two
  shared option shapes once instead of five inline copies. The parser-side
  resolution types take an Ast prefix so the twins read as twins.
- Delete three identity wrappers: parsePrivateSelector,
  selectorExpressionToMaestro, and the formatSelectorFailure forwarder —
  nothing passes it a chain any more, so the SelectorChain | string union
  and its branch go too.
- Delete internal/index.ts, an AST barrel whose only consumer was one test
  in the same directory (renamed to engine.test.ts), and the match.ts
  pass-through that existed to feed it.
- ReplaySelectorGrammar had three variants for two behaviors; 'wait' and
  'ordinary' were the same path. It is 'is' | 'positional' now.
- Drop the deleted src/sdk/selectors.ts from .fallowrc.json's entry list.

Behavior unchanged. pnpm check green: 598 unit files / 5278 tests, smoke
35 passed / 3 live skipped, layering 71/71, depgraph 22/22, mutation config
45/45, fallow clean, package smoke sound. Counterfactual: pointing
MAESTRO_SELECTOR_PROJECTION.textKeys at the state keys turns three
replay-maestro-export cells red; restored before commit.

* test(selectors): split the engine aggregation test by source concept

`internal/index.test.ts` (renamed `engine.test.ts` when its barrel went away)
was a 708-line aggregation over the whole engine — past the 500-line tripwire
and mirroring no source module, so it also ran as one serial unit.

It becomes five files that each mirror what they test, plus the parser cells
folded into the existing parse test:

  resolve.test.ts                 alternative fallback, strict uniqueness,
                                  first-match existence
  resolve-disambiguation.test.ts  ADR 0012 ranking: deepest, smallest-area,
                                  winner-vs-challenger disclosure, tie fallback
  resolve-viewport.test.ts        the visibility half: on-screen beats
                                  off-screen, including inside an off-screen
                                  scroll container
  match.test.ts                   per-key matching semantics (text, role,
                                  focused, appname/windowtitle, decoded
                                  newline labels)
  arguments.test.ts               where the selector ends and the command's
                                  positionals begin, both grammars
  parse.test.ts                   +6 grammar/escape cells beside the existing
                                  property tests

The login-form tree shared by resolve.test.ts and match.test.ts moves to
`__tests__/login-form-nodes.ts` rather than being copied into both.

All 27 cells are carried over unchanged and still pass; no file now exceeds
224 lines. pnpm check green: 602 unit files / 5278 tests, layering 71/71,
depgraph 22/22, mutation config 45/45, fallow clean over 127 changed files.

* revert(selectors): keep agent-device/selectors public, behind one AST subpath

The cutover removed the `agent-device/selectors` public subpath as part of
tightening the API. It is in use, so the removal is reverted: the subpath ships
the same ten symbols v0.20.5 shipped, with the same signatures.

That has to coexist with the reason the package façade is string-only, so the
AST leaves through one named door instead of the main one:

  @agent-device/selectors        string-in/string-out; every in-repo consumer
  @agent-device/selectors/ast    the published parser surface; one consumer,
                                 src/sdk/selectors.ts

`packages/selectors/src/ast.ts` re-exports parseSelectorChain,
tryParseSelectorChain, isSelectorToken, the AST-taking findSelectorChainMatch
and resolveSelectorChain, isNodeVisible, isNodeEditable, and types
SelectorChain / SelectorDiagnostics. `formatSelectorFailure` keeps its
published `SelectorChain | string` first parameter as a shim here rather than
widening internal/resolve.ts back to a union — the compatibility obligation
sits at the boundary that owes it.

This is strictly narrower than main, where the AST was reachable from anywhere
in src/ via src/selectors/*. Two gates hold it there: facade-symbols.ts pins
./ast to exactly the v0.20.5 list, and package-boundaries.test.ts asserts
src/sdk/selectors.ts is the only file outside the package that imports it.

Restored alongside: the ./selectors export and tsdown entry/chunk group, the
.fallowrc.json entry, the package-exports supported-subpath list, and both
client-api.md sections. No CHANGELOG entry — nothing is removed any more.

pnpm check green: 602 unit files / 5278 tests, smoke 35 passed / 3 live
skipped, layering 71/71 (10 packages, 32 subpaths), depgraph 22/22, mutation
config 45/45, fallow clean over 129 changed files, package smoke imported all
12 published entry points with publint and attw passing. Verified functionally
against the built dist: the doc's parse -> findSelectorChainMatch example
returns the same shapes as before, resolveSelectorChain still returns an AST
`selector`, and formatSelectorFailure still accepts a chain.

* fix(selectors): correct the two expectations that still assume the removal

Review P1s on a792415a: restoring the public subpath left two gates asserting
it was gone.

- installed-package-metro.test.ts moved `agent-device/selectors` into the
  blocked-specifier list. It goes back to the subpath smoke set, running the
  same `isSelectorToken('||')` + `parseSelectorChain` check it ran before the
  removal, so the file's only remaining delta from main is a formatter reflow.
- owner-files-no-leak.test.ts asserted `dist/src/sdk-selectors.js` was absent.
  It requires the stable named chunk again, and still rejects an auto-numbered
  `selectors2.js` fallback — the pair is what proves the restored tsdown chunk
  group is doing its job, verified against a clean build.

PR body corrected: the removal is no longer described as intentional API
tightening.

* refactor(selectors): satisfy the widened fallow scope after rebase

main's #1591 (the follow-up filed from this review) removed `packages/**` from
.fallowrc.json's ignorePatterns, so the new package is audited for the first
time. Everything below is a finding fallow could not previously see.

Dead surface, all confirmed consumer-free:

- 12 type re-exports from the `.` façade whose shapes consumers only ever
  reach structurally.
- MAESTRO_TEXT_SELECTOR_KEYS / MAESTRO_STATE_SELECTOR_KEYS, orphaned by this
  branch's own MAESTRO_SELECTOR_PROJECTION change, and the test-util
  SELECTOR_VALUE_HAZARDS. All three are module-local now.
- IS_PREDICATE_USAGE_HINT fails --production because its only consumer is the
  is-argument-surface parity test. It gets a commented `ignoreExports` entry
  rather than deletion: the constant is what makes the daemon and CLI raise
  ONE hint instead of two copied strings (ADR 0010), so the test asserting
  that is the point, not an accident.

`fast-check` is now declared by the package that imports it.

Duplication, split by what could be proven:

- `isUsefulVisibilityAnchor` existed character-for-character in both
  packages/selectors and packages/maestro. Moved to
  @agent-device/contracts/snapshot, which both already depend on and which
  already owns this vocabulary. Safe because the `normalizeType` each copy
  called is itself character-identical to the contracts one — checked before
  moving, since a different normalizer would have silently changed which
  nodes anchor.
- maestro additionally reimplemented `normalizeType`, `buildSnapshotNodeMap`
  (as `buildSnapshotNodeByIndex`) and `findSnapshotAncestor`, all
  character-identical to contracts'. Deleted in favour of the shared ones.
- The three scroll-ancestor walks are NOT deduped. They are structurally the
  same walk but each uses a different scrollable predicate, and I have no
  evidence the three agree; collapsing them would be a Maestro-conformance
  change, not a cleanup. Both maestro sites now say so, and the work is filed
  separately.

`projectSelectorExpression` (15 cyclomatic / 22 cognitive, written by the
cutover) splits into a dispatcher plus `readAgreedTextValue` and
`projectSelectorTerms`; all three are under threshold.

Rebase note: the one conflict, in package-boundaries.test.ts, resolved to
NEITHER side — #1591 had already deleted `AdReplayVerifiedTargetGuard` as an
unused export, and this branch deletes the seven ReplaySelectorPort names, so
the conflicting block is empty.

* build: record fast-check for packages/selectors in the lockfile

Declaring the dependency in packages/selectors/package.json without
regenerating pnpm-lock.yaml made every CI job fail in its install step with
ERR_PNPM_OUTDATED_LOCKFILE. My local `pnpm install --frozen-lockfile` printed
"+ 1 dependencies were added: fast-check@^4.9.0" and exited 0, which read as
success but was the same mismatch CI refuses.

Regenerated with the pinned pnpm 11.17.0, not the 11.5.3 on this machine:
11.5.3 rewrites peer-dependency resolution keys repo-wide (dropping
`(supports-color@7.2.0)` suffixes) and produced a 222-line diff. With the
pinned version the diff is the 4 lines this change actually needs, plus
pnpm's alphabetical re-sort of the root selectors entry.
2026-08-04 19:06:39 +02:00
Michał Pierzchała eb3fc5b28d chore: scan packages/** with fallow instead of ignoring it (#1591)
`ignorePatterns: ["packages/**"]` landed in #1494 W0 with the recorded
reason "its resolver cannot follow workspace specifiers". That was either
wrong at the time or never re-checked: the fallow version has not moved
(^2.95.0 then and now) and it resolves @agent-device/* through each
package's exports map today. packages/kernel alone exposes 8 subpaths and
~110 exports reachable only via workspace specifiers, and scanning it
reports zero findings — a resolver that could not follow the specifier
would report all of them.

The cost of the ignore is that every package extraction silently removes
its code from dead-code analysis. #1589 moved the selector engine into
packages/selectors/ and shipped a façade with 15 zero-consumer exports,
including `selectorUsesKey`, written in that PR and never called. A
follow-up commit removed them by hand; nothing would have caught them.

Removing the pattern surfaced 43 findings, driven to zero by deleting the
dead code rather than by baselining or excluding it (fallow-baselines/*.json
are empty on purpose — the posture is fix-or-document-the-exemption, so a
first baseline entry would be a policy change):

- 38 are deleted. 24 façade type re-exports whose only claim was that a
  consumer might one day want to name them — typecheck is green without
  every one, so the claim was theoretical; 5 façade value re-exports; 9
  `export` keywords on symbols used only inside their own file. Every
  deleted façade symbol comes off scripts/layering/facade-symbols.ts (and
  ad-replay's inline pin in package-boundaries.test.ts) in the same change,
  so R11 is narrowed with the façade, never weakened around it.
- 4 stale suppressions in src/provider-limrun-runtime.ts existed only
  because packages/ was invisible.
- 5 have consumers analysis genuinely cannot see, and get an
  `ignoreExports` entry naming the consumer per the existing `comment`
  convention: four test-tree importers that --production does not walk, and
  `LimrunIosCommandExecution`, which src/sdk/limrun.ts republishes as
  agent-device/limrun — its only importer compiles in a temp checkout, so
  no static edge reaches it. test/integration/limrun-public-types.test.ts
  is the standing proof that one is real API.

Three doc comments named types their façade no longer exports and are
corrected rather than left asserting something false — including #1555's
claim in session-replay-target-verification.ts that the daemon imports
`AdReplayVerifiedTargetGuard` directly. It does not; it reaches that shape
through `AdReplayTargetClassification`/`AdReplayDispatchGuard`, which is
why the name read as dead.

`scripts/maestro-conformance/**` was ignored wholesale to cover its corpus
data. Narrowed to `corpus/**`, which un-hides the tooling beside it and
turned up one more file-local export (`buildManifest`); regenerate.mjs's
importer of `fixtureContentHash` becomes visible, so that needs no
exemption at all.

scripts/check-affected/model.ts deliberately did not select the `fallow`
check for packages/*/src/**, carrying the same stale rationale as a
comment. Without that selection the new scope would never run in the
affected-driven lane, so the ignore removal would have bought nothing.
model.test.ts now pins the selection.

Verified: check:fallow and check:production-exports green with packages in
scope; full-repo `fallow dead-code` back to its one pre-existing finding;
typecheck, layering (R11), lint, format, build, check:package, and the
limrun published-types integration test all pass. Probed by adding a fresh
zero-consumer export to the xml façade — check:production-exports reports
it, so the #1589 case now fails the gate.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:26:03 +02:00
Michał Pierzchała 8d526f2402 perf(ios): halve hostile-screen capture cost under the XCTest-channel penalty (#1587)
* perf(ios): stop re-paying known-failing work on penalized private-AX captures

Live-measured on the Bluesky bench feed (139-171 nodes), each private-AX
capture wasted ~1.35s of its ~1.65s runner-side cost re-doing work a prior
capture already proved futile:

- ~1000ms: the viewport read is XCTest main-thread work; under the channel
  penalty it reliably burns its full timeout and falls back to the root
  frame anyway. Honor the penalty in privateAXSnapshotViewport the same way
  capture plans do.
- ~310ms: the depth ladder re-paid the kAXErrorIllegalArgument rejection of
  the default depth on every capture. Remember the accepted rung per bundle
  (penalty-duration TTL, cleared on target process change); expiry re-probes
  the full depth so screens that recover are not capped forever. Explicit
  --depth requests bypass the memory in both directions.

Steady-state hostile-screen captures drop 2.65s -> ~0.55s CLI wall, and
press --settle round-trips drop ~5.4s -> ~2s (settle needs two captures).

* fix(snapshot): distinguish penalty-deferred captures from genuine recoveries

A capture whose backend was PRE-selected by the XCTest-channel penalty was
stamped with the same recovered verdict as one that ground through a live
failure. Two costs followed on hostile screens (Bluesky bench: 130 repeats
per 30-task run):

- the daemon repeated the full fell-back warning on every capture, long
  after the arming capture had already said it once, and
- settle's one-shot private-AX budget reset fired on every loop even though
  the capture paid no grind to give the budget back for.

The deferred plan now stamps reasonCode 'deferred' (a new code; older
daemons drop unknown codes and keep today's behavior). The daemon keeps the
verdict recovered but suppresses the repeated warning, keeps the depth-cap
line, and skips the settle budget reset for deferred captures.

* style: wrap reasonCode union to satisfy oxfmt

* fix(ios): bind accepted-depth memory to the process, pin deferred through the wire parser

Review follow-up (#1587):

- The depth memory was cleared only in refreshCachedTargetIfProcessChanged;
  resetTargetAfterExternalRelaunch -> invalidateCachedTarget drops the cached
  PID without clearing it, so the next activation had no old PID to compare
  and could reuse a stale shallow rung for up to 120s (also A->B->A when A
  restarted while inactive). The memory now stores the PID it was learned
  under and only matches the same live process; recording without a PID is
  refused. Every invalidation path is covered automatically because they all
  drop currentAppProcessIdentifier.

- The deferred settle/warning tests constructed typed verdicts directly, so
  removing 'deferred' from the accepted reason-code set would silently
  restore the repeated warning and budget reset while tests stayed green.
  They now parse a raw runner-wire object through readSnapshotQualityVerdict
  (red on base: parser strips the code -> warning re-appears, reset fires).

- Extracted shouldReadPrivateAXViewportViaXCTest() and pinned the penalized
  viewport skip with an in-bundle regression test.
2026-08-04 17:12:30 +02:00