Commit Graph

994 Commits

Author SHA1 Message Date
Michał Pierzchała 6dbd05e824 fix: tighten acp tool updates 2026-07-07 11:32:31 +02:00
Michał Pierzchała c37f1ccb0a docs: document acp as first-class interface 2026-07-07 11:32:31 +02:00
Michał Pierzchała c3cd5bf5fb docs: keep acp prompt example cli-shaped 2026-07-07 11:31:11 +02:00
Michał Pierzchała 7447a4ebb5 fix: accept acp slash commands 2026-07-07 11:31:11 +02:00
Piotr Miłkowski 81f2b4026d feat: add agent-device acp, a deterministic stdio ACP agent
Add an Agent Client Protocol (ACP) v1 agent mode so ACP clients such as
Zed can drive devices from the agent panel. agent-device has no LLM, so
the agent is deterministic: each prompt line is one agent-device command
in CLI syntax, executed through the same AgentDeviceClient path as MCP
tools.

- Hand-rolled protocol layer in src/acp/ mirroring src/mcp/ (zero new
  runtime dependencies), reusing the MCP newline-delimited JSON-RPC
  stdio transport, payload queue, and error formatting.
- Prompt lines are tokenized with the replay-script tokenizer and parsed
  with the real CLI parser; target flags (--platform, --device, --udid,
  --session, --state-dir) are sticky within an ACP session.
- Command executions stream as tool_call/tool_call_update notifications
  with daemon progress forwarding; screenshots attach as inline images;
  errors keep the code/hint/supportedOn contract.
- session/cancel is intercepted ahead of the serialized queue so it
  takes effect between command lines of a running prompt; the command
  already in flight runs to completion (documented v1 limitation).
- Natural-language prompts are refused with guidance instead of guessed
  at; available commands are advertised per session after session/new.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:31:11 +02:00
