41 Commits

Author SHA1 Message Date
Michał Pierzchała 4f5a87b6b3 refactor(command-registry): move CLI flag grammar, text and command aliases down (#2561)
Move the vocabulary that both the CLI and commands read but no command's runtime
depends on into the package below both: flag types, registry, groups and the four
flag-definitions files, command-text, and cli-command-aliases. These are pure moves;
only their import specifiers change.

No compat re-export at the old paths — every consumer switches to the owning
subpath. The per-command defaults stay where they are for now (the daemon's edge into
the facet resolver is the harder cut and belongs with the daemon-closure work).

Part of #2545 / #2543.
2026-09-14 11:27:22 +02:00
Michał Pierzchała 6d08de4609 feat(scroll): find off-screen targets in one command with --until (#2436)
* refactor(interaction): extract the scroll command runtime out of gestures.ts

* feat(scroll): add --until <selector>, report honored travel, fix web amount units

* test(scroll): cover --until through the provider-backed integration path

* perf(selectors): keep the scroll-until predicate off the eager import path

* fix(scroll): refuse an unreadable capture instead of reporting end-of-content

* fix(selectors): keep the capture-readability check off the eager import path

* test(selectors): use a declared snapshot quality state in the capture fixtures

* fix(scroll): read the capture quality verdict under the spelling the backend uses

* refactor(scroll): collapse --until onto the one route that runs it

* refactor(scroll): drop unexported until types and duplicated guidance prose

* test(scroll): fix the climbing fixture and drop duplicated route-level cases

* refactor(scroll): delete the dead command-runtime executor and reuse canonical predicates

* refactor(interaction): keep requireResolvedPoint local to the gesture runtime
2026-09-10 17:13:26 +02:00
Michał Pierzchała 2ec4e91b11 refactor(core): move the command descriptor registry into its own workspace package (#2348)
* refactor(core): move the command descriptor registry into its own package

`src/core/command-descriptor/`, `src/command-catalog.ts`, `src/core/wait-positionals.ts`
and `src/core/parse-timeout.ts` move as git renames into a new private package
`@agent-device/command-registry` (deps: contracts, selectors). One subpath per module
points straight at the moved file; no `index.ts`, no re-export at the old path. Every
consumer switches to the owning specifier.

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

* test(host-kit): pin the command-registry package inside the daemon code graph

The daemon reaches the registry and its catalog only by workspace specifier. A walk
that stopped at the package boundary would report an unchanged signature after a
descriptor edit, and the client would keep reusing a daemon running the superseded
policy. The manifest is asserted beside the sources because its `exports` map is what
chose them. The cache doc comment quoting the old ~800-module graph is corrected.

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

* chore(gates): point the descriptor-registry gates at the package path

R66's `COMMAND_DESCRIPTOR_MODULE`, R16's record-runtime join subject and the Fallow
`AssertTrue` totality-guard key follow the registry to its package. The two descriptor
hubs leave `HUB_ENTRY_FILES` because the package manifest now publishes them, so the
eager-closure gate discovers them as facades and one entry gets one rule; this also
flips `denyPlatformImplementations` from false (hub) to true (package entry) for both,
which is intentional and stricter. `command-registry` joins the ranked spine at rank 1.

No `APPROVED_OVER_CEILING` row: rename detection carries every moved entry's merge-base
baseline, so all twelve fall under the no-growth rule rather than a ceiling.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 12:55:14 +02:00
Michał Pierzchała 4b7c561d1e refactor(commands): author each executable command once in its facet (#2313)
* refactor(commands): let the facet derive its executable definition

* refactor(commands): author interaction commands once

* refactor(commands): author system commands once

* refactor(commands): author capture commands once

* refactor(commands): author management commands once

* refactor(commands): author observability commands once

* refactor(commands): author recording commands once

* refactor(commands): author replay commands once

* refactor(commands): author debugging commands once

* refactor(commands): author react-native commands once

* refactor(commands): author metro commands once

* refactor(commands): author batch commands once

* refactor(commands): author perf commands once

* refactor(commands): state the facet type docs in one line each

* chore(gates): scan facets, not executables, for provider coverage

The integration-progress scanner mapped client calls to command names by
finding defineExecutableCommand blocks. Those are gone; a facet's run
callback now holds the call, and its name may be a module constant.
2026-09-05 23:12:41 +02:00
Michał Pierzchała 172ee149cf feat(screenshot): add --crop-on to crop captures to a selector frame (#2276)
* feat(screenshot): add crop-on geometry core and cropTarget selector rows

* feat(screenshot): declare crop-on flag, script round-trip, and snapshot runtime plan

* feat(screenshot): run the crop leaf after the platform write and before scale

* feat(screenshot): expose --crop-on in the CLI and surface crop warnings

* chore(gates): declare crop-on capture-kit subpaths and scope the crop scenario exemption

* refactor(screenshot): split crop target/policy module and trim redundant coverage

Address review comments at 570da2c417:
- Split the 328-line screenshot-crop.ts leaf: the target acceptance matrix,
  classifier, and pre-device argument policy move to screenshot-crop-target.ts,
  so both implementation modules meet the 300-line target.
- Reuse kernel isPositiveFiniteRect/rectArea in the rect-projection module
  instead of redefining them locally.
- Drop the crop-on CLI forwarding case (redundant with screenshot-options
  flag-mapping coverage + the generic dispatcher) and the transport-based
  warnings case, replacing the latter with a focused screenshot-result unit
  test. This also returns the two legacy aggregate test files to their
  merge-base length for the test-file size ratchet.

* refactor(screenshot): extract macOS crop-target decision to keep classifier under the complexity budget

classifyAppleCropTarget inlined the macOS surface decision, pushing its
cyclomatic complexity to the fallow threshold. Move it back out to a
small helper so the target classifier stays within budget.

* refactor(screenshot): dedupe the meaningful-signal predicate and polish png-crop

- Hoist isMeaningfulSignal into @agent-device/contracts/snapshot (next to
  normalizeType/isMeaningfulLabel) so the ref overlay and the crop
  rect-projection share one copy instead of each carrying an identical
  private predicate. Behavior is unchanged.
- png-crop: isCropBox was a no-op 'box is Rect' predicate (input already
  Rect) — make it a plain boolean, and tighten the doc to the contract.

* refactor(screenshot): drop the dead crop outcome flag and cover the projection seams

- ScreenshotCropOutcome.cropped was a constant true that no caller read;
  the crop either returns (success) or throws, so the outcome reduces to
  the partialIntersection observation.
- resolveScreenshotRectSpace and resolveSnapshotBounds were the only
  projection exports without coverage: pin the accepted-backend map, the
  unaccepted-backend typed refusal, and the viewport-root / union / empty
  bounds branches.
2026-09-04 11:18:57 +02:00
Michał Pierzchała 010f09bf0d refactor(daemon): move open lifecycle behind session facade (#2201) 2026-08-31 21:18:58 +02:00
Michał Pierzchała af6f12e391 chore: adopt shared oxlint config (#2115)
* chore: adopt shared oxlint config

* fix: preserve project lint boundaries

* fix: remove redundant oxlint config
2026-08-28 11:42:58 +02:00
Michał Pierzchała 04c33f9e1e feat(ios): expose AX custom actions on merged accessibility elements (#1665)
* feat(ios): expose AX custom actions on merged accessibility elements

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

`snapshot --actions` now names them:

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

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

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

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

Two gaps in the first cut.

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

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

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

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

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

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

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

Four review findings.

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

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

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

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

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

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

Since the call cannot be cancelled, contain it instead:

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

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

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

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

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

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

  RED   in-flight 6 (want 1), dispatches 6 (want 1), each repeat paid the full
        1.004s deadline (want <0.2s), blocked=false (want true), and the
        in-flight drain never completed — "Exceeded timeout of 5 seconds".
  GREEN 7/7 pass, containment regression in 1.02s.
2026-08-07 17:35:48 +02:00
Michał Pierzchała cc943400a9 feat(daemon): [RFC] prototype foreground-attach convenience (#1670)
Prototype `open --foreground`: on a fresh session with no app argument,
auto-resolves the target from the sole booted iOS simulator's sole
foreground app (reusing the exact same ambiguity-detection probe that
enriches the SESSION_NOT_FOUND hint), then attaches the initial
interactive snapshot to the response by composing the existing
snapshot-runtime dispatch. Collapses the documented 3-call
snapshot-fails -> read-hint -> open -> snapshot-succeeds dance into a
single call for the unambiguous case, while failing closed
(AMBIGUOUS_MATCH) with no guessing otherwise.

First-pass RFC, not reviewed — see PR body for the design tradeoff
writeup, live before/after evidence, and scoped-out follow-ups.
2026-08-07 13:17:13 +02:00
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 2c2df031ff feat: keep replay session active on request (#1554)
* feat: keep replay session active on request

* test: cover replay keep-session provider route

* fix: make replay session handoff reliable

* refactor(daemon): extract the replay terminal-lifecycle policy module (#1554 review)

session-replay-runtime.ts was already over the 500-line extract-before-adding-behavior
tripwire before this PR; the keep-session/repair terminal-close decision, its
live-session postcondition, and the dispatched-action count pushed it further past
budget. Move that policy into a focused session-replay-terminal-lifecycle.ts
(isExecutableReplayAction, resolveSuppressedTerminalCloseIndex,
countExecutedReplayActions, requireLiveSessionForKeepSession) so the runtime file
stays orchestration-only, and mirror its PR-added unit tests into
session-replay-terminal-lifecycle.test.ts. Pure extraction: no assertions changed.
2026-08-02 18:35:13 +02:00
Karthik Varma 92b22229e6 feat(cloud-webdriver): BrowserStack device-feature capabilities, and fix cloud orientation (#1544)
* feat(cloud-webdriver): support BrowserStack device-feature capabilities

Adds the eight BrowserStack "device feature" session capabilities that had no
representation in agent-device: deviceOrientation, geoLocation, timezone,
language, locale, networkProfile, customNetwork, and resignApp.

These are vendor capabilities, so they are emitted inside `bstack:options`
rather than at the top level. BrowserStack's YAML config lists them unnested
and its SDK relocates them; agent-device talks to the hub directly, so it
nests them itself.

A single spec table drives both the flag reader and the capability builder, so
adding a capability is a table row rather than a branch in each. A structural
test asserts every field owns exactly one row, since a field the table forgets
would parse off the CLI, ride the profile, and then be silently dropped before
the hub ever saw it.

Rejects combinations the provider cannot act on unambiguously: an unknown
orientation is caught at the flag boundary instead of being forwarded to a hub
that accepts and then ignores it, --provider-no-resign-app is refused on
Android, and a named network profile cannot be combined with a custom network
shape.

Also fixes a latent shallow-merge bug in buildBrowserStackCapabilities: a
caller supplying its own `bstack:options` replaced the whole object and
silently dropped the project, build, and session labels. It is now merged
per key.

* fix(cloud-webdriver): rotate via WebDriver orientation endpoints

`setOrientation` on the cloud WebDriver path sent `mobile: rotate`, which is
not a driver command at all. UiAutomator2's own error enumerates its
extensions and `rotate` is absent from the list, so `agent-device orientation`
was a hard failure on every hosted provider.

It also forwarded agent-device's four-way rotation vocabulary verbatim
("landscape-left", "portrait-upside-down"), where the protocol accepts only
uppercase PORTRAIT/LANDSCAPE. Every other platform has a translation layer;
this path was the only one without one.

Now two transports, ordered by backend. `POST /rotation` takes exact four-way
degrees and leads on Android, since it is the only endpoint that can express
upside-down and left-versus-right. `POST /orientation` is two-way and leads on
XCUITest, which rejects `/rotation`. Each falls back to the other, because only
BrowserStack's UiAutomator2 is verified and a provider whose driver disagrees
should degrade rather than hard-fail.

Verified live against BrowserStack App Automate:
  POST /rotation {"x":0,"y":0,"z":0} -> 200 {"value":"ROTATION_0"}

The rotation-to-surface-index mapping moves to contracts/device-rotation.ts and
the existing adb path now reads from it, so the local and hosted mappings
cannot drift apart.

Note this rotates the current display, not persistent device rotation, so an
activity that does not pin its own orientation may still need rotating once it
is in the foreground.

The capability was declared "partial" without the transport existing, and no
test covered setOrientation on the cloud path; only adb and the Apple runner
were covered. Both gaps are now closed.

* fix(cloud-webdriver): narrow orientation fallback and gate provider-owned flags

Addresses review on #1544.

The orientation fallback caught every error, so a timeout, an auth rejection, a
dead session or a provider 5xx on the first transport was swallowed and retried
against the second. When that one also failed the caller got "rejected both
endpoints" with the real cause discarded. Fallback is now keyed on structured
unsupported-endpoint signals only — HTTP 404/405, or a W3C `unknown command` /
`unknown method` code — matching the repo rule of keying on typed details rather
than message text. Everything else rethrows unchanged.

Device-feature capabilities are BrowserStack-owned, but the flags were accepted
by any cloud provider, persisted into the generated profile, and then silently
dropped at session creation. `connect aws-device-farm` now rejects them with a
typed error naming each offending flag, raised before the provider's own
required-argument checks so the caller is told what is unsupported rather than
what else is missing. Ownership is modelled on the capability spec table, so a
new capability inherits the guard without a second list to maintain.

Adds provider-backed orientation scenarios driven through public daemon dispatch
against the fake WebDriver provider: the four-way endpoint on the happy path,
the documented collapse onto the two-way endpoint when the driver does not
implement `/rotation`, and a provider 5xx that must surface without consulting
the second transport. The fake server's route handling became a table in the
process — it had grown to ten branches in one function.

* fix(cloud-webdriver): read W3C error codes before status, enforce ownership at the runtime boundary

Addresses the second review pass on #1544.

The fallback classifier returned on any 404/405 before consulting the W3C error
code, so an HTTP 404 carrying `invalid session id` was masked as a missing route
and retried against the second transport. The structured code now takes
precedence whenever the driver sent one; bare status is consulted only when no
code exists. Two cases pin it: a 404 `invalid session id` and a 405 `timeout`
must both surface rather than fall through.

Provider ownership was enforced only in the CLI profile builder, which the typed
client and hand-authored remote-config profiles bypass entirely — both reach
session preparation without passing through `connect`, so the capabilities were
accepted and then dropped. The check now lives on the capability-ownership
module and runs inside AWS Device Farm's `prepareSession`, with the CLI builder
calling the same helper instead of its own copy. Covered by a scenario that
drives the runtime boundary directly and asserts the rejection happens before
any provider session is created.
2026-08-02 08:17:49 +02:00
Michał Pierzchała 877e68fe30 fix(cli): compact stale device status (#1388)
* fix(cli): compact stale device status

* fix(cli): quote stale status selectors
2026-07-25 11:44:22 +02:00
Michał Pierzchała 5507a08b9c feat: parameterize sensitive recorded inputs (#1369)
* feat: parameterize recorded inputs

* fix: harden parameterized replay recording

* fix: sanitize parameterized fill echoes

* fix: scrub embedded parameterized fill echoes

* fix: make recorded fill scrubbing idempotent

* fix: replay parameterized coordinate fills

* test: align parameterized publication landmark
2026-07-24 20:37:29 +02:00
Michał Pierzchała d237bc555d chore: remove verified dead code and migration scaffolding (~700 LOC) (#1367)
* chore: remove verified dead code and migration scaffolding

Multi-agent audit of accumulated waste, every finding adversarially
verified against call sites, git history, and the published surface
before removal. Net -710 lines.

- delete src/core/platform-descriptor/ (superseded ADR-0009 migration
  scaffold; parity tests now assert an inline table)
- remove test-only seams: registry introspection exports,
  CommandFacet.extraDaemonWriters, MaestroEngineOptions.timing
- remove dead flexibility: backend capability allow-list,
  screenshot-diff maxRegions, CloudWebDriverSupportLevel 'partial',
  clearFirst on the TS+Swift runner wire contract
- remove dead deprecated surface: --session-locked /
  --session-lock-conflicts aliases (hard migration error now points at
  --session-lock), replay export --format single-value enum,
  unused Lease*Payload contract types, runtime-layer rotate duplicate
- collapse pass-throughs/duplication: withRetry adapter,
  default-cloud-artifact-provider, connect-profile client-id hashing
  (3x sha256 impls -> one helper, byte-identical output), shared
  scripts walker, cloneValue -> structuredClone, fill-diagnostics
  moved into android/

BREAKING CHANGE: --session-locked and --session-lock-conflicts now fail
with a migration error pointing at --session-lock; replay export
--format is removed (Maestro was the only value); Lease*Payload types
are dropped from the ./contracts subpath.

* chore: satisfy fallow gates tightened by #1363/#1364 after rebase

- drop the consumer-less AndroidFillVerificationNode re-export
- reuse requireSnapshotSession in resolveSnapshotForRef instead of
  inlining the same authorized-frame resolution (fallow clone group);
  the helper's return type now guarantees the session it already
  throws for

* chore: address review — keep cloud-webdriver partial capability metadata

The partial/supported/unsupported levels and their notes are part of the
lease-response capability contract for genuinely limited operations
(Appium page-source snapshots, upload-then-install), not dead
scaffolding. Restore them and the asserting tests unchanged from main.

Also add the missing CHANGELOG entry for the Lease*Payload type removal
from agent-device/contracts.
2026-07-23 07:36:52 +02:00
Michał Pierzchała d0227998d4 feat: add daemon stop lifecycle (#1323)
* feat: add daemon stop lifecycle

* fix: harden daemon stop cleanup

* fix: fail closed daemon stop cleanup

* fix: bound daemon shutdown lease releases

* fix: await active shutdown lease release

* fix: release provider leases independently on shutdown
2026-07-17 18:21:06 +02:00
Michał Pierzchała dd153a6233 fix(replay): default-exclude observation-only reads from repair heals, add --record opt-in (#1271 stage 2) (#1303)
* fix(replay): default-exclude observation-only reads from repair heals, add --record opt-in (#1271 stage 2)

Amends ADR 0012 decision 6: snapshot/get/is/a read-only find are excluded
from a repair-armed heal by default (session.saveScriptBoundary set), never
from ordinary open --save-script authoring recording. wait keeps recording
(flow timing, not observation).

The corrective-read trap (wave-3 E3: the diverged step was itself a get)
means blanket read-exclusion is unsafe, so a new --record flag forces one
action through when the correction is itself a read. --record/--no-record
are mutually exclusive (INVALID_ARGS if both are set) and are plumbed
identically across CLI, the Node client, and MCP.

The exclusion lives at the single daemon-side choke point
(recordActionEntry/isExcludedRepairSegmentObservation), so an excluded read
never grows session.actions.length -- the same counter the existing
record-and-heal resume watermark (describeUnperformedRecordAndHeal) already
checks, so the empty-segment fail-loud guard falls out for free (message
updated to mention --record).

Also fixes a latent bug found along the way: the get/is/find/snapshot CLI
readers never forwarded --no-record/--record into the built request (only
`open` did), so stage 1's "use --no-record" guidance was silently inert via
the CLI.

* test(integration): cover --record with a provider-backed repair-segment scenario (#1271 stage 2)

The progress ratchet (test:integration:progress:check) flagged `record` as an
unclassified public CLI flag. Classifying alone would only trade that failure
for "missing Provider-backed integration workflow flag coverage" -- and the
exclusions bucket is for config/output/transport flags, not behavior flags, so
using it would dodge the ratchet rather than satisfy it.

Adds a focused provider-backed scenario instead, next to the `--no-record`
precedent in android-lifecycle.test.ts. It drives the real request router,
session store, replay runtime, and script writer (only the ADB provider is
faked), and proves the flag's actual purpose end-to-end: inside a repair-armed
`replay --save-script` segment that diverged, the SAME `get text <selector>`
runs twice differing only in `--record`; exactly one line lands in the
committed healed .ad. Also asserts `--record` + `--no-record` is INVALID_ARGS.

Verified the scenario reproduces the bug: with the exclusion neutered it fails
on "a diagnostic read inside a repair segment must not be recorded".

* fix(replay): key the repair-segment exclusion on provenance, scope --record (#1271 review)

Addresses the maintainer review on #1303.

P1 — the exclusion dropped PLANNED reads from the heal. It discriminated by
command class, but the real discriminator is provenance. Replayed plan steps
dispatch through the ordinary request path, so an authored get/is/find step hit
the same recordIfSession -> exclusion path as an interactive read and never
reached session.actions -- and the heal IS session.actions.slice(boundary). A
repaired flow therefore replayed its authored `is visible` assertion and then
silently dropped it from its own healed script: the heal quietly stops checking
what it used to check, which for a 10x-QA-replay suite is the worst failure
mode.

Fix: an explicit provenance marker, not a heuristic. `internal.replayPlanStep`
is stamped by invokeResolvedReplayAction -- the single point every plan step is
dispatched, so it covers annotated and unannotated steps alike. `internal` is
daemon-only (toDaemonRequest never copies it off the wire), so authored
provenance cannot be spoofed; same channel as replayTargetGuard. The rule now
lives once in isInteractiveObservation and both recording call sites consume it,
so the mock fixture uses the production classifier instead of mirroring it.
Planned observations survive automatically -- users never annotate their own .ad
steps.

--record is no longer a common flag: removed from
COMMON_COMMAND_SUPPORTED_FLAG_KEYS, statically scoped via allowedFlags to
snapshot/get/is, and validated dynamically for find (read-only allows; a
mutating find click|fill|focus|type is INVALID_ARGS before any device work,
sharing one isReadOnlyFindAction predicate with the read-only routing so the two
cannot disagree). --no-record stays shared -- it applies to every recordable
command. Removed from `open`, which is never observation-only.

Rebased onto #1304 and dropped the four hand-rolled reader blocks. Split its
helper rather than broadening it: noRecordInputFromFlags (all 13 readers) +
observationRecordInputFromFlags (snapshot/get/is/find only). Two named helpers
over one `allowRecord` policy arg -- the capability is then the helper's NAME, so
a mutating reader physically cannot forward --record, whereas a policy arg would
let a future mutating reader opt in by flipping a literal with no schema change.

ADR-0012 decision 6 now states the provenance rule, not a command-class rule.

The scenario gates the P1: its authored step is a distinguishable `is visible`,
and it fails without the provenance check ("the authored 'is visible' step must
survive the heal").

* test(daemon): pin that wire-supplied `internal` never reaches a daemon request

#1271 stage 2 made `DaemonRequest.internal` semantics-affecting:
`internal.replayPlanStep` decides whether an observation-only command is an
authored plan step (kept in a repair heal) or an out-of-band diagnostic
(excluded). That makes "internal means internally-stamped" worth pinning
rather than leaving to convention.

The invariant already holds, structurally and twice over: the boundary's
`commandRpcParamsSchema` is an allowlist projection emitting only its eight
named fields, and `toDaemonRequest` then builds the request field by field.
Neither can carry `internal` off the wire.

This posts a real JSON-RPC request carrying
`internal: { replayPlanStep: true }` through a loopback server and asserts the
dispatched request has no `internal`. Verified it fails
("a wire-supplied `internal` must never reach the daemon request") when both
allowlists are regressed, so it guards the composite contract instead of
restating one layer.
2026-07-16 20:31:05 +02:00
Michał Pierzchała f474f0784e feat: unify gesture planning and multi-touch execution (#1212)
* feat: unify gesture planning and multi-touch execution

* fix: correct unified gesture helper behavior

* refactor: tighten unified gesture architecture

* fix: preserve gesture routing contracts

* test: account for fresh gesture viewport

* refactor: remove retired gesture series

* fix: preserve example app navigation targets

* test: reconcile unified gestures with helper ownership

* docs: update Android helper gesture protocol

* fix: refresh Maestro percentage swipe frames

* refactor: remove stale Maestro frame cache

* fix: harden unified gesture execution

* fix: model gesture viewport in providers

* refactor: remove legacy gesture paths

* fix: remove unused swipe preset parser

* refactor: tighten unified gesture boundaries

* fix: close gesture review gaps

* fix: preserve gesture compatibility contracts

* fix: preserve multi-touch recording semantics

* fix: refresh Apple runner state after app relaunch

* test: lock Apple fling fallback route

* fix: close Apple runner review gaps

* refactor: tighten unified gesture seams

* refactor: consolidate gesture planning policy

* fix: preserve swipe response compatibility

* fix: keep gesture lab aligned with replay coordinates
2026-07-13 13:16:38 +02:00
Michał Pierzchała e2bfed5f9f feat(replay): ADR 0012 migration steps 5+6 — resume + --update retirement (#1211)
* feat(replay): ADR 0012 migration steps 5+6 — resume + --update retirement

Step 5 (decision 4, resume): replay --from <n> --plan-digest <sha256>
resumes at a 1-based plan step, skipping 1..n-1 without executing them.
Every divergence report now carries a real resume object (allowed, from,
planDigest, reason?) computed by a preflight that rejects INVALID_ARGS
before any action when: the plan digest no longer matches the current
script (edits/includes/platform-conditioned expansion), --from is out of
range, a skipped step can produce outputEnv values, or the skipped range
or resume target is runtime control flow (retry/runFlow.when — these are
single plan entries, never individually addressable). `test` rejects
--from/--plan-digest both at the CLI-schema layer and at the daemon
dispatch layer (the original command name is only visible before test
rewrites its nested request to `command: 'replay'`).

New modules: src/replay/plan-digest.ts (canonical SHA-256 plan digest)
and src/daemon/handlers/session-replay-resume.ts (preflight + the
report's resume object), kept out of src/replay/ to avoid a
replay<->compat import cycle.

Step 6 (decision 1, retirement): --update/-u no longer rewrites .ad
files. The ADR mandates a no-op, not an error or flag removal: --update
now runs identically to a plain replay and returns the same bounded
suggestions every divergence already carries. Removed: healReplayAction's
retry-and-rewrite arm and its exclusive helpers (collectReplaySelectorCandidates
stays — decision 1's suggestions still use it), the write call from the
runtime loop, and the env/${VAR}-interpolation/compat-flow refusal guards
that existed only to protect that rewrite. writeReplayScript itself keeps
its own round-trip tests but is otherwise unused now; deleted after the
production-exports gate flagged it as dead.

Docs: cli-help.ts workflow topic + --update/--from flag help, AGENTS.md
selector pipeline note, maestro-compat-debt-map.md, website replay-e2e.md
and commands.md updated for the retired rewrite and the new resume loop.

* fix(ci): classify resume flags + provider-scenario resume coverage

The Integration Tests job's architecture-progress gate
(test:integration:progress:check) requires every public CLI flag to be
classified; --from/--plan-digest (replayFrom/replayPlanDigest) were
unclassified. Classify them as device-observable workflow flags and add
real provider-backed coverage to the Android lifecycle scenario: a full
replay diverges on a missing selector, the report's resume object is
asserted (allowed/from/planDigest), and resuming at the next index
replays only the tail. Also refresh the stale replayUpdate reason
("selector-healing replay update" -> the retired no-op).

* fix: bind replay resume digest to execution plan

* test: align replay runtime module topology

* fix: clear replay CI regressions

* docs: clarify replay repair and resume paths

* docs: clarify replay resume step semantics

* docs(replay): note that ${VAR} values stay out of the plan digest (ADR 0012 + workflow help)

Settled decision from the PR #1211 re-review (maintainer-approved): interpolated
${VAR}/--env/AD_VAR_* VALUES are deliberately NOT part of the resume plan digest.
Substitution happens after the digest is computed over the still-unsubstituted
${VAR} text, so re-running the same script with different variable values keeps
the same digest and stays resumable — supplying the right values on resume is the
caller's responsibility. The digest still binds the script/includes, the effective
--platform/--target, and per-action runtime hints + target-v1 identity. Documented
in ADR 0012 decision 4 and the `help workflow` resume topic.

* docs: clarify replay digest interpolation
2026-07-11 15:06:49 +02:00
devin-ai-integration[bot] 0a8ea3a57b refactor: consolidate architecture ownership and client results (#1210)
* refactor: consolidate architecture ownership and client results

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: keep selector parse chunk grouping current

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: update moved architecture breadcrumbs

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: enforce moved selector architecture

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: keep selector guarantee ownership current

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs: update selector ownership references

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-11 09:40:24 +02:00
Michał Pierzchała d4146c7f1b feat: add Android test IME helper for deterministic text entry (#1198) (#1201)
* feat: add Android test IME helper for deterministic text entry (#1198)

Ships a headless InputMethodService (android-ime-helper) as a third Android
helper APK, replacing the visible system keyboard during automated sessions.
Renders zero accessibility nodes and accepts Unicode/CJK/emoji text over a
base64-encoded broadcast channel, fixing both the settle-diff IME-chrome
flood and the ASCII-only adb-shell text entry limit in one structural fix.

- android-ime-helper/: InputMethodService + build/package scripts on the
  existing helper-APK toolchain (javac+d8+aapt2+zipalign+apksigner).
- src/platforms/android/ime-helper.ts, ime-lifecycle.ts: install/version
  lifecycle (shared with the other two helpers via the new
  helper-package-install.ts), activation on session open, and on-device
  restore-hygiene (previous IME persisted to a device settings key so any
  daemon/state-dir can recover it; restored on close, daemon teardown, and
  daemon startup for orphans left by a crashed run).
- input-actions.ts: fill/type route through the helper's broadcast channel
  when active, unicode-safe; unchanged ASCII-shell fallback otherwise.
- doctor: new android-test-ime check flags a stuck helper IME with a
  copy-pasteable `adb shell ime set` remediation command.
- Gating: default-on for emulators, opt-in via `open --test-ime` on real
  devices.
- Dead-weight: rewrote the manual ADBKeyBoard workaround doc, dropped the
  now-provably-live skillgym non-ASCII eval case, updated the ASCII
  fallback's error message to point at the helper instead of dead-ending.

🤖 Generated with Claude Code

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

* fix(#1201 review): permission-gate the IME receiver, fix CI, add opt-out

Addresses the independent review's blockers and should-fixes.

SECURITY (blocker 1): the text-injection receiver was RECEIVER_EXPORTED with
no gate — any co-installed app could inject text into the focused field while
the test IME was active. Fixed by requiring the WRITE_SECURE_SETTINGS sender
permission on the (in-process, dynamically-registered) receiver: adb shell
holds it, third-party apps cannot. The reviewer's suggested exported=false +
explicit-component approach was tried first but empirically breaks delivery on
API 36 (adb shell cannot reach a non-exported receiver there) — documented in
the helper README. Live-verified: a purpose-built rogue APK's broadcasts
(implicit and package-scoped, no permission) are silently dropped, field
unchanged; adb shell's bare broadcast still injects. Added
ime-helper-security.test.ts asserting the permission gate and that no
permissionless exported registration returns.

CI (blocker 2): (a) added `testIme` to integration-progress-model flag buckets
(Integration Tests was red on the unclassified flag). (b) mocked
resolveAndroidImeHelperArtifact in session-doctor-android / ime-lifecycle /
input-actions-test-ime tests so they no longer depend on android-ime-helper/dist
existing on disk (Coverage was red on a fresh checkout); verified by running
them with dist removed.

Should-fixes: added `--no-test-ime` to opt out on emulators (tri-state gating,
parser-tested); PR body's "byte-identical" claim corrected to size/CRC-match.

🤖 Generated with Claude Code

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

* docs(#1201): pin the API-36 exported-receiver constraint in a comment

The RECEIVER_EXPORTED flag cannot express why it must stay exported. Add a
one-line note so a future hardening pass doesn't switch to RECEIVER_NOT_EXPORTED
and silently break the CLI (adb shell can't deliver explicit broadcasts to
non-exported components on API 36+; WRITE_SECURE_SETTINGS is the actual gate).

🤖 Generated with Claude Code

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

* fix(#1201 review): harden IME restore lifecycle (blockers 1 & 2)

Blocker 1 — a failed restore no longer deletes the recovery value. restore now
reads back default_input_method after `ime set` and only clears the persisted
previous-IME record on a confirmed-successful restore; a failed set keeps the
value so a later retry / startup recovery / doctor remediation can still
un-strand the user off the helper IME.

Blocker 2 — startup orphan-recovery no longer overwrites/races user state.
It only restores when the device's CURRENT default IME is still our helper
(so a user who legitimately switched away is left alone), and skips any device
a live session in this process owns (the fire-and-forget startup vs. concurrent
`open` race — activate now marks the device active BEFORE the `ime set`, so any
recovery pass that could observe the helper active also observes the flag and
skips). Never persists the helper itself as the previous IME. activate also
verifies its own switch via read-back.

Exported ANDROID_IME_HELPER_SERVICE_COMPONENT so restore compares the active IME
without reading the packaged artifact from disk. Tests: failed-restore keeps the
value (+ later recovery succeeds), startup no-op when current != helper, startup
skips a live-owned device.

🤖 Generated with Claude Code

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

* chore(#1201): delete unused ACTION_ENTER path, baseline test-only export seams

Rebased onto main (#1202 production-unused-exports gate). Two follow-ups:

- Deleted the unused ACTION_ENTER broadcast end-to-end (TS sendAndroidImeHelperEnter
  + its test, Java handler, README): nothing routes through it — `keyboard enter`
  uses the keyevent ENTER path — so the new production-exports gate flagged it as
  dead production code. Removed rather than grandfathered.
- Added the three legitimate test-only seams (resetAndroidImeHelperInstallCache,
  resetAndroidTestImeActivationCacheForTests, setAndroidTestImeActiveForTests) to
  fallow-baselines/production-unused-exports.json, matching how the sibling helper
  reset functions (resetAndroidMultiTouchHelperInstallCache, ...) are already
  grandfathered there.

🤖 Generated with Claude Code

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

* fix(#1201): stop daemon-startup adb spawn on non-Android hosts (macOS Smoke)

Root cause of the red macOS Smoke shard (proven, not hand-waved): the
fire-and-forget restoreOrphanedAndroidTestImeOnDaemonStartup ran `adb devices`
at EVERY daemon startup, on every platform. GitHub macOS runners ship the
Android SDK, so this cold-started the adb server mid-replay and destabilized the
macOS System Settings replay timing — the failed job's cleanup shows
"Terminate orphan process: pid (N) (adb)"; main's green runs spawn no adb.

Fix: gate the startup orphan scan behind a host-side marker written in the
daemon state dir when a session activates the test IME (mirrors the
managed-web-browser orphan-cleanup `installed` gate). A host that never uses the
Android test IME — the macOS CI runner included — never writes the marker and so
never spawns adb at startup. The marker is cleared once nothing is left stuck.

Adds SessionStore.resolveStateDir(); tests: startup recovery does not scan adb
when no marker exists (+ marker cleared after a clean scan).

🤖 Generated with Claude Code

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

* chore(#1201): suppress fallow class-member false-positive on state-dir accessor

CI's Fallow audit flags SessionStore.resolveDaemonStateDir as an unused class
member, but it is called via sessionStore.resolveDaemonStateDir() in
session-open.ts — fallow's class-member tracer just doesn't resolve a method
call sited inside a call argument. Renamed for clarity (avoids the collision
with config.ts's free resolveStateDir) and added the localized
fallow-ignore-next-line unused-class-member suppression.

🤖 Generated with Claude Code

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

* fix(#1201 review): durable persist before switch + device-scoped recovery markers

Addresses devin-ai-integration's two P1 restore-safety blockers on 19cbce79d.

P1.1 — durably persist the restore target BEFORE the global IME switch.
writePersistedPreviousIme now checks the `settings put` exit code AND reads the
value back, returning a boolean. activate persists first and, if it cannot be
persisted, fails open to the existing input path WITHOUT switching — a rejected
`settings put` can no longer strand the user on the helper with no restore
target. Regression test added.

P1.2 — close the marker crash/offline blind spot. Recovery intent is now
recorded per device, BEFORE the switch (ordering: durable record -> marker ->
ime set), eliminating the post-switch/pre-marker crash window. Markers are
device-scoped and each is retained until that device is actually observed clean:
an offline/disconnected-but-stuck device keeps its marker and is recovered on
reconnect instead of being cleared because the current `adb devices` scan saw no
set-failed. Close-time restore clears only that device's marker (stateDir plumbed
through teardown/close). Tests cover the persist-failure, post-switch/pre-marker
crash, offline-then-reconnect, live-session-owned, and user-switched-away cases.

🤖 Generated with Claude Code

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-10 19:38:37 +02:00
Michał Pierzchała 888984169b fix: make record app-scoped by default (#1163)
* fix: reject recording for failed iOS simulator session

* fix: make record app-scoped by default
2026-07-08 21:48:35 +02:00
Michał Pierzchała 2717047b86 fix: normalize iOS simulator screenshot density (#1160)
* fix: normalize iOS simulator screenshot density

* fix: avoid density metadata after screenshot downscale

* fix: satisfy screenshot density CI gates

* fix: harden screenshot metadata collection

* refactor: centralize screenshot density policy

* refactor: reuse screenshot density support check
2026-07-08 21:05:24 +02:00
Michał Pierzchała 7f61df30ae feat: add TV remote command (#1147)
* feat: add TV remote command

* feat: improve TV remote ergonomics

* test: cover tv-remote provider scenario

* fix: preserve focused Android TV nodes

* docs: tighten PR description guidance

* fix: remove d-pad command alias

* docs: clarify tv-remote hold syntax

* feat: add tv-remote longpress CLI sugar
2026-07-08 10:59:48 +02:00
Michał Pierzchała 5c5fa012f7 feat: --settle returns the settled diff in the interaction response (#1101) (#1106)
* feat: --settle returns the settled diff in the interaction response (#1101)

press/click/fill/longpress --settle executes the action, waits for the UI
to go quiet (wait stable's loop, shared via stable-capture.ts), and returns
the settled diff vs the pre-action tree in the same response — one round
trip instead of the interact -> observe pair.

- payload: changed lines only (bounded), summary counts, added-line refs,
  refsGeneration; best-effort (settled:false + hint on never-quiet content,
  never an action failure); --verify shares the settle captures
- ref issuance: the settled tree becomes the session snapshot; a
  diff-carrying settle response clears snapshotRefsStale and the MCP layer
  merge-only re-pins added-line refs at the settle generation
- grammar: --settle + --settle-quiet <ms> + --timeout <ms> (flag-sourced
  descriptor budget with new envelope:'widen' semantics mirroring wait)
- ADR 0011: new settleObservation guarantee classified on every path with
  contract scenarios per enforced/delegated cell

* test: give the two contention-flaky doctor scenarios explicit budgets

The doctor provider scenarios sit at ~5s of real daemon-harness work on a
loaded host and flake at vitest's 5s default during full-suite runs (the
known contention flake AGENTS.md documents). Same in-file precedent as the
Metro-probe scenario's 10s budget.

* fix: move SettleParams to contracts to satisfy the layering DAG

daemon/handlers/interaction-flags.ts imported the type across the
daemon -> commands boundary (R2 commands-floor). The tuning params are
part of the interaction contract like SettleObservation, so they live
in contracts/interaction.ts and both layers import from there.

* feat: keep settle diffs content-first — drop Key nodes, added lines win the cap

Bluesky dogfood: a fill that summons the iOS keyboard spent 49 of the 80
capped diff lines spelling out QWERTY keys, and a screen transition with
269 removals could starve out the added lines entirely. Key-type nodes
are now filtered from both diff sides (the [keyboard] container line
still signals presence), and under truncation added lines — the ones
carrying fresh refs — win slots over removals.

* docs: state the core loop in the top-level help starting point

Benchmarked with headless haiku/sonnet agents given only --help: both
models skipped the help-workflow pointer and started with plain
snapshot (38KB payloads they then had to re-read from files). One
core-loop line at the starting point is what teaches snapshot -i and
--settle to models that never read a second help page.

* fix: preserve settle digest refs for mcp

* fix: reduce settle fallow complexity

* fix: surface settle output in CLI text

* fix: complete settle handling for longpress

* refactor: localize daemon timeout envelopes

* refactor: deepen post-action observation

* refactor: centralize post-action observation planning

* refactor: derive settle capability from descriptors

* refactor: trim settle descriptor helpers
2026-07-06 20:18:44 +02:00
Michał Pierzchała 664b713130 feat(interaction): opt-in --verify evidence for press/click/fill (#1064)
* feat(interaction): opt-in --verify evidence for press/click/fill (#1047)

Adds an opt-in --verify flag that returns cheap post-action evidence
(foregroundApp, nodeCount, interactiveNodeCount, digest,
changedFromBefore) instead of requiring a full follow-up snapshot to
confirm a mutating command had an effect. The digest hashes the
(type, label, identifier) multiset of an interactive-only capture,
order-independent so it doesn't flip on harmless re-ordering; the
node tree is never serialized back to the client, only the digest and
counts. Default behavior (no --verify) is byte-identical to today.

Implements the approved design from the #1047 issue comment:
- src/utils/ax-digest.ts: new standalone digest module.
- Pre-action digest reuses the snapshot the resolution path already
  captures for ref/selector targets (zero extra cost); point targets
  opt into one extra baseline capture only when --verify is set.
- Post-action: one interactive-only capture through the same capture
  helper, digested and discarded.
- --verify threaded through the CLI flag schema, MCP input schema,
  interactionResultExtra allowlist, and MCP output schemas, following
  the same plumbing as --double-tap and the #1040 targetHittable
  precedent.
- The native-ref/direct-iOS-selector fast paths are skipped when
  --verify is set, since they bypass the resolution/capture path
  evidence depends on.

* test: cover press --verify with a provider scenario; classify the flag

CI's architecture-progress gate failed on 1 unclassified public flag.
--verify drives real device captures, so it belongs in the
device-observable list backed by an actual provider scenario rather than
the intentionally-outside bucket: the new scenario asserts evidence
(changedFromBefore, digest, nodeCount) on press @ref --verify, that the
verify capture's tree is never serialized into the response, and that
the transcript completes (snapshot -> tap -> verify snapshot; the @ref
path reuses the session snapshot as its baseline, so no extra
resolution capture entry exists).

* fix: type the verify scenario transcript entries

* fix: include interaction extras in the fill @ref response branch

The ref branch of dispatchFillViaRuntime rebuilt responseData from
backendResult/coordinates, dropping interactionResultExtra(result) — so
fill @ref --verify returned no evidence even though the post-action
capture ran (live E2E gap found in PR #1064 review). Spreading the
extras also gives fill @ref the same ref/refLabel/selectorChain (and
conditional targetHittable/hint) fields press @ref already returns.

Adds daemon tests for fill @ref --verify evidence and for the no-verify
path staying evidence-free with no post-action capture.
2026-07-04 10:48:54 +02:00
Michał Pierzchała d21b8ce32e feat: add doctor command (#883)
* feat: add doctor command

* fix: reduce doctor command complexity

* fix: classify doctor integration flags

* fix: simplify doctor setup

* refactor: split doctor checks

* fix: simplify doctor check set

* fix: include stopped android avds in devices

* fix: report doctor device inventory

* refactor: reuse device inventory selectors

* fix: summarize doctor inventory by platform

* fix: show metro cwd in doctor

* refactor: simplify metro doctor lookup

* fix: update doctor imports after apple consolidation

* feat: make doctor Metro probe controllable and surface hidden toolchain failures

Two gaps found while verifying the doctor command on a real environment:

- Metro host/port were uncontrollable from the CLI: --metro-host/--metro-port
  were rejected by allowedFlags, and readDoctorOptions only read them from
  req.runtime (populated by remote/connection profiles, never a plain CLI
  flag). The Metro check's own hint told users to 'pass the correct
  --metro-host/--metro-port', which did not exist. Declare the flags and read
  them from req.flags (runtime kept as fallback) so the probe can target any
  endpoint, e.g. from outside an RN/Expo project directory.

- A broken per-platform toolchain was silently hidden: readDoctorDeviceInventory
  dropped inventory failures whenever any other platform returned devices, so a
  broken Xcode or Android SDK still reported a green 'pass'. Keep the failures
  and surface each as a warn (device-<platform>) when other platforms have
  devices; scoped --platform runs stay quiet.

* fix: align doctor CI expectations

* feat: extend doctor preflight checks

* fix: keep doctor checks within ci gates

* fix: simplify doctor metro surface

* refactor: trim doctor bundle impact

* fix: restore useful doctor diagnostics

* refactor: reuse doctor output helpers

* refactor: share device inventory grouping

* refactor: keep doctor focused on preflight checks

* refactor: simplify doctor toolchain probes

* fix: keep scoped simulator hint generic

* fix: clarify doctor Xcode selection context

* fix: recognize provider scope in remote doctor

* fix: address doctor review gaps

* fix: keep doctor metro checks inferred
2026-07-01 14:24:32 +02:00
Michał Pierzchała a707348b9b feat: add hosted WebDriver provider support (#948)
* feat: add cloud webdriver artifacts

* fix: clean up local session after provider release failure

* fix: tag cloud webdriver provider requests

* feat: connect hosted webdriver providers

* docs: document hosted provider credentials

* refactor: tighten cloud webdriver provider internals

* refactor: consolidate cloud webdriver provider definitions

* refactor: collapse hosted webdriver runtime wrapper

* refactor: reduce cloud webdriver smell surface

* docs: clarify hosted provider interfaces

* fix: avoid regex slash trimming in webdriver urls

* docs: rename hosted providers to device clouds

* fix: align provider profile imports with remote modules

* fix: skip local android recovery for provider devices

* test: classify cloud provider integration flags

* fix: close active cloud connection session

* test: cover provider disconnect cli flow

* fix: make cloud webdriver sessions launchable

* fix: align cloud webdriver input gestures

* fix: avoid keyboard input during cloud scroll

* fix: constrain cloud webdriver scroll gestures

* refactor: isolate cloud webdriver scroll frame

* refactor: deduplicate cloud webdriver helpers

* refactor: tighten cloud webdriver action types

* fix: harden cloud webdriver release

* fix: polish provider disconnect diagnostics

* refactor: group connection profile helpers

* fix: repair rebased internal paths

* fix: satisfy cloud webdriver CI guards
2026-07-01 13:01:47 +02:00
Michał Pierzchała 65227c6719 refactor: absorb CLI parser into cli/parser/ — Phase 5 (#958)
Move the CLI argument/flag/help parser out of utils/ into a dedicated
src/cli/parser/ folder, per plans/perfect-shape.md §5.5 (utils/ hosts a 3k
CLI parser among its buried subsystems).

Files moved (3): args, cli-flags, cli-help (args->cli-help intra-set import
stays relative).

- git renames; importers repointed via the resolve-based codemod
  (64 importers; staying-utils/kernel deps recomputed to ../../)
- no public-export/rslib impact
- update scripts/integration-progress-model.ts import + fallow-baselines/
  health.json keys (args incl. :high impact variant)

Behaviorless path codemod. typecheck/lint/format/build/tests green;
integration-progress model still runs.
2026-06-30 13:56:04 +02:00
Michał Pierzchała 43d0a639d0 feat: leveled response views + --level knob with snapshot digest — Phase 4 (#942)
* feat: leveled response views + --level knob, with a snapshot digest — Phase 4

Add the agent-cost leveled-response system: a responseLevel knob
(digest | default | full) plumbed end to end behind a global --level flag
(mirroring --cost), and a per-command ResponseView registry applied in the
router on the success path.

- contracts: RESPONSE_LEVELS/ResponseLevel + meta.responseLevel + boundary
  schema whitelist. Plumbing mirrors --cost: cli-flags FlagDefinition +
  GLOBAL_FLAG_KEYS, AgentDeviceClientConfig + overrides, buildClientConfig,
  buildMeta. ResponseLevel exported from the public root.
- src/daemon/response-views.ts: the ResponseView registry. Seeds the snapshot
  digest — the full node tree (the dominant token sink) collapses to
  { nodeCount, refs: first 12 hittable/non-occluded refs with labels } plus the
  cheap top-level signals (truncated/visibility/snapshotQuality). full returns
  today's shape (nothing richer is computed yet).
- router graft (applyResponseLevelView + applyAgentCostGrafts): composes with
  the existing cost block. With responseLevel default (or unset) AND no
  registered view AND no --cost, the original response is returned UNCHANGED —
  byte-identical to today (Maestro .ad recompare safe). cost.nodeCount reads the
  original node tree so it stays accurate even after a digest.

Tests: snapshot view unit test (digest filters hittable/occluded, drops the
tree, keeps cheap signals; default/full passthrough); router graft test via an
injected view (default identity byte-identical, digest applies, full passthrough,
digest+cost composition, unregistered-command passthrough, boundary parse).

Verified: tsc, oxfmt + oxlint --deny-warnings, fallow audit clean, rslib build,
Layering Guard empty, 1106 daemon/contracts/client tests pass (incl. the
existing cost/typed-error grafts after the restructure).

* fix: repoint MCP output-schemas import to kernel/device (rebase fixup)

The kernel move (#940) deleted src/utils/device.ts; #941's
command-output-schemas.ts (merged after #940's codemod ran) still imported the
old path. Same one-line fix as #943; de-dups once that lands.

* fix: re-classify responseLevel flag in integration-progress model

The --level/responseLevel flag is a diagnostics/output flag (not device-
observable), classified in the exclusion bucket alongside --cost. (Lost in an
earlier rebase; re-applying.)
2026-06-30 07:53:28 +02:00
Michał Pierzchała 8cc6bb8b92 feat: add replay test reporters (#936)
* feat: add replay test reporters

* feat: support custom replay test reporters

* fix: satisfy reporter CI guards

* refactor: modularize replay test reporters

* refactor: trim replay reporter parsing

* refactor: simplify custom replay reporters
2026-06-29 20:37:41 +02:00
Michał Pierzchała 54178319d8 perf: reduce iOS simulator screenshot overhead (#927) 2026-06-29 15:53:19 +02:00
Michał Pierzchała 62f923dec4 feat: opt-in agent-cost wallClockMs behind --cost — Phase 4 slice 1 (#922)
* feat: opt-in agent-cost wallClockMs behind --cost

Add per-command wall-clock latency as a purely additive, opt-in response
field (cost.wallClockMs) gated behind a new global --cost flag.

The flag plumbs end to end mirroring --debug: cli-flags definition +
GLOBAL_FLAG_KEYS, AgentDeviceClientConfig/overrides, buildClientConfig,
buildMeta (meta.includeCost), the DaemonRequestMeta contract, and the
boundary parse in daemonCommandRequestSchema so it survives the HTTP edge.

The graft lives in request-router handleRequest (the seam that owns the
outer wall-clock incl. lock + execute + finalize). It mirrors the
conditional registerDownloadableArtifacts spread: when --cost is off OR the
response is an error, the response is returned untouched. Only on an opted-in
successful response is cost appended, so the default serialized DaemonResponse
is byte-identical to today (Maestro .ad recompare safe). Proven by the parity
test (flag-off identity, flag-on additive-only, error path, boundary survival).

Additive / semver-minor. MCP exposure and richer signals (roundTrips,
nodeCount) are deferred to follow-up slices.

* test: classify --cost flag as outside provider-backed integration

The integration progress guard (test:integration:progress:check) treats every
public CLI flag as either device-observable (requiring provider-backed coverage)
or intentionally excluded. --cost is a diagnostics/output flag (a purely additive
response field, not device-observable), so it joins json/help/version/verbose in
the 'config, output, diagnostics, and transport' exclusion bucket.
2026-06-29 13:50:12 +02:00
Michał Pierzchała 24cb2b622c fix: refine Apple provider pressure reporting (#877) 2026-06-25 17:34:50 +02:00
Michał Pierzchała ba825d8df2 fix: use desktop scroll events on macOS (#863)
* fix: use desktop scroll events on macOS

* fix: support paced macOS desktop scroll

* fix: clear scroll CI quality gates

* fix: address macos scroll review feedback

* refactor: simplify macos scroll plumbing

* fix: tighten scroll duration contract

* fix: limit apple scroll duration reporting
2026-06-25 07:38:21 +02:00
Michał Pierzchała 7739b71a25 refactor: centralize command family facets (#849) 2026-06-24 12:40:03 +02:00
Michał Pierzchała d47cd30117 feat: add agent-device proxy command (#844) 2026-06-23 17:20:08 +02:00
Michał Pierzchała bc5726e8b0 test: cover web provider scenario (#827) 2026-06-19 13:39:39 +02:00
Michał Pierzchała feb5309e38 fix: classify external xctest runner flags (#810) 2026-06-16 15:54:00 +01:00
Michał Pierzchała 5c83fe4e50 fix: improve daemon diagnostics and remove compact snapshots (#786)
* fix: improve daemon startup diagnostics

* perf: relax query-sweep recovery budget

* fix: make compact snapshot flag a no-op

* fix: remove compact snapshot mode

* test: align tvOS remote provider expectation

* test: drop compact snapshot progress target
2026-06-12 17:32:56 +02:00
Michał Pierzchała fa1c1d55da refactor: localize command surface modules (#772)
* refactor: localize command surface modules

* refactor: localize command runtime modules

* refactor: tighten localized command exports

* refactor: address command localization review

* refactor: deepen batch command policy

* refactor: split provider progress model

* refactor: collapse command client facets

* refactor: run progress metrics as TypeScript

* refactor: remove obsolete command shims

* fix: update localized snapshot output import

* fix: preserve debug command localization
2026-06-12 14:02:04 +02:00