Repository navigation
feat: dispose session-owned platform drivers on teardown - #70
Merged
cryptotavares merged 6 commits intoOct 29, 2026
Merged
Conversation
Failed disposals were kept in a module-level WeakMap keyed by the session manager object a caller passed. When a tool receives a per-request wrapper of the manager (a Proxy on the core stack), a failed disposal is stored under that wrapper and is lost after the request. The consumer already cleared its driver, so the companion leaks, and stop() and startup rollback never see it. - Add DriverLifecycle, which owns the pending set. createServer creates one and uses it for stop(), startup rollback and the tools, through a new optional ToolContext.cleanupSession(). - Keep cleanupSession(manager, lifecycle?) with a per-manager fallback for contexts built outside the daemon. - Add createServer tests: a failing /tool/cleanup is retried by the next /tool/cleanup and by stop().
itsyoboieltr
previously approved these changes
Oct 29, 2026
cryptotavares
added a commit
to MetaMask/device-mcp
that referenced
this pull request
Oct 29, 2026
> **Summary:** Give each iOS backend its own IDB companion, dispose only its owned resources, and recover when that companion dies or loses the simulator's touch input during a session. Ending one session leaves other companions available, including sessions on the same simulator. Companion integration: MetaMask/client-mcp-core#70. ## Problem The IDB backend connects through a shared UDID mapping. It has no disposal hook or record of companion ownership. Closing a testing session can leave its companion running. Disconnecting a shared mapping does not establish which process the session can stop. Parallel agents need a clear boundary between their resources. A simulator UDID identifies a device, but does not identify a session. Two sessions can target the same simulator. Teardown therefore needs a session-owned connection, even when the device ID matches. A long-lived companion also breaks silently. In a real iOS release-validation run, the simulator restarted during a session. `idb connect` reused the old companion from the shared mapping. Reads (`describe-all`, screenshots) still worked, but every tap failed with `The dtuhidd connection … has been invalidated; events sent on it would be discarded`. The only fix was to find and kill the companion by hand. ```mermaid sequenceDiagram participant A as Session A participant I as Shared IDB mapping participant B as Session B A->>I: Connect simulator UDID B->>I: Connect simulator UDID Note over A,B: The mapping does not establish session ownership A->>A: End session Note over A,I: No backend disposal contract releases its companion Note over I: Simulator restarts: the mapped companion keeps serving reads, but every tap is discarded ``` ## Solution Add optional backend lifecycle hooks and give each IDB backend a private companion. 1. **Own the connection.** Start one companion child with a private socket. Route IDB commands and recordings through that connection. 2. **Drain initialization.** Share an in-flight connection attempt. Disposal waits for that attempt and prevents later device commands. 3. **Release owned resources.** Finish the active recording, stop the companion child, and remove its private directory. Retain failed disposal state for retry. 4. **Connect lifecycle entry points.** Dispose on device selection, stdio close, stdin EOF, SIGINT, and SIGTERM. 5. **Recover during a session.** Watch the companion for its whole life. When it is gone, or when a command fails because the companion or the simulator HID connection is lost, start a fresh private companion and send the command once more, only if the first attempt was provably not delivered. 6. **Limit orphans.** Start companions with `--terminate-offline true` and a one-hour idle shutdown. On the first connection in a process, remove private directories that a dead process left behind. ```mermaid flowchart TD A[First device call] --> B[Start private companion] B --> C[Route commands through private connection] C --> X{Command failed?} X -->|companion gone, HID invalidated, companion unreachable| R[Replace private companion] R --> S{First attempt delivered?} S -->|no| C2[Send once more] S -->|maybe| T[IdbUnavailableError with code and remediation] C2 -->|same failure| T C --> D[Selection or server shutdown] D --> E[Wait for initialization] E --> F[Stop owned recording] F --> G[Stop owned companion child] G --> H[Remove owned socket directory] D --> I[Reject later calls on disposed backend] classDef new fill:#fff3cd,stroke:#d39e00 classDef stop fill:#f8d7da,stroke:#c82333 class B,C,E,F,G,H,R,C2 new class I,T stop ``` Yellow boxes are new. Red boxes stop the call. ## What changes | Call or condition | Before | After | |---|---|---| | Create an iOS backend | Connect through a shared UDID mapping | Start a private companion for that backend | | Dispose session A | No backend hook | Stop A's recording and companion; B remains available | | Two sessions target one simulator | Connection ownership follows the shared device mapping | Each backend owns a separate companion | | Dispose during initialization | No lifecycle coordination | Wait for initialization, release resources, and reject the waiting device call | | Dispose an unused lazy backend | No lifecycle hook | Complete without connecting to a device | | Select another device | Reset the lazy backend synchronously | Await previous backend disposal before switching | | Stop signal or stdin EOF | No explicit backend disposal | Dispose the backend and close the server | | Disposal fails | No retry contract | Report the failure and retain state for another disposal attempt | | Companion exits during a session | Every later call fails on a dead connection | The next call starts a fresh private companion | | Simulator HID connection invalidated (`dtuhidd … invalidated`) | Every tap fails with raw idb stderr | Replace the companion and send once more; then `IDB_HID_UNAVAILABLE` | | Companion unreachable (`Failed to connect to companion`) | Raw idb stderr | Replace the companion and send once more; then `IDB_COMPANION_UNAVAILABLE` | | Delivery uncertain (`StatusCode.UNAVAILABLE`, `retry: unsafe`) | Raw idb stderr | Resend read commands once; never resend input commands; the error says the action may have run | | Simulator shuts down | Companion stays alive and broken | Companion exits (`--terminate-offline true`); the next call reconnects | | Host killed with SIGKILL | Companion and `/tmp/device-mcp-idb-*` leak | Companion exits with its pipes or after 1 h idle; the next process removes the directory | | Explicit simulator ID | `idb describe` can register a shared companion | Resolved through `simctl` first; `idb describe` only for physical devices | ## Decisions for reviewers | Decision | Reason | |---|---| | Use one private companion per backend | Ownership stays local. Sessions on one simulator do not need shared-companion reference counting. | | Keep `DeviceBackend.dispose()` and `reconnect()` optional | Existing Android, Appium, and custom backends can keep their current lifecycle behavior. Core can call `reconnect()` when present. | | Make `selectDevice()` asynchronous | The previous backend must release its resources before the lazy backend switches devices. Callers must await selection. This is breaking for programmatic callers. | | Signal only stored child-process handles | Teardown uses neither global process discovery nor shared IDB disconnect commands. | | Keep simulator and app lifecycle separate | Backend disposal releases connection resources. Consumer cleanup remains responsible for closing its app. | | Retry once, after a fresh companion | The companion discards HID events on an invalid connection, and an unreachable companion received nothing. The resend cannot repeat a tap. A second failure is final. | | Never resend input when delivery is uncertain | On `StatusCode.UNAVAILABLE` the request may have arrived before the channel failed. A repeated tap on a wallet confirmation is worse than an error. | | Do not pass `--retry-verdict` | Older fb-idb versions reject it, and it only covers UI automation failures. A `retry: unsafe` verdict is still honored when present. | | `--idle-shutdown-time 3600` | It bounds the lifetime of an orphan. The automatic reconnect makes an idle exit invisible to callers. | | Sweep only directories with a dead owner and a dead socket | Live sessions and shared companions are never touched. A leftover companion is signaled only when its command line names that directory's socket, so a reused PID is never signaled. | | Export `IdbUnavailableError` with `code` | Core can map typed codes to its own error codes instead of matching stderr text. | ## Review guide Read the files in this order: | # | File | What to check | |---|---|---| | 1 | `src/backends/idb-connection.ts` | Private connection ownership, readiness validation, permanent exit listener, lifetime flags, `owner.json`, directory cleanup | | 2 | `src/utils/stop-child-process.ts` | Owned-child exit handling and bounded signal escalation | | 3 | `src/backends/idb-errors.ts` | Failure patterns, `notDelivered`, remediation text | | 4 | `src/backends/idb-backend.ts` | Connection guards, `reconnect()`, the `#idb` wrapper (one reconnect, generation check for concurrent callers, no input resend on uncertain delivery), recording shutdown, disposal retry | | 5 | `src/backends/idb-companion-sweep.ts` | Stale criteria, PID-reuse safety, once per process | | 6 | `src/backends/index.ts`, `src/cli/device-mcp.ts`, `src/tools/select-device.ts`, `src/utils/platform.ts` | Lazy initialization and `reconnect` passthrough, awaited selection, EOF and signal integration, `simctl` lookup | | 7 | Changed and new tests | Cross-session isolation, partial startup failures, retry, shutdown ordering, recovery, sweep | | 8 | README, agent reference, changelog | Lifecycle contract, recovery and error codes, asynchronous selection migration | <details> <summary>Testing</summary> - `yarn build` passes. - `yarn lint` passes, including ESLint, formatting, constraints, dependency checks, and changelog validation. - `yarn test` passes: 31 files, 652 passed tests, and 6 SDK-gated skips. Typechecking and configured coverage thresholds pass. `idb-backend.ts` statement coverage rises from 69 % to 75 %. - Recovery tests: companion exit after readiness → respawn; HID invalidation → one reconnect and one retry → typed error on the second failure; uncertain delivery → no input resend; concurrent failures share one reconnect; spawn flags; sweep with real sockets and processes. - Live macOS checks exercise the built device package with the built core lifecycle branch (client-mcp-core#70), on the Phase 1 core stack, with the Mobile app on an iPhone 17 Pro simulator. | Live check | Result | |---|---| | Core cleanup of A on different simulators | B still returns its expected device ID | | Core cleanup of A on the same simulator | B still returns its expected device ID | | Stdio EOF, SIGINT, and SIGTERM | Each server exits with code 0 and removes its owned companion | | `mm launch` → `mm cleanup` | The session companion and its directory are removed in under 1 s; shared companions are unchanged | | Kill the private companion during a session | The next `list-testids` and `click` succeed on a fresh companion | | `kill -9` the host daemon | The companion exits by itself; the next launch removes the leftover directory | | Shut down and boot the simulator during a session | The companion exits with the simulator; the next tap reconnects and succeeds | | `mm launch --force` twice | One private companion at a time | The live checks read the screen and tap non-wallet elements. They do not submit transactions or exercise wallet flows. A live companion whose HID connection is invalidated (the original incident) did not reproduce on demand; unit tests cover that path. Recording exit ordering uses a mocked child-process test. </details> <details> <summary>Docs and adoption</summary> - `README.md` documents disposal, private connection ownership, awaited device selection, and a new "Recovery and lifetime" section (reconnect, error codes, companion flags, sweep). - `SKILL.md` documents explicit device IDs, server shutdown behavior, and what to do on `IDB_HID_UNAVAILABLE` / `IDB_COMPANION_UNAVAILABLE`. - `CHANGELOG.md` records the lifecycle hooks, recovery, companion lifetime, and the asynchronous selection change. - `selectDevice()` is now asynchronous, so this needs a minor release. Publish it before consumers adopt the lifecycle. The linked core change forwards disposal without requiring this release at build time. - Separate companions isolate connection teardown. App state remains shared when sessions target the same simulator. - An uncatchable process kill cannot run disposal. The companion lifetime flags and the startup sweep limit what it leaks. Metro shutdown remains outside this hook. </details>
cryptotavares
added a commit
that referenced
this pull request
Oct 29, 2026
…TimeoutMs cap (#71) > **Summary:** Fix the review comments on #62, #64 and #65 in one PR on top of the stack. The approved PRs stay unchanged. **Stack context:** mm CLI v2, Phase 1, **P1-43**. Base: [#69](#69), the top of the stack. ## Problem | Comment | Problem | |---|---| | [#65: off-screen animations](#65 (comment)) | The browser settle ignored animations whose target was outside the viewport or had zero size. A drawer sliding in, a bottom sheet or an accordion opening from height 0 start that way, so the observation caught them mid-move. | | [#64: late click](#64 (comment)) | `click` / `type` checked the request signal only before the target wait. When the request timed out during the wait, the agent got "timed out", but the click still ran once the target appeared. | | [#64: batchTimeoutMs](#64 (comment)) | `batchTimeoutMs` accepted up to 300 s, but the queue and the CLI stop `run_steps` after 30 s. | | [#62: refs after `none`](#62 (comment)) | `--observe none` does not refresh refs; some refs can then click a different element. Not documented. | | [#62: reads and animations](#62 (comment)) | Reads do not wait for animations, even with `--observe full`. Not documented. | ## Solution ```mermaid flowchart TD A[click / type] --> B[Wait for target] B --> C{Request timed out?} C -->|yes| D[MM_REQUEST_ABORTED, no action] C -->|no| E[Click / tap / type] E --> F[Mutating observation] F --> G[Wait for every running finite animation, max 1 s] G --> H[Read tree] classDef new fill:#fff3cd,stroke:#d39e00 class C,D,G new ``` Yellow boxes are new or changed. ## What changes | Commit | Change | |---|---| | `fix: wait for animations that start off screen or at zero size` | `animationsSettled` waits for every running, finite animation of an element. Infinite animations are still ignored; the 1 s cap is unchanged. | | `fix: do not click or type after the request timed out` | `IPlatformDriver.click` / `type` take an optional `signal`. Both drivers call `signal.throwIfAborted()` after the target wait, right before the action (mobile `type` also after the focus tap). The tools pass `context.signal` and return `MM_REQUEST_ABORTED`. | | `fix!: cap run_steps batchTimeoutMs at 30 s` | Schema range 1000–30000, like the `cdp` `timeoutMs`. | | `docs: …` | README, SKILL.md and CLI help: refs after `--observe none`, reads and animations, abort limits, `batchTimeoutMs`. | ## Decisions for reviewers | Decision | Reason | |---|---| | One new PR instead of changing #62/#64/#65 | Pushing to an approved PR dismisses its approval, and the restack would change every PR above it. | | An action that already started is not cancelled | `@metamask/device-mcp` takes no signal (`waitForElement`, `tapElement`, `typeText`), and Playwright's actionability checks are part of `locator.click`. The driver checks the signal between steps. Cancelling inside device-mcp needs a device-mcp change (follow-up). | | Cap `batchTimeoutMs` at 30 s instead of raising the request budget for batches | Matches the CLI and queue default; long flows can be split into several batches. | | Reads stay without the animation wait (docs only) | It is a less exact snapshot, not a wrong action. Can be revisited. | | The 5 s testId collection cap stays | Agreed with the reviewer: watch real usage first. | ## Breaking changes `run_steps` rejects `batchTimeoutMs` above 30000 (validation error), also when a server raised `requestTimeoutMs`. ## Review guide | # | File | What to check | |---|---|---| | 1 | `src/tools/utils/in-page.ts` | `animationsSettled` without the viewport/size filter. | | 2 | `src/platform/playwright-driver.ts`, `src/platform/mobile-platform-driver.ts`, `src/platform/types.ts` | `throwIfAborted()` placement. | | 3 | `src/tools/interaction.ts` | Signal passed; abort mapped to `MM_REQUEST_ABORTED`. | | 4 | `src/validation/schemas.ts` | `batchTimeoutMs` range and description. | | 5 | `README.md`, `SKILL.md`, `src/cli/mm.ts` | Docs. | <details> <summary>Testing</summary> `yarn test`: 51 files, 1809 tests pass. `yarn lint` passes. Each commit passes the tests on its own. **Extension** (e2e fixture wallet, headless), before = #69, after = this branch: | Check | Before | After | |---|---|---| | Drawer sliding in from off screen (400 ms): observationMs | 515–516 (did not wait) | 914–929 (waited) | | Accordion from height 0 (400 ms): observationMs | 515–516 | 916–917 | | Home click observationMs | 513–514 | 512–515 | | Send / account list click observationMs | 12–183 / 14–164 | 14–212 / 15–164 | | `run_steps` click whose target appears 1 s after the 30 s queue timeout | **clicked** | **not clicked** | **Android** (device-mcp 0.6.0, real funds, benign taps): all smoke and ref checks pass, same as #69. **iOS** (device-mcp 0.6.0, real funds, benign taps): all smoke and ref checks pass, same as #69. (A first attempt hit the stale idb companion after a simulator reboot, `dtuhidd connection ... invalidated`; fixed by restarting the companion. See MetaMask/device-mcp#43 and #70.) </details>
…-scoped-device-teardown # Conflicts: # src/platform/mobile-platform-driver.ts # src/platform/types.ts # src/server/create-server.ts # src/tools/test-utils/mock-factories.ts # src/types/http.ts
itsyoboieltr
approved these changes
Oct 29, 2026
cryptotavares
deleted the
cryptotavares/session-scoped-device-teardown
branch
October 29, 2026 14:42
cryptotavares
added a commit
that referenced
this pull request
Oct 29, 2026
> **Summary:** Upgrade `@metamask/device-mcp` to `^0.7.0`. 0.7.0 declares `DeviceBackend.dispose()`, so the mobile driver calls it without a type cast. iOS sessions get private IDB companion teardown and recovery. ## Problem Core pins `@metamask/device-mcp` `^0.6.0`. On 0.x a caret range does not pick up 0.7.0. - The `DeviceBackend` type in 0.6.0 has no `dispose()`. #70 therefore calls the hook through a structural cast in `MobilePlatformDriver.dispose()`. - The README says core "does not update the core dependency range" and that older device-mcp versions stay compatible. That text was correct before 0.7.0 was released. ## Solution Adopt the device-mcp 0.7.0 release. Remove the cast and the temporary README text. 0.7.0 contains: - [device-mcp#43](MetaMask/device-mcp#43): optional `DeviceBackend.dispose()` and `reconnect()`, one private IDB companion per iOS backend, and recovery when the companion or the simulator HID connection is lost. - [device-mcp#44](MetaMask/device-mcp#44): correct recovery error reporting, and no resend of commands that can change the device. - [device-mcp#42](MetaMask/device-mcp#42): Android clipboard helper. ```mermaid flowchart TD A[mm cleanup / launch --force / stop] --> B[DriverLifecycle] B --> C[MobilePlatformDriver.dispose] C --> D{backend.dispose?} D -->|IdbBackend 0.7.0| E[Stop the private companion and recording] D -->|AdbBackend: no hook| F[Nothing to release] classDef new fill:#fff3cd,stroke:#d39e00 class C,E new ``` Yellow boxes change: the driver calls the typed hook, and the 0.7.0 iOS backend releases its companion. ## What changes | Boundary | Before | After | |---|---|---| | `package.json` dependency range | `@metamask/device-mcp ^0.6.0` | `^0.7.0` | | `yarn.lock` resolution | npm 0.6.0 | npm 0.7.0 (dependencies unchanged) | | `MobilePlatformDriver.dispose()` | Casts the backend to `DeviceBackend & { dispose?() }` | Calls `this.#backend.dispose?.()` on the typed `DeviceBackend` | | `README.md` teardown paragraph | Older device-mcp versions are compatible; the range is not updated | Core requires 0.7.0 or later; iOS releases its companion; Android has no hook | | Disposal test name | "supports device-mcp versions without disposal" | "completes for backends without a disposal hook" | ## Decisions for reviewers | Decision | Reason | |---|---| | Keep the optional call `dispose?.()` | `dispose()` is still optional in the 0.7.0 `DeviceBackend` type. `IdbBackend` implements it; `AdbBackend` and `AppiumBackend` do not. | | Keep the test for a backend without the hook | It now covers the ADB backend, not an older device-mcp version. | | Async IIFE instead of `Promise.resolve().then(...)` | Same behaviour, simpler, and the same style as device-mcp. | | `chore:`, not breaking | Core's own API does not change. Every symbol core imports from device-mcp still exists. The `selectDevice()` break in 0.7.0 is on `LazyDeviceBackend`, which core does not use. | | No changelog entry | Matches the previous bump (#69). | ## Review guide | # | File | What to check | |---|---|---| | 1 | `package.json`, `yarn.lock` | The range is `^0.7.0`. Only the device-mcp entry and the workspace range change. | | 2 | `src/platform/mobile-platform-driver.ts` | The cast is gone. Shared concurrent disposal and retry after failure are unchanged. | | 3 | `README.md` | The teardown paragraph under "Session-scoped device teardown". | | 4 | `src/platform/mobile-platform-driver.dispose.test.ts` | Only the test name changes. | <details> <summary>Testing</summary> Device-mcp 0.7.0 is installed from npm, without yalc. - `yarn build` passes. - `yarn lint` passes, including ESLint, formatting, constraints, dependency and dedupe checks, and changelog validation. - `yarn test` passes: 53 files and 1,835 tests. Typechecking and coverage thresholds pass. Thresholds are unchanged. - `tsc --noEmit -p tsconfig.test.json` reports 8 errors on `main` and 8 on this branch. All 8 already exist on `main`. - In the installed package, `dist/backends/types.d.mts` declares `dispose?(): Promise<void>` and `reconnect?(): Promise<void>` on `DeviceBackend`. `IdbBackend` declares `dispose()`; `AdbBackend` and `AppiumBackend` do not. No device run for this PR. The live teardown and recovery checks are in device-mcp#43 and #70, and they used the same code. </details>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Companion implementation: MetaMask/device-mcp#43.
Problem
Core calls the consumer's
cleanup()method to close a testing session. That contract does not release device-backend resources. Mobile cleanup can close the app while leaving its IDB companion running.The consumer can also clear its driver during cleanup. Looking up the driver afterward loses the resource owner. Cleanup errors create another gap: releasing the backend must still run when closing the app fails.
sequenceDiagram participant C as Core participant S as Consumer session manager participant D as Mobile driver C->>S: cleanup S->>S: Close app and clear driver S-->>C: Cleanup result Note over C,D: No captured driver or disposal hook releases backend resourcesSolution
Introduce one server-owned cleanup lifecycle and an optional platform-driver disposal contract.
getPlatformDriver()before consumer cleanup can clear the driver.createServerowns oneDriverLifecycle. Tools reach it throughToolContext.cleanupSession(). A later cleanup or daemon stop retries the retained drivers.flowchart TD T[cleanup / launch --force / failed launch] -->|ToolContext.cleanupSession| L[DriverLifecycle, one per server] S[Daemon stop] --> L R[Startup rollback] --> L L --> B[Capture session-owned driver] B --> C[Run consumer cleanup] C -->|success or failure| D[Dispose captured driver] C -->|in parallel| P[Retry older retained drivers, best-effort] P -->|failure| W[Keep retained, write WARN to daemon log] D --> E{Cleanup or current disposal failed?} E -->|no| F[Return consumer cleanup result] E -->|yes| G[Retain the failed current driver] G --> H[Report current session errors to caller] G -.->|next cleanup or stop| P W -.->|next cleanup or stop| P D --> I[Mobile driver delegates to its backend] classDef new fill:#fff3cd,stroke:#d39e00 classDef stop fill:#f8d7da,stroke:#c82333 class L,B,D,P,W,G,I new class H stopYellow boxes are new. Red boxes stop successful cleanup completion.
What changes
mm cleanup[WARN]line in the daemon log; do not fail the current cleanup or launchcleanupSession(manager)falls back to per-manager retentionDecisions for reviewers
IPlatformDriver.dispose()optionalDriverLifecycle), not a map keyed by the session managerToolContext.cleanupSession()launch --forcepermanently. Each retry can take about 10 s, so serial retries also push cleanup toward the 30 s request timeout. Parallel retries and daemon-log warnings keep the retry without blocking the user.Review guide
Read the files in this order:
src/server/cleanup-session.tsDriverLifecycle: capture order, error aggregation, retained disposal ownership, fallback for contexts without a lifecyclesrc/platform/mobile-platform-driver.ts,src/platform/types.tssrc/server/create-server.ts,src/types/http.tsToolContext.cleanupSession, shutdown and startup rollbacksrc/tools/cleanup.ts,src/tools/launch.tssrc/server/session-manager.tsvitest.config.mtsTesting
yarn buildpasses.yarn lintpasses, including ESLint, formatting, constraints, dependency checks, and changelog validation.yarn testpasses after mergingmain(Phase 1 stack, chore: bump @metamask/device-mcp to ^0.5.0 #58–fix!: review follow-ups: off-screen animations, no late clicks, batchTimeoutMs cap #71): 53 files and 1,835 tests. Typechecking and configured coverage thresholds pass (97.53 / 94.91 / 96.73 / 97.68).attw --pack) passes for CommonJS, ESM, and bundler resolution./tool/cleanupis retried by the next/tool/cleanup, and byserver.stop(). Both tests fail when the retry state is keyed by a per-request session-manager wrapper./tool/cleanuporlaunch --force. It is retried on every cleanup, in parallel with other retained drivers, andserver.stop()logs it.mainafter the Phase 1 stack landed. Conflicts were "keep both sides": inmobile-platform-driver.ts, keep#excludeDevOverlay,#windowSizeand#disposing; inplatform/types.ts, keepdispose?()and theclicksignal docs; increate-server.tsandtypes/http.ts, keepsignaland the removal ofknowledgeStore, and adddriverLifecycleandcleanupSession.IDB backend has been disposedThe live checks read the screen and tap non-wallet elements. They do not submit transactions or exercise wallet flows.
Docs, adoption, and boundaries
README.mddocuments session-owned drivers, fresh instances per launch, the server-owned retry, and consumer-managed partial-launch failures.SKILL.mdclarifies that--shutdownstops the worktree daemon. Driver disposal does not stop Metro or shut down simulators.CHANGELOG.mdrecords the lifecycle hook, cleanup entry points, and server-owned retries.cleanup()directly must also manage driver disposal. Drivers hidden during partial launch remain the consumer's responsibility.