* test: gate public command surface against commands.md reference
Enumerate PUBLIC_COMMANDS against website/docs/docs/commands.md in both
directions with a waivered unit-lane gate, and document the drift found on
main (doctor, react-native).
Refs #1420
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test: recognize tilde-fenced code blocks in command-doc gate
Refs #1420
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* docs: bless the works-today iOS SpringBoard/widget workflow (#1296 PR A)
Live probe on iOS 26.2/Xcode 26.2 proved open com.apple.springboard
already binds a driveable SpringBoard session with zero code changes:
the full widget add/edit/remove flow is selector-driven from a fresh
snapshot, aside from two documented coordinate fallbacks. Add a
help ios-system-ui topic (and cross-links from physical-device/workflow,
the skill router, and docs/commands.md) so agents can use it today,
ahead of the --ui-target contract and gallery-capture fix landing.
* docs: scope the SpringBoard claim to verified iOS simulator support
#1296 explicitly leaves physical-iPhone SpringBoard unverified; the
only evidence so far is an iPhone simulator run, whose private-AX
fallback is simulator-only. Applied to both the CLI help topic and
website/docs/docs/commands.md, with a link to the tracking issue.
Dropped the SkillGym case from this PR per thymikee: most of that
harness is being removed in #1411, so it's not worth iterating on
here.
* fix(test): keep help ios-system-ui out of the 30-line first-screen budget
#1404 added a benchmark gate requiring every Agent Workflows pointer
to stay within the first 30 lines of bare `agent-device help` output.
Adding ios-system-ui to that list pushed help macos to line 31.
Drop the AGENT_WORKFLOWS entry; the topic stays fully reachable via
its cross-references from help physical-device, help workflow's
Escalate section, the agent-device skill router, and
website/docs/docs/commands.md.
* fix(gestures): fail pre-removal gesture forms at .ad parse time (#1216)
#1315 removed the timed forms of `swipe`, `gesture fling`, and `gesture swipe`
and `gesture rotate`'s `velocity`, but shipped without the migration guide, the
repository sweep, or the parse-time error that issue #1216's own checklist
gates a removal on. The sweep finds what that left behind: both
`06-swipe-gestures.ad` integration fixtures still carried the 5-argument swipe
and would fail at replay, two tests still asserted the removed shapes, and two
branches still read the retired positional.
Argument arity for every public gesture syntax now lives in one table keyed off
the canonical `GESTURE_KINDS`, so a new kind cannot skip it and a form removed
from the CLI is removed from `.ad` in the same edit. Both callers read it: the
CLI argv parse, and a new `.ad` preflight. A stale script now fails when it is
parsed — before the replay executes any device action — naming the line and
computing its rewrite, instead of running up to that step and failing as a
repairable divergence. The preflight checks arity only: `${VAR}` tokens resolve
after planning, and interpolation never splits a token, so the count is
decidable while the values are not.
Deleting the dead duration read in `readSwipeGeometry` would have left
`replay export` emitting no duration, handing Maestro's 400ms default to a
gesture the script runs at 100ms, so the export now states `duration: 100`.
`.ad` positional gesture parsing is NOT removed. Its only remaining callers are
the CLI argv parse and the `.ad` line parse, both the current public syntax
rather than a bridge to an older one, so there is nothing to migrate off. ADR
0013 records that and drops the "compatibility" framing that made it read as
debt.
Both migrated fixtures verified on real devices with the repo's own CLI: iOS
simulator 34.9s, Android emulator 45.9s.
* fix(gestures): reject removed swipe input at the Node/MCP boundary
Review findings on d88c6ed8.
P1: `interactionDaemonWriters.swipe` hand-projects five fields, so a JavaScript
caller's `durationMs` was dropped before the daemon's `readSwipeInput` could
reject it and a default-duration fling ran instead — the exact silent
reinterpretation the guide promises does not happen. `gesture` was already safe
because its writer runs `readGestureInput` -> `readGesturePayload`, which
rejects the removed keys; `swipe` was the one surface with no reader of its own.
The rejection now lives in contracts and is shared by the client writer and the
daemon handler, so there is one rule and one message. The SDK regression covers
all four removed keys and asserts the transport is never reached; reverting the
writer call fails it on `swipe durationMs`.
P2: the preflight's retired-slot test required a numeric token, so
`swipe 197 650 197 300 ${DURATION}` fell back to bare usage text. An unresolved
`${VAR}` now counts as the retired slot and is carried into the pan rewrite,
while a stray flag or word stays a plain usage error.
P2: the removal shipped in 0.20.0, not 0.21 — removal commit 6d99914f4 is
contained in tag v0.20.0. The guide said 0.21 because the CHANGELOG still files
it under `Unreleased`; the tag is the truth (headings lag several releases
repo-wide, so that is pre-existing and left alone). The `.ad` grep recipe now
matches variable-backed durations too.
* docs(gestures): make the migration sweep and MCP claim accurate
Re-review findings on 328bad8e (both migration-guide accuracy).
The saved-script sweep matched only the five-argument `swipe` form; it missed
timed `gesture fling`, timed `gesture swipe`, and `gesture rotate ... velocity`,
so the repository-cleanliness step was incomplete. Provide one grep per retired
form (number-or-`${VAR}` token), each verified to flag the removed shape and
skip the valid one.
The MCP section claimed the structured rejection carries the CLI's concrete
replacement command. It does not: `readGesturePayload` and
`assertNoRemovedSwipeInput` return a message that names the removed key and the
replacement command (e.g. `gesture fling does not accept durationMs; use gesture
pan for timed movement`) but not the fully-substituted coordinate rewrite,
because the structured request carries no positional string to rewrite. Describe
what the structured path actually returns.
* docs(gestures): make the .ad sweep parser-aligned
Re-review finding on 4fd5d39: the documented sweep required literal single
spaces and unquoted numeric tokens, but the `.ad` tokenizer separates on any
whitespace (`/\s/`, so tabs too) and accepts double-quoted tokens, so
tab-separated or quoted-duration stale lines the parser rejects were missed.
Rewrite the patterns to follow the tokenizer: `[[:space:]]+` between tokens and
a numeric slot that accepts a bare or double-quoted number (optionally signed)
or `${VAR}`. Requiring a digit in the numeric slot keeps a trailing flag like
`--count` from being read as the retired positional. Verified against a fixture
of tab/space/quoted/quoted-var/negative encodings that the four greps flag
exactly the lines `parseReplayScriptDetailed` rejects and none it accepts, and
that the live repo sweeps clean.
Also state plainly what a regex cannot promise: the parser is the authoritative
gate — every retired form is rejected at parse time before any device action, so
running the suite finds every stale line by construction and a missed grep can
never reach execution. The grep stays a bulk pre-flight, and the "every affected
line" claim is scoped to that.
* chore: remove verified dead code and migration scaffolding
Multi-agent audit of accumulated waste, every finding adversarially
verified against call sites, git history, and the published surface
before removal. Net -710 lines.
- delete src/core/platform-descriptor/ (superseded ADR-0009 migration
scaffold; parity tests now assert an inline table)
- remove test-only seams: registry introspection exports,
CommandFacet.extraDaemonWriters, MaestroEngineOptions.timing
- remove dead flexibility: backend capability allow-list,
screenshot-diff maxRegions, CloudWebDriverSupportLevel 'partial',
clearFirst on the TS+Swift runner wire contract
- remove dead deprecated surface: --session-locked /
--session-lock-conflicts aliases (hard migration error now points at
--session-lock), replay export --format single-value enum,
unused Lease*Payload contract types, runtime-layer rotate duplicate
- collapse pass-throughs/duplication: withRetry adapter,
default-cloud-artifact-provider, connect-profile client-id hashing
(3x sha256 impls -> one helper, byte-identical output), shared
scripts walker, cloneValue -> structuredClone, fill-diagnostics
moved into android/
BREAKING CHANGE: --session-locked and --session-lock-conflicts now fail
with a migration error pointing at --session-lock; replay export
--format is removed (Maestro was the only value); Lease*Payload types
are dropped from the ./contracts subpath.
* chore: satisfy fallow gates tightened by #1363/#1364 after rebase
- drop the consumer-less AndroidFillVerificationNode re-export
- reuse requireSnapshotSession in resolveSnapshotForRef instead of
inlining the same authorized-frame resolution (fallow clone group);
the helper's return type now guarantees the session it already
throws for
* chore: address review — keep cloud-webdriver partial capability metadata
The partial/supported/unsupported levels and their notes are part of the
lease-response capability contract for genuinely limited operations
(Appium page-source snapshots, upload-then-install), not dead
scaffolding. Restore them and the asserting tests unchanged from main.
Also add the missing CHANGELOG entry for the Lease*Payload type removal
from agent-device/contracts.
* feat(maestro): support optional on scrollUntilVisible and extendedWaitUntil
Add optional support at command level and element level for
scrollUntilVisible and extendedWaitUntil. The parser now accepts
optional in both positions and propagates it to the command so the
existing optional-command execution boundary downgrades a timed-out
lookup to a warning and continues the flow.
Update the upstream/076_optional_assertion divergence entry so only
assertTrue remains unsupported, and keep the docs/support matrix in sync.
Closes#1291
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(maestro): reject bare optional selectors, ORed visible/notVisible optionality, and add device differential scenario
- parseMaestroSelectorMapEntries now rejects selectors that contain only
optional and no real matching criteria, with rejection tests for
scrollUntilVisible.element, extendedWaitUntil.visible, and .notVisible.
- extendedWaitUntil now rejects simultaneous visible and notVisible conditions
and derives optionality only from the single condition that will execute.
- Add layer-3 differential flow/scenario optional-warned-scroll-and-wait that
exercises both command-level and element-level optional on a missing target
and verifies the flow continues to the final assertion.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(maestro): split parseExtendedWaitUntil to satisfy fallow complexity gate
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* Maestro compat: support childOf on assertVisible/assertNotVisible (#1294)
- Accept childOf at command level in the Maestro IR and parser.
- Thread childOf through the observation condition to the snapshot
target resolver, reusing the existing ancestor-scoping path.
- Project childOf into the conformance canonical selector so
upstream/114_child_of_selector matches.
- Remove the stale divergence declaration for 114 and update docs.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test: regression-cover assertVisible/assertNotVisible childOf forwarding
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: remove deprecated rotate CLI command alias (#1277)
The rotate CLI command alias was renamed to orientation a few versions
ago and is now removed at the next minor, aligned with the gesture-shim
deprecation window.
- Remove the rotate -> orientation entry from src/cli-command-aliases.ts.
- Add an actionable parser error: invoking rotate now fails with
"rotate was renamed to orientation".
- Update the command-suggestion guard comments and the true-alias list
in the curated suggestion map test.
- Update CLI parser/help usage tests and src/__tests__/cli-help.test.ts
to assert the migration error.
- Remove the rotate deprecation note from the commands doc and add a
breaking migration note to the changelog.
No other command-name aliases are marked deprecated; long-press,
metrics, tap, launch, and relaunch remain supported true aliases.
Fixes#1277
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: disambiguate removed rotate alias error message (#1277)
Update the migration error so users who meant the two-finger gesture are
pointed to `gesture rotate` instead of `orientation`.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: align CHANGELOG with runtime rotate migration message (#1277)
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: rename rotate command to orientation, keep rotate as a deprecated alias
The top-level `rotate` command (device orientation: portrait/landscape) shared
a name with the `gesture rotate` two-finger rotation gesture. Rename the
orientation command to `orientation` and keep `rotate` working as a minimal,
silent CLI alias (same mechanism as `tap`->`press`) for a few versions.
The rename is applied across every layer:
- command-descriptor registry `name`, daemon dispatch handler, and the typed
system facet (metadata/cliReader/daemonWriter/schema/output formatter)
- navigation projection + `CommandResultMap` (`OrientationCommandResult`,
`action: 'orientation'`), client types (`OrientationCommandOptions`), and the
runtime family (`device.system.orientation`)
- interactor + backend methods -> `setOrientation` (matching the backend's
`setKeyboard`/`setClipboard` verb convention); Android helper
`rotateAndroid` -> `setAndroidOrientation`
- Apple/cloud-webdriver capability keys and plugin gate
- user-facing docs (commands.md, client-api.md)
Client SDK method is `orientation` (client convention = camelCase of the
command name, matching `back`/`home`/`appSwitcher`); execution layers use the
imperative `setOrientation`.
Deliberately unchanged:
- the Swift runner wire protocol keeps `command: 'rotate'` — the runner has its
own command namespace with no gesture collision, so renaming it would only
risk CLI<->installed-runner version skew on physical devices
- the `DeviceRotation` value type / `parseDeviceRotation` (names the orientation
values, no collision)
Note: `client.command.rotate` / `device.system.rotate` and the `RotateCommand*`
exported types are removed (the alias only rewrites CLI tokens); SDK consumers
must use `orientation`. The JSON `action` value changes `rotate` -> `orientation`.
* style: wrap long lines to satisfy oxfmt (orientation rename tests)
* fix: add compatibility layer for the rotate->orientation rename
Addresses review blockers on the CLI-only alias: `rotate` previously
resolved only in CLI token parsing, so command-data/RPC paths that carry
the wire command directly failed descriptor validation, and the removed
typed SDK surface broke shipped consumers.
Central command-alias boundary (was CLI-only):
- Promote `cli-command-aliases.ts` to `command-aliases.ts` as the single
alias source, applied at each command-name ingress that bypasses the CLI
parser: the daemon request boundary (`handleRequest`, covering replay and
older remote clients) and the batch step readers (CLI `batch-steps.ts` and
daemon `batch-policy.ts`). No hand-synced command tables.
Retain deprecated typed SDK surface (shipped v0.18/v0.19):
- `RotateCommandOptions` / `RotateCommandResult` type aliases (legacy
`action: 'rotate'` contract) and `SystemRotate*` runtime types.
- `client.command.rotate` and `device.system.rotate` deprecated wrappers
that delegate to `orientation` and restore the legacy response
(`action: 'rotate'` / `kind: 'systemRotated'`).
ADR 0014: rename `rotate` -> `orientation` in the invalidation guidance
(lines 229, 237) so the accepted architecture doc matches the command name.
Tests: daemon-boundary rewrite, CLI+daemon batch alias resolution, and the
deprecated client/runtime wrappers preserving the legacy contract.
Live emulator evidence (emulator-5554):
- `orientation landscape-left` -> user_rotation=1
- `rotate portrait` (CLI alias) -> user_rotation=0
- batch step `{command:'rotate'}` (no CLI parser) -> user_rotation=1
* fix: preserve orientation rename compatibility
* test: stabilize orientation compatibility formatting
* style: format MCP compatibility test
* revert: drop cross-surface rotate compatibility, keep the lean rename
The rotate->orientation change is a bug fix (name collision with the
`gesture rotate` two-finger gesture), not a compatibility feature. The
cross-surface command-data compatibility added disproportionate weight
(~480 B, dominated by the alias module inlined into the batch bundle) for a
command that was only canonical for two minor versions, so shipped batch/
replay/MCP data carrying `rotate` is a rare, documentable break.
Removed:
- daemon request-boundary command normalization (`request-router.ts`)
- batch step alias resolution (`batch-policy.ts`, `cli/batch-steps.ts`)
- MCP tool-runner alias/legacy-result handling (`mcp/command-tools.ts`)
- the `command-aliases.ts` module rename and cross-surface machinery
(reverted to `cli-command-aliases.ts`)
- the cross-surface tests
Kept (cheap, high value — prevents build breaks for typed consumers):
- CLI `rotate` alias (one line, same mechanism as `tap`/`launch`)
- deprecated `RotateCommand*` / `SystemRotate*` type aliases and the
`client.command.rotate` / `device.system.rotate` wrappers that delegate to
`orientation` and restore the legacy response contract
Net bundle vs main is now +473 B (was +952 B), almost all the kept SDK
wrappers plus the unavoidable longer command name.
* feat(replay): ADR 0012 migration steps 5+6 — resume + --update retirement
Step 5 (decision 4, resume): replay --from <n> --plan-digest <sha256>
resumes at a 1-based plan step, skipping 1..n-1 without executing them.
Every divergence report now carries a real resume object (allowed, from,
planDigest, reason?) computed by a preflight that rejects INVALID_ARGS
before any action when: the plan digest no longer matches the current
script (edits/includes/platform-conditioned expansion), --from is out of
range, a skipped step can produce outputEnv values, or the skipped range
or resume target is runtime control flow (retry/runFlow.when — these are
single plan entries, never individually addressable). `test` rejects
--from/--plan-digest both at the CLI-schema layer and at the daemon
dispatch layer (the original command name is only visible before test
rewrites its nested request to `command: 'replay'`).
New modules: src/replay/plan-digest.ts (canonical SHA-256 plan digest)
and src/daemon/handlers/session-replay-resume.ts (preflight + the
report's resume object), kept out of src/replay/ to avoid a
replay<->compat import cycle.
Step 6 (decision 1, retirement): --update/-u no longer rewrites .ad
files. The ADR mandates a no-op, not an error or flag removal: --update
now runs identically to a plain replay and returns the same bounded
suggestions every divergence already carries. Removed: healReplayAction's
retry-and-rewrite arm and its exclusive helpers (collectReplaySelectorCandidates
stays — decision 1's suggestions still use it), the write call from the
runtime loop, and the env/${VAR}-interpolation/compat-flow refusal guards
that existed only to protect that rewrite. writeReplayScript itself keeps
its own round-trip tests but is otherwise unused now; deleted after the
production-exports gate flagged it as dead.
Docs: cli-help.ts workflow topic + --update/--from flag help, AGENTS.md
selector pipeline note, maestro-compat-debt-map.md, website replay-e2e.md
and commands.md updated for the retired rewrite and the new resume loop.
* fix(ci): classify resume flags + provider-scenario resume coverage
The Integration Tests job's architecture-progress gate
(test:integration:progress:check) requires every public CLI flag to be
classified; --from/--plan-digest (replayFrom/replayPlanDigest) were
unclassified. Classify them as device-observable workflow flags and add
real provider-backed coverage to the Android lifecycle scenario: a full
replay diverges on a missing selector, the report's resume object is
asserted (allowed/from/planDigest), and resuming at the next index
replays only the tail. Also refresh the stale replayUpdate reason
("selector-healing replay update" -> the retired no-op).
* fix: bind replay resume digest to execution plan
* test: align replay runtime module topology
* fix: clear replay CI regressions
* docs: clarify replay repair and resume paths
* docs: clarify replay resume step semantics
* docs(replay): note that ${VAR} values stay out of the plan digest (ADR 0012 + workflow help)
Settled decision from the PR #1211 re-review (maintainer-approved): interpolated
${VAR}/--env/AD_VAR_* VALUES are deliberately NOT part of the resume plan digest.
Substitution happens after the digest is computed over the still-unsubstituted
${VAR} text, so re-running the same script with different variable values keeps
the same digest and stays resumable — supplying the right values on resume is the
caller's responsibility. The digest still binds the script/includes, the effective
--platform/--target, and per-action runtime hints + target-v1 identity. Documented
in ADR 0012 decision 4 and the `help workflow` resume topic.
* docs: clarify replay digest interpolation
* feat: add Android test IME helper for deterministic text entry (#1198)
Ships a headless InputMethodService (android-ime-helper) as a third Android
helper APK, replacing the visible system keyboard during automated sessions.
Renders zero accessibility nodes and accepts Unicode/CJK/emoji text over a
base64-encoded broadcast channel, fixing both the settle-diff IME-chrome
flood and the ASCII-only adb-shell text entry limit in one structural fix.
- android-ime-helper/: InputMethodService + build/package scripts on the
existing helper-APK toolchain (javac+d8+aapt2+zipalign+apksigner).
- src/platforms/android/ime-helper.ts, ime-lifecycle.ts: install/version
lifecycle (shared with the other two helpers via the new
helper-package-install.ts), activation on session open, and on-device
restore-hygiene (previous IME persisted to a device settings key so any
daemon/state-dir can recover it; restored on close, daemon teardown, and
daemon startup for orphans left by a crashed run).
- input-actions.ts: fill/type route through the helper's broadcast channel
when active, unicode-safe; unchanged ASCII-shell fallback otherwise.
- doctor: new android-test-ime check flags a stuck helper IME with a
copy-pasteable `adb shell ime set` remediation command.
- Gating: default-on for emulators, opt-in via `open --test-ime` on real
devices.
- Dead-weight: rewrote the manual ADBKeyBoard workaround doc, dropped the
now-provably-live skillgym non-ASCII eval case, updated the ASCII
fallback's error message to point at the helper instead of dead-ending.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(#1201 review): permission-gate the IME receiver, fix CI, add opt-out
Addresses the independent review's blockers and should-fixes.
SECURITY (blocker 1): the text-injection receiver was RECEIVER_EXPORTED with
no gate — any co-installed app could inject text into the focused field while
the test IME was active. Fixed by requiring the WRITE_SECURE_SETTINGS sender
permission on the (in-process, dynamically-registered) receiver: adb shell
holds it, third-party apps cannot. The reviewer's suggested exported=false +
explicit-component approach was tried first but empirically breaks delivery on
API 36 (adb shell cannot reach a non-exported receiver there) — documented in
the helper README. Live-verified: a purpose-built rogue APK's broadcasts
(implicit and package-scoped, no permission) are silently dropped, field
unchanged; adb shell's bare broadcast still injects. Added
ime-helper-security.test.ts asserting the permission gate and that no
permissionless exported registration returns.
CI (blocker 2): (a) added `testIme` to integration-progress-model flag buckets
(Integration Tests was red on the unclassified flag). (b) mocked
resolveAndroidImeHelperArtifact in session-doctor-android / ime-lifecycle /
input-actions-test-ime tests so they no longer depend on android-ime-helper/dist
existing on disk (Coverage was red on a fresh checkout); verified by running
them with dist removed.
Should-fixes: added `--no-test-ime` to opt out on emulators (tri-state gating,
parser-tested); PR body's "byte-identical" claim corrected to size/CRC-match.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* docs(#1201): pin the API-36 exported-receiver constraint in a comment
The RECEIVER_EXPORTED flag cannot express why it must stay exported. Add a
one-line note so a future hardening pass doesn't switch to RECEIVER_NOT_EXPORTED
and silently break the CLI (adb shell can't deliver explicit broadcasts to
non-exported components on API 36+; WRITE_SECURE_SETTINGS is the actual gate).
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(#1201 review): harden IME restore lifecycle (blockers 1 & 2)
Blocker 1 — a failed restore no longer deletes the recovery value. restore now
reads back default_input_method after `ime set` and only clears the persisted
previous-IME record on a confirmed-successful restore; a failed set keeps the
value so a later retry / startup recovery / doctor remediation can still
un-strand the user off the helper IME.
Blocker 2 — startup orphan-recovery no longer overwrites/races user state.
It only restores when the device's CURRENT default IME is still our helper
(so a user who legitimately switched away is left alone), and skips any device
a live session in this process owns (the fire-and-forget startup vs. concurrent
`open` race — activate now marks the device active BEFORE the `ime set`, so any
recovery pass that could observe the helper active also observes the flag and
skips). Never persists the helper itself as the previous IME. activate also
verifies its own switch via read-back.
Exported ANDROID_IME_HELPER_SERVICE_COMPONENT so restore compares the active IME
without reading the packaged artifact from disk. Tests: failed-restore keeps the
value (+ later recovery succeeds), startup no-op when current != helper, startup
skips a live-owned device.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* chore(#1201): delete unused ACTION_ENTER path, baseline test-only export seams
Rebased onto main (#1202 production-unused-exports gate). Two follow-ups:
- Deleted the unused ACTION_ENTER broadcast end-to-end (TS sendAndroidImeHelperEnter
+ its test, Java handler, README): nothing routes through it — `keyboard enter`
uses the keyevent ENTER path — so the new production-exports gate flagged it as
dead production code. Removed rather than grandfathered.
- Added the three legitimate test-only seams (resetAndroidImeHelperInstallCache,
resetAndroidTestImeActivationCacheForTests, setAndroidTestImeActiveForTests) to
fallow-baselines/production-unused-exports.json, matching how the sibling helper
reset functions (resetAndroidMultiTouchHelperInstallCache, ...) are already
grandfathered there.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(#1201): stop daemon-startup adb spawn on non-Android hosts (macOS Smoke)
Root cause of the red macOS Smoke shard (proven, not hand-waved): the
fire-and-forget restoreOrphanedAndroidTestImeOnDaemonStartup ran `adb devices`
at EVERY daemon startup, on every platform. GitHub macOS runners ship the
Android SDK, so this cold-started the adb server mid-replay and destabilized the
macOS System Settings replay timing — the failed job's cleanup shows
"Terminate orphan process: pid (N) (adb)"; main's green runs spawn no adb.
Fix: gate the startup orphan scan behind a host-side marker written in the
daemon state dir when a session activates the test IME (mirrors the
managed-web-browser orphan-cleanup `installed` gate). A host that never uses the
Android test IME — the macOS CI runner included — never writes the marker and so
never spawns adb at startup. The marker is cleared once nothing is left stuck.
Adds SessionStore.resolveStateDir(); tests: startup recovery does not scan adb
when no marker exists (+ marker cleared after a clean scan).
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* chore(#1201): suppress fallow class-member false-positive on state-dir accessor
CI's Fallow audit flags SessionStore.resolveDaemonStateDir as an unused class
member, but it is called via sessionStore.resolveDaemonStateDir() in
session-open.ts — fallow's class-member tracer just doesn't resolve a method
call sited inside a call argument. Renamed for clarity (avoids the collision
with config.ts's free resolveStateDir) and added the localized
fallow-ignore-next-line unused-class-member suppression.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(#1201 review): durable persist before switch + device-scoped recovery markers
Addresses devin-ai-integration's two P1 restore-safety blockers on 19cbce79d.
P1.1 — durably persist the restore target BEFORE the global IME switch.
writePersistedPreviousIme now checks the `settings put` exit code AND reads the
value back, returning a boolean. activate persists first and, if it cannot be
persisted, fails open to the existing input path WITHOUT switching — a rejected
`settings put` can no longer strand the user on the helper with no restore
target. Regression test added.
P1.2 — close the marker crash/offline blind spot. Recovery intent is now
recorded per device, BEFORE the switch (ordering: durable record -> marker ->
ime set), eliminating the post-switch/pre-marker crash window. Markers are
device-scoped and each is retained until that device is actually observed clean:
an offline/disconnected-but-stuck device keeps its marker and is recovered on
reconnect instead of being cleared because the current `adb devices` scan saw no
set-failed. Close-time restore clears only that device's marker (stateDir plumbed
through teardown/close). Tests cover the persist-failure, post-switch/pre-marker
crash, offline-then-reconnect, live-session-owned, and user-switched-away cases.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: honor session metro hints and expo dev-client bundle urls
- metro reload now resolves against the dev server the session's last
metro prepare bound (via a per-session hint file), instead of
silently defaulting to localhost:8081 and reloading an unrelated
project. Explicit --metro-host/--metro-port/--bundle-url still win.
- metro prepare --metro-kind expo (detected or forced) now hints the
virtual-metro-entry bundle URL instead of index.bundle, which 404s
against Expo dev servers in monorepos (live-verified against
react-navigation's example app). Package-manager detection for
--install-deps now walks up from --project-root to the nearest
lockfile so Yarn/pnpm workspace monorepos don't wrongly fall back to
npm and hit EUNSUPPORTEDPROTOCOL on workspace: deps; install
failures now hint at --no-install-deps and the detected PM.
- open now accepts --metro-host/--metro-port/--bundle-url/--launch-url
as session-hint setters (folded into the same runtime object the
daemon already persists for open), so a fresh session doesn't need a
throwaway reload-first call just to seed hints.
Updates help text for metro and open to match.
* fix: make the metro-sessions file the single reload hint store
Review follow-up (PR #1199):
- open's --metro-host/--metro-port/--bundle-url now also record the
session's dev-server binding in the metro-sessions file (the one
local store metro reload resolves against), so a later plain reload
actually reuses what open set. The daemon runtime-hints write stays
for device-native dev-server prefs.
- The binding now carries bundleUrl (prepare persists the local-flow
bundle URL; bridge runtimes are excluded).
- clearMetroSessionHints is wired: session close drops the binding
(teardown intent — even when the daemon call fails), and a hintless
open that creates the session clears a leftover same-name binding.
The open result carries sessionReused so the client can tell fresh
from reused sessions. Regression test pins prepare -> close ->
flagless reload resolving to the default, never the stale port.
- Package-manager detection recognizes bun.lock (text lockfile,
default since Bun 1.2) and bounds the lockfile walk-up at the
nearest .git entry.
- Comment audit per maintainer directive: multi-line acceptability
arguments trimmed to one-line constraint statements; the store's
lifecycle is documented once on MetroSessionHints.
* fix: preserve bundle-url mount prefix and broadcast expo reloads over /message
Maintainer review follow-ups (PR #1199):
- Reload/message endpoint URLs keep the bound bundle URL's mount prefix
(e.g. /tenant-42/index.bundle -> /tenant-42/reload) instead of
collapsing to the host root. The Expo virtual entry
(.expo/.virtual-metro-entry.bundle) is an entry-module path, not a
server mount, so it maps to the server-root endpoints (verified
live: Expo serves /message at the root).
- When the dev server has no HTTP /reload route and answers with the
app page (Expo), metro reload now broadcasts
{"version":2,"method":"reload"} over the server's /message websocket
(the channel dev-server CLIs use for the r key) instead of reporting
the app-page 200 as a successful reload. The result carries a
transport field (http | message-socket). Live-verified: a flagless
metro reload against the running Expo server made the app re-fetch
its JS bundle.
- Endpoint resolution moved to src/metro/metro-reload-endpoints.ts so
the seam tests use is production-imported (keeps the new
production-unused-exports ratchet clean).
- Docs reconciled: help metro and website commands.md describe the
single session binding store, open's hint flags, prefix
preservation, and the message-socket fallback.
* fix: preserve Expo reload mount prefixes
* 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>
* feat: add integrated device leasing
* fix: keep metro bearer token out of generated proxy profile
The proxy connect profile is written to disk as a non-secret remote config,
but it unconditionally copied `metroBearerToken` into that file, leaking the
secret at rest. Mirror the cloud path, which keeps `daemonAuthToken` in-memory
only: the token still flows through this connect via the returned flags, and
later commands re-supply it via AGENT_DEVICE_METRO_BEARER_TOKEN. Extend the
non-secret-profile test to assert the bearer token is absent from disk.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPa5Z9GBkeqoxVctC85N7e
* fix: always release device lease on session close
releaseSessionLease + sessionStore.delete ran only on the happy path, after
several awaits (app-log/perf/snapshot teardown, platform close dispatch,
runner stop) that can throw. A failed close therefore stranded the device
lease until the inactivity expiry. Wrap teardown in try/finally so ownership
is always freed; the original error still propagates after finally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPa5Z9GBkeqoxVctC85N7e
* fix: reconcile integrated device leasing
* docs: simplify remote lease guidance
* refactor: satisfy leasing fallow checks
* fix: harden integrated device leasing
* refactor: deepen device lease lifecycle
* refactor: centralize lease scope projection
* fix: harden proxy lease e2e flow
* fix: address lease review feedback
* refactor: tighten lease release cleanup
* fix: simplify proxy startup output
* fix: harden cloud lease identity
* fix: color proxy startup output
* fix: simplify proxy tunnel placeholder
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: expose web network dump through agent-browser
* fix: reduce web network mapper complexity
* fix: gate web network headers by include mode
* test: assert compact web network summary
* refactor: simplify web network dump mapping
* refactor: trim web network coverage
* feat(recording): make iOS export quality configurable
Wire the existing recording-export-quality enum through the record command
down to the Swift export preset. Adds a `--export-quality <medium|high>`
option for iOS recordings that controls the AVAssetExportSession preset used
when a recording is re-encoded.
`medium` stays the default and selects AVAssetExportPresetMediumQuality, which
preserves the fast simulator-friendly export. `high` opts into
AVAssetExportPresetHighestQuality for evidence-grade output. This is separate
from the existing integer `--quality <5-10>` capture flag that scales render
resolution.
Closes#568
* fix(recording): apply export quality to touch-overlay export path
The --export-quality flag was only wired into the resize export path. The
touch-overlay re-encode (finalizeRecordingOverlay -> overlayRecordingTouches ->
recording-overlay.swift) ignored it and always picked AVAssetExportPresetMediumQuality,
so record stop with --export-quality high had no effect when the stop path
re-encodes only to burn in touch overlays.
Thread the recording's exportQuality through finalizeRecordingOverlay and
overlayRecordingTouches, pass it as --export-quality to recording-overlay.swift,
and resolve the preset there via the same exportPresetName() helper used by
recording-resize.swift. Medium stays the default when the arg is absent, so
behavior is unchanged for callers that do not set it.
* feat: align recording quality and size flags
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>