46 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 775163bb35 fix(record): end the recorder a loaded host could not confirm (#2565)
* fix(record): end the recorder a loaded host could not confirm

`record stop` refused to signal a recorder whose identity probe it could not
afford, and the recording left in `cleanup-pending` then blocked `record start`
until the daemon restarted.

- Read the identity facts that decide who may be signaled off the event loop,
  with a budget the caller names, instead of one synchronous `ps` per field.
- Keep "the host would not answer" apart from "the host named a different
  process": the first is unknown evidence, the second is a refusal.
- End the recorder this owner spawned through its own handle while the host is
  not answering, and report any marker it cannot account for.
- Re-drive a termination or a finish the host refused instead of replaying the
  rejection to every later stop.

Closes #2549

* test(daemon): answer the ownership facts seam in the recovery fixture
2026-09-14 11:54:01 +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 1527146507 feat(snapshot): disclose a cut capture on every platform; raise the iOS bridge node cap to 5000 (#2510)
Every backend sets truncated: true when it cuts a capture at one of its
limits, but only JSON carried it. One shared warning now renders from that
flag in the cross-platform warnings assembly and tells the agent what fell
off (what comes last in document order) and what to do.

The iOS Simulator AX bridge cap moves from 1500 to 5000 nodes, the Android
helper's bound. Measured on a synthetic 600-row screen, acquisition time did
not change with the cap while the 1500 cut dropped the on-screen footer.
2026-09-12 20:52:48 +02:00
Michał Pierzchała 076234e2eb perf(screenshot): read the crop region instead of decoding the whole capture (#2504)
`screenshot --crop-on` paid for a full PNG decode and an RGBA re-encode of the
capture before keeping a frame. One worker job now turns the captured bytes into
the cropped bytes: a region reader that reconstructs pixels only down to the
box's last row and allocates only the box's pixels, and a truecolor writer that
drops the alpha channel when the cropped pixels carry none.

The reader claims the 8-bit non-interlaced truecolor layout that iOS simulator
and Android emulator captures arrive in, and only for a file it can vouch for:
the IHDR and every chunk checksum are verified, an unrecognised critical chunk
name is a decline, and every row's filter byte is read whether or not the box
reaches that row. Everything else — palette, grayscale, interlaced, 16-bit, a
checksum that does not match — falls through to the general PNG reader, which
keeps owning the canonical decode error and the previous RGBA output. A box
covering the whole image reads through that general reader too, so an unchanged
answer is only reported for a file that reader accepts.

Cropped bytes verify pixel-for-pixel against ImageMagick's own crop across RGB,
RGBA, grayscale, palette, 16-bit, interlaced, and translucent sources, on both
iOS simulator and Android emulator captures.
2026-09-12 18:23:28 +00:00
Michał Pierzchała 37d67de776 fix(android): carry accessibility selected state into snapshots (#2515)
The snapshot helper never serialized `selected`, and the host reads only the
helper's XML, so no later layer could recover it: `get attrs` had no `selected`
field, no snapshot node was marked selected, `is selected` could not match, and
a Maestro `assertVisible {id, selected: true}` failed with "Maestro visible
condition did not match" for a visible element while `selected: false` matched
every Android node (#2462).

The helper now emits both answers, like `enabled` and `password`, so an
unselected control answers `false` and a helper older than the attribute answers
nothing. The parser, the Android hierarchy node, and the published snapshot node
carry it to `get attrs` and the `[selected]` marker.

Snapshot lines render that marker on the default formatter path too: `--settle`
and `diff` already compared selection, and a line that weighs a fact it cannot
print turns a tab tap into a changed pair whose two lines look identical.
2026-09-12 20:18:35 +02:00
Michał Pierzchała 3cd6341388 fix(scroll): pace edge passes to rest and stop a stuck surface (#2499)
* fix(scroll): pace edge passes to rest and stop a stuck surface

A rubber-band bounce keeps shifting the surface after a fling, so the next
edge pass captured a phantom new offset, stacked another fling on top of the
bounce, and a single stuck container could fling 40 times without net progress.

`runScrollEdgePasses` now waits for the scoped scroll surface to hold between
passes before deciding or flinging again, and stops with `scroll_edge_no_progress`
when a container still reports hidden content but its descendants never shift.
The end pass-limit keeps its own `scroll_edge_pass_limit` reason. `scroll --until`
shares the same stuck-signature window and raises `scroll_until_no_progress`.

The stuck-window bookkeeping and surface fingerprint are shared from capture-kit
so both loops read one definition of "stuck" rather than a copy.

* fix(scroll): count recycled-cell and fresh-signature progress as movement

Two false stops survived the first cut:

- The surface signature coalesced to `identifier ?? label ?? value`, so a recycled
  cell that keeps its identifier and slot while its text changes looked unchanged
  and a genuinely advancing list was reported stuck. The signature now keys on
  identifier, label, and value together.
- `scrollSurfaceIsStuck` accepted any four captures with at most two distinct
  signatures, including `A,A,A,B`, and both loops stopped on the pass that finally
  advanced. It now also requires the newest signature to be one the window already
  showed, so rubber-banding trips but a fresh signature continues.
2026-09-12 07:45:42 +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
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 bd42b2602f fix(ios): serve regular --depth from every snapshot backend (#2431)
* fix(ios): serve regular --depth from every snapshot backend

A regular depth-capped request was refused on every runner backend but the
recursive tree: the query sweep past depth 1 and private AX at any depth
returned no capture, so a plan pinned or deferred to private AX (custom
actions, a private AX verdict on the session, the XCTest channel penalty)
fell through to the synthetic sparse root, which the daemon then rejected as
"regular iOS snapshot presentation requires a valid viewport".

Presentation already applies the presented-depth cut to whatever hierarchy a
backend acquired, and a depth-capped regular capture is a subset of the
unscoped one from the same backend, so the refusal protected nothing the
unscoped answer did not already disclose through truncated/effectiveDepth.
Delete the gate, declare private AX as regular-depth=presentation-cut, and
record the rule in ADR 0004.

Closes #2403

* test(ios): prove a private-AX-pinned plan serves regular --depth through acquisition

The presentation-package test passes with the old backend depth gate restored,
because it calls presentation directly. This runner-bundle test pins private
AX, asks for regular depth 1 against the launched host app, and requires the
plan to reach acquisition and presentation: a private-ax verdict that is not
sparse, more than one node, a real root rect, and a payload no larger than the
unscoped capture from the same backend. With the gate restored the plan logs
SNAPSHOT_BACKEND_DEPTH_UNSUPPORTED and returns the zero-rect sparse root, and
the test fails.
2026-09-10 11:21:01 +02:00
Michał Pierzchała fef0b12cc5 chore: hoist shared snapshot/selector test fixtures into a single canonical location (#2419)
* chore: hoist shared snapshot/selector test fixtures into @agent-device/selectors

PR #2397 left two copies of the snapshot-state builder and duplicated
geometry/touch-point arbitraries (root's src/__tests__/test-utils/ and the
package's internal/__tests__/), because packages cannot import root src/.
Move the canonical versions into a new @agent-device/selectors/test-fixtures
subpath and have both root and the selectors package import from it, leaving
buildNodes and the root-only replay/gesture arbitraries in place.

Fixes #2402

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

* chore: exempt test-fixtures.ts's test-only arbitraries from dead-code check

PROPERTY_RUNS, scrollingContainerTypeArb, distinctRectPairArb, and
interactionTouchPointScenarioArb are consumed only by *.test.ts files, which
Fallow's --production analysis does not see, matching the existing pattern
for other workspace-package symbols reached only from the test tree.

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

* chore: consolidate makeSnapshotState into capture-kit, rename fixtures file

An adversarial review of the #2402 fixture-hoisting change found a third
copy of makeSnapshotState in packages/capture-kit/src/snapshot-state.fixtures.ts,
predating PR #2397. Since @agent-device/selectors already depends on
capture-kit, make capture-kit's copy canonical (exported as
./snapshot-state-fixtures) and have the selectors package's fixtures module
re-export it instead of duplicating it a third time.

Also rename packages/selectors/src/test-fixtures.ts to
snapshot-geometry.fixtures.ts (subpath ./snapshot-geometry-fixtures) to match
every other test-fixture module's *.fixtures.ts convention in this repo,
which lets it fall under .fallowrc.json's existing blanket **/*.fixtures.ts
dead-code exemption instead of needing a bespoke per-symbol entry.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-09 13:56:40 +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
Bills Booth e7a5b8b622 fix(capture): export durable resource record type (#2365) 2026-09-07 10:16:00 +02:00
Michał Pierzchała b4ebd778cc refactor(daemon): move four pure leaves to their kits (#2347)
* refactor(selectors): own the parameterized recorded fill leaf

`parameterized-recorded-fill.ts` has no value dependency on the daemon: it
reads a `TargetAnnotationV1` type from contracts and calls
`selectorContainsValue`, so its whole value graph already sits inside
`@agent-device/selectors`. Move it there behind its own subpath and let the
two daemon consumers reach it by specifier.

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

* refactor(host-kit): own the daemon code signature leaves

`code-signature.ts` fingerprints a checkout from `node:crypto`/`fs`/`path`
and `findProjectRoot`; `code-signature-cache.ts` adds a stat-validated cache
over it through `publishFileSync`. Neither reaches the daemon, and both
questions — what does this source tree hash to, and can that hash be replayed
from stat alone — are host mechanics.

Move both into host-kit behind their own subpaths, carrying
`code-signature-cache.test.ts` unchanged apart from its specifiers, and let
the launch spec and server lifecycle reach them by specifier.

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

* refactor(capture-kit): own the screenshot overlay cluster

`screenshot-overlay.ts` decides which snapshot nodes earn a ref and where the
ref lands on a screenshot; `screenshot-overlay-draw.ts` paints them. Both read
kernel snapshot vocabulary, contracts snapshot predicates, and capture-kit's
own PNG and rect-projection mechanics — nothing from the daemon. The two
`src/snapshot/screenshot-overlay/` helpers had no other importer, and
`react-native-overlay.ts` sits on kernel plus its contracts vocabulary alone.

Move the cluster into capture-kit as flat siblings of the PNG and projection
modules it already used, exposing `./screenshot-overlay` and
`./react-native-overlay`; the draw, rects, and android halves stay package
internals with no subpath of their own. The moved tests carry over unchanged
apart from their specifiers, over a package-local snapshot-state fixture.

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

* refactor(capture-kit): own the post-gesture stability loop

`post-gesture-stability.ts` polls a caller-supplied snapshot function until a
surface settles. It is generic over its snapshot and signature types and reads
only host-kit diagnostics and `sleep`, so the loop is capture mechanics with no
daemon knowledge; the daemon keeps the pending record, the comparator, and the
verdict wiring it hands in.

The verdict test stays in `src/daemon` because it composes the loop with the
daemon's own `interaction-outcome-policy.ts`; only its specifier changes.

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

* chore(gates): pin the four leaf subpaths in the R11 export lists

R11 pins every workspace package's exact subpath set, so the four moves need
their new specifiers named: `@agent-device/selectors/parameterized-recorded-fill`,
`@agent-device/host-kit/code-signature{,-cache}`, and
`@agent-device/capture-kit/{screenshot-overlay,react-native-overlay,post-gesture-stability}`.

The selectors comment counted its subpaths in prose; it now counts four and
says what the fourth is. No eager-closure row is needed: every new entry is a
rename the merge-base reader follows, and each closure is unchanged
(56/5/19/32/4/8), so all six fall under no-growth rather than the new-entry
ceiling.

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

* refactor(capture-kit): drop the needless duplication suppression

The `fallow-ignore-next-line code-duplication` on the package-local
snapshot-state fixture suppressed nothing: `fallow dupes` reports three clone
groups on this tree and the fixture is in none of them, with or without the
comment. A suppression that matches no finding is dead weight at best and a
stale-suppression failure at worst.

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

* fix(host-kit): follow workspace subpaths when fingerprinting daemon source

`walkDaemonCodeGraph` followed relative specifiers only, so a source checkout's
signature covered whatever the daemon still imported by relative path. That was
already lossy and the leaf moves made it wrong: the walker itself, the overlay,
the recorded-fill and the stability loop all left the graph, so editing them no
longer changed the signature a client compares a running daemon against, and
the cache's format guard lost the "the walk invalidates every document" property
its comment rests on. Measured from `src/daemon.ts`: 619 modules on main with
the walker stamped, 611 after the moves with it gone.

Resolve a scoped specifier through the owning workspace package's `exports` map
and walk into the file it names. The manifest is stamped, not merely probed, so
an `exports` retarget invalidates without either endpoint changing; an
uninstalled package is recorded as an absent path. Installed dependencies are
still not followed — they change on install, not on edit — and the test is
structural rather than a name pattern. The graph is now 1459 modules and ~112ms
cold, which is what the stat-validated cache exists to absorb.

Regression: five of the six new walker tests fail against the previous walker,
including one that stamps the real daemon graph and asserts the walker is in it.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 11:47:17 +02:00
Michał Pierzchała bcb6c55b7f refactor(capture-kit): move durable-capture resource mechanics out of the daemon (#2320)
* refactor(daemon): give durable capture a session-store port and a cleanup report

The durable-capture mechanics reached two daemon-owned authorities directly:
the concrete `SessionStore` class plus `SessionState`, and the admission
ledger, which `recoverFailedAdoption` called to block or clear a replacement
start. Both are daemon policy, so neither can travel with the mechanics.

Replace them with a two-member `DurableCaptureSessionStore<S>` port and a
session type parameter, and let the mechanics report what they observed —
`DurableCaptureCleanupOutcome` — while `createDurableCaptureResource` keeps
the clear/block decision and the reason text. Recovery takes the session
directory resolver from its caller instead of importing `safeSessionName`.

Splitting `DurableCaptureRecordDefinition` out of the definition says which
half needs a session at all: recovery, finish-recovered, and start preflight
terminalize a persisted record with no session in hand.

* refactor(capture-kit): move durable-capture resource mechanics out of the daemon

The daemon held two halves of one mechanism. capture-kit already owned the
durable-resource envelope, JSON, and descriptor codec; the fence, transition,
adoption, and recovery mechanics that operate on that envelope still sat in
`src/daemon` as eight files.

Move them behind the store port and cleanup report the previous commit
introduced, exposed through one new `@agent-device/capture-kit/durable-capture`
subpath — not the `.` index, which is the eager closure every platform runtime
imports. Admission, start preflight, runtime binding, the kind stamps, and the
composition root that wires the mechanics to the admission ledger stay daemon
policy.

The moved tests exercise the mechanics through a resource kind and session type
of their own, so what they prove is that the mechanics need neither the daemon's
closed kind set nor `SessionState`. The composition root keeps the admission
mapping the adoption test used to assert, now in
`durable-capture-resource.test.ts` where the ledger lives.

* refactor(capture-kit): drop the now-dead durable-envelope decoder re-export

The daemon's store and adoption modules were the `.` index's only production
consumers of `decodeDurableResourceEnvelope`; both now sit beside the encoder
inside capture-kit and import it directly.

* chore(gates): approve the durable-capture subpath over the domain-facade ceiling

* refactor(daemon): merge the duplicated durable-capture subpath imports

* refactor(capture-kit): keep the durable-capture subpath to its consumed surface

`tsc -b` cannot name the fixture spy's inferred type across the package
boundary, and five re-exported vocabulary types had no consumer.

* style(capture-kit): keep package specifiers ahead of relative imports

* test(daemon): make the failed-adoption clear mapping effective

The confirmed-cleanup test started with an unblocked ledger, so deleting
`clearUndurableCleanup` from the relocated composition mapping still left
`assertStartAllowed` green. Seed a block first, so the assertion is that the
mapping lifted it.
2026-09-06 07:57:15 +02:00
Michał Pierzchała e0f8c55f6e refactor(move): move managed device allocation into its own workspace package (#2316) (#2321)
* refactor(move): move managed device allocation into its own workspace package (#2316)

* chore(gates): unrank the managed-allocation zone, declare its durable-json seam, and ignore its unconsumed root dependency (#2316)
2026-09-06 07:32:25 +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 09c1caeb7b feat(daemon): add managed allocation operation journal (#2284)
* feat(daemon): add managed allocation operation journal

* fix: harden allocation journal recovery

* refactor: share durable file publication seam

* fix: preserve host-kit import locality

* fix: keep directory sync helper private
2026-09-05 18:35:12 +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 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 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 b8ce09db73 refactor(ios): route Appium snapshots through engine (#2224)
* refactor(ios): route Appium snapshots through engine

* perf(ios): keep Appium snapshot adapter lazy

* fix(ios): preserve legacy snapshot presentation boundary

* perf(ios): tighten Appium snapshot facts

* test(ios): cover legacy snapshot presentation boundary

* test(ios): cover WebDriver snapshot seams

* fix(ios): centralize WebDriver snapshot evidence

* fix(ios): preserve unavailable snapshot facts

* fix(ios): preserve snapshot error context

* fix(ios): centralize snapshot presentation ownership

* fix(ios): disclose Appium snapshot limits accurately

* fix(ios): disclose Appium snapshot evidence limits

* fix(ios): harden Appium evidence disclosure

* fix(ios): tighten Appium snapshot disclosures

* fix(ios): close Appium audit gaps
2026-09-02 07:58:47 +02:00
Michał Pierzchała 1826b2e68b refactor(ios): integrate runner with snapshot engine (#2214)
* refactor(ios): integrate runner with snapshot engine

* fix(ios): preserve macOS runner snapshots

* refactor(ios): keep runner presentation device-aware

* fix(ios): validate runner scroll presentation

* fix(ios): close presenter package boundaries

* fix(ios): preserve snapshot source lineage

* test(ios): colocate snapshot engine coverage

* fix(ios): settle post-merge audit checks

* test(ios): fix manifest parity lint

* refactor(ios): simplify runner source walk

* test(ios): cover shared package source fixture

* fix(ios): close post-merge audit gaps

* perf(ios): avoid bundling acquired snapshot path
2026-09-01 18:36:09 +02:00
Michał Pierzchała a8ee397168 test(ios): add snapshot engine conformance gates (#2213)
* test(ios): add snapshot engine conformance gates

* test(ios): align differential acquisition inputs

* fix(ios): gate Swift differential on macOS

* test(ios): keep differential coverage host-aware

* test(ios): own snapshot differential on macOS
2026-09-01 15:59:31 +02:00
Michał Pierzchała 02116ccdd8 refactor(ios): implement snapshot engine (#2211)
* refactor(ios): implement snapshot engine

* fix(ios): finish snapshot engine ownership move
2026-09-01 15:59:30 +02:00
Michał Pierzchała fd4ab84166 refactor(ios): define snapshot acquisition and presentation contracts (#2203)
* refactor(ios): define snapshot acquisition and presentation contracts

* refactor(ios): isolate snapshot planning exports
2026-09-01 11:33:18 +02:00
Michał Pierzchała 70fb783729 fix: reuse canonical Maestro visibility context (#2156)
* fix: reuse canonical Maestro visibility context

* perf: skip empty Maestro visibility filtering

* refactor: centralize snapshot visibility context
2026-08-31 13:41:15 +02:00
Michał Pierzchała af6f12e391 chore: adopt shared oxlint config (#2115)
* chore: adopt shared oxlint config

* fix: preserve project lint boundaries

* fix: remove redundant oxlint config
2026-08-28 11:42:58 +02:00
Michał Pierzchała 7a6284bb36 refactor(platforms): break the four upward edges out of src/platforms (#2090)
* refactor(platforms): break the four upward edges out of src/platforms (#2082 W2)

src/platforms carried four imports that point up into root src, each of
which would become an R11 violation the moment its family moves into a
platform package:

- android/app-helpers.ts reached the composition root for the foreground
  parser. The parser is pure dumpsys vocabulary, so it moves to
  @agent-device/contracts/android-observation; the platform-android
  app-state module and app-helpers both consume it from there, and the
  composition wrapper plus the package facade's lazy re-export retire
  (R13 allows only the composition root to import platform packages, so
  vocabulary relocation is the inversion that stays legal).
- web/provider.ts and web/agent-browser-network.ts type-imported the
  backend diagnostics/network-dump vocabulary from src/backend.ts. Those
  six types move to @agent-device/contracts/backend-diagnostics;
  backend.ts re-exports them for its SDK consumers.
- snapshot/snapshot-desktop-surface.ts split three ways: the pure
  projection (scope/interactive/depth) moves to
  @agent-device/contracts/snapshot-desktop-projection, the per-family
  captures move to platforms/linux/surface-snapshot.ts and
  platforms/apple/os/macos/surface-snapshot.ts beside the code they
  dispatch to, and the root file keeps only the device-dispatching
  runtime host behind R3-tolerated dynamic imports. apple/interactor
  now reaches macOS surface capture family-internally instead of
  through root.

src/platforms -> root src is now zero edges. Contracts grows two entries
(pinned, budgeted); the android foreground-parser tests move beside the
parser. Left for a later pass: app-parsers.ts shares the marker-walk loop
shape with the contracts parser but parameterizes it for blocking-dialog
parsing - generalizing that is a design change, not a move.

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

* refactor(platforms): keep platform parsing with its family; give snapshot shaping its capture owner

ADR-0019's amendment forbids satisfying R13 by moving implementation
into contracts, so this wave's two relocations invert instead of sink:

- The Android dumpsys foreground parser returns to
  @agent-device/platform-android with its owning test, and
  contracts/android-observation goes back to observation vocabulary
  only. src/platforms/android/app-helpers exposes
  createAndroidAppStateReader(parseForegroundApp) and never imports
  upward; the composition seam in src/sdk/android-adb.ts injects the
  root-composed parser, keeping the published
  getAndroidAppStateWithAdb(adb) signature intact.
- The desktop snapshot projection moves to
  @agent-device/capture-kit/snapshot-desktop-projection beside the rest
  of the capture-side snapshot behavior; contracts exports the
  snapshot-scope vocabulary it consumes.

The #1832 history narration in the projection test is gone; the test
name and golden fixture carry the invariant.

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

* refactor(platforms): the adb app-state read lives whole behind the platform-android seam

The injected-parser loop retires: platform-android's app-state module
owns the complete adb-executor read/parse (readAndroidAppStateWithExecutor,
beside its host-based twin), the façade exposes it lazily, the
composition root wraps it, and src/sdk/android-adb.ts reaches it through
that root in one hop. app-helpers keeps only the app-list helpers, and
the SDK-route tests live in SDK topology as src/sdk/android-adb.test.ts.

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

* docs: drop the seam wrapper explainer comments

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

* docs: drop the projection docblocks the test names already carry

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: keep tool directives only in the touched files

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

* docs: keep tool directives only across the touched tree

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-28 07:46:48 +02:00
Michał Pierzchała 9f16fc885c refactor: migrate perf to device runtime (#2061) 2026-08-26 20:41:19 +02:00
Michał Pierzchała aeb2ff8402 refactor(contracts): retire the platform/interaction compatibility façades (#2048)
#1959 granularized the contracts entry surface but left the wide
platform/interaction façades in place as a compatibility surface for
~490 type-only importers. This mechanically moves every importer
(~350 files) onto the granular subpath each symbol actually lives on,
deletes the two façade files, and removes the fallow ignoreExports
entries and eager-closure-budget rows that existed only to cover them.

Eight previously-unexported source files needed new package.json
subpaths (clipboard, keyboard, network-traffic, platform-plugin,
platform-providers, runner-lease-context, screen-recording-runtime-host),
and ./interaction now points directly at src/interaction.ts instead of
the deleted barrel. .oxlintrc.json's no-restricted-imports rules for
the two façades are removed since there's no wide facade left to warn
against value-importing.
2026-08-26 13:13:25 +02:00
Michał Pierzchała 494f1c5ad0 Migrate audio probe to platform runtime with durable resource lifecycle (#2038)
* refactor(daemon): migrate audio onto the request-bound device runtime (R60)

Wave 6 closure unit 1 of 2 for #1739. The audio probe leaves legacy execution
for exact-owner runtime facts with the logs/record durable treatment:

- contracts: audio-probe-runtime operations (audioProbeStart/Reattach/Cleanup
  for the durable host capture, audioProbeQuery for the stateless web page
  probe), audio-runtime-plan action-selected uses + shared positional grammar,
  audio-probe-runtime-host seams; two new required unavailable cells.
- capture-kit: one shared host-capture implementation (descriptor codec v1
  with cleanup-only recovery, start pipeline waiting on the sampler's first
  status publication, live handle, exact-identity recovery operations) used by
  both darwin-hosted owners.
- owners: apple and android state their exact capture cells (macOS host only;
  the legacy bucket's physical-iOS over-claim becomes a stated refusal), web
  owns the page probe, all other families and both providers state refusals.
- daemon: audio-probe.resource.json envelope via the DurableCaptureResource
  coordinator, fence-minting admission ledger, session slot becomes the
  neutral handle+envelope pair (store-owned under R7), teardown/close finish
  through the coordinator, startup recovery registered in the device-claim
  reconciler; the handler admits by facts inspection and binds once per plan.
- deleted: the capability bucket, the WEB_QUERY_COMMANDS graft and its
  matrix pass, both supportsByDefault closures, src/daemon/audio-probe.ts,
  src/platforms/audio-probe-backend.ts, and the macOS backend shim.
- gates: cutover row R60 (durable tier, lifecycle proof on the session slot),
  R7/R11 baselines moved for the slot reclassification and new subpaths.

Known parity delta, itemized: status/stop with no active probe now answers
without backend-specific notes (the daemon no longer knows a backend before a
capture starts); the wire shape is otherwise unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(daemon): doctor becomes host-scoped execution behind the host-diagnostics surface (R62)

Wave 6 closure unit 2 of 2 for #1739, carrying the ADR-level discriminator
decision the pre-unit record names: CommandPlatformExecution gains
{ kind: 'host' } — host-scoped diagnostics contributed by platform families
through a neutral surface, binding no device runtime of its own. 'none'
remains barred as a migration target; doctor's device legs already ride the
migrated inventory gateway, facts inspection, and the apps unit's use.

- contracts: host-diagnostics facet (toolchain/device/ambient/warmup methods
  over the existing DoctorCheck vocabulary, a per-call context carrying the
  neutral inputs, and an opaque provider transport override the one owning
  family narrows back); the discriminator assert, entry gate (host is held to
  the same no-bucket rule as none), and error text extend to the new kind.
- probes move to their owning families: apple (xcodebuild/xcode-select +
  runner-cache warmup), android (adb/SDK/license toolchain, Metro reverse,
  orphaned test-IME), harmonyos (hdc), vega (tool provider + VVD inventory
  read via the context), web (managed browser census); one shared
  first-line probe helper. Wire shapes and check ids are byte-identical.
- composition: createHostDiagnostics with per-family lazy loading, injected
  from daemon-runtime through router/chain/session params — the daemon never
  imports the composition root, so no transitive platform edge returns.
- daemon: session-doctor.ts keeps orchestration only; its six platform
  imports and the three probe-owning modules
  (session-doctor-{toolchain,android,web}.ts) are deleted.
- gates: HostCutover row variant with a gateway-identity proof (R17's shape);
  R62 row; the descriptor-row completeness test now mandates rows for host
  descriptors; ADR 0019 rules-at-a-glance amended.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(audio): fold the per-family probe factories into one capture-kit operation set

Post-migration fallow pass over R60/R62: apple and android carried
byte-identical audio-probe operation factories, so the shared
createHostAudioProbeCaptureOperations now lives in capture-kit and both
bind arms call it directly; the family files keep only their stated
capture facts. Also un-exports the plan/recovery symbols nothing
consumes anymore, splits the durable-descriptor field validation out of
the decoder, names the Linux audio refusal by its file's convention, and
drops a stale session-doctor-web mock from the relocated doctor test.

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

* fix(coverage): repoint platform audio/doctor coverage claims at the migrated evidence

The R60/R62 rewrite renamed the daemon audio contract tests and moved the
web doctor suite, which broke the macos-coverage smoke gate on CI and five
sibling coverage claims that only fail on non-darwin hosts. Repoints the
macOS, web, and iOS-simulator manifests at the surviving tests (adding an
iOS-simulator start contract the manifest already named), converts the
Linux audio row from capability-denial to a fact-owned contract backed by
new stated-refusal assertions in the Linux runtime denominator test, and
retires the darwin-dependent capability special-cases: audio admission is
owned by the exact-owner runtime fact now, not the catalog.

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

* fix(daemon): forward hostDiagnostics through the session command route

Adversarial review of the R62 cutover found the wiring gap that broke
every doctor request served through the daemon: handleSessionCommands
re-composed its handler params without the hostDiagnostics gateway, so
requireHostDiagnostics always threw. Forwards it, composes
createHostDiagnostics() in the provider-scenario harness the same way the
daemon runtime does (all nine doctor integration tests pass again), merges
a duplicate test import that failed lint, applies oxfmt to the files the
branch left unformatted, and trims blank-line residue from a deleted
capabilities test.

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

* fix(audio): refuse to publish a capture without an exact process identity

Review P1 on the R60 unit: start permitted resolveManagedProcessIdentity
to yield no marker, recovery then mapped the markerless descriptor to
missing, read a possibly in-progress status file as completed, and
terminalized the durable resource without proving or terminating the
child. The marker is now required end to end: start terminates the helper
and fails when the process exposes no identity, the descriptor codec
rejects markerless bodies so a foreign or corrupted record routes to
manual recovery instead of a guessed outcome, and a planted-red
regression pins that a markerless record with a live status file is never
read as completed or missing.

Also splits the capture-kit audio-probe module along its concerns
(descriptor codec / status reads / cleanup-only recovery / live-process
owner) per the same review, moving the eager-closure budget rows the
three new files cost.

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

* fix(audio): let timed probes complete and never adopt a stale status file

Live exact-head evidence on macOS surfaced two lifecycle defects:

- The helper's runAudioProbe parked the CLI's main thread in a semaphore
  while an unstructured Task ran the capture loop; with no run loop ever
  spinning in the one-shot process, the loop stalled at its first
  Task.sleep suspension, so every timed probe froze after its first
  bucket and never completed on its own. The probe is now fully
  synchronous: ScreenCaptureKit's completion-handler APIs bridged
  through semaphores (the pattern the helper's screenshot path already
  uses in production) and a plain Thread.sleep cadence loop.
- startHostAudioProbe accepted a pre-existing audio-probe.json, so
  restarting in a session that had already run a probe returned the
  previous run's snapshot as the new probe's first status. The status
  path is cleared before the spawn — the previous handle's finish() has
  already terminated and awaited its process, so any file observed after
  the spawn was written by the new helper. A planted-red regression pins
  that a stale file is neither adopted nor left behind.

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

* test(audio): complete runtime host fixtures

* fix(audio): surface helper death after a running checkpoint instead of completing it

Review P1 on the live-evidence pass: the live handle discarded the
helper's terminal result, so a sampler that died after publishing a
running checkpoint read as running forever and stop fabricated a normal
stopped completion; marker-missing recovery compounded it by finalizing
any persisted status — a running checkpoint included — as completed.

The handle now observes process.wait: a non-terminal status file plus an
observed exit fails status and stop with the helper's exit detail, so
the durable coordinator records the failed terminal transition and the
record resolves through descriptor cleanup rather than a fabricated
result. Recovery treats only a terminal stopped publication as a
completion; a running checkpoint with the exact PID gone reports
missing, which terminalizes the envelope as already-missing with no
completion metadata. Planted-red regressions cover both: the live handle
with a running checkpoint plus child exit, and a daemon-restart recovery
over an orphaned running checkpoint.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-26 12:36:46 +02:00
Michał Pierzchała 03c3984066 perf(contracts): granularize entry surfaces so hub importers stop evaluating the facade clump (#1969)
* perf(contracts): granularize entry surfaces so hub importers stop evaluating the facade clump

`@agent-device/contracts/platform` unions 32 vocabulary modules and
`/interaction` another 18. A file that value-imports either evaluates the whole
union to reach one function, and because permanent hubs sat behind them —
`command-descriptor/registry.ts`, `core/capabilities.ts`,
`interactors/register-builtins.ts`, `command-descriptor/platform-execution-entry.ts` —
that union rode into roughly half the unit suite's test graphs.

Give every vocabulary module its own entry subpath and move all value-importers
onto the module that owns the symbol. Type-only importers are left alone: `import
type` is erased, so it already evaluated nothing.

Measured with the #1950 eager-import-closure walker over all 974 unit-core test
files, against base e5bfde3d1:

  aggregate eager module evaluations  143,248 -> 129,738  (-9.4%)
  facades/platform.ts   carried by    466 -> 1 test graphs
  facades/interaction.ts carried by   451 -> 0 test graphs

  registry.ts                 105 -> 66
  capabilities.ts             113 -> 76
  register-builtins.ts        111 -> 73
  platform-execution-entry.ts  43 -> 3
  dispatch.ts                 134 -> 100

Three gate adjustments the split forces:

- R11's pinned contracts subpath list grows to the new entries, and the resolver
  test's "must not resolve" example moves to `./clipboard`, since `./gesture-plan`
  is now a real entry.
- R16 anchored the record-runtime join on the literal `contracts/platform`
  specifier. It now accepts any contracts entry — the assertion's provenance is
  what the rule pins, not which subpath carried it.
- `gesture-plan.ts` became an entry target, and the no-bare-star rule rejects the
  `export * from './gesture-plan-types.ts'` it carried. Its one internal consumer
  now imports the owning module directly.

Both facades keep their type re-exports for the ~490 type-only importers, so
every symbol on them now reads as value-unused; one fallow entry records that
and names retiring them as the follow-up.

Closes #1959

* test(contracts): text-filter the facade scan before parsing

The repo-wide scan parsed all ~3000 sources, which the coverage lane's
instrumentation pushed past both the 5s test timeout and the 2.5s slow-test
budget. A file that never names the specifier cannot import it, so filter on the
text first and parse only the ~490 candidates.

Non-vacuity moves with it: instead of counting narrow imports across every file,
require that the surviving type-only importers were seen and classified as
erased — which an empty scan cannot satisfy.
2026-08-22 15:35:38 +02:00
Michał Pierzchała b0f9c050ce test: make hard-coded caps overridable behind seams (#1781 B5) (#1858)
* test: make hard-coded caps overridable behind seams (#1781 B5)

HarmonyOS snapshot gains a maxNodes seam (mirroring the Android helper
and Linux AT-SPI capture options) so the node cap's truncation signal is
exercised below the 5,000 default; the durable descriptor JSON node cap
gets its boundary test alongside the existing depth one.

* test: cover both durable-JSON node-cap guards (#1781 B5)

* fix(harmonyos): keep snapshot analysis counting past the emitted-node cap

The walk returned as soon as the cap filled, so an omitted node's
descendants reached neither rawNodeCount nor maxDepth and analysis
under-reported exactly the oversized trees the cap exists for. Emission
is now capped while accounting continues, matching the whole-tree claim
the Android helper's analysis makes.

Also pins the durable descriptor JSON node cap at its exact boundary:
4,096 nodes accepted, 4,097 rejected, through both the object-owned and
array-owned counting sites.

* refactor(harmonyos): own the snapshot traversal policy in a pure function

B5 asked for caps to be overridable so tests can reach them; for this
cap the better answer is to extract the policy rather than widen the
production interface. collectArkUiNodes takes every bound explicitly, so
the emission limit and the accounting-continues-below-it rule are tested
at their owning interface, and snapshotHarmony keeps its SnapshotOptions
shape with no option no caller sets.
2026-08-20 10:37:20 +02:00
Michał Pierzchała 39cd4d346a refactor: route appstate through platform runtime (#1755)
* refactor: route appstate through platform runtime

* test: keep appstate capability fixture below complexity limit

* test: cover appstate required readiness fact

* fix: align appstate facts with boot readiness

* fix: keep appstate use declaration minimal

* fix: close appstate parity and ownership gaps

* docs: record final appstate size accounting

* fix: merge neutral runtime imports

* docs: align final appstate size totals

* fix: remove stale runtime dependency edges

* docs: correct appstate size accounting

* refactor: keep runtime-use factory internal

* docs: itemize runtime-use relocation

* fix: move appstate queries into runtime packages

* refactor: retire root foreground query paths

* refactor: share Android foreground parser ownership

* fix: preserve Android appstate parser precedence

* docs: keep appstate evidence in review artifacts

* fix: keep appstate runtime loading lazy

* fix: fail closed for stale limrun appstate

* fix: preserve limrun recovery and abort appstate

* fix: narrow limrun exact-owner recovery

* fix: allocate appstate cutover rule

* fix: reconcile appstate with merged main

* style: format harmony runtime test

* fix: allocate appstate rule id

* fix: allocate appstate layering rule

* fix: remove stale app command admissions

* fix: close appstate layering regressions

* fix: align Harmony capability parity with runtime facts

* test: cover limrun recovery-only readiness

* fix: keep Limrun recovery binding app-log only

* fix: parse Android app state in linear time
2026-08-12 16:55:50 +02:00
Michał Pierzchała eabc936a0f refactor: route apps through request runtime (#1756)
* refactor: route apps through request runtime

* test: remove stale apps adapter mock

* fix: clean apps runtime replay artifacts

* fix: remove stale runtime test exports

* refactor: simplify apps runtime admission

* test: exercise runtime use through facade

* fix: close apps runtime admission gaps

* fix: disambiguate doctor app inventory callback

* fix: keep capability fixture below complexity limit

* fix: keep HarmonyOS app inventory fail-closed

* test: type HarmonyOS app admission fixture

* fix: restore HarmonyOS app inventory parity

* test: align HarmonyOS readiness fixture

* fix: preserve HarmonyOS doctor app parity

* test: type HarmonyOS doctor fixture

* fix: isolate HarmonyOS doctor policy

* fix: align apps cutover with shared rule catalog
2026-08-12 11:55:09 +02:00
Michał Pierzchała b13c06338e refactor: route boot through readiness runtime (#1747)
* refactor: route boot through readiness runtime

* fix: separate boot admission from readiness

* fix: register boot cutover policy

* refactor(runtime): move readiness into platform owners

* fix(test): tolerate provider temp cleanup races
2026-08-12 11:55:09 +02:00
Michał Pierzchała f5d9789764 feat: enforce local device claims and reconcile stale owners (#1735)
* feat: enforce local device claims

* fix: address device claim review feedback

* fix: persist canonical daemon claim state directory
2026-08-11 16:18:45 +02:00
Michał Pierzchała 1b2e786128 refactor: move screen recording onto platform runtime (#1724) 2026-08-11 10:24:57 +02:00
Michał Pierzchała b15c502318 refactor: extract platform network runtime (#1702)
* refactor: extract platform network runtime

* fix: preserve platform network recovery routes

* test: guard network parser placement
2026-08-10 17:58:42 +02:00
Michał Pierzchała b1ed5353d1 refactor: extract platform log runtime (#1701)
* refactor: extract platform log runtime

* fix: clear terminal app log recovery markers

* fix: preserve scoped app log tooling

* fix: preserve app log cancellation

* fix: handle large changed coverage diffs

* fix: harden Limrun runtime identity

* refactor: tighten platform log runtime

* fix: close app log trust gaps

* fix: accept canonical session path aliases

* refactor: extract durable capture kit

* fix: refresh retained log marker admission

* fix: rotate app logs after process relaunch
2026-08-10 17:58:42 +02:00