Michał Pierzchała 0dcc1aa553 fix: normalize interaction response wire shapes (#1114)
* fix: normalize interaction response wire shapes

* fix: reduce interaction response complexity
2026-07-07 11:29:01 +02:00
Michał Pierzchała 7e583c4136 fix: simplify Android recording recovery (#1135)
* fix: harden android recording recovery

* fix: reduce android recording recovery fallow complexity

* test: fix android recording recovery rebase

* fix: block uncertain android recording fallback

* fix: address android recording recovery review

* fix: address android recording recovery review followup

* fix: simplify Android recording recovery

* fix: address android recovery ownership review

* refactor: reuse android recovery manifest helpers

* refactor: split android pending recovery resolution

* fix: clarify scoped android recovery hint
2026-07-07 10:50:57 +02:00
Michał Pierzchała d5a7af0f4d fix: hint on iOS runner main-thread timeouts (#1140) 2026-07-07 10:26:49 +02:00
Michał Pierzchała b052eb9a37 fix: speed up iOS text entry (#1139)
* fix: speed up iOS text entry

* fix: reverify settled iOS text entry
2026-07-07 09:50:40 +02:00
Michał Pierzchała 8ef4e73408 refactor: derive command exposure lists from descriptors (#1137) 2026-07-07 08:00:33 +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 db69124c00 feat: add capabilities command (#1133) 2026-07-06 19:35:46 +02:00
Michał Pierzchała 54f6d45b32 refactor: extract host process primitives (#1134) 2026-07-06 19:01:32 +02:00
Michał Pierzchała 126d85ac8a fix: reap managed web browser orphans (#1112)
* fix: reap managed web browser orphans

* fix: narrow managed web browser reaping

* fix: harden managed web cleanup
2026-07-06 15:51:36 +02:00
Michał Pierzchała be4bd092b6 fix: recover Android recordings after daemon restart (#1129)
* fix: recover android recordings after daemon restart

* refactor: reduce recording recovery complexity

* fix: address android recording recovery feedback
2026-07-06 15:28:27 +02:00
Michał Pierzchała 7475415ac5 build: strip runner unit-test blocks from package (#1128)
* build: strip runner unit-test blocks from package

* ci: fix package size and fallow checks

* fix: resolve packaged recording scripts

* fix: keep recording script resolver internal
2026-07-06 12:52:05 +02:00
Michał Pierzchała ea69dc3767 fix: harden apple runner recovery (#1126)
* fix: harden apple runner recovery

* fix: address runner recovery ci failures

* fix: clean up runner recycle review findings
2026-07-06 11:40:54 +02:00
Michał Pierzchała f9721e7e8b test: split the Android platform test aggregation and share the scripted adb stub (#1103)
* test: split the Android platform test aggregation and share the scripted adb stub

AGENTS.md names the platform index.test.ts aggregations as offenders to
shrink opportunistically; this splits the 2,735-line Android one along
its (already well-factored) source modules, every test moved verbatim
(92 tests before and after):

- ui-hierarchy.test.ts (22): parseUiHierarchy/androidUiNodes
- app-lifecycle-install.test.ts (13): install/resolve/infer/launch
  component parsing
- app-lifecycle-open.test.ts (19): open/close, deep links, launch args,
  TV category, fallback resolve-activity
- input-actions.test.ts (11): type/fill/swipe/scroll/rotate
- settings.test.ts (14): appearance/clear-app-state/fingerprint/
  permissions
- notifications.test.ts (2), app-parsers.test.ts (1)
- keyboard state/dismiss tests (10) appended to the existing
  device-input-state.test.ts

Consistency fix folded in: the file carried a local withMockedAdb fork
because it needs scripted per-subcommand adb responses, which the shared
arg-recorder helper cannot express. The fork now lives in
src/__tests__/test-utils/mocked-binaries.ts as withScriptedAdb next to
withMockedAdb, and hands each call a fresh copy of the shared
ANDROID_EMULATOR fixture.

The copy matters: the Android TV test mutated the callback's device
(device.target = 'tv'), which the old per-call object literal absorbed
silently. With a shared fixture that mutation leaked into the next test
and flipped its launch to LEANBACK. The helper now clones per call and
the TV test builds { ...device, target: 'tv' } instead of mutating.

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

* test: serialize the scripted-adb group and repoint its slow-test pins

Review follow-up for the android index.test.ts split: the monolith
implicitly serialized the env-mutating adb-stub tests (PATH,
AGENT_DEVICE_TEST_ARGS_FILE) in one worker, and the split let vitest
run them across parallel files. Make the contract explicit:

- new android-adb vitest project runs the six scripted-adb test files
  in a single fork (singleFork), keeping the pre-split execution
  semantics; ui-hierarchy and app-parsers stay in the parallel unit
  project (pure parsing, no env mutation)
- test/test:unit scripts run both projects
- the five slow-test ratchet pins that referenced index.test.ts keys
  now point at the split file names, so the pinned real-time offenders
  keep their exemption instead of failing at 2x budget under load; the
  reporter's own pinned-key fixture updated to match

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

* test: use vitest 4 android adb serialization

* docs: update unit project readiness guidance

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-06 11:23:03 +02:00
Michał Pierzchała 8a8ebcafda fix: name split declaration chunks (#1127) 2026-07-06 11:17:49 +02:00
Michał Pierzchała e4139b6802 ci: deepen node 22 packaged smoke (#1125) 2026-07-06 10:24:12 +02:00
Michał Pierzchała 318e813cc8 test: split interaction runtime tests (#1113) 2026-07-06 08:22:45 +02:00
Michał Pierzchała 915f5ba374 build: support packaged CLI on Node 22.12 (#1116) 2026-07-06 07:59:37 +02:00
Michał Pierzchała 83d54614d8 fix: bound iOS capture stalls and make runner recovery session-preserving (#1105) (#1107)
* fix: bound iOS capture stalls and make runner recovery session-preserving (#1105)

Runner (Swift):
- Coalesce duplicate transport sends of one commandId onto the in-flight
  execution instead of enqueueing them again behind it (capture pileup).
- Fail fast with RUNNER_BUSY while watchdog-abandoned main-thread work is
  draining; escalate to RUNNER_WEDGED past 120s so the daemon recycles.
- Carry the capture-plan deadline into the query-sweep and private-AX
  ladder tiers so chained recovery cannot stack past the watchdog.
- Penalize the tree backend after a slow (>5s) or abandoned capture and
  lead subsequent regular plans with private-AX for that bundle (sticky,
  120s), stamped recovered/budget so the deferral stays observable.

Daemon (TS):
- Per-request runner recycle budget: at most one invalidate+reboot per
  request, then fail fast with an actionable, session-preserving hint.
- RUNNER_WEDGED joins the runner-fatal invalidation reasons.
- Interaction commands (click/fill/longpress/press/type/get/is) preserve
  the daemon on request timeout like snapshot/wait/find: resetting it
  destroyed every healthy app session the daemon owned.

* fix: suppress AX-broken-screen snapshot issues so the runner survives capture

XCTest records 'Failed to get matching snapshot: kAXErrorIllegalArgument'
issues for every XCUIApplication query on AX-broken screens; after a few
of them the test case tears down the moment the in-flight command
completes, killing the long-lived runner after every capture of the
screen (the restart loop behind #1105). The capture plan already
classifies and recovers from AX failures, so this issue class is noise:
swallow exactly it in record(_:); everything else still records and
still drives XCTEST_RECORDED_FAILURE.

* feat: time-slice the XCTest tree capture on a worker thread

The tree snapshot XPC is a single blocking call whose duration moves
with live content (4s to minutes on Bluesky profile screens); no
in-process budget could bound it on the main thread. Run it on a worker
bounded to an 8s slice: on timeout the plan penalizes the tree backend,
skips the XCTest-backed tiers while the abandoned XPC drains (they
would block behind it inside testmanagerd), and recovers through the
private AX backend, which does not use testmanagerd.

* tune: lower the tree-backend penalty threshold to 3s

The Bluesky profile tree grind measures ~4.5s before kAXErrorIllegalArgument,
just under the old 5s threshold, so every capture re-paid the doomed grind
(9s each). At 3s the second capture onward defers to private AX (2.4s
snapshot, 4.9s press on the live repro).

* fix: harden the AX-issue suppression per review

- Require the kAXError token: 'Failed to get matching snapshot: Timed out
  while evaluating UI query.' is a genuinely-hung-query signal and must
  keep recording (and keep driving XCTEST_RECORDED_FAILURE). Sibling AX
  server codes (kAXErrorCannotComplete, ...) are deliberately included:
  any AX-server rejection inside a matching-snapshot fetch is the same
  capture-plan noise.
- State honestly that the override is suite-global and why (tap-triggered
  queries record the same noise; command outcomes stay honest via their
  own error paths).
- Lock-guarded suppressed-issue counter following the file's existing
  abandoned-work counter pattern, logged with each suppression.
- Unit-test the pure classifier (record(_:) itself is not invoked: the
  must-record variants would record real failures in the test run).
2026-07-05 10:08:15 +02:00
Michał Pierzchała a0556583c3 docs: tighten agent operating guide (#1104)
* docs: tighten agent operating guide

* docs: update agent guide cli paths
2026-07-05 08:17:34 +02:00
Michał Pierzchała 0159975f2e test: split the args.test.ts aggregation along source topology (#1102)
* test: split the args.test.ts aggregation along source topology

AGENTS.md file-size tripwires now apply to tests with no exemption, and
test files are expected to mirror source topology 1:1. args.test.ts was
a 2,503-line aggregation in src/utils/__tests__ while the code it
exercises lives in src/cli/parser. Split it into six focused files with
every test moved verbatim (142 tests before and after):

- src/cli/parser/__tests__/args-parse-interaction.test.ts (29 tests):
  parseArgs shapes for press/click/swipe/gesture/type/record/screenshot
  and friends
- src/cli/parser/__tests__/args-parse-session.test.ts (41 tests):
  parseArgs shapes for session/daemon/device flags, passthrough,
  install/metro/connect/proxy/auth and friends
- src/cli/parser/__tests__/args-validation.test.ts (17 tests): strict/
  compat modes, rejections, deterministic errors
- src/cli/parser/__tests__/cli-help-topics.test.ts (15 tests): global
  usage and help topics
- src/cli/parser/__tests__/cli-help-command-usage.test.ts (35 tests):
  per-command usage copy
- src/utils/__tests__/command-schema-guards.test.ts (5 tests): schema/
  catalog/capability guards and the cli.ts dispatch-literal walk (the
  oxc-parser helpers live here)

AGENTS.md testing-matrix and help-source pointers updated to the new
paths, including the stale src/utils/cli-help.ts and cli-flags.ts
locations (both live under src/cli/parser/).

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

* docs: fix cli parser paths in agent guide

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-04 21:46:10 +02:00
Michał Pierzchała 5a362ec752 build: drop tsc entirely and fix sandbox-hostile tests (#1100)
Follow-ups from the bundler/CI speed work, re-validated against latest
main. The typescript package is gone from the toolchain:

- pnpm typecheck stays on tsgo; the typecheck:tsc escape hatch is
  removed along with the typescript devDependency.
- args.test.ts extracted cli.ts dispatch literals through the
  TypeScript compiler API - the only remaining consumer. It now walks
  the same AST via oxc-parser (matching the OXC lint/format/build
  stack); both implementations extract an identical 14-literal set
  from cli.ts, verified side by side before the swap. The
  substitution-free template case ts.isStringLiteralLike covered is
  preserved.
- dts bundling is unaffected: the tsdown build uses the tsgo backend
  and builds green with no typescript package installed.

Test fixes for containerized agent environments:

- The missing-binary doctor-guidance web provider test pins Node 24
  via the file's existing withNodeRuntimeVersion helper, so it asserts
  the setup hint instead of inheriting the host Node and failing on
  Node 22 (the supported engines floor).
- The clean-xcuitest cleanup-failure smoke test skips as root: chmod
  0o500 cannot force a removal failure when the process bypasses
  directory permissions.

AGENTS.md toolchain notes updated to match.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-04 21:02:36 +02:00
Michał Pierzchała 86736a5f87 feat: versioned snapshot refs with MCP auto-pinning (#1096)
* feat: versioned snapshot refs with MCP auto-pinning

Refs are positional indexes into the latest stored session tree; #1093's
coarse snapshotRefsStale marker warns honestly but cannot say WHICH tree
a ref came from. Give the session a monotonically increasing
snapshotGeneration, advanced wherever the stored tree is replaced: the
setSessionSnapshot choke point and the snapshot/diff command path that
bypasses it.

Token economy (non-negotiable): the snapshot tree output is unchanged —
plain e12 refs on every node. Ref-issuing responses (snapshot command,
find ref outputs) carry the generation ONCE as the additive
refsGeneration field. Ref-consuming commands (press/click/fill/longpress/
get/wait) accept both forms: plain @e12 keeps today's behavior including
the coarse #1093 warning; pinned @e12~s3 is clean when the generation
matches the stored tree, gets a precise warning naming both generations
when it does not, and a malformed suffix is INVALID_ARGS with a grammar
hint. Warn-only this release — tightening comes later per the compat
ladder.

The MCP layer auto-pins at zero token cost: it sees snapshot/find
responses before the model does, remembers the last refsGeneration per
session name, and rewrites plain @ref tool arguments to the pinned form
before forwarding. The model never sees or types suffixes; with no
remembered generation, refs pass through unpinned (never guess).

Replay parsing and script writing strip and IGNORE pins — generations
are meaningless outside the session that minted them.

Refs #1076

* docs: CONTEXT.md vocabulary for ref generation pins

Moved from #1097 per the review sequencing note: the term lands with
the behavior it describes.

* docs: teach the ref pin syntax in CLI help

MCP agents get pins transparently (auto-pinning), but CLI-driving
agents only ever met the coarse warning — refsGeneration arrived in
snapshot responses with nothing explaining it, making pins an
undiscoverable feature on the primary agent surface. One help line in
the agent loop guidance closes that; warnings stay short (they fire
repeatedly, teaching belongs in once-read surfaces).

* fix: per-ref MCP pin provenance and seeded generations

Review findings on the first cut:

1. The MCP layer kept ONE refsGeneration per session, so after
   snapshot(s12) -> find(s13) a plain @e37 from the pre-find snapshot got
   pinned ~s13 and read as current — recreating the find-blessing hole at
   the pinning layer. Replace it with per-ref provenance:
   Map<pinScope, Map<refBody, generation>>, scoped by state dir + session
   name (stateDir is a per-call MCP config field, so one server process
   can face multiple daemons). Merge-only updates: refs present in a
   ref-issuing response (snapshot nodes, digest refs, the find ref) move
   to its generation; absent refs KEEP their older pins — an old pin on a
   replaced tree is what makes the daemon warn. Never-issued refs pass
   through unpinned; an issuing response without refsGeneration clears
   the scope; memory bounded to the ~1000 most recently issued pins.

2. Generations were per-lifetime counters from 1, so a reopened
   session's ~s1 collided silently with the previous lifetime's. Seed
   the first bump at a random 6-digit base (crypto randomInt):
   cross-lifetime collisions become ~1e-6 — probabilistic (seeded), not
   identity-based, documented on the field. Pin format unchanged;
   within-lifetime comparisons stay exact.

Tests: the MCP blessing scenario (pre-find ref stays pinned to ITS
generation), the daemon half in the provider scenario (find must not
bless a pre-find pin), reopen/reseed at unit + handler level, state-dir
scope isolation, digest-ref merging; generation fixtures made
seed-agnostic (relative bumps, echo the observed seed).

Refs #1076
2026-07-04 21:02:10 +02:00
Michał Pierzchała 2557670193 test: slow-test ratchet and speed rules from measured experiments (#1099)
* test: slow-test ratchet, budget-derived emulator poll, speed guidance from experiments

Measured (2026-07-04, full unit suite: 340 files / 3,210 tests / 48s wall):
wall clock was bounded by the slowest FILE (44.6s android monolith at ~7x
file-level parallelism), and the slowest tests were sleeping through real
production budgets (10.8s proving 'times out' by waiting the constant out,
8s emulator polls at 1Hz, real retry backoff). Two config experiments
rejected with data: --no-isolate exploded the suite to 205s (module state
thrashes across files sharing workers) and --pool=threads changed nothing.

- scripts/vitest-slow-test-reporter.ts: the slow-test ratchet. Unit budget
  2.5s / integration 15s; failure at 2x budget (the band between reports
  without failing so host-load variance cannot make the gate cry wolf);
  36 pinned offenders, exact keys, ratchet-only pin (tracking #1098).
- waitForAndroidEmulatorByAvdName: poll cadence derives from the caller's
  budget (min 1s, floor 50ms, ~timeout/20) — devices.test.ts 25.6s -> 2.8s
  (9x) in isolation, and short-budget production calls stop sampling at
  1Hz against small budgets.
- vitest.config: slowTestThreshold 500 for local visibility; reporter
  wired; isolation/pool decisions documented with the measurements.
- docs/agents/testing.md 'Speed rules' + AGENTS.md testing bullet: the
  three conversion patterns in preference order (budget-derived cadence,
  budget-wiring assertion, fake clocks), the no-seam constraint, and the
  file-granularity Amdahl argument that makes the monolith test split a
  wall-clock fix, not just navigation.

* fix: fallow findings on the slow-test gate — import edge, factory reporter, unit tests

The string-path reporter wiring read as a dead file (fallow cannot see
vitest's reporter loading); the config now imports the factory, making
the edge real and type-checked. The class shape tripped the
unused-class-members rule (framework callbacks are invisible to
reference analysis) — converted to a factory returning the Reporter
object, with the classification and rendering logic extracted as pure
exported functions. Those functions now carry their own unit tests
(budget bands, integration budgets, pin matching, warn-vs-fail
rendering), which also grounds the CRAP estimate in real references.
Canary re-verified: unpinned 5.2s sleeper fails the run with exit 1;
clean runs exit 0.
2026-07-04 19:13:06 +02:00
Michał Pierzchała cccd34fb27 docs: refocus AGENTS.md on principles and enforcement gates (#1097)
* docs: refocus AGENTS.md on principles and gates; index ADRs; extend CONTEXT.md vocabulary

AGENTS.md: replace the routing/command-family prose maps (already
drifting from the code) with pointers to the self-describing,
parity-tested registries; add the two sections agents actually cannot
rediscover cheaply — Principles (one line per incident-backed lesson)
and Enforcement gates (the classify-don't-suppress index); extend the
module-size guidance from raw LOC caps to answer-one-question files,
1:1 test topology mirroring (removing the integration-aggregation
exemption that produced 3,400-line test files), sibling fixture
modules, claim collocation, and boundary-only barrels; record the
dev-loop staleness triple (dist/daemon/adopted-runner), the tsgo
typecheck, the Gatekeeper first-node-exec stall, the DEVICE_IN_USE
signature, and the contention-flake protocol; append the two gate
steps to the new-flag checklist.

CONTEXT.md: vocabulary for the ADR 0011 domain (dispatch path,
guarantee cell, owned waiver, parity table, coverage manifest,
delegation-on-error, ref generation pin) and an architecture paragraph
positioning ADR 0011 as ADR 0008's interaction-semantics counterpart.

docs/adr: flip 0011 to Accepted (implemented through Layer 3) and add
a read-this-when index that names the registries as the living source
of truth over ADR prose.

* docs: defer versioned-ref references to the implementing PR

Review sequencing note on #1097: these lines described #1096 behavior
not yet on main. They move to #1096's branch so docs land with the
implementation and the two PRs merge in any order.
2026-07-04 19:09:46 +02:00
Michał Pierzchała a6c254939b docs: record that the native-ref path is web-only in production (#1095)
tapTarget/fillTarget are wired solely to the web provider's clickRef
(interaction-runtime.ts); apple/android backends never define them.
Found while designing the #1088 retirement experiment, which this
dissolves: the hypothesized iOS runner round trip does not exist.
2026-07-04 17:28:28 +02:00
Michał Pierzchała 6ee483c33f feat: warn when @refs outlive the session snapshot they came from (#1093)
Refs are positional indexes into the latest stored session tree; any
selector-based command's resolution capture silently reshuffles them
(#1076). Track an honest marker on the daemon session
(snapshotRefsStale): set wherever the stored snapshot is replaced by a
response that does not hand the new refs to the client, cleared only
where the client demonstrably receives them (snapshot responses, find
ref outputs). Commands consuming @ref arguments while the marker is set
(press/click/longpress/fill/get/wait) attach a warning and still
execute — the geometric guards keep catching detectable drift.

Closes #1076
2026-07-04 17:11:22 +02:00
Michał Pierzchała ad754ac0a4 feat: direct iOS selector falls back to tree resolution on semantic failures (ADR 0011) (#1091)
* feat: direct iOS selector falls back to tree resolution on semantic failures (ADR 0011)

Runner ELEMENT_NOT_FOUND/AMBIGUOUS_MATCH on the direct iOS selector
dispatch now delegate to the tree-based runtime path, which supplies
runtime disambiguation, occlusion refusal, non-hittable
promotion/annotation, and rich selector diagnostics/hints.

Maestro replay dispatches (allowNonHittableCoordinateFallback) keep the
runner-native error shapes: the new delegateSemanticFailures option is
threaded from the dispatch flag, and the query path
(allowElementNotFound at selector-runtime) keeps its exact semantics.

Registry: direct-ios-selector errorTaxonomy/nonHittable/occlusion flip
from gap waivers to delegated cells; the pinned gap list shrinks 6 -> 3.
Success-path parity cells (disambiguation, responseIdentity)
intentionally remain gaps per ADR 0011.

Refs #1081

* test: contract coverage for the three newly-delegated direct-iOS cells

The coverage gate demanded scenarios the moment the rebase flipped
errorTaxonomy/nonHittable/occlusion to delegated — as designed. Three
transcript scenarios prove the delegation end to end: runner
ELEMENT_NOT_FOUND falls back to runtime no-match diagnostics, to the
covered refusal, and to an annotated coordinate tap (the transcript
asserts the fallback tap is coordinate-keyed).
2026-07-04 16:41:03 +02:00
Michał Pierzchała d167eaf0f3 ci: drop duplicate unit-test run, cache Size base dist, typecheck with tsgo (#1094)
Three measured dev-loop/CI cuts, no signal loss:

- typecheck now runs tsgo (already trusted for declaration emit by the
  tsdown build): 21.7s -> 5.3s locally, and check:tooling drops to ~18s
  total. tsc stays available as typecheck:tsc; verified tsgo fails on
  type errors and respects noUnusedLocals.
- remove the Unit Tests CI job: Coverage runs the same unit +
  provider-integration suites under coverage thresholds, so the job
  reran ~64s of tests every PR for no extra signal.
- Size workflow: skip docs-only paths (same paths-ignore as CI) and
  cache the base commit's dist keyed on base SHA, since dist is fully
  determined by that commit. Startup medians are still measured fresh
  on the same runner so the base/PR startup comparison stays
  same-machine; the cache is saved immediately after the base
  measurement so the PR build never poisons it.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-04 16:40:34 +02:00
Michał Pierzchała f3e07ff236 test: interaction contract suite with registry-driven coverage gate (ADR 0011 Layer 3) (#1092)
* fix: preserve the runner's non-hittable fallback marker through direct selector press

The direct iOS selector press handler spread successText('Tapped <selector>')
after the runner payload, clobbering the 'tapped via non-hittable coordinate
fallback' message that directIosSelectorFallbackDetails keys on — so
maestroNonHittableCoordinateFallbackUsed could never be true end-to-end (the
existing unit test passes because it mocks dispatchCommand above this layer).
Found by the ADR 0011 Layer-3 maestro-fallback contract scenario.

Share the marker string as MAESTRO_NON_HITTABLE_FALLBACK_MESSAGE and keep it
as the success message when the runner reports fallback usage.

* test: interaction contract suite with registry-driven coverage gate (ADR 0011 Layer 3)

test/integration/interaction-contract/ holds one scenario file per dispatch
path, each with a sibling .coverage.ts manifest declaring which guarantee
matrix cells it proves (scenario strings double as the vitest test titles).
index.ts aggregates the manifests statically, and a new Layer-3 gate in
src/contracts/__tests__/interaction-contract-coverage.test.ts fails when any
enforced (runtime/runner/delegated) cell lacks a scenario or when a scenario
claims a waived/inapplicable cell — coverage of the matrix is by
construction in both directions.

Path forcing is natural (no test-only env switch needed): selector/ref
targets take the runtime path, a tapTarget backend takes the native-ref fast
path, x/y takes the coordinate path, and simple-selector clicks on an iOS
provider transcript take the direct runner path (the transcript itself
proves which path ran via assertComplete).

Fixtures are the permanent Bluesky shapes: closed drawer, drawer + visible
twin, edge-grazing container, covered button, non-hittable cell.

Refs #1081
2026-07-04 16:02:41 +02:00
Michał Pierzchała 03b666bb49 feat: actionable hint for adb timeouts (#1090)
A timed-out adb invocation has no stderr for the failure classifier to
match, so it fell through to the generic retry hint — retrying a wedged
adb server times out identically. Classify exec-layer timeouts on the
structured timeoutMs detail (the signal createTimeoutError sets) as a
'timeout' family with a kill-server/start-server hint, winning over
untrustworthy partial stderr and losing to site hints as before.

Doctor's android inventory sweep already routes adb failures through
attachAdbFailureHint via listAndroidDeviceEntries, so the device-android
check now surfaces the wedged-server hint; a regression test locks that
path.

Closes #1079
2026-07-04 16:00:14 +02:00
Michał Pierzchała e031a5cf97 chore: delete dead tap-by-text runner arm and settledAfterMs; hint on tiny stable trees (#1089)
- Remove the runner .tap text arm: the daemon only ever sends
  selectorKey/selectorValue or x/y taps (src/platforms/apple/interactions.ts),
  and daemon+runner ship in lockstep. findElement(app:text:) stays for the
  findText probe.
- Drop settledAfterMs from the wait stable result: it always equaled waitedMs
  and never shipped in a release (v0.18.3 predates #1059).
- Add a loading hint when wait stable settles on a tree with fewer than 5
  nodes (#1078): stability on a nearly-empty tree is a weak readiness signal.

Refs #1078
2026-07-04 15:59:17 +02:00
Michał Pierzchała cd0cd16a9e build: migrate the library build from rslib to tsdown (Rolldown) (#1087)
* build: migrate the library build from rslib to tsdown (Rolldown)

Replace the Rspack-based rslib build with tsdown, the Rolldown-based
library bundler from the Vite toolchain family, so bundling, testing
(Vitest/Vite), linting (oxlint), and formatting (oxfmt) all run on the
same OXC/Rolldown stack.

Outcome vs the rslib baseline (size-report):
- build time: ~53s -> ~2s
- JS raw +16.2 kB (+1.1%), JS gzip +2.7 kB (+0.6%) - the residual gap
  is OXC vs SWC minifier tightness, not chunking
- npm tarball -3.0 kB
- CLI --version startup ~3 ms faster; --help within the +/-5 ms
  measurement noise of interleaved A/B runs

Chunk-merging experiments (single shared group, entries-aware groups,
small-module groups) all regressed either total size or --help startup
(a merged shared chunk adds +140 ms), so the default Rolldown split
graph is kept. Custom codeSplitting groups also currently trip a
rolldown-plugin-dts bug that re-emits type-only imports as runtime
imports.

Declarations still bundle per entry via tsgo; dist layout, entry names,
and the internal/ worker/daemon entry resolution contract are unchanged.
@microsoft/api-extractor was only consumed by rslib dts bundling and is
removed together with @rslib/core.

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

* ci: only cache the pnpm store when setup installs dependencies

The layering-guard job uses setup-node-pnpm with install-deps: false, so
it never creates a pnpm store. setup-node's post-job cache save then
fails with a path validation error whenever the lockfile hash misses the
cache - which any lockfile-changing PR does. Gate the cache on
install-deps so no-install jobs skip pnpm store caching entirely.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-04 15:58:05 +02:00
Michał Pierzchała 415f599c3b feat: golden tap-point parity table for TS and the iOS runner (ADR 0011) (#1086)
ADR 0011 Layer 2: the offscreen tap-point rule now has exactly one home per
side of the wire, proven equivalent by a shared golden fixture table.

- contracts/fixtures/tap-point-policy.json: 12 cases including this week's
  real bug shapes (closed drawer fully left, 0.07pt edge-grazing container)
  plus edge-inclusive and empty-frame fail-open semantics.
- Swift: pure TapPointPolicy.isAllowed(elementFrame:windowFrame:) extracted;
  the ELEMENT_OFFSCREEN guard (onScreenWindowFrame stays the frame getter)
  and hasTappableFrame (Maestro fallback) both delegate the decision to it.
  Gated XCTest asserts every table case (runs in the existing
  AGENT_DEVICE_RUNNER_UNIT_TESTS surface CI already compiles).
- TS: isTapPointInsideViewport extracted from isNodeVisibleOnScreen (no
  duplicated math); vitest parity test asserts the same table.
- Registry: parityTable wired on direct-ios-selector/offscreen and
  maestro-non-hittable-fallback/offscreen.
2026-07-04 15:11:55 +02:00
Michał Pierzchała 248e08afd4 feat: warn on out-of-viewport coordinate taps (ADR 0011) (#1085)
* feat: warn on out-of-viewport coordinate taps (ADR 0011)

Implements the coordinate/offscreen guarantee: when a point tap target
falls outside the last-known viewport, attach a warning to the result
instead of silently forwarding the tap. This is a WARNING not an error,
maintaining the escape-hatch semantics of coordinate taps.

The warning is generated at the runtime layer in resolvePointInteractionTarget
using the session's last snapshot to infer viewport bounds via resolveViewportRect.
No snapshot is captured (by design), so the implementation reuses the session's
existing snapshot state. When no snapshot is available, no warning is emitted.

Changes:
- Add `warning?: string` field to PressCommandResult and LongPressCommandResult
  types (FillCommandResult already had it for --verify evidence)
- Implement tryResolveOutOfBoundsPointWarning in resolution.ts and export it
  for registry verification
- Update interaction-guarantees.ts to mark coordinate/offscreen as runtime-enforced
- Remove coordinate/offscreen from the pinned gap list in the test registry
- Add tests: out-of-bounds point with snapshot → warning; in-bounds → no warning;
  no snapshot → no warning
- Carry the warning field through daemon handlers for press/click/longpress

Closes #1081 (coordinate/offscreen gap).

* refactor: drop suppressed re-export; registry points at the enforcement carrier

The registry via now references resolveInteractionTarget (the exported
carrier, same precedent as the verifyEvidence cell) instead of forcing a
suppressed unused-export of the internal helper.
2026-07-04 15:11:10 +02:00
Michał Pierzchała b25ef7b024 refactor: declare command timeout policy on descriptors (ADR 0011) (#1084)
The wait timeout bug (#1075) happened because request-envelope budgets and
on-timeout daemon policy lived in two hand-maintained lists in the daemon
client: isExplicitTimeoutCommand (daemon-client.ts) and
DAEMON_PRESERVING_TIMEOUT_COMMANDS / shouldResetDaemonAfterRequestTimeout
(daemon-client-timeout.ts). A command could fall through both without anyone
noticing.

Both lists are deleted. Each command descriptor (ADR 0008 registry) now
declares a required timeoutPolicy:

  timeoutPolicy: {
    budget: { source: 'none' | 'flag' | 'positional-parser'; parser? };
    envelopeMs: number | 'unbounded';
    onTimeout: 'preserve-daemon' | 'reset-daemon';
  }

The client derives the request envelope and the on-timeout daemon policy from
the declaration; the +30s margin / never-shrink rule for positional budgets is
preserved generically. Envelope constants move from
src/daemon/request-timeouts.ts to src/core/command-descriptor/timeout-policy.ts
next to the policies they parameterize.

A completeness gate (timeout-policy.test.ts) asserts every public command
declares a policy and pins the deviating sets (preserve-daemon = snapshot/
wait/find; flag budget = prepare/replay/snapshot; positional = wait; envelopes
= prepare 240s, install-like 180s, test unbounded) as bounded diffable lists.
The pre-existing oracle tests in src/utils/__tests__/daemon-client.test.ts
pass byte-for-byte unchanged, proving the migration is behaviorally exact.
2026-07-04 14:48:42 +02:00
Michał Pierzchała f7dd42234e refactor: single construction site for interaction responses (ADR 0011 Layer 2) (#1083)
Every press/click/fill/longpress dispatch branch now builds its result/
responseData payloads through buildInteractionResponseData
(interaction-touch-response.ts), so identity extras (ref/refLabel/
selectorChain/targetHittable/hint/evidence) are composed in exactly one
place. Hand-rolled per-branch assembly is the class of bug that dropped
fill @ref evidence (#1064 review); a guard test now fails any touch
handler that assembles a responseData literal outside the builder.

Wire shapes are byte-identical: the fill @ref backendResult+identity
shape is preserved behind refBackendWireShape, and the daemon +
integration suites pass unchanged.

Registry: all six responseConstruction cells flip from gap waivers to
the shared runtime cell; the pinned gap list shrinks accordingly.

Refs #1081, ADR 0011.
2026-07-04 14:43:08 +02:00
Michał Pierzchała b9fb4db6df feat: preflight native-ref fast path with shared interaction guards (ADR 0011) (#1082)
click @ref / fill @ref dispatch straight to backend.tapTarget/fillTarget,
and a backend fast path can silently "succeed" — delegation-on-error never
triggers there. The ref came from the stored session snapshot, so the node
is already in hand: preflightNativeRefInteraction runs the SAME shared
guards the runtime path uses against that node before the backend call.

- occlusion: isSnapshotNodeInteractionBlocked via assertInteractionNotBlocked
  (same covered-element error shape as the runtime path)
- offscreen: isNodeVisibleOnScreen via assertVisibleRefTarget (same
  offscreen_ref error shape)
- nonHittable: annotation only — targetHittable/hint attach to the result;
  promotion stays a runtime-path behavior and the tapped element never changes

No snapshot in the session, an unresolvable ref, or a missing/unusable rect
make the preflight a no-op: it never adds a capture round trip.

Registry: the three native-ref gap cells flip to kind 'runtime' and leave
the pinned gap list.

Refs #1081
2026-07-04 14:24:47 +02:00
Michał Pierzchała c506ddf3e7 RFC: ADR 0011 — interaction guarantee contract (path × guarantee matrix) (#1080)
* docs+feat: ADR 0011 interaction guarantee contract, Layer-1 registry and gate

Design for making interaction guarantees hold across every dispatch path
(runtime selector/ref, direct iOS selector, native ref, coordinate,
maestro fallback) instead of eroding at path boundaries one incident at
a time — every interaction bug this week was a (path, guarantee) cell
nobody was watching.

Three layers (ADR 0011): declare the path x guarantee matrix as a typed
registry whose completeness is a compile error; share one implementation
per rule on both sides of the wire with golden fixture tables proving
TS/Swift parity; prove every non-waived cell with contract scenarios
generated from the registry.

This lands Layer 1: the registry with an HONEST initial classification —
ten cells are acknowledged gap waivers (direct-path disambiguation/
occlusion/nonHittable/responseFields/errorTaxonomy, native-ref guards,
coordinate bounds) — plus the gate test that keeps entries truthful:
referenced TS symbols must be exported, runner symbols must exist in the
Swift sources, delegations must land on paths that actually enforce the
guarantee, and the gap list is pinned so it can only change explicitly
in a reviewed diff.

* refactor: apply ADR 0011 design review

- Frame Layer 1 as an honesty/completeness gate, not a truth gate:
  it proves every path declared a stance and referenced symbols exist;
  behavioral parity starts with the Layer-2/3 fixture and scenario work.
- Split responseFields into responseConstruction (one shared response
  construction site — a single Layer-2 refactor) and responseIdentity
  (which identity fields a path can provide — per-path capability work);
  note the anticipated errorTaxonomy split (codes vs diagnostics).
- Encode the hybrid gap-closure strategy: runner-side parity for
  geometry-local rules, delegation-on-error for semantic failures (with
  the explicit caveat that delegation-on-error is NOT success-path
  parity), and a shared runtime preflight for native-ref where a silent
  backend success means delegation never triggers.
- Gap waivers now require a trackingIssue (gate-enforced URL); all 16
  pinned gaps link the umbrella issue #1081. The honest reclassification
  grew the pin list from 10 to 16 — responseConstruction is a gap on
  every path including runtime ones, which is exactly the partial
  progress the coarser guarantee was hiding.
- Align ADR wording with the code: parityTable is optional until
  Layer 3, required once a runner cell claims parity.

* fix: address registry review — maestro disambiguation honesty, command-scoped verify

1. maestro-non-hittable-fallback/disambiguation was overclaimed: the
   guarantee is defined as visible-first/deepest/smallest ranking, but
   findElement only implements unique-or-ambiguous scanning. Reclassified
   as an intentional waiver (deliberate Maestro-semantics divergence),
   mirroring how the direct path keeps its success-path parity gap.

2. verifyEvidence was claimed path-wide on paths that dispatch longpress,
   which has no --verify. Cells can now be command-scoped via appliesTo
   (non-empty strict subset of the path's commands, gate-enforced), and
   the three affected cells scope to press/click/fill.
2026-07-04 14:06:21 +02:00
Michał Pierzchała 07181acc0b fix: honor wait budgets, preserve the daemon on polling timeouts, refuse off-screen selector taps (#1075)
* fix: agent-UX fixes from Bluesky dogfooding

Four fixes found by driving the Bluesky dev build end to end as an agent:

1. wait honors its user-supplied budget in the daemon request envelope.
   The wait timeout travels as a positional, so the client never extended
   the request timeout: any 'wait ... 180000' died at the 90s default.
   resolveDaemonRequestTimeoutMs now parses wait positionals (shared
   src/core parser) and extends the envelope to budget + 30s margin.

2. wait/find timeouts no longer reset the daemon. They are repeated
   snapshot captures, sharing snapshot's stalled-bridge failure mode, but
   sat on the daemon-reset path — one timed-out wait destroyed every
   session the daemon owned (observed live: wait timeout -> runner
   killed -> SESSION_NOT_FOUND on the next command).

3. Off-screen selector targets are refused instead of silently tapped.
   'tap label=Explore' against Bluesky's closed drawer reported success
   while tapping (-161, 265) — coordinates that cannot land:
   - selector disambiguation now prefers candidates whose CENTER lies in
     the root viewport (isNodeVisibleOnScreen; edge-grazing containers
     poke fractions of a pixel into the viewport and must not count),
     so 'press label=Profile' picks the visible tab over the drawer item;
   - selector-resolved interactions get the same off-screen guard refs
     already had (reason: offscreen_selector, with a scroll/open hint);
   - the runner's direct selector tap refuses matches whose center is
     outside the main window frame (app.frame unions transformed
     subtrees, so a closed drawer inflates it) with ELEMENT_OFFSCREEN,
     which falls back to the tree-based path.

4. The non-hittable 'may have had no visible effect' hint is dropped when
   --verify evidence proves the interactive tree changed — the warning
   sat directly next to data contradicting it.

Live-verified against Bluesky on the iPhone 17 Pro simulator: offscreen
tap -> offscreen_selector error; ambiguous Profile -> on-screen tab
(355, 817); 100s wait survives the old 90s envelope with the session
intact.

* refactor: dedupe resolved-target tails and off-screen guards, decompose disambiguation

Fallow follow-up on the dogfood fixes: extract the shared ref/selector
resolved-node tail (describeResolvedInteractionNode), the shared
off-screen check-and-throw skeleton (throwIfOffscreenInteractionTarget —
per-caller messages/hints preserved), and the candidate-accumulation
step out of analyzeSelectorMatches. No behavior change; suites and the
fallow gate are green.
2026-07-04 13:08:46 +02:00
Michał Pierzchała c5f7b7baa4 refactor: harden exec failure wrapping end to end (#1074)
* refactor: harden exec failure wrapping end to end

Follow-up to #1072, closing the structural gaps that let the
missing-processExitError bug class exist:

- requireExecSuccess(result, message, extra?) in utils/exec.ts: guards an
  allowFailure result and throws the curated COMMAND_FAILED (flag set via
  execFailureDetails) itself. Result-side by design — tool providers and
  executor overrides return results without throwing, so an ExecOptions
  knob interpreted at spawn time would silently not fire on those paths.
  ~30 pure guard-and-throw sites across apple/android/web converted; sites
  with tolerance branches, cleanup, or exit-0 reachability keep their
  explicit shape.

- Source-scan guard (src/__tests__/exec-wrap-guard.test.ts): fails when an
  AppError('COMMAND_FAILED', ...) details literal rebuilds the
  stdout/stderr/exitCode trio inline without the helper; intentional
  holdouts opt out with a documented exec-guard-allow comment. The guard
  immediately found three wrap sites the July audit missed (runtime-hints
  run-as probe, device-ready not_ready branch, perf export table) — now
  converted.

- coerceExecResult at the provider boundaries (executor overrides, apple
  tool provider scope, android adb provider scope): SDK-supplied callbacks
  cross an unchecked boundary; coercing once there replaces the per-site
  String(result.stdout ?? '') defensiveness, which is removed.

- normalizeError stderr excerpts now strip noise prefixes (adb:/xcrun:/
  simctl:/error:) before rendering; skip/strip lists stay in the kernel
  deliberately (platforms cannot hook normalizeError) with a comment
  marking the registry escape hatch if they grow.

- AppErrorDetails exported and documented in kernel/errors.ts: the magic
  keys (hint, processExitError, retriable, reason, diagnosticId, logPath,
  stdout/stderr/exitCode) now carry types and doc comments.

- runIosDevicectl gains tolerateOutput; the devicectl uninstall path in
  app-install.ts reuses it instead of duplicating the wrap + hint logic
  (its failure hint now falls back to the devicectl default hint).

* fix: reconcile exec hardening with the adb failure classifier

Main landed the central adb failure classifier (androidAdbResultError +
withAdbFailureHintProvider) while this branch was in flight. Resolution:

- Android call sites keep main's androidAdbResultError form — it composes
  execFailureDetails with the classified adb hint, which is strictly
  richer than a plain requireExecSuccess conversion for adb invocations.
  requireExecSuccess remains the shape for non-adb tools and apple/web.
- Provider result coercion folds into withAdbFailureHintProvider's
  enrichment pass (exec/pull/install), so the existing WeakSet memo also
  prevents coercer stacking.
- The reverse-remove wrap in adb-executor now uses androidAdbResultError
  instead of hand-rolling the trio (flagged by the exec-wrap guard).
- Guard-test scan split into helpers (fallow cyclomatic threshold) and
  the perf artifact-tail clone carries fallow-ignore markers on both
  platforms, matching the pre-existing marker on the apple side.
- Two expectations updated for the stderr noise-prefix strip: classifier
  compose test and perf sampling reason ('device offline', no 'error:').
2026-07-04 12:56:50 +02:00
Michał Pierzchała 9aae457533 fix(errors): close call-site and consumer gaps around the central error system (#1071)
* fix(errors): close call-site and consumer gaps around the central error system

Audit + iOS/Android dogfood findings (see docs/adr/0010-error-system.md):

- press/click/fill targets that parse as neither @ref, selector, nor point
  now fail with INVALID_ARGS grammar guidance (incl. unquoted multi-word
  selector values) instead of UNKNOWN 'Expected x to be a finite number'
- daemon command-input validation throws AppError INVALID_ARGS instead of
  bare Error surfacing as UNKNOWN
- selector-no-match and stale-ref failures carry targeted hints
  (selectorFailureHint / STALE_REF_HINT)
- retriable/supportedOn survive wire rehydration to CLI --json and SDK
  (previously dropped at throwDaemonError / toDaemonHttpRpcError)
- MCP tool errors carry code + hint instead of message-only text
- lease busy/capacity use DEVICE_IN_USE (the retriable code)
- asAppError(err, fallbackCode) replaces cause-dropping coercions in the
  Apple runner; new default hints for AMBIGUOUS_MATCH, DEVICE_IN_USE,
  UNSUPPORTED_PLATFORM, and a distinct UNKNOWN hint
- ADR 0010 documents the error-system conventions

* fix: format touched files and clear fallow audit gate

- privatize SELECTOR_NO_MATCH_HINT / SELECTOR_NOT_UNIQUE_HINT (consumed
  only via selectorFailureHint in the same module) and integerSchema
  (only used inside command-input.ts)
- dedupe the resolved-node return tail in interaction resolution into
  describeResolvedNode
- extract stringDetail/booleanDetail readers so normalizeError stays
  under the complexity threshold

* fix: reject unquoted trailing text after interaction selectors

press/click/longpress positionals like 'press text=Gesture lab' used to
silently drop the leftover tokens and act on the truncated selector
(text=Gesture), potentially hitting the wrong element. Reject non-empty
splitSelectorFromArgs rest with INVALID_ARGS guidance that suggests the
merged quoted form (text="Gesture lab"). Fill keeps consuming rest as
its text payload; wait/is/replay-heal already handle rest explicitly.
2026-07-04 12:32:21 +02:00
Michał Pierzchała 67703ed863 feat: classify adb failures with actionable hints at the executor level (#1067)
* feat: classify adb failures with actionable hints at the executor level

Android COMMAND_FAILED errors surfaced raw adb stderr with only the
generic retry-with-debug hint; the sole stderr classification lived in
snapshot.ts and was retry-only. Add a central classifier in
adb-executor.ts that recognizes the common adb failure families (device
offline/unauthorized/not found, more than one device, no devices,
server version mismatch, connection drops, INSTALL_FAILED_* variants)
and attaches the resolved hint — plus retriable for clearly transient
families — to the AppError details in both executor funnels (local
serial exec and provider-scoped exec), so every adb call site benefits
without per-site changes. The adb devices -l discovery call is wrapped
too, where server-mismatch/no-devices failures actually surface.

snapshot.ts now consumes the shared classifier for its retry decision,
keeping only its snapshot-specific dump-race patterns. normalizeError
lifts details.retriable to the top-level error (mirroring the hint
lift) and the daemon typed-error graft lets a throw-site classification
win over the code-level policy. Also fix the port-reverse removal path
throwing a bare Error that surfaced as UNKNOWN.

Update session-appstate-input-perf.test.ts: its Android sampling-failure
fixture uses stderr "error: device offline", which the classifier now
recognizes, so error.hint carries the actionable adb-reconnect hint
instead of the generic "retry with --debug". The result shape (hint
string, details.metric/package, code) is unchanged — only the hint text
improved, which is this change's intent — so the test expectation moves
to the new hint rather than reverting production.

* feat: classify tolerated adb failures and semantic provider methods

Review follow-up closing two classifier coverage gaps:

1. allowFailure paths bypassed the classifier: the executor wrapper only
enriches THROWN errors, but many Android flows run adb with allowFailure,
inspect the nonzero result, and throw a fresh AppError built from its
stdout/stderr — so e.g. "device offline" during uninstall still surfaced
the generic hint. There was no common construction point for these
errors, so androidAdbResultError in adb-executor.ts now IS that point:
it builds the COMMAND_FAILED error from a tolerated result and runs it
through the classifier, keeping hint strings central. The
result-inspecting throw sites (app launch/uninstall, appearance read,
keyboard/clipboard queries, package listing, logcat capture, helper APK
installs, heap-dump pull, uiautomator dump) route through it; a site
hint (heap-dump pull) still wins because attachAdbFailureHint never
overwrites.

Composes with #1072 rather than replacing it: androidAdbResultError
builds its details via execFailureDetails for nonzero exits, so
normalizeError still suffixes the curated message with the first stderr
line while the classified hint/retriable/adbFailure ride along. Semantic
failures thrown at exit 0 (the am start error-on-stdout path) stay
unflagged, matching #1072's deliberate exit-0 exclusion.

2. Provider-scoped enrichment only wrapped exec, but the transfer
helpers prefer the semantic provider methods, so a provider whose
install rejects with INSTALL_FAILED output kept the generic hint.
Enrichment now lives in normalizeAndroidAdbProvider — the single funnel
every provider passes through (scope installation and explicitly passed
providers alike) — wrapping exec plus pull/install/installBundle; a
WeakSet keeps repeated normalization from stacking wrappers. The local
provider needs no wrap since its methods delegate to the already
enriched serial executor.

Tests: androidAdbResultError classification, hint-plus-excerpt
composition through normalizeError, exit-0 no-excerpt guard, site-hint
precedence, and provider install/pull classification in
adb-executor.test.ts; an allowFailure regression on a real production
path (keyboard state query with a nonzero device-offline result) in
device-input-state.test.ts.
2026-07-04 12:20:28 +02:00
Stanisław Chmiela 9407839f6e feat: tag daemon artifacts with semantic types (#1066)
* feat: tag daemon artifacts with semantic types

* Normalize artifact type threading through daemon tracking

* Add timeout error to http server artifact test helper

* test: restore artifact wait helper behavior

* test: fail artifact wait helper on timeout

* refactor: model artifactType as optional on wire shapes

Applies the design-review tweak: producer-owned APIs (reserveOutput,
trackDownloadableArtifact, finalization callbacks) keep the required
'DaemonArtifactType | undefined' form so artifact owners must explicitly
decide, while public/wire/result shapes (DaemonArtifact, both artifact
inventory entry types) become 'artifactType?:' — missing metadata is
valid, JSON drops undefined, and remote/older daemons may omit the
field. Construction sites now omit the key for untyped artifacts, and
the finalization test asserts key absence (toEqual cannot distinguish
absent from explicitly-undefined).

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-07-04 12:19:47 +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 4711bc434b fix: report expired daemon resources as expiry, not INVALID_ARGS (#1069)
* fix: report expired daemon resources as expiry, not INVALID_ARGS

TTL/GC'd registry entries (downloadable/uploaded artifacts, resumable
uploads, materialized paths) surfaced as INVALID_ARGS, so agents got
"Check command arguments and run --help" when the resource had simply
expired. Add a shared requireTenantOwnedEntry helper that reports these
as COMMAND_FAILED with reason RESOURCE_EXPIRED and a per-resource
recovery hint, and dedupes the paired wrong-tenant UNAUTHORIZED check.

Also: give request cancellation an explicit "canceled intentionally"
hint instead of the misleading retry default; replace the ad-hoc
INTERNAL_ERROR code (not in the KnownAppErrorCode union) with UNKNOWN
plus a report-a-bug hint; and fix materialized-path TTL expiry of
tenant-owned entries, which threw UNAUTHORIZED in a void-ed promise
(unhandled rejection) and leaked the entry and its temp dir.

* fix: report expired resumable upload tickets as expiry, not INVALID_ARGS

Route requireResumableUpload() through the shared requireTenantOwnedEntry
helper so a missing/expired upload ticket on PUT /upload/direct/:id and
POST /upload/finalize returns the COMMAND_FAILED + RESOURCE_EXPIRED shape
(with a recovery hint) instead of the misleading INVALID_ARGS/400.

Genuinely invalid input (malformed preflight args, bad content-range,
incomplete upload, hash mismatch) stays INVALID_ARGS.
2026-07-04 10:46:49 +02:00