Commit Graph

874 Commits

Author SHA1 Message Date
Michał Pierzchała 0af752e339 fix: share prepare ios-runner timeout budget 2026-06-30 17:48:22 +02:00
Michał Pierzchała edc8dd059b ci: automate iOS runner request-count gate for the Apple runner unwind (Phase 3 step c prep) (#966)
Replaces the manual "run with --debug, hand-count the runner phases" check with
an automated, committed assertion so the Phase 3 step (c) runner relocation (and
future runner refactors) can prove byte-identical runner request behavior.

- src/daemon/runner-request-count.ts: pure, unit-testable counter. Parses the
  daemon --debug diagnostics ndjson and counts the iOS-runner round-trip phases,
  plus baseline parse/compare logic. Owns RUNNER_ROUND_TRIP_PHASES as the single
  source of truth, now imported by request-router.ts (was a local const) so the
  in-process cost graft and the external counter never drift.
- src/daemon/__tests__/runner-request-count.test.ts: 13 unit tests over synthetic
  ndjson fixtures (tolerant parse, counting, baseline parse/compare). Run in the
  normal unit suite; no hardware.
- scripts/runner-request-count/: assertion harness (run.ts) + committed baseline
  (expected-counts.json). Drives the existing smoke-ios replay scenario with
  --debug in an isolated --state-dir, counts runner round-trips from daemon.log,
  and asserts against the baseline. --update regenerates the baseline. Infra
  hiccups are inconclusive (don't fail); only a real count drift fails.
- .github/workflows/ios.yml: new "Assert iOS runner request count" step in the
  smoke-ios job, reusing the booted simulator.
- package.json: `validate:runner-count` script. .fallowrc.json: harness entry.

The baseline ships unarmed (established=false); the harness records observed
counts (printed + uploaded as a test/artifacts artifact) without failing, so the
maintainer arms it once from a real CI run.
2026-06-30 17:26:01 +02:00
Michał Pierzchała 903a35624e fix: extend iOS physical install timeout (#964) 2026-06-30 17:05:16 +02:00
Michał Pierzchała f430888baa refactor: route capability bucket through PlatformPlugin + pin supports() closures (Phase 3 step b) (#965)
b.1: isCommandSupportedOnDevice now reads each platform's capability bucket
from getPlugin(device.platform).capability.bucket (the PlatformPlugin registry,
ADR-0009) instead of the platformDescriptors fold. capabilities.ts registers the
builtin plugins at module load (idempotent, lazy closures only) so the admission
path populates the registry without depending on core/interactors.ts load order.

b.2: the per-command supports()/unsupportedHint() closures stay VERBATIM on the
command-descriptor facet; they cannot move to the plugin's per-FAMILY
capability.supportsByDefault without flattening their per-command shape
(perfect-shape §7). A new table-equivalence parity test pins both the bucket-route
swap and the closures byte-for-byte across the full platform x command x
device-kind x target matrix.
2026-06-30 17:04:33 +02:00
Michał Pierzchała bcf910a2bb refactor: split daemon client driver into daemon/client/ — Phase 5 (#962)
Move the daemon CLIENT driver (the in-process side that sends requests to a
running daemon) out of the src/ root into src/daemon/client/, per
plans/perfect-shape.md §5.5 ('daemon/client/ <- daemon-client*.ts'; the
daemon- prefix co-located client driver + server bootstrap at src root).

Files moved (7): daemon-client{,-lifecycle,-metadata,-progress,-rpc,-timeout,
-transport}.

- git renames; 19 importers repointed via the resolve-based codemod
  (intra-set stays ./, kernel -> ../../, daemon/remote deps recomputed)
- Layering Guard verified: none import src/commands/* (safe under src/daemon/)
- not a public export; no rslib impact
- update fallow-baselines/health.json keys

Behaviorless path codemod; typecheck/lint/format/build/tests green.
2026-06-30 15:51:53 +02:00
Michał Pierzchała 1cbe446df5 chore: prune public package exports (#961)
* chore: prune public package exports

* chore: drop extra facade exports

* chore: remove dead export leftovers

* docs: align public API docs
2026-06-30 15:51:37 +02:00
Michał Pierzchała 189b062519 refactor: extract client/ folder — Phase 5 (#960)
Move the SDK client + companion-tunnel cluster out of the src/ root into a
dedicated src/client/ folder, per plans/perfect-shape.md §5.5 (~8k LOC
client/remote unfoldered at src root; remote/ already extracted in #951).

Files moved (9): client, client-types, client-shared, client-normalizers,
client-companion-tunnel{,-contract,-worker}, client-react-devtools-companion,
companion-tunnel.

- git renames; 68 importers repointed via the resolve-based codemod
  (intra-client stays ./, staying deps recomputed)
- companion-tunnel.ts keeps rslib key 'internal/companion-tunnel' so dist
  output stays dist/src/internal/companion-tunnel.js (public subpath
  byte-identical; verified by build)
- update non-src importers (3 integration tests, vitest coverage include),
  .fallowrc.json entrypoint, fallow-baselines/health.json keys

backend.ts and daemon-client*.ts are intentionally left for follow-up
(daemon/client split). Behaviorless path codemod; all gates green.
2026-06-30 14:36:59 +02:00
Michał Pierzchała 531aad76d8 refactor: PlatformPlugin registry foundation (step a) — Phase 3 (#956)
* refactor: PlatformPlugin registry foundation + parity tests (Phase 3)

* refactor: trim PlatformPlugin step-a contract to implemented facets

Remove the speculative daemon-owned facets (providers/recording/appLog/perf)
from the PlatformPlugin type. The earlier 'recording' facet baked the
iOS-simulator provider seam (IosSimulatorRecordingRequest) into the contract and
could not represent the Android/web/macOS-runner/iOS-device-runner/stop-path
recording contracts, which need the daemon recording context. The step-a
contract now carries only what this slice implements and parity-tests:
id, platforms, familySelector?, createInteractor, discoverDevices, capability.
The facets are introduced in step (b) as platform-neutral, daemon-owned
wrappers, pinned by table-equivalence parity tests (plan updated).
2026-06-30 14:36:33 +02:00
Michał Pierzchała 305594f6b7 fix: avoid unsafe iOS keyboard dismissal (#957) 2026-06-30 14:08:41 +02:00
Michał Pierzchała 65227c6719 refactor: absorb CLI parser into cli/parser/ — Phase 5 (#958)
Move the CLI argument/flag/help parser out of utils/ into a dedicated
src/cli/parser/ folder, per plans/perfect-shape.md §5.5 (utils/ hosts a 3k
CLI parser among its buried subsystems).

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

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

Behaviorless path codemod. typecheck/lint/format/build/tests green;
integration-progress model still runs.
2026-06-30 13:56:04 +02:00
Michał Pierzchała afcf79abfc feat: find/get digest response-views + batch-step elision — Phase 4 (#955)
* feat: find/get digest response-views + batch-step elision — Phase 4

Add opt-in leveled response views for the find and get selector reads and
elide intermediate batch steps to digest, completing the two remaining
Phase 4 agent-cost grafts. All additions activate only when a non-default
responseLevel (digest/full) is requested; the default wire shape is
byte-identical to today (Maestro .ad recompare safe).

- response-views: register a shared selectorReadView under find and get.
  A text read keeps ref/selector + text and drops the redundant verbose
  node; an attrs read keeps a compacted node (semantic attributes only,
  geometry/index/process plumbing dropped); exists/wait/click keep their
  cheap actionable signals. default/full return today's shape unchanged.
- batch: when a non-default level is requested, intermediate steps are
  forced to digest while the final step keeps the requested level. With no
  responseLevel the per-step meta is passed through unchanged.
- tests mirror the existing response-views / response-level suites.

* fix: make find/get digest conservative — never drop interaction warnings

Review feedback on #955: `find` is registered command-wide, but
`find fill/focus/type` return the underlying INTERACTION response, which can
carry cheap, agent-critical signals (notably `warning` from Android
blocking-dialog recovery, plus `message`). The previous allowlist-based digest
silently dropped those under --level digest.

The only token sink in a find/get result is the verbose matched snapshot
`node`, which appears solely on a selector READ (text/attrs). The view is now
conservative: it acts ONLY on a result carrying such a node and otherwise
returns the data UNCHANGED, so node-less shapes (exists/wait/click and the
fill/focus/type interaction responses) are never narrowed. For a text read the
redundant node is dropped; for an attrs read the node is compacted; in both
cases every other cheap field (e.g. `warning`) is preserved verbatim.

Adds a regression test asserting a `find fill` response carrying a `warning`
is returned unchanged under digest.
2026-06-30 13:55:28 +02:00
Michał Pierzchała f1248970f2 refactor: extract snapshot/ folder — Phase 5 (#953)
Move the AX-snapshot processing domain out of utils/ into a dedicated
src/snapshot/ intent folder, per plans/perfect-shape.md §5.5 (utils hosts
the AX-snapshot domain among 3 subsystems).

Files moved (9): snapshot-{diff,label-signals,lines,occlusion,processing,
quality,tree,visibility} + mobile-snapshot-semantics (processes SnapshotNode,
depends on snapshot-tree). android-helper-snapshot-presentation stays in
utils/ with its android-helper-presentation/ cluster.

- git renames; imports repointed via the resolve-based codemod
  (staying-utils -> ../utils/, intra-snapshot -> ./, kernel unchanged)
- no public-export/rslib impact; update fallow-baselines/health.json keys
- tests stay in their domain __tests__/ dirs, imports repointed

Behaviorless path codemod. typecheck/lint/format/build/tests green.
2026-06-30 13:31:39 +02:00
Michał Pierzchała 5913480779 chore: exclude helper readmes from package (#954) 2026-06-30 12:41:47 +02:00
Michał Pierzchała 548bf0c47c refactor: extract remote/ folder — Phase 5 (#951)
Move the remote/proxy/upload subsystem out of the src/ root cluster into a
dedicated src/remote/ intent folder, per plans/perfect-shape.md §5.5:

  daemon-proxy · daemon-artifacts · upload-client(-artifact) · remote-config
  · remote-config-core · remote-config-schema · remote-connection-state

- 8 files moved (git renames); imports repointed via a resolve-based codemod
  (path.relative recomputation — correctly distinguishes the root remote-config
  from the unrelated src/utils/remote-config.ts)
- rslib entry keeps key 'remote-config' so dist output stays
  dist/src/remote-config.js; public 'agent-device/remote-config' byte-identical
- update .fallowrc.json entrypoint + fallow-baselines/health.json keys +
  vitest.config.ts coverage include + the integration test import paths

Behaviorless path codemod. typecheck/lint/build/fallow/tests all green.
Stacked on #950 (contracts→kernel).
2026-06-30 11:47:39 +02:00
Michał Pierzchała b6a9bb2186 refactor: move contracts.ts into src/kernel — Phase 5 (#950)
Relocate the central contracts barrel into the kernel/ dependency sink
alongside device/errors/redaction/snapshot (kernel now owns the pure
domain types per plans/perfect-shape.md §5.5).

- src/contracts.ts -> src/kernel/contracts.ts (git rename)
- repoint all 44 internal importers to ../kernel/contracts.ts
- rslib entry keeps key 'contracts' so dist output stays dist/src/contracts.js;
  the public 'agent-device/contracts' subpath is byte-identical (proven by the
  metro precedent in #947 and verified via build + package-exports test)
- update .fallowrc.json entrypoint + fallow-baselines/health.json key

Behaviorless path codemod (49 files, +57/-57). typecheck/lint/build/fallow
audit/public-contract tests all green.
2026-06-30 11:34:16 +02:00
Michał Pierzchała 193deaf7fc feat: screenshot digest response-view — Phase 4 (#945)
* feat: screenshot digest response-view — Phase 4

Add a `screenshot` entry to the Phase 4 RESPONSE_VIEWS registry. At
responseLevel `digest`, the view keeps the cheap result fields — the
captured `path` and the `artifacts` retrieval handle — and collapses the
token-heavy `overlayRefs` array (each carrying ref + label + three
geometry rects) to a total `overlayCount` plus the first 12 refs leveled
down to `{ ref, label }`. `default`/`full` return today's shape unchanged,
so unregistered and default-level responses stay byte-identical.

* fix: preserve the screenshot digest through the client capture path

The daemon screenshot view returns a leveled digest (path, overlayCount, leveled
overlayRefs, artifacts), but client.capture.screenshot() always ran the data
through readScreenshotResultData, which keeps only path + full-geometry overlay
refs and drops overlayCount/artifacts — so the digest never reached SDK/CLI
callers. Make the capture method level-aware: when the effective responseLevel
(request override or client config) is non-default, return the leveled payload
verbatim instead of normalizing it. Default-level behavior is unchanged.

Adds shipped-path client tests: capture.screenshot({ responseLevel: 'digest' })
returns the raw digest (overlayCount/artifacts preserved, no normalizer
identifiers); the default path still normalizes. Kept the return type as
CaptureScreenshotResult (the union variant cascaded into many default-path
consumers); the caller opted into the level, so the runtime value is leveled.

* fix: preserve the screenshot digest through the CLI command path

agent-device screenshot --level digest --json still dropped overlayCount and
artifacts: the CLI command rebuilt the default { path, overlayRefs } shape from
the (now leveled) client result. Make screenshotCommand level-aware — for a
non-default responseLevel it emits the leveled payload verbatim (JSON / JSON
text). Adds a CLI shipped-path test (--level digest --json preserves the digest;
the default level still emits the normalized shape). Factors the predicate into
a shared isNonDefaultResponseLevel in contracts.ts, reused by the client helper.

* fix: preserve snapshot digest through the client capture path — Phase 4 (#949)

* fix: preserve the snapshot digest through the client capture path

client.capture.snapshot() always ran the daemon data through
normalizeSnapshotResult, which expects the full `nodes` tree — so a non-default
responseLevel digest ({ nodeCount, refs }) collapsed to an empty snapshot, the
same gap #945 fixed for screenshot. Make it level-aware: when the effective
responseLevel is non-default, return the leveled payload verbatim. Default-level
behavior is unchanged. Adds a shipped-path client test asserting the digest
survives (nodeCount/refs preserved, no normalizer identifiers).

* fix: preserve leveled digests through the generic CLI path

agent-device snapshot --level digest --json dropped nodeCount/refs: snapshot
goes through the generic CLI path (runCliCommandWithOutput -> formatCliOutput),
whose snapshot formatter serializes the default CaptureSnapshotResult shape and
discards digest-only fields. Make runGenericClientBackedCommand level-aware: for
a non-default responseLevel it emits the raw leveled payload verbatim (JSON /
JSON text), bypassing the default-shape formatter. This generalizes to any
generic-path command. Adds a snapshot CLI shipped-path test.
2026-06-30 10:46:34 +02:00
Michał Pierzchała 1e395c5158 feat: expose responseLevel over MCP — Phase 4 (#944)
* feat: expose responseLevel over MCP — Phase 4

* fix: render non-default MCP response levels as JSON text

When an MCP caller sets responseLevel:digest/full, structuredContent carries the
leveled (e.g. snapshot digest) payload, but renderToolText still ran it through
the optimized CLI formatters, which assume the default shape — the snapshot
formatter expects `nodes` (the digest drops them) and printed 'Snapshot: 0
nodes', contradicting structuredContent. Bypass the optimized formatters for any
non-default responseLevel and emit the leveled payload verbatim as JSON. Adds the
shipped-path test (snapshot, mcpOutputFormat:optimized, responseLevel:digest).
2026-06-30 08:58:57 +02:00
Michał Pierzchała 3aa43bfc91 refactor: extract metro/ folder — Phase 5 (#947)
Move the metro cluster into src/metro/ per plans/perfect-shape.md §5.5
(`metro/ ← metro* · client-metro*`):

  src/metro.ts                 -> src/metro/metro.ts
  src/metro-types.ts           -> src/metro/metro-types.ts
  src/client-metro.ts          -> src/metro/client-metro.ts
  src/client-metro-companion.ts-> src/metro/client-metro-companion.ts

Pure path codemod, no behavior change. Imports were rewritten by a
resolve-based codemod (compares resolved absolute paths, not naive
string match). Also updated the three non-.ts references to the moved
paths: the rslib `metro` entry, the fallow entry list, and the
fallow health-baseline key.

The companion-tunnel cluster (client-companion-tunnel*, companion-tunnel,
client-react-devtools-companion) stays at src/ root — it is a separate
domain (the future `companion/` slice) and keeps the worker process
entrypoint (companion-tunnel.ts / client-companion-tunnel-worker.ts)
co-located with its spawner (client-companion-tunnel.ts), so the worker
entry resolution is unchanged. `npm run build` emits both
dist/src/metro.js and dist/src/internal/companion-tunnel.js.
2026-06-30 08:22:41 +02:00
Michał Pierzchała f67b72d7db refactor: delete the WaitCommandResult/AlertCommandResult mirror types — Phase 2c (#946)
BREAKING (intentional, approved): removes the last two hand-written result-type
mirrors from client-types.ts. wait and alert are genuinely dynamic — wait's
daemon data is toDaemonWaitData's Record, and alert's iOS path is a generic
runner Record — so per the typed-result doctrine they should be the untyped
CommandRequestResult, not an invented closed shape.

- delete WaitCommandResult and AlertCommandResult from client-types.ts; the
  client.command.wait/alert methods now return CommandRequestResult.
- drop their public exports from index.ts and the now-unused AlertInfo import.
- the android-lifecycle integration test that read the typed alert.alert.source
  now casts the untyped bag.

This completes the result-type half of the client-types.ts mirror deletion (the
13 closed commands already live in src/contracts/* via CommandResultMap). The
Options-type half (deriving from inputSchema) is a separate follow-up.

Verified: tsc, oxfmt + oxlint --deny-warnings, fallow audit clean, Layering
Guard empty, 476 client/contracts/mcp tests pass.
2026-06-30 08:21:47 +02:00
Michał Pierzchała 43d0a639d0 feat: leveled response views + --level knob with snapshot digest — Phase 4 (#942)
* feat: leveled response views + --level knob, with a snapshot digest — Phase 4

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

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

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

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

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

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

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

The --level/responseLevel flag is a diagnostics/output flag (not device-
observable), classified in the exclusion bucket alongside --cost. (Lost in an
earlier rebase; re-applying.)
2026-06-30 07:53:28 +02:00
Michał Pierzchała bb45967d12 fix: point MCP output-schemas at kernel/device after the kernel move (#943)
#941 (MCP outputSchema) and #940 (errors/redaction/device -> src/kernel) merged
in an order where #940's import codemod never saw #941's new
command-output-schemas.ts, so it still imports '../utils/device.ts' — which no
longer exists. tsc/build on main is red. Repoint the import at
'../kernel/device.ts'. The only stale reference on main.
2026-06-30 07:41:47 +02:00
Michał Pierzchała 7a1640e53f refactor: move errors/redaction/device into src/kernel — Phase 5 slice 3 (#940)
* refactor: move errors/redaction/device into src/kernel — Phase 5 slice 3

Relocates the foundational primitive trio from src/utils/ into the kernel/ layer
(joining snapshot.ts from slice 2), per the target folder DAG in
plans/perfect-shape.md §5.5. A pure path codemod, no behavior change.

They form a closed cluster — device -> errors -> redaction, with redaction a
leaf — so kernel/ takes no upward dependency, and every importer becomes a clean
downward import toward kernel. errors.ts is the most-imported module in the
tree; device.ts the §5.5-named headliner. Moving all three atomically avoids a
half-state where one would import another across the utils/kernel boundary.

Imports rewritten by a resolve-based codemod (compares each specifier's resolved
path to the moved files, so the unrelated commands/management/device.ts and
other same-named files are untouched): 483 sites across 402 files. The two
platform-descriptor doc comments and the fallow health baseline key for
device.ts are updated to the new path; the contracts-schema-public guard that
asserts the error helpers pull no diagnostics/node: deps now reads kernel/.

Verified: tsc --noEmit, oxfmt + oxlint --deny-warnings, rslib build, full vitest
suite (2877 pass), fallow audit clean (411 changed files), Layering Guard empty;
kernel/ files import only within kernel.

* docs: update guidance references to kernel/{device,errors} after the move

AGENTS.md (Apple-family sync rule + normalizeError), ADR-0009, and
plans/apple-platform-consolidation.md still named the old src/utils/ paths.
Point them at src/kernel/. plans/perfect-shape.md's utils/device.ts mention is
left as-is — it describes the pre-move diagnosis.
2026-06-30 07:25:02 +02:00
Michał Pierzchała 2e41c819f1 feat: per-command MCP outputSchema — Phase 4 (#941)
* feat: per-command MCP outputSchema — Phase 4

Hand-author per-command MCP outputSchemas for the 13 typed commands whose
closed result shapes live in the contracts layer (mirroring CommandResultMap):
press, fill, longpress, boot, shutdown, viewport, home, back, rotate,
app-switcher, clipboard, appstate, keyboard.

The new COMMAND_OUTPUT_SCHEMAS registry is injected into tools/list via
listCommandTools(). It is additive-only: untyped/dynamic tools (snapshot,
gestures, perf, logs, …) carry no outputSchema key and stay byte-identical.
Schemas are non-strict (no additionalProperties:false) so the additive cost
object rides into structuredContent and still validates. MCP agents can now
trust structuredContent against the advertised schema instead of re-parsing
text.

* refactor: type-tie COMMAND_OUTPUT_SCHEMAS to CommandResultMap

Replace Partial<Record<string, JsonSchema>> with
`satisfies Record<keyof CommandResultMap, JsonSchema>`, so the one-for-one
invariant with the typed-result spine is compiler-enforced: a new
CommandResultMap entry without an output schema is now a missing-key error, and
a misspelled/extra key is an excess-property error (previously both compiled and
silently omitted the schema). The lookup in listCommandTools guards with an `in`
check since the registry is keyed by the typed commands only.
2026-06-30 07:24:28 +02:00
Michał Pierzchała 6ae0612ebc fix: clean up maestro test reporter output (#935)
* fix: clean up maestro test reporter output

* chore: enable expo build disk cache

* refactor: simplify replay progress detail formatting

* fix: surface replay runner recovery hints

* fix: prioritize ios runner recovery hint

* fix: avoid trailing punctuation in runner state hint

* fix: keep internal cleanup scripts out of runner hints

* chore: remove redundant maestro test app open flag

* fix: make test app maestro flow self-contained

* fix: simplify maestro test duration output

* fix: refine maestro test summary output

* fix: dim maestro live progress counters

* test: clear test app state before maestro flow

* test: update maestro reporter progress expectations

* chore: remove maestro app open flag handling

* fix: apply maestro reporter cleanup to default reporter
2026-06-29 21:02:30 +02:00
Michał Pierzchała 8cc6bb8b92 feat: add replay test reporters (#936)
* feat: add replay test reporters

* feat: support custom replay test reporters

* fix: satisfy reporter CI guards

* refactor: modularize replay test reporters

* refactor: trim replay reporter parsing

* refactor: simplify custom replay reporters
2026-06-29 20:37:41 +02:00
Michał Pierzchała 47eea11ec3 feat: typed errors — supportedOn + retriable signals — Phase 2 (#939)
Graft the Phase 2 TypedError signals onto DaemonError: machine-readable hints an
agent can act on without a wasted round-trip.

- supportedOn: for UNSUPPORTED_OPERATION / UNSUPPORTED_PLATFORM errors, the
  platform families that DO support the command, DERIVED from the capability
  matrix (new supportedPlatformsForCommand in core/capabilities.ts). So an agent
  that hit a platform mismatch learns where the command works.
- retriable: flags transient failures worth retrying. Conservative policy
  (retriableForErrorCode in utils/errors.ts) — currently only DEVICE_IN_USE
  (device healthy but busy); ambiguous/deterministic codes stay undefined.

Both are additive and applied at a single chokepoint (handleRequest, where the
command is in scope) only when a signal applies, so the default error wire shape
is unchanged for the common codes — verified across 1632 daemon/core/contracts/
utils/commands tests (no error-shape ripple). New unit + router e2e test covers
all three cases (supportedOn present, retriable true, deterministic unchanged).

Verified: tsc --noEmit, oxfmt + oxlint --deny-warnings, fallow audit clean,
Layering Guard empty.
2026-06-29 19:58:05 +02:00
Michał Pierzchała fca2eef23e feat: typed command results, batch 4 (keyboard) — Phase 2 (#938)
Wire `keyboard` into the CommandResultMap spine, grounded in the dispatch
handlers' literal returns (src/core/dispatch.ts handleAndroidKeyboardCommand /
handleIosKeyboardCommand).

Modeled as a flat closed shape (platform + action always present; the remaining
keyboard-state / message fields appear per branch) rather than a five-way
platform×action union — the per-branch field sets overlap heavily and the
underlying Android keyboard-state types live in the platform layer (below the
public contract). The Record index signature of the previous hand-written mirror
is dropped and the spurious `| null`s removed (the handler never returns null).

Moves from the open client-types.ts mirror into src/contracts/keyboard.ts, wired
through CommandResult<'keyboard'>; public export name preserved (re-exported via
client-types.ts -> index.ts), so no API break. Parity test pins all 13 migrated
commands.

Stacked on the batch 3 PR. Verified: tsc --noEmit, oxfmt + oxlint
--deny-warnings, fallow audit clean, Layering Guard empty, 379 tests across
core/contracts/client/commands/system pass.
2026-06-29 19:57:45 +02:00
Michał Pierzchała dae80c70c0 refactor: add provider device runtime foundation (#933) 2026-06-29 19:12:30 +02:00
Michał Pierzchała 41358fe281 feat: typed command results, batch 3 (clipboard, appstate) — Phase 2 (#937)
Wire two more commands into the CommandResultMap spine as closed shapes,
grounded in the handlers' literal returns:

- clipboard (src/core/dispatch.ts handleClipboardCommand) -> a discriminated
  union on `action`: read => { text }, write => { textLength, message }.
- appstate (src/daemon/handlers/session-state.ts handleAppStateCommand) -> a
  discriminated union on `platform`: Apple (ios/macos) session state — now
  including the iOS-only device_udid / ios_simulator_device_set locators the
  previous hand-written mirror OMITTED — or Android package/activity.

Both result types move from the open client-types.ts mirror (DaemonResponseData
& {…}) into new src/contracts/{clipboard,app-state}.ts and are wired through
CommandResult<'clipboard'> / CommandResult<'appstate'>. Public export names are
preserved (re-exported via client-types.ts -> index.ts), so no API break.

Tightening clipboard to a closed union surfaced an unguarded .text/.textLength
access in an Android integration test (previously masked by the Record index
signature); fixed with discriminant guards. The parity test now pins all 12
migrated commands; the public-root export test gains clipboard/appstate samples.

Verified: tsc --noEmit, oxfmt + oxlint --deny-warnings, fallow audit clean,
Layering Guard empty, 791 tests across core/contracts/client/commands pass.
2026-06-29 19:08:39 +02:00
Michał Pierzchała e9975e172d feat: typed command results, batch 2 (home/back/rotate/app-switcher) — Phase 2 (#934)
Wire four more commands into the CommandResultMap spine, narrowing their public
client return types from the open DaemonResponseData bag to closed shapes.

Each shape is grounded in a re-read of the dispatch handler's literal return
(src/core/dispatch.ts DISPATCH_HANDLERS) — a fixed `action` discriminant plus
the always-present successText `message`:

- home         -> { action: 'home'; message }
- back         -> { action: 'back'; mode: BackMode; message }
- rotate       -> { action: 'rotate'; orientation: DeviceRotation; message }
- app-switcher -> { action: 'app-switcher'; message }

The handlers spread nothing else, so the shapes are closed (consistent with the
viewport contract, the generic-dispatch Android dialog-recovery `warning`
annotation is intentionally not part of the contract). The result types move
from the client-types.ts mirror into a new src/contracts/navigation.ts; the
now-unused CommandActionResult helper is deleted. Public export names are
preserved (re-exported via client-types.ts -> index.ts), so no API break.

The exact-equality parity test pins CommandResult<name> === the contract type
for all ten migrated commands; the public-root export test gains back/rotate
samples.

Verified: tsc --noEmit, oxfmt + oxlint --deny-warnings, fallow audit clean,
Layering Guard empty, 791 tests across core/contracts/client/commands pass
(the lone failure was the known-flaky daemon-client mid-stream-abort test, which
passes in isolation).
2026-06-29 18:25:50 +02:00
Michał Pierzchała 0be806cc50 refactor: seed src/kernel with snapshot.ts type module — Phase 5 slice 2 (#932)
Establishes the kernel/ layer from the perfect-shape DAG (plans/perfect-shape.md
§5.5) by moving the foundational snapshot type module (Rect, SnapshotNode,
SnapshotQualityVerdict, centerOfRect — re-exported by contracts.ts and imported
by ~130 files) from src/utils/snapshot.ts to src/kernel/snapshot.ts.

Pure path codemod, no behavior change. snapshot.ts is a leaf (zero imports, after
the preceding cycle-break PR), so kernel/ takes no upward dependency — and every
one of its ~130 importers (utils, core, daemon, platforms, commands, tests)
becomes a clean downward import toward kernel. This also unblocks the future
snapshot/ AX-domain extraction: those domain files now import DOWN into kernel
rather than sideways within utils.

Imports rewritten by a resolve-based codemod (compares each specifier's resolved
path to the old module, so the other snapshot.ts files under commands/platforms/
daemon are untouched). 138 sites across 128 src files + 2 integration worlds.

Verified: tsc --noEmit, oxfmt + oxlint --deny-warnings, full vitest suite
(308 files / 2863 tests), fallow audit clean (133 files), rslib build, Layering
Guard empty; kernel/snapshot.ts confirmed import-free.
2026-06-29 17:30:46 +02:00
Michał Pierzchała 0942ff072b refactor: break snapshot.ts <-> snapshot-quality.ts type cycle — Phase 5 prep (#931)
snapshot.ts (the foundational snapshot type module; Rect/SnapshotNode, 96
importers, re-exported by contracts.ts) imported SnapshotQualityVerdict from
snapshot-quality.ts, which in turn imports SnapshotNode from snapshot.ts — a
type-level cycle that blocks moving snapshot.ts down into a kernel layer.

Move the SnapshotQualityVerdict type definition into snapshot.ts (it is
referenced by a SnapshotNode field) and re-export it from snapshot-quality.ts so
every existing `from './snapshot-quality.ts'` import is unaffected. The
validation logic (readSnapshotQualityVerdict + const Sets) stays put.

Result: snapshot.ts has zero imports (a clean leaf), unblocking the kernel-seed
move and the eventual snapshot/ domain extraction. No behavior change.

Verified: tsc, oxfmt + oxlint --deny-warnings, fallow audit clean, 369 tests
across utils pass.
2026-06-29 17:12:03 +02:00
Michał Pierzchała ed701f3d1f feat: support resumable proxy uploads (#919)
* feat: support resumable proxy uploads

* refactor: reduce upload proxy fallow findings

* refactor: localize proxy upload routing

* test: simplify resumable proxy upload coverage

* refactor: deepen upload route handling

* refactor: tighten upload http types

* test: avoid dynamic proxy route dispatch

* feat: show remote upload progress

* fix: show install phase after upload

* fix: isolate resumable upload attempts

* refactor: split upload client internals

* test: make install notice test independent of CI env
2026-06-29 16:21:00 +02:00
Michał Pierzchała 4b7ee24637 refactor: extract screenshot-diff domain out of utils — Phase 5 slice 1 (#928)
First Phase 5 (layering) move: a pure path codemod, no behavior change. Relocates
the cohesive screenshot-diff domain (9 source files + 3 tests) from src/utils/
into a dedicated top-level src/screenshot-diff/ folder, per the target folder DAG
in plans/perfect-shape.md §5.5.

Boundary respects the real dependency graph:
- screenshot-diff-pixels.ts STAYS in utils — it is the pixel-diff primitive the
  shared png-worker depends on, not domain code. No moved file imports it.
- the moved files keep importing shared utils (png, exec, screenshot-geometry,
  snapshot, errors) via ../utils/.
- consumers updated: commands/capture/runtime/diff-screenshot.ts and
  utils/output.ts (the diff result-type formatter).

fallow-baselines/health.json keys for the two flagged files are renamed to the
new paths to preserve the baseline (no new findings).

Verified: tsc --noEmit, oxfmt + oxlint --deny-warnings, fallow audit (clean, 16
files), rslib build, and 431 tests across screenshot-diff/utils/capture all pass;
Layering Guard empty. git tracks all 12 as renames.

Note: utils/output.ts now imports the screenshot-diff result types cross-folder
(it formats diff output); when the import-direction lint lands it should move up
out of utils. Tracked as a Phase 5 follow-up.
2026-06-29 16:10:46 +02:00
Michał Pierzchała 54178319d8 perf: reduce iOS simulator screenshot overhead (#927) 2026-06-29 15:53:19 +02:00
Michał Pierzchała 9dc07cc56e perf: reuse Apple runner cache across version bumps (#900)
* perf: reuse Apple runner cache across version bumps

* perf: remove unused Apple runner symbols

* perf: keep Swift runner unit tests out of runtime builds

* perf: skip Apple runner asset catalog in runtime builds

* perf: use concrete simulator for xcuitest script builds

* perf: show cold Apple runner startup progress

* perf: prewarm Apple runner cache during simulator boot

* refactor: dedupe Apple runner option plumbing
2026-06-29 15:43:15 +02:00
Michał Pierzchała c702f0978f feat: add opt-in agent-cost nodeCount signal — Phase 4 slice 3 (#926)
Extends the opt-in cost block (slice 1 wallClockMs, slice 2/#925
runnerRoundTrips) with a command-agnostic nodeCount: the size of the UI/
accessibility node tree a command returns. In production only the snapshot
node-tree commands put a `nodes` array on response.data, so the router reads
it generically (Array.isArray(response.data.nodes)) and omits nodeCount
everywhere else. It lets an agent size a snapshot before re-fetching at a
different depth/scope, without parsing the full tree.

Purely additive and gated on meta.includeCost + response.ok, exactly like the
existing cost fields: with the flag off the serialized response is byte-
identical to today (Maestro .ad recompare safe). nodeCount is a pure read of
the existing nodes array — the parity test proves deleting the cost block
leaves a payload deep-equal to the flag-off response. Additive / semver-minor.
2026-06-29 15:36:05 +02:00
Michał Pierzchała 70844df4f2 feat: expose opt-in agent-cost over MCP via includeCost — Phase 4 slice 2 (#924)
Slice 1 added the opt-in cost.wallClockMs field on the daemon response; it
already rides on response.data → MCP structuredContent. This slice adds the
MCP-boundary opt-in so agents can request it: an includeCost tool argument
(mirroring the existing stateDir / mcpOutputFormat MCP config fields) that maps
to the client `cost` config (→ meta.includeCost on the daemon).

- readClientConfig sets client.cost = true only when includeCost === true, and
  rejects non-boolean values at the boundary.
- includeCost is stripped from the command input so it never leaks as a flag.
- withMcpConfigSchema advertises includeCost: boolean in each tool inputSchema.

Default-off is unchanged: with includeCost absent or false, the client config
and the MCP structuredContent are byte-identical to today. Additive /
semver-minor. Per-command MCP outputSchema and richer signals stay deferred.
2026-06-29 15:35:51 +02:00
Michał Pierzchała 3ce768160b feat: opt-in agent-cost runnerRoundTrips — Phase 4 slice 2 (#925) 2026-06-29 14:41:41 +02:00
Michał Pierzchała 59e1f4bd4a perf: speed up iOS simulator screenshots (#918)
* perf: skip boot probe for iOS simulator screenshots

* fix: recover skipped simulator screenshot boot checks

* refactor: simplify ios screenshot options
2026-06-29 14:35:21 +02:00
Michał Pierzchała 4eccfdde5c feat: infer install target from artifact (#923)
* feat: infer install target from artifact

* fix: allocate proxy lease for install
2026-06-29 14:33:40 +02:00
Michał Pierzchała 62f923dec4 feat: opt-in agent-cost wallClockMs behind --cost — Phase 4 slice 1 (#922)
* feat: opt-in agent-cost wallClockMs behind --cost

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

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

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

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

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

The integration progress guard (test:integration:progress:check) treats every
public CLI flag as either device-observable (requiring provider-backed coverage)
or intentionally excluded. --cost is a diagnostics/output flag (a purely additive
response field, not device-observable), so it joins json/help/version/verbose in
the 'config, output, diagnostics, and transport' exclusion bucket.
2026-06-29 13:50:12 +02:00
Michał Pierzchała 43399780a7 refactor: consolidate apple/mobile platform family-groupings via shared predicates — Phase 3 (#921)
Replace scattered inline `platform === 'ios' || === 'macos'` and `=== 'ios' || === 'android'` family-grouping branches with the shared `isApplePlatform`/`isMobilePlatform` leaf predicates. Strictly behaviorless: each site yields an identical boolean for every possible platform input. Adds `isMobilePlatform` to utils/device.ts (leaf predicate, no registry/core import). Part of the Phase 3 PlatformPlugin/ADR-0009 effort to reduce open-coded platform branches; defers the ios+macos→apple Platform collapse and load-bearing table relocations per ADR-0009.
2026-06-29 13:14:50 +02:00
Michał Pierzchała ef282d2b1a docs: add cloud agents video (#915) 2026-06-28 17:44:30 +02:00
Michał Pierzchała a2c47ad050 feat: typed command results, batch 1 (Phase 2 typed-results) (#913)
* feat: typed command results, batch 1 (boot, shutdown, viewport)

Wire the dormant CommandResultMap spine into the public client return types
for three commands whose daemon handlers return clean, closed result objects:

- boot      -> BootCommandResult     (src/daemon/handlers/session-state.ts)
- shutdown  -> ShutdownCommandResult (src/daemon/handlers/session-state.ts)
- viewport  -> ViewportCommandResult (src/core/dispatch.ts handleViewportCommand)

Each result type mirrors the handler's literal return exactly; the public
client methods narrow from the generic Record (CommandRequestResult) to these
precise shapes. No field removed; shapes that the runtime leaves open stay open.

* fix: export typed command results
2026-06-28 14:46:35 +02:00
Michał Pierzchała c49e4fc4a4 feat: PlatformDescriptor registry deriving the capability bucket — Phase 3 step 1 (#914)
* feat: derive capability bucket from a PlatformDescriptor registry

Introduce src/core/platform-descriptor/ (mirroring src/core/command-descriptor/):
a 5-row PlatformDescriptor registry carrying each leaf platform's capability
bucket and isApple flag, pure derive folds, and a byte-for-byte parity test.

Rewrite selectCapabilityForPlatform to fold the registry via the derive fn and
delete the hand switch. Behaviorless and parity-proven; layering-safe (all in
core, no utils->core inversion). Defers the ios/macos->apple collapse and the
interactor/discovery/runner-profile tables to later per ADR-0009.

* refactor: drop unused definePlatformDescriptor helper

The identity helper had no consumers (the registry uses `as const satisfies`),
so Fallow flagged it as a newly-added unused export. Remove it.
2026-06-28 14:10:42 +02:00
Michał Pierzchała 9422881cb4 feat: derive the batch command union from the as-const registry (simplify #909) — Phase 1 step 7 (#912)
The command-descriptor registry is now `as const` (#910), so each entry keeps
its literal `name` and literal `batchable`. Derive StructuredBatchCommandName
from it via `Extract<…, { batchable: true }>['name']` instead of the 43-member
hand-authored union from #909, and delete the now-tautological exhaustive Record
membership assertion in parity.test.ts (type and value now derive from the same
`batchable: true` entries).

Strictly behaviorless: the derived union is the identical 43-member set
(confirmed bidirectionally assignable to the old hand union, member count
unchanged), the runtime allowlist value is unchanged, and the public
BatchCommandName re-export is structurally identical (consumer switches on
specific batch command names still typecheck).
2026-06-27 21:23:51 +02:00
Michał Pierzchała c4d6c8a9ae feat: additive CommandResult type spine — Phase 1 step 6 (#911)
* feat: registry-driven exhaustive command dispatch — Phase 1 step 5

Part A (enabler): make commandDescriptors `as const` so each entry keeps its
literal `name`, and export `Command` — the literal command-name union.

Part B: replace the dispatchKnownCommand switch with a
`Record<DispatchCommand, DispatchHandler>` lookup table. The Record type forces
every dispatch command to have a handler (a missing entry is now a COMPILE
error, replacing the runtime `default: throw` as the coverage net); an
`Object.hasOwn` guard preserves the identical INVALID_ARGS error for unknown
commands. DispatchCommand is hand-authored to match the switch cases verbatim
(it is not the registry `generic` route, and `swipe-preset`/`read` aren't
registry names). Strictly behaviorless: same routing, same handler args, same
results.

* feat: additive CommandResult type spine — Phase 1 step 6
2026-06-27 21:02:17 +02:00
Michał Pierzchała 6a65e612e1 feat: registry-driven exhaustive command dispatch — Phase 1 step 5 (#910)
Part A (enabler): make commandDescriptors `as const` so each entry keeps its
literal `name`, and export `Command` — the literal command-name union.

Part B: replace the dispatchKnownCommand switch with a
`Record<DispatchCommand, DispatchHandler>` lookup table. The Record type forces
every dispatch command to have a handler (a missing entry is now a COMPILE
error, replacing the runtime `default: throw` as the coverage net); an
`Object.hasOwn` guard preserves the identical INVALID_ARGS error for unknown
commands. DispatchCommand is hand-authored to match the switch cases verbatim
(it is not the registry `generic` route, and `swipe-preset`/`read` aren't
registry names). Strictly behaviorless: same routing, same handler args, same
results.
2026-06-27 20:59:36 +02:00
Michał Pierzchała 607883d66c feat: build batch allowlist from descriptors, delete the hand list — Phase 1 step 4 (#909) 2026-06-27 20:38:58 +02:00