1829 Commits

Author SHA1 Message Date
Michał Pierzchała d2caab7edf 0.21.0 v0.21.0 2026-09-08 14:27:55 +02:00
Michał Pierzchała 31b01a2fe8 docs(adr): record the #2278 coupling audit and re-trace entry-to-platform hops (#2355)
* docs(adr): record the #2278 coupling audit and re-trace entry-to-platform hops (ADR 0022)

* docs(adr): re-trace hop tables with spine side-calls excluded from hop counts

* docs(adr): renumber R74 references to R76 after the main merge
2026-09-08 13:21:29 +02:00
Michał Pierzchała d4cf02a889 chore(gates): classify #2278 daemon-platform-runtime edges and ratchet handler session authority (#2354)
* chore(gates): classify #2278 daemon-platform-runtime edges (R74) and ratchet handler session authority (R75)

* chore(gates): discover the R75 shape target from the SessionState declaration

* chore(gates): capture dynamic-import bindings in R74 so symbol drift cannot hide

* chore(gates): reject dynamic-import destructure residue in R74

* chore(gates): R74 rejects open-ended dynamic imports beside named bindings

* chore(gates): renumber daemon-platform-runtime-inventory to R76 (R74 taken on main)
2026-09-08 13:21:29 +02:00
Michał Pierzchała 257b3e10c2 test(capture): shrink the 3x crop fixture to avoid coverage timeouts (#2399) 2026-09-08 13:10:48 +02:00
Michał Pierzchała 07c4e404b2 perf(ios): settle the first interaction on a deadline, not a fixed sleep (#2395)
The runner guarantees a 250 ms gap between activating a target and the first
interaction on it. It implemented that as a 250 ms sleep taken *at* the
interaction, so the gap was charged again however much of it had already
passed — and in an agent-driven flow the client round trip alone is 190-260 ms,
so almost all of it had.

Record when the window closes on the monotonic uptime clock at each of the
three activation points, and wait only for what is left of it. A caller that
already spent the window waits for nothing; a tight loop still gets the whole
guard. The guarantee is unchanged, and nothing downstream re-pays the wait.

Refs #2381.
2026-09-08 12:55:36 +02:00
Rohit Bansal d11c8cf9d6 feat: support standalone Maestro clearState command (#2366)
* feat: support standalone Maestro clearState command

Accept '- clearState' / '- clearState: <appId>' in Maestro YAML flows.
Unlike launchApp.clearState (clear-then-open), the standalone form clears
app state without relaunching, projecting to 'settings clear-app-state'
on the daemon. Covers the Rocket.Chat login-with-deeplink helper, which
previously failed with 'Maestro command "clearState" is not supported'.

* test(maestro): cover standalone clearState with authored corpus flow

Replace the UNVERIFIED_COMMANDS exemption with an authored
clear-state flow exercising default and explicit appIds, plus the
regenerated upstream parser fixture proving Maestro compatibility.

Live iOS Simulator evidence (iPhone 16, com.apple.mobilesafari):
- marker files in the data container, then replay '- clearState'
  (default) and '- clearState: <appId>' (explicit) via
  'replay --maestro'; both replay 1/1, wipe the container, and leave
  MobileSafari not running (no reopen).
2026-09-08 12:42:44 +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 be622491f6 fix(conformance): isolate post-tap settling from app launch (#2390) 2026-09-08 10:37:26 +02:00
Michał Pierzchała 13a45b9632 test(remote): assert the proxied snapshot's whole wire conversation (#2387)
#2198 required proof that "the bridge adds no network round trip and transfers
only the published response". That was read off the RTT benchmark — identical
response bytes, unchanged wall-clock slope — which is inference, not proof, and
the raw-result schema carries no round-trip count.

Assert it instead. Record every request the proxy forwards upstream while one
`snapshot -i` runs, and pin the whole conversation rather than just the RPC, so
a new call of any kind fails. The bridge contributes nothing to it: no helper,
admin or acquisition route appears.

Then read what crossed rather than sizing it, at every level of the payload —
envelope, result, published data — each against its declared key set. Comparing
the wire's result against the client's response proves nothing on its own, since
the client publishes whatever the result holds and both sides move together.

Refs #2198.
2026-09-08 10:37:08 +02:00
Michał Pierzchała bfec821dab fix(daemon): stop an attested tenant from downgrading its own isolation (#2386)
When the auth hook attests a tenant, isolation is the server's answer. The RPC
handler was defaulting to `'tenant'` rather than deciding it, so a client that
sent `sessionIsolation: 'none'` kept it: `scopeRequestSession` then returned
early, the session never got its `<tenant>:` prefix, and the request landed in
the `cwd:<hash>:` namespace instead — one the client names and another tenant
can name too.

An attested tenant now always gets tenant isolation, on both carriers, so a
downstream consumer that reads the flag cannot see an isolation the meta just
overrode.

Stacked on #2382, which threads the attested/declared distinction this uses.
2026-09-08 10:36:53 +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.
evidence/ios-snapshot/6a03688d80
2026-09-08 10:36:37 +02:00
Michał Pierzchała 527a56a6e7 refactor(move): move the batch runner and batch policy into @agent-device/command-registry (#2388)
* refactor(command-registry): move the batch runner and batch policy into @agent-device/command-registry

* chore(gates): re-point the sdk-batch chunk groups and fallow baseline at the command-registry batch module
2026-09-08 07:48:33 +02:00
Michał Pierzchała 367e795ee7 fix(remote): let a plain-session client read its own failure record (#2382)
`GET /sessions/<session>/requests/<requestId>/diagnostics` applied the
`<tenant>:` prefix rule to every caller carrying a tenant, but
`scopeRequestSession` only writes that prefix under tenant isolation — which
the daemon forces exactly when the auth hook ATTESTS the tenant. A client
whose tenant is only declared (the `x-agent-device-tenant` header on a daemon
with no auth hook) therefore ran in a plain session such as `default` or
`cwd:<hash>:default` and was then refused 401 reading the record its own
failed command wrote, directly and through `agent-device proxy`.

The addressability rule now lives beside the naming rule in
`session-tenant-scope.ts`, which exists so the two cannot disagree.
`isTenantAddressableSessionName` takes the caller's session namespace and
applies the prefix rule only where the namespace is actually partitioned;
`resolveTrustedTenant` now reports whether the tenant was attested, and
`authorizeAuxiliaryHttpRequest` hands that namespace to the route.

The attested case is unchanged: an attested tenant is still refused any
session outside its own prefix, with the same typed UNAUTHORIZED error.
2026-09-07 18:26:16 +02:00
Michał Pierzchała a9283fabc7 refactor(move): move replay divergence vocabulary into @agent-device/ad-replay (#2384)
* refactor(ad-replay): move the replay divergence vocabulary into @agent-device/ad-replay

* chore(gates): pin the ad-replay divergence subpath in the layering boundary
2026-09-07 18:25:38 +02:00
Michał Pierzchała 63711929d8 refactor(cli): take the CLI's vocabulary off runtime barrels (#2379)
* refactor(cli): read the cloud provider vocabulary from its own subpath

`provider-policy.ts` asks three questions of `@agent-device/provider-webdriver`:
the known-provider map, the predicate over it, and one type. All three live in
`providers.ts`, a leaf with zero imports. Reaching them through the package
barrel loaded 31 modules of WebDriver runtime — session handling, capture,
XML — into every CLI invocation to answer "is this string a known provider
name".

The package predates the subpath-per-file rule and published only `"."`. It
now publishes `./providers` as well (in the gates commit, with the recorded
surface); this points the sole in-closure consumer at it.

`src/cli.ts` eager closure: 325 -> 295 modules.

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

* chore(gates): publish provider-webdriver/providers and record the surface

The provider vocabulary needs a door of its own so a consumer asking whether
a string names a known cloud provider does not load the WebDriver runtime.
`src/providers.ts` has no imports, so the subpath points straight at it and
costs the package nothing.

R11's recorded export surface for the package grows by exactly that entry;
the assertion is sorted so the list stays order-independent.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-07 18:23:44 +02:00
dvd233 65ff27000b perf(cli): keep scroll vocabulary off gesture runtime (#2378) 2026-09-07 17:16:17 +02:00
Michał Pierzchała e90206d64e chore: drop the stale planned-operations closure approval (#2373)
The APPROVED_OVER_CEILING row for packages/command-registry/src/planned-operations.ts
was consumed by #2329. Now that main carries the entry, the no-growth rule governs it
and the row can no longer change any verdict, so the "no row is stale" probe fails on
main and on every PR based on it. Remove the row, as the probe instructs.
2026-09-07 17:05:23 +02:00
Michał Pierzchała 3f022b0730 fix(gates): stop an eager-closure approval from turning main red on merge (#2375)
`no APPROVED_OVER_CEILING row is stale` reads the introduced-entry set, which
is derived from `git merge-base origin/main HEAD`. On a push to main the
merge-base IS the head, so nothing is first-introduced and every approval row
reads as stale whatever its real state. That is exactly the shape of the
approving PR's own merge commit: #2329 added the
`packages/command-registry/src/planned-operations.ts` row to merge, and the
merge that followed it called the row dead. Coverage has been red on main
since (run 34099687663), and every branch cut from main after it inherits the
same failure.

- `staleApprovalRows` makes the verdict a named rule and defers it when the
  merge-base is the head, where no row is readable at all. Enforcement is not
  lost: a row that outlives its PR is still reported on the first branch whose
  merge-base could have read it, and the rule is pinned in both directions.
- The `planned-operations.ts` row goes, which is what the rule asks for now
  that main carries the entry: its closure (74) is governed by the no-growth
  rule from here on, not by the domain-facade ceiling.


Claude-Session: https://claude.ai/code/session_01SfQqXj7JKQVgBA8eg9SMVB

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-07 17:05:04 +02:00
Daniel Morales 8299d5b4a7 fix(ios): honor the startup budget through a cold Simulator boot (#2325)
* fix(ios): honor the startup budget through a cold Simulator boot

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

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

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

Closes #2324

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

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

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

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-09-07 13:06:18 +02:00
Bills Booth e7a5b8b622 fix(capture): export durable resource record type (#2365) 2026-09-07 10:16:00 +02:00
Michał Pierzchała cc9fd725a1 test(android-e2e): read the logcat tail for rotation evidence (#2359)
The first CI failure with the evidence hook (PR #2356, run 34029660070)
lost its logcat section: dumping the emulator's whole 2MB buffer took
longer than the 5s per-probe bound on the loaded host. The probe now
reads the last 4000 lines, which holds the rotation decisions of the
last minutes and returns well inside the bound.
2026-09-07 10:14:34 +02:00
Michał Pierzchała 64b7cc45d4 fix(android): return from orientation once the display reports the rotation (#2356)
`orientation` wrote accelerometer_rotation and user_rotation and returned
at once, while the display rotated some time later. On the loaded CI
emulator that takes seconds, and accessibility reads hang meanwhile: the
Android smoke's `wait text landscape` right after `orientation
landscape-left` got a helper request timeout and then no readable
capture for its whole 10s budget, with the failed-step snapshot taken
afterwards already in landscape (PR #2344, run 34025424834).

The command now polls `dumpsys display` for mCurrentOrientation to
match the requested rotation before returning, each probe bounded by
what is left of the 15s settle budget so a stuck probe ends the settle
as a failure. A display that never gets there fails the command with the
observed rotation instead of reporting success; a display that reports
no rotation at all is left to the setting as before. The provider
scenario scripts the display read against the last user_rotation write.
2026-09-07 10:14:09 +02:00
Michał Pierzchała 7bcbf1350b feat(remote): proxy parity for Simulator observation (#2198 slice B) (#2351)
* feat(remote): give a proxied client the daemon's own failure envelope, cancellation, and version check

#2198 slice B. Direct-daemon and proxy execution over the same deterministic Simulator fixture
now publish the same responses, and the three places where they did not are closed:

- A client that disconnects mid-request behind the proxy now cancels the daemon request. The
  proxy's upstream fetch is bound to its client's connection, so the daemon's own disconnect
  cancellation (`markRequestCanceled`) fires exactly as it does for a direct client.
- The proxy forwards `GET /sessions/<session>/requests/<id>/diagnostics` (#1801), so a remote
  client behind it localizes a failure's diagnostics record instead of reporting
  `logPathUnavailable: HTTP 404`. GET only; the route still enumerates nothing.
- The client's ADR 0006 health check reads the `upstream` link a proxy's /health already nests:
  a proxy whose daemon speaks another RPC protocol fails at health, before the command RPC.

The provider scenario harness exposes its request boundary so a scenario daemon can sit behind a
real HTTP server and proxy; the new parity suite runs one script direct and proxied and compares
the published responses with transport identity removed, and proves two proxied clients
contending for one device fail at claim admission before any lifecycle call.

* test(remote): a proxied lease that expires tears its session down and a reacquired lease starts clean

#2198 acceptance: lease expiry, session cleanup, and device reacquisition do not reuse prior
capture or comparison state. The parity world takes a clock-driven LeaseRegistry; a leased
session behind the proxy captures a diff baseline, its lease lapses past the proxy TTL, the next
request is refused as UNAUTHORIZED/LEASE_NOT_FOUND with the session torn down, and a freshly
allocated lease reopens and reports baselineInitialized on its first diff.

* test(remote): a proxied lease heartbeat renews the lease and keeps the session's comparison state

#2198 acceptance: lease heartbeat through the proxy. A leased session behind the proxy captures a
diff baseline; an explicit lease_heartbeat RPC just before the lease's reported expiry moves the
expiry forward; a request past the old expiry but inside the renewed window still finds the
session and reports the baseline. Red without the renewing heartbeat (LEASE_NOT_FOUND).

* test(remote): follow the daemon client and session artifact path moves
2026-09-07 10:12:59 +02:00
Michał Pierzchała d26b0786fb perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner (#2329)
* perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner

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

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

Refs #2198

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A `possible` open's prewarm registers its session only when the start completes, so a `none` open
that released in that window found nothing and the runner it meant to release survived as a
retained speculative session. The release now takes the runner session lock: it queues behind the
in-flight start, sees the registered speculative session, and stops it; a start a command asked for
is left alone. Two deferred-start regressions pin both outcomes.
2026-09-07 10:12:58 +02:00
Thiago Brezinski 5ba4ac707e test(android): reveal smoke canaries by visibility (#2369) 2026-09-07 08:02:59 +02:00
Michał Pierzchała 233a34d138 refactor(daemon): extract the session event journal into a workspace package (#2361)
* refactor(daemon): extract the session event journal into a workspace package

`src/daemon/session-event-*.ts` (6 modules) and `src/core/keyboard-actions.ts` move as
git renames into a new private package `@agent-device/session-journal`. One subpath per
moved module points straight at the moved file; no `index.ts`, no re-export at any old
path. Every consumer switches to the owning specifier.

The journal's request-shaped inputs now name `DaemonRequest`/`DaemonResponse`/
`DaemonResponseData` from `@agent-device/kernel/contracts` instead of the daemon's own
`daemon-request.ts`, which the package may not reach (R11) and which carries `internal`
with its `SessionState` callbacks and admitted `DeviceLease`. The response types were
already re-exports of the kernel ones, so no shape changes; the request type narrows to
the four fields the journal reads.

A type-level test reads every request-shaped parameter off the real signatures and
asserts the reachable type graph declares no `internal` key, holds nothing shaped like a
live session record or a `DeviceLease`, and carries no callback.

The daemon reaches the journal only by workspace specifier now, so the code-signature
walk gets the same pin the descriptor registry got: a walk stopping at the package
boundary would report an unchanged signature after an entry-shape or retention-window
edit, and a client would keep reusing a daemon writing the superseded journal.

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

* chore(gates): rank the session-journal package on the layering spine

R6's drift guard requires every production zone to be a deliberate ranked-or-unranked
decision. `session-journal` is vocabulary the daemon reads a dispatched request through,
so it takes rank 1 beside `command-registry` and `contracts` rather than the unranked
kit treatment: its only ranked edges are to same-rank zones, which is not a back-edge.

No `APPROVED_OVER_CEILING` row and no fallow baseline edit: rename detection carries all
seven moved entries' merge-base closures, so each falls under the no-growth rule, and no
baseline entry was keyed on the old paths.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 07:52:27 +02:00
Michał Pierzchała 51ed6217cc refactor(daemon): relocate the daemon client out of src/daemon (#2360)
* refactor(daemon): extract the repair-tombstone reader below store and client

`findUnrecoveredRepairCommitFailure` reads session artifacts off disk and is
reached from the daemon client, which had to import `session-store.ts` — the
daemon's largest server module — for it. Move the tombstone shape, its file
reader and the unrecovered-commit scan into `session-repair-tombstone.ts`, a
leaf below both, and give the tombstone file name a single owner.

No behavior change; both consumers keep their existing tests.

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

* refactor(daemon): relocate the daemon client out of src/daemon

`src/daemon/client/` is the daemon's client, not the daemon: no daemon file
imports it, and its consumers are the CLI, the Node client, the proxy command
and the injected dispatch type. Move it to `src/daemon-client/` as renames so
`src/daemon` is server code plus the shared kernel the client still needs —
`config.ts`, `daemon-process.ts`, `request-progress-protocol.ts`,
`daemon-request.ts` and the extracted `session-repair-tombstone.ts`.

Zone name and rank are unchanged (`daemon-client`, 5); the zone now falls out
of the folder instead of a `src/daemon/client/` prefix. Tests move unchanged.

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

* refactor(daemon): move the session artifact path helpers out of session-store

`src/cli.ts` and `src/remote/remote-request-diagnostics.ts` reach into
`session-store.ts` for one pure path function, `resolveRemoteRequestDiagnosticsPath`,
which made every CLI process eagerly evaluate the daemon's session store and
its whole subtree — the script writer, the event log, the action recorder and
the replay transaction vocabulary.

The four artifact path helpers name files; they hold no store state. Move them
to `src/daemon/session-artifact-paths.ts`, a leaf over `session-paths.ts`, and
point all ten consumers at it. `src/cli.ts`'s eager closure drops from 379
modules to 365 and no longer contains `session-store.ts`; the store itself is
464 -> 341 lines. AGENTS.md's declaration-site pointer follows.

No behavior change: the helpers are unmodified.

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

* chore(gates): re-key the daemon-client gate paths onto src/daemon-client

Path-keyed enforcement follows the relocated files: the fallow health baseline
entries, the oxlint per-file override, the wire-compat surface/ledger/mutation
paths, and the layering zone derivation (the `src/daemon/client/` prefix is
dead now that the folder itself names the zone).

R10's external daemon request/session-state importer list gains the five client
modules. The edges are unchanged by this PR — the client has always built
`DaemonRequest` and read `DaemonResponse`; it sat inside `src/daemon/` and so
fell under the prefix skip. Naming the files keeps the dependency enumerated
and shrink-only, so a new `src/daemon-client/` module reaching `session-state`
still fails. Its size assertion now reads the recorded list instead of a
literal.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-07 07:49:19 +02:00
Michał Pierzchała bd08e6e0f2 refactor(contracts): move single-owner modules out of @agent-device/contracts (#2357)
* refactor(daemon): move root-only contracts vocabulary into its owning zone

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 12:55:14 +02:00
Michał Pierzchała 2ec4e91b11 refactor(core): move the command descriptor registry into its own workspace package (#2348)
* refactor(core): move the command descriptor registry into its own package

`src/core/command-descriptor/`, `src/command-catalog.ts`, `src/core/wait-positionals.ts`
and `src/core/parse-timeout.ts` move as git renames into a new private package
`@agent-device/command-registry` (deps: contracts, selectors). One subpath per module
points straight at the moved file; no `index.ts`, no re-export at the old path. Every
consumer switches to the owning specifier.

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

* test(host-kit): pin the command-registry package inside the daemon code graph

The daemon reaches the registry and its catalog only by workspace specifier. A walk
that stopped at the package boundary would report an unchanged signature after a
descriptor edit, and the client would keep reusing a daemon running the superseded
policy. The manifest is asserted beside the sources because its `exports` map is what
chose them. The cache doc comment quoting the old ~800-module graph is corrected.

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

* chore(gates): point the descriptor-registry gates at the package path

R66's `COMMAND_DESCRIPTOR_MODULE`, R16's record-runtime join subject and the Fallow
`AssertTrue` totality-guard key follow the registry to its package. The two descriptor
hubs leave `HUB_ENTRY_FILES` because the package manifest now publishes them, so the
eager-closure gate discovers them as facades and one entry gets one rule; this also
flips `denyPlatformImplementations` from false (hub) to true (package entry) for both,
which is intentional and stricter. `command-registry` joins the ranked spine at rank 1.

No `APPROVED_OVER_CEILING` row: rename detection carries every moved entry's merge-base
baseline, so all twelve fall under the no-growth rule rather than a ceiling.

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

---------

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

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

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

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

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

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

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

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

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

This was a dormant bug: the Android build-cache job only runs on a
fingerprint miss, and no PR had changed the test-app's native
dependencies in a while. Adding expo-secure-store (#2282's keychain
fixture) was enough to trigger it. Reproduced locally against a clean
install with the exact CI script, confirmed the corrupted property,
and confirmed the printf-based fix builds cleanly (870/870 tasks).
2026-09-06 12:51:51 +02:00
Michał Pierzchała a78b6bb9a3 feat(wait): carry a per-poll timeline in timeout failures (#2344)
* feat(wait): carry a per-poll timeline in timeout failures

A wait timeout said `reason`, `readableCaptures`, and `waitedMs`, so a
failure could not say where its budget went: the runs behind #2343 spent
a 10s budget on one poll (5.8s runner findText on a fresh app, 3.4s of
target discovery, a fallback cancelled at the deadline) and reported the
same `wait_capture_stalled` as a dead runner. The failure details now
carry `captures` and `polls[]`, one entry per poll with `startedMs` on
the wait's own clock, `durationMs`, and a typed outcome (readable,
unreadable, deadline, runner-restart), next to the unchanged reason and
the request-log link. Long waits keep the first five and last twenty-five
polls so the response stays compact.

* docs(wait): name the polling timeout paths that carry the poll timeline

Review follow-up: wait --stable uses its own error builder and a
never-readable strict absence preserves its predicate failure, so the
timeline is documented for the polling timeout paths that emit it.

* feat(wait): carry the poll evidence on the replay landmark-mismatch refusal

Review follow-up. A replayed selector wait refused for a recorded
landmark mismatch threw without the captures/polls evidence, and when
its final poll ended in a runner restart the refusal hid that outcome.
The refusal now carries the same failure evidence a timeout does, next
to its mismatch details; two regressions cover a mismatch followed by a
deadline-cancelled capture and by a runner restart. Docs and changelog
name the refusal alongside the polling timeout paths.
2026-09-06 12:51:36 +02:00
Michał Pierzchała ff59309415 test(android-e2e): record rotation state and logcat rotation decisions on a failed step (#2350)
* test(android-e2e): record rotation state and logcat rotation decisions on a failed step

The Android smoke has failed on the post-alert canary since 2026-09-03,
and the failed-step screenshot from run 34021894996 shows why the reads
miss: the device is in landscape at that point, with the canary below
the fold, although `orientation portrait` had taken effect (the fixture
confirmed it and every tap before the alert landed at x=540). Nothing we
keep says what rotated it. A failed step now also writes
failed-step-N-device.txt with the two rotation settings, the display's
rotation lines, and WindowManager's rotation decisions from logcat, read
through adb so they stand even when the CLI path failed.

* test(android-e2e): keep the rotation evidence to WindowManager decisions and display rotation fields

* test(e2e): own failed-step evidence in one collector, bound the device probes, test it

Review follow-up on the rotation evidence. The collectors move out of
the harness closure into failed-step-evidence.ts (fallow complexity),
where the platform hook runs alongside the screenshot and snapshot and
is bounded as a group (15s) so it can never delay them; a hook that
throws, answers nothing, or never answers records nothing for the device
file and leaves the CLI evidence in place. The Android probes get a 5s
per-command bound, and logcat lines are capped in count and length.
Deterministic tests cover the file contents, the hook failure and
timeout cases, and the harness naming every evidence file, device file
included, in failed-step.txt.
2026-09-06 12:46:27 +02:00
Michał Pierzchała dcd8b65d4c refactor(daemon): split src/daemon/types.ts into request types and session state (#2346)
* refactor(daemon): split daemon/types.ts into request and session-state modules

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

Three modules replace it, each importing only downward:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 12:36:54 +02:00
Michał Pierzchała 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 835af32577 fix(ios): stop charging every capture for a slow Simulator app discovery (#2331)
* fix(ios): stop charging every capture for a slow Simulator app discovery

Since #2279 every eligible Simulator capture resolves its AX-bridge target
first, and a cache miss spawns `simctl launchctl list` through xcrun with
a 3s timeout on the capture's own critical path. On a loaded CI host that
spawn takes longer than 3s, the timeout is not remembered, and the next
capture pays it again. A `wait` issued right after `open` lost its whole
10s budget that way (runner findText on the fresh app, then the probe
timeout, then a fallback capture cancelled at the deadline) and reported
`wait_capture_stalled` with the app already on screen: four iOS Smoke
failures on main and PRs since 2026-09-05 21:00, none before.

Discovery is now single-flight and detached from the capture that starts
it: a capture waits a bounded 1.5s for it, then takes the XCTest fallback
while the probe keeps running with its own 15s budget; later captures
join the in-flight probe or reuse its result. A responsive host still
reaches the bridge on the first capture. The resolver's error names its
reason so the route diagnostic says why the fallback ran.

* fix(ios): one deadline per discovery, exact claims, route-level regression

Review follow-up. The 15s budget was per subprocess (two simctl probes,
then a 3s ps read); discovery now shares one deadline across all three.
The comments and changelog no longer claim "once per app generation":
single-flight removes duplicate discoveries, each capture still waits up
to its 1.5s slice for the in-flight one, and a failed discovery is
forgotten so the next capture starts another. A route-level test runs
the production resolver over a simctl that answers only when released:
the first capture falls back within the slice, the released discovery
then serves the bridge without a second spawn.

* fix(ios): keep the discovery slot until both probes settle

Review follow-up. Promise.all rejected as soon as either probe failed and
the finally then released the single-flight entry while the other probe
was still running, so a failed `simctl list devices` next to a slow
`launchctl list` let every later capture start its own launch-job probe.
Both probes now settle before the discovery does; the regression test
runs three captures against exactly that partial failure and sees one
spawn, then the settled discovery reports the runtime failure and frees
the slot.
2026-09-06 11:33:24 +02:00
Michał Pierzchała 6768a045a2 refactor(commands): move commands-side rendering out of src/daemon and retire the doctor progress flag (#2349)
* refactor(core): move doctor line formatting and status markers below their callers

`formatDoctorCheckSummaryLine`, `formatDoctorCheckDetailLines`, and
`formatCliStatusMarker` are shared by the daemon doctor handlers, the CLI
replay-test reporter, and the doctor CLI output formatter. Owning them in
`src/daemon/handlers/` forced `src/commands` to reach up into `src/daemon`
through a dynamic import; `src/core` sits below every caller.

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

* refactor(commands): move result serialization to the command surface

The `serialize*` helpers render command results for the CLI and MCP surfaces,
not for the daemon; only the two deployment target resolvers were shared with
daemon handlers. The serializers move to `src/commands/output/` beside the
formatters that call them, and the resolvers move to `src/core` below both
owners, so `src/commands` and `src/mcp` no longer reach into `src/daemon` for
them. The MCP edge becomes a static import; the `src/commands` edges stay
function-scoped because `src/cli.ts` eagerly evaluates both output modules.

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

* refactor(cli): render command progress in the command surface, retiring the doctor progress flag

Streamed `command` progress was rendered to stderr by the daemon client, which
recorded "doctor progress was rendered" in a process-global flag that
`doctorCliOutput` then consumed to suppress its final summary. The rendering was
commands-side, so it moves there: the CLI installs `createStderrCommandProgressSink`
as the request's `RequestProgressSink` for every non-`test`, non-`--json` command,
and hands its `CommandProgressState` to the output formatters with the result.
The transport now only forwards events to the sink the caller installed.

Behavior is unchanged in the three modes the tests pin: progress streamed to
stderr suppresses doctor's check lines, no progress prints them, and a caller
that installs its own sink (SDK, MCP) writes nothing to this process's stderr
and is not suppressed.

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

* test(cli): own the doctor progress cases in their own file

The end-to-end doctor progress cases landed in `cli-network.test.ts`, which is
already past the 1,000-line tripwire, so the test-file-size ratchet rejected the
growth. They are doctor-output cases rather than network ones: they move to
`cli-doctor-progress.test.ts`, and `cli-network.test.ts` returns to its
merge-base size.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 11:30:51 +02:00
Bills Booth 27a97ee619 fix(ios): confirm alerts without repeating activation (#2326) 2026-09-06 10:00:02 +02:00
Michał Pierzchała ed5e9736ec fix(runtime): resolve each lazily imported platform module exactly once (#2327)
The application-tools ports each opened their own `import(...)` of the same
specifier — seven of them for `@agent-device/platform-apple/runner/operations`
alone. In production those duplicates are equivalent, because the loader caches.
Under Vitest they are not: while a `vi.mock` factory is still awaiting
`importOriginal()`, a second dynamic import of that id resolves to the UNMOCKED
module.

The open path makes that overlap routine — it deliberately leaves the iOS runner
prewarm unawaited — so a unit test that mocks the runner could still reach the
real one. `session-open-runtime.test.ts` did: its two iOS-simulator cases
scheduled two prewarms, the second one bypassed the mock, and the real local
XCTest runner started for device `sim-1`. Its stale-process cleanup then spawned
`pkill -f xcodebuild...session-sim-1-[0-9]` against the developer's own process
table, which the hermetic-signal guard refused and reported against whichever
test happened to be running when it landed — the unrelated
`open --metro-port alone stays host-ambiguous on a physical Android device`.

Each specifier now has one memoized loader, and the ports reach their module
only through it, so the second resolution the escape needs no longer exists.

Fixes #2314
2026-09-06 09:51:56 +02:00
Michał Pierzchała 7bea29d61e fix: repair main after the managed-allocation move landed under stale PRs (#2328)
`main` has been red since #2308. That PR was authored before #2316 moved
managed-device allocation into `@agent-device/managed-allocation`, so the daemon
files it added still import pre-move sibling paths that no longer exist:

    src/daemon/managed-device-allocation/lease-admission.ts(19,8): error TS2307:
      Cannot find module './record-validation.ts'
    src/daemon/managed-device-allocation/__tests__/lease-admission.fixtures.ts(10,61):
      error TS2307: Cannot find module './fixtures.ts'

Typecheck, Repo Guards, and the two managed provider-integration suites all fail
on it, which makes every open PR red.

- `lease-admission.ts` now reaches the record validators through the package's
  `./record` surface, which re-exports them. The `TS2322` at line 70 was a
  consequence of the unresolved import, not a separate defect: with the module
  resolved, `isVerbatimId` narrows `identityIncarnationId` again.
- The daemon-side grant fixtures come back under `src/daemon`, stated in
  contract terms only. Both trees keeping their own test data is the shape #2316
  already chose for `managed-device-allocator.fixtures.ts`.
- The root now consumes `@agent-device/managed-allocation`, so its
  `ignoreDependencies` entry — whose comment said "no root consumer yet" — goes.

Separately, the eager-closure ratchet was failing on a stale approval row: the
merge-base now carries `packages/capture-kit/src/durable-capture/index.ts`, so
nothing can read its `APPROVED_OVER_CEILING` row and the table's own staleness
rule fails it. Removed, exactly as the rule prescribes.
2026-09-06 09:51:56 +02:00
Michał Pierzchała 6e22e266d7 refactor(contracts): own the daemon HTTP wire contract so clients stop importing src/daemon (#2322)
* refactor(contracts): own the daemon HTTP wire contract so clients stop importing src/daemon

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

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

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

* test: verify managed request authority and activation boundaries

* test: run managed request admission in provider integration
2026-09-06 08:10:59 +02:00
Michał Pierzchała ebdaa7617e feat: delegate reviewed managed automation (#2312)
* feat: delegate reviewed automation through managed lease authority

* fix: preserve lazy simulator readiness through scoped authority

* fix: admit managed operations at their dispatch boundary

* test: move managed automation scenarios to integration lane

* chore(gates): declare the private managed readiness scope export
2026-09-06 08:10:59 +02:00
Michał Pierzchała 07e25087e1 fix: contain managed ADB transport to its lease target (#2311)
* fix: contain managed ADB transport to its lease target

* fix: block managed ADB server administration and inherited sockets
2026-09-06 08:10:58 +02:00
Michał Pierzchała 6b0e5d6c1c feat: add fenced managed lease admission (#2308)
* feat(daemon): add fenced managed lease admission

* refactor: derive managed horizons from canonical budgets

* fix: carry managed admission through operation dispatch
2026-09-06 08:10:58 +02:00
Michał Pierzchała 9b5b2452e7 refactor: share daemon command and teardown budgets (#2307)
* refactor(daemon): share command and teardown budgets

* fix(daemon): load shared request timing on demand

* refactor: reuse core request timeout policy

* test(session): isolate runtime prewarm side effect

---------

Co-authored-by: AdzeB <59929333+AdzeB@users.noreply.github.com>
2026-09-06 08:10:58 +02:00
Bills Booth c4dc5621a3 fix(ios): avoid replaying alert mutations (#2323)
* fix(ios): avoid replaying alert mutations

* docs(alert): state the single-send rule once, without a backend qualifier

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-09-06 07:58:19 +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
AdzeB f30328d086 fix(android): preserve editable-field metadata in snapshots (#2290)
* fix(android): preserve editable-field observation metadata

* fix(android): carry field facts through the attrs digest and selection offsets past editability

- `get attrs --level digest` kept only the pre-#2288 semantic fields, so
  `editable`/`password`/`hintShowing`/`selectionStart`/`selectionEnd`
  vanished on the token-cheap route. The digest now keeps them, with a
  regression covering explicit false/zero/empty and omission when unavailable.
- The helper emitted selection offsets only inside `isEditable()`, but
  read-only selectable text exposes a selection too. Each nonnegative offset
  is now emitted independently; -1 stays absent. Parser-to-snapshot regression
  for a non-editable selectable node.
- Docs: the field-metadata notes get their own section instead of leading the
  efficiency tips.
- Dropped the test-isolation commit: main already mocks
  notifyIosRunnerAppRelaunched, and the lifecycle test passes without it.

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-09-06 07:32:43 +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