1205 Commits

Author SHA1 Message Date
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
Michał Pierzchała ab55db0b83 feat: add wait stable settle primitive (#1059)
* feat: add wait stable settle primitive

Add `agent-device wait stable [quietMs] [timeoutMs]` (defaults 500/10000)
as a new target kind on the existing wait command/plumbing: it polls the
interactive-only tree via the existing selector-snapshot backend at the
existing wait poll cadence, and resolves once two or more consecutive
captures are unchanged for quietMs, comparing a small local per-node
digest (type, label, identifier, rounded rect). Timeout throws the
standard wait COMMAND_FAILED shape with capture stats (captures,
nodeCount) attached, and the wait-current-surface enrichment now also
recognizes the stable timeout message. Successful results add
settledAfterMs/captures/nodeCount alongside waitedMs.

This rides the same CLI grammar -> command catalog -> daemon handler ->
runtime poll loop as `wait text`/`wait selector`; no new runner protocol.
Polling snapshots intentionally do not update the session cache, since
the interactive-only tree is a settle signal and would otherwise degrade
subsequent ref/get/find lookups in the same session.

Documents wait stable in `help workflow` as the replacement for guessed
sleeps between actions and snapshots in the core loop.

Closes #1049

* fix: bound wait stable by the user timeout when a capture stalls

The stable loop only checked the deadline between captures, so a stalled
backend capture (observed with macOS AX captures) rode the wait into the
90s daemon request timeout instead of the user-supplied timeoutMs.

- Race each settle capture against the remaining wait budget with a real
  timer (test clocks advance synthetic time and cannot represent a hung
  backend call) and abandon the capture when the deadline wins, throwing
  wait_stable_timeout with captureStalled: true and a hint.
- Skip the current-surface enrichment on the stalled path: it fires
  another capture, which is just as slow or hung and pushed the response
  ~8s further past the timeout on macOS.

Verified live against macOS System Settings: cold-runner stall now exits
in 10.1s for a 10s timeout (previously ~90s); warm settle unchanged.
2026-07-04 10:28:59 +02:00
Michał Pierzchała 1c95bd319b fix: accept selector-first is form and print find action confirmations (#1068)
* fix: accept selector-first is form and confirm find actions

is <selector> <predicate> failed with a misleading predicate error because
every entry point read positionals[0] as the predicate, and the trailing
predicate token could not be recovered later: visible/hidden/editable/selected
double as selector boolean keys, so greedy selector parsing swallowed it.
Normalize both argument orders into the canonical predicate-first shape at the
CLI reader and splitIsSelectorArgs, and explain the key/predicate collision in
the error hint when nothing parses.

find click succeeded silently: the delegated click response (with its
'Tapped @ref (x, y)' message) was discarded, and the find formatter printed
fill's raw text field instead of its success message. Carry the success
message through find click and print message-first, so find click/fill/focus/
type confirm exactly like their direct counterparts.

* style: format cli-grammar test
2026-07-04 10:27:26 +02:00
Michał Pierzchała 83a1eec986 fix: surface typed error codes for metro, MCP, and batch validation failures (#1070)
Bare `throw new Error(...)` surfaces to users as code UNKNOWN with a
generic hint. Convert the remaining user-reachable clusters from the
July 2026 error audit to AppError with the right code, preserving
messages:

- metro/client-metro.ts: timeouts, bridge failures, and Metro start /
  not-ready errors are now COMMAND_FAILED (the not-ready error also
  carries logPath in details); createMetroBridgeRequestError builds on
  AppError so non-retryable bridge errors that escape via rethrow get
  the right code too
- mcp/command-tools.ts + mcp/router.ts: MCP input validation is now
  INVALID_ARGS (JSON-RPC responses unchanged; the router only reads
  .message)
- commands/batch/metadata.ts: step validation is now INVALID_ARGS;
  with the plain-Error opt-out gone, the now-unused
  BatchStepErrorFactory injection seam is removed from
  batch-contract.ts
- cloud-webdriver/webdriver-source.ts: xml.ts parser failures are
  wrapped at the consumer boundary into a single COMMAND_FAILED naming
  what was being parsed
2026-07-04 10:26:24 +02:00
Michał Pierzchała 1009086145 fix: make artifact inventory consumption deterministic on download (#1073)
Artifact downloads consumed the inventory entry only from the source
stream's 'end' / response 'finish' handlers. But the response carries an
explicit content-length, so a client's fetch completes as soon as that
many body bytes arrive - which happens right after the final chunk is
written to the socket, while the server's read stream still needs one
more threadpool read to detect EOF and emit 'end'. Under CPU contention
(e.g. the instrumented Coverage CI job) that threadpool completion can
land after the client's follow-up GET /artifacts, which then still lists
the consumed artifact.

Consume the entry as soon as the stream has emitted content-length
bytes, before pipe() writes the final chunk to the client. Any client
that observed a complete download is now guaranteed to observe the
entry as consumed. The 'end'/'finish'/'close' handlers stay as backstops
(idempotent via didCleanupArtifact).
2026-07-04 10:25:31 +02:00
Michał Pierzchała 50a6a3533a fix: surface stderr excerpts from hand-rolled exec failure wraps (#1072)
* fix: surface stderr excerpts from hand-rolled exec failure wraps

normalizeError only replaces a generic COMMAND_FAILED message with the
first meaningful stderr line when details.processExitError is true, but
only createExitError in utils/exec.ts set that flag. Hand-rolled wrap
sites (throw new AppError('COMMAND_FAILED', msg, {stdout, stderr,
exitCode}) after an allowFailure run) missed it, so users saw messages
like "xcrun exited with code 22" with a generic hint instead of the
actual stderr excerpt.

Add execFailureDetails(result, extra?) to utils/exec.ts returning the
stdout/stderr/exitCode spread plus processExitError: true (extras spread
after the base keys so call sites can keep truncation or String()
normalization), and convert ~42 exit-guarded wrap sites across the apple
platform (apps launch/terminate paths, devicectl, simulator boot, perf,
perf-xctrace, runner modules, dsym/symbolication, macOS host provider)
and android/web (app-helpers, logcat, devices, perf, settings,
snapshot helpers, app-lifecycle, device-input-state, multitouch-helper,
agent-browser provider).

Sites reachable at exit 0 (parse/stdout checks), failure aggregations,
timeout/forced-kill errors, and sites that already build richer messages
(macos helper JSON, runner-contract early-exit, maestro run-script) are
deliberately left unflagged so stderr enrichment never replaces a more
specific message.

* fix: append stderr excerpts to curated wrap messages instead of replacing them

Spreading processExitError to curated call sites made normalizeError
replace specific messages like 'uiautomator dump did not return XML'
with the bare stderr line, losing the operation context (caught by the
provider-failures integration test). Keep wholesale replacement only for
the information-free generic '<tool> exited with code N' wraps; curated
messages now keep their specific reason and gain the stderr excerpt as a
': <excerpt>' suffix (skipped when already present).

Separate commit rather than amend so the semantics change to
maybeEnrichCommandFailedMessage stays individually reviewable on the PR.
2026-07-04 10:25:03 +02:00
Michał Pierzchała 4a8a7bb895 feat(snapshot): dedup inherited labels/identifiers in output; promote diff snapshot (#1063)
* feat(snapshot): dedup inherited labels/identifiers in client output; promote diff snapshot

Deep RN/AX trees repeat the same accessibility label across a whole parent
chain (ScrollView -> Other -> Button -> inner Button), so agents pay for the
same string 3-4x in one snapshot. When a node's label (or, separately,
identifier) string-equals its nearest ancestor's, the CLI/JSON client output
now omits it and sets an `inheritsLabel`/`inheritsIdentifier: true` marker
instead; the human text renderer shows "[same label as parent]" in its place.

This is output-only: it runs in snapshotCliOutput (src/commands/capture/output.ts),
which feeds both the default text and --json presentations, right before they
are handed to the caller. The in-daemon session tree that selector building
(buildSelectorChainForNode), wait/is/get matching (selectors-match.ts), and
replay/replay-heal read from is never touched, since none of those consumers
go through this serializer -- they all read session.snapshot.nodes directly.
--raw and non-default MCP responseLevels (e.g. digest, which can omit `nodes`
entirely) bypass dedup and stay byte-for-byte.

Also promotes `diff snapshot` as the named post-action verification step in
the core-loop help text, replacing the generic "verify" step: it diffs the
rendered snapshot lines against the previous snapshot in the session and
prints only what changed, which is the actual token-cheap way to confirm a
mutation today (discovery, not new behavior).

* fix: dedup only labels long enough to pay for the marker

Review finding: dedup fired on any string-equal duplicate, but the
replacement markers cost ~21 chars in JSON and ~23 in text — short
duplicated labels (tab bars: Home, Settings) made output BIGGER, not
smaller. Only values of 24+ chars dedup now, which is net-positive on
both surfaces and keeps short labels readable inline. Fixtures updated
to threshold length; added a short-label regression test.
2026-07-04 10:18:23 +02:00
Michał Pierzchała e3566b638a perf: smaller minified output and a lighter eager CLI startup path (#1065)
* build: run SWC minifier compress with 3 passes

Measured -9.7 kB raw / -7.0 kB npm tarball on the emitted JS at no
runtime cost. splitChunks minSize tuning was also evaluated for the
tiny-chunk overhead but measured as a byte-identical no-op (the sub-kB
files are dynamic-import boundaries, not split products), so it is
intentionally not included.

* perf(cli): keep help text, replay reporting, and diff runtime off the eager command path

Every CLI invocation eagerly parsed 599 kB of JS. Three static imports
dragged in code that most commands never run:

- cli.js -> cli-help.js (75 kB of help text) via the usage()/
  usageForCommand() builders in parser/args.ts, which are only needed
  on help and usage-error paths. They now lazy-import cli-help.ts.
- cli.ts/generic.ts -> replay/test/reporting.ts (~17 kB), only needed
  by the test command. Now imported at the call sites.
- cli/commands/screenshot.ts -> createAgentDevice (68 kB client-side
  command runtime chunk incl. screenshot pixel diffing), only needed
  by the diff screenshot branch. Now imported inside diffCommand.

Eager closure of cli.js drops 599 kB -> 431 kB (-28%), ~2.5 ms median
module-load per command invocation. --help/--version fast paths in
bin.ts are unchanged. Package size is unchanged by design (the code
moves to lazy chunks; it does not disappear).
2026-07-04 09:04:32 +02:00
Michał Pierzchała e833de474d refactor: split oversized session and apple app files (#1061)
* refactor: split oversized session and apple app files

* fix: satisfy ci after file split
2026-07-04 02:02:06 +02:00
Michał Pierzchała f98111ef7e feat(help): document behavioral guarantees in help workflow (#1051) (#1054)
Add a "Guarantees" section to `agent-device help workflow`: statements of
fact for agents to reason from instead of probing behavior with trial
commands. Covers selector ambiguity (folds/extends the existing #1040
paragraph instead of duplicating it), hittability/targetHittable, open
idempotent-foreground vs --relaunch's single simctl launch
--terminate-running-process call, close's runner retention policy plus the
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS idle-stop window, ref lifetime
(cleared by open/relaunch), diff snapshot's comparison baseline, and wait's
polling model. Each statement is traceable to source, noted in the PR body.

Adds three doc-assertion tests in cli-help.test.ts mirroring the #1040
pattern.
2026-07-03 14:03:44 +02:00
Michał Pierzchała 2adf3d5535 refactor: split runner xctestrun module (#1062) 2026-07-03 13:10:49 +02:00
Michał Pierzchała b92883e883 fix: clarify prepare ios-runner timing overlap (#1058) 2026-07-03 13:06:08 +02:00
Michał Pierzchała db07c200f4 refactor: centralize known CLI command checks (#1057)
* refactor: centralize known cli command checks

* feat: emit drift diagnostic for registered-but-unhandled commands

Folds #1055's telemetry into this branch: known-command fall-through now
emits cli_known_command_unhandled at error level alongside the distinct
user-facing message, so catalog/dispatch drift is visible in diagnostics
as well as to the user who hits it.
2026-07-03 12:32:52 +02:00
Michał Pierzchała b1d77c68af docs: document conservative revisit triggers (#1056) 2026-07-03 12:11:42 +02:00
Michał Pierzchała 5e8a90a924 refactor: add shared ttl memo reset registry (#1060) 2026-07-03 12:02:18 +02:00
Michał Pierzchała 016ce8d8a6 feat: accept tap as an alias of press (#1053)
* feat: accept tap as an alias of press

* test: restore #1036 ordering regression coverage, assert tap dispatches press

Making tap a real alias deleted the only tests covering
unknown-command-before-flag-validation ordering (they used tap as the
unknown command). Restore them with a genuinely unknown command, and
strengthen the alias test to assert the dispatched call records press
with positionals preserved instead of merely not-erroring.

* test: cover tap alias composing with the bare-ref hint

Adopted from #1052 (credit: @vku2018): tap e3 must normalize to press
and then surface the @e3 suggestion, proving the alias and the bare-ref
hint compose instead of masking each other.
2026-07-03 12:00:34 +02:00
Michał Pierzchała 0453826b2e fix: report unknown command before flag-support validation (#1038)
* fix: report unknown command before flag-support validation

When an unknown command is provided with flags, the parser was
validating flag support before checking if the command exists,
resulting in misleading "Flags are not supported for command X"
errors instead of "Unknown command: X".

This fix reorders the validation to check for unknown commands
first, reporting more helpful error messages. Additionally, when
the unknown command is 'tap', the error now includes a hint
suggesting 'press or click' as alternatives.

Closes #1036

* refactor: drop as-any casts and note the cli.ts sync requirement

Review follow-ups: widen the command lists to readonly string[] instead
of narrowing the value, and document that isCommandKnown must stay in
sync with the cli.ts dispatch fall-through.
2026-07-03 10:15:13 +02:00
Michał Pierzchała a79dad69f4 feat(interaction): surface targetHittable/hint for non-hittable press/fill targets (#1040)
When a resolved press/fill/click selector or ref target lands on a node
that reports hittable: false (after any hittable-ancestor promotion), the
success response now carries targetHittable: false and a hint suggesting
the interaction may have had no visible effect. Resolution semantics are
unchanged: no failing, no filtering, no strict-unique matching — iOS AX
hittable flags are unreliable on deep RN trees, and react-navigation's
maestro suite depends on today's auto-disambiguation behavior (#1037).

Also documents the selector disambiguation policy (deepest node, then
smallest area, exact ties fail) in `help workflow`, including that
replay/replay-heal apply the same policy for touch/fill/get-text.
2026-07-03 10:14:45 +02:00
Michał Pierzchała 9b19aeee7d fix: hint @ref when a bare snapshot ref is passed as interaction target (#1039)
When a user forgets the @ prefix on a snapshot ref (e.g., 'click e3' instead
of 'click @e3'), the positional parser would attempt to parse it as
coordinates, resulting in a confusing error message "Expected x to be a
finite number."

Add a check in readInteractionTargetFromPositionals and readFillTargetFromPositionals
to detect bare snapshot refs matching /^e\d+$/ and throw INVALID_ARGS with
a helpful hint message suggesting the correct @-prefixed form.

Closes #1035
2026-07-03 10:01:21 +02:00
Michał Pierzchała bff28009e2 0.18.3 v0.18.3 2026-07-02 21:03:48 +02:00
Michał Pierzchała 1d916b1d76 fix: capture ios device logs with devicectl console (#1026)
* fix: report unsupported ios device log streaming

* docs: clarify test app device verification

* docs: clarify alternate metro port flow

* refactor: split app log diagnostics helpers

* refactor: tighten app log diagnostics shape

* fix: capture ios device logs with devicectl console

* fix: tighten ios device log capture state

* refactor: clarify ios log capture support flow

* refactor: consolidate ios log diagnostics flow
2026-07-02 21:03:35 +02:00
Michał Pierzchała a4b35c4226 feat: list daemon artifacts through proxy sessions (#1027) 2026-07-02 20:27:51 +02:00
Szymon Dziedzic 08594fced9 feat: expose daemon artifact inventory (#1020)
* feat: expose daemon artifact inventory

* refactor: isolate daemon artifact HTTP routes

* test: reduce proxy artifact test complexity

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-07-02 20:14:16 +02:00
Michał Pierzchała c4e1fc887a feat: warm the iOS runner build cache from doctor and boot (#1033)
* feat: warm the iOS runner build cache from doctor and boot

Cold-cold startup (first run ever on a machine) pays a ~10s xcodebuild
build-without-testing before the first open can even start the runner.
The background cache prewarm existed but only fired on simulator cold
boot during open — too late for the fresh-machine flow.

- doctor now resolves an iOS simulator from the session or device
  inventory and, when the runner artifact is missing, kicks the
  best-effort background build and reports it as an ios-runner-cache
  check ('build started in the background' / 'artifact cached'). The
  artifact builds against a generic simulator destination, so any
  simulator record works and the result is shared across runtimes.
- boot fires the same warmup for already-booted iOS simulators (cold
  boots already warmed via the onIosSimulatorColdBootStart hook).
- help workflow, help debugging, doctor --help, and the agent-device
  skill now say to run doctor first on fresh machines, so agents
  (including small models) discover the warmup instead of paying the
  build inside their first open. Doctor's help no longer claims to be
  strictly read-only.

Verified live: wiped artifact cache -> doctor returns immediately with
the warming check while the build runs detached -> artifact present
~15s later -> second doctor reports cached; first open then skips the
build entirely (~10s off cold-cold).

* feat: show doctor runner warmup progress and guard remote/non-mac hosts

The warmup now emits request-progress lines ('Checking iOS runner build
cache (device)...' / 'Warming ... in the background') so doctor
progressively shows what it is doing, and it is guarded to environments
where a local xcodebuild makes sense: non-macOS hosts and provider-backed
devices skip it entirely (--remote already returned before device
checks). Tests cover both guards with a mocked platform.

* fix: validate the runner cache probe like the ensure path

Review finding (P2): hasCachedAppleRunnerArtifact treated any .xctestrun
under the expected derived dir as cached, while the open path rejects
partial/stale caches via cache-metadata and product-path validation - so
doctor could report 'artifact cached', skip the warmup, and leave the
first open paying the build anyway.

The probe now runs the same evaluateExistingXctestrun no-build evaluation
the ensure path uses (extracted into a shared device-scoped helper) and
reports cached only on reuse_ready. Regression tests cover the empty and
stray-xctestrun-only derived dirs; live-verified that a cache with
deleted metadata now triggers the background warmup instead of reporting
cached, and that a genuinely valid cache still probes true.
2026-07-02 20:09:33 +02:00
Michał Pierzchała 091179e91d 0.18.2 v0.18.2 2026-07-02 17:45:48 +02:00
devin-ai-integration[bot] d18066ff2b fix(cloud-webdriver): stop artifacts command from allocating a new provider session (#1030)
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-02 17:44:21 +02:00
devin-ai-integration[bot] a3c492be10 fix(cloud-webdriver): extend remote lease TTL for cloud provider connections (#1029)
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-02 17:11:26 +02:00
devin-ai-integration[bot] 7777fa549e fix(aws-device-farm): send requested platform in WebDriver capabilities (#1028)
The AWS Device Farm runtime is registered with a hardcoded platform of 'android', so baseSessionForLease builds the createSession capabilities with platformName 'Android' before prepareSession resolves the real platform. On an iOS device Device Farm rejects this with "reserved ... invalid values: ['platformName']". Rebuild webdriverCapabilities in the AWS prepareSession using the resolved platform and device name (mirroring the BrowserStack provider).

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-02 16:33:50 +02:00
Michał Pierzchała 6dc0aa550e perf: collapse iOS simulator relaunch into one simctl launch call (#1024)
* perf: collapse iOS simulator relaunch into one simctl launch call

open --relaunch on a simulator dispatched close (simctl terminate,
~0.7s), settled 300ms, then dispatched open (simctl launch, ~0.8s).
simctl launch --terminate-running-process does the whole relaunch in one
call (~0.94s), so the fast path skips the close dispatch and the
post-close settle: steady relaunch ~2.9s -> ~2.0s.

The terminateRunningApp option threads from the open context through the
interactor to buildIosSimulatorLaunchArgs; only the simulator bundle-id
launch path consumes it. Runtime hints keep working - they are
user-defaults writes read at the next launch, which the collapsed call
still performs. --clear-app-state keeps the close-first ordering so it
never mutates a running app's container; real devices and Android keep
the two-step relaunch.

* fix: keep close-first ordering for URL relaunches on simulators

Review finding: open <app> <url> --relaunch reached the collapsed fast
path, but the URL dispatch branch never consumes terminateRunningApp -
a deep-link open never launches the app, so there is nothing to attach
the terminate to. The session layer skipped the close while the platform
layer did not terminate either, silently losing relaunch semantics.

The collapse now applies only to the single app-launch form
(openPositionals.length === 1); URL relaunches keep the explicit
close-then-open sequence, with a regression test.
2026-07-02 15:53:33 +02:00
Michał Pierzchała b67053e975 fix: bound retained iOS runner lifetime with an idle stop (#1025)
* fix: bound retained iOS runner lifetime with an idle stop

A runner retained after session close (#1021) holds the device's runner
lease indefinitely, which blocks every other daemon on the machine from
using the device - observed in the wild when a leftover verification
daemon squatted a simulator through its retained runner.

Session close now schedules an idle stop when it retains the runner:
if nothing touches the runner within 5 minutes (any ensureRunnerSession
call cancels the timer), it is stopped and the lease released.
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS overrides the window; 0 disables
idle stops entirely (the previous retain-until-daemon-exit behavior).
The timer is unref'd and cleared on explicit stop and shutdown detach.

Runners warmed by explicit prepare ios-runner are not timed - prepare
expresses intent to keep a warm runner, and its ensureRunnerSession call
cancels any pending stop.

* test: reset idle-stop scheduler mock between session tests

Review finding on #1025: every other mock in the file resets in
beforeEach; without it the retention assertion could pass on call
history leaked from an earlier close test.
2026-07-02 15:29:45 +02:00
Michał Pierzchała 3e8401a88e fix: surface open timing in debug diagnostics (#1023) 2026-07-02 14:12:45 +02:00
Michał Pierzchała 505e2af12f feat: add opt-in exec command diagnostics (#1019)
* feat: add opt-in exec command diagnostics

* fix: flush exec trace diagnostics on success

* fix: trace background exec spawns

* test: lower exec trace test complexity
2026-07-02 14:05:47 +02:00
Michał Pierzchała fcb7e32f1c fix: clarify clean-xcuitest output (#1018)
* fix: clarify clean-xcuitest output

* fix: simplify clean-xcuitest formatter

* fix: clarify clean-xcuitest failure output
2026-07-02 13:53:42 +02:00
Michał Pierzchała 9e14f0e8eb fix: retain hot iOS simulator runner on safe close (#1021) 2026-07-02 13:51:11 +02:00
Michał Pierzchała ce39f106ee perf: overlap iOS runner prewarm, seed booted-memo, hand runners off across daemon restarts (#1011)
* perf: start iOS simulator runner prewarm before close/open dispatch

Since simulator close/open ride simctl and never touch the runner, the
xcodebuild ramp can overlap the app (re)launch instead of following it.
Real devices keep the post-open prewarm because relaunchCloseApp tears
their runner down first; the Maestro prewarm-before-open path is
unchanged.

Daemon-cold plain open: first snapshot 4.6-5.1s -> ~2.5s (the ramp gets a
~2s head start). Daemon-cold open --relaunch: ~8.9s -> ~8.2s mean; most
of the head start is eaten by ramp slowdown under concurrent simctl work,
matching earlier contention observations.

* perf: seed the simulator booted-memo from the device inventory parse

A simctl listing that reports a simulator Booted is the same observation
ensureBootedSimulator would make, so resolving a device now seeds the
memo and the ensureDeviceReady boot check that follows in the same
request skips its own ~0.7s listing. Daemon-cold plain open: ~2.6s ->
~2.0s.

* perf: hand iOS simulator runners off across daemon restarts

Graceful daemon shutdown detaches healthy simulator runner sessions
instead of killing them: the lease token is rewritten to a detached form
(so the daemon's own teardown paths no longer classify it as owned) and
the xcodebuild/runner pair keeps running. The next daemon start adopts a
stale lease when the runner process is alive, the artifact fingerprint
matches the current toolchain, and an uptime probe answers - otherwise
the existing cleanup-and-restart hygiene applies unchanged. Crash-killed
daemons leave the same stale lease, so crash recovery and deliberate
handoff share one path.

The adopted session wraps the orphaned xcodebuild in a pid-backed
surrogate child; every downstream consumer (liveness, kill-tree,
early-exit detection, disposal wait) operates on pid/exitCode, which a
low-frequency exit poll maintains.

Bounds and escape hatches: at most one detached runner per device, the
runner's XCTWaiter self-expires after 24h, clean:daemon still kills by
lease runnerPid, and AGENT_DEVICE_IOS_RUNNER_DETACH=0 disables both
detach and adoption.

Daemon-cold open --relaunch with a handed-off runner: ~8s -> ~3.2s
(startup.durationMs ~780), for both SIGTERM and SIGKILL'd predecessors.

* fix: keep scoped simulator-set runners out of the shutdown handoff

Review finding on #1011: detach released the XCTestDevices device-set
redirect while the runner kept running, and adoption rebuilt the session
without it - a custom-set runner could outlive the symlink/lock that its
xcodebuild depends on, breaking scoped simulator-set isolation.

The redirect's lifetime is bound to the owning session by design, so
scoped-set runners simply do not participate in the handoff: shutdown
detach skips any session holding a redirect (it goes through the normal
dispose-and-restore path), and adoption refuses devices that resolve to a
custom simulator set. Default-set sessions - the only ones that never
hold a redirect - keep the fast handoff.

* fix: gate relaunch runner teardown on Apple platforms explicitly

'device.platform !== \'android\'' predates the platform set growing to
include linux and web - for those it would take the runner-session lock
and walk the lease cleanup path on relaunch for a device that can never
have an XCUITest runner. isApplePlatform() states the actual intent:
Apple non-simulator targets tear down, simulators stay hot, everything
else never touches the runner.

* refactor: quality pass over the iOS startup perf stack

Applied from a 4-angle review (reuse / simplification / efficiency /
altitude) of the #1011 diff:

- RunnerSession.child is now a RunnerProcessHandle (pid/exitCode) instead
  of ChildProcess: every consumer only reads pid/exitCode and kills by
  pid, the adoption surrogate no longer needs double casts, and the
  compiler enforces what a comment used to promise. Real children satisfy
  the handle structurally.
- session-open prewarm scheduling collapsed into schedulePrewarm/
  awaitPrewarm closures; the fallback guard is a dedicated local instead
  of reading the OpenTiming telemetry object (kept separate from the
  promise itself, which is legitimately undefined when prewarm is
  unavailable).
- adoption: expected derived path computed once and threaded through
  (resolveExpectedRunnerCacheMetadata does a recursive source-stat walk
  per call, and it runs under the lease lock); guard chain flattened into
  skip(reason) early returns; probe timeout 2s -> 500ms (localhost
  refusal is instant, the timeout only bounds the wedged-runner case);
  dead path.dirname fallback removed.
- shared normalizeRunnerStartupTimeoutMs and buildRunnerSessionId in
  runner-session-types; deleted the verbatim copies.
- detach kill switch parses through utils/source-value parseBooleanLiteral
  (now exported) instead of a bespoke dialect that treated 'no' as
  enabled.
- dropped buildDetachedRunnerLease's untriggerable idempotence guard,
  direct Map iteration in the detach loop, and a freshness warning on
  markSimulatorBooted for future callers.
2026-07-02 12:38:41 +02:00
Michał Pierzchała 657442260e fix: reduce iOS runner keepalive log noise (#1017) 2026-07-02 12:29:13 +02:00
Michał Pierzchała 996d93e979 perf: cut iOS open --relaunch from 11s to 2.85s (#1010)
* perf: keep iOS simulator runner hot across open --relaunch

open --relaunch tore down the XCUITest runner session before closing the
app, then paid a full xcodebuild test-without-building restart (~6s) after
reopening it. Simulator close/open go through simctl and never touch the
runner, so a healthy runner survives the relaunch; stale runners are
recovered by the readiness preflight + invalidate/restart path that landed
after #705 reverted #700's version of this.

Steady-state open --relaunch on iPhone 17 Pro sim: 11.0s -> 4.9s
(startup.durationMs 5929 -> 1574). Real devices keep the conservative
teardown.

* perf: drop redundant simctl inventory listings from the iOS open path

A single open --relaunch spawned `simctl list devices -j` (~0.7s each)
three times: session-device re-resolve, closeIosApp's booted check, and
launchIosSimulatorApp's booted check.

- ensureBootedSimulator now keeps a 5s recently-observed-Booted memo
  (mirroring DEVICE_READY_CACHE_TTL_MS at the daemon layer) so repeated
  boot checks inside one request cost nothing; shutdownSimulator
  invalidates it, boot transitions seed it.
- refreshSessionDeviceIfNeeded skips the re-resolve while the device's
  XCUITest runner session is alive - a live runner attached to the UDID
  already proves the simulator exists and is booted.

With the runner kept hot across relaunch, steady-state open --relaunch on
iPhone 17 Pro sim drops 4.9s -> 2.85s (startup.durationMs ~1574 -> ~760).
Combined with the previous commit: 11.0s -> 2.85s.

* test: expect single simctl listing in tvOS provider flow

The recently-observed-Booted memo removes the repeat state listings the
launch and terminate boot checks used to make; the transcript now records
one listing per flow.
2026-07-02 11:33:43 +02:00
Michał Pierzchała ed1f30c34f feat: surface appleOs discriminant on public device output (#1009)
* feat: surface appleOs discriminant on public device output

Additive, non-breaking: the public device/result shapes now carry the
stored Apple-OS discriminant (iPhone/iPad/tvOS/visionOS/macOS) alongside
the existing leaf platform (ios/macos). Consumers can distinguish Apple
OSes on the wire instead of only the collapsed ios/macos leaves.

- session-inventory devices/session_list stop stripping appleOs and emit
  it only for Apple devices (non-Apple omit it); platform stays the leaf.
- boot/shutdown success results (contracts/device.ts) gain optional appleOs.
- AgentDeviceDevice/AgentDeviceSessionDevice + client normalizers carry it.
- appleOs values never equal the internal 'apple' token, so the
  apple-platform-output-guard is unaffected.

Tests: devices projection per Apple fixture (ios/ipados/tvos/visionos/
macos) + non-Apple omission; client normalizer coverage.

* fix: gate public appleOs surfacing to Apple platforms

appleOs is Apple-only, but the daemon projection (devices, session_list, boot,
shutdown) and the client normalizers preserved it whenever the field was present —
so a malformed/legacy non-Apple record carrying a valid Apple OS value would leak
it on the public wire. Gate every emission site on isApplePlatform(platform), not
just field presence.

Regression tests: a non-Apple device carrying a stray appleOs='macos' drops it in
both the daemon devices projection and normalizeDevice.
2026-07-02 11:10:05 +02:00
Michał Pierzchała 127e2be4a6 docs: require provider-integration + coverage verification for platform/response changes (#1008)
Process guardrail (addresses the recurring miss behind the Platform-collapse leaks):
- Testing Matrix: platform/device-response changes must also run test:integration:provider
  and test:coverage — check:unit alone misses the provider-integration project (which runs the
  apple-platform-output leak guard).
- PR Readiness: don't call a PR CI-green off a local --project unit run; the Integration Tests
  and Coverage jobs run provider-integration, so verify those on the actual PR head.
2026-07-02 10:45:49 +02:00
Michał Pierzchała 7488346513 feat: route recording + provider platform gates through PlatformPlugin facets (#1007)
* feat: route recording + provider platform gates through PlatformPlugin facets

Phase 3 step b.3 (issue #974): land the two deferred daemon-owned
PlatformPlugin facets as DATA-ONLY seams, mirroring appLog/perf.

recording facet: PlatformPlugin.recording.resolveBackendTag(device)
returns a neutral RecordingBackendTag string ('web'|'android'|'macos'|
'ios-device'|'ios-simulator'|'unsupported', daemon-owned, type-only in the
plugin). resolveRecordingBackendForDevice now maps the tag back to the
daemon-owned backend instance via RECORDING_BACKENDS_BY_TAG, with a
'?? unsupported' fallthrough for the factless linux family. Backend
instances + RecordingBackend type stay in the daemon.

providers facet: PlatformPlugin.providers.platformGatedResolvers declares,
per family, which platform-gated request provider resolvers apply (the data
that replaces each descriptor's hand 'device.platform === …' gate). The
daemon still owns resolver invocation, wrapper composition, and request-scope
concurrency isolation; only the gate moved to data. Ungated resolvers
(appLog/recording providers) stay ungated in the daemon.

Both facet types are type-only in the plugin (R3-clean, like LogBackend), so
no platforms/->daemon value edge and no CLI cold-start regression. Pinned by
two table-equivalence parity tests (independent verbatim oracle, full
platform x kind x target matrix, facet-presence + fallthrough + end-to-end
routing).

* docs: mark b.3 recording/providers facets as landed in CONTEXT.md

This PR routes the recording + providers PlatformPlugin facets, so the durable
architecture context can no longer list them as deferred/on-the-daemon-branch.
Update the Deferred section: all four b.3 facets now route through the plugin;
only the perf sampling body (buildPerfResponseData) still branches in the daemon.
2026-07-02 09:42:50 +02:00