* feat: polish replay test progress reporter
* test: stabilize replay reporter cursor test in CI
* refactor: dedupe replay reporter live progress checks
* fix: make Expo build cache path configurable
Phase 3 d.1. Makes getInteractor's core -> platforms routing the final shape by
moving the Apple-specific plugin pieces under src/platforms/apple/ while keeping
the generic registry where non-interactor core code can still import it.
Moved to src/platforms/apple/:
- plugin.ts - the applePlugin instance (APPLE_SUPPORTS_BY_DEFAULT closures,
appLog/perf facets, createInteractor/discoverDevices), extracted
from the former core/platform-plugin/register-builtins.ts
- interactor.ts - was core/interactors/apple.ts (createAppleInteractor)
- interactions.ts - was platforms/ios/interactions.ts (the Apple interaction
dispatcher: iOS synthesized gesture / tvOS remote-press /
macOS desktop-scroll); platforms/ios/ is now removed
- __tests__/watchos-sentinel.test.ts - co-located with its subject
Stayed in core/ (layering):
- The generic registry + PlatformPlugin type stay in core/platform-plugin/plugin.ts.
core/capabilities.ts (non-interactor core) imports getPlugin/tryGetPlugin, and R3
forbids core outside core/interactors/ from statically importing platforms/, so the
registry cannot move.
- register-builtins.ts moved to core/interactors/register-builtins.ts (still core/):
the android/linux/web wiring plus the registry-population entry point. As an
interactor-seam module it is the one place R3 permits a static value import of
platforms/, so it pulls in applePlugin and keeps the exhaustiveness assertion.
- apple-os-capabilities.ts stays in core/ (smallest move; the moved Apple closures
reach it via a legal platforms -> core import).
Layering guard passes (R1/R2/R3, 678 files). The applePlugin only reaches leaf code
via lazy dynamic import(), so the new static platforms/apple/plugin.ts import at the
seam does not regress CLI cold-start.
Introduce a per-`AppleOS` capability data table
(`src/core/platform-plugin/apple-os-capabilities.ts`) — the capability-axis
sibling of `RUNNER_PLATFORM_PROFILES` and the Swift `#if os()` guards — and have
the Apple capability closures read `device.appleOs` through it, collapsing the
scattered `target !== 'tv'` / `platform !== 'macos'` / `isTvOsDevice` predicates
into one lookup (`resolveDeviceAppleOs` + `appleOsCapabilities`).
Discipline (perfect-shape §7 / ADR-0009 step d.5): relocate the OS-axis
predicates into data, never change behavior. Only the AppleOS-shaped facts moved
to the table; the DEVICE-shaped nuance (simulator vs physical device — e.g.
two-finger synthesis is iOS-simulator-only, and the physical-iOS hint) stays in
the reading closure, and non-Apple branches keep their verbatim verdicts
(`appleOsCapabilities` returns `undefined` off the Apple family).
Parity gate: a new table-equivalence test
(`apple-os-capability-table-parity.test.ts`) pins the table-driven closures
byte-for-byte against an INDEPENDENT verbatim copy of the original predicates
across the full {command × sample-device} matrix, and the existing
capability-plugin-routing-parity test is extended with appleOs-bearing
iPadOS/visionOS fixtures so the stored-`appleOs` read path is covered
(iOS/iPadOS/tvOS/macOS/visionOS).
Deferred (behavior-change risk / out of scope): the `isTvOsDevice` interaction
leaves (dispatch-interactions, interactors/apple, platforms/ios) are irreducible
per-device gesture/focus synthesis, not capability admission; per-gesture table
granularity (pinch/rotate/transform) is left as one `multiTouchSynthesis` field
since all three are uniform today.
* feat: add doctor command
* fix: reduce doctor command complexity
* fix: classify doctor integration flags
* fix: simplify doctor setup
* refactor: split doctor checks
* fix: simplify doctor check set
* fix: include stopped android avds in devices
* fix: report doctor device inventory
* refactor: reuse device inventory selectors
* fix: summarize doctor inventory by platform
* fix: show metro cwd in doctor
* refactor: simplify metro doctor lookup
* fix: update doctor imports after apple consolidation
* feat: make doctor Metro probe controllable and surface hidden toolchain failures
Two gaps found while verifying the doctor command on a real environment:
- Metro host/port were uncontrollable from the CLI: --metro-host/--metro-port
were rejected by allowedFlags, and readDoctorOptions only read them from
req.runtime (populated by remote/connection profiles, never a plain CLI
flag). The Metro check's own hint told users to 'pass the correct
--metro-host/--metro-port', which did not exist. Declare the flags and read
them from req.flags (runtime kept as fallback) so the probe can target any
endpoint, e.g. from outside an RN/Expo project directory.
- A broken per-platform toolchain was silently hidden: readDoctorDeviceInventory
dropped inventory failures whenever any other platform returned devices, so a
broken Xcode or Android SDK still reported a green 'pass'. Keep the failures
and surface each as a warn (device-<platform>) when other platforms have
devices; scoped --platform runs stay quiet.
* fix: align doctor CI expectations
* feat: extend doctor preflight checks
* fix: keep doctor checks within ci gates
* fix: simplify doctor metro surface
* refactor: trim doctor bundle impact
* fix: restore useful doctor diagnostics
* refactor: reuse doctor output helpers
* refactor: share device inventory grouping
* refactor: keep doctor focused on preflight checks
* refactor: simplify doctor toolchain probes
* fix: keep scoped simulator hint generic
* fix: clarify doctor Xcode selection context
* fix: recognize provider scope in remote doctor
* fix: address doctor review gaps
* fix: keep doctor metro checks inferred
Add the `perf` facet to `PlatformPlugin`, typed platform-neutral as
`{ supportsMetrics(device: DeviceInfo): boolean }` (never the iOS provider
seam). Populate it by wrapping the Apple + Android arms of the existing
`supportsPlatformPerfMetrics` predicate verbatim (both return `true`), leave
linux/web factless, and route `supportsPlatformPerfMetrics` in
`daemon/handlers/session-perf.ts` through `tryGetPlugin(...).perf?.supportsMetrics`
with a `?? false` fallthrough that preserves the former hand disjunction.
Pinned by a table-equivalence parity test
(`daemon/__tests__/perf-plugin-routing-parity.test.ts`) that mirrors the merged
appLog gate: an independent verbatim copy of the former predicate is the BEFORE
oracle across the exhaustive platform x kind x target device matrix, facet
presence/fallthrough are asserted, and `buildPerfResponseData` is exercised with
no-app sessions to prove the daemon actually routes through the facet.
Only the support gate is routed; the perf sampling body (`buildPerfResponseData`)
and the Android-only native-collector gate stay on their daemon branch until each
clears the same gate. Follows the merged appLog facet template (type-only
core->daemon edge, lazy `registerBuiltinPlatformPlugins()`).
Refs #974
Phase 3 step b.2. Move the per-command supports() / unsupportedHint() device
closures VERBATIM off the command-descriptor facet onto the owning
PlatformPlugin's capability.supportsByDefault / unsupportedHintByDefault
(perfect-shape §7 / ADR-0009: relocate, never flatten). Bodies are byte-for-byte
identical; only their ownership moves to the Apple plugin, the family that owns
every discriminating device (macOS-coordinate-pinch, tvOS-no-touch, physical-iOS,
two-finger-synthesis).
The relocation is faithful because every closure is a no-op (returns true /
undefined) on non-Apple devices, so consulting it only for the Apple family
leaves admission unchanged across the full device matrix. isCommandSupportedOnDevice
and unsupportedHintForDevice now read the closure off getPlugin(device.platform);
the command facet carries platform/kind buckets only, and supports/unsupportedHint
are removed from the CommandCapability type.
Parity gate (byte-for-byte, before deleting the hand sites): independent VERBATIM
oracle in capability-plugin-routing-parity.test.ts pins (a) production admission +
hint output unchanged across the {platform x command x kind x target} matrix, and
(b) the relocated Apple-plugin closures are behaviorally identical to the originals
across the device-fixtures sample matrix, with a guard that no non-Apple family
grew a gate.
Name the tvOS Apple-OS leaf instead of branching on a `target === 'tv'`
string smeared across the Apple interaction paths (Phase 3 d.2, part of #972).
- Add `isTvOsDevice(device)` to kernel/device.ts — the sink, so core, the
command-descriptor registry, and the platform code can all gate on one
explicit, Apple-only predicate (the layering DAG forbids core importing
platforms). Android TV shares `target: 'tv'` but is a distinct leaf, so the
`platform === 'ios'` gate is load-bearing.
- Extract the XCUIRemote focus-navigation command builder into a real
`src/platforms/apple/os/tvos/` leaf (mirroring os/macos/), delivering the
dedicated tvOS leaf ADR-0009 deferred.
- Route the unambiguous tvOS gates (interactor back/home/scroll, iOS-touch
synthesis, registry capability predicates) through `isTvOsDevice`.
Do-not-flatten preserved: the tvOS focus-only interaction contract stays
per-OS. back/home/scroll drive XCUIRemote focus; coordinate tap goes to the
runner un-synthesized (rejected off the focused element); pinch/rotate/
transform stay UNSUPPORTED. This is a rename/extraction — behavior is
byte-identical for tvOS devices.
Intentionally left (noted inline): devices.ts `resolveAppleOs` (the canonical
target→appleOs classification source), and the dispatch pinch/rotate/transform
gates — those `target === 'tv'` checks also reject Android TV, so narrowing
them to the Apple-only leaf would change Android-TV behavior.
Tests: `isTvOsDevice` Apple-only gate (excludes Android TV); tvOS back/home
focus navigation via remotePress menu/home + iOS keeps in-app back; tvOS
rotate/transform reject UNSUPPORTED at dispatch (alongside existing pinch).
Phase 3 b.3: add the first daemon-owned facet to `PlatformPlugin`, typed
against a PLATFORM-NEUTRAL wrapper and pinned by a table-equivalence parity
test before the daemon lookup routes through it.
- `PlatformPlugin.appLog.resolveBackend(device): LogBackend` — a neutral
string-union tag (never the iOS-simulator provider seam). Optional facet,
present only on families with an app-log backend (Apple + Android).
- Populate by wrapping the existing `resolveLogBackend` branch verbatim on the
Apple/Android plugins; linux/web omit the facet and the daemon lookup
preserves the historical `'android'` fallthrough.
- Route `src/daemon/app-log.ts`'s `resolveLogBackend` through
`tryGetPlugin(...).appLog?.resolveBackend(...)`; populate the registry at
module load (idempotent, lazy — mirrors `core/capabilities.ts`).
- Pin with `applog-plugin-routing-parity.test.ts`: the routed function is
byte-identical to an independent verbatim copy of the former hand branch
across the device fixtures + exhaustive platform x kind x target matrix.
Deferred (not populated / not parity-added to the contract): `providers`
(load-bearing scope-orchestration seam), `recording` (needs the
de-iOS-naming start/stop-context redesign), and `perf` (heavy internal
response-builder). Each stays the daemon branch's source of truth.
XCUITest cannot drive watchOS UI (no XCUIApplication), so a watchOS device has no
runner backend. Today `appleOs: 'watchos'` silently falls through to the iOS
runner profile (resolveRunnerPlatformNameForAppleOs). Reject it explicitly at the
admission seam — createAppleInteractor — with a clear UNSUPPORTED_PLATFORM error,
before any runner work.
Discovery never produces watchOS today (resolveAppleOs only yields
ios/ipados/tvos/visionos), so this changes no real-device behavior; it makes the
"declared but unsupported" watchOS case honest instead of a silent wrong fallback.
Per-AppleOS capability-table integration is deferred to d.5 (#978).
Adds a focused unit test asserting the watchOS rejection and that a non-watchOS
appleOs does not trigger the sentinel.
Completes the #983 relocation. The 2 files deferred there compute runtime fs
paths (fileURLToPath/__dirname), so they needed path-depth fixes on top of import
re-relativization:
- runner-client.test.ts -> src/platforms/apple/core/__tests__/ (joins the rest of
the Apple engine tests); repoRoot recomputed one level deeper.
- recording-scripts.test.ts -> src/recording/__tests__/ (beside the overlay.ts it
tests); ios-runner RecordingScripts + test/integration/support paths recomputed
one level shallower.
src/platforms/ios/__tests__/ is now empty (all Apple-engine tests live beside
their source under apple/core and recording). Pure test relocation — tsc, oxlint,
oxfmt, layering guard, fallow green; full unit suite passes (2883).
#984 added the R3 platforms-seam layering rule and #986 moved the public SDK
entry barrels into src/sdk/; the two merged mutually inconsistent, so the
Layering Guard is failing on main. sdk/ are public re-export barrels that
legitimately expose platform symbols and are off the CLI cold path (not imported
by bin.ts), so they are a correct R3 exemption alongside core/interactors and the
daemon server — not a cold-start regression.
Move the package's public entry points into a dedicated src/sdk/ folder so the
public surface lives in one clearly-owned place, per plans/perfect-shape.md §5.5
("sdk/ = re-export barrels only") and the §3 target DAG. Follows the #951/#960
pattern (behaviorless path codemod; rslib entry KEYS unchanged so dist output
paths — and therefore package.json `exports` — stay byte-identical).
Barrel structure (all 11 public subpaths now resolve through src/sdk/):
- 9 already-thin barrels moved verbatim (git rename, internal import paths
re-depthed by one ../): index, artifacts, metro, batch, remote-config,
install-source, android-adb, contracts, selectors.
- io and finders keep their implementation at src root (real logic + internal
importers); src/sdk/io.ts and src/sdk/finders.ts re-export them (export *).
package.json exports/main/types: UNCHANGED. Each rslib entry key is preserved
(index, io, ...), so dist output stays dist/src/<name>.{js,d.ts} and the
published subpaths + files do not move. No exports rewrite was needed.
Repoints:
- rslib.config.ts: 11 entry sources -> src/sdk/*.ts (keys unchanged).
- 7 public *-test imports -> src/sdk/*.
- 3 internal src importers of the selectors barrel (find.ts, selector-read.ts,
resolution.ts) split to import impl directly (utils/selectors-parse.ts +
daemon/selectors.ts) — removes internal dependence on the public barrel.
- .fallowrc.json entrypoints + vitest coverage exclude (src/sdk/** glob).
Published-surface verification (npm pack --dry-run before/after):
- exports map identical; 146 packed files identical EXCEPT one internal
code-split chunk renumbered (dist/src/9836.js -> 893.js, byte-identical
content).
- All 11 public .d.ts byte-identical; 10/11 public .js byte-identical;
selectors.js differs only by that internal chunk reference. Same symbols.
Gates: tsc 0 · rslib build 0 · oxfmt/oxlint clean · fallow audit (31 files) clean
· vitest unit 2883 pass.
Phase-5 §5.5 folder move (server side; the daemon/client/ split shipped in
#962). Extracts the process-bootstrap / server-runtime cluster into
src/daemon/server/ as a pure, behaviorless path codemod — no logic changes.
Moved (server bootstrap/runtime — the layer that spins up the daemon and
owns the platform graph; each imported only by the bootstrap layer + each
other):
src/daemon-runtime.ts -> src/daemon/server/daemon-runtime.ts
src/daemon/http-server.ts -> src/daemon/server/http-server.ts
src/daemon/transport.ts -> src/daemon/server/transport.ts
src/daemon/server-lifecycle.ts -> src/daemon/server/server-lifecycle.ts
src/daemon/server-shutdown.ts -> src/daemon/server/server-shutdown.ts
Left in src/daemon/ root (request core / shared wire helpers, out of scope):
request-router.ts, handlers/, session-store.ts, lease-registry.ts, context.ts
(the daemon's request layer) and http-contract.ts / http-health.ts /
http-errors.ts / config.ts (HTTP wire contract + daemon config shared across
client, remote, and cli — not server-only).
Left: src/daemon.ts (the thin process entry) stays at src/ with the other
package entrypoints; it is coupled to its physical path by four non-import
string references (rslib entry, config dev-mode sentinel, process-identity
detection regex, daemon-client launch srcPath), so moving it is beyond a pure
import codemod.
Rewrote every from/import/import()/type-only specifier per importer
(resolve-based path.relative recompute) across src and test, and renamed the
fallow health-baseline key for http-server.ts. daemon-runtime's static
platforms/ import is now inside the daemon-server seam the layering lint
(#984 R3) allows.
Verification: tsc --noEmit 0; layering check (branch script) unchanged (3
pre-existing R3 violations, 0 new); oxfmt clean; oxlint --deny-warnings 0;
fallow audit --base origin/main clean (14 files); rslib build 0
(internal/daemon entry still emits); vitest 17 passed (daemon-entrypoint,
http-server-rpc-validation, server-shutdown + 3 provider-integration).
Generalize the inline CI "Layering Guard" grep into a structured
import-direction lint (scripts/layering/check.ts) over the resolved
import graph, per plans/perfect-shape.md §5.5.
The full target DAG (kernel ◄ platforms ◄ core ◄ commands ◄ {cli,
client, daemon/server}; client ◄ daemon/client) is only partly realized
— the client/remote/metro extraction, the daemon/server split, and the
utils dissolution are still pending Phase-5 moves, so the tree still
holds legitimate back-edges (platforms→core, commands→cli, utils→*).
Enforcing the whole DAG today would need a mass import rewrite that
Phase 5 defers. The lint therefore enforces the three invariants the
completed moves (kernel/, daemon/client/) already guarantee and that are
green today:
R1 kernel-sink — nothing under src/kernel/ imports another zone,
except the one type-only kernel→contracts re-export.
R2 commands-floor — nothing below the command surface (kernel,
platforms, core, daemon) imports src/commands/.
Generalizes the former guard (daemon + platforms).
R3 platforms-seam — platforms/ is statically imported only at the
core interactor seam (src/core/interactors/) and by
the daemon server; elsewhere use a dynamic import()
or a type-only import, preserving CLI cold-start.
Dynamic import('../platforms/*') and `import type` stay allowed.
Fixes the three pre-existing R3 violations by converting static
platforms value imports to dynamic imports (all in already-async call
sites, behavior-preserving and cold-start-improving):
- src/client/client.ts debug.symbols → lazy symbolicateCrashArtifact
- src/cli/commands/web.ts setup/doctor → lazy agent-browser-tool
- src/core/dispatch-interactions.ts runner-sequence → lazy (matches the
file's own dynamic-import pattern)
Wire the check into the Layering Guard CI job and add a check:layering
package.json script (also folded into check:tooling). scripts/layering/**
is excluded from fallow (untested CI script, like scripts/perf/**).
After #968 moved the OS-agnostic Apple engine to src/platforms/apple/core/, its
tests still lived in src/platforms/ios/__tests__/. Move the 19 that test Apple
engine code into src/platforms/apple/core/__tests__/ so tests sit beside their
source, re-relativizing every import / dynamic import / vi.mock / vi.importActual
specifier to the new depth.
Deferred (still in src/platforms/ios/__tests__/): recording-scripts.test.ts and
runner-client.test.ts — both compute runtime fs paths (__dirname / fileURLToPath)
to ios-runner artifacts, so they need path-string fixes, not just specifier
re-relativization. Tracked under #980.
Pure test relocation — no source or behavior change. tsc + oxlint + oxfmt green;
the moved suites pass (384 tests across the apple + deferred dirs).
The remaining Phase 3 Apple PlatformPlugin work (steps b + d) is now filed as
GitHub issues under umbrella #972, so the standalone progress plan is redundant
and a staleness hazard (it already drifted once re: cost.runnerRoundTrips).
- Remove plans/phase3-platform-plugin-progress.md.
- Repoint its references at the durable sources: perfect-shape.md (x3) and
ADR-0009 now link the Phase 3 tracking issue #972; the plugin.ts step-b facet
note points at ADR-0009 (+ issue #974). Design rationale stays in
perfect-shape.md and ADR-0009; live status lives in the issues.
The Step (c) request-count bullet claimed both the dev-only CI gate AND the
runtime `cost.runnerRoundTrips` surface were removed in #968. #970 restored the
public agent-cost field, so the bullet is stale/misleading for the next
Apple/agent-cost worker.
Split the bullet: the dev-only request-count CI gate (the #966 --debug ndjson
counter + smoke-ios assertion) stays removed (zero runner events on main runs);
the runtime `cost.runnerRoundTrips` agent-cost field (ResponseCost /
buildResponseCost over RUNNER_ROUND_TRIP_PHASES) is a separate pre-existing
surface, restored in #970, and remains part of the agent-cost contract.
PR #968 (apple-platform-consolidation) accidentally dropped the
runnerRoundTrips field from the agent-cost block during an over-broad
conflict resolution. This restores the shipped Phase-4 feature:
- src/kernel/contracts.ts: re-add ResponseCost.runnerRoundTrips: number
- src/utils/diagnostics.ts: restore the countDiagnosticEventsByPhase()
accessor over the flush-surviving phaseCounts tally (the tally itself
survived #968; only the accessor was removed)
- src/daemon/request-router.ts: repopulate runnerRoundTrips in the cost
graft by counting the two real round-trip diagnostic phases
(ios_runner_command_send + ios_runner_readiness_preflight). The
RUNNER_ROUND_TRIP_PHASES constant is now defined locally (its former
home, the dev-only runner-request-count.ts, was removed in #968 and is
out of scope to restore)
- request-router-cost.test.ts: restore the round-trip counting test and
the runnerRoundTrips:0 assertion
Byte-identical-default invariant preserved: with --cost OFF or on an
error response the serialized payload is unchanged.
Post-#968 follow-up: the OS-agnostic Apple runner engine moved from
src/platforms/ios/ to src/platforms/apple/core/, but several docs/config still
pointed at the old locations, misleading agents that grep those paths.
- AGENTS.md: repoint the runner-seam map, the Apple-family sync rule, the
record/trace seam, the search-roots hint, and the platform-backends list at
src/platforms/apple/core/...; rename "iOS Runner Seams" -> "Apple Runner Seams".
- .fallowrc.json: drop the two stale ignoreExports entries for the removed
src/platforms/ios/apps.ts and src/platforms/ios/index.ts (the live
src/platforms/apple/core/apps.ts entry already covers those test-only exports).
- ios-runner/{README,RUNNER_PROTOCOL}.md: point the TypeScript-client links at
src/platforms/apple/core/runner/runner-client.ts.
Docs/config only; no behavior change. fallow audit + build/lint stay green, and a
live iOS simulator replay suite (6/6) confirms the consolidated runner works.
* feat: support live replay test reporters
* refactor: simplify replay progress readers
* fix: preserve verbose replay reporter progress
* feat: expose semantic replay reporter hooks
* refactor: trim replay reporter context
* refactor: trim reporter progress internals
* refactor: move replay test reporting under replay
* refactor: make live replay reporter hooks synchronous and simplify dispatch
Live reporter hooks (onSuiteStart/onTestStart/onTestStep/onTestResult)
were typed as `void | Promise<void>` but fired from the synchronous daemon
progress stream reader without being awaited, so a stateful async reporter
could receive onSuiteEnd before its live work settled. Type them as `void`
to make the contract honest; onSuiteEnd stays awaited for async flushing.
A returned promise from a misbehaving custom JS reporter is still caught so
it cannot crash the CLI with an unhandled rejection, but it is documented as
unsupported and not awaited.
Collapse the four near-identical per-event hook dispatch branches into a
single table-driven path, and document the synchronous-hook and
exit-code-escalation contracts. Add a regression test covering a throwing
live hook.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XXHAYxWpvSzqc6CtneYL8J
---------
Co-authored-by: Claude <noreply@anthropic.com>
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.
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.
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.
* 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).
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.
* 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.
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.
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).
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.
* 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.
* 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).
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.
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.
* 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.)
#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.
* 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.
* 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.