171 Commits

Author SHA1 Message Date
Michał Pierzchała 6db1a4270f fix(android): report the clip an Android recording really captured (#2566)
* fix(android): report the clip an Android recording really captured

Android `screenrecord` encodes a frame only when the screen changes, so a window that ends on an
unchanged screen returns a video far shorter than the requested duration, and `record stop` had
nothing to say about it: the reported `durationMs` is host wall clock from `record start` until the
export finished, which is not the length of the file that was just pulled.

`record stop` now measures the pulled MP4 timelines and reports them as `capturedDurationMs`, and
warns with the clip length against the window when the video is two or more seconds short. The
window is measured on the device's own elapsed clock, read before the stop signal and at launch,
because host wall clock drifts against the clock the encoder timestamps frames with; an unreadable
clock or a chunk that answers no duration costs the caller the claim, never the recording.

Measuring the timeline needed an ISO-BMFF box walk, which now lives in
`@agent-device/capture-kit/recording-mp4-duration` and replaces the private top-level atom scan
that MP4 container detection was doing. Stop replay through daemon recovery carries the field too,
so a completion read back from the session resource reports the same numbers it did live.

* chore(gates): enumerate the capture-kit MP4 subpaths the layering scan holds

`@agent-device/capture-kit/recording-mp4-duration` and its fixture sibling are new declared package
subpaths, so the boundary enumeration that holds every exported workspace subpath has to name them
for the layering scan to accept the Android recorder's read of a pulled clip's timeline.

* fix(capture-kit): evaluate the MP4 box scan only when a file is validated

The Coverage job's ADR-0019 eager-closure probe failed: `recording/video.ts` evaluated 25 modules on
import where the merge-base evaluated 24, because the MP4 container gate statically imported the box
walk it now shares with the clip-duration read, and `recording/overlay.ts` grew by the same module.
An entry the merge-base already carries gets no growth budget, so the edge moves behind a
function-scoped `await import`: the scan is something recording completion asks for, and importing
this module for `waitForStableFile` or WebM detection should not evaluate a box walker.

The alternative the probe offered -- hosting the walker in a module both growing entries already
evaluate -- would have put an ISO-BMFF walk in `swift-cache.ts` or `video-webm.ts`, or made the
duration read import the Swift validator machinery that sits behind `video.ts`.

* refactor(android): bracket the recording window with the host clock

Human review of #2566: the device-clock read defended against host-vs-encoder drift that does not
matter at this threshold. Quartz drifts by tens of ppm, so a 30-minute chunked recording moves the
window under 100 ms against a 2s warning threshold, while the read cost a transport operation, its
own probe budget, and two adb round trips per recording. The window is now the host elapsed time
between `Date.now()` immediately before the recorder launches and `Date.now()` immediately before the
stop signal, so the contract change and the extra device I/O are gone, and the one case where the
clocks genuinely diverge -- a host that sleeps mid-recording -- reports a shorter window and misses
the warning rather than inventing one.

The surviving clock arithmetic is one subtraction, so it lives in the window module that already owns
that concern instead of a module of its own. A stop recovered through daemon recovery now passes the
manifest's own start instant, which is the first host timestamp the recording ever had, so a recovered
stop gets the same comparison a live stop gets.
2026-09-14 13:54:30 +02:00
Michał Pierzchała 04052fdcd2 fix(android): retire recording evidence stranded by a re-adopted device id (#2564)
* fix(android): retire recording evidence stranded by a re-adopted device id

Android `record start` refused forever with "native recovery evidence already
exists" once an emulator was re-adopted under a new serial: the device-side
marker names the device identity that wrote it, reconciliation retired evidence
only when that identity matched, and the leftover classified as neither
recoverable nor retireable — an `UNKNOWN` internal error whose hint asked for a
bug report, while `record stop` owned nothing to clear.

Start reconciliation now retires evidence whose recording is terminal or whose
device identity the transport can no longer address, and only after every
artifact it names is proven released: a committed recorder through process
inspect, an uncommitted pending artifact against the recorders running on the
device. A recorder still writing is never deleted; the refusal names the writer
in `details.writer`. Remaining refusals — unreadable evidence, the other
transport mode, and an open recording this identity owns — are typed errors
carrying the marker path and the command that clears it.

Closes #2550

* fix(android): keep an unreadable recorder off the delete path

The writer probe filtered candidate processes down to the ones it could prove
were recorders, so a caller that read an empty list as "nothing writes this
path" removed an artifact from under a recorder whose /proc entries could not
be read, along with the marker that named it. A process table it could not read
at all threw a bare Error, which reached callers as an unclassified failure.

The transport answers a writer search with `clear`, `found`, or `uncertain`.
Only `clear` proves an artifact is free: start retirement refuses with
`native_recording_recorder_unproven` and owned cleanup keeps the evidence
pending, so both wait for a conclusive answer instead of deleting on doubt.

* fix(android): keep a mixed writer scan inconclusive

The writer search answered `found` as soon as it identified one recorder, which
hid the candidates it could not read. Start reconciliation already refuses a
found writer, but owned cleanup stops only the recorders it was handed and then
removes the artifact and marker — deleting under an unreadable recorder still
writing that same path.

A search now reports the writers it identified together with whether every
candidate was read. Cleanup requires both halves: an identified recorder does
not prove the others are gone, so an inconclusive scan retains the evidence
before anything is signalled or removed.

* test(android): record retirement side effects through one evidence rig

21 reconciliation scenarios each rebuilt the same marker reader and the same
recording transport stubs. One rig holds the marker and the ordered side-effect
log, so a test names only the probe outcome it is about.

* test(android): keep legacy reconciliation scenarios as they were

The scenarios that already covered retirement were rewritten into a shared rig,
which spent most of this PR's churn budget on moving lines around. They read
from the device marker again as before; only the recorder-state table names the
outcome each state now produces, and the stale row that expected evidence naming
another device identity to be kept is gone, since retiring it is this fix.
New scenarios use the rig.

* test(android): table-drive the inconclusive writer scans

Each inconclusive writer-scan scenario rebuilt the same scoped adb fake and the
same cleanup transport. The transport cases now differ only in which candidate
reads fail, and the owned-cleanup cases differ only in the scan they return, so
both run from one table against the same assertions.
2026-09-14 11:52:56 +02:00
Michał Pierzchała 15644b6f0d feat(devices): answer when a device's current boot began (#2575) 2026-09-14 11:30:53 +02:00
Michał Pierzchała d77eff452d refactor(daemon): cut the last edges into the CLI schema layer (#2543) (#2562)
* refactor(command-registry): own command defaults; cut the daemon's CLI-schema edges

The daemon resolved routes through the command registry but still reached two symbols
across the layer boundary — recordedFlagKeys and applyCommandDefaults — both imported
from src/cli-schema/command-schema.ts, and the apps filter default was declared a second
time in the apps facet.

The registry is now the single source of command defaults: a COMMAND_DEFAULTS table typed
on CommandFlags plus applyCommandDefaults sit in registry.ts, where every other command
default (the defaultValue descriptors) is already declared. The CLI parser and the daemon
request scope both import it from @agent-device/command-registry/registry, the one module
already in both callers' eager closure. Registry and CLI closure are unchanged: the table
adds a literal, not a new import, so no entry grows. The apps facet default and
CommandSchema.defaults go away; DEFAULT_APPS_FILTER collapses into resolveAppsFilter's
provider fallback and stops being a public export.

session-action-recorder reads the recorded-flag vocabulary straight from
@agent-device/command-registry/flag-registry, and removing that import also drops
command-schema's now-unconsumed recordedFlagKeys re-export.

Part of #2545 / #2543.

* chore(gates): forbid the daemon importing the CLI schema layer

R10 (scripts/layering/daemon-modularity) now rejects any daemon import of src/cli-schema/,
value or type. The last two value edges into command-schema.ts are gone, so a total boundary
is available, and an explicit rule beats a rank that would let the next daemon module reach
the CLI schema layer again.

Part of #2545 / #2543.
2026-09-14 11:27:22 +02:00
Michał Pierzchała 5cf6414fd2 fix(contracts): state the scroll keyboard clip once for every platform (#2537)
* fix(contracts): state the scroll keyboard clip once for every platform

* fix(apple): surface the scroll keyboard clip as evidence and a typed reason

* refactor(contracts): state the scroll keyboard refusal details once and keep the runner's message

The Apple scroll owner rebuilt the refusal per command, discarding the
runner's measured message and carrying an unmeasured variant of the
error builder for it. The shared reason and hint are now one frozen
object in scroll-gesture; the Apple owner adds it to the runner's own
error (matched on the typed runner code, transport details kept), and
the error builder takes a plain measured occlusion, which only Android
produces in-process. The help text names the behaviour in one clause;
the hint carries the recovery at the moment it matters.
2026-09-13 13:55:37 +02:00
Michał Pierzchała 7a25a02f6d fix(record): replay the finished export from a retried record stop (#2534)
* fix(record): replay the finished export from a retried record stop

A remote record stop can outlive its client window while the daemon is still exporting. The finished manifest was then read as no active recording, and its metadata carried no client output path, so the caller had no way to collect the file. A repeated record stop now serves the completed export and says so in the timeout hint.

* refactor(record): declare each completion codec once

A mapped codec per completion property drives encoding and decoding from one declaration, and the declaration fails to typecheck if a property has no codec.

* fix(record): keep manifest encoding inside the session resource module

Session teardown reaches the recording resource definition while it loads, and that eager closure takes no new module. Writing a completion is property reads only, so the field map and writers now live with the resource definition; reading one back needs the recording vocabulary and stays behind the stop path.

* test(client): give the request timeout hint its own mirror file

The hint assertions had outgrown the aggregate client test past its size ratchet; they mirror src/daemon-client/daemon-client-timeout.ts, so they move rather than shrink.

* refactor(record): store the finished stop response under one manifest key

The manifest now holds the completion as the one object record stop returned, so a replay cannot lose a field between an encoder and a decoder, and the reader lives with the stop path that needs it. Recovery still refuses a response whose served path or caller-side paths are not whole.

* refactor(record): reuse the scope guard and record path their owners declare

A stored scope is checked by isRecordingScope next to the vocabulary it validates, and a session's durable record path comes from the factory that names it instead of being re-derived at each read.

* refactor(client): hand the timed-out request to its timeout handler

Command, session, and action all come from the same request, so they are passed as one request instead of three more positional arguments.

* refactor(client): name the timed-out request fields the handler reads

The client timeout handler stays off the daemon request shape: R10 daemon-modularity holds external importers of that module at the merge-base count, so the fields arrive as named properties instead of the request object.

* refactor(client): read a timed-out request's fields once for both transports

A socket timeout and an HTTP timeout described the same request with two copies of the same mapping.
2026-09-13 13:54:19 +02:00
Michał Pierzchała 8e8eeb2ced refactor(snapshot): drop the bridge truncation dimension; stop promising --scope for depth caps (#2511)
The dimension and limit the bridge adapter inferred for a cut capture had
no renderer and one consumer, the comparison-identity string, where the
kind alone gives the same comparability. The runner never produced them.

The depth-cap warning suggested --scope to read deeper content; on iOS
scope narrows presentation and acquisition stays scope-blind.
2026-09-12 20:52:49 +02:00
Michał Pierzchała eefe37b51e docs(adr-0011): narrow the offscreen rescue comment to the per-request surface policy (#2465)
* docs(adr-0011): note the offscreen live rescue reads the tree's surface

The offscreen guarantee's live rescue runs the runner's direct querySelector, which consumes the single activeApp that prepareActiveCommandContext resolved for the snapshot tree as well, including an in-place system surface. Record that the tree and the rescue read the same surface so the cell's rationale stays accurate.

Closes #2452

* docs(adr-0011): narrow the offscreen rescue comment to the per-request surface policy

The snapshot capture and the live rescue are separate runner requests, and each
calls prepareActiveCommandContext on its own, so only the selection policy is
shared, not the surface instant. State that, and say plainly that a surface
appearing or dismissing between the two requests is not detected.

Closes #2452

* docs(adr-0011): limit the shared surface seam to runner-routed captures

An eligible iOS simulator snapshot is served by the host AX bridge
(packages/platform-apple/src/snapshot-route.ts), which never reaches the
runner's prepareActiveCommandContext. The rescue's direct querySelector always
does, so the two requests share that surface policy only when the capture is
runner-routed too - which is the case #2448 forces for the system surface.
Keep the unchanged statement that no surface identity crosses the two requests.
2026-09-12 07:54:50 +02:00
Michał Pierzchała d80fb35ec3 fix(browserstack): carry the full provider-allocation config over the lease_allocate envelope (#2494) (#2495)
* fix(browserstack): forward provider session metadata over the lease envelope

`--provider-project`, `--provider-build`, and `--provider-session-name` are
stored in the connection profile and reach the daemon on the line transport
(which forwards the whole request), but the compact JSON-RPC lease envelope
carried only `providerApp`. Over HTTP/remote daemons the daemon's lease-lifecycle
provider therefore saw no session-naming metadata and created BrowserStack
sessions as "Untitled Project" / "Untitled Build" with an empty name.

Read the four provider session-metadata flags through one shared projection
(`readLeaseAllocateProviderMetadata`) used by both the client's
`buildHttpRpcPayload` and the daemon's `toLeaseDaemonRequest`, so the transports
agree and the producer and consumer cannot drop a sibling again.

Closes #2494

* chore(gates): pin lease_allocate wire digests for the forwarded provider metadata (#2494)

* test(daemon-http): move the lease provider-metadata check into its own file

The provider-scenario daemon-http-server test is over the 1000-line tripwire
and may not grow; the lease_allocate metadata assertion now lives in
daemon-http-lease-allocate.test.ts, mirroring the lease projection in
http-server.ts.

* fix(browserstack): carry the full provider-allocation config over the lease envelope

The lease envelope named only the session-label fields, so a fresh remote
allocation still failed in prepareSession before the names could take effect:
device selection (platform, device), providerOsVersion, and the configured
device-feature/AWS knobs were dropped on the HTTP transport, while the line
transport forwards the whole request for free.

readLeaseAllocateProviderMetadata becomes readLeaseAllocateProviderFlags and
projects the full set the lease-lifecycle provider reads, pinned exhaustive
against CloudProviderProfileFields so a new field cannot silently miss it. The
producer and consumer already share this reader, so both transports agree.

Covered end to end in cloud-webdriver-lease-http.test.ts: the exact client
envelope is driven through a real daemon HTTP server into the real BrowserStack
prepareSession, asserting the capabilities that reach the hub.

* chore(gates): re-pin lease_allocate wire digests for the broadened projection (#2494)
2026-09-11 18:21:37 +02:00
Michał Pierzchała 0feb4e26a0 feat(ios): drive ASWebAuthenticationSession sign-in sheets in place (#2438) (#2448)
* 0.21.1

* feat(ios): drive ASWebAuthenticationSession sign-in sheets in place (#2438)

iOS apps that sign in via ASWebAuthenticationSession present the identity
provider in com.apple.SafariViewService, out of the app's process. Two facts,
both verified live on the iOS 26.2 Simulator, made these flows unautomatable:
activating or launching the host cancels the auth session, and the host AX
bridge cannot see the sheet because the app stays the AX primaryApp.

Serve and drive the sheet in place. A closed registry names the host (shared by
the TypeScript and Swift sides under a parity test); the runner reads and drives
it without activation and never adopts it as the session target; and the
Simulator route detects a running host with a cheap device-scoped ps probe and
takes the runner path, since the bridge would serve the occluded app tree as if
healthy. open refuses to launch a registered host, and captures carry a
system-surface disclosure.

Presence is foreground state, not tree content: a torn-down host serves a richer
tree than a live one, so content heuristics cannot tell them apart. The
never-activate guard is what keeps the foreground predicate sound, which also
makes the stale-tree failure mode unrepresentable for this flow.

Closes #2438

* chore(gates): register contracts/ios-system-surface in the export snapshot

* fix(ios): close the system-surface correctness gaps from review

Presence probe: absence and probe failure are no longer reported as "no
surface". The probe returns present/absent/unknown and the route takes the
runner for anything but a proven absent, so a sheet opened between two captures,
or a probe that cannot answer, can no longer fall through to a bridge capture
that would answer confidently from the occluded app tree. Only a positive
observation is memoized. The probe now matches with pgrep and reads only a
matched pid's environment, which is ~3x cheaper than the previous full
process-environment dump and stops copying every process's environment.

Open guard: the refusal moved to every resolved-host launch and terminate, so
the URL, deep-link and launch-args branches that returned before the old check
can no longer launch the host. Terminating a host is refused too, since that
cancels the presented session just as launching it does.

Comparison: the surface identity now reaches SnapshotState, and tap-failure
corroboration refuses outright when a baseline and a post-action capture
disagree about it, instead of letting app and sheet captures meet in legacy
same-presentation matching. Selector routes disclose an iOS system surface
through the shared disclosure seam rather than reading only the Android field.

The contracts import in the launch path is deferred so the app-lifecycle
facade's eager closure stays flat, and the runner's comment prose is trimmed
because apple/runner ships to npm as uncompiled source.

* fix(ios): route the system-surface probe through the Apple tool provider

The probe shelled out with runCmd, so every eligible capture spawned a real
process even in provider-backed tests that stub the Apple tool seam — 17 real
spawns in one scenario file, which is both wasted work and added latency on
timing-sensitive settle paths. It now goes through runAppleToolCommand like the
sibling ps probe, so a stubbed provider answers instead of spawning.

* fix(ios): disclose a skipped bridge when the surface probe cannot answer

Routing an unprovable probe to the runner is right, but the early return also
skipped runFallback, so the response lost its warning and kept an identity that
could still be compared against a bridge publication. An unknown probe now falls
back through the same disclosed path as a bridge failure, with its own reason.

* fix(ios): keep surface identity through comparison, find, and probe scope

A ps read that carries no SIMULATOR_UDID at all was reported as absence, so an
unreadable or truncated environment could route a live sheet to the occluded app
tree. Only a scope naming a different device is a real negative now; a missing
one stays unknown.

The shared post-gesture comparison token used comparisonKey or the backend
alone, so an app capture and a sheet capture — both XCTest — compared equal and
a sheet appearing or dismissing read as a stable surface. The token now carries
the surface, which covers stabilization, verify and settle through the one path
they share.

Mutating find rebuilt its capture without iosSystemSurfaceBundleId, so the
shared disclosure helper could not report the sheet on either outcome. It is
preserved now.

Each fix has a regression that fails without it.

* fix(ios): keep surface identity in verify and settle comparisons

`--verify` compared node digests and `--settle` diffed node-only baselines, so an app
baseline and an in-place system-surface capture (a web sign-in sheet) were treated as one
presentation: a meaningless changed verdict, and a whole-surface replacement presented as an
in-surface diff with refs.

The pre-action baseline now travels with the surface its capture described, from the resolution
and the session frame through to the settled capture, and one module owns the comparison for
both routes. Across a surface change no same-surface claim is made: evidence reports the
transition instead of a digest comparison, the settled diff and its refs are withheld, and both
payloads disclose the transition.

* refactor(test): move the cross-surface settle tests onto their source mirror

The #2438 cross-surface cases were appended to `settle.test.ts`, taking it over the
test-file size ratchet (2528 lines, 2359 at the merge-base). They assert the
comparison `post-action-surface.ts` owns, so they move to that module's mirror test
file, and the device double plus the trees both files drive move to a sibling
fixtures module under `__tests__/` rather than being duplicated.

Pure move: every test and every assertion is unchanged, and `settle.test.ts` is back
under its merge-base length.

* test(daemon): cover the cross-surface settle refusal on the generic route

`scroll --settle` and `back --settle` plumb the baseline's surface identity
through `baselineSurfaceBundleId`, but nothing asserted it: the generic route
had zero coverage of the #2438 refusal, so a regression there would have been
silent while the element-targeted route stayed green.

Assert the same contract the targeted route guarantees, in both directions and
for both commands: no diff is attached across an app/sheet boundary — therefore
no tail and no `refsGeneration` — the transition is disclosed, and the settle
observation still reports its own verdict alongside that disclosure.

Each direction falsifies a different half of the plumbing, so both are needed:
dropping the baseline's surface identity fails only the sheet-to-app tests (an
app baseline has no surface id to lose), and dropping the settled capture's
fails only the app-to-sheet tests. No production change: the plumbing was
correct, only untested.

* refactor(ios): inline the single-caller surface disclosure wrapper

iosSystemSurfaceDisclosure() only mapped provenance-or-nothing onto the shared
constant for one caller, so the caller now reads the constant directly and the
wrapper is gone. Its test becomes a test of the transition disclosure, which is
the function that still earns its place (the "sheet is gone" sentence).

readAppleSnapshotResult also called readSystemSurfaceProvenance twice inside one
spread; it is bound to a local and read once.

* docs(adr): state that a presented surface outranks a requested bundle id

prepareActiveCommandContext checks for a presented system surface before it
resolves or activates command.appBundleId, so a command naming a different app is
still served the sheet. That is intended, but the code does not read that way;
the amendment now says it plainly.

* refactor(ios): carry the system surface in the capture's comparison lineage

A capture of an in-place system surface (a web sign-in sheet) describes a
different presentation than a capture of the app, so it must never compare
equal to one. The `present` branch of the iOS snapshot route returned a bare
fallback, so that capture carried no comparison identity at all, and two
comparison sites hand-rolled the distinction from `iosSystemSurfaceBundleId`
instead.

The probe now reports which host it matched, and the `present` branch goes
through `runFallback` like the `unknown` branch beside it, lineaged to
`<device>:<host bundle>`. The comparison key then differs from an app
capture's by construction, so the surface branch in `hasMatchingPresentation`
and the surface concatenation in `snapshotComparisonKey` are gone: both sites
are plain key equality again, and neither knows that system surfaces exist.
Two captures of the same surface still share a lineage, so they stay
comparable with each other.

A presented surface is not a bridge failure, so it gets its own warning
wording: the bridge is inapplicable here, not unavailable.

* refactor(interaction): carry the pre-action baseline as one surface-scoped value

The same pre-action tree travelled as a flattened nodes/surface pair at every
boundary, and each boundary rebuilt it with a conditional spread. Carry
SurfaceScopedNodes itself instead:

- ResolvedInteractionTarget gets preAction?: SurfaceScopedNodes, replacing the
  preActionNodes/preActionSurfaceBundleId pair and the PreActionBaselineFields
  intersection on all three arms of the union.
- SettleObservationCommandOptions gets baseline: SurfaceScopedNodes, replacing
  baselineNodes/baselineSurfaceBundleId.
- RefResolution carries tree: SurfaceScopedNodes instead of nodes plus a loose
  surfaceBundleId.

That retires preActionBaselineFields(), preActionBaseline(), evidenceBaseline(),
the local SettleBaseline type, the split-then-reassemble in
settleObservationCommand, and the 'preActionNodes' in resolved narrowing tests.
SurfaceScopedNodes moves to contracts, where ResolvedInteractionTarget can name
it; only two sites now mint one from a SnapshotState.

Behaviour is unchanged: the cross-surface guarantees keep their existing tests.

* fix(ios): identify a surface capture by what the runner served

The `present` path stamped the capture's comparison lineage from the host-side
presence probe. That probe answers about a host PROCESS and deliberately stays
positive while a dismissed host lingers, so during that window the runner
truthfully returned APP content while the route lineaged it to the HOST: the
sheet capture before the dismissal and the app capture after it compared equal,
and a post-gesture poll could read the transition as a stable surface.

Derive the identity from the returned capture's `systemSurface` instead - the
runner stamps the surface it actually served - and say which of the two the
capture holds in the warning. The probe's host is now evidence only: it names
the matched host in a route diagnostic so a lingering window is legible in the
daemon log. Other reasons keep their lineage and wording byte for byte.

Captures that bypass the route's planning (a pinned backend, a custom-actions
read) also reach the runner, and the runner serves the sheet there too. They
carried no comparison key at all, so a sheet and app content fell through to
legacy presentation matching as one presentation and could corroborate a tap
across the two. The capture owner now gives those a surface-scoped identity as
well, with no fallback-source residue: nothing fell back. An app capture off
the route is untouched.

* fix(ios): derive a served surface identity at the one stamping point

A runner fallback's comparison identity was decided per call site. The
`present` path and the off-route path read the runner's `systemSurface`
stamp, but the plain `runFallback` path did not: it stamped the app
lineage the route had planned, whatever the runner returned.

The probe and the capture are separate observations, so a sheet can
appear in the gap between them. With the bridge circuit already disabled
for the generation, an app capture and a later sheet capture both
received the same app-generation key, so tap corroboration could treat
two different surfaces as comparable.

`stampFallback` now owns the decision for every runner fallback: the
surface the runner served outranks the app lineage the route planned.
The reason the bridge was skipped survives either way, and
app-generation evidence leaves with the app lineage it describes, so two
captures of the same sheet still compare equal. `runSurfaceFallback`
keeps only the reason, which is the one thing that path decides.
2026-09-11 17:37:35 +02:00
Michał Pierzchała fd4cee83f8 fix(android): retire completed recording evidence after pid reuse (#2487)
* fix(android): retire completed recording evidence after pid reuse (#2476)

A reused emulator can reassign a recorded screenrecord pid to an unrelated
process. The transport proves that replacement with `ownership-lost`, but
completed-evidence retirement and reattach accepted only `missing`, so they
treated proven termination like an uncertain live recorder: `record start`
refused forever on the retained marker, and `record stop` could not return
the already-finalized completion.

Classify the declared ownership observations once, in the contract that
declares them, and ask that question instead of comparing to `missing`.
Retirement still refuses a live or unreadable recorder and never signals a
pid it proved is not its own.

* fix(android): read proven termination in the recovery warning too

Review follow-up. Classify the recovered chunk's recorder with the owning
observation predicate, so a recorder proven gone through pid reuse also
discloses that the MP4 may be truncated instead of only a pid directory that
went absent. Attribute each `ownership-lost` producer — reassigned executable,
foreign remote path, exited task with no command line — in the transport test,
and state them in the contract comment the classification rests on.

* fix(android): retain completed recording evidence while a replacement recorder writes its path

Review follow-up. A reused pid that runs screenrecord on the recorded remote
path with a different start time proved the old recorder gone, and retirement
read that as permission to remove the artifact — deleting the replacement
recording's active MP4. Classify that observation as foreign-writer in the
contract: it still proves termination, so recovery and the truncation warning
keep reading it, but it never proves the path unclaimed, so retirement retains
the marker and artifact until the replacement ends and never signals it.
Stop-wait refuses it like ownership-lost.
2026-09-11 15:39:02 +02:00
Prateek Ranka eb0d791957 fix(orientation): disclose an unconfirmed rotation instead of asserting it (#2483)
* fix(orientation): disclose an unconfirmed rotation instead of asserting it

executeSetOrientation fell back to the requested rotation when the owner reported no resulting orientation, then reported 'Rotated to <request>' as a success claim. Keep the requested rotation for compatibility, but mark the claim unconfirmed and warn.

* fix(orientation): carry the unconfirmed rotation through the journal and public surface

Review follow-up on #2483. The daemon disclosed `confirmed: false` plus a warning,
but the surfaces that consume the result still asserted a rotation:

- `buildOrientationActionSummary` rebuilt "Rotated to <orientation>" from the
  orientation field alone, so a session journal recorded an unconfirmed rotation as
  fact. It now records "Requested <orientation> (unconfirmed)" when the owner
  reported nothing; the journal regression fails on the previous commit.
- `OrientationCommandResult` declares the optional `confirmed` and `warning`
  fields, and the MCP output schema advertises them so clients can consume the
  distinction (the navigation schema parity test covers the lockstep).

The disclosed-warning shape stays: no hard failure.

Gate: pnpm check:affected --run - 332 files / 2158 tests, all runnable checks passed.
2026-09-11 14:14:17 +02:00
Brad Anderson f57b42166a fix(network): report iOS requests that reused a keep-alive connection (#2433)
* fix(network): report iOS requests that reused a keep-alive connection

CFNetwork logs a request URL only on the `com.apple.network:connection`
line that opens a connection. A request that reuses a keep-alive
connection emits a task summary carrying status, timing, and byte counts
but no URL anywhere in the log, so a URL-keyed reader dropped it and the
dump silently omitted a request that did happen. An "assert this endpoint
was called on startup" check therefore read as a definite fail.

Correlate a reused task summary with the connection it names and report
it against that connection's origin, with `pathUnavailable` set, its
status, and its timing. The request path is not in the log at all, so the
dump also notes how many requests it could not name — a gap in
observation now reads as a gap rather than as a negative observation.

Also stop a URL parsed out of a log line from carrying the punctuation
that follows it, so an entry's `url` compares equal to the endpoint under
test instead of failing on a trailing comma.

The correlation lives in the reader rather than a sibling module because
`packages/capture-kit/src/index.ts` may not grow its eager import closure.

Refs callstack/agent-device#2430

* fix(network): count keep-alive requests the reader cannot name at all

Review of the parent commit found the same definite-negative it fixes,
one level down: a reused task summary whose connection was opened before
the scanned window resolves to no origin, so it produced no entry and no
signal — an empty dump reporting "No HTTP(s) entries were found" for a
window that demonstrably carried traffic. Count those in the dump's
`unnamedRequests` and say so in the notes, so an unnameable request is
still a reported observation.

Also order the Apple note builders so the keep-alive note no longer trips
the `notes.length === 0` guard that suppresses lifecycle guidance, and
give the android-backend test a fixture an Apple dump would actually
resolve, so the backend gate it names is the thing it proves.

* fix(network): scope connection correlation to the process that opened it

Review findings on the parent commits: three ways the reader still answers
with something other than what it observed.

A connection number is only meaningful within one process, but the index
keyed on the number alone, so an app that relaunched and reopened the same
number inherited the origin its predecessor had contacted — a request
attributed to a host it never reached, which is worse than dropping it.
Key the index by the compact log's `name[pid]` and the connection number
together; a line whose process cannot be read correlates to nothing and its
traffic stays unnamed.

The simulator recovery pass merged its dump only when it carried entries,
so a recovery window holding nothing but unnameable reused-task summaries
discarded that count and the response still reported an empty window. Merge
whenever the pass observed traffic in either form, and reserve the "none
looked like HTTP traffic" note for a pass that found neither.

The trailing-separator strip was global, so a valid URL ending in
punctuation became a different endpoint. Take the URL from the delimited
`url:` field where the format establishes the separator, and leave a bare
URL exactly as matched.

Regressions cover each: the same connection number under a different pid,
an unreadable process identity, recovery-only unnamed traffic, and a path
that legitimately ends in a period.

* fix(network): reconcile unnamed keep-alive requests across scan windows

The app log and the simulator recovery pass cover different, sometimes
overlapping windows, so taking the larger of their two unnamed counts was
wrong in both directions: two unnameable requests in one window and three
in the other reported three rather than five, and a request the recovery
pass resolved stayed counted as unnamed from the app log.

Carry the identities instead of a count. Every CFNetwork line names its
request as `Task <UUID>.<seq>`, scoped here to the emitting process, so the
same request seen in two windows is recognisable as one. A merge unions the
unnamed identities and subtracts anything either window managed to name, and
a resolved reused request carries its identity as `packetId` so that
subtraction has something to key on.

`NetworkDump.unnamedRequests` becomes `unnamedRequestIds`, since a list of
identities is what makes the reconciliation exact rather than a lower bound.

Regressions cover disjoint windows, overlapping windows, and a request one
window named while the other could not.

* fix(network): keep unnamed-request identities out of the response

`unnamedRequestIds` collected every unresolved task in the scan window and
was spread straight into the response, so `network dump 1` could answer
with thousands of task ids: an output whose size tracked the log rather
than the requested entry limit.

The identities exist to reconcile two scan windows, which is a step that
finishes before a dump is returned. Keep them there. `NetworkDump` carries
`unnamedRequests` as a count again, bounded by construction; the identities
ride `ScannedNetworkDump`, the internal widening that the reader and the
merge speak, and the Apple runtime projects them away with
`withoutScanIdentities` on the way out.

Reconciliation is unchanged: overlapping windows still collapse to one
request and disjoint windows still sum, because the merge still sees the
identities and recomputes the count from them.

Regression: five unnameable tasks against `maxEntries: 1` reports all five
and exposes no identity list.

* fix(network): return scan identities beside the dump, not on it

The Apple route stopped leaking task identities into its response, but
Limrun and WebDriver return the scanner result directly and both serve
Apple sessions, so an iOS `network dump 1` through either still answered
with every unresolved task id in the scan window. Projecting at one
producer was never going to hold: `ScannedNetworkDump` was assignable to
`NetworkDump`, so returning the scanner result compiled everywhere and
each producer had to remember not to.

Take the shape away instead. `readRecentNetworkTrafficFromText` returns a
`NetworkScan` — `{ dump, unnamedRequestIds }` — so identities sit beside
the public dump rather than on it, and `mergeNetworkScans` reconciles the
pair. A route returning `scan.dump` cannot carry them out, and a route that
forgets does not compile. All four producers are updated; the response
shape is unchanged.

Regressions cover the Apple, Limrun and WebDriver routes: five unnameable
tasks against `maxEntries: 1` report the count and expose no identity list.
All three fail if the identities are put back on the dump.
2026-09-10 20:44:16 +02:00
Michał Pierzchała 6d08de4609 feat(scroll): find off-screen targets in one command with --until (#2436)
* refactor(interaction): extract the scroll command runtime out of gestures.ts

* feat(scroll): add --until <selector>, report honored travel, fix web amount units

* test(scroll): cover --until through the provider-backed integration path

* perf(selectors): keep the scroll-until predicate off the eager import path

* fix(scroll): refuse an unreadable capture instead of reporting end-of-content

* fix(selectors): keep the capture-readability check off the eager import path

* test(selectors): use a declared snapshot quality state in the capture fixtures

* fix(scroll): read the capture quality verdict under the spelling the backend uses

* refactor(scroll): collapse --until onto the one route that runs it

* refactor(scroll): drop unexported until types and duplicated guidance prose

* test(scroll): fix the climbing fixture and drop duplicated route-level cases

* refactor(scroll): delete the dead command-runtime executor and reuse canonical predicates

* refactor(interaction): keep requireResolvedPoint local to the gesture runtime
2026-09-10 17:13:26 +02:00
Michał Pierzchała da76aa4f1e refactor(commands): declare project-config admission and recorder sanitization on the flag declaration (#2453)
* refactor(commands): declare project-config admission and recorder sanitization on the flag declaration

Move the two fail-closed flag properties — may a key be set from a project
`agent-device.json`, and does the session recorder copy it into `SessionAction.flags`
— off the hand-maintained allowlists and onto each `FlagDefinition` as required
`projectConfig` / `recorded` fields. Omitting either is now a type error, so the
compiler holds the fail-closed property a list held by omission.

- 156 declarations carry both fields; the 6 screenshot-specific definitions carry them
  too. Populated to match the old sets exactly (one-off diff empty: 85 project-config
  and 39 recorded keys, byte-for-byte).
- `cli-config.ts` and `session-action-recorder.ts` derive their sets from the registry
  and no longer list keys; `RECORDED`/`PROJECT_CONFIG` derivations recomputed per call so
  a consumer builds its set at its own module load. Recorder reaches the derivation
  through the `cli-schema/command-schema.ts` seam (daemon may not import `commands/`).
- Planted-divergence tests, per #2421: flipping one declaration's field moves the
  admission/sanitization outcome through the production derivation, plus a compile-time
  pin that an incomplete declaration does not build.
- `docs/agents/cli-flags.md` now points at the declaration fields, not the allowlist.

Refs #2445

* refactor(commands): return the recorded keys as a set, matching project-config

Both derivations answer the same question — the set of flag keys a surface admits —
so both return ReadonlySet<FlagKey>. Drops a needless set-then-spread on the
recorder path; consumers already iterate the value.

Refs #2445

* fix(commands): keep the CommandFlags guard on recorded flag declarations

The deleted `SANITIZED_FLAG_KEYS` was `satisfies readonly (keyof CommandFlags)[]`,
so every recorded key had to be a `CommandFlags` key. The derived set returns
`FlagKey` and the recorder indexed it through a cast, so `recorded: true` on a
CLI-only key (`daemonAuthToken`, `help`, …) compiled and could leak an uncarrable
value into a recorded action.

State the constraint on the declaration: `FlagDefinition` is a union that locks
`recorded` to `false` for a `NonRecordableFlagKey = Exclude<FlagKey, keyof CommandFlags>`.
`recordedFlagKeys()` returns `ReadonlySet<RecordableFlagKey>` via a narrowing
predicate, so `sanitizeFlags` drops its cast. Adds a `@ts-expect-error` test that a
CLI-only key cannot opt into recording.

Refs #2445
2026-09-10 17:04:32 +02:00
Michał Pierzchała 2d109e5cc5 refactor(contracts): additive capability facts on the clipboard family (#2464)
* refactor(contracts): make clipboard capability facts additive

Additive capability facts on the clipboard family (#2443, family 3).

clipboardRuntimeOperationFacts took exactly two cells, read and write, and both are
required. The halves stay separate claims — a WebDriver provider whose Appium clipboard
extension exposes only a getter is a real owner with one half and not the other, and
`clipboard read` must not be refused because the write half is missing — so both are
optional and `unsupported` names the denial an unnamed half reports. A call naming no
denial is still refused: omission is a classified refusal, never an unclassified half
and never an implied success.

Six of the eight owners drive both halves from one shell command set or one leaf gate,
so they now state that once: web, HarmonyOS, and Vega name a single denial, and the
shared unavailable record carries one clipboard cell where it carried two. The owners
that serve clipboard keep naming both halves, and the one install-source double that
enumerated the two keys by hand now goes through the family builder like every other
construction site.

Fact values are unchanged: every owner's clipboard cells are byte-identical, and the
family's own test pins an unnamed half reporting the stated denial verbatim, an owner
naming nothing answering with the exhaustive shape, and the freeze.

* fix(contracts): derive clipboard family denials from the served cell

Review follow-up on the clipboard family.

Apple restated its leaf branches to choose a clipboard denial, so the leaf split lived
twice in one file. It now reads the leaf's own refusal, keeping a leaf-scoped
placeholder only where the leaf serves clipboard and therefore has no clipboard
refusal to state.

Android named a build-level shell verdict as its family denial, which is the claim its
own probe refuses to make when the probe did not complete; the denial now follows the
probed cell, so an unnamed half reports the unknown rather than a verdict.

Pin the shared unavailable record's clipboard fan-out the way the keyboard commit
pinned its own: one input cell, two operations, each with its reason.
2026-09-10 17:02:32 +02:00
Michał Pierzchała 1131fb3118 refactor(contracts): additive capability facts on the gesture family (#2460)
* refactor(contracts): make gesture capability facts additive

Additive capability facts on the gesture family (#2443, family 2).

gestureRuntimeOperationFacts took one fact per tier, and it had five: plan,
directionalFling, multiTouch, targetAuthoredDrag, viewport. The tiers split exactly
where an owner's mechanics split — no tier is one every owner serves, and several
owners serve none — so every tier is optional now and `unsupported` names the denial
an omitted tier reports. The type still refuses a call that names no denial at all:
omission is a classified refusal, never an unclassified tier and never an implied
success, and an owner cannot leave the family blank.

An owner states a tier only to say something the family denial does not. Web refuses
every tier with one cell and names only the drag tier whose wording came from a
retired closure. Vega names the two tiers whose refusals came from separate closures.
Limrun's session-less and iOS branches name one denial each, where each wrote five.
The owners that serve gestures keep naming what they serve: the win is the next tier,
which an owner can now ignore entirely.

Fact values are unchanged: every owner's gesture cells are byte-identical, and the
family's own test pins an omitted tier reporting the stated denial verbatim, an
owner that names nothing answering with the exhaustive shape, and the freeze.

* fix(platform-apple): name the gesture family denial by leaf

Review follow-up on the gesture family.

Apple refuses gestures for two reasons depending on the leaf: watchOS and visionOS
have no gesture surface at all, while every other leaf without a touch kind is the
wrong device kind. One constant cannot be truthful for both, and the family denial
is what the first unnamed tier will report, so it now follows the leaf.

The Limrun gesture call collapsed to one source for the session-less branch, so pin
the reason and hint it reports there rather than availability alone, and cover the
directional-fling tier the loop had skipped.
2026-09-10 17:02:32 +02:00
Michał Pierzchała d80b021fa5 refactor(contracts): make keyboard capability facts additive (#2459)
The keyboard family's facts builder took one required cell per operation, so an
operation only one owner implements still cost a hand-written denial in every
other owner. The builder now takes the family's denial once, as `unsupported`,
and every operation cell is optional: an owner names what it serves and omission
reports that denial verbatim, with the reason and hint the owner would otherwise
have repeated per cell.

Omission stays a classified refusal, never an unclassified cell and never an
implied success: `unsupported` is required, so a call that leaves the family
blank does not compile. An owner that names every operation still states the
family refusal, and it must refuse the family rather than one operation of it —
whatever the owner leaves unnamed reports that cell verbatim.

The shared unavailable-facts input collapses `keyboardStatus`/`keyboardDismiss`/
`keyboardEnter` into one required `keyboard` cell, so the owners that answered
the family with raw keys (webdriver, vega, linux, limrun's no-session binding)
now answer it through the builder, which is the family's one entry point.

Facts are unchanged for every owner, so the per-platform admission assertions
hold untouched. Two owner tests now assert the keyboard reason as well as the
availability, because one family cell reports one reason for all three
operations: the WebDriver inactive session and the stale Limrun identity.

Refs #2443
2026-09-10 17:02:31 +02:00
Michał Pierzchała 41e2633f10 fix(wait): retire native selector bypass and recover text observations (#2440)
* fix(apple): resolve selector waits through canonical capture

* fix(wait): retire dead selector observations and recover native text failures
2026-09-10 12:03:58 +02:00
Michał Pierzchała ad79461c72 refactor(commands): declare a command option once and derive its surfaces (#2421)
* refactor(commands): declare a command option once and derive its surfaces

Collapses the pass-through hops the #2410 hop trace named, so an option is
stated once and every surface reads that statement instead of a copy.

- The snapshot option/flag pair (`customActions` <-> `snapshotCustomActions`
  and its seven siblings) is declared once in `@agent-device/kernel/snapshot`
  with both projections, generalising the shipped screenshot pair. The nine
  hand-written renames now read the declaration.
- The dispatch pass-through flags are declared once as
  `DISPATCH_CONTEXT_FLAG_KEYS`; `DispatchContext` and the daemon's
  `contextFromFlags` both derive from it, so an identically-named flag no
  longer costs two files. The mapper keeps only what it decides.
- The snapshot capture option keys are one type
  (`SnapshotCommandOptionFields`), referenced by the public SDK options, the
  internal request bag and the command runtime options instead of four copies.
- An option's prose has one owner: its `FlagDefinition` now carries both
  audiences (`usageDescription` for --help, `inputDescription` for the tool and
  SDK field), and `optionField` derives the command's input field -- its JSON
  schema shape, bounds and description -- from that declaration. The duplicate
  doc comments in contracts are gone.

Adds planted-divergence tests: a divergence planted in the one declaration must
move the derived surface, which a second hand-written copy could not do.

No help text, MCP tool schema or wire shape changes.

* fix(contracts): re-pin SNAPSHOT_OPTION_FLAGS against CommandFlags

The kernel declares SNAPSHOT_OPTION_FLAGS but sits below contracts and cannot
name CommandFlags, so nothing pinned its values to real flag keys after the
`satisfies Record<RoutedSnapshotOption, keyof CommandFlags>` guard on the old
src/backend-snapshot-options.ts table was dropped. A misspelled or renamed
flag key would compile and snapshotOptionsFromFlags would read `undefined` at
every seam, silently dropping the option. Re-pin it in contracts, the lowest
point that can name both.

* fix(contracts): stop exporting the SNAPSHOT_OPTION_FLAGS pin

The compile-time pin added against CommandFlags was exported, which the
layering gate's facade-exhaustiveness check flagged: packages/contracts/src/
facades/command.ts re-exports command-flags.ts but omitted it, an accidental
narrowing. The pin is not API -- nothing reads its value, it only exists to
fail the build on a renamed or misspelled flag key. Keep it unexported and
consume it with `void` so oxlint's no-unused-vars stays satisfied without
widening the facade.

* test(commands): plant the option divergence before the surfaces are built

The planted-divergence tests called optionField after command metadata had
already been constructed, so they proved only that optionField reads its own
argument: a command that went back to a hand-written booleanField('...') would
still have passed. The plant now lands on the option declaration in a fresh
module graph BEFORE the builders run, and both derived surfaces -- the command
metadata table and the MCP tool schema built from it -- are asserted against it.

Also restores the public SDK documentation the branch dropped. AppOpenOptions
.foreground and CaptureSnapshotOptions.customActions carry their JSDoc again: a
.d.ts is read in an editor where no FlagDefinition resolves, and this repo has
no step that generates those docs. The prose is the option's one declaration
verbatim, and a unit test pins each SDK field's JSDoc to that declaration's
inputDescription so the two cannot drift.

* fix(kernel): stop bundling SNAPSHOT_OPTION_FLAGS' rationale into the public .d.ts

Its JSDoc was internal refactor rationale, not SDK documentation, but
rolldown-plugin-dts inlines it wherever CaptureSnapshotOptions resolves
through SnapshotCommandOptionFields. Move it to a source comment (stripped
from declaration output) and keep one short hover line. Cuts the packaged
size growth from +3.3 kB/+2.2 kB to +2.3 kB/+1.8 kB (unpacked/download);
no behavior change.
2026-09-10 08:52:27 +02:00
Michał Pierzchała edbd96ea30 refactor(contracts): additive capability facts pilot on touch family (#2420)
* refactor(contracts): make touch capability facts additive

The touch family's facts builder took one required cell per operation, so an
operation only one owner implements still cost a hand-written denial in every
other owner. The builder now takes the family's denial once, as `unsupported`,
and every operation cell is optional: an owner names what it implements and
omission reports that denial verbatim, with the reason and hint the owner would
otherwise have repeated per cell.

Omission stays a classified refusal, never an unclassified cell and never an
implied success: `unsupported` is required, so a call that leaves the family
blank does not compile. Facts are unchanged for every owner, so the per-platform
admission assertions hold untouched.

Refs #2412

* fix(contracts): keep tap/longPress/fill required in touch facts

Making every touch cell optional dropped the compile-time guard on the
three operations every owner implements, with nothing replacing it: an
owner could silently drop `tap` and nothing would fail. Only the truly
per-owner cells (tapRef, hover, hoverRef, fillRef, tapElementSelector)
need `unsupported` as their default.

Restoring the required fields caught a real regression this refactor
introduced: platform-web's touch facts had silently lost their explicit
`longPress: readinessUnavailable` cell, so longPress reported the
family's `unsupported` fallback instead of the owner's own readiness
reason. Restored it, and fixed the other now-required-field call sites
(the fully-unavailable owner fixtures, and the screenshot-runtime-fixture
test double, which had also silently lost its longPress/fill distinction).

Refs #2412

* fix(coverage): repoint hover evidence off the retired runtime.ts source line

The touch family's additive-facts refactor dropped every owner's explicit
hover: unavailable cell in favor of the family-level unsupported fallback,
so the coverage manifests' literal-substring checks against
packages/platform-apple/src/runtime.ts and packages/platform-linux/src/runtime.ts
broke: that source line no longer exists.

Point the macOS, iOS, tvOS, and Linux hover rows at new tests that assert the
typed unavailable denial directly against each owner's bound facts, instead of
re-adding a hand-written denial line the refactor was meant to retire.

Refs #2412

* test(coverage): point hover evidence at the typed-denial tests in the declaration table
2026-09-09 22:16:45 +02:00
Michał Pierzchała 8d5ca680c0 refactor(move): move the selector pipeline and interaction targeting into @agent-device/selectors (#2397)
* refactor(move): move the selector pipeline and interaction targeting into @agent-device/selectors

The 11 pipeline modules (selector-pipeline, selector-pipeline-policy,
interaction-targeting, touch-semantics, interaction-positionals,
press-retarget, interaction-touch-point, absence-observation and its
errors/resolution companions, and the interaction-error vocabulary) are
exposed as per-file subpaths. The two test-utils files the moved tests
share with root tests are copied into the package, following the
existing package-local test-utility pattern.

* chore(gates): point the layering pins at the moved selector pipeline

R19's owner constant now names the pipeline in
packages/selectors, and the rule additionally refuses in-package
relative routes to the engine file so the co-location cannot widen the
door. The package-boundaries export/dependency pins and the fallow
health baseline key follow the files.

* fix: drop two unused exports flagged by fallow

* test: point press-retarget comment at the relocated touch-semantics module
2026-09-09 08:28:34 +02:00
Ahmad Al-Faqih ef5a459294 refactor(daemon): isolate Apple session observations (#2405)
* refactor(daemon): consume a semantic Apple session observation port

* chore(gates): retire direct daemon observation imports

---------

Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
2026-09-09 08:28:06 +02:00
Michał Pierzchała 0627190524 refactor(move): move lease scope vocabulary into @agent-device/contracts (#2380)
* refactor(contracts): move the lease scope vocabulary into @agent-device/contracts

* chore(gates): pin the contracts lease-scope subpath in the exports snapshot

* fix(tests): re-point remote-proxy-parity lease-scope import to contracts

The test landed on main after this branch cut and still imported the
pre-move path src/core/lease-scope.ts; use the
@agent-device/contracts/lease-scope subpath like the other consumers.
2026-09-08 21:52:00 +02:00
Michał Pierzchała c5d9707196 refactor(daemon): move the device selection cluster into @agent-device/device-selection (#2396)
* refactor(daemon): move the device selection cluster into a workspace package

git-rename src/core/dispatch-resolve.ts, src/core/device-selection-resolver.ts and src/request/device-inventory-context.ts (plus their tests and fixtures) into a new private package @agent-device/device-selection (deps: contracts, host-kit, kernel). One subpath per module points straight at the moved file; no index.ts, no re-export at the old path. Consumers switch to the owning specifier in the follow-up commit.

* refactor(daemon): install the installed-app probe on the inventory gateways and switch consumers to the device-selection package

The composition root (src/platform-runtime-device-inventory.ts) now attaches an optional findInstalledApp probe to the composed device inventory gateways, lazily importing the Apple simulator app-resolution mechanics. Device selection reads the probe from the request context instead of importing a platform package directly, so absent probes fall back to the ordinary inventory rules. All consumers switch to the @agent-device/device-selection/{dispatch-resolve,device-selection-resolver,device-inventory-context} subpaths; the moved tests carry a package-local inventory test util.

* chore(gates): re-point the layering gates at the device-selection zone

TARGET_DAG_RANK gains the device-selection leaf zone and drops the retired request zone; the back-edge fixture and the capture-kit ALS substrate fixture move to the package path.

* test(daemon): prove the factory-installed app probe narrows simulator selection

The moved selection tests inject their own probe, so nothing covered the
composition root actually attaching findInstalledApp to the gateways:
omitting it would leave those tests green while app-based selection
silently fell back to the generic local rules.

The new case runs two booted simulators through
createComposedDeviceInventoryGateways and the request context, fakes
only the leaf xcrun spawn (core tool-provider), and asserts the
single-app-installed-local selection plus both probe consults.
2026-09-08 21:51:41 +02:00
Michał Pierzchała 22a46d12d2 refactor(move): move the remaining package-ready modules out of src/core (#2401)
* refactor(move): move remaining package-ready modules out of src/core

- validation.ts            -> @agent-device/kernel/validation
- android-system-surface-disclosure.ts -> @agent-device/contracts/android-system-surface-disclosure
- project-runtime.ts       -> @agent-device/host-kit/project-runtime
- runtime-transport-hints(.test).ts -> @agent-device/host-kit/runtime-transport-hints
- app-events.ts (+tests)   -> src/daemon/app-events.ts
- dispatch-payload.ts (+test), payload-input.ts -> src/daemon/
- fill-backend-result.ts (+test) -> src/daemon/

interaction-outcome.ts stays in core: it correlates ResolvedInteractionTarget
with error objects across the commands(rank3)/daemon(rank4) boundary, and
contracts explicitly refuses mutable interaction-outcome lifecycle (R18).

* chore(gates): pin the exports and boundary snapshots for the moved core modules
2026-09-08 21:50:14 +02:00
Michał Pierzchała 1f9d940bff refactor(capture-kit): complete ADR 0019 end state — relocate snapshot and recording zones (#2385)
* refactor(capture-kit): relocate snapshot and recording zones into capture-kit

Move the ADR 0019 end-state capture zones into @agent-device/capture-kit:

- src/snapshot/** -> packages/capture-kit/src/snapshot/** (presentation,
  freshness, scroll-edge-state, ios-snapshot-runtime, android occlusion)
- src/recording/** -> packages/capture-kit/src/recording/**
- src/core/snapshot-{chrome,state,tree-ingestion,node-lookup}.ts ->
  packages/capture-kit/src/
- src/snapshot-quality/ test -> capture-kit presentation tree (directory
  retires with its last file)

Pure renames: import re-pointing and gate updates follow in the next commit.
The snapshot-desktop-surface test parks in src/__tests__/ because it pins
the root eager-import-closure walker.

* refactor(capture-kit): re-point capture and recording consumers to the new subpaths

Rewires every consumer of the relocated snapshot/recording modules to the new @agent-device/capture-kit subpath exports, adds the 23 subpath entries to the capture-kit exports map, fixes the moved recording-scripts test's __dirname-relative paths for the deeper location, and records the completed migration in ADR 0019's end state.

* chore(gates): align layering, mutation, fallow and CI gates with the capture-kit relocation

Moves the executable-policy roots, presentation-owner constant, zone ranks, authority fixture, mutation sharding globs, stryker aliases, fallow baselines and the iOS workflow's android-owned paths-ignore entry onto the new packages/capture-kit paths, and extends the planted-red coverage to the new presentation-owner subpath.

* chore: point capture-domain source-of-truth comments at the relocated capture-kit modules

* test: point shutdown recording mock at capture-kit and cover interactor acquisition presentation

* test(capture-kit): update upstream presentation test imports

* chore(gates): follow relocated snapshot assembly in R74

* test(daemon): freeze prewarm deadline assertion clocks
2026-09-08 12:41:39 +02:00
Michał Pierzchała a6cf1b1fd4 refactor(ios): delete the unused snapshot plan interface (#2392)
`planIosSnapshot`, `IosSnapshotPlan`, the `plan` member of `IosSnapshotEngine`
and `createIosSnapshotEngine` had no production caller: production reaches
presentation through `publishIosSnapshot` / `presentIosSnapshot` directly, and
the barrel re-export was all that kept the factory alive for fallow.

Deleting the plan takes the last reader of most of
`IOS_SNAPSHOT_PRODUCER_CAPABILITIES` with it. The table was typed over all four
producers while only the two provider producers ever consumed its
residue-shaping fields, and it had already drifted: it declared
`simulator-ax-bridge` with `hittabilityEvidence: 'available'` while the bridge
adapter emits `unavailable-fact: hittability` on every capture. Rather than
correct the value, the table is now keyed on `IosProviderAcquisitionProducer`,
so a producer that builds its own facts cannot declare one at all.

Truncation is the one capability the runner and the bridge still need answered,
so it moves to a table of its own over all four producers, read through
`iosSnapshotTruncationEvidence`. Both keep `'available'`, which is what the
adapter and the runner payload actually prove.
2026-09-08 12:41:26 +02:00
Michał Pierzchała 6a03688d80 refactor(ios): prune converged snapshot paths (#2383)
The daemon snapshot assembly no longer presents. `shouldPresentLegacyIosInteractiveSnapshot`
fired whenever an xctest capture arrived without a producer, or with a producer whose capability
table still named `snapshot-state` as its presentation owner — which `simulator-ax-bridge`
still did after routing moved it onto the engine, so a bridge capture with `--interactive-only`
ran the iOS semantic presentation twice (#2188 invariant 2).

Rather than deleting a runtime guard and hoping, `buildSnapshotState` now takes
`SnapshotCaptureProvenance`: a capture either knows nothing about its origin or carries the whole
pair, so the producer-less branch does not compile. Requiring the pair broke only test fixtures,
which is the proof that production never omitted it.

`presentationOwner` had one value left once the bridge was accounted for, so the capability and
its type are gone; the truncation verdict that read it now reads `truncationEvidence`, which is
the fact it was standing in for and matches it producer for producer. Post-wire scope planning
names the channels that still need the pass instead of excluding the ones that do not, which takes
iOS out of it. `compactIosInteractiveSnapshot` was a byte-identical alias of
`presentIosInteractiveSnapshot` with no production caller.

R74 holds it: the assembly and the Simulator bridge producer adapter may not import iOS
presentation, and the assembly may not name the iOS channel or a producer.
2026-09-08 10:36:37 +02:00
Michał Pierzchała 527a56a6e7 refactor(move): move the batch runner and batch policy into @agent-device/command-registry (#2388)
* refactor(command-registry): move the batch runner and batch policy into @agent-device/command-registry

* chore(gates): re-point the sdk-batch chunk groups and fallow baseline at the command-registry batch module
2026-09-08 07:48:33 +02:00
Daniel Morales 8299d5b4a7 fix(ios): honor the startup budget through a cold Simulator boot (#2325)
* fix(ios): honor the startup budget through a cold Simulator boot

A never-booted Simulator runs Apple's first-boot migration, which can take
minutes, but the boot wait was capped at a fixed 120 seconds that neither
`prepare --timeout` nor `open` could reach (#2324).

- The boot wait takes an absolute deadline. `prepare --timeout` now covers the
  boot and the runner preparation as one budget; `open --timeout` is new and
  bounds the boot. Expiry fails with `boot_timeout` and leaves the Simulator
  booting, so a retry finds it further along.
- The client envelope for open/prepare keeps the 30s margin over the budget so
  the daemon's structured timeout wins the race against the client's reset.
- `close --shutdown` no longer trusts the session device's selection-time
  `booted: false`; it always asks simctl. A session opened on a cold Simulator
  otherwise reported a shutdown that never happened.

Supersedes the original implementation of #2325 by @PrinceD96 (head 8bdb85b7a3),
which found the bug, the shutdown shortcut, and the validation recipe.

Closes #2324

Co-authored-by: PrinceD96 <53633741+PrinceD96@users.noreply.github.com>

* fix(ios): keep the confirming boot listing inside the startup budget

After bootstatus, the listing that confirms the Booted state ran on its own
15-second timeout and the success path never re-checked the deadline, so a
bootstatus that used nearly the whole budget could still return success past
it. The listing now gets the remaining budget (capped at its own 15s), and a
confirmation that lands after the deadline is reported as boot_timeout.

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-09-07 13:06:18 +02:00
Michał Pierzchała d26b0786fb perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner (#2329)
* perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner

Local Simulator opens now decide how much the XCTest runner is needed from the
runtime operations declared by the steps still ahead in the same batch: an
observation-only plan starts no runner, an unknown plan keeps the speculative
prewarm without ever awaiting it, and a plan with an interaction prepares
readiness for that step. open --relaunch no longer waits for runner readiness
on a Simulator and resets the runner target only when a session is already
alive. The Apple find ports report not-proven instead of starting a runner on
a Simulator without a live session, so wait and read-only find observe through
the canonical AX-bridge tree. Physical devices keep their lifecycle unchanged.

The plan travels through the server-private internal request channel, never
the wire; the Apple owner maps declared operations to a runner demand through a
record complete over the runtime operation union.

Refs #2198

* test(fixtures): share one inert audio-probe host across the platform runtime fixtures

The Apple and Android runtime fixtures carried identical audio-probe doubles;
host-kit now owns the one copy and both fixtures import it. Also folds the two
Apple native-find ports onto one admission helper and lifts the Simulator
runner prewarm policy out of the open sequence, keeping both under the
complexity gate.

* fix(ios): answer runner liveness through the runner provider seam

The find ports and the relaunch target reset asked the local session registry
whether a runner was alive, which misreads scripted and request-scoped runner
providers as absent. Liveness is now a provider question: the local provider
consults its session registry, a provider without startup cost counts as live,
and an awaited prewarm proves liveness without asking.

* perf(ios): select plan uses from step input and give young Simulator targets a bounded bridge grace

A snapshot, diff, or find step now selects the runtime uses its structured input
reaches, the way its handler does, so a plain snapshot no longer counts the
custom-actions alternative and an observation-only batch resolves runner demand
none. The descriptor declares the selector next to its alternatives; the daemon
plan derivation honors it and keeps the union for every other command.

Without the runner wait, the first snapshot after an open reached the AX bridge
while the app was still becoming the primary foreground owner or registering
its accessibility server, and the typed fallback then started the runner the
plan had just avoided. A target younger than ten seconds is re-read for a
bounded grace measured from the first such failure: five seconds for a missing
AX server, one second for an ownership miss so a launch-time system dialog
still reaches the fallback quickly. Established targets get no grace.

* fix(ios): a registered runner session counts as live only once it has answered

A session record exists while xcodebuild is still connecting, so an alive
child pid is not a runner that can answer. Treating it as live sent the
relaunch target reset into a starting runner, queued behind its connection
retries, and the failed reset invalidated the very session the prewarm was
building. Liveness now also requires the session's readiness flag, which the
first successful runner response sets.

* refactor(ios): lift the bridge launch grace out of the snapshot route capture

* test(descriptors): pin the snapshot, diff, and find step-use selectors

* test: stub runner operations in the replay test-runner suite and keep runner-session tests within the size ratchet

A Simulator open schedules a best-effort runner prewarm that outlives its
request. The replay test-runner suite opened a Simulator with the real Apple
tools, so the prewarm's deferred import resolved after the file finished and
spawned into whichever file the worker ran next, where the hermetic signal
guard failed an unrelated test.

* fix(plan): count only required operations and read find and snapshot steps the way their handlers do

Runner demand now counts a command's required operations only: a preferred or
conditional operation is a measured fast path the command succeeds without, so
get, wait, and read-only find stay observation-only. The step selectors for
snapshot, diff, and find live next to the registry and read the daemon step
exactly as the handlers do: the daemon flag for custom actions, and find's
positionals through the same parser, where a missing action is a click and an
unparseable step keeps every declared alternative. The handler and the selector
share one action-to-intent map. The batch runner hands each step its remaining
steps in handler shape, and the derived operations reach the platform as a
typed list on the lifecycle execution instead of an untyped plan on every open.

* perf(ios): let open wait for the launched app to become observable, and make runner liveness explicit

The snapshot route no longer infers a launch from process start text and
retries inside its own capture. Open owns launch timing instead: a local
Simulator open asks the AX bridge whether the launched app is observable,
bounded by per-code windows measured from the first typed launch-transition
failure and never extended, so an ownership miss seen after an AX-server miss
shrinks the deadline to the ownership window and a launch-time system dialog
still reaches the typed fallback quickly. Any other device, or a bridge that
cannot answer, keeps the fixed settle. The open response reports what it
learned.

Every runner provider now states whether it can answer without a startup wait;
a bare executor answers directly by construction and scripted providers say so.
The runner prewarm policy and the observation settle move out of the open
sequence into their own module, and the native find admission is named for what
it admits.

* docs(context): keep the runner-demand vocabulary within the guidance budget

The enumeration and the no-public-flag rule live on the contract type that
owns them; CONTEXT.md keeps the term itself, and two neighbouring entries lose
words that carried no meaning.

* refactor(contracts): name the runtime operation vocabulary below the operations union

The lifecycle execution carries the operations a plan requires, but typing
that list with the operations union closed a 36-file type cycle: the
operations types depend on the lifecycle types. The vocabulary now lives as a
const list below both, proven equal to the union by a type test, so the plan
is typed end to end, the Apple host table indexes it without casts, and the
daemon narrows descriptor names through a guard instead of a cast.

* fix(apple): reach runner liveness through the memoized operations loader

Every Apple tool port loads the runner operations through the one memoized
loader (#2314): a port that opens its own dynamic import can resolve the
unmocked module while a test's mock factory is still loading and let a real
local runner escape. The liveness port now uses the loader like its siblings;
the facade members consumed only through the loader are declared to fallow,
and the plan resolver reads one step per helper to stay under the complexity
threshold.

* fix(ios): keep bridge-only behavior to iOS Simulators

The launch observation, the runner-free find admission, and the relaunch
policy apply only where the host AX bridge exists: iOS Simulators. A tvOS
Simulator keeps its awaited prewarm and asks for no observation, which the
tvOS provider scenario now pins.

* bench(ios): add a first-interaction cell to the snapshot convergence harness

An open that defers runner readiness moves its cost to the first
runner-dependent command. The cell starts each sample like cold, opens the
fixture untimed, then times the first press that follows (the deep-link
confirmation when the launch URL raises it, otherwise the screen anchor).

* bench(ios): read the deep-link confirmation from a snapshot and by node type

The open response carries no tree and regular snapshots publish the node type,
so the confirmation iOS raises for a launch URL was never seen on this runtime
and every deep-linked cell failed its anchor check.

* refactor(plan): keep the step-use selectors inside the registry

The eager-closure ratchet counts every module the registry loads; the
selectors need nothing the registry does not already import, so they live
beside find's recording-effect reader instead of adding a module to every
entry that loads the registry.

* feat(apple): release a speculative runner when the plan is proven observation-only

#2198 requires a `none` runner demand to retain no runner, not only to start none. A runner a
prewarm started that no command has used yet is speculative: the session records that mark at
creation, the first command that is not a readiness probe clears it, and a Simulator open whose
plan is proven observation-only asks the runner owner to release a speculative session in the
background, so the observation path never waits for a runner to stop either. A runner that has
served a command is the session's working runner and stays under the existing idle-stop policy,
so a mixed workload does not pay a cold runner start at every observation-only open.

The release goes through the runner provider seam: the local provider stops its own speculative
session; a provider that never starts speculative work omits the operation and releases nothing.

* bench(ios): press an unambiguous target on the catalog and Settings screens

The first-interaction cell pressed the screen's anchor text, which on the catalog and iOS
Settings screens names two actionable elements (the native tab and the screen title); the CLI
refuses that as AMBIGUOUS_MATCH by design, so those two cells could never measure anything.
Each such screen now names the element the cell presses.

* fix(ios): keep observation on the bridge while app discovery is pending and no runner is live

#2331 bounds one capture's wait for the Simulator app discovery and takes the XCTest fallback
past it; #2198 stops a Simulator open from awaiting the runner. Together, a `wait` right after a
relaunch on a loaded host fell back to XCTest while the runner was still starting, spent its poll
budget on that start, and timed out (the iOS smoke lane after the main merge). A capture with no
live runner now stays on the single-flight discovery, one wait slice at a time, until the
discovery's own deadline or the request signal ends it; a runner that is already live still takes
the fallback at once, the cheaper route #2331 chose.

* fix(apple): queue a speculative-runner release behind a start that is still in flight

A `possible` open's prewarm registers its session only when the start completes, so a `none` open
that released in that window found nothing and the runner it meant to release survived as a
retained speculative session. The release now takes the runner session lock: it queues behind the
in-flight start, sees the registered speculative session, and stops it; a start a command asked for
is left alone. Two deferred-start regressions pin both outcomes.
2026-09-07 10:12:58 +02:00
Michał Pierzchała bd08e6e0f2 refactor(contracts): move single-owner modules out of @agent-device/contracts (#2357)
* refactor(daemon): move root-only contracts vocabulary into its owning zone

Six @agent-device/contracts modules had no consumer outside the root zones,
so the shared vocabulary package carried types only the daemon and root
composition ever read. Each one moves to the zone that owns it and every
consumer switches to the owning module; no re-export stays behind at the old
contracts path.

- perf-runtime-plan, snapshot-timeout-evidence, platform-resource-cleanup ->
  src/daemon
- daemon-owner-cleanup -> src/
- interaction-error -> src/core

wait-runtime-plan stays in contracts: @agent-device/command-registry consumes
it, so it is not root-only after the registry package landed.

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

* refactor(platform): move single-consumer contracts modules into their platform package

Four modules in @agent-device/contracts had exactly one consuming package, so
the shared vocabulary carried Android- and Apple-specific shapes no other zone
could use. Each moves into the package that owns it, with every consumer
switched to the owning module and no re-export left at the old contracts path.

- android-helper-artifacts -> platform-android/src/helper-artifacts.ts
- android-touch-plan -> platform-android/src/touch-plan-lowering.ts, which
  also retires the package-local touch-plan.ts re-export barrel that existed
  only to give the contracts module a local name
- snapshot-presentation -> platform-android/src/snapshot-presentation-node.ts
  (renamed to keep the package's existing Android-specific
  snapshot-presentation.ts distinct)
- apple-multitouch-support -> platform-apple/src/multitouch-support.ts

APPLE_OS_DISPLAY_NAMES folds into gesture-admission.ts, its one remaining
contracts caller, so both gesture refusals still share one copy of the wording
without a new contracts subpath for a table its own doc calls non-public.

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

* refactor(core): move the replay divergence implementation into src/core

replay-divergence.ts mixed the wire vocabulary every zone reads with the
sanitizing, bounding and reporting implementation only root zones call. The
ten value consumers are all root (daemon replay, the session replay
coordinator, the daemon client lifecycle, the replay-test reporter, the
command error projection, and the MCP tool error), so the implementation moves
to src/core/replay-divergence.ts and carries its test unchanged.

The types stay in contracts and keep the @agent-device/contracts/divergence
subpath, which packages/ad-replay and packages/selectors type-import.
ReplayVarScrubEntry follows the implementation: it is the sanitizer's own
parameter shape, not part of the divergence wire report.

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

* chore(gates): shrink the contracts export surface by the moved subpaths

The nine relocated modules no longer live in @agent-device/contracts, so its
exports map drops their subpaths (118 -> 109) and
scripts/layering/contracts-exports.snapshot.json is regenerated from the
manifest, which is what R11 package-boundaries diffs the live surface against.

The two resolution assertions naming the retired snapshot-presentation and
snapshot-timeout-evidence subpaths go with them; interaction, snapshot and
react-native-overlay still cover both the direct-module and facade shapes the
assertions were there to prove.

The property tests that needed fast-check left with snapshot-presentation and
replay-divergence, so the dependency moves too: contracts drops it and
platform-android declares it, as fallow's unused-devDependency check reports.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 12:55:14 +02:00
Michał Pierzchała 89c7536850 feat(ios): support explicit iOS simulator keychain reset (#2345)
* feat(ios): support explicit iOS simulator keychain reset

`settings clear-app-state` never touched keychain-backed credentials
(e.g. Firebase auth), so a customer's fresh-install reset via the CLI
left an app signed in when their in-app reset button did not (#2282).

simctl exposes no per-app keychain reset, only a whole-simulator one
(`simctl keychain <device> reset`), so this ships as a separate,
explicit `settings reset-keychain clear` command rather than folding
it into `clear-app-state` — callers opt in knowing the scope is the
whole simulator, not just the app under test.

Split the pre-existing `apps.test.ts` and `snapshot-handler.test.ts`
suites along the `app-settings.ts`/`snapshot-settings.ts` modules they
actually mirror, since both were already over the test-file-size
tripwire and could not grow further.

* fix(ios): reject extra reset-keychain arguments and add live-tested keychain fixture

settings reset-keychain clear <extra-arg> silently dropped the extra
argument in both the CLI reader and the direct-daemon parser, so a
caller expecting per-app scoping could get a whole-simulator wipe
without any signal something was off. Reject it instead in both
places, with tests proving no settings mutation happens.

Also add a small keychain-backed "auth" fixture to the test-app's
automation lab (expo-secure-store) so the settings reset-keychain
guarantee has a real regression surface: authenticate, verify the
credential survives clear-app-state and a plain relaunch, then verify
reset-keychain actually clears it. Validated live against a disposable
iOS simulator.

* fix(ci): stop a bare gradle.properties append from corrupting the last line

expo prebuild's generated android/gradle.properties has no trailing
newline, so `echo "org.gradle.jvmargs=-Xmx4g" >> gradle.properties`
appended directly onto its last line instead of a new one, producing
expo.inlineModules.watchedDirectories=[]org.gradle.jvmargs=-Xmx4g.
Gradle's JSON.parse of that property then fails at configure time,
before any real compilation runs -- the exact "Process 'command
'node'' finished with non-zero exit value 1" failure this branch hit
on Android Release and the Smoke Tests fixture-app fallback build.

This was a dormant bug: the Android build-cache job only runs on a
fingerprint miss, and no PR had changed the test-app's native
dependencies in a while. Adding expo-secure-store (#2282's keychain
fixture) was enough to trigger it. Reproduced locally against a clean
install with the exact CI script, confirmed the corrupted property,
and confirmed the printf-based fix builds cleanly (870/870 tasks).
2026-09-06 12:51:51 +02:00
Michał Pierzchała dcd8b65d4c refactor(daemon): split src/daemon/types.ts into request types and session state (#2346)
* refactor(daemon): split daemon/types.ts into request and session-state modules

`src/daemon/types.ts` served two audiences from one file: the dispatch request
shape and the daemon's live session record. It also sat in the only daemon type
cycle — it imported `RefFrame` from `ref-frame.ts`, which imported `SessionState`
back — so neither file could be read in isolation.

Three modules replace it, each importing only downward:

- `daemon-request-wire.ts` declares `DaemonWireRequest`: a dispatched request
  with no `internal` key and no property path to `SessionState` or `DeviceLease`,
  so a consumer can read a request's command, flags and public metadata without
  depending on the session record.
- `daemon-request.ts` adds the daemon-only half (`DaemonRequestInternal`, which
  stays unexported) plus the response vocabulary.
- `session-state.ts` owns `SessionState` and the shapes only it holds.

The cycle is cut by `ref-frame-slot.ts`, declared below both `ref-frame.ts` and
`session-state.ts`: it owns the frame VALUE (the class stays unexported, so the
type remains nominal and unconstructible from outside), while `ref-frame.ts`
keeps every lifetime transition and every `session.refFrame` write.

No behavior change: every importer moves to the module owning the symbol it
uses, with no re-export shim at the old path. `client-normalizers.ts` takes
`SessionRuntimeHints` from `@agent-device/kernel/contracts`, which declares it.

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

* test(daemon): assert the wire request shape cannot reach session state

A type-level walk over `DaemonWireRequest` fails `tsc` if the shape regains an
`internal` key or grows a property path back to `SessionState` or `DeviceLease`.
Positive controls over `DaemonRequest` prove the walk finds both when they are
there, so a walk that never matches anything cannot pass by accident.

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

* test(daemon): keep the three over-budget test files at their base length

Splitting `daemon/types.ts` turns one combined import into two in every file
that used both halves. Three of those test files are already over the 1,000-line
tripwire, where the size ratchet allows no growth, so each sheds one line that
was carrying nothing:

- `snapshot-handler.test.ts` and `find.test.ts` each drop a `toHaveLength`
  assertion an adjacent `toEqual` on an explicit array literal already makes.
- `session-replay-repair-transaction.test.ts` names the filtered close actions
  instead of wrapping the expression across three lines inside `expect`.

No assertion is weakened and no test content is removed. Splitting these files
along the modules they mirror is the standing remedy, but none of those modules
split here, so it stays out of this change and is tracked in #2353.

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

* chore(gates): point the daemon modularity and wire-compat gates at the split modules

R7 now locates the `SessionState` declaration by the declaration itself rather
than by a recorded path: `sessionStateWritePressure` measures the merge-base
tree too, and that tree still declares it in `daemon/types.ts` — a path constant
would measure it as zero pressure and bank the headroom.

R10's external-importer ratchet covers all three modules that replaced
`daemon/types.ts`, so moving a symbol between them cannot reopen the boundary to
a new outside zone. The recorded membership is unchanged: `client-normalizers.ts`
and `remote/daemon-artifacts.ts` both import `daemon-request.ts` only.

The daemon RPC closure gate waives `DaemonRequest`, `DaemonResponse` and
`DaemonArtifact` by path, so those three keys follow the declarations to
`daemon-request.ts`. `DaemonRequest`'s rationale now says what it is — the
server-side narrowing of the kernel declaration that fixes the wire shape —
rather than calling it a re-export alias.

The `live-state-shape` and session-resource declaration sites move with
`SessionState`; the depgraph lookalike fixture takes a new plausible path now
that `daemon/session-state.ts` is the real root.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 12:36:54 +02:00
Michał Pierzchała 6e22e266d7 refactor(contracts): own the daemon HTTP wire contract so clients stop importing src/daemon (#2322)
* refactor(contracts): own the daemon HTTP wire contract so clients stop importing src/daemon

Move the pure wire vocabulary (base path, header names, URL/auth/tenant
builders, /health payload) from src/daemon into @agent-device/contracts as
the daemon-http subpath, so src/remote and src/cli stop importing daemon
server internals. buildDaemonHealthPayload takes the version its caller
advertises (R18 keeps host mechanics out of contracts); both callers pass
readVersion(). Wire-compat surface, mutation, and ledger references follow
the package path.

* chore(gates): pin the moved daemon HTTP wire surface and teach the released-baseline check file moves

Exports map + snapshot gain the daemon-http subpath. The wire ledger
re-keys the eight moved declarations (buildDaemonHealthPayload moves with
its new caller-supplied version parameter, acked additive). The
released-baseline comparison now classifies a baseline declaration that
re-appears unchanged at exactly one new path as a move instead of a
removal: a file move is not wire surface a released peer stopped sending.
A move that changes shape is a change acked at the destination path, and a
name still owned by the baseline stays a removal.
2026-09-06 08:20:39 +02:00
Michał Pierzchała 6b0e5d6c1c feat: add fenced managed lease admission (#2308)
* feat(daemon): add fenced managed lease admission

* refactor: derive managed horizons from canonical budgets

* fix: carry managed admission through operation dispatch
2026-09-06 08:10:58 +02:00
Michał Pierzchała cf83afb9c9 feat(ios): route Simulator snapshots through AX bridge (#2279)
* feat(ios): route simulator snapshots through AX bridge

* fix(ios): preserve snapshot fallback lineage

* fix(ios): keep regular depth in presentation

* perf(ios): reuse process-verified snapshot targets

* fix(ios): refuse snapshots beneath another foreground owner

* test(ios): bound native setup and isolate runner reset

* test(ios): synchronize helper crashes with request dispatch

* test(ios): exercise foreground guards through native capture

* chore(gates): run native snapshot ownership regression on iOS CI
2026-09-05 23:05:58 +02:00
Michał Pierzchała 0c8227e9b7 refactor(runtime): let platform runtimes list apps and read app state directly (#2295)
* refactor(runtime): let platform runtimes list apps and read app state directly

The root host carried two adapters, appInventory and appState, that only
forwarded a platform call back into that platform's own package. Each platform
runtime now performs its own listApps and appState call through a lazy import
inside its package, keeping the deferred load, the AbortSignal threading, and
the package/bundleId -> id rename. PlatformRuntimeHost loses both keys, so
Android, Apple and Harmony fixtures no longer stub the two platforms they do
not own.

Android is the one platform runtime whose package now reaches adb directly.
The adb host that adb mechanics require is bound by a module side effect that
only the root can perform, so the Android runtime-module registration binds it
before the module loads. loadAndroidMechanics keeps its own binding import for
the root host ports that reach mechanics without binding a runtime; neither
binder subsumes the other.

Android appstate now runs one foreground-focus loop instead of two. The host
shaped readAndroidAppState/AndroidAppStateHost pair is gone: limrun's adapter
already closes over its own adb executor, so it calls the executor variant
directly, and that variant took the per-attempt abort check the host variant
had. AppStateRuntimeCommand and AppStateRuntimeCommandResult described the
deleted host port and go with it.

Tests: the new ordering test in
src/platform-runtime-android-adb-binding.test.ts was seen red by deleting the
binding import from that registration (order came back
["android-runtime", "adb-host"]); the composed-gateway listApps test in the
same file was seen red by reverting the Android runtime's inlined listApps to a
host.appInventory lookup (TypeError reading 'android'); the new abort test in
packages/platform-android/src/app-state.test.ts was seen red by removing both
signal?.throwIfAborted() calls from readAndroidFocusWithExecutor (the second
dumpsys was issued and the call resolved). All green after.

* chore(gates): drop the retired app-inventory/app-state host allowances

The two PLATFORM_RUNTIME_HOST_FILES rows point at host files this change
deletes, and the ./platform-runtime-app-state-host.ts composition allowance has
no importer left.

* refactor(runtime): construct the Android runtime module with its adb host binding

The Android runtime now calls adb from inside its package for listApps and
appState, which needs the process-wide adb host port bound. That dependency
was hidden in a registry wrapper doing a side-effect import, with a paragraph
explaining why it and loadAndroidMechanics did not subsume each other and an
import-order test pinning the ordering. The package now declares the
dependency: createAndroidRuntimeModule({ bindAdbHost }) awaits the binding
before the runtime loads, and the composition root supplies the one binding
implementation (evaluating its adb host module). The wrapper, the paragraph
and the import-order test are gone; the routed listApps test stays and a
routed appState test joins it.
2026-09-05 22:40:42 +02:00
Michał Pierzchała 51bed41381 test(contracts): one typed conformance helper for the runtime-family suites (#2301)
* test(contracts): one typed conformance helper for the runtime-family suites

Extracts the shared binding-rule mechanics that ten *-runtime.test.ts
files repeated by copy into interactor-operation-conformance.fixtures.ts.
Each module keeps its own expected table and calls the helper with it;
the helper owns no expectations.

* chore(gates): add cross-module completeness test for interactor operation conformance

Asserts both directions against the production registry: every catalog
operation has exactly one conformance table naming it, and every named
operation is registered in the catalog.

* docs(contracts): note the interactor conformance gate's coordinated-deletion blind spot

Review on PR #2301 (P2): the two-direction completeness check in
interactor-operation-conformance.test.ts can't catch a catalog row and its
conformance rows being deleted together in one change — both sets shrink in
lockstep and neither assertion trips. No behavior change; documents the
limitation for a future maintainer who wants to close it with an independent
count.

* test(contracts): drive interactor-operation conformance from the catalog itself

The completeness gate read raw test sources and regex-matched operation
literals, so a commented-out row still counted as conformed while nothing
executed it. The three binding rules now live in one test file that walks
INTERACTOR_OPERATIONS and binds through each catalog row's own bind and
label, with the per-operation expectations (interactor method, minimal
input) in a table typed over every catalog operation. A missing, duplicated
or unregistered row fails typecheck and the table-vs-catalog test; every row
is executed by construction. The conformance fixture and the per-facet
conformInteractorOperations blocks are gone; each facet test keeps only its
dedicated tests.
2026-09-05 22:11:28 +02:00
Michał Pierzchała d1b9914d88 refactor(commands): retire the navigation-only type projection (#2294)
* refactor(commands): retire the navigation-only type projection

`commands/system/navigation-projection.ts` built the five navigation client
methods out of a phantom-typed registry: a `unique symbol` brand carrying
Options/Result/required-ness, two conditional types to read them back, and a
mapped type keyed on `clientMethod`. Nothing else ever used the concept, so the
machinery existed to derive five signatures that fit in five lines.

Those five now say what they mean. `BackCommandOptions`, `HomeCommandOptions`,
`OrientationCommandOptions`, `AppSwitcherCommandOptions` and
`TvRemoteCommandOptions` join their siblings in
`packages/contracts/src/client-system.ts`, and `AgentDeviceCommandClient`
declares all 14 methods in one object type. `back` keeps the `--settle` triple
(#1638), and `orientation`/`tv-remote` keep their required options parameter.
The five MCP output schemas move to `mcp/command-output-schemas.ts` beside the
other handwritten ones, byte-identical.

With the projection gone, `defineExecutableCommand`'s third overload,
`ExecutableCommandProjection`, `AnyCommandDefinition.projection`,
`ProjectedCommandOutputSchemas`/`projectCommandOutputSchemas` and the family's
`clientCommandMethods` table have no users either. Removing the table also
removes the `as unknown as` cast the client used to build eight system methods
from it; the client now writes all eight out, typed.

That closes the `commands/system` -> `client` inversion the client-types header
called the one remaining one.

Public API: the five method signatures are unchanged (structural comparison of
the built `dist/src/index.d.ts` before and after: empty diff).
`HomeCommandOptions` is a new published name for the shape `home` already took.

Tests seen red before green:
- `src/__tests__/client-system-commands.test.ts` (new): wired `home` to the
  `app-switcher` daemon command, saw it fail, restored.
- `src/mcp/__tests__/command-tools.test.ts`: dropped `durationMs` from the
  inlined `tv-remote` schema, saw the dispatch-shape assertion fail, restored.
- `src/commands/system/index.test.ts`: made `home`'s options parameter
  required, saw `expectTypeOf` fail under `pnpm typecheck`, restored.

* test(mcp): pin the closed top-level shape of the navigation output schemas

Retiring the projection replaced an identity assert (`schema === projection.outputSchema`)
with a deep-equal over properties/required, which no longer rejected an extra top-level
key such as a stray `description` or `additionalProperties`. The loop now also asserts the
key set is exactly type/properties/required, so the closed shape is pinned by a test again
rather than by object identity.

Seen red once by giving the `app-switcher` schema a description argument, which adds a
top-level `description` key: the new assert failed with `+ "description"`. Green after
removing it.

The `deriveSettleObservationSchemas` docstring cited that deleted identity assert as the
reason for copying. The press/click shared-object half is the real reason and is all that
remains.

* chore(gates): drop the retired projection from the R6 inversion rationale

The R6 baseline numbers are unchanged (5 inversions, commands -> client still 3):
retiring the projection removed a client -> commands edge, which the ratchet does
not count. What changed is the ARGUMENT next to those numbers. The commands/mcp ->
client bullet justified itself with a zone-level cycle (client-types.ts imported
ProjectedNavigationCommandClient back out of commands/system/); that cycle no
longer exists, so the bullet now rests only on the port argument that was always
the second half of it. docs/dependency-graph-findings.md §0/§0b/§1 carried the
same claim and the same 'move the navigation-projection types out of commands/'
follow-up, now recorded as answered by deletion.

The blocked-shapes table in §1 now reads eight-at-the-time / three-still-blocked, matching
the struck navigation row directly under it.

* test(mcp): split the navigation schema tests out of command-tools.test.ts
2026-09-05 19:30:21 +02:00
Michał Pierzchała fd221b15e1 refactor(contracts): build unavailable runtime facts once (#2291)
* refactor(contracts): build unavailable runtime facts from one policy table

freezeUnavailableFacts() classified each of the 34 UnavailablePlatformRuntimeFacts
cells with its own Object.freeze call and its own inline comment, and
createUnavailablePlatformRuntimeFacts() destructured all 38 fields off the
result before spreading them into the operations map. Replace the per-field
freezes with a single UNAVAILABLE_CELL_POLICY table (satisfies
Record<UnavailableCellKey, 'owner-stated' | 'inherits-network'>) and a loop, so
a new cell added to UnavailablePlatformRuntimeFacts must get a policy entry or
the file fails to type-check. The scattered per-field comments collapse into
one block comment above the table citing #1873. Drop the 39-line destructure;
createUnavailablePlatformRuntimeFacts now reads the frozen record's fields
directly.

Add createFullyUnavailablePlatformRuntimeFacts(), an exported constructor that
points every cell (lifecycle included) at one unavailability reason, for
missing-owner cases that have no per-family classification of their own.

Verified the table forces every cell to be classified: flipping `apps` from
'inherits-network' to 'owner-stated' (an optional cell most local runtimes
still leave unclassified) is not a type error, since the table only governs
runtime fallback and the field stays optional either way — but it does turn
into a real test failure: packages/platform-linux/src/runtime.test.ts's
`listApps` assertion goes from `{ available: false }` to `{}`, because the
loop now treats an unclassified `apps` cell as owner-stated and stops falling
back to the network gap. Reverted before committing.

* refactor(gateway): reuse the shared fully-unavailable facts constructor

unavailableProviderBinding() and unavailableProviderFacts() each hand-spelled
every UnavailablePlatformRuntimeFacts cell for a provider mode with no
registered module. The two lists had already drifted: the binding map omitted
`readiness`, the facts map included it — harmless only because both fall back
to the same 'unsupported-provider-mode' reason today, but nothing would have
caught a real divergence. Both become one-liners over
createFullyUnavailablePlatformRuntimeFacts(), so there is exactly one place
that enumerates "every cell is this one reason", and unavailableProviderLifecycleFacts()
is no longer needed.

Added a regression test asserting bind() and inspectFacts() produce
byte-for-byte equal facts for the same unregistered-provider device: it
passes today (confirmed against the pre-refactor gateway.ts, restored
temporarily to check) since the drift was reason-compatible, but it now pins
that equivalence so a future cell added to only one of the two paths fails
loudly instead of silently drifting again.

* refactor(contracts): derive unavailable-cell fallback from optionality

UNAVAILABLE_CELL_POLICY hand-duplicated which cells inherit the
network gap and which are owner-stated, restating exactly what
UnavailablePlatformRuntimeFacts's optional vs required properties
already say. Nothing checked the two against each other, so a
misclassified entry passed `satisfies Record<UnavailableCellKey,
'owner-stated' | 'inherits-network'>` and the cast at the read site
turned it into `Object.freeze({ ...undefined })` = `{}` at runtime -
a fact object missing `available` entirely.

Replaced the two-value table with a key-only UNAVAILABLE_CELLS list
and one `mapUnavailableCells` helper shared by both call sites.
freezeUnavailableFacts now reads `unavailable[cell] ?? unavailable.network`,
which TypeScript resolves without a cast because the union already
covers the optional case - a wrong classification is impossible to
express, not just checked for.

Seen red once: reverted the `?? unavailable.network` fallback (kept
`unavailable[cell]` alone) and reran
platform-runtime-unavailable.test.ts - the new `listApps` assertion
failed with the exact `{}` malformed-fact shape the policy-table bug
could produce. Restored the fallback and it passes.

Also added the missing inherits-network assertion itself
(platform-runtime-unavailable.test.ts): the existing test only
exercised owner-stated cells, so the inheriting path had no direct
coverage in this file.

* test(gateway): fold bind/inspect parity into the existing fixture

The parity test duplicated the whole gateway construction (single
apple module, inline webdriver ProviderDeviceRuntime literal) from
the test directly above it instead of reusing it. Appended the
inspectFacts() call and the equality assertion to that test instead,
and switched toEqual to toStrictEqual: toEqual ignores
undefined-valued keys, and an omitted cell is exactly how the two
facts maps could drift from each other.
2026-09-05 19:05:58 +02:00
Michał Pierzchała 172ee149cf feat(screenshot): add --crop-on to crop captures to a selector frame (#2276)
* feat(screenshot): add crop-on geometry core and cropTarget selector rows

* feat(screenshot): declare crop-on flag, script round-trip, and snapshot runtime plan

* feat(screenshot): run the crop leaf after the platform write and before scale

* feat(screenshot): expose --crop-on in the CLI and surface crop warnings

* chore(gates): declare crop-on capture-kit subpaths and scope the crop scenario exemption

* refactor(screenshot): split crop target/policy module and trim redundant coverage

Address review comments at 570da2c417:
- Split the 328-line screenshot-crop.ts leaf: the target acceptance matrix,
  classifier, and pre-device argument policy move to screenshot-crop-target.ts,
  so both implementation modules meet the 300-line target.
- Reuse kernel isPositiveFiniteRect/rectArea in the rect-projection module
  instead of redefining them locally.
- Drop the crop-on CLI forwarding case (redundant with screenshot-options
  flag-mapping coverage + the generic dispatcher) and the transport-based
  warnings case, replacing the latter with a focused screenshot-result unit
  test. This also returns the two legacy aggregate test files to their
  merge-base length for the test-file size ratchet.

* refactor(screenshot): extract macOS crop-target decision to keep classifier under the complexity budget

classifyAppleCropTarget inlined the macOS surface decision, pushing its
cyclomatic complexity to the fallow threshold. Move it back out to a
small helper so the target classifier stays within budget.

* refactor(screenshot): dedupe the meaningful-signal predicate and polish png-crop

- Hoist isMeaningfulSignal into @agent-device/contracts/snapshot (next to
  normalizeType/isMeaningfulLabel) so the ref overlay and the crop
  rect-projection share one copy instead of each carrying an identical
  private predicate. Behavior is unchanged.
- png-crop: isCropBox was a no-op 'box is Rect' predicate (input already
  Rect) — make it a plain boolean, and tighten the doc to the contract.

* refactor(screenshot): drop the dead crop outcome flag and cover the projection seams

- ScreenshotCropOutcome.cropped was a constant true that no caller read;
  the crop either returns (success) or throws, so the outcome reduces to
  the partialIntersection observation.
- resolveScreenshotRectSpace and resolveSnapshotBounds were the only
  projection exports without coverage: pin the accepted-backend map, the
  unaccepted-backend typed refusal, and the viewport-root / union / empty
  bounds branches.
2026-09-04 11:18:57 +02:00
Michał Pierzchała 7bf8d8c4a3 fix(remote): materialize test suite artifacts against a remote daemon (#2272)
* fix(remote): materialize test suite artifacts against a remote daemon (#2246)

`agent-device test` crashed with ENOENT against a remote daemon because the
scheduler resolved `--artifacts-dir` against the caller's `cwd`, sent over the
wire, on the daemon's own filesystem. Mirrors #1802's read-side fix for the
same command: the CLI now redirects `--artifacts-dir` to a temp directory the
daemon owns before the suite runs, and the daemon rewrites every artifact path
in its response back to the caller-local root and registers the suite
directory as one downloadable artifact through the existing screenshot/record
transport, extended here to also support directories via the codebase's
existing safe archive extractor (the archive comes from a remote daemon, a
different trust domain, so a raw `tar` invocation was not enough).

* fix(remote): publish test artifacts atomically

* perf(cli): keep artifact downloads lazy
2026-09-03 20:25:44 +02:00
Michał Pierzchała e882cf9723 feat(runtime): add managed-local ownership and the exact-only managed runtime (#2258)
* docs: trim the CONTEXT.md glossary within the guidance byte budget

CONTEXT.md sat at 11,992 of its 12,000-byte guidance budget, so no new domain term could be added
without first paying for it.

- Condense eighteen definitions that had grown past one line (platform leaf, command surface,
  runtime use, runner command traits, interactor, coordinate-first resolved element activation,
  parent-owned touch point, guarantee cell, delegation-on-error, ref frame, snapshot producer,
  snapshot policy facet, capture hint, regular presented-depth frontier, clip fold,
  AX-unavailable target invalidation, Maestro program, Maestro observation generation). The
  definitions keep their meaning; only the elaboration is gone.
- Move the five test-harness terms of 'Providers and tests' (provider-backed integration
  scenario, provider transcript, scenario transcript, in-process provider scenario harness, HTTP
  contract test) to docs/agents/domain.md, which AGENTS.md already routes to for domain
  vocabulary. None of them names a concept a command or a wire shape carries, and none appears in
  a test name.

CONTEXT.md is 10,517 bytes after this pass.

* feat(runtime): add the managed-local owner kind, device-claim rule, and managed binding fence

ADR 0021 foundations, unit 1. Nothing registers a managed local owner yet, so every arm below is
reached from tests only; the point of the unit is that the arms exist and fail closed.

- `RuntimeOwnerRef` gains `{ kind: 'managed-local'; instance }` with `managedLocalRuntimeOwner`:
  one owner per allocator instance, family-agnostic because the device carries its family. Every
  owner-kind discrimination becomes an exhaustive switch, so a fourth kind is a type error at each
  site: the owner key, the unavailable-facts provider mode, the durable envelope decode, and the
  gateway's provider-mode acceptance and exact-owner selection.
- `deviceClaimRuleForOwner` ('ordinary' | 'allocator-held' | 'none') in the new leaf
  src/daemon/device-claim-rule.ts replaces the boolean `isLocalDeviceClaimTarget`. Both claim
  gates switch on it, and the admission gate now evaluates it under every device-claim policy: the
  `transient-exclusive` condition moved inside the ordinary arm, so a managed owner is verified
  where an ordinary owner would never have touched the store.
- `requireAllocatorHeldDeviceClaim` (src/daemon/device-claim-allocator.ts) is the one read-only
  verifier both gates consult. It never acquires, never locks and never clears; in this unit it
  can only answer `binding-invalid`, `missing`, or `conflict`, because no allocator-held claim
  kind exists until unit 2. `allocatorHeldAdmissionError` answers each outcome with its own
  refusal through an exhaustive switch, so an outcome the verifier learns to produce is a
  compile error until it is answered.
- A missing allocator-held claim refuses with COMMAND_FAILED / `allocator-claim-missing`,
  `retriable: false`. It is deliberately not a `DeviceClaimConflictReason`: replay retries every
  conflict reason as infrastructure, and a managed identity no allocator activated is permanent.
- `managedBindingFence` / `decodeManagedBindingFence` encode `[requesterId, identityIncarnationId]`
  as the fence token and the request generation as its generation, so two requesters on one
  identity incarnation never share a fence. The ids are fenced verbatim, and the decoder accepts a
  token only if it re-encodes to itself.
- Claim admission now receives the binding intent the gateway bound, so an exact-owner fence
  reaches the gate unchanged. Session open still binds ordinarily and passes an ordinary intent:
  a managed local owner is therefore refused there structurally, and the Host open route replaces
  that intent when it lands.
- CONTEXT.md: managed local owner, device-claim rule, managed binding fence, request generation,
  identity incarnation.

* fix(daemon): decide allocator-held admission totally instead of by an optional error

`allocatorHeldAdmissionError` returned `AppError | undefined`, so its switch without a default
was never exhaustiveness-checked: TS2366 fires only when the return type excludes `undefined`,
`noImplicitReturns` is off, and oxlint has no exhaustiveness rule. A verifier outcome nobody
answered would therefore fall out as `undefined`, which both gates read as an admission — claim
admission throws nothing and session open proceeds to open the session on a device it never
verified.

Replace it with `decideAllocatorHeldAdmission`, returning
`{ admitted: true } | { admitted: false; error }`. The return type excludes `undefined`, so
dropping an arm is now a compile error at the switch, and a gate asks whether the outcome was
admitted rather than whether an error happened to come back. `buildAllocatorHeldRefusal` and the
admission gate are projections of that one decision.

* docs: restore the meaning five CONTEXT.md definitions lost in the trim

The condensing pass shortened these five past the point where they still said what they meant:

- Capture hint said 'presented depth' where the term is 'regular presented depth', which is what
  Regular presented-depth frontier is measured against; the short form read as a different axis.
- Clip fold lost both that the interpreter runs inside presentation for every backend and that a
  platform difference may not enter as a backend exception. Those are the whole rule.
- Snapshot policy facet lost the process boundary that makes it host-side at all: runner-side
  Swift presentation stays separate.
- Runner command traits lost 'independently of the public command surface', which is what
  distinguishes them from the command surface.
- Delegation-on-error said 'settles', and Settled observation makes 'settle' a term of its own.

CONTEXT.md is 11,674 of its 12,000-byte budget.

* docs(daemon): correct the claim-gate and managed-owner comments

- The claim-gate docstring claimed there is no other way to obtain device operations. That is
  true of command handlers, but two daemon-owned recovery paths bind outside the seam:
  application-lifecycle-recovery.ts (ordinary intent, daemon shutdown) and
  durable-capture-runtime-recovery.ts (exact-owner intent read back from a durable envelope,
  which this unit makes able to carry a managed local owner). Name them instead of claiming
  coverage the seam does not have.
- The open path's comment described a session executing under an allocator-held claim, a state
  this route cannot produce. Say what the `{ kind: 'ordinary' }` literal actually is: the truth
  of a route that binds ordinarily, which the Host open route replaces with the request's exact
  intent when it lands.
- Name U3 as the unit that fills the exact-owner selection arm, rather than the whole ADR.

* fix(runtime): accept transport-composed facts for a managed owner

providerModeMatchesOwner's managed-local arm accepted mode === 'local' only, but
selectExactOwner's managed-local arm loads the device's local family owner through the same
loadLocal a local-family owner uses, so it inherits that owner's provider modes verbatim. A
managed binding over a transport-composed local device (e.g. a remote ADB or web-provider
transport) would fail bindingContractFailure's facts check and be rejected as an owner/facts
mismatch. Accept the same local-family modes the local-family arm already does; still
unreachable until U3 registers the exact-only owner, which is where the binding regression
test that pins this lives.

* feat(runtime): register the managed local owner as an exact-only wrapper and add the neutral allocator port (#2259)

* feat(runtime): register the managed local owner as an exact-only wrapper and add the neutral allocator port

ADR 0021 foundations, unit 3. Unit 1 added the `managed-local` owner kind and left the gateway's
exact-owner arm for it failing closed; this unit gives that arm a registry and the owner it selects.
Nothing in production registers a managed owner yet, so both are reached from tests only.

- `createComposedPlatformRuntimeGateway` gains a `managedOwners` list that only the `managed-local`
  arm of `selectExactOwner` reads. `selectOrdinaryProvider`, `inspectFacts` and the ordinary `bind`
  arm never see it, and `providerModules` pairs one provider-runtime owner with one
  `ProviderDeviceRuntime`, so ordinary selection cannot reach a managed owner by construction
  rather than by a check. A duplicate instance is refused at composition.

- The wrapper (src/platform-runtime-managed-owner.ts, root zone, no platform imports) binds only
  under an exact-owner intent naming itself, loads the device's own family owner through the
  gateway's loader, delegates with an ordinary intent — a family owner refuses a foreign exact
  owner — and republishes the binding under the managed owner. It does not read the fence: what a
  managed binding fence proves is the device-claim gate's business. `ownsDevice` returns false.

- Twenty cells are withheld as `owner-capability-missing`, enumerated by mechanics rather than by
  catalog group: the four device-lifecycle cells, the four application cells that boot or shut the
  device down (`prepareApplicationOpen`, `prepareAppleRunner`, `closeApplication`,
  `finalizeApplicationClose`), and the twelve durable-capture cells, which a managed binding could
  never reattach because the family runtime stamps envelopes with its own local owner. The
  operations are then filtered by those facts, so an operation cannot outlive its own fact.

- `@agent-device/contracts/managed-device-allocation` is agent-device's own allocator port: lease
  request, lookup, supersession, cancellation, renewal, release, activation confirmation, identity
  status, removal acknowledgement, and the typed environment projection. Types only, named to match
  the allocator's published contract so the two sides cannot drift, with no dependency on any
  allocator package. Its only implementation is a scripted fake under `*.fixtures.ts`.

- Budgets: the new contracts entry surface is a one-module closure; the `src/platform-runtime.ts`
  hub moves 47 -> 48 for the wrapper, whose own value imports were already in that closure.

* fix(runtime): withhold the deployment cells from a managed binding and trim the allocator port

Review findings on the managed local owner.

- `deployApp` and `deployMaterializedApp` join the lifecycle group. Both family deployment runtimes
  ensure device readiness before installing, and `deployAppUse` requires `deployApp` alone — so
  `install` on a managed binding would have booted the allocator's device with nothing to refuse
  it. Twenty withheld cells become twenty-two, and the refused-uses test covers `deployAppUse`.

- The wrapper's doc comment no longer implies that withholding cells is a complete lifecycle
  exclusion: several retained Apple cells (screenshot capture, settings, clipboard, application
  launch) boot the simulator lazily inside the family runtime, where cell selection cannot reach.
  That is the same class as the pre-binding readiness path, and closing it is a family-runtime
  change.

- `readLeaseEnvironment` leaves the allocator port. It was beyond the vocabulary the contract
  fixes, and it made the scripted fake carry a real parser whose only test passed with every
  production line reverted. `ManagedLeaseEnvironment`, `ManagedLeaseEnvironmentKey` and
  `LeaseEnvironmentError` stay as types; the reader that produces them lands with the unit that
  first turns a grant into a device.

- CONTEXT.md drops an operation enumeration that was already incomplete.

* fix(runtime): withhold the lazily-booting Apple system and screenshot cells

Screenshot capture, settings, clipboard and application launch were retained on a managed
binding even though their Apple family-runtime implementations can boot the simulator lazily
below cell-selection granularity (screenshot's shutdown-failure retry boot; settings, clipboard
and application launch each resolve a local interactor the same way). That preserves rather than
blocks the exact bypass ADR-0021 section 3's hard boundary names: managed lifecycle/readiness
belongs to the allocator, and no handler path may fall back to direct lifecycle tooling.

Withhold captureScreenshot, setSetting, readClipboard, writeClipboard and openApplication
alongside the existing withheld groups. The wrapper's doc comment now names the pre-binding
readiness gap explicitly as the same class of follow-up, rather than folding it into a retained-
cells caveat that no longer applies. MANAGED_RETAINED_OPERATION moves to tapPoint, the cell the
fixture-based regression tests now use to prove something survives the wrapper.

* chore: retrigger CI (stale synchronize event after rebase)

* fix(runtime): lazy-load the managed owner wrapper to satisfy the eager-closure no-growth gate

Main's eager-closure budget gate (the merge-base ratchet) replaced the hand-tracked
HUB_BUDGETS map with an automatic no-growth-vs-merge-base check: src/platform-runtime.ts
is a hub with no growth allowed at all, not a number bumped by hand with a justifying
comment. The static import of createManagedLocalRuntimeOwner in platform-runtime-gateway.ts
added one module to that hub's closure (47 -> 48), which now fails
scripts/__tests__/eager-closure-budgets.test.ts outright rather than needing a manual bump.

Move the value import into loadManaged's dynamic `await import`, matching how the rest of
this file's owner loaders defer their leaf modules. Only the managed-local arm reaches this
path, so an ordinary bind never pays for it, same as before -- the wrapper module itself was
simply the wrong side of the eager/lazy line.
2026-09-03 19:41:53 +02:00
Michał Pierzchała a4f625c774 feat: add strict wait absent polling (#2236) (#2264)
* feat: add strict wait absent polling

* fix: keep wait absent coverage gates green

* fix: preserve wait absent restart diagnostics
2026-09-03 14:01:15 +02:00
Michał Pierzchała 2371ba9bff feat: add strict native absence assertion (#2245)
* feat: add strict native absence assertion

* fix: address absence assertion review feedback
2026-09-03 08:02:09 +02:00
Michał Pierzchała 7ee1a5ded7 refactor(ios): carry provider acquisitions through one presentation owner (#2233)
* refactor(ios): centralize provider snapshot presentation

* fix(ios): close provider snapshot ownership gaps

* fix(ios): enforce provider snapshot ownership boundary

* fix(capture-kit): preserve snapshot engine lazy closure
2026-09-02 14:06:33 +02:00
Michał Pierzchała 6c8c0508d9 refactor(ios): converge Limrun snapshots through engine (#2222)
* refactor(ios): converge Limrun snapshots through engine

* fix(limrun): defer snapshot engine loading

* fix(limrun): harden snapshot viewport evidence

* fix(limrun): preserve snapshot engine evidence

* fix(limrun): preserve unknown snapshot truncation

* refactor(ios): reuse private presentation evidence seam

* test(ios): remove stale presentation assertion binding

* test(ios): extract snapshot truncation regressions

* test: ratchet snapshot suite size pins

* test(snapshot): cover provider presentation ownership

* test(snapshot): type Limrun composition fixture

* test(snapshot): exercise public Limrun runtime composition
2026-09-02 10:15:40 +02:00
Michał Pierzchała 947582a3cc refactor(daemon): move interaction and find routes behind facade (#2178) (#2228) 2026-09-02 07:59:06 +02:00