diff --git a/docs/adr/0001-provider-first-integration-scenarios.md b/docs/adr/0001-provider-first-integration-scenarios.md index ad71b6bc9b..8ab82f1e5c 100644 --- a/docs/adr/0001-provider-first-integration-scenarios.md +++ b/docs/adr/0001-provider-first-integration-scenarios.md @@ -192,13 +192,20 @@ The script is the source of truth for: These flags stay in their owning unit/CLI suites: -- Config. -- Remote transport. -- Metro preparation. -- Parser/client-only. -- Report-writing. -- Boot-fallback. - +- Config, output, diagnostics, and transport. +- Remote connection and session-lock policy. +- Cloud artifact provider lookup. +- Metro and React Native runtime preparation. +- Apple launch and perf artifact options. +- Parser/client-only command flags. +- Daemon lifecycle control. +- Platform boot fallback without a provider seam. +- Open foreground auto-resolution (RFC prototype). +- Local device contention waiting. +- Apple simulator screenshot, private-AX, and observe-only capture options. +- Daemon screenshot selector crop. + +`summarizeProviderScenarioFlagExclusions` in `scripts/integration-progress-model.ts` is the full list. The report lists these flags as explicit exclusions, so they are not silently missing from the denominator. Provider pressure separates semantic Apple `simctl`/`devicectl`, macOS helper, and macOS host usage from generic Apple host-tool usage. It also separates semantic Linux desktop/accessibility/clipboard/screenshot/input usage from generic Linux tool usage. diff --git a/docs/adr/0002-persistent-platform-helper-sessions.md b/docs/adr/0002-persistent-platform-helper-sessions.md index 276fa8f198..2db4cba683 100644 --- a/docs/adr/0002-persistent-platform-helper-sessions.md +++ b/docs/adr/0002-persistent-platform-helper-sessions.md @@ -14,7 +14,7 @@ Accepted - When startup costs are high and an automation context is reusable, use persistent platform helper sessions. - The daemon owns helper lifetime policy. Platform modules own helper-manager mechanics. - Unless a separate cache contract has explicit invalidation, a helper must execute each command against fresh platform state. -- On Android, snapshot capture, gesture viewport resolution, and planned-touch injection share one bundled automation helper. +- On Android, snapshot capture, gesture viewport resolution, and instrumentation-backed planned-touch injection share one bundled automation helper. - On Android, after a failed session command, retry one-shot only for idempotent reads, and only after the failed session has stopped. - On Android, return a session gesture failure directly. Do not retry it one-shot. - For Android snapshots, if the packaged helper is unavailable or invalid, agent-device must fail closed. It must not substitute the legacy `adb uiautomator dump` snapshot engine. @@ -87,7 +87,7 @@ If that helper is unavailable or invalid, agent-device must fail closed. It must not substitute the legacy `adb uiautomator dump` snapshot engine. Android permits only one reliable instrumentation-owned `UiAutomation` context per device. -Thus, snapshot capture, gesture viewport resolution, and planned-touch injection share one bundled automation helper (amended 2026-07, issue #1275, consistent with ADR 0013). +Thus, snapshot capture, gesture viewport resolution, and instrumentation-backed planned-touch injection share one bundled automation helper (amended 2026-07, issue #1275, consistent with ADR 0013). A live persistent helper session executes touch commands directly over its session socket. Without a live persistent session, the same helper runs one-shot. Before that amendment, touch synthesis shipped as a separate instrumentation helper. diff --git a/docs/adr/0003-daemon-command-registry.md b/docs/adr/0003-daemon-command-registry.md index e909deb7e8..cf8f363264 100644 --- a/docs/adr/0003-daemon-command-registry.md +++ b/docs/adr/0003-daemon-command-registry.md @@ -2,16 +2,19 @@ ## Status -Accepted +Accepted. Amended 2026-10-16: invariants 3 and 4 now describe the shipped derivation (#906, #907). ## Rules at a glance - Keep public command identity in `packages/command-registry/src/catalog.ts` and public input/output contracts in `src/commands/**`. -- Keep daemon route ownership and request-policy traits in the daemon command registry. +- Declare daemon route ownership and request-policy traits in the separately typed `daemon` facet of the descriptor registry. +- Derive the daemon registry from that facet. Expose request-policy predicates only through the daemon registry. - Request modules consume named predicates from the registry. They do not recreate command string sets. - Handler modules own execution logic only. They do not export duplicate coverage tables to prove route membership. - The daemon registry must not define CLI grammar, Node.js client options, MCP schemas, user-facing help, or platform capability support. -- A derived daemon registry is permitted only if it preserves all four invariants in the 2026-06 update. +- Preserve all four invariants in the 2026-06 update. +- Do not derive a public contract from daemon traits. A diagnostic display, such as `command explain`, may read them. +- A command without a `daemon` facet uses the `generic` route with default request policy. ## Context @@ -35,7 +38,9 @@ Duplicate command sets required alignment by convention. Keep public command identity in `packages/command-registry/src/catalog.ts`. Keep public input/output contracts in `src/commands/**`. -Add `packages/daemon/src/core/daemon-command-registry.ts` as the daemon-side source of truth for command route ownership and request-policy traits. +Declare command route ownership and request-policy traits in the `daemon` facet in `packages/command-registry/src/registry.ts`. +`DaemonCommandDescriptor` in `packages/command-registry/src/daemon-command-descriptor.ts` types that separate facet. +`packages/daemon/src/core/daemon-command-registry.ts` derives its table through `deriveDaemonCommandDescriptors(commandDescriptors)`. Request modules consume predicate functions from the registry. They do not recreate command string sets. Handler modules own execution logic only. @@ -64,7 +69,8 @@ The command contract, projection, help, and capability modules retain these resp ## Consequences -When a daemon-handled command is added or moved, its route and request-policy traits require updates in the daemon command registry. +When a daemon-handled command is added or moved, update its `daemon` facet in `packages/command-registry/src/registry.ts`. +The daemon registry derives its table from those declarations. The registry tests pin the trait decisions. Provider-backed integration scenarios verify important request-policy behavior through the real daemon request path. @@ -79,7 +85,7 @@ Thus, future changes do not require readers to infer the rationale from agent in ## Update (2026-06): single-declaration / derivation model -A later proposal, the `CommandDescriptor` direction in [ADR 0008](0008-command-descriptor-registry.md), unifies a command's declarations at one registration site. +The implemented `CommandDescriptor` direction in [ADR 0008](0008-command-descriptor-registry.md) unifies a command's declarations at one registration site. It derives these surfaces from that site: - Public catalog. @@ -89,7 +95,7 @@ It derives these surfaces from that site: - Daemon registry. Its purpose is to remove the drift between these tables. -Convention keeps several of these surfaces aligned against that drift. +Registry tests pin the derived tables. **This ADR's decision remains accepted.** Its boundary concerns ownership and the predicate interface, not the physical file that contains a trait. @@ -97,7 +103,8 @@ Its boundary concerns ownership and the predicate interface, not the physical fi That property remains true for a projected or derived backing table. A derived daemon registry is therefore permitted **only if** it preserves all these invariants: -1. **Daemon-owned declaration.** A daemon-owned facet under `packages/daemon/src/` declares route and request-policy traits. +1. **Daemon-owned declaration.** The separately typed `daemon` facet in `packages/command-registry/src/registry.ts` declares route and request-policy traits. + `DaemonCommandDescriptor` in `packages/command-registry/src/daemon-command-descriptor.ts` owns its type. The registration composes that facet. The public command contract in `src/commands/**` or `packages/command-registry/src/catalog.ts` must never contain these traits as inline fields. A registration call may be co-located with public declarations. @@ -106,15 +113,20 @@ A derived daemon registry is therefore permitted **only if** it preserves all th 2. **Predicate interface unchanged.** Consumers continue to use named predicates such as `getDaemonCommandRoute`, `isLeaseAdmissionExempt`, and `shouldLockSessionExecution`. Only the daemon registry exposes these predicates. Derivation changes table construction, not table access. -3. **No leakage into public projections.** Types must prevent the catalog/CLI/MCP/help/capability projections from reading daemon-only traits. - The daemon registry must still not define CLI grammar, Node.js options, MCP schemas, user-facing help, or capability support. -4. **One declaration per concern, enforced by types.** At the single registration site, a missing or duplicate daemon trait must cause a compile error. - This replaces alignment by convention. - This structural improvement justifies derivation instead of a separate hand-authored table. +3. **No daemon traits in public contracts.** CLI grammar, Node.js client options, MCP schemas, user-facing help, and capability support must not derive from daemon-only traits. + A diagnostic display may read the `daemon` facet. `command explain` in `src/cli/command-explain.ts` displays routes and request-policy traits this way. +4. **One declaration per command.** Each command has at most one `daemon` facet, at its registration in the descriptor registry. + No second table declares the same traits. This replaces alignment by convention, and it justifies derivation instead of a separate hand-authored table. + A command without a `daemon` facet uses the `generic` route with default request policy. The daemon registry tests pin the derived table. A single flat public descriptor that exposes daemon fields in public views is **not** permitted. This would combine daemon policy with a public command registry, which this ADR rejects. Compose facets owned by their domains. Derive the registry from those facets. -Until that derivation is implemented and the registry tests pin it, the hand-authored `packages/daemon/src/core/daemon-command-registry.ts` remains the source of truth. -Until then, the operating rule in `AGENTS.md` does not change. +PR #906 introduced the descriptor and proved parity. +PR #907 made the derivation live and deleted the hand-authored table. +The daemon registry tests pin route and request-policy decisions. + +**Amendment 2026-10-16.** The 2026-06 text required types to stop every public projection from reading daemon traits, and required a compile error for a missing daemon trait. +The shipped derivation does neither: `CommandDescriptorBase.daemon` is optional (`packages/command-registry/src/types.ts`), and a command without a facet gets the `generic` route. +Invariants 3 and 4 now state the property that matters and that the code holds: no public contract derives from daemon traits, and no second table declares them. diff --git a/docs/adr/0004-ios-snapshot-backend-strategy.md b/docs/adr/0004-ios-snapshot-backend-strategy.md index 723a07e8b9..65f87a7da4 100644 --- a/docs/adr/0004-ios-snapshot-backend-strategy.md +++ b/docs/adr/0004-ios-snapshot-backend-strategy.md @@ -17,7 +17,8 @@ Structured snapshot quality verdicts and fallback warnings make degraded or reco - Route eligible local iOS Simulator snapshots through the host AX bridge. Present them once through the shared TypeScript engine. - If bridge acquisition or presentation fails, use one typed XCTest fallback. -- After fallback, disable the bridge for that app generation, except for capture-scoped failures. A new app generation enables it again. +- After fallback, disable the bridge for that app generation, except for `preparing` failures and capture-scoped failures. + A new app generation enables it again. - A refusal that provides evidence about the screen, not the app, has capture scope. - Keep the regular visible strategy and the raw diagnostic strategy explicit as the two public snapshot strategies. - The regular visible strategy must not use an arbitrary node-count cutoff. @@ -52,7 +53,9 @@ Keep XCTest as the iOS automation runner. Route eligible local iOS Simulator snapshots through the host AX bridge. Present them once through the shared TypeScript engine. If bridge acquisition or presentation fails, use one typed XCTest fallback. -After fallback, disable the bridge for that app generation, subject to the capture-scoped exception below. +After fallback, disable the bridge for that app generation, except for `preparing` failures and the capture-scoped exception below. +A `preparing` failure describes a bridge preparation that is still running, not the app. +It sends only that capture to XCTest. A new app generation enables it again. Physical devices, providers, custom-action captures, and interactions remain on their existing owners. diff --git a/docs/adr/0005-ios-runner-interaction-lifecycle.md b/docs/adr/0005-ios-runner-interaction-lifecycle.md index a165c91b6a..c8a5ecf25e 100644 --- a/docs/adr/0005-ios-runner-interaction-lifecycle.md +++ b/docs/adr/0005-ios-runner-interaction-lifecycle.md @@ -7,7 +7,7 @@ Accepted ## Rules at a glance - For iOS/macOS selector taps on an element with a frame, use coordinate-first resolved element activation. tvOS remains focus/remote-driven. -- Before a command to a ready session, send an `uptime` preflight, except for the stated startup, readiness, and recent-mutation cases. +- Before a command to a ready session, send an `uptime` preflight, except for the stated startup, readiness, exempt-command, and recent-mutation cases. - Keep recent-mutation health only on the `RunnerSession` and only for the stated app, commands, response checks, and 5-second window. - Never count snapshots or other read-only responses as health signals. - Future optimization work should reduce the `uptime` preflights only after runner status survives command-induced XCTest teardown and can prove that the session still serves new requests. @@ -45,6 +45,7 @@ tvOS remains focus/remote-driven because tvOS does not support normal coordinate Before command send, ready runner sessions receive a short `uptime` preflight. Read-only startup commands still skip that preflight because, for a newly launched runner, the first successful command is the readiness proof. Readiness probe commands skip preflight to avoid recursion. +A ready session also skips the preflight for the preflight-exempt commands `activate`, `terminate`, and `targetReset` (`readinessPreflightExempt` in `RUNNER_COMMAND_TRAITS`). The daemon may also skip the ready-session `uptime` preflight for an explicit allowlist of mutating interactions: @@ -53,6 +54,8 @@ The daemon may also skip the ready-session `uptime` preflight for an explicit al - `drag`. - `swipe`. - `scroll`. +- `desktopScroll`. +- `gesture`. - `sequence`. The skip requires that the same session produced a healthy mutating response for the same `appBundleId` within 5 seconds. diff --git a/docs/adr/0006-daemon-rpc-protocol-version.md b/docs/adr/0006-daemon-rpc-protocol-version.md index 432ae7a8f3..20e42f7ca8 100644 --- a/docs/adr/0006-daemon-rpc-protocol-version.md +++ b/docs/adr/0006-daemon-rpc-protocol-version.md @@ -91,7 +91,9 @@ Checks enforce the increment rules above, so nobody has to remember them (#1432) `test/wire-compat/ledger.json` records the hash of each declaration and the protocol version of that hash. The unit lane fails if the shape of a declaration changes and the ledger does not change with it. `pnpm check:daemon-wire-compat` compares the ledger with the last released tag. -Drift since that release must carry either a protocol increment or a `compatibleChanges` acknowledgment that invokes the additive list above. +A declaration removed since that release needs a protocol increment; an acknowledgment cannot cover a removal. +A declaration changed since that release needs a protocol increment or a `compatibleChanges` acknowledgment that invokes the additive list above. +Declarations added since that release need neither. One bullet is only partly digestible: the `/health` and `/rpc` path literals inside `http-server.ts`. For that gap, the manifest records the gap and its reason instead of implying coverage. See `test/wire-compat/README.md`. diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index b829062b1f..993838b890 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -41,7 +41,7 @@ It stays below daemon device leases and should not be weakened or replaced by th `open` is the natural point to acquire a device lease because target resolution and session creation meet there. Commands after `open` must refresh the lease. -After five minutes without activity, the device should become available again. +After the default 60 seconds without activity, the device should become available again. These operations must run under the same daemon request lock: diff --git a/docs/adr/0008-command-descriptor-registry.md b/docs/adr/0008-command-descriptor-registry.md index 337fec37e5..719ebb6fab 100644 --- a/docs/adr/0008-command-descriptor-registry.md +++ b/docs/adr/0008-command-descriptor-registry.md @@ -25,10 +25,11 @@ Completed process history is in git history. - Keep the cross-process client `invoke` seam and the in-daemon `execute` seam distinct. Never collapse the process boundary. - Preserve all four invariants from ADR 0003. - The descriptor module must own the `Input`/`Result` types. The client must be derived as a view type, and a lint boundary enforces this. +- Before client methods can stop returning `CommandRequestResult`, move the result contracts described in Consequences. ## Context -Roughly ten manually maintained tables repeat command identity and require alignment by convention: +Before the descriptor migration, roughly ten manually maintained tables repeated command identity and required alignment by convention: - `PUBLIC_COMMANDS` in `src/command-catalog.ts`. - Per-command metadata and family facets in `src/commands/**`. @@ -59,7 +60,7 @@ Introduce one `CommandDescriptor` per command. It **composes facets owned by their domains**. Every consumer table is **derived** through pure projection: -- The descriptor composes catalog identity, command-family surface projection hooks, a `capability` facet, a `daemon` facet, platform dispatch membership, response data transforms, and typed-result hooks. +- The descriptor composes catalog identity, command-family surface projection hooks, the `platformExecution` declaration (successor to the retired `capability` facet), a `daemon` facet, platform dispatch membership, response data transforms, and typed-result hooks. `src/commands/**` remains the owner of the CLI schema/reader and executable surface metadata. ADR 0003 shapes the route and request-policy traits of the `daemon` facet. - Narrow command traits that affect multiple projections belong to the descriptor. @@ -99,9 +100,12 @@ This design composes with ADR 0003's four invariants and is bound by them: ## Consequences A plain new command changes approximately 1–2 files. -Per-platform behavior remains N implementations behind the descriptor's `execute`. -The descriptor is the prerequisite for typed per-command results in ADR 0010, which deletes the `src/client-types.ts` mirror. -It also supplies the capability facet used by the platform-plugin work in ADR 0009. +Per-platform behavior remains N implementations behind the in-daemon `execute` seam. +The descriptor's `platformExecution` declaration names the execution shape: `none`, `host`, `inventory`, or `device-runtime`. +For the `device-runtime` variants it also names the runtime use that seam binds. +The descriptor is the prerequisite for typed per-command results. +`src/client/client-types.ts` retains the public client interface and some `CommandRequestResult` return types. +It also supplied the capability facet used by the platform-plugin work in ADR 0009. That facet is retired; [ADR 0009](0009-apple-platform-consolidation.md) records the retirement. During the migration, the import-cycle inversion was the principal risk. At that time, `command-catalog.ts` had approximately 95 importers. @@ -119,16 +123,21 @@ As of 2026-07, the descriptor registry is live for these surfaces: - MCP exposure list. - Capability-checked CLI command list. - Post-action observation traits. -- Platform dispatch command set, including the remaining dispatch-only alias `read`. +- Platform dispatch command set, which then included the dispatch-only alias `read`. + +PR #1877 removed `read` when `get` moved to the request-bound device runtime. +The daemon registry now derives its table from descriptor facets instead of a hand-maintained table (PR #907). Command-family surface metadata remains under `src/commands/**`, beside CLI grammar and client-backed executors. Coherence checks compare it with the descriptor CLI catalog. Thus, new surface names cannot differ from the descriptor root. -The remaining deferred work is the public Node-client typed-result narrowing. -[#1153](https://github.com/callstack/agent-device/issues/1153) tracks this work outside this registry migration. +[#1153](https://github.com/callstack/agent-device/issues/1153), the former tracker for public Node-client typed-result narrowing, closed on 2026-07-08. +In `src/client/client-types.ts`, 21 client methods still return `CommandRequestResult`. +They include `alert`, `reactNative`, and `interactions.swipe`. Some commands still expose dynamic daemon response payloads or retain closed result contracts in runtime modules. Before their client methods can stop returning `CommandRequestResult`, those contracts must move. +Issue closure does not establish complete result narrowing. This ADR owns the decision and its constraints. The descriptor projections enforce the delivered end state. diff --git a/docs/adr/0009-apple-platform-consolidation.md b/docs/adr/0009-apple-platform-consolidation.md index 02c538c6d0..71888dc896 100644 --- a/docs/adr/0009-apple-platform-consolidation.md +++ b/docs/adr/0009-apple-platform-consolidation.md @@ -93,7 +93,8 @@ Snapshot fidelity is uneven. The deep-RN AX-server fallback is iOS-simulator-only. Public leaf output remains a separate compatibility projection. -This design composes with ADR 0008's descriptor capability facet and ADR 0003. +This design composes with ADR 0003 and the descriptor `platformExecution` declaration in ADR 0008. +Per-OS behavior is data in request-bound runtime facts. Implementation status as of 2026-08: @@ -113,7 +114,7 @@ Implementation status as of 2026-08: Discovery never produces it. - visionOS profile/build/discovery, plus simulator-deployment evidence. - Decision-only support boundary: - - visionOS discovery and simulator deployment are supported and unit-tested in `packages/platform-apple/src/inventory-classification.ts` and `packages/platform-apple/src/deployment/runtime.test.ts`. + - visionOS discovery and simulator deployment are supported and unit-tested in `packages/platform-apple/src/inventory-classification.test.ts` and `packages/platform-apple/src/deployment/runtime.test.ts`. - App deployment is also admitted for CoreDevice-backed physical devices. - XCTest-backed physical deployment is unsupported. - Push remains simulator-only. diff --git a/docs/adr/0010-error-system.md b/docs/adr/0010-error-system.md index 04fe858d7d..873ecb7c8e 100644 --- a/docs/adr/0010-error-system.md +++ b/docs/adr/0010-error-system.md @@ -16,7 +16,7 @@ - If the per-code default hint would mislead, supply a hint. If the default suffices, omit the hint. - When you wrap an error, preserve an existing hint and the cause chain. - Every consumer surface must carry at least code, message, and hint. Rehydration helpers copy all fields. -- Failed requests always flush diagnostics. Messages must not embed raw stderr dumps or secrets. +- The router's thrown-error finalizer attempts a diagnostics flush. `diagnosticId` requires an active request diagnostics scope, and `logPath` appears only when that flush produces a file. Returned or pre-scope failures may omit both fields. Messages must not embed raw stderr dumps or secrets. ## Context @@ -78,8 +78,15 @@ Nothing documented the conventions, so each new site decided them again. - Events timeline. Rehydration helpers (`throwDaemonError`, `toDaemonHttpRpcError`) copy all fields. `NormalizedError` hoists the typed signals, so `--json` consumers see them. -6. **Observability.** Failed requests always flush diagnostics. - A `diagnosticId` and an ndjson `logPath` accompany every error. +6. **Observability.** The router's thrown-error finalizer, `finalizeThrownRequestError` + (`packages/daemon/src/server/request-router.ts`), emits a `request_failed` diagnostic and attempts one forced + flush. It attaches a `diagnosticId` only when a request diagnostics scope is active, and an ndjson + `logPath` only when that flush produces a file; the flush returns no record for zero events or a write failure. + `withDiagnosticsScope` itself runs no finalizer. + The token refusal returned by `runRequestWithinScope` and malformed RPC method params rejected in + `packages/daemon/src/server/http/http-rpc-methods.ts` omit both fields. + On a remote path, the client keeps only a `logPath` it can open. + When it cannot produce one, `logPathUnavailable` replaces `logPath` and names the reason (#1801). Daemon-side diagnostics are under the session state dir. Client-side diagnostics are under `~/.agent-device/logs`. Messages must not embed raw stderr dumps or secrets. diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index 810dd0ad0e..a669d69414 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -73,12 +73,18 @@ An unproven cell is at least a visible claim that has an owner. export const INTERACTION_GUARANTEES = [ 'disambiguation', // collapse one equivalent wrapper chain; distinct subtrees fail with candidates 'occlusion', // covered targets are refused + 'keyboardOcclusion', // refuse tap points behind the visible software keyboard + 'parentOwnedTouchPoint', // preserve parent identity; avoid interactive descendants 'offscreen', // tap point (rect center) must lie in the root viewport 'nonHittable', // promotion + targetHittable/hint annotation 'responseConstruction', // one shared response construction site (Layer 2) 'responseIdentity', // refLabel/selectorChain availability on this path 'verifyEvidence', // --verify baseline + post-action digest + 'settleObservation', // best-effort --settle diff after the action 'errorTaxonomy', // no-match/ambiguous/offscreen codes, messages, hints + 'resolutionDisclosure', // disclose the source of target resolution + 'outcomeObservation', // state what the path observes after dispatch + 'targetStability', // refuse tap positions that change between observations ] as const; ``` @@ -155,13 +161,15 @@ Some rules must run **runner-side**, because the direct iOS path cannot see the These rules get pure-function Swift twins that operate on plain geometry, without `XCUIElement`. `TapPointPolicy.isAllowed(elementFrame:windowFrame:)` is one example. **Golden fixture tables** enforce parity. -These tables are JSON files under `contracts/fixtures/`, and three test suites consume them: +These tables are JSON files under `contracts/fixtures/`. +`tap-point-policy.json` has two test consumers: -1. Vitest asserts the TypeScript rule against the table. -2. Runner Swift unit tests assert the Swift twin against the same table. - CI already compiles them through "Swift Runner Unit Compile". -3. The provider harness's fake runner derives its behavior from the table. - Integration scenarios therefore exercise the real contract instead of a manual approximation. +1. `packages/contracts/src/snapshot-visibility-tap-point-policy.test.ts` asserts the TypeScript rule against the table. +2. `apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TapPointPolicyTests.swift` asserts the Swift twin against the same table. + CI already compiles it through "Swift Runner Unit Compile". + +The provider harness's fake runner does not consume this table. +Migration step 3 remains open for that consumer. Then, a TypeScript or Swift drift turns CI red on the side that changed, without a simulator. @@ -313,7 +321,7 @@ A daemon reset there destroyed every healthy app session that the daemon owned. Their divergence is priced and documented, instead of discovered on-device. - The Swift/TypeScript split stops being a parity blind spot. Golden tables are the single source of truth for cross-language rules. - The fake runner stops being a second implementation that drifts. + After the remaining migration step 3 work, the fake runner would consume the same table instead of a separate approximation. - The initial cost is mostly classification honesty. The first registry contains `waived('gap: ...')` cells, and that is the purpose. For example, the maestro non-hittable fallback intentionally waives `offscreen`. @@ -386,7 +394,7 @@ That Swift policy is the source of truth, because the current table has only thr - A coordinate synthesized tap never probes keyboards and may use the coordinate fallback. This applies to a standalone tap, a `sequence` step, and the in-app `back` top-leading tap. -- Default iOS scroll probes keyboards only after AX is known healthy. +- Default iOS scroll probes keyboards whenever AX is not stamped unavailable, including while health is still unknown (#2500). It must not fall back to `XCUICoordinate`. - Synthesized one-contact `gesture` plans may still use the coordinate fallback before AX health is known. After a snapshot stamps AX unavailable, they stop using it. diff --git a/docs/adr/0012-interactive-replay.md b/docs/adr/0012-interactive-replay.md index 8362cac3be..b4d1771bce 100644 --- a/docs/adr/0012-interactive-replay.md +++ b/docs/adr/0012-interactive-replay.md @@ -1468,7 +1468,8 @@ The 2026-08-07 structural-equivalence-or-reject amendment supersedes that policy Selector resolution used the opposite default. ADR 0011 classified runtime-selector `disambiguation` as `{ kind: 'runtime', via: '...selectors-resolve.ts#resolveSelectorChain' }` (`packages/contracts/src/interaction-guarantees.ts:176-179`). This proved consistent heuristic execution, not disclosure. - The acceptance-time design did not reconsider that default; its hard-reject alternative explains why. + The acceptance-time design did not reconsider that default. + The structural-equivalence rationale in Consequences explains why blanket rejection remains unsuitable. - **(f) Issue #1037 / PR #1040 provided partial precedent.** Apple Maps uniquely matched `text="Anthropic - Headquarters"` to a 30x30 pin instead of a recents row. `describeNonHittableTarget` exposed `targetHittable:false` and a hint (`resolution.ts:259-268`). It disclosed but did not prevent the incorrect tap. diff --git a/docs/adr/0013-unified-gesture-plans.md b/docs/adr/0013-unified-gesture-plans.md index 1566af8feb..c0547d2d30 100644 --- a/docs/adr/0013-unified-gesture-plans.md +++ b/docs/adr/0013-unified-gesture-plans.md @@ -137,7 +137,7 @@ Platform adapters consume the canonical plan: - **Apple.** iOS lowers one-contact endpoint-hold plans to the established fast-swipe synthesis profile. That profile reaches the endpoint in 100 ms, then holds there for the planned duration before it lifts. This matches Maestro's XCTest driver. - One-contact plans are linear and therefore carry only their start and end samples, with the authored duration between them. + An endpoint-hold plan is linear and therefore carries only its start and end samples, with the authored duration between them. Two-contact plans convert every planned point to native orientation and send the exact arrays to the private XCTest event bridge. macOS lowers a one-contact plan to its drag executor. tvOS lowers a one-contact plan to remote direction. @@ -187,12 +187,16 @@ Providers should compose transport/device bindings with the shared platform adap That `.ad` codec is the script format's own syntax, not a compatibility shim. It is not scheduled for removal (amended 2026-07, issue #1216). -Its only remaining callers are the CLI argv parse and the `.ad` line parse. -Both are the current public syntax, so nothing is left to migrate off. +Its callers include the CLI argv parse, the `.ad` line parse, and the recording path. +`packages/daemon/src/execution/interaction/internal/interaction-gesture.ts` encodes gesture inputs for recordings. +`packages/daemon/src/core/session-script-writer.ts` decodes recorded drag endpoints. +`packages/replay-port/src/daemon-port/target-token.ts` decodes drag endpoints to select the replay target token. +`packages/daemon/src/core/session-script-active-publication.ts` decodes drag endpoints to validate target evidence before script publication. +These are current syntax and recording consumers, so nothing is left to migrate off. A structured `.ad` gesture payload was rejected. It would make recordings unreadable and impossible to search with grep, for no behavioral gain. -One table, `PUBLIC_GESTURE_SYNTAX`, defines argument arity for both callers. +One table, `PUBLIC_GESTURE_SYNTAX`, defines argument arity for both parsers. Thus, a form removed from the CLI is removed from `.ad` in the same edit. If a `.ad` script still carries a removed positional, the script fails when it is parsed. This failure occurs before the replay executes any device action. diff --git a/docs/adr/0014-session-ref-frame-lifetime.md b/docs/adr/0014-session-ref-frame-lifetime.md index 45d87bea33..596c9349b3 100644 --- a/docs/adr/0014-session-ref-frame-lifetime.md +++ b/docs/adr/0014-session-ref-frame-lifetime.md @@ -25,11 +25,14 @@ The ref-frame vocabulary is promoted into `CONTEXT.md`. The frame is one value on `SessionState`. A transition replaces the frame whole and never edits it in place. The frame type is nominal (`#`-private fields). - Thus, no module outside `packages/daemon/src/core/ref-frame.ts` can construct a frame, edit a frame, or derive a frame from an existing frame. + Thus, no module outside `packages/daemon/src/core/ref-frame-slot.ts` can construct the frame class directly, access its private fields, or edit a frame in place. + Callers construct and derive frames only through the exported factory APIs `issuedRefFrame` and `expiredRefFrame`. + `packages/daemon/src/core/ref-frame.ts` owns the transitions and admission that call these factories. The type cannot judge a whole frame that moves unchanged, for example when code clears the field or assigns the frame of another session. The field-owner gate still covers these moves. - A complete snapshot activates an `all` frame. `find`, settled diffs, and replay divergence screens activate a bounded partial frame that supersedes the prior frame. + A partial result with no issued ref does not supersede the prior frame. Internal read-only captures never activate, reindex, or expire a frame. - Every mutating leaf expires the frame at the side-effect seam: after all pre-action guards, immediately before the device operation. There is no success-only rollback. diff --git a/docs/adr/0015-direct-maestro-engine.md b/docs/adr/0015-direct-maestro-engine.md index 68a142c2b5..5d8554a554 100644 --- a/docs/adr/0015-direct-maestro-engine.md +++ b/docs/adr/0015-direct-maestro-engine.md @@ -159,7 +159,7 @@ That oracle replaced the original hand-typed parser fixture. The pinned parser is SHA-verified at regeneration. Layer 1 captures each parse. The deterministic verifier replays that capture against our engine. - The verifier classifies every flow as identical / both-reject / we-reject / mismatch. + The verifier classifies every flow as identical / both-reject / we-reject / mismatch / we-are-lenient. Each non-identical outcome must be a declared, on-the-record divergence. 2. Layer 2 reads upstream constants (retry cap, animation-wait threshold/timeout, erase cap) straight from the pinned bytecode. It also reads parser-observed defaults (swipe duration) straight from the pinned bytecode. @@ -193,8 +193,10 @@ Each of the four #1217 regressions has a dedicated fixture: A pure unit test, rather than a device scenario, pins the runtime half of percent truncation, because a one-pixel delta is not app-observable. The detector for bug class 4 is an engine-side timing invariant that executes on the device path. Unit tests exercise that detector against synthetic traces. -On the device path, the detector is currently blocked by a declared `scrollUntilVisible` divergence (#1299). -The differential itself found that divergence on its first working run. +The `settle-after-tap` device scenario has no declared divergence. +It requires at least one settle latch and no settle timeouts. +The differential found the former `scrollUntilVisible` divergence on its first working run. +Issue #1299 closed on 2026-07-21; that divergence no longer blocks the detector. Documented intentional deviations are encoded as expected-divergence entries rather than silent mismatches: diff --git a/docs/adr/0017-parameterized-recorded-inputs.md b/docs/adr/0017-parameterized-recorded-inputs.md index a5ee3e419d..5e845278fe 100644 --- a/docs/adr/0017-parameterized-recorded-inputs.md +++ b/docs/adr/0017-parameterized-recorded-inputs.md @@ -9,7 +9,8 @@ Accepted - The fill-only `recordAs` input parameterizes a recorded fill value. The CLI spells it `--record-as `. - The live fill dispatch receives the literal. - The public response, recording state, session event, and published script of the fill use `${VAR}`. + The public response, recording state, and published script of the fill use `${VAR}`. + The session event hides the fill value as ``. - Variable names use replay's uppercase `[A-Z_][A-Z0-9_]*` grammar. `AD_*` remains reserved for runtime built-ins. - `--record-as` is rejected unless script recording is armed. @@ -52,7 +53,8 @@ agent-device session save-script ``` The live fill dispatch receives the literal. -Its public response, recording state, session event, and published script use `${PASSWORD}`. +Its public response, recording state, and published script use `${PASSWORD}`. +The session event hides the fill value as ``. Later, replay resolves the placeholder from `AD_VAR_PASSWORD` or `--env PASSWORD=` before the fill is dispatched. Variable names use replay's uppercase `[A-Z_][A-Z0-9_]*` grammar. diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index a825c62316..44e35a55cf 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -13,8 +13,9 @@ It extends `host` beyond diagnostics to host-scoped platform work through a narr Host diagnostics, daemon-owner resource cleanup, and managed host tooling share this execution category because none binds a request-scoped device runtime. Their domain services remain separate and do not form a generic host grab bag. -An amendment proposed on 2026-09-23 accompanies the web press-shape refusal. -It states in section 3 that a modifier on an admitted operation is not a second operation and therefore has no fact cell. +The amendment of 2026-09-23 accompanies the web press-shape refusal (#2806 and #2808). +Its rule is in force. +It states in section 2 that a modifier on an admitted operation is not a second operation and therefore has no fact cell. A per-shape refusal on the owning leaf mechanic rests on that statement. Without that statement, the next contributor can reasonably reach for a per-shape fact key. The operation-facts mapped type cannot express such a key. @@ -147,6 +148,7 @@ They may not import these items: R13 covers static, type-only, dynamic, and re-export edges. Package-owned tests may import their own public façade. +Root `src/__tests__/` owns shared install-source tests, including `install-source.test.ts` and `cli-install-from-source.test.ts`. Contracts may depend on kernel vocabulary. Contracts never depend on concrete platform packages or daemon implementation types. diff --git a/docs/adr/0020-composable-recorded-fragments.md b/docs/adr/0020-composable-recorded-fragments.md index cb3060e880..0653e651d4 100644 --- a/docs/adr/0020-composable-recorded-fragments.md +++ b/docs/adr/0020-composable-recorded-fragments.md @@ -67,8 +67,9 @@ Consequently, the design must solve four boundaries together: The entry guard and element actions reuse ADR 0012's `target-v1`/`targets-v1` annotations and verification. Every v1 native fragment and composed parent starts with a required first-executable format guard (`fragment-v1` or `composition-v1`). A new reader consumes that guard during preflight. - An old reader treats the guard as an unknown action and rejects it before dispatch. - Thus the old reader cannot execute a root `open` or a known prefix before it reaches `include`. + An old reader treats the guard as an unknown action. + The required safety property is failure before platform execution, before a root `open` or any known prefix. + An old reader fails with its existing errors, not `INVALID_ARGS`; see Annotation compatibility and versioning. Other fragment metadata and references remain reserved comments. Thus old readers ignore those annotations, and do not silently flatten the composition. 7. **Native fragment repair is explicit re-recording.** @@ -99,7 +100,8 @@ But these invariants are fixed: It is a format envelope, not a `SessionAction`, and it has no device side effect. A composed native parent uses the corresponding `composition-v1` guard as its first executable line, before root `open` or any `include`. An old reader parses either guard as an unknown action. - The existing `dispatchKnownCommand` seam rejects it with `INVALID_ARGS` before it invokes any platform handler. + The required safety property is failure before platform execution. + An old reader returns its existing, session-dependent errors; see Annotation compatibility and versioning. The fragment has no `open`, `close`, or hidden launch action. The writer always emits the effective `context platform` and `target`. A parser rejects a fragment that is missing either constraint. @@ -525,22 +527,42 @@ No error projection is allowed to collapse a fragment failure to a parent global The first entry `wait` and every element-targeting action continue to carry the existing `target-v1`/`targets-v1` evidence. That evidence keeps its size limits, canonical field order, and fail-closed `verification` behavior. Readers that do not know the reference annotation ignore that comment. -Readers that do not know the executable guard reject the artifact at its format boundary. +Readers that do not know the executable guard must fail before platform execution. The new reader consumes the native guard during source preflight. An old reader can get either a v1 fragment or a composed parent. It parses the guard as an ordinary action. -Then the existing `dispatchKnownCommand` rejection in `src/core/dispatch.ts` throws `INVALID_ARGS` before the platform handler is invoked. -Therefore, the old reader cannot execute a root `open`, a known prefix, or an expanded child body. +The current parser is `parseReplayScriptLine` in `packages/ad-script/src/internal/script.ts`. +It accepts an unknown command name without format validation. +The replay engine dispatches that action through `invokeReplayAction` in `packages/replay-port/src/daemon-port/session-replay-action-runtime.ts`. +An unknown command has no `replayScopedAction` trait, so `createReplayScopedActionInvoker` uses the full request path. +`getDaemonCommandRoute` selects `generic`, and `runRequestHandlerChain` returns no handler response. + +After request admission succeeds, `dispatchGenericForLockedScope` in `packages/daemon/src/server/request-router.ts` checks the session first. +If no session exists, it returns `SESSION_NOT_FOUND` through `noActiveSessionError`. +If a session exists, it calls `resolveGenericRuntimeExecution` in `packages/daemon/src/execution/generic-runtime-execution.ts`. +For an unknown guard, that resolver returns `COMMAND_FAILED` with reason `generic-route-runtime-missing` before platform execution. +These are responses for the guard request, not the outer replay response. +The replay adapter wraps this dispatch failure as `REPLAY_DIVERGENCE`. +The replay engine stops at the failed action before a later root `open`, known prefix, or expanded child body. + +This trace proves failure before platform execution for an unknown guard that reaches generic dispatch. +Admission can resolve a device for lock and provider selection; that resolution reads the device inventory and runs no command. +This trace does not cover failures during admission. +A new fragment codec cannot change an old reader's behavior. +Thus the old-reader contract accepts these existing errors: `SESSION_NOT_FOUND`, or `COMMAND_FAILED` with reason `generic-route-runtime-missing`, wrapped as `REPLAY_DIVERGENCE`. +The contract does not require `INVALID_ARGS` from an old reader. +A reader that ships with the fragment codec would refuse an unknown or malformed guard with `INVALID_ARGS` during source preflight. An old reader that gets an unguarded legacy `.ad` retains its existing behavior. This includes the possibility that it executes a known prefix before a later unknown command. That legacy case is explicitly outside the v1 composed-artifact acceptance contract, and it is not safety evidence. The new reader rejects a fragment supplied as a top-level replay artifact with `fragment_composition_required`. Unknown future reference comments remain ordinary comments to an older reader. -An unknown future executable guard fails before device work. +An unknown future executable guard has the same required safety property. -The old-reader evidence points to the actual unknown-command seam in `src/core/dispatch.ts`. -The current `src/core/__tests__/dispatch-keyboard.test.ts` does not prove this contract. +`packages/daemon/src/execution/__tests__/generic-route-runtime-completeness.test.ts` verifies the resolver's unknown-command branch. +It does not exercise the full replay path or the missing-session branch. +Its unknown-command test proves the routing-gap response, not the proposed fragment format contract. The fragment codec implementation must add planted-red tests with these properties: - They run a guarded composition with `open` through the old dispatch path. @@ -676,8 +698,8 @@ The planted-red checks below are unit or fixture assertions. The slice that introduces each check owns it. They are not a claim that this docs-only spike already has a structural gate. -The old-reader dispatch seam is `src/core/dispatch.ts`. -The existing keyboard-dispatch test is not evidence for unknown-command rejection. +The current generic-dispatch seam is `resolveGenericRuntimeExecution` in `packages/daemon/src/execution/generic-runtime-execution.ts`. +Its completeness test does not prove the proposed old-reader fragment contract. 1. **Shared pure model and canonical fixtures.** Add typed composition nodes, fragment identity, invocation paths, local addresses, digest canonicalization, cycle/depth/size validation, and preflight result types without dispatch. diff --git a/docs/adr/0022-daemon-platform-runtime-coupling.md b/docs/adr/0022-daemon-platform-runtime-coupling.md index 94ed5119b5..d52cfd6036 100644 --- a/docs/adr/0022-daemon-platform-runtime-coupling.md +++ b/docs/adr/0022-daemon-platform-runtime-coupling.md @@ -3,10 +3,11 @@ ## Status Accepted (2026-09-06). This ADR implements issue #2278's audit deliverables. -Gates enforce the classification inventory and structural no-regrowth checks. -The ADR records decisions and references gates instead of duplicating the inventory. +R76 prohibits daemon coupling to root platform-runtime modules. +Gates enforce structural no-regrowth checks. +The ADR records decisions and references gates instead of duplicating their checks. -Amended 2026-10-11 by #3431. +Amended 2026-10-12 by #3431 and #3430. Daemon-runtime composition edges moved from `packages/daemon/src/` to process-root `src/daemon-composition.ts`: - Gateway, diagnostics, resource-cleanup, and lifecycle edges. @@ -118,8 +119,8 @@ The ADR 0019 trace recorded routes of approximately 24 files without an accepted - Runtime lifecycle participation deepens through the existing lifecycle phases, with no generic hook bag (#2333). - Open-target planning separates plan/result from platform mechanics and preserves one construction path. - Session state/store authority retains its access structure and ratchets the handler-owned portion through R75. - - Route depth records the re-traced routes and collapses only the proven pass-through hops. - This change collapses none. + - Route depth records the re-traced routes and collapses only the hops that the deletion test proves removable. + The deletion test covers pass-through and translation hops. This change collapses none. ## 3. Rationale diff --git a/docs/adr/0023-end-state-hop-trace.md b/docs/adr/0023-end-state-hop-trace.md index 3dd528e9c8..9f5115b893 100644 --- a/docs/adr/0023-end-state-hop-trace.md +++ b/docs/adr/0023-end-state-hop-trace.md @@ -343,7 +343,7 @@ The builders are in `packages/contracts/src/*-runtime.ts`. They are side-calls o The plan sheet suggested façade PRs #2178, #2222, and #2232 as a possible explanation. All merged before `e624ef9d3f`, the commit cited for the ADR's 38/29 measurement. -Thus, they cannot explain why that count is higher than this one. +Thus, they cannot explain the difference between 38/29 and this trace's 41 and 47/49 counts. They were already in effect when 38/29 was recorded. This trace measures 41 hops for `press`/Android and 47/49 per arm for `snapshot`/iOS. diff --git a/docs/adr/0024-screen-recording-provable-signal.md b/docs/adr/0024-screen-recording-provable-signal.md index 4619010989..bb0d587650 100644 --- a/docs/adr/0024-screen-recording-provable-signal.md +++ b/docs/adr/0024-screen-recording-provable-signal.md @@ -364,13 +364,15 @@ Section 5 currently has these requirements: - Uncertain cleanup stays `cleanup-pending`. - When finish throws, forced cleanup runs (in `transitions.ts`). -The amendment: -*Screen recording is a **stop-and-collect** resource. -It keeps the manifest, the fence on every destructive step, and the completed replay. -It replaces `reattach` and `cleanup` with the `stop(target)` and `collect` of the backend. -For this kind, it drops `cleanup-pending` as a phase and drops the admission ledger. -It records the recorder observation beside the committed export. -For every durable kind, forced cleanup is no longer inferred from a failed finish.* +The proposed amendment: +*Screen recording would be a **stop-and-collect** resource. +It would keep the manifest, the fence on every destructive step, and the completed replay. +Backend `stop(target, budget)` and `collect` would replace the `reattach` and `cleanup` facet operations. +This kind would remove the `cleanup-pending` phase and admission ledger. +It would record the recorder observation and native-path disposition beside the committed export. +A manifest would remain archived by fence generation until both are settled. +For each durable kind, failed finish would no longer imply forced cleanup once its failed-finish test states its retry requirements. +App-log, audio-probe, and perf-capture would retain the ADR 0019 contract unchanged.* ### 2.8 Android duration (separate decision) diff --git a/docs/adr/0025-foldable-apple-panels.md b/docs/adr/0025-foldable-apple-panels.md index 045d7a5191..2854d28b77 100644 --- a/docs/adr/0025-foldable-apple-panels.md +++ b/docs/adr/0025-foldable-apple-panels.md @@ -6,6 +6,7 @@ Accepted (2026-09-20). The pose-settle amendment is dated 2026-09-21 under #2730. The touch-overlay export diagnosis was corrected on 2026-09-22 under #2707. The Android emulator pose amendment is dated 2026-10-10. +The no-panel refusal correction (#3761) is dated 2026-10-18. This ADR covers iPhone Duo (iOS 27.1, `iPhone19,4`) and Apple devices with more than one integrated CoreDevice display. The amendment below also covers foldable Android emulators. @@ -19,13 +20,16 @@ Only inference answers the second question. | Situation | Behavior | | --- | --- | -| Any iOS simulator capture | First resolve the CoreDevice display table. Capture the panel that the system currently lights. | +| iOS simulator capture through `simctl` | First resolve the CoreDevice display table. Capture the panel that the system currently lights. | +| Runner fallback after an eligible `simctl` failure | Follow the target app's window and capture the display that hosts it. Use the facts that the runner reports. Only a failure the fallback policy (`shouldFallbackToRunner`) accepts reaches the runner; other failures rethrow. | | More than one integrated panel | Pass `--display=` on every `simctl io screenshot`. Never use the implicit default. | -| Zero or multiple lit panels | Name the `primary` panel. Emit `apple_display_capture_ambiguous`. Report pose as `unknown`. | +| Zero or multiple lit panels with a `primary` panel | Name the `primary` panel. Emit `apple_display_capture_ambiguous`. Report pose as `unknown`. | +| No sole lit panel and no `primary` panel | Refuse with `COMMAND_FAILED` and `details.reason: capture-panel-unidentified`. Never choose a panel by array order. | | One integrated panel | Preserve pre-panel behavior exactly: no display flag, no pose, unchanged scale probe. | | Density normalization | Use the captured panel's `pointScale`. For runner fallback, use that capture's reported pixels-per-point. | -| Pose report | From panel power alone, report only `closed`, `fully-open`, or `unknown`, never a narrower pose. `fold` reports the exact pose because it reads the hinge angle. | +| Pose report | From panel power alone, report only `closed`, `fully-open`, or `unknown`, never a narrower pose. `fold` reports an exact pose only after hinge readings verify it. | | Pose request | `agent-device fold ` sends private HID inside the simulator. Then it reads the hinge angle back from CoreDevice until the angle agrees. For `half-open`, two consecutive readings must agree. If the readings never agree, refuse the pose. | +| The first hinge read returns no sample | Refuse with `fold-pose-dispatched-unverified`. Panel power does not verify the pose. | | The hinge reaches `half-open` but keeps moving | Refuse with `fold-pose-unsettled` and the observed and previous angles. An angle inside the open interval is an observed category, not a pose that the hinge holds. | | External display | It is not a panel. It creates neither multi-screen classification nor a pose. | | CoreDevice cannot answer | Return unresolved inventory. Preserve single-panel capture. An absent host feature is not a capture failure. | @@ -67,10 +71,12 @@ It is the only reader of CoreDevice display information. If the primary panel is also largest, `primary` still decides. In that case, emit `apple_display_primary_geometry_conflict`. Do not silently replace identity with a geometry rule. - **Capture target.** - `resolveAppleCaptureDisplay` returns a panel for every multi-panel device. + `resolveAppleCaptureDisplay` resolves a panel or a refusal for every multi-panel device. It returns `undefined` only for a single-panel device or unresolved inventory. If exactly one panel is lit, name that panel. - Otherwise, name `primary`, set `ambiguous`, and emit `apple_display_capture_ambiguous`. + Otherwise, if CoreDevice marks a `primary` panel, name it, set `ambiguous`, and emit `apple_display_capture_ambiguous`. + If neither identifies a panel, refuse with `COMMAND_FAILED` and `details.reason: capture-panel-unidentified` (#3761, merged 2026-10-18). + Array order is not identity; the resolver never falls back to it. A multi-panel device never receives a capture without a display name. The implicit default caused the black-image, exit-0 failure. An explicitly named, possibly dark panel makes failure visible and diagnosable. @@ -136,7 +142,8 @@ SpringBoard owns the home-screen window. Thus, a session without an app, or with a stopped app, can still resolve a window's display. The result always names a display occupied by a window. It never defaults to `XCUIScreen.main`, which is dark on a Duo when the app uses the inner panel. -A capture fails closed with a typed reason only when neither window resolves. +A capture fails closed only in three cases. Neither window resolves. A resolved window names no display. A resolved display yields an unrenderable image. +The typed reasons are `unresolvedWindow`, `unresolvedScreen`, and `unrenderableImage` (`RunnerAppScreenCapture.swift`). The query uses both `exists` and frame evidence to refuse an app without a window. Such an app returns `(0,0 0x0)` from `frame` without an exception. Geometry alone cannot prove that no window exists. @@ -230,12 +237,16 @@ The split above still holds: the press is not evidence, and the read-back is evi The headless HID amendment above replaces the Device Hub press. Git history retains the superseded Device Hub process. -`xcrun devicectl device motion hinge-angle --device ` streams the Duo simulator hinge angle over `Range:0-180°`. +In the 2026-09-21 run, `xcrun devicectl device motion hinge-angle --device ` streamed the Duo simulator hinge angle over `Range:0-180°`. Measured presets are Closed 0°, Book 130°, and Open 180°. `--session-timeout` does not stop the stream. Each read therefore uses devicectl's own `--timeout`, whose minimum accepted value is 5 seconds. The sample printed before timeout is the reading. -This read path was checked on the shipping 27.1 toolchain. +This run did not establish support in every 27.1 toolchain. +On 2026-10-16, the host reports Xcode 27.1 build 27A9275 and devicectl 651.13.5. +Its `device` help lists no `motion` subcommand. +The 2026-10-14 fix (#3587) records the same limitation for that build, then identified as 27.1 RC. +Without a sample, the pose remains unconfirmed as described below. `closed` and `open` are hinge end stops. One read at the stop proves the pose. @@ -286,7 +297,7 @@ Attempt count, per-read timeout, cancellation, and open/closed end-stop rules re An unsettled fold still consumes four hinge streams. If the first hinge read returns no sample, the pose remains unconfirmed regardless of cause. -For example, devicectl 651.13.5 in Xcode 27.1 RC build 27A9275 lacks `device motion`. +The 2026-10-14 evidence identifies devicectl 651.13.5 in Xcode 27.1 build 27A9275 as an example without `device motion`. An unreachable device also prints no sample. HID has already dispatched, so refusal uses `fold-pose-dispatched-unverified`. This distinguishes unconfirmed dispatch from an observed contradictory pose. @@ -311,8 +322,8 @@ The shipping-toolchain investigation recorded these candidate results: | `simctl` subcommands | No `hinge`/`fold`/`pose` surface; No such token in the `simctl` binary or in `CoreSimulator.framework` | | `simctl io` | `screenshot`/`recordVideo` take `--display`; `screenConfig` sets `power` and `geometry` per screen — Neither changes hinge state. | | XCUITest | No hinge token in any `XCUIAutomation` header; `XCUIDeviceButton` is Home/VolumeUp/VolumeDown/Action/Camera only | -| CoreDevice | `com.apple.coredevice.action.streamhingeangle`, `HingeAngleManager`, `DeviceHingeAngleSnapshot` and `HingeAngleStreamConfig` exist in `/Library/Developer/PrivateFrameworks/CoreDeviceUtilities.framework` (and `dtdeviceinfod`), They **stream** an angle. The Duo reports no such feature. `devicectl` exposes no CLI for it. | -| `devicectl` | `device info displays` reports panel state only | +| CoreDevice | The initial 2026-09-20 examination found `com.apple.coredevice.action.streamhingeangle`, `HingeAngleManager`, `DeviceHingeAngleSnapshot`, and `HingeAngleStreamConfig` in `/Library/Developer/PrivateFrameworks/CoreDeviceUtilities.framework` and `dtdeviceinfod`. The Duo then reported no such feature. The 2026-09-21 run above later observed a CLI stream. | +| `devicectl` | `device info displays` reports panel state only. Hinge-stream availability depends on the toolchain; the 2026-10-16 host examination above found no `device motion`. | | `Xcode.app` frameworks | No hinge reference in `Xcode.app/Contents/{SharedFrameworks,Frameworks}`, Device Hub control is therefore not a hinge API shipped inside Xcode. | Two searches came back empty, not negative. This ADR records them as limits, not as refuted claims: @@ -402,7 +413,8 @@ Only `AVAssetExportPresetHighestQuality` preserves arbitrary geometry. Hardware encoding makes full-resolution export inexpensive: approximately 1–2s for 90s at `1206x2622`. Thus, `high` was not slower. -`recording-overlay.swift` now always exports through the geometry-preserving preset at both quality tiers. +`recording-overlay.swift` exports every composited overlay through the geometry-preserving preset at both quality tiers. +An empty gesture list skips the overlay export and copies the raw capture unchanged. `--quality` no longer reduces capture resolution. Before adoption, it verifies that the composited track's resolved size matches the capture. It also rejects uniformly black output when the raw capture had visible content. diff --git a/docs/adr/0027-descriptor-root-vs-eager-closure-budget.md b/docs/adr/0027-descriptor-root-vs-eager-closure-budget.md index 75286abbd1..401a13b5e5 100644 --- a/docs/adr/0027-descriptor-root-vs-eager-closure-budget.md +++ b/docs/adr/0027-descriptor-root-vs-eager-closure-budget.md @@ -99,7 +99,8 @@ Three facts determine eligibility from the closure graphs and merge-base tree, w It removes or replaces none. 2. **Added-module novelty.** Each module newly evaluated by the head is absent from the merge-base tree at its rename-canonicalized path. - It must also carry no code from a source deleted since the merge-base. + It must also carry no verbatim transplant of code from a source deleted since the merge-base. + The probe detects identical normalized statements. A rewritten transplant can pass the probe. The probe limits below record that fact. The check matches an added module when it shares two or more identical normalized top-level statements with one deleted source; that is a move signal. One shared statement counts as coincidence. Short constants, such as schema versions or default timeouts, repeat across the repository. diff --git a/docs/adr/0028-capability-family-cell-vocabulary.md b/docs/adr/0028-capability-family-cell-vocabulary.md index 21389373e4..b567b8930a 100644 --- a/docs/adr/0028-capability-family-cell-vocabulary.md +++ b/docs/adr/0028-capability-family-cell-vocabulary.md @@ -48,7 +48,7 @@ This ADR proposes these rules: - One runtime row per capability family owns its cell key. The table derives the cell-key type instead of a separately maintained type. -- Each runtime owner still reports exhaustive facts for its exact device shape under [ADR 0019](0019-request-bound-platform-runtime.md), section 3. +- Each runtime owner still reports exhaustive facts for its exact device shape under [ADR 0019](0019-request-bound-platform-runtime.md), section 2. Derivation may enumerate cells. It must not derive an owner's answer for a cell. - There is no cross-family default denial set. diff --git a/docs/adr/0033-collocation-decision-table.md b/docs/adr/0033-collocation-decision-table.md index 1590bbb700..a29f413657 100644 --- a/docs/adr/0033-collocation-decision-table.md +++ b/docs/adr/0033-collocation-decision-table.md @@ -43,7 +43,7 @@ Re-evaluate those files against their remaining edges. | `src/capture-admission/screen-recording-admission-ledger.ts` | keep (proposed move rejected) | The same direction is rejected. Capture-kit's screen-recording session resource and stop-recovery consume it. | | `src/durable-json.ts` | keep (proposed move rejected) | Managed-allocation already imports this capture-kit module. Reversal forms a package cycle unless that import disappears first. A design that first removes that import stays open. Contracts beside `json.ts` was also rejected: under R18, contracts holds wire vocabulary, not durable-descriptor validation mechanics. Three managed-allocation `record-*` consumers explain the community coupling and can remain. | | `src/snapshot/snapshot-evidence.ts` | keep | This is snapshot-capture mechanics. The daemon-dominated community represents consumers. A daemon-server home would force every platform and provider to depend on the daemon zone for capture evidence. | -| `src/snapshot/snapshot-freshness/index.ts` | keep | The reason for the row above applies. This is platform-neutral freshness policy. All its consumers, capture-kit itself, platforms, and daemon, import it downward today. | +| `packages/capture-kit/src/snapshot/snapshot-freshness/index.ts` (moved from `src/snapshot/snapshot-freshness/index.ts` in #2385) | keep | The reason for the row above applies. This is platform-neutral freshness policy. Its consumers today are the root command layer and the daemon; capture-kit owns the module. | | `src/snapshot/touch-reference-frame.ts` | keep | Interaction and capture share these snapshot-geometry mechanics. Only capture-kit is below both. | ### contracts @@ -63,7 +63,7 @@ All its consumers occupy one zone that already owns the operations represented b | `src/device-boot.ts` | keep | Device-selection, platforms, and daemon-server consume this boot-phase vocabulary. | | `src/facades/device.ts` | keep | This is the published facade of the contracts package. Facades are the package boundary, not movable content. | | `src/interaction-guarantees.ts` | keep | AGENTS.md names this declaration site for interaction dispatch paths and guarantee cells under ADR 0011. Cross-language golden tables in `contracts/fixtures/` bind it to this package. | -| `src/managed-device-allocation.ts` | move (batch 1, landed in #3287; package removed 2026-10-10) → `packages/managed-allocation` | Only managed-allocation and its dependent daemon composition consume this port and vocabulary; see [ADR 0021](0021-host-simlock-managed-device-allocation.md) §3. No other contracts file or zone imports it. Types therefore belong beside their implementing operations. | +| `src/managed-device-allocation.ts` | move (batch 1, landed in #3287; package removed 2026-10-10) → `packages/managed-allocation` | Only managed-allocation and its dependent daemon composition consume this port and vocabulary; see [ADR 0021](0021-host-simlock-managed-device-allocation.md) §4, the withdrawn design. No other contracts file or zone imports it. Types therefore belong beside their implementing operations. | | `src/wait-runtime-plan.ts` | keep | Command-registry and daemon-server share it. Both are spine zones; contracts is the only seam below them. | ### device-selection @@ -154,7 +154,7 @@ They are not independent proof that the placement is optimal. | File | Decision | Reason | | --- | --- | --- | | `src/daemon-diagnostics-scope.ts` | moved → `packages/daemon-contracts/src/daemon-diagnostics-scope.ts` ([#3297](https://github.com/callstack/agent-device/pull/3297) into `src/daemon-contracts/`, then the package extraction) | The complete zone resides in `packages/daemon-contracts`. Its directory derives zone ownership without per-file `ROOT_MODULE_ZONES` rows. An internal rename therefore needs no ownership-table edit. | -| `packages/daemon-contracts/src/device-claim-recovery.ts` | keep (shared-below-both) | The owner-scoped claim reconciler recovers durable resources from a dead owner's state directory. Daemon `open`/startup and daemonless `device release --stale` both run it. It therefore belongs below both. Runtime recovery may reside in `daemon-contracts` only when both sides run it and root composition supplies only the gateway, per ADR 0019 section 1. The package depends on `capture-kit`. Capture-kit cannot depend back on it, because that dependency forms a project-reference cycle through `selectors`. | +| `packages/daemon-contracts/src/device-claim-recovery.ts` | keep (shared-below-both) | The owner-scoped claim reconciler recovers durable resources from a dead owner's state directory. Daemon `open`/startup and daemonless `device release --stale` both run it. It therefore belongs below both. Runtime recovery may reside in `daemon-contracts` only when both sides run it and root composition supplies only the gateway, per ADR 0019 section 1. The package depends on `capture-kit`. Capture-kit cannot depend back on it: that dependency forms a direct project-reference cycle between `daemon-contracts` and `capture-kit`. | | `src/daemon-policy-file.ts` | moved → `packages/daemon-contracts/src/daemon-policy-file.ts` | This shared daemon/client contract follows #2559 and #3288's `daemon-contracts` declaration. Rank-5 daemon-client dynamically imports it, so the spine requires a home below both. Package extraction supplies that physical home with manifest-owned exports. | | `src/daemon.ts` | keep | This is the `internal/daemon` bundle entry. Umbrella §4 keeps composition roots at the root. | | `src/platform-runtime-apple-runner-owner.ts` | retain for this batch | This is a `platform-runtime-*` composition seam. R13's exact-importer rule and ADR 0022's R76 ban on the `src/platform-runtime-*.ts` family key on its path. Relocation requires coordinated ownership changes to both declarations. The rules constrain the move without independently proving placement. | diff --git a/docs/dependency-graph-findings.md b/docs/dependency-graph-findings.md index 159018d69d..ca2e55c751 100644 --- a/docs/dependency-graph-findings.md +++ b/docs/dependency-graph-findings.md @@ -268,7 +268,12 @@ moving `DaemonRequest` is most of what §1's second cluster needs. > `bin.ts`, `cli.ts` and `daemon.ts`, and R80 asserts that the zone graph of static value imports is > acyclic. That reduces `(root)` in the logical graph only: the modules still live directly under > `src/`, and the declaration is a bridge whose rows go as groups move into zone folders or -> packages. The physical moves remain outstanding, starting with #3294. Ranking the composition roots did not weaken R2: it still rejects a direct `daemon/` -> +> packages. Correction (2026-10-16): #3294 and #3281 are closed. +> PR #3297 moved daemon diagnostics to `src/daemon-contracts/`. +> PR #3299 moved the runtime assembly pair to `src/command-runtime/`. +> Package extraction later moved daemon diagnostics to `packages/daemon-contracts/`. +> Other physical moves remain outstanding, as shown by `scripts/layering/root-module-zones.ts` on 2026-10-16. +> Ranking the composition roots did not weaken R2: it still rejects a direct `daemon/` -> > `commands/` import, and the daemon reaches the command surface through the ranked > `command-runtime` zone, which the zone graph now shows instead of hiding. The SDK entries rank > above `daemon-client`, because the typed client they publish reaches the daemon through it. diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 76de176176..462eb6d106 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -1613,16 +1613,14 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'appSwitcher' }, frameworkTier: 'extended', - // R56 retires this command's capability bucket, its `dispatch` leaf, and its HarmonyOS - // overlay membership together: admission is the owner's `appSwitcher` fact, and the only - // execution is that one bound operation (ADR 0019 §9). + // R56 removed the legacy `dispatch` leaf, capability bucket, and HarmonyOS overlay membership. + // The daemon route remains `generic`; admission checks the owner's `appSwitcher` fact, + // and execution uses that one bound operation (ADR 0019 §9). recordsSessionAction: true, recordingEffect: 'mutates-app', - // ADR 0014: app-switcher previously reached the generic daemon leaf via the - // registry's generic fallback with no daemon facet, so it could not be - // classified. Add the facet (route unchanged) so its device mutation is - // covered by the completeness gate; this is the escape hatch the ADR calls - // out, not a new specialized route. + // ADR 0014, "Command effects are daemon-owned and completely classified": + // The `daemon` facet classifies this command's device mutation for the completeness gate. + // The route remains `generic`. daemon: { route: 'generic', refFrameEffect: 'may-invalidate' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, diff --git a/packages/contracts/src/interaction-guarantees.ts b/packages/contracts/src/interaction-guarantees.ts index 2d0a6280ae..1408370c2a 100644 --- a/packages/contracts/src/interaction-guarantees.ts +++ b/packages/contracts/src/interaction-guarantees.ts @@ -3,7 +3,7 @@ * * Every dispatch path an interaction command can take must classify EVERY * guarantee: enforced by shared runtime code, enforced runner-side (with a - * parity table once ADR 0011 phase 3 lands), delegated to another path, + * parity table under ADR 0011, Layer 2), delegated to another path, * inapplicable by construction, or explicitly waived with a reason. * * This registry plus its gate test is an HONESTY/COMPLETENESS gate, not a diff --git a/packages/contracts/src/interaction.ts b/packages/contracts/src/interaction.ts index 1585db217b..09da96514f 100644 --- a/packages/contracts/src/interaction.ts +++ b/packages/contracts/src/interaction.ts @@ -160,7 +160,7 @@ export type SurfaceScopedNodes = { presentedSurface?: PresentedSurface; }; -/** Which tree a resolved `@ref`'s node was read from (ADR 0014 step 5). */ +/** Which tree a resolved `@ref`'s node was read from (ADR 0014, "Mutation admission and read behavior"). */ export type RefObservationSource = 'observation' | 'authorized-frame'; export type ResolvedInteractionTarget = @@ -182,7 +182,7 @@ export type ResolvedInteractionTarget = recordingTarget?: RecordingTargetOverride; preAction?: SurfaceScopedNodes; /** - * ADR 0014 step 5: which tree the dispatched node was read from, reported by the + * ADR 0014, "Mutation admission and read behavior": the source tree of the dispatched node, reported by the * reconciliation that chose it. `'observation'` means the session's newest capture, so the * node's rect is a position that capture measured; `'authorized-frame'` means the older tree * the ref was authorized against, whose rect says nothing about where the target sits now. diff --git a/packages/daemon/src/__tests__/support/handlers/snapshot-handler.fixtures.ts b/packages/daemon/src/__tests__/support/handlers/snapshot-handler.fixtures.ts index 39c66efea8..8f81b24975 100644 --- a/packages/daemon/src/__tests__/support/handlers/snapshot-handler.fixtures.ts +++ b/packages/daemon/src/__tests__/support/handlers/snapshot-handler.fixtures.ts @@ -97,8 +97,9 @@ export function snapshotRequest( } /** - * The suite's snapshot runtime with a bind counter, so a test can assert whether the handler - * bound the device at all (ADR 0019 §9: at most one bind per request). + * The suite's snapshot runtime counts device binds. + * ADR 0019 §9 permits one execution bind. + * If the action use has no capture, `--settle` may acquire a second, capture-only bind. */ export function countingSnapshotRuntime(): Readonly<{ inspectFacts: InspectDeviceRuntimeFacts; diff --git a/packages/daemon/src/core/__tests__/command-descriptor-ref-frame-effect.test.ts b/packages/daemon/src/core/__tests__/command-descriptor-ref-frame-effect.test.ts index aace09dc82..b3c127f6d7 100644 --- a/packages/daemon/src/core/__tests__/command-descriptor-ref-frame-effect.test.ts +++ b/packages/daemon/src/core/__tests__/command-descriptor-ref-frame-effect.test.ts @@ -5,7 +5,7 @@ import { test } from 'vitest'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { DAEMON_COMMAND_DESCRIPTORS, resolveRefFrameEffect } from '../daemon-command-registry.ts'; -// ADR 0014 migration step 2: the ref-frame-effect classification is an +// ADR 0014, "Command effects are daemon-owned and completely classified": the classification is an // honesty/completeness guard. Every command that reaches a session-owning daemon // leaf must classify how it relates to the authorized ref frame, so a device // mutation cannot be added on a path that silently leaves stale refs admissible. @@ -48,7 +48,7 @@ test('every public command is classified or explicitly non-daemon', () => { } }); -test('app-switcher no longer bypasses classification (ADR 0014 escape hatch)', () => { +test('app-switcher classifies its device mutation (ADR 0014 command effects)', () => { // app-switcher reaches the generic daemon leaf and mutates the device. Before // ADR 0014 it had no daemon facet and relied on the registry's generic // fallback, so it could not be classified. It must now resolve to a concrete diff --git a/packages/daemon/src/core/ref-frame-slot.ts b/packages/daemon/src/core/ref-frame-slot.ts index 3006dc8727..a7581ae2b0 100644 --- a/packages/daemon/src/core/ref-frame-slot.ts +++ b/packages/daemon/src/core/ref-frame-slot.ts @@ -30,7 +30,8 @@ type RefFrameFields = Readonly<{ /** * One value, replaced whole by the transitions below. The class is not exported, so nothing - * outside this file can construct, edit, or derive a frame; its ECMAScript private field also + * outside this file can construct it directly or edit its private fields; frame construction + * and derivation go through the exported factories below. Its ECMAScript private field also * makes the exported `RefFrame` nominal, so no object literal can stand in for one. */ class SessionRefFrame { diff --git a/packages/daemon/src/core/ref-frame.ts b/packages/daemon/src/core/ref-frame.ts index c8d4b6ff3e..5a7dc43a91 100644 --- a/packages/daemon/src/core/ref-frame.ts +++ b/packages/daemon/src/core/ref-frame.ts @@ -26,10 +26,11 @@ const outdatedObservations = new WeakSet(); * * The frame is expired at the device side-effect seam, carries a non-`all` * issuance scope after a partial publication, and its admission matrix is - * enforced fail-closed on every platform before dispatch (ADR 0014 steps 3–7). - * Read-only ref staleness is now derived from frame state (an expired frame - * warns; an active one does not) rather than a coarse client-stale marker, which - * migration step 8 removed. + * enforced fail-closed on every platform before dispatch (ADR 0014, "Mutation admission and read behavior"). + * Read-only ref staleness comes from frame state instead of a coarse client-stale marker. + * An expired frame warns for any ref; an active frame warns only when a pinned ref's + * generation differs from the frame epoch (execution/session-snapshot.ts, + * `resolveRefStalenessWarning`). */ /** diff --git a/src/commands/interaction/runtime/ref-target-resolution.ts b/src/commands/interaction/runtime/ref-target-resolution.ts index a7ec7e3c60..d8a846b4b5 100644 --- a/src/commands/interaction/runtime/ref-target-resolution.ts +++ b/src/commands/interaction/runtime/ref-target-resolution.ts @@ -46,7 +46,7 @@ type RefResolution = { /** The keyboard band the capture of `tree.nodes` measured, when it measured one (#2660). */ keyboard?: SnapshotKeyboardBandFact; /** - * Which tree `resolved.node` was read from, as the reconciliation decided it (ADR 0014 step 5). + * Which tree `resolved.node` was read from, as the reconciliation decided it (ADR 0014, "Mutation admission and read behavior"). * Absent for a caller-carried node (#1654), which no reconciliation of this module chose. */ adoptedFrom?: RefObservationSource; @@ -180,7 +180,7 @@ async function resolveSnapshotForRef( } /** - * ADR 0014 step 5: decouple Android freshness from ref authorization. The frame + * ADR 0014, "Mutation admission and read behavior": separate Android freshness from ref authorization. The frame * tree names WHICH node `@eN` authorizes. When a freshness (or other read-only) * capture has advanced the operational observation past the frame, adopt the * observation's node — its fresh on-screen coordinates — ONLY when its local diff --git a/src/commands/schema/cli-help-first-screen.test.ts b/src/commands/schema/cli-help-first-screen.test.ts index f11eafce1a..21aeb61feb 100644 --- a/src/commands/schema/cli-help-first-screen.test.ts +++ b/src/commands/schema/cli-help-first-screen.test.ts @@ -101,7 +101,7 @@ const FIRST_SCREEN_RULES: Record = { '9. Reopen the app bundle under test', ], foldable: [ - "Each iOS simulator capture resolves the CoreDevice display table, captures the lit panel explicitly, and normalizes density with that panel's own point scale. Do not add a screen flag to the normal loop", + "Each iOS simulator capture resolves the CoreDevice display table, selects the sole lit panel when power identifies it, and normalizes density with that panel's own point scale. There is no screen flag. When panel power names no single lit panel, the capture names CoreDevice's primary panel, which may be dark and yield an all-black PNG. Check the pose with UIHinge.status before trusting that capture. With no primary panel either, capture fails with COMMAND_FAILED and reason capture-panel-unidentified rather than pick a panel by list order.", 'The two panels are different sizes (iPhone Duo: 466x678 points closed on the outer panel, 669x951 open on the inner). A pose change therefore invalidates every ref and coordinate. Re-snapshot after any pose change and never carry coordinates or refs across', 'Expect a fold to take 10-16 seconds: each hinge read is a five-second devicectl stream, and half-open waits for the hinge to stop moving. Re-snapshot after every fold', 'fold sends a private HID hinge event inside the selected simulator',