* perf: start iOS simulator runner prewarm before close/open dispatch
Since simulator close/open ride simctl and never touch the runner, the
xcodebuild ramp can overlap the app (re)launch instead of following it.
Real devices keep the post-open prewarm because relaunchCloseApp tears
their runner down first; the Maestro prewarm-before-open path is
unchanged.
Daemon-cold plain open: first snapshot 4.6-5.1s -> ~2.5s (the ramp gets a
~2s head start). Daemon-cold open --relaunch: ~8.9s -> ~8.2s mean; most
of the head start is eaten by ramp slowdown under concurrent simctl work,
matching earlier contention observations.
* perf: seed the simulator booted-memo from the device inventory parse
A simctl listing that reports a simulator Booted is the same observation
ensureBootedSimulator would make, so resolving a device now seeds the
memo and the ensureDeviceReady boot check that follows in the same
request skips its own ~0.7s listing. Daemon-cold plain open: ~2.6s ->
~2.0s.
* perf: hand iOS simulator runners off across daemon restarts
Graceful daemon shutdown detaches healthy simulator runner sessions
instead of killing them: the lease token is rewritten to a detached form
(so the daemon's own teardown paths no longer classify it as owned) and
the xcodebuild/runner pair keeps running. The next daemon start adopts a
stale lease when the runner process is alive, the artifact fingerprint
matches the current toolchain, and an uptime probe answers - otherwise
the existing cleanup-and-restart hygiene applies unchanged. Crash-killed
daemons leave the same stale lease, so crash recovery and deliberate
handoff share one path.
The adopted session wraps the orphaned xcodebuild in a pid-backed
surrogate child; every downstream consumer (liveness, kill-tree,
early-exit detection, disposal wait) operates on pid/exitCode, which a
low-frequency exit poll maintains.
Bounds and escape hatches: at most one detached runner per device, the
runner's XCTWaiter self-expires after 24h, clean:daemon still kills by
lease runnerPid, and AGENT_DEVICE_IOS_RUNNER_DETACH=0 disables both
detach and adoption.
Daemon-cold open --relaunch with a handed-off runner: ~8s -> ~3.2s
(startup.durationMs ~780), for both SIGTERM and SIGKILL'd predecessors.
* fix: keep scoped simulator-set runners out of the shutdown handoff
Review finding on #1011: detach released the XCTestDevices device-set
redirect while the runner kept running, and adoption rebuilt the session
without it - a custom-set runner could outlive the symlink/lock that its
xcodebuild depends on, breaking scoped simulator-set isolation.
The redirect's lifetime is bound to the owning session by design, so
scoped-set runners simply do not participate in the handoff: shutdown
detach skips any session holding a redirect (it goes through the normal
dispose-and-restore path), and adoption refuses devices that resolve to a
custom simulator set. Default-set sessions - the only ones that never
hold a redirect - keep the fast handoff.
* fix: gate relaunch runner teardown on Apple platforms explicitly
'device.platform !== \'android\'' predates the platform set growing to
include linux and web - for those it would take the runner-session lock
and walk the lease cleanup path on relaunch for a device that can never
have an XCUITest runner. isApplePlatform() states the actual intent:
Apple non-simulator targets tear down, simulators stay hot, everything
else never touches the runner.
* refactor: quality pass over the iOS startup perf stack
Applied from a 4-angle review (reuse / simplification / efficiency /
altitude) of the #1011 diff:
- RunnerSession.child is now a RunnerProcessHandle (pid/exitCode) instead
of ChildProcess: every consumer only reads pid/exitCode and kills by
pid, the adoption surrogate no longer needs double casts, and the
compiler enforces what a comment used to promise. Real children satisfy
the handle structurally.
- session-open prewarm scheduling collapsed into schedulePrewarm/
awaitPrewarm closures; the fallback guard is a dedicated local instead
of reading the OpenTiming telemetry object (kept separate from the
promise itself, which is legitimately undefined when prewarm is
unavailable).
- adoption: expected derived path computed once and threaded through
(resolveExpectedRunnerCacheMetadata does a recursive source-stat walk
per call, and it runs under the lease lock); guard chain flattened into
skip(reason) early returns; probe timeout 2s -> 500ms (localhost
refusal is instant, the timeout only bounds the wedged-runner case);
dead path.dirname fallback removed.
- shared normalizeRunnerStartupTimeoutMs and buildRunnerSessionId in
runner-session-types; deleted the verbatim copies.
- detach kill switch parses through utils/source-value parseBooleanLiteral
(now exported) instead of a bespoke dialect that treated 'no' as
enabled.
- dropped buildDetachedRunnerLease's untriggerable idempotence guard,
direct Map iteration in the detach loop, and a freshness warning on
markSimulatorBooted for future callers.
* perf: keep iOS simulator runner hot across open --relaunch
open --relaunch tore down the XCUITest runner session before closing the
app, then paid a full xcodebuild test-without-building restart (~6s) after
reopening it. Simulator close/open go through simctl and never touch the
runner, so a healthy runner survives the relaunch; stale runners are
recovered by the readiness preflight + invalidate/restart path that landed
after #705 reverted #700's version of this.
Steady-state open --relaunch on iPhone 17 Pro sim: 11.0s -> 4.9s
(startup.durationMs 5929 -> 1574). Real devices keep the conservative
teardown.
* perf: drop redundant simctl inventory listings from the iOS open path
A single open --relaunch spawned `simctl list devices -j` (~0.7s each)
three times: session-device re-resolve, closeIosApp's booted check, and
launchIosSimulatorApp's booted check.
- ensureBootedSimulator now keeps a 5s recently-observed-Booted memo
(mirroring DEVICE_READY_CACHE_TTL_MS at the daemon layer) so repeated
boot checks inside one request cost nothing; shutdownSimulator
invalidates it, boot transitions seed it.
- refreshSessionDeviceIfNeeded skips the re-resolve while the device's
XCUITest runner session is alive - a live runner attached to the UDID
already proves the simulator exists and is booted.
With the runner kept hot across relaunch, steady-state open --relaunch on
iPhone 17 Pro sim drops 4.9s -> 2.85s (startup.durationMs ~1574 -> ~760).
Combined with the previous commit: 11.0s -> 2.85s.
* test: expect single simctl listing in tvOS provider flow
The recently-observed-Booted memo removes the repeat state listings the
launch and terminate boot checks used to make; the transcript now records
one listing per flow.
* feat: surface appleOs discriminant on public device output
Additive, non-breaking: the public device/result shapes now carry the
stored Apple-OS discriminant (iPhone/iPad/tvOS/visionOS/macOS) alongside
the existing leaf platform (ios/macos). Consumers can distinguish Apple
OSes on the wire instead of only the collapsed ios/macos leaves.
- session-inventory devices/session_list stop stripping appleOs and emit
it only for Apple devices (non-Apple omit it); platform stays the leaf.
- boot/shutdown success results (contracts/device.ts) gain optional appleOs.
- AgentDeviceDevice/AgentDeviceSessionDevice + client normalizers carry it.
- appleOs values never equal the internal 'apple' token, so the
apple-platform-output-guard is unaffected.
Tests: devices projection per Apple fixture (ios/ipados/tvos/visionos/
macos) + non-Apple omission; client normalizer coverage.
* fix: gate public appleOs surfacing to Apple platforms
appleOs is Apple-only, but the daemon projection (devices, session_list, boot,
shutdown) and the client normalizers preserved it whenever the field was present —
so a malformed/legacy non-Apple record carrying a valid Apple OS value would leak
it on the public wire. Gate every emission site on isApplePlatform(platform), not
just field presence.
Regression tests: a non-Apple device carrying a stray appleOs='macos' drops it in
both the daemon devices projection and normalizeDevice.
Process guardrail (addresses the recurring miss behind the Platform-collapse leaks):
- Testing Matrix: platform/device-response changes must also run test:integration:provider
and test:coverage — check:unit alone misses the provider-integration project (which runs the
apple-platform-output leak guard).
- PR Readiness: don't call a PR CI-green off a local --project unit run; the Integration Tests
and Coverage jobs run provider-integration, so verify those on the actual PR head.
* feat: route recording + provider platform gates through PlatformPlugin facets
Phase 3 step b.3 (issue #974): land the two deferred daemon-owned
PlatformPlugin facets as DATA-ONLY seams, mirroring appLog/perf.
recording facet: PlatformPlugin.recording.resolveBackendTag(device)
returns a neutral RecordingBackendTag string ('web'|'android'|'macos'|
'ios-device'|'ios-simulator'|'unsupported', daemon-owned, type-only in the
plugin). resolveRecordingBackendForDevice now maps the tag back to the
daemon-owned backend instance via RECORDING_BACKENDS_BY_TAG, with a
'?? unsupported' fallthrough for the factless linux family. Backend
instances + RecordingBackend type stay in the daemon.
providers facet: PlatformPlugin.providers.platformGatedResolvers declares,
per family, which platform-gated request provider resolvers apply (the data
that replaces each descriptor's hand 'device.platform === …' gate). The
daemon still owns resolver invocation, wrapper composition, and request-scope
concurrency isolation; only the gate moved to data. Ungated resolvers
(appLog/recording providers) stay ungated in the daemon.
Both facet types are type-only in the plugin (R3-clean, like LogBackend), so
no platforms/->daemon value edge and no CLI cold-start regression. Pinned by
two table-equivalence parity tests (independent verbatim oracle, full
platform x kind x target matrix, facet-presence + fallthrough + end-to-end
routing).
* docs: mark b.3 recording/providers facets as landed in CONTEXT.md
This PR routes the recording + providers PlatformPlugin facets, so the durable
architecture context can no longer list them as deferred/on-the-daemon-branch.
Update the Deferred section: all four b.3 facets now route through the plugin;
only the perf sampling body (buildPerfResponseData) still branches in the daemon.
* docs: retire plans/perfect-shape.md — roadmap complete
The perfect-shape roadmap (two-registry thesis: CommandDescriptor +
PlatformPlugin, typed-result spine, folder DAG + layering lint, agent-cost,
and the Apple apple+appleOs platform model with a non-breaking leaf wire) is
substantively complete and merged. Per its own §5 retirement note, the durable
decisions now live in ADR-0008 (command descriptor) and ADR-0009 (Apple/AppleOS),
and current-state terms in CONTEXT.md; this removes the last plan file.
- Delete plans/perfect-shape.md (plans/ is now empty and gone).
- CONTEXT.md: add "Architecture (perfect-shape refactor, completed 2026-07)"
end-state summary plus a "Deferred / next-minor" note (Phase 2c client-types
narrowing, b.3 recording/providers facets, strict DAG back-edge inversion,
legacy alias drops) so nothing is lost.
- Repoint every remaining perfect-shape.md/§ reference (ADRs 0003/0008/0009,
ci.yml, scripts/layering/check.ts, and the platform-plugin/apple comments)
to ADR-0008/0009 or CONTEXT.md. No dangling references remain.
Docs/comment-only; tsc, oxlint, oxfmt, and the layering DAG check all pass.
* docs: repoint dangling perfect-shape section refs before retiring the roadmap
Removing plans/perfect-shape.md left three comments citing bare section numbers
with no surviving target. The rationales are already inlined, so drop the numbers
(and point the do-not-flatten note at the durable ADR):
- src/platforms/apple/plugin.ts: `(§7)` -> "do-not-flatten; see docs/adr/0009".
- src/core/interactors/register-builtins.ts: "the §5.1 ... sketch" -> "an ... sketch".
- scripts/layering/check.ts: drop `(§5.5 ...)`, keep the inline "re-export barrels only".
Adds a provider-integration guard (apple-platform-output-guard.test.ts) that
stands up a fake-provider daemon for BOTH a macOS Apple session and an
iOS-simulator Apple session, drives EVERY public command off PUBLIC_COMMANDS,
and deep-scans each serialized response for the internal 'apple' platform token
(any string VALUE or object KEY that exactly equals 'apple').
The guard is catalog-driven: a partition test fails if a new public command is
neither in DRIVEN_COMMANDS nor SKIPPED_COMMANDS, so a new command can't silently
escape the check. All 50 public commands are driven; the skip-set is empty.
Caught + fixed a leak PR #1004 misses: doctor's data.platform (session-doctor.ts)
echoed the raw internal device.platform ('apple') when doctor ran against a bound
session with no --platform flag. Now projected via publicPlatformString(device).
doctor's byPlatform.apple KEY leak stays tracked to PR #1004 via a narrow,
documented allowlist entry (not duplicated here).
The Platform collapse projected most `doctor` output but missed the device
inventory breakdown: `deviceInventoryEvidence` keyed `byPlatform` by the raw
internal `device.platform`, so an Apple session emitted `byPlatform: { apple: … }`
in the doctor response — a machine-facing `apple` leak (approach b keeps output
leaf `ios`/`macos`). Key it by `publicPlatformString(device)` instead (now
`Map<PublicPlatform, …>`, so an `apple` key is compile-impossible), which also
splits Apple devices into ios vs macos in the breakdown.
Also project two doctor command SUGGESTION strings (`apps --platform …`,
`close --platform …`) to the leaf — `--platform apple` was valid but broader than
the session's actual device.
The doctor descriptor routing (command-descriptor `daemon` block) and its lack of
a platform-specific `supports` gate are already correct; toolchain checks already
accept `apple`. Verified: tsc, oxlint, oxfmt, layering, fallow, provider-integration
82/82; full unit green except the known android fillAndroid contention flake
(passes 92/92 in isolation).
* refactor: collapse public Platform ios/macos into apple (#979)
Phase 3 d.3: collapse the internal `Platform` union from `ios`/`macos` to a
single `apple` platform, with `appleOs` as the sole OS discriminant. Approach
(b) NON-BREAKING: the daemon still ACCEPTS the legacy `ios`/`macos` selectors on
every read path and still EMITS the leaf `ios`/`macos` strings on every output,
so machine consumers see no change.
Kernel (src/kernel/device.ts):
- PLATFORMS = ['apple','android','linux','web']; add PUBLIC_PLATFORMS (leaf) and
PublicPlatform; PLATFORM_SELECTORS keeps legacy `ios`/`macos` as input aliases.
- New predicates: isMacOs (appleOs- or legacy-leaf-based), isIosFamily (the
post-collapse equivalent of `platform === 'ios'`), publicPlatformString (output
projection), deviceFieldsFromPublicPlatform (inverse), isPublicPlatform.
- isMobilePlatform and matchesPlatformSelector are now device-aware (appleOs).
Discovery now stamps `platform: 'apple'` (+ appleOs); ~125 internal
`device.platform === 'ios'|'macos'` branch sites migrated to the predicates,
behavior-preserving. Apple plugin owns `['apple']`; platformDescriptors collapse
to one `apple` row.
Output projection (approach b) emits the leaf via publicPlatformString at:
devices / session_list (session-inventory), boot / shutdown / appstate /
prepare-ios-runner (session-state, session), the selector/backend platform
(selector-runtime/screenshot-runtime/snapshot-runtime/interaction-runtime),
proxy device key, request-lock backfill, runtime-set binding, click-button
validation, and both `.ad` context-line writers.
Contracts/client keep leaf types (PublicPlatform); read paths (parsePlatform,
REPLAY_METADATA_PLATFORMS, matchesPlatformSelector) accept `apple` + legacy
leaves. Adds a parity test gate (platform-collapse-parity.test.ts).
Refs #979 (part of #972).
* fix: project platform to the public leaf at open/perf response sites (#979)
The Platform collapse left two daemon response builders emitting the raw
internal `device.platform` ('apple'), which the client normalizer rejects
(isPublicPlatform excludes 'apple') — dropping the resolved device from the
response:
- session-open-surface.ts: `open` result `platform`/device projection.
- session-perf.ts: the perf/frames/memory base response builders.
Both now go through `publicPlatformString(device)`, so output stays the leaf
`ios`/`macos` per approach (b). (The android/non-apple perf branches were
already leaf-safe.)
Also update macos-desktop provider test: the lifecycle mock observes the
INTERNAL DeviceInfo, which is now `platform:'apple'` (+ appleOs:'macos'), so the
recorded tag is `prepare:apple:desktop`.
Fixes the provider-integration assertions that blocked both the Integration
Tests and Coverage CI jobs (both run the provider-integration project).
Verified: provider-integration 82/82, coverage passes, tsc/oxlint/oxfmt/layering/
fallow green.
* fix: project platform to the public leaf at nested output sites (#979)
The Platform collapse (approach b) projects device.platform through
publicPlatformString at emit sites so machine consumers keep seeing the
leaf ios/macos and never the internal `apple`. Several nested output
fields were missed. Project them and narrow their emitted types to
PublicPlatform:
- Apple perf memory snapshot support (buildAppleMemorySnapshotSupport) —
response.support.platform / artifact.support.platform, plus the
sibling sampleAppleFramePerf error data.
- Apple xctrace perf capture/result platform surfaced in the perf
cpu-profile started/stopped response data.
- snapshotDiagnostics.stats.platform (recordSnapshotTiming) surfaced in
snapshot/test response data and the slow-snapshot warning string.
- doctor target-app evidence.platform + human summary, and doctor
target-app-device evidence.booted[].platform.
- provider/cloud UNSUPPORTED_OPERATION error.data.platform for cloud
Apple devices (reachable via deviceFieldsFromPublicPlatform).
Internal 'apple' emissions (selector-matching input, diagnostic
emitDiagnostic telemetry, session appLog state, replay .ad flags) are
left as-is. Adds focused tests pinning Apple perf memory support to the
leaf and a guard asserting no emitted platform field equals 'apple'.
* 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.