Commit Graph

60 Commits

Author SHA1 Message Date
Michał Pierzchała 7646a73b1b perf: avoid redundant physical iOS runner health check (#2215)
* perf: avoid redundant physical iOS runner health check

* test: isolate iOS runner prewarm coverage
2026-09-01 15:30:00 +02:00
Michał Pierzchała 81a9cb2b3c feat(daemon): establish interaction application facade (#2205)
* feat: establish interaction application facade (#2177)

* fix(daemon): narrow interaction runtime request seam
2026-09-01 10:46:54 +02:00
Michał Pierzchała b042045522 refactor(output): split presentation owners (#2202)
* refactor(output): split presentation owners

* fix(output): keep candidate rendering in surface owners
2026-09-01 07:50:25 +02:00
Michał Pierzchała 010f09bf0d refactor(daemon): move open lifecycle behind session facade (#2201) 2026-08-31 21:18:58 +02:00
Michał Pierzchała d330a679e2 fix(apple): switch to manual code signing when a provisioning profile is set (#2172)
* fix(apple): switch to manual code signing when a provisioning profile is set

CODE_SIGN_STYLE was hardcoded to Automatic even when
AGENT_DEVICE_IOS_PROVISIONING_PROFILE was configured, so xcodebuild rejected
the resulting PROVISIONING_PROFILE_SPECIFIER + CODE_SIGN_STYLE=Automatic
combination with "conflicting provisioning settings" on physical-device runs.

Fixes #2153

* fix: satisfy formatting and the test-file size ratchet

- oxfmt: wrap the long array literal in the new manual-signing test.
- runner-client.test.ts was already pinned at the 1000-line tripwire
  (1577 lines); adding a test grew it past the pin, which the ratchet
  test rejects by design ("extract instead of adding to a file over
  the tripwire"). Extract the pure runner-cache-metadata.ts build-
  settings tests (signing, bundle, performance, sandbox args) into a
  new runner-cache-metadata.test.ts, shrinking runner-client.test.ts
  to 1441 lines and lowering its pin to match.
2026-08-31 16:28:27 +02:00
Michał Pierzchała f513b1d4ae refactor: extract daemon replay behind one application facade (#2166)
* refactor: extract daemon replay behind application facade

* fix: address replay facade review findings

* test: close replay ownership import scan gap

* fix: tighten replay capability boundaries
2026-08-31 15:43:33 +02:00
Michał Pierzchała ed26b31c94 refactor: contract Apple platform surface (#2125)
* refactor: contract Apple platform surface

* refactor: use Apple plugin seam in tests

* test: ratchet snapshot handler size
2026-08-29 13:10:48 +02:00
Szymon Dziedzic d9677301f4 feat: add human takeover controls (#2078)
* feat: add human takeover controls

* fix: harden human takeover controls

* fix: align host XCTest selection count

* fix: address takeover readiness feedback

* fix: handle macos runner permission prompt in smoke tests

* fix: detect background macos permission dialog

* fix: dismiss inaccessible macos privacy sheet visually

* fix: map macos privacy prompt without accessibility

* refactor: own human-control holds in lease registry

* fix: cancel pending human takeover on disconnect
2026-08-29 13:09:54 +02:00
Michał Pierzchała 9abcd7fe03 refactor: move Apple platform family into package (#2118)
* refactor: move Apple platform family into package

* fix: preserve Apple facade sync contracts

* fix: complete Apple W4 rebase review fixes
2026-08-28 15:25:44 +02:00
Michał Pierzchała c7f42ccedc refactor: move Android family behind package exports (#2117)
* refactor: move Android family behind package exports

* fix: address Android W5 review feedback

* fix: update relocated routing fixture assertion
2026-08-28 13:02:28 +02:00
Michał Pierzchała af6f12e391 chore: adopt shared oxlint config (#2115)
* chore: adopt shared oxlint config

* fix: preserve project lint boundaries

* fix: remove redundant oxlint config
2026-08-28 11:42:58 +02:00
Michał Pierzchała 437465f37b ci(1874): declare the diagnose lane and read its iterations honestly (#2059)
The loop that #1874 is investigated with could not tell the truth about itself.
It classified every non-`passed` iteration as a stall, which after #2035 gave the
looped test an XCTSkipIf meant an environment flip would report a 100% stall
rate; it captured cadence only for failures, though an absorbed episode now
passes; and it read its logs with shell pipelines whose exit status means "did
this match", so an iteration that legitimately matched nothing killed the job
before it could be summarized.

scripts/diagnose-1874-iteration.ts reads one iteration: xcodebuild's own verdict,
the `type-all` duration, and the cadence worth keeping. A nonzero exit outranks a
green measured test — in `pair` mode the neighbour or the runner can fail while
the measured test passes — and a run that produced no verdict is named as ours
rather than counted as a stall. The workflow gains the #1781 lane declaration it
never had. Its kill criterion names #2080, which the loop can now serve rather
than merely claim to: the looped test is a dispatch input, so the fill route that
#2080 traces loops the same way. One test pins the contract the script cannot
check about itself — that the workflow hands it the status xcodebuild returned
rather than a literal.

Closes #1874.

Both filed symptoms are resolved. `smoke:form-input` was root-caused and fixed in
#2035: the fixture's placeholder was identical to the value every suite filled,
so `fill` could never be verified on the penalized route — deterministic, not a
flake, and only visible under load because that route is gated on a penalized
XCTest channel. The targeted XCTest is mitigated by the progress-aware commit
budget, with 200 consecutive green loop iterations across two dispatches.

The issue's remaining question — why the input pipeline throttles — is answered
by the second dispatch, and the premise was wrong: it does not. Posting 17
characters took 484 ms and the commit was observed on the first poll, inside an
iteration whose `type-all` measured 14334 ms. The ~12.6 s went to accessibility
round-trips before any character was posted, which is #1105's path, not the
input pipeline's.
2026-08-28 08:33:46 +02:00
Michał Pierzchała ddb415a2c7 refactor: sink package-closed src modules into existing packages (#2106)
* refactor: sink package-closed src modules into existing packages

Move closed modules into contracts, kernel, capture-kit, and ad-script,
and declare DaemonCommandDescriptor in core so R6/R9 can pin the remaining
provider-webdriver type cycle.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: keep contracts and capture-kit off generic sinks

Move interaction-outcome, snapshot warning rendering, and inventory ALS
behind focused owners, and plant R18/R70 domain-shape gates so they
cannot return as package export-map growth.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: drop moved implementation comments from owner modules

Names, types, and tests already carry those invariants; the relocated
files should not keep review-history or control-flow narration.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: drop the empty snapshot-quality layering zone

W1 moved the verdict into capture-kit and this PR moved warning rendering
into snapshot-presentation, so the ranked zone no longer has production files.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 07:56:18 +02:00
Michał Pierzchała e832325e87 refactor(substrate): split host mechanics into @agent-device/host-kit capability ports (#2088)
* refactor: split generic host mechanics into @agent-device/host-kit (#2082 W1)

The shared src/utils closure that blocked the platform-family moves lands
on declared owners: generic host mechanics form a new private
@agent-device/host-kit package between kernel and capture-kit, and
capture-kit keeps capture, snapshot, and recording behavior, depending on
host-kit for the mechanics it needs. tar-stream and yauzl move with the
archive code.

Every seam's exported subpaths are pinned in package-boundaries.test.ts,
the layering model ranks the new zone, R13's allow-list names it, and each
seam carries an exact eager-closure row. ADR-0019's substrate amendment
describes the layout.

Tests that mocked two of the moved modules separately became duplicate
same-seam vi.mock factories, where the second silently replaced the first;
those are merged, and the mocks that production code reaches past are
pinned at their injection points instead.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* refactor(host-kit): one narrow capability port per export

The four technical barrels (exec/fs/values/request) grouped by category
rather than by capability, so a consumer needing one mechanic evaluated
unrelated ones. Each export is now a single capability over the host
machine: command, process, diagnostics, retry, archive, file, request,
version. A port re-exports only what a consumer of that capability uses,
and every port carries its own eager-closure row.

Most of the old values barrel was never host mechanics. Pure record
readers, config-source values, result text, memoization, async scoping,
coordinate validation, and device-scope parsing touch no process, file, or
environment, so they join kernel's other primitives instead.

Closures fall accordingly: capture-kit's png-worker-client from 20 to 10,
png-resize from 28 to 18, session-teardown from 79 to 68, and the CLI from
386 to 380.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* chore: drop the migration inventories and trim the touched comments

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* docs: trim the touched host-kit and mutation-lane comments

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* docs: keep tool directives only in the touched files

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* docs: keep tool directives only across the touched tree

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* fix: point the Swift parity comment at the real TS twin and test

The W1 move rewrote this citation to packages/contracts/src/mobile-snapshot-semantics.ts,
which does not exist: the module went to capture-kit while isTapPointInsideViewport itself
went to packages/contracts/src/snapshot-visibility.ts. The TS test line was left pointing at
the pre-move path. Both now resolve.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* fix: repoint comment citations at the homes this refactor moved them to

The W1 move left ~20 comment citations pointing at src/utils/*.ts and
src/request/*.ts paths that no longer exist. Each now names the capability
port that owns the symbol, which survives further file moves:

  exec -> host-kit/command          host-process, owner-identity -> host-kit/process
  diagnostics -> host-kit/diagnostics   atomic-file, process-lock -> host-kit/file
  retry -> host-kit/retry           request progress/cancel -> host-kit/request
  version -> host-kit/version       ttl-memo, source-value, parsing, device-isolation,
                                    keyed-lock, success-text -> kernel subpaths

Comment-only; no closure, budget, or behavior change. ADR citations are left
as written, being dated records of the decision rather than live references.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-28 07:46:48 +02:00
Michał Pierzchała c794c11d7e refactor: close the daemon platform boundary (#2072)
* refactor: move runtime resource mechanics out of daemon

* refactor: move Apple resource access out of daemon

* chore: enforce the terminal daemon platform boundary
2026-08-27 13:11:01 +02:00
Nicolas Bataille ed8d5efb00 fix(daemon): address cwd-scoped sessions by their store key, not their public name (#2068)
* fix(daemon): address cwd-scoped sessions by their store key, not their public name

An implicitly cwd-scoped session is NAMED `default` and STORED under
`cwd:<hash>:default`. Three surfaces built caller-facing text from the name, so
each pointed at something that does not exist:

- `DEVICE_IN_USE` reported `session "default"` and its recovery hint said
  `agent-device close --session default`. `--session` marks the session
  explicit, which disables cwd scoping, so following the hint addressed a
  different, absent session: `SESSION_NOT_FOUND`, and the device stayed held —
  the residual half of #2031 that #2057's superseded-daemon reconciliation does
  not cover.
- `session list` reported `sessionStateDir` and `runnerLogPath` under
  `<state>/sessions/default`, a directory that is never created; the session's
  real artifacts sit in `<state>/sessions/cwd_<hash>_default`, which is what
  `open` already answers with.

`SessionStore.entries()` exposes the key alongside the record, and
`buildSessionRecoveryHint` takes the session's address explicitly. Call sites
that only hold the session the current request named pass its name — the address
there — so their text is unchanged, as are explicitly named sessions everywhere.

Live before/after on an iOS simulator: `open` (cwd-scoped default), then
`open --session qa` on the same device. Before, the hint's own `close --session
default` answered `SESSION_NOT_FOUND` and the retry failed again; after, the
hint names `cwd:8bea844ab16aa9b3:default`, that close releases the device, and
the retry opens.

Refs #2031, #1394

* fix(daemon): thread the resolved session address through every recovery producer

The store key a request resolves — `cwd:<hash>:default` for an implicit
session — was known upstream but dropped before the selector-conflict and
lock-conflict producers, which then named `SessionState.name`. Both emitted
`close --session default`, the unreachable recovery this PR fixes for
DEVICE_IN_USE: `--session` marks the session explicit, so that command
addresses a different session.

`SessionRef` ({ address, session }) now carries the pair, so a recovery
producer cannot be handed a record whose address was never resolved.
`buildSessionRecoveryHint`, `assertSessionSelectorMatches` and
`applyRequestLockPolicy` take it; `prepareLockedRequestBinding` and the
Maestro replay route build it from the store key they already hold.

Replaces the broad `SessionStore.entries()` with the store's own narrow
lookups — `lookup`, `findByDevice`, `listRefs` — so callers receive the
session with its address instead of enumerating raw key/record tuples.

`session list` reports `address` alongside `name`, through the client
contract and serializer, so its discovery output names a value `--session`
accepts.

Regressions run the production routes, not the helpers: router-level
device-in-use, selector-conflict, lock-policy-conflict and `session list`
cases open an implicit session and read the address back off the store, plus
a typed-Maestro selector-conflict case. Each was observed red against the
pre-fix behavior it pins.

Refs #2031, #1394.

* fix(daemon): doctor names same-device sessions by address; green the fallow gate

- sessionChecks enumerated same-device sessions by SessionState.name:
  the printed close --session default was the exact unreachable
  recovery this PR removes elsewhere, the name-based dedupe hid two
  cwd-scoped default sessions from each other, and the evidence
  sessionStateDir pointed at <state>/sessions/default, which never
  exists. Candidates now come from listRefs() and report addresses.
- findByDevice joins values/delete in .fallowrc.json usedClassMembers
  (same resolution false positive on this class; the call site is
  session-open-execution.ts).
- the scoped-paths test narrows its response once instead of nine
  optional-chaining hops, which tripped the CRAP gate.

* style: format .fallowrc.json

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-08-27 11:06:32 +02:00
Michał Pierzchała 71214e11da refactor(runtime): close residue execution units (#2054)
* refactor(runtime): close residue execution units

* refactor(runtime): address residue ownership review

* fix(runtime): preserve viewport diagnostic log path
2026-08-27 07:46:25 +02:00
Michał Pierzchała 9f16fc885c refactor: migrate perf to device runtime (#2061) 2026-08-26 20:41:19 +02:00
Michał Pierzchała 33a816966c perf(apple): uninstall stale runner bundles concurrently (#2058)
* perf(apple): start stale simulator runner bundle uninstalls concurrently

cleanupStaleSimulatorRunnerBundles awaited each simctl uninstall
sequentially while discarding the results (best-effort cleanup). Run the
per-bundle uninstalls under Promise.allSettled like the sibling disposal
paths, and pin the concurrent start with a deferred-promise test.

* test(apple): split stale-bundle cleanup coverage out of the pinned runner-session suite

runner-session.test.ts is over the test-size tripwire and its pin may only
shrink. Move the three stale-bundle cleanup tests (boot availability,
best-effort stall, concurrent start) into a sibling file named for the
domain question, carrying the same seam scaffolding.

* test(apple): lower runner-session suite pin to bank the stale-bundle extraction

* test(apple): format stale bundle coverage
2026-08-26 17:43:16 +02:00
Michał Pierzchała 72cae2bc72 refactor(apple): colocate the XCUITest runner client into packages/platform-apple (#2040) (#2050)
* refactor(apple): colocate the XCUITest runner client into packages/platform-apple (#2040)

Moves src/platforms/apple/core/runner/ (34 modules + apple-runner-platform.ts and
the 30 runner test suites) into packages/platform-apple/src/runner/ — Apple
mechanics live in the Apple package. Host capabilities (exec, diagnostics,
retry, process probes, locks, Apple tooling, physical-device control) enter
through the package-owned AppleRunnerHost port; the root composition module
src/platforms/apple/core/runner-client.ts constructs the client exactly once
and re-exposes the bound operations under their historical names.

R13 admits the transitional state deliberately: the family exports its root
façade plus exactly the enumerated ./runner, ./runner/client, and
./runner/test-host subpaths; the ./runner façade subpath is the recorded #1983
seam for unmigrated root consumers; ./runner/client has one composition root
and ./runner/test-host one vitest installer; the runner subtree may own its
cache files and sockets while raw process primitives stay banned. When #1983
completes, the subpaths and every subtree exemption are deleted and the family
returns to a single implementation-lazy façade export.

* docs(adr): model the runner subtree as a durable platform-owned facet

Review correction on #2050: the sunset story attributed the runner-consumer
migration to #1983, which owns snapshot/presentation vocabulary — not the
runner's daemon/root consumers — so that event cannot delete the ./runner
subpaths or the subtree exemptions. Reword ADR-0019, R13, and the gate
comments: the facet is the intended ownership model, its seam is enumerated
and pinned (exact export list, one client composition root, one test-host
installer, raw-process ban, eager-closure pins), and the seam narrows only
if a real runner-consumer migration retires the direct consumers. The
declaration mechanism stays apple-specific until another family needs a
mechanics facet. No behavior change; identifiers and comments only.
2026-08-26 15:53:01 +02:00
Michał Pierzchała 74a70f1764 refactor: remove next-major compatibility surfaces (#2046) 2026-08-26 14:14:15 +02:00
Michał Pierzchała c77bc40d48 refactor(daemon): Wave 6 — migrate clipboard, app-switcher, trigger-app-event, settings, alert, react-native and capabilities onto request-bound runtimes (R55–R63) (#2021)
* refactor(daemon): migrate clipboard onto request-bound runtimes (R55)

Wave 6 unit 1 of the ADR 0019 platform-free daemon migration (#1739).
`clipboard` leaves the legacy dispatch projection: admission is now the
action-selected `readClipboard`/`writeClipboard` fact the parsed subcommand
names, and the only execution is that one bound operation.

- new `@agent-device/contracts/clipboard-runtime` facet, riding the existing
  `Interactor` seam through `interactor-operation-binding.ts`; read and write
  are separate cells because a provider can genuinely expose one half only.
- every owner states its own cells: Apple gains a `system/` facts module
  (simulator or the macOS host, matching the retired
  `supportsHostOrSimulatorSurface` closure), Android admits every real kind,
  Linux the desktop device, and HarmonyOS/Vega/web refuse -- none ever carried
  a bucket. Limrun reuses the local Android interactor and refuses on iOS;
  WebDriver rides interactor reachability like `back`/`home`.
- retires the `core/dispatch.ts` clipboard arm and handler, the descriptor's
  capability bucket and `dispatch` leaf, and the Apple plugin's clipboard
  admission closure. `handlers/session.ts` loses its inline handler (and its
  last `dispatchCommand`/`requireCommandSupported` imports) to the new
  `handlers/session-clipboard.ts`.
- `bindLocalInteractorOperationSet` collapses the byte-identical local
  interaction bind list Android and Linux each held a copy of.

Cutover row R55 with its retirement, admission-member and single-bind claims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

* refactor(daemon): migrate app-switcher onto request-bound runtimes (R56)

Wave 6 unit 2 of the ADR 0019 platform-free daemon migration (#1739).
`app-switcher` leaves the legacy dispatch projection: admission is the owner's
`appSwitcher` fact and the only execution is that one bound operation, resolved
by the generic route alongside back/home/orientation/tv-remote.

- new `@agent-device/contracts/app-switcher-runtime` facet on the shared
  `Interactor` seam, bound through the interactor catalog.
- Apple states one springboard reading for `home` and `app-switcher` (parity:
  the retired `supportsAppAndDeviceLifecycle` closure gated both off the same
  per-AppleOS row, so macOS and watchOS refuse); Android admits every real kind;
  HarmonyOS admits both kinds, restating the retired overlay membership;
  Linux/Vega/web refuse. Limrun reuses the local Android interactor and refuses
  on iOS; WebDriver rides interactor reachability.
- retires the `core/dispatch.ts` arm, the capability bucket, the `dispatch`
  leaf, `HARMONYOS_SUPPORTED_COMMANDS` membership, the Apple plugin closure, and
  the now-readerless `appAndDeviceLifecycle` row in the per-AppleOS table.
- router tests that used `app-switcher` as their legacy-dispatch stand-in move
  onto bound operations; the typed-error `supportedOn` test moves to `perf`, the
  one command that keeps a capability-matrix row after this wave.

Cutover row R56 with its retirement, admission-member and single-bind claims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

* refactor(daemon): migrate trigger-app-event onto request-bound runtimes (R57)

Wave 6 unit 3 of the ADR 0019 platform-free daemon migration (#1739).
`trigger-app-event` leaves the legacy dispatch projection: admission is the
owner's `triggerAppEvent` fact and the only execution is that one bound
operation.

The split follows ADR 0019 §2 — a facet input names no command, request, or CLI
flag. The event name pattern, the payload size limit, and the per-platform
`AGENT_DEVICE_*_APP_EVENT_URL_TEMPLATE` are daemon policy and stay in
`core/app-events.ts`; what reaches the owner is a resolved URL to open. They
also stay downstream of admission, where the retired `dispatchCommand` ran them,
so an unsupported device still reports its unsupported cell rather than an
argument error.

- new `@agent-device/contracts/app-event-runtime` facet on the shared
  `Interactor` seam, bound through the interactor catalog.
- Apple admits every leaf with a constructible interactor (no closure ever gated
  this command beyond its bucket), Android every real kind, and
  Linux/HarmonyOS/Vega/web refuse. It is the one system leaf both Limrun legs
  serve, since each implements `open`; WebDriver rides interactor reachability.
- retires the `core/dispatch.ts` arm and handler, the capability bucket, the
  `dispatch` leaf, and the session route's last
  capability-gate-then-`dispatchCommand` thunk: every leaf on that route now
  supplies a bind-and-execute thunk.
- the end-to-end delivery tests keep their shell-level assertions and move onto
  the migrated composition.

Cutover row R57 with its retirement and single-bind claims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

* refactor(daemon): migrate settings onto request-bound runtimes and retire the legacy dispatcher (R58)

Wave 6 unit 4 of the ADR 0019 platform-free daemon migration (#1739). `settings`
was the last `DISPATCH_HANDLERS` arm, so this change closes the command and
retires the legacy command dispatcher whole.

- new `@agent-device/contracts/settings-runtime` facet on the shared
  `Interactor` seam, bound through the interactor catalog. What reaches the
  owner is its own settings vocabulary (setting, state, resolved app id, typed
  coordinates); the CLI parse, the macOS setting-name gate, the clear-app-state
  app-id check and the coordinate typing are daemon policy and stay daemon-side,
  downstream of admission where the retired leaf ran them.
- Apple shares clipboard's exact host-or-simulator reading (the retired
  admission intersected the `settings` bucket with the same
  `supportsHostOrSimulatorSurface` closure); Android admits every real kind;
  HarmonyOS matches its retired overlay membership; Linux/Vega/web refuse.
  Limrun splits Android-reuse / iOS-refusal like `app-switcher`; WebDriver
  refuses unconditionally, since its interactor declares settings unsupported.
- retires `dispatchCommand`, `dispatchWithInteractor`, `dispatchKnownCommand`,
  `DISPATCH_HANDLERS`, `listRegisteredDispatchCommandNames`, and the request
  router's `executeGenericPlatformCommand` fallback. `core/dispatch.ts` keeps
  only `dispatchGestureViewport`, whose last consumers are replay/test.

Retiring the dispatcher surfaced two callers broken since Wave 5 moved `press`
onto a bound runtime: react-native overlay dismissal and the opt-in interaction
no-change retry both called `dispatchCommand(device, 'press', …)`, which has
thrown `INVALID_ARGS: Unknown command: press` on main since R48. Both now run
the same bound `tapPoint` every other touch leaf uses. The retry declares its
own callback seam rather than importing runtime admission, so the policy stays
readable without the binding stack — and that inversion, plus the dispatcher's
retirement, drops the largest type-level import cycle from 25 files to 21.

Cutover row R58 with its retirement and single-bind claims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

* refactor(daemon): migrate alert, react-native and capabilities onto facts (R59/R61/R63)

Wave 6 units 5, 7 and 9 of the ADR 0019 platform-free daemon migration (#1739),
plus the residue reclassification the tracker asks for as an analysis task.

R59 `alert` — new `@agent-device/contracts/alert-runtime` facet with four
action-selected legs (`readAlert`, `awaitAlert`, `acceptAlert`, `dismissAlert`)
on the shared `Interactor` seam. The daemon route admits and binds exactly the
leg the parsed subcommand names, and the poll and retry windows move to the
owners with it: how long a transient sheet takes to appear, and how many times
to re-ask a runner that says it is not there yet, are family mechanics, not
request policy. `src/platforms/apple/alert.ts` now holds the Apple windows
verbatim (with the macOS-helper / XCTest-runner split), and Android's legs read
the same presented tree `snapshot` publishes, which is why their occlusion
reading still holds.

Apple's cell is the retired `supportsAlertSurface` closure restated as facts —
the host-or-simulator reading widened by physical iOS — and that closure was the
per-AppleOS capability table's last reader, so `src/platforms/apple/capabilities.ts`
goes with it.

R61 `react-native` — the command's device work moved onto a bound `tapPoint`
with R48; this retires the capability gate that still stood in front of it and
moves admission ahead of the observing capture, so an owner that cannot dismiss
an overlay refuses without first spending a snapshot on it. That exposed a real
defect: the request handler chain never forwarded the request's runtime bindings
to this route, so the dismissal leg had been reaching a missing gateway ever
since R48 — only the no-overlay-detected path returned early enough to hide it.
Fixed, with a chain-level regression test.

R63 `capabilities` — the projection now reads each command's own declared
`platformExecution` uses instead of a hand-written map plus a "no capability
bucket means supported everywhere" fallback. That fallback is what let a stopped
Android AVD advertise `snapshot press fill` it cannot run, and a Vega VVD
advertise every migrated command; both collapse to the fact-derived set here.
The command itself executes nothing on a device, so it declares `none`.

Residue: `batch`, `debug` and `events` reclassify to `none` — each reaches no
device and delegates nothing that does. `replay`/`test` keep their gesture
viewport and boot-diagnostics edges, `daemon`/`web` hold platform imports in
their own CLI modules, and `react-devtools` still injects device-runtime
`runtime`, so all five stay `legacy`.

Cutover rows R59 and R61 with their retirement and single-bind claims.
Descriptors: 32 legacy at the wave checkpoint, 9 now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

* fix(daemon): restore two settings/alert sequences the migration had shifted

Self-review of the Wave 6 diff against `origin/main` found two places where the
migrated routes were faithful in what they did but not in when:

- `settings` typed its location coordinates before expiring the ref frame, so a
  request that failed on a bad coordinate no longer expired it. The retired route
  expired the frame first, then emitted its diagnostic, then typed the
  coordinates inside the leaf. Same order again.
- `alert` narrowed a frontmost-app session to "no bundle" in the daemon, which
  also stripped the bundle from the XCTest runner leg. That narrowing was only
  ever the macOS helper's, and it already lives in `platforms/apple/alert.ts`;
  the runner leg gets `session.appBundleId` unconditionally again, pinned by a
  test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

* fix(daemon): address adversarial review of the Wave 6 cutovers

Three independent reviews (behavior parity, correctness, ADR 0019 conformance)
ran against the branch. What they found, and what changed:

Correctness

- The R48 retry seam was unreachable. `captureSnapshot` builds it from the
  request's runtime bindings, but no caller forwarded them, so every retry
  resolved to a skip. The `snapshot` route now threads `inspectFacts`/
  `bindDevice` through `createSnapshotRuntime` and the daemon snapshot backend
  down to the capture.
- A retry tap that rejected escaped the capture it was decorating and turned a
  plain `snapshot` into an error. It is caught and reported as a skip, matching
  what the seam's own contract already claimed.
- The attempt is spent before the device work again, as the retired route did,
  so an owner that fails mid-flight cannot be re-attempted from a full budget.
- `react-native dismiss-overlay` reached its required `tapPoint` through `?.`
  and answered `dismissed: true` when the operation was absent. It refuses.
- `factOwnedCapabilityAvailable` indexed the facts map unguarded, and treated an
  empty `required` as proof (`[].every` is vacuously true). Both fail closed.

ADR 0019 conformance

- §6 forbids a `none` descriptor from binding a device, and `capabilities` bound
  three times to answer `logs`/`network`/`record`. Every owner composes a
  binding's facts with the same function `inspectFacts` calls, so those probes
  read back values the single inspection already carries — at the cost of a
  device claim on a read-only query. They are gone, and with them the last three
  empty-`required` admission uses.
- §9 is one admission per handler; the retry tap re-admitted on every retry
  round. It memoizes per device.
- `installFamilyCapabilityAvailable` was scaffolding this wave was scheduled to
  retire: the general projection returns the same verdict for all four
  install-family commands. Deleted.

Leftovers the cutovers created

- `requireCommandSupported` lost its last production caller when R56 migrated
  `app-switcher`: every generic-route command is admitted from owner facts
  before the dispatcher runs. The dead arm, the function, and
  `commandUsesDeviceRuntimeExecution` are removed.
- `CommandDispatchFacet`, `descriptor.dispatch`, and `explain`'s `dispatch=`
  field described a dispatcher R58 deleted.
- `request-router-android-modal.test.ts` asserted on a `dispatchCommand` mock
  whose module export no longer exists, so three assertions were vacuous.
- `generic-route-runtime-completeness.test.ts` now exists — a comment claimed it
  did. It pins the routing table as total over the generic route.
- Comments and test names describing the retired dispatcher, the deleted AppleOS
  capability table, and a react-native regression that never shipped.

Also records two deliberate provider cell changes the migration made (physical
Apple `clipboard` admitted, provider `alert` refused) and the react-native
widening to Linux, web and HarmonyOS, and drops a scratch probe file that was
committed by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

* fix(apple): type the alert-absence retry instead of matching error prose

Review blocker 1 on #2021. The Apple alert legs decided retry and hint
eligibility by substring-matching error messages for "alert not found" / "no
alert", and `alert wait` swallowed *every* read failure. A dead runner, an
unreachable macOS helper or a canceled request was therefore spent as poll
budget and finally reported as `alert wait timed out`, hiding the real cause.

Both backends now state absence as typed evidence:

- The XCTest runner answers `ErrorPayload(code: "ALERT_NOT_FOUND", ...)`. It is
  diagnostic-only, so it stays `COMMAND_FAILED` on the wire and surfaces as
  `details.runnerErrorCode` — the same shape `RUNNER_BUSY` already used.
- The macOS helper adds `reason: "alert-not-found"` to its JSON error details,
  which the helper client already forwards verbatim.

`isAlertNotFoundError` reads only those two fields. `awaitAppleAlert` re-throws
anything that is not a typed absence instead of polling through it, and the
scoped-snapshot fallback hint attaches to typed absence alone.

The three tests the review asked for, plus coverage the daemon-altitude copies
could not express: a non-absence failure propagates immediately from `wait`; an
action does not retry a failure whose message merely reads like an absence; the
macOS helper's typed reason is retried like the runner's. The daemon-level
non-absence test moved to the family suite that owns this policy since R59,
lowering that file's size pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

* fix(runtime): admit clipboard and provider operations from what execution checks

Review blocker on #2021, reproduced on a Pixel 9 Pro XL / Android 36 emulator:
`capabilities` advertised `clipboard`, then `clipboard read` failed with
`UNSUPPORTED_OPERATION: Android shell clipboard read is not supported on this
device.` Admission and execution were consulting different authorities, which
ADR 0019 §2 forbids — a bound operation must already be admitted.

Android. `cmd clipboard` has no shell implementation on every build, and the
retired bucket admitted both halves on every real Android kind, leaving the leaf
to discover the refusal after the fact. Support is now a fact: the owner probes
once per device (cached for its lifetime — a build's shell command set cannot
change while the device is up) and states `owner-capability-missing` when adb
names the condition. The probe is definitive in one direction only: adb saying
so means unsupported, a probe that cannot run means unknown, and reporting
unknown as unsupported would hide a working clipboard behind a transport
hiccup. The predicate moves to `@agent-device/contracts/android-clipboard-support`
so admission and the leaf's own defense-in-depth check cannot drift apart.

Cost, stated plainly: the first facts inspection per device now spends one adb
round trip, including for requests that never touch the clipboard.

WebDriver. `webdriver-interactor.ts` refuses through `capabilitySupported`,
while fact generation admitted from interactor reachability alone — so a
provider configured with `capabilityOverrides: { 'clipboard.read': 'unsupported' }`
was admitted and then thrown out of. The declared capability map is now an input
to fact generation, and the refusal carries the map author's own note. Applied to
every operation with an unambiguous capability key, not just the two named in
review: the mechanism is identical and a half-applied fix would leave the same
defect for `back`/`home`/`orientation`/`tap`/`fill`/`type`/`scroll`. Behavior is
unchanged by default — every one of those is `supported` or `partial` in the base
map — so only an explicit override bites. `focus`, the gesture tiers and
`trigger-app-event` keep reachability: no capability key maps to them 1:1.

Also collapses the eight identical `*RetiredDispatchProjectionProof` wrappers in
the cutover table into one parameterized factory (second review point).

Parity tests: an Android build reporting either unsupported-shell phrasing, the
probe cache, an adb failure staying admitted, and a WebDriver override refused at
admission for each keyed operation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

* refactor(layering): split the Wave 6 cutover rows into a sibling module

Second review P2 on #2021. `runtime-command-cutover-table.ts` had reached 1,325
lines, past the point where one read covers it.

Wave 6's eight rows move to `runtime-command-cutover-table-wave6.ts` and are
spread back in, leaving the table at 1,095 lines. The split is by wave because
that is how these rows are retired: a wave's rows are deleted together once the
ADR declares its commands' migrations closed, and deleting a whole file is a
cleaner end than excising a run of literals from the middle of a larger one.

`retiredDispatchProjectionProof` moves to the shared extensions module, since
both tables now use it — the main table for `snapshot`/`diff`, the sibling for
its own eight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

* fix(android): never fabricate clipboard availability from a failed probe

Review blocker on #2021. The probe I added had a `catch { return true }`, then
cached that result by device id for the runtime owner's lifetime. A transient
adb offline or timeout therefore made `capabilities` advertise the clipboard on
a build with no clipboard shell — recreating the exact lie the fix was for, and
pinning it for the rest of the session. A test locked the behavior in.

Support is now a typed verdict with three states, because "we could not ask" is
not "it works": `supported | unsupported | probe-failed`. Only a definitive
answer is cached; `probe-failed` refuses conservatively with a hint saying
support could not be determined, and is deliberately not remembered, so the next
inspection asks again.

The same change repairs the ownership boundary. Turning raw adb stdout/stderr
into a verdict is Android tool knowledge, so it belongs to the Android owner, not
to shared vocabulary — `@agent-device/contracts/android-clipboard-support` now
carries the typed union alone. The parser returns to `src/platforms/android/adb.ts`
and runs in exactly one place, behind a new `AndroidToolHost.probeClipboardShellSupport`
that hands owners the verdict. That also settles which Android home owns it:
R13 lets only `src/platform-runtime.ts` import `@agent-device/platform-android`,
so a parser shared between the package and the root leaf cannot live in the
package either.

Tests now cover the failure path the previous ones locked the wrong way: a failed
probe refuses instead of admitting, its refusal says it could not determine
support rather than claiming the build lacks it, and it is not cached — a second
inspection re-probes and admits once the device answers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

* refactor(contracts): declare each interactor operation once

Second review P1 on #2021. `interactor-operation-catalog.ts` declared the same
operation set three times — a name tuple, a complete local binder map, and a
complete provider binder map — and each facet carried a mirrored
`bindLocal…Interactor`/`bindProvider…Interactor` pair whose only difference was
which interactor source to use and which label a refusal names.

There is now one row per operation, carrying its facts key, its provider refusal
label, and the facet's own executor. The local/provider split lives in the two
adapters, which differ by exactly the thing that differs: the interactor source.
Adding an operation is adding one row.

Deleted: the parallel tuple, both binder maps, 32 mirrored wrappers across ten
facet modules, and the per-facet `Local…`/`Provider…InteractorResolver` aliases
that existed only to be re-exported. Kept: every facet's typed executor, now
exported as its binding surface.

Net −563 production lines in `packages/contracts`.

Two consumers moved onto the catalog's public entry point rather than keeping a
private path to a single operation: the app-event delivery test and the provider
scenario fixture, whose two hand-bound keyboard legs are now whichever legs its
facts admit. Each facet's tests spell out the composition the retired wrappers
performed, so every assertion still exercises one executor reached through one
source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

* fix(android): let only a clean adb exit prove clipboard support

Third review P1 on #2021. The typed verdict landed one layer too high. The
adapter probe runs `adb shell cmd clipboard get text` with `allowFailure`, so a
non-zero exit comes back as an ordinary result rather than a throw — and the
only thing standing between that result and `supported` was the missing-shell
prose check. A device that had gone offline, was unauthorized, timed out, or
failed for any other reason produced none of that prose, so it fell through to
`supported` and was then cached by device id for the runtime owner's lifetime.
The `catch` I added guarded the one path adb almost never takes.

Each adb outcome now proves only what it can:

- `exitCode === 0` is the sole evidence of support, because it is the only
  result that shows the command ran.
- The recognized missing-shell prose is the sole evidence of absence, and is
  read before the exit code — adb reports that condition non-zero, so checking
  the code first would turn every honest `unsupported` into a refusal.
- Everything else — non-zero without that prose, and the transport throw — is
  `probe-failed`, which admission refuses and the cache does not remember.

The package tests mocked the typed verdict, so they sat downstream of the bug
and could not see it. The regression is therefore at the adapter, over the raw
adb result: four planted reds (offline, unauthorized, device-not-found, generic
failure) that all returned `supported` before this change, plus the two
definitive verdicts and the ordering case that keeps `unsupported` reachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

* fix(android): never read adb's refusal prose out of the clipboard's contents

Fourth review P1 on #2021, and a second instance of the same bug it names.

The previous fix read `isClipboardShellUnsupported(stdout, stderr)` before the
exit code. On a *successful* `cmd clipboard get text`, stdout is the clipboard's
contents — arbitrary user text. Anyone who had copied "unknown command" or "no
shell command implementation" (from a terminal, a bug report, this repo) had
their own working clipboard classified `unsupported`, and the runtime owner
cached that for its lifetime. Ordering prose ahead of the exit code to keep
`unsupported` reachable traded one wrong admission for another.

The exit code is decisive on its own when it is zero, so it goes first. Only a
call that failed can carry prose about the call itself, which makes the missing-
shell phrases meaningful on non-zero exits alone:

    if (result.exitCode === 0) return 'supported';
    return isClipboardShellUnsupported(...) ? 'unsupported' : 'probe-failed';

`isClipboardShellUnsupported` now states that precondition, because reading it
on a successful call is exactly the mistake to prevent.

The same defect was already shipped in the helper's other caller.
`runAndroidClipboardShellCommand` in `src/platforms/android/device-input-state.ts`
has checked the prose before the exit code since #1950, so `clipboard read` on a
clipboard holding either phrase threw `UNSUPPORTED_OPERATION` — telling the user
their device does not support a clipboard it had just read correctly. It is not
this wave's code and not reachable from the migration, but it is the same helper
misused the same way, and documenting a precondition while leaving a caller that
violates it invites the next regression. Repaired here, with the failure ordering
otherwise unchanged: a non-zero exit still reports missing-shell as
`UNSUPPORTED_OPERATION` and anything else as the adb result error.

Both repairs are pinned by regressions that fail against the code they replace:
four exit-0 cases at the adapter (verified red against the ordering this commit
removes), and three at `readAndroidClipboardWithAdb` (verified red against
`origin/main`) covering contents that look like a refusal, a genuine missing
command, and an unrelated non-zero failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

* fix(cli): bring the workflow help card back under its size budget

`Coverage (2)` has been red on `main` and on every PR branched from it since
#2020, which replaced three short Bootstrap lines with one longer line carrying
the new selection semantics. It updated the content matcher for that line but
not the size assertion beside it, so the card went to 9003 bytes against the
`< 9000` both `cli-help.test.ts` and `cli-help-topics.test.ts` enforce.

Nothing #2020 added is removed here — all of it is pinned by the matcher it
shipped, and it is the sentence agents most need. The bytes come back from a
clumsy repetition elsewhere in the card, where "settle" named itself twice in
one clause:

  ... only when you did not settle, settle reported not settled, or ...
  ... only when you did not settle, it reported not settled, or ...

which reads better short and puts the card at 8999.

That is one byte inside the budget, which is the real finding: the card has no
slack left, and the next sentence anyone adds re-opens this. The durable fix is
a base-owner call between raising the budget and moving a block down into its
sub-topic — the mechanism the card already uses, and which its own test
documents. Flagged on #2021 rather than decided here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

* test(cli): raise the workflow help-card budget to 9100

The card is a curated agent-facing reference, and #2020 grew it for a good
reason: the selection semantics it added are what an agent needs to predict
which device a bare `open` picks. Holding that content to a limit set before it
existed just moves the cost onto whoever writes the next sentence.

9100 is headroom, not a target. The previous commit left the card at 8999 of
9000 -- one byte -- which is not a state anyone should have to work in, and I
had already established there is no slack left to reclaim: no trailing
whitespace, and the only repeated runs are the deliberate column alignment in
the Escalate footer. Trimming further would have meant deleting content the
tests pin as load-bearing.

This is explicitly interim. The card is ~9KB of dense prose in one string, and
the real answer is to move a block down into its owning sub-topic -- the
mechanism the card already uses and its own test documents ("Deep content moved
out of the compact card, not deleted"). Raising the ceiling buys room to do that
deliberately instead of under a red CI.

Both enforcement sites move together, since they measure the same card through
different surfaces: `cli-help.test.ts` reads it through the CLI, and
`cli-help-topics.test.ts` through `usageForCommand`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 17:47:35 +02:00
Michał Pierzchała 52ac5da091 fix(daemon): stop branch-named daemon before replacement (#2015)
* fix(daemon): recognize branch-named daemon entries

* fix(daemon): require process identity for takeover

* fix(daemon): preserve shutdown cleanup identity
2026-08-25 13:42:34 +00:00
Michał Pierzchała 608bf7aa47 Harden the MCP surface: registry rug-pull fix, operator-only credentials/endpoints, device-shell argv gate, declared timeouts (#2023)
* chore(release): keep the version on main distinct from every published version

Registry scanners diff the repository's tool surface per version string, so a
released number left on main while main keeps changing is indistinguishable
from a republished ("rug-pull") version — two scans of the same version see
two different tool sets (AS-012).

- release:publish now runs release:mark-dev after npm publish, moving
  package.json and the synchronized server.json to the next patch with a
  -dev prerelease marker.
- release:prepare refuses to publish while the -dev marker is in place, so
  a forgotten version bump cannot ship a prerelease as latest.
- Mark the current tree 0.20.11-dev: main had been sitting on the published
  0.20.10 while the tool surface kept changing, which is the live finding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(mcp): remove credential inputs from the model-writable tool surface

Every MCP tool advertised daemonAuthToken (and the Metro tools bearerToken)
as a free-form string the model writes. The model both reads untrusted app UI
text and picks tool arguments, so on-screen text steering it to set a token
was a prompt-injection exfiltration path. Credentials are operator-owned:

- the keys are omitted from every advertised tool schema (MCP and AI SDK,
  which share listCommandTools()),
- an explicit value is refused with env-var guidance instead of being
  forwarded (the retired-field posture: refuse, never silently drop),
- operator-sourced values are untouched — env/config defaults still merge,
  and the daemon and Metro clients keep their AGENT_DEVICE_DAEMON_AUTH_TOKEN
  / AGENT_DEVICE_METRO_BEARER_TOKEN fallbacks. CLI flags are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(mcp): move operator endpoints and paths off the model-writable surface; declare timeouts

Follow-up to the credential removal: daemonBaseUrl and the Metro
proxyBaseUrl are the endpoints the env-resolved tokens are SENT to, so a
model-writable value redirects the operator's token to an arbitrary server —
same exfiltration path, one step removed. stateDir, cwd,
iosSimulatorDeviceSet, and the three iosXctest* paths select operator
infrastructure, never per-call work. All of them leave the advertised
MCP/AI-SDK tool schemas and are refused as explicit input with env/config
guidance; operator env/config defaults keep flowing exactly as before
(config-backed defaults still merge, and explicit input can no longer
override them). Dropping these shared properties also cuts tools/list
substantially.

Every tool description now also declares its enforced client timeout
envelope (90s default, 180s install, unbounded only for the streaming test
runner), sourced from the descriptor registry's timeout policy so the
declared number cannot drift from the enforced one (answers AS-011, which
read the undeclared envelope as "no timeout").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* feat(ci): inventory every dynamic value reaching a device shell

adb shell, adb exec-out, and hdc shell join their argv into one string the
device's sh evaluates, so any unquoted dynamic element is a potential argv
injection — the class of bug the audit found (and fixed) on input text and
cmd clipboard set text. Nothing enumerated the surface, so a new call site
could regress it silently.

scripts/shell-argv is an AST-based gate (oxc-parser, same as di-seams and
layering) keeping an exact inventory of every dynamic device-shell argv
element, keyed by (file, expression) with counts: 121 values today. A new
or grown entry fails CI until the author quotes it through shellQuoteIfNeeded
or records it with --update in the same PR, making "a new value now reaches
the device shell" a reviewable diff; a stale entry fails the other way so
the inventory always matches the code. Wired as the shell-argv gate in the
lint lane and registered in the check catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(shell-argv): satisfy the fallow audit without suppressions

The Compatibility & Provenance lane's fallow audit flagged the new gate:
main was an unused export (only the self-run guard consumed it) and four
functions sat over the complexity thresholds. Restructure instead of
suppressing: the AST walk dispatches through a composite-child-field table,
the argv detection is hoisted out of the visitor, drift reporting moves into
helpers, and main is no longer exported. Behavior is unchanged — the model
tests pass as written and the regenerated inventory is byte-identical
(121 values).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(test): close the port-reuse race in the unreachable-takeover test

Coverage (1) failed once in CI with the takeover notice missing while the
response still came from the fresh daemon — the exact signature of the
fresh fixture being handed the just-freed ephemeral port: the recorded
daemon becomes reachable and reusable (same version and signature), so the
takeover path is skipped. Bind the fresh fixture before acquiring and
freeing the unreachable port; with no bind after the close, the port can
never be reclaimed. Line-neutral so the size-ratchet pin holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(mcp): enforce the advertised tool schema at a real admission boundary

P1 (reported by the PR author): hiding operator keys from tools/list did not
stop them reaching the command route. The router forwards raw tools/call
arguments verbatim and resolveMcpConfigDefaults reads them as CLI flags, so an
unadvertised `config`/`remoteConfig` key loaded an arbitrary file whose
daemonBaseUrl/daemonAuthToken then flowed to runCommand — a model-writable
redirect to an attacker endpoint with the operator's token. Reproduced:
{config: <path>} on `snapshot` put both values into the command input.

Replace the per-key operator refusal with a deny-by-default admission boundary
in the shared executor (the one path both the MCP router and the AI SDK adapter
use): every raw input key must appear in the tool's advertised schema, else it
is rejected with guidance BEFORE config/env resolution. This closes the config
loaders, the operator keys, and any unknown key at once, and makes the
advertised additionalProperties:false contract actually enforced. Operator
env/config defaults still resolve — they never arrive as tool input.

Retired keys (maxSize) are admitted so the command's own reader still answers
with migration guidance; they're exposed as metadata.retiredInputKeys for that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* revert(ci): remove the check:shell-argv inventory gate

The PR author correctly flagged that this gate is an inventory, not a
security invariant: --update lets any site self-approve a raw value, and the
literal-first array heuristic is blind to indirect argv (a variable-built
subcommand, or an argv assembled in a helper). Reproduced: adb(['shell',
'input','text',text]) is inventoried, but const s='shell';
adb([s,'input','text',text]) yields no finding. Shipping it security-framed
gives false assurance.

Remove it. The sound fix — a typed device-shell execution boundary where a
raw string cannot reach adb/hdc shell without being quoted or explicitly
marked — is a ~188-site cross-package migration on device execution paths,
scoped to a dedicated follow-up PR. The two known-dangerous sites (input
text, cmd clipboard set text) already quote through shellQuoteIfNeeded on
main, so no regression. This keeps the PR focused on the MCP tool surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 15:19:07 +02:00
Michał Pierzchała 5b6feafe92 Extract snapshot policy from daemon to host-side facet (#1983) (#2014)
* refactor(snapshot): give the Wave 4 policies neutral host seams (#1983)

#2005 established the presentation ownership boundary and moved the iOS
presentation policies out of `src/daemon/`. It left the three remaining Wave 4
policies behind their existing daemon adapters. This closes that gap, so
`src/snapshot/` owns host-side snapshot policy generally rather than
presentation alone.

Freshness recovery: the window vocabulary, the Android staleness classification
and its thresholds, and the retry loop move to `src/snapshot/snapshot-freshness/`.
The loop is parameterized by a classifier and a retry schedule, so how long a
backend may lag behind a real transition is a policy input rather than a
constant the loop owns. `src/daemon/session-snapshot-freshness.ts` keeps only
what needs a session — reading and retiring the window on store-owned
`SessionState`, and choosing the comparison baseline from snapshot lineage — and
remains the declared R7 owner of `androidSnapshotFreshness`. The two call sites
#1739 named as the Wave 5 blockers, `selector-capture-runtime.ts` and
`deferred-interaction-outcome.ts`, now reach freshness through the seam.

Timeout evidence: whether a failure is the accessibility-timeout shape becomes a
policy in `src/snapshot/snapshot-timeout-policy.ts`. The published
`details.androidSnapshotTimeoutScreenshot` payload becomes vocabulary in
`@agent-device/contracts/snapshot-timeout-evidence`, built through constructors
so an assembly site cannot publish a fifth, undeclared arm. It gets its own
subpath rather than riding the shared capture facade, which keeps it out of the
CLI cold-start closure. Typed details, diagnostics and screenshot evidence are
unchanged.

Screenshot-overlay policy: which Android nodes earn an overlay ref, and what
rectangle an overlay covers, move to `src/snapshot/screenshot-overlay/`. The
daemon keeps approved artifact and ref assembly only — ranking, projection to
screenshot pixels, drawing and PNG IO.

The boundary test generalizes from the presentation subtree to the whole facet:
nothing under `src/snapshot/` may import `src/daemon/`. It gains a positive
control, because a filter that stopped matching would look identical to a
boundary being obeyed.

The residual call sites #1983 also named are audited and deliberately left in
place. `direct-ios-selector.ts` carries no presentation policy; its two pure
exports are selector derivation and ADR 0011 delegation-on-error, whose owner
would be the selector pipeline governed by R19, not this facet. ADR 0004 records
the finding so it does not have to be re-derived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLYhmt5ZNHQATG8T8ZFo7R

* refactor(snapshot): address adversarial review of the Wave 4 seams

Three findings from an adversarial pass over bc95d7f, all in the new seams.

`SnapshotFreshnessRetrySchedule.deadlineMs` was an absolute epoch instant named
almost identically to the duration constant `ANDROID_FRESHNESS_RETRY_DEADLINE_MS`
that feeds it. A backend binding the loop with the duration instead of
`markedAt + duration` type-checked, drove `remainingMs` hugely negative, and
silently ran zero retries with no annotation. Renamed to `retryUntilMs` — the
pre-refactor local's name — and the doc now says which one it is. The recovery
loop also gains direct tests it never had: the trustworthy, recovered and
still-suspicious paths, plus an already-expired deadline that pins the budget to
the action rather than to whenever the first capture returned, which is the shape
the mis-binding would have taken.

Two stale doc references from earlier drafts of the same commit: the timeout
assembly claimed its evidence shape lives in `@agent-device/contracts/capture`,
which is where it deliberately does NOT live — following that comment would
re-home the type into the shared facade and reintroduce the cold-start closure
cost the dedicated subpath exists to avoid. And the freshness window doc cited
`SnapshotFreshnessPolicy`, a type removed before commit for being unused; the
real seam is the loop's `classify` callback.

No production behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLYhmt5ZNHQATG8T8ZFo7R

* refactor(snapshot): key timeout evidence on a typed reason, budget retries by duration

Addresses the three review findings on the Wave 4 facet work.

1. The recovery loop accepted an absolute `retryUntilMs`, and its own comment
   admitted that passing a duration type-checks and silently disables retries.
   Documenting a footgun is not removing one. The schedule is now a duration
   budget and the loop derives the deadline from the window's `markedAt`
   itself, so there is no absolute instant a caller can get wrong. Two tests
   pin the invariant: a budget already spent before the loop starts runs the
   capture once, and the same budget retries or not depending only on how old
   the window is — a loop measuring from its own start would return the same
   count for both.

2. The timeout policy recognized failures from hint prose and helper message
   text. That is a message shape standing in for a decision, and extracting it
   into a named facet made it worse by promoting the sniffing to declared
   policy. The Android platform boundary now decides once and publishes the
   typed reason `accessibility-timeout`, joining the existing
   `ANDROID_CONTENT_RECOVERY_REASONS` taxonomy in the contract that already
   exists to stop producers and consumers growing separate ones. The facet
   reads that reason. The hint is derived from it rather than decided
   alongside it, so rewording prose can no longer change what a reader
   concludes. Coverage now runs producer to consumer: the platform tests assert
   that both timeout shapes publish the reason, that an ordinary helper failure
   does not, and that the real policy recognizes exactly what the real producer
   emits — the message-sniffing approvals are gone.

3. `SnapshotTimeoutEvidence` still permitted `annotated: true` with zero refs.
   The annotated arm now carries a non-empty tuple, so the contradiction is
   unconstructible rather than merely unconstructed, with a `@ts-expect-error`
   guard that fails the build if it ever becomes valid again.

The timeout tests moved out of `snapshot.test.ts` into a cohesive
`snapshot-capture-failure-reason.test.ts` rather than growing a file already
over the size tripwire; its pin ratchets down 1495 -> 1445.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLYhmt5ZNHQATG8T8ZFo7R

* refactor(snapshot): decide the capture-failure reason from machine values only

Addresses the two remaining typed-policy blockers on #2014.

P1. The previous commit moved the message sniff rather than removing it:
`androidCaptureFailureReasonOf` still ran `/timed out/i` over helper and
wrapper prose, and a regex over the wrapper message for exit 137. A producer
sniffing prose is the same defect as a consumer sniffing prose, one layer down.

The decision now happens at the deepest boundary that holds the evidence, from
machine-defined values only. `snapshot-capture-failure-reason.ts` maps the
helper's structured `errorType` field by exact equality against
`java.util.concurrent.TimeoutException` — the same constant
`isUiAutomationConnectionTimeoutResponse` already compares — and the SIGKILL
exit code 137, which the fallback constructor knows structurally instead of
re-deriving from the message it just wrote. The helper-result,
session-protocol, and killed-instrumentation constructors attach the reason;
every layer above rewraps it. Both regexes are deleted, and the only
`TimeoutException` string left on the path is that constant.

This tightens behavior deliberately: a helper reporting ok=false with
timeout-looking prose but some other `errorType` is no longer classified as a
timeout. Both directions are proved end to end against the real producer —
four rewordings of the helper message (including empty) keep the typed value,
and three timeout-looking messages under non-timeout error types produce no
value and are not recognized by the real policy.

P2. The evidence union stored `overlayRefCount` beside the refs, so
`{annotated: true, count: 0, refs: [ref]}` and arbitrary mismatches stayed
assignable. No arm stores a count now — it is derived from `overlayRefs`, the
one source of truth — and the arms that carry no refs have nothing to count,
which `overlayRefsAnnotated: false` already states. Two type regressions guard
it: the empty-annotated contradiction, and the reintroduction of a stored
count, both as `@ts-expect-error` so the build fails if either becomes valid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLYhmt5ZNHQATG8T8ZFo7R

* refactor(snapshot): retire the duplicate timeout classifier on the session path

`isUiAutomationConnectionTimeoutResponse` compared `helper.errorType` to
`java.util.concurrent.TimeoutException` on its own, so the session fallback
diagnostic decided "was this a UiAutomation timeout" a second time. I cited it
as precedent for the constant in the previous round without noticing that
leaving it standing is the drift it was cited against: one taxonomy, two
deciders. The session protocol already publishes the typed reason on exactly
these errors, so the diagnostic now reads it.

The regression is proved rather than assumed: with the protocol's
`androidCaptureFailureReason` attachment removed, the new session-path test
fails; with it restored, it passes. It rides the existing
`ui-automation-timeout` fixture, so it exercises the real socket response
shape rather than a hand-built error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLYhmt5ZNHQATG8T8ZFo7R

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 07:57:09 +02:00
Michał Pierzchała 50f460cce4 refactor(snapshot): establish presentation ownership boundary (#2005)
* refactor(snapshot): establish presentation ownership boundary

* docs: keep context glossary within budget

* fix(snapshot): address presentation boundary review

* test(snapshot): ratchet eager closure budgets
2026-08-24 20:36:33 +02:00
Michał Pierzchała 957a6727f8 fix(android): publish covered state from exact order evidence (#1981)
* fix(android): unify snapshot occlusion across API levels

* fix(android): preserve exact occlusion evidence

* fix(android): restore collective occlusion coverage

* fix(android): preserve snapshot evidence across consumers
2026-08-24 18:09:05 +02:00
Michał Pierzchała 759f175332 refactor: move Wave 5 touch commands to platform runtime (#1987)
* refactor: move touch commands to platform runtime

* fix: require direct selector touch binding

* fix: address touch runtime review

* fix: classify maestro direct click guarantee

* fix: distinguish Maestro direct selector dispatch
2026-08-24 14:54:44 +02:00
Michał Pierzchała 6dd7d406c0 perf(android): keep the automation helper warm across fill and scroll (#1974)
* perf(android): keep the automation helper warm across fill and scroll

Android permits one UiAutomation owner, so a command-scoped helper call stops
the instrumentation session when it finishes and the next call pays a fresh
`am instrument` start plus the UiAutomation connect wait. `fill` reads the live
hierarchy four times per attempt (the pre-action target read plus the 0/150/350
ms settling samples) and every one of those reads was command-scoped, so a
single fill ran four instrumentation lifecycles — seven on retry — and left
`scroll` without a session for its viewport read.

Thread the existing `daemon-session` scope, which app-backed sessions already
select for snapshots and whose release session teardown already owns, through
fill's captures and the gesture viewport read. The viewport read may now warm
the session so it and the gesture that follows share one instrumentation
instead of starting two. Snapshot capture and the viewport read build their
capture options through one path, because the session identity is derived from
them: two builders would restart each other's session instead of sharing it.

Session teardown also stops force-stopping the runtime once the helper both
acknowledged `quit` and its process exit was observed — that pair is the
release evidence. Forced, timed-out, and aborted teardowns still force-stop, so
a daemon that dies without sending `quit` cannot leave the helper squatting
UiAutomation.

The settling samples still capture separately; only the session is shared.

* fix(android): reset the helper runtime on content-failure retirement

REVIEW.md F1. Gating the teardown force-stop on confirmed release also
changed what `stopAndroidSnapshotHelperSession` returning `true` means to
its callers. `retireAndroidSnapshotHelperAfterContentFailure` reads that
return as "the session stop was the runtime reset" and skips
`resetAndroidSnapshotHelperRuntime` when it is true, so a helper whose
output failed content validation three times over a daemon session was
retired with no `am force-stop` at all, while the one-shot arm of the same
recovery still reset the runtime.

The session stop now takes `resetRuntime`, and the retirement passes it.
Content failure is a recovery path, not a clean release: the helper
answered with output we could not trust, so the next capture must meet a
runtime that was reset, and "it quit politely" is not a reason to leave a
suspect process owning it. Genuine session close still skips the round
trip, which is the optimization this branch exists for.

The pre-existing test "content failure retirement does not layer a second
reset over a persistent session stop" rested on a premise this branch had
already made false — stopping a live session was no longer the runtime
reset. It is renamed to "makes the session stop reset the runtime instead
of layering a second one" and asserts the requirement it now depends on,
so its title states what it enforces. The reviewer's probe returns as a
regression test in `snapshot.test.ts`.

REVIEW.md F2. `observeAndroidSnapshotHelperProcessExit` returns an
observation that knows whether the end it saw is release evidence: the
process must have been alive when the teardown started watching and must
then exit with code 0 and no terminating signal. A signal, a non-zero
code, and a host child that was already gone before `quit` was sent all
mean the transport died — and in the pre-died case the acknowledgement is
positive evidence that the device-side helper OUTLIVED its host through
the open forward. None of them confirm release now. What is left is
declared at the gate: host-side exit codes are only as strong as adb's
exit forwarding, and a device without shell protocol v2 can report 0 for
an instrumentation that did not finish.

REVIEW.md F3. `&& graceful.exited` survived deletion against all 423
tests. Two named tests now fail without it, both cheap because the
stricter evidence above makes acknowledged-but-not-released reachable
without waiting out the 11 s graceful-exit timeout.

Gates: `pnpm vitest run src/platforms/android` (44 files, 423 tests) and
with `src/core/interactors` (426 tests), `pnpm typecheck`, `pnpm lint`,
`pnpm format`, `pnpm check:fallow --base origin/main` (24 changed files,
clean). No emulator is attached in this worktree, so the teardown-path
force-stop counts were not re-observed live.

* test: lower the snapshot.test.ts ratchet pin to 1,495 (PR #1974 CI)

This branch extracted the helper-session, retirement, and touch-helper cases
out of src/platforms/android/__tests__/snapshot.test.ts into sibling files,
taking it from 1,658 to 1,495 lines. The test-file size ratchet fails an
un-banked shrink ("lower its pin in this PR so the ratchet keeps the gain"),
so bank it. The pin only moves down; nothing grew into it.

* refactor(android): own helper-session lifecycle and text input in their own modules

PR #1974 review, blocker 1. Both files the branch grew were already at the
extraction threshold, so the behavior landed on top of a boundary that
should have moved first.

`snapshot-helper-session.ts` (563) carried two concepts: who owns the
device's UiAutomation, and what runs over that ownership. Ownership — the
live-session registry, the enable gate, session identity, start, reuse, and
retirement — moves to `snapshot-helper-session-lifecycle.ts`. What is left
is 170 lines of snapshot capture and touch commands that acquire through it
and never reach the registry themselves; the touch path reads ownership
through `getLiveAndroidSnapshotHelperSession` instead of the map. The stale
re-export block for the retirement symbols is gone with its last importer.

`input-actions.ts` (508, pre-branch 501) sheds every text-entry path to
`text-input.ts`: provider injection, the test IME, the adb-shell writer, and
the fill orchestration this branch changed. What is left is 172 lines of
pointer, key, and gesture actions — below the pre-branch length.

Tests follow their owners. `snapshot-helper-session.test.ts` splits around
the same seam into a lifecycle file (start, reuse, identity, teardown,
quarantine) and a capture file (budgets, cancellation, fallback), and its
session fake becomes a named export of the sibling fixture module, which
drops the duplicated process double. `input-actions-fill.test.ts` and
`input-actions-test-ime.test.ts` are renamed to `text-input-*`, and the
`typeAndroid`/`fillAndroid` cases in `input-actions.test.ts` move to a new
`text-input.test.ts`.

Blocker 2. The teardown skipped `am force-stop` on an acknowledged quit plus
a clean host exit, and its own comment admitted the hole: adb without shell
protocol v2 exits 0 whenever the connection closed cleanly, including for
instrumentation that never finished, so that exit status was never proof the
device released UiAutomation. A residual-risk paragraph is not an invariant.

`adb-shell-protocol.ts` asks the transport instead. `adb features` lists
only the features both ends negotiated, so `shell_v2` there is positive
proof that a host `adb shell` child's exit status is the device command's.
The skip now requires it; an unsupported transport, a failed probe, and an
adb too old to know `features` all keep the device-side stop. The probe is
host-side and cached per device, so it costs one query per device rather
than one per teardown, and it clears with the session registry. Quarantine
keys on the same proven-release signal, so an unproven quit whose stop also
failed now reports unknown ownership instead of trusting the quit.

Red first: a transport without `shell_v2` and an unanswerable probe both
skipped the stop against the pre-fix gate, and the cache fence saw no probe
at all.

Rebased onto origin/main, which had moved the fill files.
2026-08-23 17:40:49 +02:00
Michał Pierzchała 443bbd0cb8 fix(mutation): move the repo size ratchet where the lane cannot reach it (#1977)
Stryker runs the suite from a sandbox copy under `.tmp/stryker/`, so a test that
asserts about the repository checkout itself — its files on disk, or its git
history — reads a repository that does not exist.

`test-file-size-ratchet.test.ts` is such a gate, and it fails there for two
independent reasons: `disableTypeChecks` (Stryker's default) prepends
`// @ts-nocheck` to every copied file, so all 26 pinned files read one line
longer than they are; and the sandbox has no `origin/main`, so the gate's
history-backed half cannot resolve its merge-base. Fixing either leaves the
other. Its own `.tmp` skip entry cannot help: that is matched relative to
`REPO_ROOT`, which inside the sandbox *is* the sandbox.

Move it to `scripts/__tests__/` and include it explicitly in `unit-core`, the
address the repo already uses for maintained gates that are not `src` tests.
`KERNEL_TEST_FILE_RE` admits only root/package `src` tests, and its comment
already names `scripts/__tests__` as unreachable by construction — so the gate
leaves every mutation lane by virtue of where it lives, with no classifier to
recognise it and nothing to keep in sync.

This replaces the source-text scanner of the previous revision, which was the
wrong boundary: it sniffed for a single-quoted `walk-files` import or the string
`origin/main`, so a behavioral test could match and be silently excluded while an
equivalent repo gate using double quotes, another walker, or another base ref
would be missed. The scanner, its test, and its justifying comment are all gone.

`REPO_ROOT` and the walked roots are unchanged — both addresses are two levels
below the repo root, and `TEST_ROOTS` already included `scripts`, so the gate
measures exactly what it did before.

The one new assertion pins the invariant this now depends on: `isKernelTestFile`
accepts root/package `src` tests and rejects `scripts/__tests__`. Widening that
pattern would silently pull the gate back into every lane.

Verified with `pnpm mutation:run --modules kernel-errors`: scope 804 -> 803 test
files, dry run clean, lane `pass` at stage complete, score 74.8%
(187 killed / 63 survived / 250) — unchanged. `pnpm mutation:test` 39/39,
`pnpm check:layering` 181/181, `typecheck`, `lint`, `format` clean.
`stryker.config.json` is untouched, so scores stay comparable.

Unblocks #1964, whose two mutation checks fail on main's tip without its code.
2026-08-23 07:39:49 +02:00
Michał Pierzchała cb65d6ca1f refactor(tests): replace the test-utils barrel with direct module imports (#1956)
* refactor(tests): replace the test-utils barrel with direct module imports

The barrel re-exported 13 modules, so every importer evaluated all of them
(store-factory alone drags 16 daemon session-store files; property-arbitraries
drags fast-check). Importing the backing modules directly cuts the unit
suite's aggregate eager module evaluations from 153,401 to 144,344 (-5.9%),
measured with the eager-import-closure walker. Deleting the barrel makes the
tax unrepresentable instead of pinning it with a guard test.

* docs(testing): point fixture guidance at the test-utils modules, not the deleted barrel

* test: extract replay session fixture
2026-08-22 12:21:35 +02:00
Michał Pierzchała 5676d5ff8a refactor(apple): drop dead runner code and collapse duplicated helpers (#1936)
* refactor(apple): drop dead runner code and collapse duplicated helpers

Removes declarations with no consumers (findScopeElement, interactiveTypes,
two unused PresentedNode convenience inits) and collapses copy-pasted logic:
DataPayload now relies on the synthesized memberwise init, TvRemoteButton is
String-raw-valued, point-hit sorting shares smallestElementFirst, command-id
trim-or-nil lives once on RunnerCommandJournal, scroll/desktopScroll share
direction and durationMs validators, and the seven inline NSError refusals use
unsupportedOperationError. elementTypeName reads a table pinned by the
visibility-fold parity test.

The packager now skips files whose unit-test blocks were their whole body, so
10 test-only files stop shipping (and stop compiling on user machines) as
empty translation units.

Packaged Swift: 432.3 kB -> 427.8 kB; 56 files instead of 66. Net -144 lines.

* test(apple): pin skeleton-file exclusion in the packaging guard

The strip fixture always kept runtime content, so reverting the
skeleton-skip branch left every guard green. The new fixture's whole body is
unit-test blocks; the packaged path must be absent while a non-skeleton
sibling still ships. Observed red with the skip branch disabled before
re-enabling it.

* refactor(apple): tighten runner cleanup boundaries
2026-08-21 16:42:17 +02:00
Michał Pierzchała 30de1597d3 ci: attribute native package size and trim Apple runner (#1934)
* ci: attribute npm package size by shipped component

* refactor: modularize size reporting and trim Apple runner

* ci: preserve size reporter modules across base checkout
2026-08-21 13:46:53 +02:00
Michał Pierzchała 40e4b0dd3e docs(agents): restore and enforce progressive disclosure (#1888)
* docs(agents): restore and enforce progressive disclosure

* test(maestro): pin typed selector fallback signal

* docs(agents): address progressive disclosure review

* docs(agents): restore orphaned traps and close guidance-gate bypasses

- AGENTS.md: skills carry a minimal start/routing card; command semantics
  stay in versioned CLI help (the skills contract enumerates two skills by
  hand, so prose retains ownership for the rest)
- testing.md: restore the two local-only XCTest snags CI never hits
  (unsigned-bundle policy refusal signature + first-run automation permission)
- scripts/gate/routing.ts: record GitHub's 300-changed-file path-filter limit
  at the paths-ignore assertion it bounds
- agent-guidance-contract.test.ts: recurse docs/agents so nested guidance
  cannot evade the byte budgets while the gate stays green
2026-08-20 16:58:26 +02:00
Michał Pierzchała 250e30a578 test(bench): falsification fixtures for oracles + typed runner outcomes (#1893)
* test(bench): falsification fixtures for oracles + typed runner outcomes

Two deterministic PR-time quality gates for the help-conformance bench
(the repo's single non-gating small-model planning oracle):

- Every EXPECTATION_SCORERS entry in help-conformance-case-checks.mjs
  now has a falsification fixture (a minimal passing witness plus at
  least one known-bad counterexample, and a metamorphic variant where
  useful) in the new help-conformance-expectation-fixtures.ts, run
  through the real validatePlanCommands/scoreExpectations pipeline.
  help-conformance-expectation-falsification.test.ts is the "what
  enumerates N" completeness gate: a new named expectation with no
  fixture fails it. Counterexamples cover swallowed lifecycle command
  prefixes, unsupported flags/selectors, pseudo refs, shell operators,
  and invalid positional ordering.

- help-conformance-runner-output.mjs now returns a discriminated
  RunnerOutcome ({kind:'success',commands}|{kind:'runner-error',
  message,reason}) instead of a raw-string success inference. Only a
  'success' outcome ever reaches validatePlanCommands/scoreExpectations
  in runCase, so a runner-error result can no longer also carry
  model-validation checks, and an all-runner-error aggregate now
  reports passRate: null (rendered as "N/A") instead of "0/0 (0%)".

Fixes #1481

* refactor(bench): dedupe RunnerOutcome construction, drop leftover narrowing

Thermo-nuclear pass over 4b2df0a38's diff:

- help-conformance-bench.mjs's runOutcome() catch block was hand-building
  the exact {kind:'runner-error', raw, message, reason} shape that
  runner-output.mjs's private runnerError() helper already constructs for
  its own two error paths. Export it as runnerErrorOutcome so the
  discriminated union has exactly one constructor for its error variant,
  reused by both error sources instead of duplicated.
- runCase's two return branches repeated the same
  {runner, caseId, trial, outputPath} fields; pulled into a shared `base`
  object.
- Reverted bench.test.ts's rateLimitedOutcome block: it had an explicit
  `: RunnerOutcome` annotation and an if/throw narrowing guard, added only
  to give fallow's dead-code checker a "real consumer" of the type before
  the actual fix (adding the .d.mts to .fallowrc.json's ignorePatterns,
  matching the existing sample-outputs.d.mts precedent) was found. That
  workaround is now unnecessary scaffolding — replaced with the same
  flat assert.deepEqual style the surrounding assertions already use.
2026-08-20 12:52:42 +02:00
Michał Pierzchała 735ab7672a refactor(daemon): one capture-input builder and one admit-then-bind step (#1876)
Behaviour-neutral. No descriptor changes platform execution, the cutover table
is untouched, and no contract surface is added.

- buildRuntimeCaptureInput moves to its own module so every request-bound
  capture consumer builds CaptureSnapshotInput one way.
- The admit-then-bind sequence in the snapshot/diff resolver becomes one named
  step, ready for the selector units' second caller.
- handlers/find.ts splits into focused target-capture and match-resolution
  concepts (600 -> 346 lines); behaviour unchanged.

Co-authored-by: agent <agent@local>
2026-08-19 17:34:21 +02:00
Michał Pierzchała d07b837621 test: classify the runner XCTests — pure decisions to a macOS host lane, simulator semantics gated os(iOS) (#1781 A7) (#1861)
Every declared AgentDeviceRunnerUITests method now belongs to a lane, and
the #if guard is the classification: AGENT_DEVICE_RUNNER_UNIT_TESTS alone
means a pure runner decision (runs on the macOS host on every PR — ci.yml's
existing compile job now executes the bundle it builds), '&& os(iOS)' means
runner/XCTest semantics (simulator lanes only). check:xctest-selection
evaluates the guards per platform, derives each lane's reach, and fails on
a flagged identifier that is undeclared or uncompiled on that lane, on a
declared test no lane reaches (found the two tvOS-only tests, dark since
birth — widened to os(tvOS) || os(macOS)), and on testCommand reaching any
lane. The host and nightly lanes assert executed == derived reach, so a
missing -D flag or a guard that compiles a file out reads red, not as a
smaller green. One duplicate test deleted (sparse-verdict assertions folded
into its twin).
2026-08-19 13:59:45 +02:00
Michał Pierzchała f03c0309a1 fix: derive iOS transition snapshots from visible presentation (#1831)
* fix: project iOS transition semantics

* fix: derive iOS transition semantics from visible state

* fix: preserve iOS presentation context for scoped snapshots

* fix: confirm broad iOS transition settlement

* ci: run coordinate input regression on pull requests

* test: mock migrated snapshot capture seam

* fix: confirm transitions across snapshot backends

* fix: arm transition confirmation after first capture

* fix: settle against immutable action baseline
2026-08-18 17:53:23 +02:00
Michał Pierzchała 6a8beb653e feat(mcp): compact server instructions in both eras + MCP-only help tool (#1839)
* feat(mcp): compact server instructions in both eras + MCP-only help tool (#1833)

MCP-only clients got no workflow guidance: server/discover carried two
sentences, legacy initialize carried nothing, and the CLI guides
(agent-device --help, help <topic>) were unreachable over MCP.

- MCP_SERVER_INSTRUCTIONS: one MCP-phrased workflow card (<2 KB, the
  Claude Code truncation limit) returned by server/discover and legacy
  initialize alike.
- help tool, router-owned (not a command descriptor): no topic -> the CLI
  decision card; topic -> agent-device help <topic|command> text, prefixed
  with the one-line CLI->tool-property mapping; unknown topic -> isError
  listing the topics. listCommandTools() stays descriptor-only for the AI
  SDK; the router composes descriptors + help.
- Move src/cli/parser/cli-help{,-overview}.ts to src/cli-schema/ so
  src/mcp (rank 3) can import the renderers without a layering back-edge
  into src/cli (rank 6).

* fix(mcp): name terminal-only commands in help guides; colocate cli-help tests with their sources

- The MCP guide preamble claimed every `agent-device <command>` line is a
  tool of that name; `help web` tells the reader to run `web setup` /
  `web doctor` and no `web` tool exists. The preamble now lists the exact
  CLI-only set (listCliCommandNames minus listMcpExposedCommandNames) —
  derived, not scanned out of prose where `device`/`web` are ordinary
  words. Regression: help web names `web` as terminal-only, and the listed
  set equals the registry difference.
- cli-help-*.test.ts move from src/cli/parser/__tests__ to src/cli-schema/
  to mirror the moved sources.

* perf(mcp): tighten the guide card, tool description, and preamble

Instructions card 1572 -> 1378 bytes (paid every session), tool
description and preamble trimmed, HELP_TOOL built once as a const.
Bundle delta vs main 3189 -> 2715 bytes; the remainder is the guide text
itself, which the bundle carried in no MCP-phrased form before.
2026-08-18 17:48:36 +02:00
Michał Pierzchała 142d156338 ci(ios): run the full XCTest suite nightly and check the PR test list (#1781 A7) (#1789)
* ci(ios): run the full XCTest suite nightly and check the PR test list (#1781 A7)

* fix(ci): skip the runner server entry point in the nightly and validate both test flags

* docs(ci): restate the nightly lane cost and timeout honestly

* docs(ci): stop quoting XCTest counts that drift between commits

* ci(ios): tighten the nightly timeout to the measured suite duration
2026-08-18 12:00:43 +02:00
Michał Pierzchała 801734d433 feat(ai-sdk): add agent-device/ai-sdk tool set and document the MCP zero-code path (#1804)
* feat(ai-sdk): add agent-device/ai-sdk tool set and document the MCP zero-code path

Adds `createAgentDeviceTools()` under a new `agent-device/ai-sdk` subpath,
built from the same command registry the MCP server uses so both stay in
lockstep without a hand-maintained tool list. Introduces a `frameworkTier`
descriptor facet ('core' | 'extended') so the factory can default to a
curated perceive/act loop instead of handing a model dozens of tools.

`ai` is wired as an optional peer dependency, imported lazily inside the
factory rather than at module scope, so importing the subpath itself never
requires `ai` to be installed - only calling it does. The package's own
publishing gate (scripts/lib/shipped-imports.ts) is extended to recognize
peerDependencies as a valid resolution source, since this is the first
optional peer this package has shipped.

Also restructures the AI SDK doc around three tiers (zero-code via
@ai-sdk/mcp, the new typed tool set, hand-written tools) and fixes a stale
`needsApproval` reference in favor of the current `toolApproval` API.

* fix(layering): classify src/ai-sdk as a rank-4 zone

The layering guard requires every src/<folder>/ to be explicitly ranked or
unranked; the new src/ai-sdk/ subpath (added in the prior commit) was left
unclassified, failing CI's Layering Guard job. It sits at the same tier as
client/compat/daemon-server/metro/remote/sdk - a public integration surface
consuming mcp (3) and core (2), imported by nothing else in the tree.

* fix(ci): cover, exempt, and pack the new ai-sdk subpath

Fixes the remaining CI failures on the ai-sdk subpath commit:

- Coverage: src/ai-sdk/index.ts had no dedicated unit test (only manual/
  integration verification), so changed-line coverage sat at 6.9% against
  the 70% gate. Adds src/ai-sdk/__tests__/index.test.ts (core vs 'all' tool
  filtering, session/platform pinning and schema hiding, error
  normalization, toolApproval passthrough) with createCommandToolExecutor
  and createAgentDeviceClient mocked the same way command-tools.test.ts
  does, plus a dedicated missing-peer-dependency.test.ts that mocks `ai`
  itself to throw, isolated to its own file so it doesn't affect the other
  tests' use of the real, installed `ai` package. Changed-line coverage is
  now 29/29 (100%).
- Fallow Code Quality: src/ai-sdk/index.ts and examples/sdk/ai-sdk-tools.ts
  are entry points with no in-repo importer (reached only via package.json
  exports / run directly), and the new subpath's exports are unused
  internally by design - both need the same treatment src/sdk/*.ts and its
  examples already have in .fallowrc.json.
- Integration Tests: test/integration/installed-package-metro.test.ts and
  src/__tests__/package-exports.test.ts each hand-list every published
  subpath and smoke-check it from a real packed install; added ./ai-sdk to
  both so the new subpath is actually exercised, not just silently passing.

* fix(ai-sdk): hide MCP transport/config fields from the model too

createAgentDeviceTools() only removed session and mcpOutputFormat from tool
schemas. stateDir was still model-visible and reached the shared executor
as client configuration, letting a tool call redirect into a different
daemon state directory - defeating the "one pinned session" guarantee the
factory exists to provide. includeCost and responseLevel are MCP
tool-config knobs in the same category, irrelevant to this adapter.

Widens the hidden-field set to session/stateDir/mcpOutputFormat/
includeCost/responseLevel, and now strips them from the runtime input
inside execute() too (not just the schema), so the guarantee holds even if
a caller bypasses schema validation. The schema-properties filter and the
input filter now share one omitHidden() helper instead of two near-
duplicate implementations.

Addresses the P1 review comment on #1804.
2026-08-18 11:57:34 +02:00
Michał Pierzchała 04613ae8d3 ci: keep Bundle Size job green on transient GitHub comment failures (#1795)
* ci: keep Bundle Size job green on transient GitHub comment failures

The size measurement and job summary had already succeeded on PR #1789
(run 32050847506) when the PR comment write got a 503 during a GitHub
incident and failed the whole lane.

--post-comment now retries 5xx / 429 / network errors (4 attempts,
1s/2s/4s backoff) on both the list and write calls. If it still fails,
it prints a ::warning::, appends a note to $GITHUB_STEP_SUMMARY, and
exits 0. Other 4xx (bad token, missing permissions) stay fatal.

* refactor: split GitHub response classification to satisfy fallow complexity gate

* fix: reconcile uncertain comment creates instead of re-POSTing; add regressions

Retry now wraps the whole list -> write cycle rather than each request, so a
create whose response was lost (network error / 5xx) is re-listed on the next
attempt and turned into a PATCH of the marker comment instead of a duplicate
POST. Splits the retry/classify helpers under the fallow complexity gate.

Adds scripts/__tests__/size-report-post-comment.test.ts (unit-core): spawns the
real script against a stubbed fetch and pins uncertain-create reconciliation,
transient exhaustion (warn + exit 0), and fatal 4xx (nonzero, no retry).
SIZE_REPORT_RETRY_BASE_MS lets the tests skip real backoff.
2026-08-18 11:35:36 +02:00
Michał Pierzchała c7565cb1f8 refactor(snapshot): clean snapshot ownership (#1754)
* refactor(snapshot): clean snapshot ownership

* fix(snapshot): address ownership review feedback
2026-08-12 13:44:21 +02:00
Michał Pierzchała 7f5dbd2e50 chore: drive unused production exports to zero (#1743)
* chore: drive unused production exports to zero

`pnpm check:production-exports` has been failing on main with 21 findings.
Each was investigated rather than blanket-suppressed; they split three ways.

Genuinely dead, deleted:
- `androidDeviceForSerial` (android/adb.ts) had zero references anywhere,
  tests included.
- `streamAndroidLogcatWithAdb` (android/logcat.ts) had no production consumer
  and only a guard-clause test; its `captureAndroidLogcatWithAdb` sibling is
  the published SDK surface. Removed with its options type and test.

Test-only aliases over live siblings, collapsed:
- app-log-resource-store re-exposed four bound store methods; production used
  only `resolvePath`, tests used the other three. The sibling
  screen-recording-resource-store exports just the store, so this now matches:
  one export, all consumers call `appLogResourceStore.x`.
- device-claims re-exported `canonicalLocalDeviceKey` for a single test, while
  production imports it from device-claim-paths directly. Dropped the
  re-export and pointed the test at the canonical module.

Real consumers the analysis cannot see, exempted with the reason:
- The nine remaining `src/cli/commands/*Command` handlers are reached only
  through `dedicatedCliCommandHandlerLoaders`, the dynamic import() table in
  router.ts. `deviceCommand` already carried an inline suppression for exactly
  this; replaced it with one config entry naming the table that enumerates all
  ten, matching the existing daemon route-handler entry.
- `resolveVitestMaxWorkers` (vitest.config.ts), `DEVICE_CLAIM_IN_USE_SAMPLE`
  (bench sample producers) and the capture-kit `createAppLogLiveHandle` facade
  export joined the existing entries that already record their exact shape.
- `**/*.fixtures.ts` is now an ignorePattern: all 15 build doubles for
  co-located tests, several import `vi`, and none is imported by production
  source. Pattern-matching them as test infrastructure also keeps this class
  of finding from recurring.

Gate now reports zero. Unit suite, layering, fallow audit, MCP metadata,
build, bundle-owner and package checks all pass.

* chore: scope the fixture exemption to unused exports

Review feedback on #1743: `ignorePatterns` removes a file from every Fallow
mode and rule, but the false positive here is only production-unused-exports.
Moved *.fixtures.ts to an ignoreExports entry so fixtures stay inside health,
dead-code and cycle analysis. Kept `exports: ["*"]` rather than today's three
symbols because the property is per-file — no fixture module has a production
consumer — so a new fixture symbol should not reopen the finding.

check:production-exports still reports zero, and a full `fallow --summary`
returns identical totals (2 dead-code / 4 dupes / 123 health) with and without
the change, so nothing was newly surfaced or newly hidden.

* test(fallow): prove fixture policy scope
2026-08-12 11:17:33 +02:00
Michał Pierzchała f5d9789764 feat: enforce local device claims and reconcile stale owners (#1735)
* feat: enforce local device claims

* fix: address device claim review feedback

* fix: persist canonical daemon claim state directory
2026-08-11 16:18:45 +02:00
Michał Pierzchała b0d4b40467 chore: stop publishing skills to npm (#1730)
* chore: stop publishing skills to npm

* fix: align simulator skill startup

* docs: align agent setup with open-first workflow
2026-08-11 11:46:33 +02:00
Michał Pierzchała c2c81549d9 feat: add simulator verification skills (#1716)
* feat: add simulator verification skills

* chore: simplify simulator skills

* docs: refine simulator skill guidance

* test: guard simulator skill workflows

* style: format simulator skill contract test
2026-08-10 21:15:53 +02:00
Michał Pierzchała cdc754e6ed perf: speed up iOS agent recovery and streamline CLI guidance (#1700)
* Avoid interactive children in parent taps

* docs: streamline no-skill CLI help

* perf: recover faster from sparse iOS trees

* fix: preserve selector context for blocked parent taps

* fix: preserve coordinate text-entry focus

* fix: preserve thin parent touch targets

* fix: fail closed for unscoped iOS typing

* test: isolate replay lock fixture

* test: share node integration process
2026-08-10 20:43:01 +02:00
Michał Pierzchała 18291ba8e2 perf: collapse app-driving startup turns (#1693)
* perf: collapse app-driving startup turns

* fix: align foreground open guidance
2026-08-09 10:10:11 +02:00
Michał Pierzchała 9c25bc66f4 docs(cli): advertise open --foreground and snapshot --actions in the workflow card (#1682)
* docs(cli): advertise open --foreground and snapshot --actions in the workflow card

open --foreground (#1670/#1671) and snapshot -i --actions (#1665) shipped
with no mention in the compact `help workflow` card, so a planning model
never discovers either. Add one terse line each: the foreground fast-path
in Bootstrap, and the merged-element custom-action guidance in Validation
and evidence. Stays under the 9,000-byte compact-card budget (8493 -> 8908
bytes).

Adds two help-conformance bench cases per the repo's changed-guidance rule:
foreground-attach-single-sim (correct plan starts with `open --foreground`
in an unambiguous single-sim scenario, fail-closed alternative forbidden)
and merged-card-actions-not-directly-invokable (a merged Bluesky-style feed
card's actions list is evidence, not a selector). Both use a real pinned
sample rebuilt through the production snapshot renderer.

* fix(scripts): accept flag order in the foreground-attach conformance matcher

Flag order after `open` isn't semantically meaningful (`open --platform ios
--foreground` is exactly as correct as `open --foreground --platform ios`),
but startsWithForegroundOpen required --foreground to be the literal next
token after `open`. Rescoring the completed repeat=3 bench report shows this
docked codex:gpt-5.4-mini on all 3 trials even though its plan was
config-order noise, not a real deviation -- the no-positional/no-device
guarantee already comes from the forbidden checks. Loosened to require
--foreground anywhere on the open line; foreground-attach-single-sim now
scores 54/54 across both runners.

* fix: close workflow help conformance gaps
2026-08-08 10:35:12 +02:00